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([]); }); });