2026-06-15 08:06:08 +03:00
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
import { extractStepsBlock, parsePlanSteps } from './plan-steps-parse.mjs';
|
|
|
|
|
|
|
|
|
|
const good = [
|
|
|
|
|
'# Plan', '```steps-json',
|
|
|
|
|
'[{"op":"Edit","object":"app/Foo.php","ref":"dec-a"},',
|
|
|
|
|
' {"op":"Bash","object":"npm run build","ref":"dec-b"}]',
|
|
|
|
|
'```',
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
|
|
|
describe('plan-steps-parse', () => {
|
|
|
|
|
it('extracts and parses valid steps', () => {
|
|
|
|
|
const steps = parsePlanSteps(good);
|
|
|
|
|
expect(steps).toHaveLength(2);
|
|
|
|
|
expect(steps[0]).toMatchObject({ op: 'Edit', ref: 'dec-a' });
|
|
|
|
|
});
|
|
|
|
|
it('extractStepsBlock returns block body or null', () => {
|
|
|
|
|
expect(extractStepsBlock(good)).toContain('app/Foo.php');
|
|
|
|
|
expect(extractStepsBlock('# no block')).toBe(null);
|
|
|
|
|
});
|
|
|
|
|
it('canonicalizes file object to repo-relative POSIX (SE-5)', () => {
|
|
|
|
|
const md = '```steps-json\n[{"op":"Write","object":"app\\\\Bar.php","ref":"r"}]\n```';
|
|
|
|
|
expect(parsePlanSteps(md)[0].object).toBe('app/Bar.php');
|
|
|
|
|
});
|
|
|
|
|
it('rejects step without ref (SE-1) → throws', () => {
|
|
|
|
|
const md = '```steps-json\n[{"op":"Edit","object":"x"}]\n```';
|
|
|
|
|
expect(() => parsePlanSteps(md)).toThrow(/ref/);
|
|
|
|
|
});
|
|
|
|
|
it('rejects op:Task (VA-4, субагенты запрещены) → throws', () => {
|
|
|
|
|
const md = '```steps-json\n[{"op":"Task","object":"coder","ref":"r"}]\n```';
|
|
|
|
|
expect(() => parsePlanSteps(md)).toThrow(/Task/);
|
|
|
|
|
});
|
|
|
|
|
it('missing block → throws (fail-CLOSE)', () => {
|
|
|
|
|
expect(() => parsePlanSteps('# Plan no block')).toThrow();
|
|
|
|
|
});
|
|
|
|
|
it('non-JSON block → throws', () => {
|
|
|
|
|
expect(() => parsePlanSteps('```steps-json\nnot json\n```')).toThrow();
|
|
|
|
|
});
|
2026-06-18 11:54:42 +03:00
|
|
|
|
|
|
|
|
// B+C ч.2: сеанс осмотра op:'session' — особый тип шага (нет object/ref; goal+tools+produces).
|
|
|
|
|
it('парсит op:session (goal/tools/produces сохранены, object/ref не нужны)', () => {
|
|
|
|
|
const md = '```steps-json\n[{"op":"session","goal":"осмотр логина","tools":["browser_click","browser_type"],"produces":["docs/observer/notes.md","docs/observer/report.md"]}]\n```';
|
|
|
|
|
const s = parsePlanSteps(md);
|
|
|
|
|
expect(s).toHaveLength(1);
|
|
|
|
|
expect(s[0]).toMatchObject({
|
|
|
|
|
op: 'session', goal: 'осмотр логина',
|
|
|
|
|
tools: ['browser_click', 'browser_type'],
|
|
|
|
|
produces: ['docs/observer/notes.md', 'docs/observer/report.md'],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
it('сеанс со строковым produces → нормализован в массив', () => {
|
|
|
|
|
const md = '```steps-json\n[{"op":"session","goal":"g","tools":[],"produces":"docs/observer/s.md"}]\n```';
|
|
|
|
|
expect(parsePlanSteps(md)[0].produces).toEqual(['docs/observer/s.md']);
|
|
|
|
|
});
|
|
|
|
|
it('сеанс без goal → throws', () => {
|
|
|
|
|
const md = '```steps-json\n[{"op":"session","tools":[],"produces":"r.md"}]\n```';
|
|
|
|
|
expect(() => parsePlanSteps(md)).toThrow(/goal/i);
|
|
|
|
|
});
|
|
|
|
|
it('сеанс без produces → throws (нужен ≥1 итоговый файл)', () => {
|
|
|
|
|
const md = '```steps-json\n[{"op":"session","goal":"g","tools":[]}]\n```';
|
|
|
|
|
expect(() => parsePlanSteps(md)).toThrow(/produces/i);
|
|
|
|
|
});
|
|
|
|
|
it('op:Skill как шаг → throws с явным сообщением (объяви в skills-json)', () => {
|
|
|
|
|
const md = '```steps-json\n[{"op":"Skill","object":"superpowers:test-driven-development","ref":"r"}]\n```';
|
|
|
|
|
expect(() => parsePlanSteps(md)).toThrow(/skills-json|Skill/i);
|
|
|
|
|
});
|
2026-06-15 08:06:08 +03:00
|
|
|
});
|