mirror of
https://github.com/robonen/questlang.git
synced 2026-08-20 18:31:56 +00:00
feat: init
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { QuestLang } from '..';
|
||||
|
||||
describe('questLang Integration', () => {
|
||||
it('should parse and validate complete quest', () => {
|
||||
const questSource = `
|
||||
квест ИнтеграционныйТест;
|
||||
цель "Полная проверка функциональности";
|
||||
|
||||
граф {
|
||||
узлы {
|
||||
старт: {
|
||||
тип: начальный;
|
||||
описание: "Начало квеста";
|
||||
переходы: [действие1];
|
||||
}
|
||||
|
||||
действие1: {
|
||||
тип: действие;
|
||||
описание: "Первое решение";
|
||||
варианты: [
|
||||
("Вариант А", действие2),
|
||||
("Вариант Б", концовка1)
|
||||
];
|
||||
}
|
||||
|
||||
действие2: {
|
||||
тип: действие;
|
||||
описание: "Второе решение";
|
||||
варианты: [
|
||||
("Продолжить", концовка2)
|
||||
];
|
||||
}
|
||||
|
||||
концовка1: {
|
||||
тип: концовка;
|
||||
название: "Быстрый финал";
|
||||
описание: "Вы завершили квест быстро";
|
||||
}
|
||||
|
||||
концовка2: {
|
||||
тип: концовка;
|
||||
название: "Полный финал";
|
||||
описание: "Вы прошли весь квест";
|
||||
}
|
||||
}
|
||||
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
|
||||
// Test parsing
|
||||
const ast = QuestLang.parse(questSource);
|
||||
expect(ast.name).toBe('ИнтеграционныйТест');
|
||||
expect(ast.goal).toBe('Полная проверка функциональности');
|
||||
|
||||
// Test interpretation
|
||||
const interpreter = QuestLang.interpret(questSource);
|
||||
expect(interpreter.getQuestInfo().name).toBe('ИнтеграционныйТест');
|
||||
|
||||
// Test validation
|
||||
const validation = QuestLang.validate(questSource);
|
||||
expect(validation.isValid).toBe(true);
|
||||
|
||||
// Test gameplay flow
|
||||
interpreter.moveToNode('действие1');
|
||||
const choices = interpreter.getAvailableChoices();
|
||||
expect(choices).toHaveLength(2);
|
||||
|
||||
const result = interpreter.executeChoice(0);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newState.currentNode).toBe('действие2');
|
||||
});
|
||||
|
||||
it('should handle parsing errors gracefully', () => {
|
||||
const invalidSource = 'квест без точки с запятой';
|
||||
|
||||
const validation = QuestLang.validate(invalidSource);
|
||||
expect(validation.isValid).toBe(false);
|
||||
expect(validation.errors[0]).toContain('Expected');
|
||||
});
|
||||
|
||||
it('should work with the original quest example', () => {
|
||||
const originalQuest = `
|
||||
квест Шашлык;
|
||||
цель "Сегодня день труда и отдыха и надо купить шашлык. На пути нас встречают разнообразные трудности.";
|
||||
|
||||
граф {
|
||||
узлы {
|
||||
старт: {
|
||||
тип: начальный;
|
||||
описание: "Начало приключения";
|
||||
переходы: [выбор_пути];
|
||||
}
|
||||
|
||||
выбор_пути: {
|
||||
тип: действие;
|
||||
описание: "Ближайшая дешёвая шашлычная находится в 100 метрах от вас. Хорошая - в 1 км. Куда идти?";
|
||||
варианты: [
|
||||
("Пойти в дешёвую шашлычную", дешевая_очередь),
|
||||
("Пойти в хорошую шашлычную", дорога_к_хорошей),
|
||||
("Пойти домой", поход_в_магазин)
|
||||
];
|
||||
}
|
||||
|
||||
дешевая_очередь: {
|
||||
тип: концовка;
|
||||
название: "В очереди";
|
||||
описание: "Вы стоите в очереди за шашлыком";
|
||||
}
|
||||
|
||||
дорога_к_хорошей: {
|
||||
тип: концовка;
|
||||
название: "Дорога к хорошей шашлычной";
|
||||
описание: "Вы идёте к хорошей шашлычной";
|
||||
}
|
||||
|
||||
поход_в_магазин: {
|
||||
тип: концовка;
|
||||
название: "В магазине";
|
||||
описание: "Вы пошли в магазин за продуктами";
|
||||
}
|
||||
}
|
||||
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
|
||||
const validation = QuestLang.validate(originalQuest);
|
||||
expect(validation.isValid).toBe(true);
|
||||
|
||||
const interpreter = QuestLang.interpret(originalQuest);
|
||||
expect(interpreter.getQuestInfo().name).toBe('Шашлык');
|
||||
|
||||
// Test navigation
|
||||
interpreter.moveToNode('выбор_пути');
|
||||
const choices = interpreter.getAvailableChoices();
|
||||
expect(choices).toHaveLength(3);
|
||||
expect(choices[0].text).toBe('Пойти в дешёвую шашлычную');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { QuestLang } from '..';
|
||||
|
||||
describe('questInterpreter', () => {
|
||||
const questSource = `
|
||||
квест ТестКвест;
|
||||
цель "Найти выход из лабиринта";
|
||||
|
||||
граф {
|
||||
узлы {
|
||||
старт: {
|
||||
тип: начальный;
|
||||
описание: "Вы стоите перед входом в лабиринт";
|
||||
переходы: [выбор];
|
||||
}
|
||||
|
||||
выбор: {
|
||||
тип: действие;
|
||||
описание: "Перед вами два коридора. Куда пойти?";
|
||||
варианты: [
|
||||
("Налево", налево),
|
||||
("Направо", направо)
|
||||
];
|
||||
}
|
||||
|
||||
налево: {
|
||||
тип: действие;
|
||||
описание: "Вы идете налево и находите сокровище!";
|
||||
варианты: [
|
||||
("Взять сокровище", победа)
|
||||
];
|
||||
}
|
||||
|
||||
направо: {
|
||||
тип: концовка;
|
||||
название: "Ловушка!";
|
||||
описание: "Вы попали в ловушку и проиграли";
|
||||
}
|
||||
|
||||
победа: {
|
||||
тип: концовка;
|
||||
название: "Победа!";
|
||||
описание: "Вы нашли сокровище и победили";
|
||||
}
|
||||
}
|
||||
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
|
||||
let interpreter: any;
|
||||
|
||||
beforeEach(() => {
|
||||
interpreter = QuestLang.interpret(questSource);
|
||||
});
|
||||
|
||||
it('should initialize with correct quest info', () => {
|
||||
const questInfo = interpreter.getQuestInfo();
|
||||
|
||||
expect(questInfo.name).toBe('ТестКвест');
|
||||
expect(questInfo.goal).toBe('Найти выход из лабиринта');
|
||||
expect(questInfo.isComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('should start with correct initial state', () => {
|
||||
const state = interpreter.getState();
|
||||
|
||||
expect(state.currentNode).toBe('старт');
|
||||
expect(state.history).toHaveLength(0);
|
||||
expect(state.isComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('should get current node information', () => {
|
||||
const currentNode = interpreter.getCurrentNode();
|
||||
|
||||
expect(currentNode).toBeDefined();
|
||||
expect(currentNode?.id).toBe('старт');
|
||||
expect(currentNode?.nodeType).toBe('начальный');
|
||||
});
|
||||
|
||||
it('should move to next node', () => {
|
||||
const result = interpreter.moveToNode('выбор');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newState.currentNode).toBe('выбор');
|
||||
expect(result.newState.history).toContain('старт');
|
||||
});
|
||||
|
||||
it('should get available choices for action node', () => {
|
||||
interpreter.moveToNode('выбор');
|
||||
const choices = interpreter.getAvailableChoices();
|
||||
|
||||
expect(choices).toHaveLength(2);
|
||||
expect(choices[0].text).toBe('Налево');
|
||||
expect(choices[0].target).toBe('налево');
|
||||
expect(choices[1].text).toBe('Направо');
|
||||
expect(choices[1].target).toBe('направо');
|
||||
});
|
||||
|
||||
it('should execute choice correctly', () => {
|
||||
interpreter.moveToNode('выбор');
|
||||
const result = interpreter.executeChoice(0); // Choose "Налево"
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newState.currentNode).toBe('налево');
|
||||
});
|
||||
|
||||
it('should handle invalid choice index', () => {
|
||||
interpreter.moveToNode('выбор');
|
||||
const result = interpreter.executeChoice(5); // Invalid index
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid choice index');
|
||||
});
|
||||
|
||||
it('should complete quest when reaching ending', () => {
|
||||
interpreter.moveToNode('направо'); // Go to ending
|
||||
const state = interpreter.getState();
|
||||
|
||||
expect(state.isComplete).toBe(true);
|
||||
expect(state.endingTitle).toBe('Ловушка!');
|
||||
});
|
||||
|
||||
it('should reset to initial state', () => {
|
||||
interpreter.moveToNode('выбор');
|
||||
interpreter.reset();
|
||||
|
||||
const state = interpreter.getState();
|
||||
expect(state.currentNode).toBe('старт');
|
||||
expect(state.history).toHaveLength(0);
|
||||
expect(state.isComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate quest structure', () => {
|
||||
const validation = interpreter.validate();
|
||||
|
||||
expect(validation.isValid).toBe(true);
|
||||
expect(validation.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect invalid quest structure', () => {
|
||||
const invalidSource = `
|
||||
квест Неправильный;
|
||||
цель "Тест";
|
||||
граф {
|
||||
узлы {
|
||||
старт: {
|
||||
тип: действие;
|
||||
описание: "Тест";
|
||||
варианты: [
|
||||
("Выбор", несуществующийУзел)
|
||||
];
|
||||
}
|
||||
}
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
|
||||
const invalidInterpreter = QuestLang.interpret(invalidSource);
|
||||
const validation = invalidInterpreter.validate();
|
||||
|
||||
expect(validation.isValid).toBe(false);
|
||||
expect(validation.errors).toContain('Node \'старт\' references non-existent target \'несуществующийУзел\'');
|
||||
});
|
||||
|
||||
it('should find all possible paths', () => {
|
||||
const paths = interpreter.getAllPaths();
|
||||
|
||||
expect(paths.length).toBeGreaterThan(0);
|
||||
// Should have at least two paths (налево->победа and направо)
|
||||
expect(paths.some(path => path.includes('победа'))).toBe(true);
|
||||
expect(paths.some(path => path.includes('направо'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Lexer } from '../lexer';
|
||||
import { TokenType } from '..';
|
||||
|
||||
describe('lexer', () => {
|
||||
it('should tokenize quest keywords', () => {
|
||||
const source = 'квест Тест; цель "Описание"; конец;';
|
||||
const lexer = new Lexer(source);
|
||||
const tokens = lexer.tokenize();
|
||||
|
||||
expect(tokens[0].type).toBe(TokenType.QUEST);
|
||||
expect(tokens[0].value).toBe('квест');
|
||||
|
||||
expect(tokens[1].type).toBe(TokenType.IDENTIFIER);
|
||||
expect(tokens[1].value).toBe('Тест');
|
||||
|
||||
expect(tokens[2].type).toBe(TokenType.SEMICOLON);
|
||||
|
||||
expect(tokens[3].type).toBe(TokenType.GOAL);
|
||||
expect(tokens[3].value).toBe('цель');
|
||||
|
||||
expect(tokens[4].type).toBe(TokenType.STRING);
|
||||
expect(tokens[4].value).toBe('Описание');
|
||||
});
|
||||
|
||||
it('should tokenize strings correctly', () => {
|
||||
const source = '"Тестовая строка с пробелами"';
|
||||
const lexer = new Lexer(source);
|
||||
const tokens = lexer.tokenize();
|
||||
|
||||
expect(tokens[0].type).toBe(TokenType.STRING);
|
||||
expect(tokens[0].value).toBe('Тестовая строка с пробелами');
|
||||
});
|
||||
|
||||
it('should tokenize numbers', () => {
|
||||
const source = '42 3.14';
|
||||
const lexer = new Lexer(source);
|
||||
const tokens = lexer.tokenize();
|
||||
|
||||
expect(tokens[0].type).toBe(TokenType.NUMBER);
|
||||
expect(tokens[0].value).toBe('42');
|
||||
|
||||
expect(tokens[1].type).toBe(TokenType.NUMBER);
|
||||
expect(tokens[1].value).toBe('3.14');
|
||||
});
|
||||
|
||||
it('should handle comments', () => {
|
||||
const source = '// это комментарий\nквест';
|
||||
const lexer = new Lexer(source);
|
||||
const tokens = lexer.tokenize();
|
||||
|
||||
expect(tokens[0].type).toBe(TokenType.COMMENT);
|
||||
expect(tokens[0].value).toBe('// это комментарий');
|
||||
|
||||
expect(tokens[1].type).toBe(TokenType.QUEST);
|
||||
});
|
||||
|
||||
it('should track line and column numbers', () => {
|
||||
const source = 'квест\nТест';
|
||||
const lexer = new Lexer(source);
|
||||
const tokens = lexer.tokenize();
|
||||
|
||||
expect(tokens[0].line).toBe(1);
|
||||
expect(tokens[0].column).toBe(1);
|
||||
|
||||
expect(tokens[1].line).toBe(2);
|
||||
expect(tokens[1].column).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle all symbols', () => {
|
||||
const source = '{ } [ ] ( ) : ; , .';
|
||||
const lexer = new Lexer(source);
|
||||
const tokens = lexer.tokenize();
|
||||
|
||||
const expectedTypes = [
|
||||
TokenType.LEFT_BRACE,
|
||||
TokenType.RIGHT_BRACE,
|
||||
TokenType.LEFT_BRACKET,
|
||||
TokenType.RIGHT_BRACKET,
|
||||
TokenType.LEFT_PAREN,
|
||||
TokenType.RIGHT_PAREN,
|
||||
TokenType.COLON,
|
||||
TokenType.SEMICOLON,
|
||||
TokenType.COMMA,
|
||||
TokenType.DOT,
|
||||
];
|
||||
|
||||
expectedTypes.forEach((expectedType, index) => {
|
||||
expect(tokens[index].type).toBe(expectedType);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error on unexpected character', () => {
|
||||
const source = 'квест @';
|
||||
const lexer = new Lexer(source);
|
||||
|
||||
expect(() => lexer.tokenize()).toThrow('Unexpected character');
|
||||
});
|
||||
|
||||
it('should throw error on unterminated string', () => {
|
||||
const source = 'квест "незакрытая строка';
|
||||
const lexer = new Lexer(source);
|
||||
|
||||
expect(() => lexer.tokenize()).toThrow('Unterminated string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Lexer } from '../lexer';
|
||||
import { Parser } from '../parser';
|
||||
|
||||
describe('parser', () => {
|
||||
const parseSource = (source: string) => {
|
||||
const lexer = new Lexer(source);
|
||||
const tokens = lexer.tokenize();
|
||||
const parser = new Parser(tokens);
|
||||
return parser.parse();
|
||||
};
|
||||
|
||||
it('should parse simple quest', () => {
|
||||
const source = `
|
||||
квест ТестКвест;
|
||||
цель "Тестовое описание";
|
||||
|
||||
граф {
|
||||
узлы {
|
||||
старт: {
|
||||
тип: начальный;
|
||||
описание: "Начало";
|
||||
переходы: [действие1];
|
||||
}
|
||||
|
||||
действие1: {
|
||||
тип: действие;
|
||||
описание: "Первое действие";
|
||||
варианты: [
|
||||
("Выбор 1", конец1),
|
||||
("Выбор 2", конец2)
|
||||
];
|
||||
}
|
||||
|
||||
конец1: {
|
||||
тип: концовка;
|
||||
название: "Первый финал";
|
||||
описание: "Описание финала";
|
||||
}
|
||||
|
||||
конец2: {
|
||||
тип: концовка;
|
||||
название: "Второй финал";
|
||||
описание: "Описание второго финала";
|
||||
}
|
||||
}
|
||||
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
|
||||
const ast = parseSource(source);
|
||||
|
||||
expect(ast.type).toBe('QuestProgram');
|
||||
expect(ast.name).toBe('ТестКвест');
|
||||
expect(ast.goal).toBe('Тестовое описание');
|
||||
expect(ast.graph.start).toBe('старт');
|
||||
|
||||
const nodes = ast.graph.nodes;
|
||||
expect(Object.keys(nodes)).toHaveLength(4);
|
||||
expect(nodes.старт.nodeType).toBe('начальный');
|
||||
expect(nodes.действие1.nodeType).toBe('действие');
|
||||
expect(nodes.конец1.nodeType).toBe('концовка');
|
||||
expect(nodes.конец2.nodeType).toBe('концовка');
|
||||
});
|
||||
|
||||
it('should parse action node with options', () => {
|
||||
const source = `
|
||||
квест Тест;
|
||||
цель "Тест";
|
||||
граф {
|
||||
узлы {
|
||||
действие1: {
|
||||
тип: действие;
|
||||
описание: "Выберите действие";
|
||||
варианты: [
|
||||
("Первый вариант", цель1),
|
||||
("Второй вариант", цель2)
|
||||
];
|
||||
}
|
||||
}
|
||||
начало: действие1;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
|
||||
const ast = parseSource(source);
|
||||
const actionNode = ast.graph.nodes.действие1 as any;
|
||||
|
||||
expect(actionNode.nodeType).toBe('действие');
|
||||
expect(actionNode.options).toHaveLength(2);
|
||||
expect(actionNode.options[0].text).toBe('Первый вариант');
|
||||
expect(actionNode.options[0].target).toBe('цель1');
|
||||
expect(actionNode.options[1].text).toBe('Второй вариант');
|
||||
expect(actionNode.options[1].target).toBe('цель2');
|
||||
});
|
||||
|
||||
it('should parse ending node with title', () => {
|
||||
const source = `
|
||||
квест Тест;
|
||||
цель "Тест";
|
||||
граф {
|
||||
узлы {
|
||||
финал: {
|
||||
тип: концовка;
|
||||
название: "Название финала";
|
||||
описание: "Описание финала";
|
||||
}
|
||||
}
|
||||
начало: финал;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
|
||||
const ast = parseSource(source);
|
||||
const endingNode = ast.graph.nodes.финал as any;
|
||||
|
||||
expect(endingNode.nodeType).toBe('концовка');
|
||||
expect(endingNode.title).toBe('Название финала');
|
||||
expect(endingNode.description).toBe('Описание финала');
|
||||
});
|
||||
|
||||
it('should throw error on missing semicolon', () => {
|
||||
const source = 'квест Тест цель "Описание"';
|
||||
|
||||
expect(() => parseSource(source)).toThrow('Expected \';\' after quest name');
|
||||
});
|
||||
|
||||
it('should throw error on invalid node type', () => {
|
||||
const source = `
|
||||
квест Тест;
|
||||
цель "Тест";
|
||||
граф {
|
||||
узлы {
|
||||
узел1: {
|
||||
тип: неправильныйТип;
|
||||
описание: "Тест";
|
||||
}
|
||||
}
|
||||
начало: узел1;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
|
||||
expect(() => parseSource(source)).toThrow('Invalid node type');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user