070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
// Plan §3.EE1 — simulatePipeline + parseSignalKv pure helpers.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { PluginPipeline } from '@open-design/contracts';
|
|
import { parseSignalKv, simulatePipeline } from '../src/plugins/simulate.js';
|
|
|
|
const pipe = (stages: PluginPipeline['stages']): PluginPipeline => ({ stages });
|
|
|
|
describe('simulatePipeline — single-shot stages', () => {
|
|
it("emits outcome='single' for repeat:false stages", () => {
|
|
const result = simulatePipeline({
|
|
pipeline: pipe([{ id: 'plan', atoms: ['todo-write'] }]),
|
|
signals: {},
|
|
});
|
|
expect(result.outcome).toBe('all-single');
|
|
expect(result.stages[0]?.outcome).toBe('single');
|
|
expect(result.stages[0]?.iterations).toBe(1);
|
|
});
|
|
|
|
it("emits outcome='single' even when until is set but repeat:false", () => {
|
|
const result = simulatePipeline({
|
|
pipeline: pipe([{ id: 'plan', atoms: ['todo-write'], until: 'critique.score>=4' }]),
|
|
signals: { 'critique.score': 0 },
|
|
});
|
|
expect(result.stages[0]?.outcome).toBe('single');
|
|
});
|
|
});
|
|
|
|
describe('simulatePipeline — repeat: true stages', () => {
|
|
it("emits outcome='converged' when the until expression is satisfied", () => {
|
|
const result = simulatePipeline({
|
|
pipeline: pipe([{
|
|
id: 'critique', atoms: ['critique-theater'], repeat: true,
|
|
until: 'critique.score>=4',
|
|
}]),
|
|
signals: { 'critique.score': 5 },
|
|
});
|
|
expect(result.outcome).toBe('all-converged');
|
|
expect(result.stages[0]?.outcome).toBe('converged');
|
|
expect(result.stages[0]?.iterations).toBe(1);
|
|
expect(result.stages[0]?.matched).toBeDefined();
|
|
});
|
|
|
|
it("emits outcome='cap' when the until never satisfies within iterationCap", () => {
|
|
const result = simulatePipeline({
|
|
pipeline: pipe([{
|
|
id: 'critique', atoms: ['critique-theater'], repeat: true,
|
|
until: 'critique.score>=4',
|
|
}]),
|
|
signals: { 'critique.score': 2 },
|
|
iterationCap: 3,
|
|
});
|
|
expect(result.outcome).toBe('cap-hit');
|
|
expect(result.stages[0]?.outcome).toBe('cap');
|
|
expect(result.stages[0]?.iterations).toBe(3);
|
|
expect(result.stages[0]?.reason).toMatch(/never satisfied/);
|
|
});
|
|
|
|
it("emits outcome='unparsable' when the until expression fails to parse", () => {
|
|
const result = simulatePipeline({
|
|
pipeline: pipe([{
|
|
id: 'critique', atoms: ['critique-theater'], repeat: true,
|
|
until: 'this is not a valid expression',
|
|
}]),
|
|
signals: {},
|
|
});
|
|
expect(result.outcome).toBe('unparsable');
|
|
expect(result.stages[0]?.outcome).toBe('unparsable');
|
|
expect(result.stages[0]?.reason).toBeTruthy();
|
|
});
|
|
|
|
it('honours per-iteration signal providers (signals can change as iterations run)', () => {
|
|
const result = simulatePipeline({
|
|
pipeline: pipe([{
|
|
id: 'critique', atoms: ['critique-theater'], repeat: true,
|
|
until: 'critique.score>=4',
|
|
}]),
|
|
// Score grows across iterations; converges on iteration 4 (i=3).
|
|
signals: (_stageId, iteration) => ({ 'critique.score': iteration + 1 }),
|
|
});
|
|
expect(result.stages[0]?.outcome).toBe('converged');
|
|
expect(result.stages[0]?.iterations).toBe(4);
|
|
});
|
|
|
|
it('honours boolean compound until (build.passing == true && tests.passing == true)', () => {
|
|
const both = simulatePipeline({
|
|
pipeline: pipe([{
|
|
id: 'verify', atoms: ['patch-edit', 'build-test'], repeat: true,
|
|
until: 'build.passing == true && tests.passing == true',
|
|
}]),
|
|
signals: { 'build.passing': true, 'tests.passing': true },
|
|
});
|
|
expect(both.stages[0]?.outcome).toBe('converged');
|
|
|
|
const oneShort = simulatePipeline({
|
|
pipeline: pipe([{
|
|
id: 'verify', atoms: ['patch-edit', 'build-test'], repeat: true,
|
|
until: 'build.passing == true && tests.passing == true',
|
|
}]),
|
|
signals: { 'build.passing': true, 'tests.passing': false },
|
|
iterationCap: 2,
|
|
});
|
|
expect(oneShort.stages[0]?.outcome).toBe('cap');
|
|
});
|
|
});
|
|
|
|
describe('simulatePipeline — aggregate outcome', () => {
|
|
it("'mixed' when some stages are single and some are converged", () => {
|
|
const result = simulatePipeline({
|
|
pipeline: pipe([
|
|
{ id: 'plan', atoms: ['todo-write'] },
|
|
{ id: 'critique', atoms: ['critique-theater'], repeat: true, until: 'critique.score>=4' },
|
|
]),
|
|
signals: { 'critique.score': 5 },
|
|
});
|
|
expect(result.outcome).toBe('mixed');
|
|
});
|
|
|
|
it('totalIterations sums per-stage iterations', () => {
|
|
const result = simulatePipeline({
|
|
pipeline: pipe([
|
|
{ id: 'plan', atoms: ['todo-write'] },
|
|
{ id: 'verify', atoms: ['patch-edit', 'build-test'], repeat: true, until: 'build.passing == true && tests.passing == true' },
|
|
]),
|
|
signals: (_id, i) => i >= 2 ? { 'build.passing': true, 'tests.passing': true } : { 'build.passing': false, 'tests.passing': false },
|
|
});
|
|
// plan = 1, verify = 3 (converges on i=2 → iteration 3).
|
|
expect(result.totalIterations).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe('parseSignalKv', () => {
|
|
it('parses numbers and booleans into the closed UntilSignals vocabulary', () => {
|
|
const r = parseSignalKv([
|
|
'critique.score=4',
|
|
'iterations=2',
|
|
'user.confirmed=true',
|
|
'preview.ok=false',
|
|
'build.passing=1',
|
|
'tests.passing=0',
|
|
]);
|
|
expect(r.signals).toEqual({
|
|
'critique.score': 4,
|
|
'iterations': 2,
|
|
'user.confirmed': true,
|
|
'preview.ok': false,
|
|
'build.passing': true,
|
|
'tests.passing': false,
|
|
});
|
|
expect(r.warnings).toEqual([]);
|
|
});
|
|
|
|
it('warns on unknown signal keys (typo guard)', () => {
|
|
const r = parseSignalKv(['critic.score=4']);
|
|
expect(r.signals).toEqual({});
|
|
expect(r.warnings[0]).toMatch(/unknown signal/);
|
|
});
|
|
|
|
it("warns on missing '=' separator", () => {
|
|
const r = parseSignalKv(['critique.score4']);
|
|
expect(r.warnings[0]).toMatch(/key=value/);
|
|
});
|
|
|
|
it('warns on type mismatch (numeric signal with non-number value)', () => {
|
|
const r = parseSignalKv(['critique.score=high']);
|
|
expect(r.signals).toEqual({});
|
|
expect(r.warnings[0]).toMatch(/expected number/);
|
|
});
|
|
});
|