Files
portal/tools/router-learning-queue.test.mjs
T
Дмитрий 69e20099db fix(router-mentor): sharp-edges audit M1-M4 — close 8 misuse-resistance holes
Второй аудит машин 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>
2026-06-07 06:24:21 +03:00

93 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([]);
});
});