feat: init

This commit is contained in:
2025-09-01 02:28:55 +07:00
commit eb357ef703
21 changed files with 6628 additions and 0 deletions
+143
View File
@@ -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('Пойти в дешёвую шашлычную');
});
});
+176
View File
@@ -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);
});
});
+106
View File
@@ -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');
});
});
+148
View File
@@ -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');
});
});