167 lines
9.3 KiB
JavaScript
167 lines
9.3 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
detectHighRisk, validateLevelSkip, LEVEL_SKIP_CATEGORIES, cheaperOf,
|
|
validateTrace, groundTrace, TRACE_SLOTS,
|
|
buildRouterPrompt, parseRouterResponse, runRouter,
|
|
} from './router-engine.mjs';
|
|
import { buildNodeGraph } from './node-graph.mjs';
|
|
|
|
const GRAPH = buildNodeGraph({ nodes: [
|
|
{ id: '#19', slug: 'writing-plans', name: 'writing-plans', status: 'active' },
|
|
{ id: '#55', slug: 'discovery-interview', name: 'discovery-interview', status: 'active' },
|
|
], chains: {} });
|
|
|
|
const CATALOG = { nodes: [
|
|
{ id: '#19', slug: 'writing-plans', name: 'writing-plans', capabilities: 'plans', status: 'active' },
|
|
] };
|
|
|
|
const FULL_TRACE = {
|
|
candidates: ['writing-plans', 'discovery-interview'],
|
|
chosen: 'writing-plans',
|
|
why_chosen: 'multi-step feature plan',
|
|
twins_considered: 'discovery-interview — отброшен: цель не интервью',
|
|
confidence: 0.8,
|
|
};
|
|
|
|
describe('detectHighRisk (6.1 — ДЕТЕРМИНИРОВАННО, LLM-оценка только совет)', () => {
|
|
it('разрушительная операция → high', () => {
|
|
expect(detectHighRisk({ op: 'Bash', command: 'rm -rf x' }).high).toBe(true);
|
|
expect(detectHighRisk({ op: 'Bash', command: 'git push --force' }).high).toBe(true);
|
|
});
|
|
it('прод-выкат → high', () => {
|
|
expect(detectHighRisk({ op: 'Bash', command: 'php artisan migrate', prodDeploy: true }).high).toBe(true);
|
|
});
|
|
it('многошаговость над порогом → high', () => {
|
|
expect(detectHighRisk({ op: 'Skill', stepCount: 9 }).high).toBe(true);
|
|
});
|
|
it('инъецированный sensitive-флаг → high (портативно, без зашитых путей)', () => {
|
|
expect(detectHighRisk({ op: 'Edit', sensitive: true }).high).toBe(true);
|
|
});
|
|
it('обычное чтение → не high', () => {
|
|
expect(detectHighRisk({ op: 'Read' }).high).toBe(false);
|
|
});
|
|
it('reasons перечисляет причины', () => {
|
|
expect(detectHighRisk({ op: 'Bash', command: 'DROP TABLE x' }).reasons.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('validateLevelSkip (6.2 — только закрытый список категорий)', () => {
|
|
it('категория из закрытого списка → ok', () => {
|
|
expect(validateLevelSkip(LEVEL_SKIP_CATEGORIES[0]).ok).toBe(true);
|
|
});
|
|
it('свободный текст → отклонён', () => {
|
|
expect(validateLevelSkip('не хочу заморачиваться').ok).toBe(false);
|
|
});
|
|
it('пусто → отклонён', () => {
|
|
expect(validateLevelSkip('').ok).toBe(false);
|
|
expect(validateLevelSkip(null).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('cheaperOf (6.1 — цена РАЗДЕЛИТЕЛЬ равноценных, не глушилка)', () => {
|
|
it('при равном качестве берёт дешевле', () => {
|
|
const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 5 }, { skill: 'B', qualityRank: 1, costRank: 2 }]);
|
|
expect(r.skill).toBe('B');
|
|
});
|
|
it('лучшее качество побеждает несмотря на цену (цена НЕ важнее правильности)', () => {
|
|
const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 9 }, { skill: 'B', qualityRank: 2, costRank: 1 }]);
|
|
expect(r.skill).toBe('A');
|
|
});
|
|
it('пусто → null', () => { expect(cheaperOf([])).toBe(null); });
|
|
});
|
|
|
|
describe('validateTrace (5.1 структурные слоты; пустой слот = красный флаг, §6.2 A)', () => {
|
|
it('полная трасса → ok', () => {
|
|
expect(validateTrace(FULL_TRACE)).toEqual({ ok: true, missingSlots: [] });
|
|
});
|
|
it('пустой слот → невалидно + перечень', () => {
|
|
const r = validateTrace({ ...FULL_TRACE, why_chosen: '' });
|
|
expect(r.ok).toBe(false); expect(r.missingSlots).toContain('why_chosen');
|
|
});
|
|
it('нет кандидатов → невалидно', () => {
|
|
expect(validateTrace({ ...FULL_TRACE, candidates: [] }).ok).toBe(false);
|
|
});
|
|
it('TRACE_SLOTS перечисляет обязательные слоты', () => {
|
|
expect(TRACE_SLOTS).toContain('chosen');
|
|
});
|
|
});
|
|
|
|
describe('groundTrace (ОВ-Д2 C — все кандидаты в реальные узлы; выдумка → отклонение)', () => {
|
|
it('все кандидаты резолвятся → grounded', () => {
|
|
expect(groundTrace(FULL_TRACE, GRAPH)).toMatchObject({ grounded: true, invented: [] });
|
|
});
|
|
it('выдуманный кандидат → not grounded + перечень', () => {
|
|
const r = groundTrace({ ...FULL_TRACE, candidates: ['writing-plans', 'elasticsearch-magic'] }, GRAPH);
|
|
expect(r.grounded).toBe(false); expect(r.invented).toContain('elasticsearch-magic');
|
|
});
|
|
it('выдуманный chosen → not grounded', () => {
|
|
const r = groundTrace({ ...FULL_TRACE, chosen: 'made-up' }, GRAPH);
|
|
expect(r.grounded).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildRouterPrompt (чистая, {system,user}; граф+каталог в system для кэша 6.2)', () => {
|
|
it('детерминирована: тот же вход → тот же текст', () => {
|
|
const a = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
|
|
const b = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
|
|
expect(a.system).toBe(b.system);
|
|
expect(a.user).toBe(b.user);
|
|
});
|
|
it('system несёт вшитый брейнсторм + слоты трассы; user — задачу', () => {
|
|
const { system, user } = buildRouterPrompt({ prompt: 'add feature X', graph: GRAPH, catalog: CATALOG });
|
|
expect(system).toMatch(/вариант|brainstorm|2.?3/i);
|
|
expect(system).toMatch(/candidates/);
|
|
expect(user).toMatch(/add feature X/);
|
|
});
|
|
it('фикс-3: system несёт нюх 5.3 (подозрение к размытому) + интервьюер 4.4 (уточняющий вопрос)', () => {
|
|
const { system } = buildRouterPrompt({ prompt: 'настрой красиво', graph: GRAPH, catalog: CATALOG });
|
|
expect(system).toMatch(/нюх|5\.3|размыт/i);
|
|
expect(system).toMatch(/4\.4|уточня|переспрос/i);
|
|
});
|
|
it('фикс-2: контракты в промпте + look-ahead (нужды/решения вызываемых навыков вперёд)', () => {
|
|
const contracts = [{ skill: 'writing-plans', needs: ['spec'], 'key-decisions': ['file structure'], 'acceptance-criteria': ['each step 2-5 min'] }];
|
|
const { system } = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG, contracts });
|
|
expect(system).toMatch(/look-ahead|вперёд|вызываемых/i);
|
|
expect(system).toMatch(/spec/);
|
|
expect(system).toMatch(/file structure/);
|
|
});
|
|
it('фикс-2: без контрактов — раздел КОНТРАКТЫ не добавляется (детерминизм сохранён)', () => {
|
|
const a = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: CATALOG });
|
|
const b = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: CATALOG, contracts: [] });
|
|
expect(a.system).toBe(b.system);
|
|
expect(a.system).not.toMatch(/КОНТРАКТЫ ВЫЗЫВАЕМЫХ/);
|
|
});
|
|
});
|
|
|
|
describe('parseRouterResponse', () => {
|
|
it('парсит JSON-трассу', () => {
|
|
const t = parseRouterResponse('```json\n{"chosen":"writing-plans","candidates":["writing-plans"],"why_chosen":"x","twins_considered":"y","confidence":0.9}\n```');
|
|
expect(t.chosen).toBe('writing-plans');
|
|
});
|
|
it('мусор → null', () => { expect(parseRouterResponse('не json')).toBe(null); });
|
|
});
|
|
|
|
describe('runRouter (оркестратор; llmCall мокается)', () => {
|
|
const okTrace = { candidates: ['writing-plans'], chosen: 'writing-plans', why_chosen: 'feature', twins_considered: 'none', confidence: 0.85 };
|
|
it('валидная заземлённая трасса с уверенностью → ok-результат', async () => {
|
|
const r = await runRouter({ prompt: 'add feature', graph: GRAPH, llmCall: async () => okTrace });
|
|
expect(r.ok).toBe(true); expect(r.trace.chosen).toBe('writing-plans'); expect(r.abstain).toBe(false);
|
|
});
|
|
it('пустой слот трассы → невалидно (мех. флаг, не результат)', async () => {
|
|
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, why_chosen: '' }) });
|
|
expect(r.ok).toBe(false); expect(r.reason).toMatch(/слот|trace|missing/i);
|
|
});
|
|
it('выдуманный кандидат → отклонение (заземление)', async () => {
|
|
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, candidates: ['made-up'], chosen: 'made-up' }) });
|
|
expect(r.ok).toBe(false); expect(r.reason).toMatch(/выдум|invent|заземл/i);
|
|
});
|
|
it('низкая уверенность → воздержание (5.2), не угадывание', async () => {
|
|
const r = await runRouter({ prompt: 'x', graph: GRAPH, confidenceThreshold: 0.7, llmCall: async () => ({ ...okTrace, confidence: 0.3 }) });
|
|
expect(r.abstain).toBe(true);
|
|
});
|
|
it('llmCall вернул мусор → ok=false (не падает)', async () => {
|
|
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => null });
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|