69e20099db
Второй аудит машин 1-4 другим объективом (sharp-edges: устойчивость к неправильному применению / мягкие умолчания / совпадение по пустоте-подстроке). Криптоядра здоровы (подтверждено). 8 реальных дыр закрыты по TDD: M3: - coverage-machine F-1: покрытие считалось по двусторонней ПОДСТРОКЕ — produces "a" покрывал запрос "audit-rls-policy" (ложное «всё покрыто»). Новый tokensCover: точное равенство ИЛИ подмножество слов по границам. coveringSkill + coverageRegistry. - router-engine F-8: confidence не проверялся на диапазон — 5/Infinity проходили как «уверен» (обход воздержания 5.2), -3 как принуд. abstain. validateTrace: [0,1] finite. - round-control C: пустой roundKey="" активировал managed-режим (!= null) → все сессии делили один счётчик-бакет. Теперь managed требует непустую строку. - router-learning-queue G: повторное approve уже-решённого id повторно клало запись в фонд (дубль). applyApprovalBatch: переводит только status==='pending'. M2: - plan-lock F5: шаг с пустым object был джокером (object:'' матчил действие, чей путь не извлёкся → object''). actionMatchesStep: пустой object шага не матчит ничего. M4 (инертна; чистые fail-closed правки кода, корректны и при включении): - judge-slop-counter H: битый/null вердикт в списке ронял счёт (v.missing на null). Теперь не крашит, считается халтурой (безопасная сторона). - judge-engine J: consensusDecision на пустом/битом списке дрейфовал к GO. Теперь GO только если есть голоса И каждый чистый GO; иначе NO-GO (fail-closed для hard-risk). - judge-orchestrator K: finalGate снимал вето пола на любой falsy floorBlocked (undefined от упавшей проверки = fail-open). Теперь снять может только явный false. Регрессия tools-only 2555 passed + 2 skip (+15 TDD-тестов, 0 регрессий). Осознанно НЕ менялось (без призраков): - M1 receipt-sign domain default '' / разделитель пробел — backward-compat контракт (тест 18-19), инъективен на enum-доменах без пробелов. - M1 action-journal атомарность записи головы + битая .jsonl строка — fail-closed (битьё → verifyChain ok:false → стена блокирует); чистого behavioral-теста нет. - M3 round-control requiredSkills=[] — контракт вызывающего (пустой = не требуется). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
93 lines
4.5 KiB
JavaScript
93 lines
4.5 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
makeQueueEntry, enqueue, pendingCount, applyApprovalBatch,
|
||
renderApprovalSection, statusSignal, saveQueue, loadQueue,
|
||
} from './router-learning-queue.mjs';
|
||
|
||
const cand = (id, kind = 'example') => ({ id, kind, summary: `s-${id}`, why_proposed: `w-${id}` });
|
||
|
||
describe('enqueue (hard-rule: предложение, НИКОГДА не одобрено само)', () => {
|
||
it('новый кандидат → status pending, не approved', () => {
|
||
const q = enqueue([], cand('a'));
|
||
expect(q).toHaveLength(1);
|
||
expect(q[0].status).toBe('pending');
|
||
});
|
||
it('makeQueueEntry всегда pending (нет авто-одобрения)', () => {
|
||
expect(makeQueueEntry(cand('x')).status).toBe('pending');
|
||
});
|
||
it('pendingCount считает только ожидающие', () => {
|
||
const q = enqueue(enqueue([], cand('a')), cand('b'));
|
||
expect(pendingCount(q)).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe('applyApprovalBatch (одобрение пачкой — ТОЛЬКО по явному решению владельца)', () => {
|
||
const base = enqueue(enqueue(enqueue([], cand('1')), cand('2')), cand('3'));
|
||
it('approve → в фонд; reject → помечен; defer → остаётся pending', () => {
|
||
const r = applyApprovalBatch(base, { approve: ['1'], reject: ['2'], defer: ['3'] });
|
||
expect(r.fund.map((e) => e.id)).toEqual(['1']);
|
||
expect(r.queue.find((e) => e.id === '2').status).toBe('rejected');
|
||
expect(r.queue.find((e) => e.id === '3').status).toBe('pending');
|
||
expect(pendingCount(r.queue)).toBe(1);
|
||
});
|
||
it('пустое решение → ничего не входит в фонд (без «да» НИКАК)', () => {
|
||
const r = applyApprovalBatch(base, {});
|
||
expect(r.fund).toEqual([]);
|
||
expect(pendingCount(r.queue)).toBe(3);
|
||
});
|
||
it('rejected повторно не «pending» (не предлагается снова)', () => {
|
||
const once = applyApprovalBatch(base, { reject: ['1'] });
|
||
expect(once.queue.find((e) => e.id === '1').status).toBe('rejected');
|
||
expect(pendingCount(once.queue)).toBe(2);
|
||
});
|
||
it('конфликт approve+reject одного id → НЕ в фонд (reject-приоритет; hard-rule «без явного да НИКАК»)', () => {
|
||
const r = applyApprovalBatch(base, { approve: ['1'], reject: ['1'] });
|
||
expect(r.fund.map((e) => e.id)).not.toContain('1');
|
||
expect(r.queue.find((e) => e.id === '1').status).toBe('rejected');
|
||
});
|
||
// fix: tools/router-learning-queue.mjs (G, аудит M1-M4) — повторное решение по уже-решённому id не дублирует фонд
|
||
it('повторное approve уже одобренной записи не кладёт дубль в фонд', () => {
|
||
const r1 = applyApprovalBatch(base, { approve: ['1'] });
|
||
expect(r1.fund.length).toBe(1);
|
||
const r2 = applyApprovalBatch(r1.queue, { approve: ['1'] });
|
||
expect(r2.fund.length).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('renderApprovalSection (/brain-retro «На одобрение»)', () => {
|
||
it('перечисляет только pending с id/summary/why', () => {
|
||
const q = enqueue(applyApprovalBatch(enqueue(enqueue([], cand('1')), cand('2')), { approve: ['1'] }).queue, cand('3'));
|
||
const md = renderApprovalSection(q);
|
||
expect(md).toMatch(/На одобрение/);
|
||
expect(md).toMatch(/2/);
|
||
expect(md).toMatch(/3/);
|
||
expect(md).not.toMatch(/s-1/);
|
||
});
|
||
it('пустая очередь → пометка «нет кандидатов»', () => {
|
||
expect(renderApprovalSection([])).toMatch(/нет кандидатов|пусто/i);
|
||
});
|
||
});
|
||
|
||
describe('statusSignal (STATUS.md «ждут: N»)', () => {
|
||
it('строка с числом ожидающих', () => {
|
||
const q = enqueue(enqueue([], cand('a')), cand('b'));
|
||
expect(statusSignal(q)).toMatch(/ждут.*2/);
|
||
});
|
||
});
|
||
|
||
describe('персист очереди (fs инъектируется)', () => {
|
||
function memFs() {
|
||
const m = new Map();
|
||
return { m, readFileSync: (p) => { if (!m.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return m.get(String(p)); }, writeFileSync: (p, d) => m.set(String(p), String(d)) };
|
||
}
|
||
it('save→load round-trips', () => {
|
||
const fs = memFs();
|
||
const q = enqueue([], cand('a'));
|
||
saveQueue({ queue: q, path: '/q.json', fsImpl: fs });
|
||
expect(loadQueue({ path: '/q.json', fsImpl: fs })).toEqual(q);
|
||
});
|
||
it('нет файла → []', () => {
|
||
expect(loadQueue({ path: '/none', fsImpl: memFs() })).toEqual([]);
|
||
});
|
||
});
|