2b6170313b
Единица разбора — спан: реальный промпт владельца + вся активность ассистента
до следующего реального промпта. Системные ходы (гейт-фидбек, загрузка навыка)
приклеиваются к спану, не считаются отдельными. Разбор отложенный: закрытые
спаны разбираются один раз (курсор в флажке сессии); reconcile и аудитор
получают ПОЛНЫЙ склеенный спан (промпт + все ответы + все действия).
- Слой 1: снят обрез вывода действий (полная картина), защита структурных меток.
- Граница спана — событие UserPromptSubmit (prompt-hook метит realPromptTurns),
фолбэк по sysLabel; выключение через mode:closing (финальный спан добивает Stop).
- Калибровка скрытых вопросов: страж-ноп (не мутировать при неизменном тексте) +
кап показа родословной (~~первая~~ → текущая, данные целы).
- Шаги — по спанам («Ход (промпт) N [вобрал ходы X-Y]»); «висит N промптов».
- Новый модуль secretary-span.mjs (computeSpans/spansToDistill/recordRealPrompt/
parseTurnBlock/assembleSpan).
Свод секретаря зелёный (138 тестов), живой прогон на реальной модели подтвердил:
Шаги по спанам, гейт-шум не плодит скрытые вопросы, находки выживают по одному раз.
Спека/план: docs/superpowers/{specs,plans}/2026-06-23-secretary-span-redesign*.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
382 lines
23 KiB
JavaScript
382 lines
23 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import { parseReconcileResponse, reconcileGuard, buildGuardRemark, stampProvenance, buildReconcilePrompt, reconcileTurn } from './secretary-reconcile.mjs';
|
||
|
||
describe('parseReconcileResponse', () => {
|
||
it('парсит весь протокол (с обёрткой/хвостовой запятой)', () => {
|
||
const out = parseReconcileResponse('```json\n{ "subject":"S", "decisions":[{"text":"A","why":"w","struck":false}], "open":[{"text":"Q","struck":true}], }\n```');
|
||
expect(out.subject).toBe('S');
|
||
expect(out.decisions[0]).toEqual({ text: 'A', why: 'w', struck: false });
|
||
expect(out.open[0]).toEqual({ text: 'Q', struck: true });
|
||
expect(out.will).toEqual([]);
|
||
expect(out.doneNext).toEqual([]);
|
||
});
|
||
it('мусор → null', () => {
|
||
expect(parseReconcileResponse('не json')).toBeNull();
|
||
expect(parseReconcileResponse('')).toBeNull();
|
||
});
|
||
it('читает step{user,assistant}; пустой/кривой → null', () => {
|
||
const out = parseReconcileResponse('{ "subject":"S", "step":{ "user":" хотел X ", "assistant":"сделал Y" } }');
|
||
expect(out.step).toEqual({ user: 'хотел X', assistant: 'сделал Y' });
|
||
expect(parseReconcileResponse('{ "subject":"S" }').step).toBeNull();
|
||
expect(parseReconcileResponse('{ "subject":"S", "step":{} }').step).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('reconcileGuard', () => {
|
||
const old = { decisions: [{ text: 'Берём Postgres' }], open: [{ text: 'Хайку или Sonnet?' }], will: [], doneNext: [] };
|
||
it('всё на месте (в т.ч. зачёркнутое) → ok', () => {
|
||
const ret = { decisions: [{ text: 'берём postgres', struck: false }], open: [{ text: 'Хайку или Sonnet?', struck: true }], will: [], doneNext: [] };
|
||
expect(reconcileGuard(old, ret).ok).toBe(true);
|
||
});
|
||
it('строка пропала → не ok + список потерь', () => {
|
||
const ret = { decisions: [{ text: 'берём postgres' }], open: [], will: [], doneNext: [] };
|
||
const g = reconcileGuard(old, ret);
|
||
expect(g.ok).toBe(false);
|
||
expect(g.lost).toContain('Хайку или Sonnet?');
|
||
});
|
||
});
|
||
|
||
describe('buildGuardRemark', () => {
|
||
it('обоснованное замечание называет потерянные строки и что делать', () => {
|
||
const r = buildGuardRemark(['Хайку или Sonnet?']);
|
||
expect(r).toContain('Хайку или Sonnet?');
|
||
expect(r.toLowerCase()).toContain('верни');
|
||
expect(r.toLowerCase()).toContain('не удаляй');
|
||
});
|
||
});
|
||
|
||
describe('stampProvenance', () => {
|
||
const old = { subject: 'тема', history: [{ oldText: 'x', newText: 'y', turns: [1] }],
|
||
decisions: [{ text: 'A', why: 'w', turns: [3], session: 'sessOLD', struck: false }],
|
||
will: [], open: [], doneNext: [] };
|
||
const returned = { subject: 'тема',
|
||
decisions: [{ text: 'A', why: 'w', struck: false }, { text: 'B', why: 'w2', struck: false }],
|
||
will: [], open: [], doneNext: [] };
|
||
it('старая запись сохраняет свой turns/session, новая получает текущие', () => {
|
||
const p = stampProvenance(old, returned, 9, 'sessNEW');
|
||
expect(p.decisions[0]).toMatchObject({ text: 'A', turns: [3], session: 'sessOLD' });
|
||
expect(p.decisions[1]).toMatchObject({ text: 'B', turns: [9], session: 'sessNEW' });
|
||
});
|
||
it('history прежнего протокола сохраняется', () => {
|
||
const p = stampProvenance(old, returned, 9, 'sessNEW');
|
||
expect(p.history).toEqual(old.history);
|
||
});
|
||
});
|
||
|
||
describe('stampProvenance — многоходовый провенанс + История-таймлайн (живой хук)', () => {
|
||
it('смена статуса (зачёркивание) дописывает текущий ход в turns: [33] → [33,50]', () => {
|
||
const old = { subject: 't', history: [], decisions: [], alternatives: [], consequences: [], will: [],
|
||
open: [{ text: 'Q?', struck: false, turns: [33], session: 's0' }], doneNext: [] };
|
||
const ret = { open: [{ text: 'Q?', struck: true }], decisions: [], alternatives: [], consequences: [], will: [], doneNext: [] };
|
||
const p = stampProvenance(old, ret, 50, 's1');
|
||
expect(p.open[0].turns).toEqual([33, 50]);
|
||
});
|
||
it('строка без смены статуса НЕ копит ходы', () => {
|
||
const old = { subject: 't', history: [], decisions: [{ text: 'A', struck: false, turns: [3], session: 's0' }], alternatives: [], consequences: [], will: [], open: [], doneNext: [] };
|
||
const ret = { decisions: [{ text: 'A', struck: false }], alternatives: [], consequences: [], will: [], open: [], doneNext: [] };
|
||
const p = stampProvenance(old, ret, 9, 's1');
|
||
expect(p.decisions[0].turns).toEqual([3]);
|
||
});
|
||
it('зачёркивание заводит запись в История: [→добавлен] [←ход]', () => {
|
||
const old = { subject: 't', history: [], decisions: [], alternatives: [], consequences: [], will: [],
|
||
open: [{ text: 'Q?', struck: false, turns: [41], session: 's0' }], doneNext: [] };
|
||
const ret = { open: [{ text: 'Q?', struck: true }], decisions: [], alternatives: [], consequences: [], will: [], doneNext: [] };
|
||
const p = stampProvenance(old, ret, 43, 's1');
|
||
expect(p.history).toContainEqual({ text: 'Q?', events: [{ turn: 41, dir: 'in' }, { turn: 43, dir: 'out' }] });
|
||
});
|
||
it('возврат строки (снятие зачёркивания) дописывает [→ход] в тайм-линию', () => {
|
||
const old = { subject: 't',
|
||
history: [{ text: 'Q?', events: [{ turn: 41, dir: 'in' }, { turn: 43, dir: 'out' }] }],
|
||
decisions: [], alternatives: [], consequences: [], will: [],
|
||
open: [{ text: 'Q?', struck: true, turns: [41, 43], session: 's0' }], doneNext: [] };
|
||
const ret = { open: [{ text: 'Q?', struck: false }], decisions: [], alternatives: [], consequences: [], will: [], doneNext: [] };
|
||
const p = stampProvenance(old, ret, 55, 's1');
|
||
const h = p.history.find((x) => x.text === 'Q?');
|
||
expect(h.events).toEqual([{ turn: 41, dir: 'in' }, { turn: 43, dir: 'out' }, { turn: 55, dir: 'in' }]);
|
||
});
|
||
});
|
||
|
||
describe('buildReconcilePrompt', () => {
|
||
const proto = { subject: 'дело', decisions: [{ text: 'A' }], open: [{ text: 'Q?' }], will: [], doneNext: [] };
|
||
const ex = { user: 'ответ на Q', assistant: 'ок', actions: [] };
|
||
it('правила: не удалять, только зачёркивать, воля у [ЮЗЕР], шум игнор', () => {
|
||
const { system } = buildReconcilePrompt({ protocol: proto, lastExchange: ex });
|
||
expect(system.toLowerCase()).toContain('не удаляй');
|
||
expect(system.toLowerCase()).toContain('зачерк');
|
||
expect(system).toContain('[ЮЗЕР]');
|
||
expect(system.toLowerCase()).toContain('служебн');
|
||
});
|
||
it('в user — текущий протокол и обмен; замечание добавляется при возврате', () => {
|
||
const { user } = buildReconcilePrompt({ protocol: proto, lastExchange: ex, remark: 'ВЕРНИ X' });
|
||
expect(user).toContain('Q?');
|
||
expect(user).toContain('ответ на Q');
|
||
expect(user).toContain('ВЕРНИ X');
|
||
});
|
||
it('просит поле step (суть хода)', () => {
|
||
const { system } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: {} });
|
||
expect(system.toLowerCase()).toContain('step');
|
||
expect(system.toLowerCase()).toContain('суть');
|
||
});
|
||
it('подаёт действия с содержимым (input/result), а не только имена', () => {
|
||
const ex = { user: 'u', assistant: 'a', actions: [{ tool: 'Read', input: '{"f":"x"}', result: 'СОДЕРЖИМОЕ' }] };
|
||
const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: ex });
|
||
expect(user).toContain('Read');
|
||
expect(user).toContain('{"f":"x"}');
|
||
expect(user).toContain('СОДЕРЖИМОЕ');
|
||
});
|
||
});
|
||
|
||
describe('reconcile — 9 категорий + стабильная тема', () => {
|
||
it('parse читает alternatives и consequences', () => {
|
||
const out = parseReconcileResponse('{ "subject":"S", "alternatives":[{"text":"ALT","struck":false}], "consequences":[{"text":"C","struck":false}] }');
|
||
expect(out.alternatives[0]).toEqual({ text: 'ALT', struck: false });
|
||
expect(out.consequences[0]).toEqual({ text: 'C', struck: false });
|
||
});
|
||
it('guard сторожит и альтернативы', () => {
|
||
const old = { decisions: [], will: [], open: [], doneNext: [], alternatives: [{ text: 'ALT' }], consequences: [] };
|
||
const ret = { decisions: [], will: [], open: [], doneNext: [], alternatives: [], consequences: [] };
|
||
expect(reconcileGuard(old, ret).ok).toBe(false);
|
||
});
|
||
it('stamp штампует альтернативы и держит ПЕРВУЮ тему (стабильность)', () => {
|
||
const old = { subject: 'создание', history: [], decisions: [], will: [], open: [], doneNext: [], alternatives: [], consequences: [] };
|
||
const ret = { subject: 'узкая тема хода', alternatives: [{ text: 'ALT', struck: false }], decisions: [], will: [], open: [], doneNext: [], consequences: [] };
|
||
const p = stampProvenance(old, ret, 9, 's1');
|
||
expect(p.alternatives[0]).toMatchObject({ text: 'ALT', turns: [9], session: 's1' });
|
||
expect(p.subject).toBe('создание');
|
||
});
|
||
});
|
||
|
||
describe('reconcileTurn', () => {
|
||
const proto = { subject: 'дело', decisions: [{ text: 'A', turns: [1], session: 's0' }], will: [], open: [{ text: 'Q?' }], doneNext: [], history: [] };
|
||
const ex = { user: 'ответ', assistant: 'ок', actions: [] };
|
||
it('чистый ответ модели → штампует и возвращает протокол', async () => {
|
||
const callModel = async () => '{ "subject":"дело", "decisions":[{"text":"A","why":null,"struck":false}], "open":[{"text":"Q?","struck":true}], "will":[], "doneNext":[] }';
|
||
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel });
|
||
expect(out).not.toBeNull();
|
||
expect(out.open[0]).toMatchObject({ text: 'Q?', struck: true });
|
||
expect(out.decisions[0]).toMatchObject({ turns: [1], session: 's0' });
|
||
});
|
||
it('потерял строку → восстанавливает и возвращает протокол (не null), один вызов', async () => {
|
||
let n = 0;
|
||
const callModel = async () => { n++; return '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[], "will":[], "doneNext":[] }'; };
|
||
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel });
|
||
expect(out).not.toBeNull();
|
||
expect(n).toBe(1); // без ретраев — восстановление вместо отклонения
|
||
expect(out.open.map((e) => e.text)).toContain('Q?'); // потерянная строка возвращена
|
||
});
|
||
it('кривой JSON → null без ретраев', async () => {
|
||
let n = 0;
|
||
const callModel = async () => { n++; return 'не json'; };
|
||
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel });
|
||
expect(out).toBeNull();
|
||
expect(n).toBe(1);
|
||
});
|
||
it('проброс step из ответа модели в результат; без step — поля нет', async () => {
|
||
const withStep = async () => '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[{"text":"Q?","struck":true}], "will":[], "doneNext":[], "step":{"user":"u","assistant":"a"} }';
|
||
const r1 = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel: withStep });
|
||
expect(r1.step).toEqual({ user: 'u', assistant: 'a' });
|
||
const noStep = async () => '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[{"text":"Q?","struck":true}], "will":[], "doneNext":[] }';
|
||
const r2 = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel: noStep });
|
||
expect(r2.step).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
import { mergeTurnIntoProtocol, formatReconcileLogLine, restoreLostLines, collapseProtocol } from './secretary-reconcile.mjs';
|
||
|
||
describe('collapseProtocol — детерминированное схлопывание дублей', () => {
|
||
it('8 одинаковых решений → 1, ходы объединены (union)', () => {
|
||
const dup = (turns) => ({ text: 'D', why: 'W', struck: true, turns });
|
||
const p = { decisions: Array.from({ length: 8 }, () => dup([3, 9])) };
|
||
const out = collapseProtocol(p);
|
||
expect(out.decisions).toHaveLength(1);
|
||
expect(out.decisions[0]).toMatchObject({ text: 'D', why: 'W', struck: true });
|
||
expect(out.decisions[0].turns).toEqual([3, 9]);
|
||
});
|
||
it('повторяющиеся клаузы в «почему» схлопываются: A — A — A → A', () => {
|
||
const p = { decisions: [{ text: 'D', why: 'A — A — A', struck: false, turns: [1] }] };
|
||
expect(collapseProtocol(p).decisions[0].why).toBe('A');
|
||
});
|
||
it('«почему», вшитое в text, разворачивается и сливается с обычной записью (ходы объединены)', () => {
|
||
const p = { decisions: [
|
||
{ text: 'D', why: 'W', struck: false, turns: [1] },
|
||
{ text: 'D — W', why: 'W', struck: false, turns: [2] },
|
||
] };
|
||
const out = collapseProtocol(p);
|
||
expect(out.decisions).toHaveLength(1);
|
||
expect(out.decisions[0]).toMatchObject({ text: 'D', why: 'W' });
|
||
expect(out.decisions[0].turns).toEqual([1, 2]);
|
||
});
|
||
it('живая и зачёркнутая с одним текстом НЕ сливаются (история статуса цела)', () => {
|
||
const p = { decisions: [
|
||
{ text: 'D', why: 'W', struck: false, turns: [1] },
|
||
{ text: 'D', why: 'W', struck: true, turns: [2] },
|
||
] };
|
||
expect(collapseProtocol(p).decisions).toHaveLength(2);
|
||
});
|
||
it('история: события (turn,dir) дедупятся, записи по тексту сливаются', () => {
|
||
const p = { history: [
|
||
{ text: 'X', events: [{ turn: 3, dir: 'in' }, { turn: 9, dir: 'out' }, { turn: 9, dir: 'out' }, { turn: 9, dir: 'out' }] },
|
||
{ text: 'X', events: [{ turn: 9, dir: 'out' }, { turn: 10, dir: 'in' }] },
|
||
] };
|
||
const out = collapseProtocol(p);
|
||
expect(out.history).toHaveLength(1);
|
||
expect(out.history[0].events).toEqual([{ turn: 3, dir: 'in' }, { turn: 9, dir: 'out' }, { turn: 10, dir: 'in' }]);
|
||
});
|
||
it('идемпотентность: повторный прогон ничего не меняет (форма стабильна по ходам)', () => {
|
||
const p = { decisions: [
|
||
{ text: 'Берём Postgres', why: 'дешевле — дешевле', struck: false, turns: [1] },
|
||
{ text: 'Берём Postgres — дешевле', why: 'дешевле', struck: false, turns: [2] },
|
||
{ text: 'Берём Postgres', why: 'дешевле', struck: true, turns: [3] },
|
||
] };
|
||
const once = collapseProtocol(p);
|
||
const twice = collapseProtocol(once);
|
||
expect(twice).toEqual(once);
|
||
// живая (две формы слились) + зачёркнутая = 2 записи
|
||
expect(once.decisions).toHaveLength(2);
|
||
});
|
||
it('дословные повторы кусков внутри строки убираются без перестановки', () => {
|
||
const p = { open: [{ text: 'Хайку или Sonnet? — Хайку или Sonnet?', struck: false, turns: [1] }] };
|
||
expect(collapseProtocol(p).open[0].text).toBe('Хайку или Sonnet?');
|
||
});
|
||
it('срезает впечённую метку «[зачёркнуто]» из текста и сливает с чистой записью', () => {
|
||
const p = { decisions: [
|
||
{ text: 'Выбрано X', why: 'причина', struck: true, turns: [3] },
|
||
{ text: '[зачёркнуто] [зачёркнуто] Выбрано X', why: 'причина', struck: true, turns: [31] },
|
||
] };
|
||
const out = collapseProtocol(p);
|
||
expect(out.decisions).toHaveLength(1);
|
||
expect(out.decisions[0].text).toBe('Выбрано X');
|
||
expect(out.decisions[0].turns).toEqual([3, 31]);
|
||
expect(JSON.stringify(out)).not.toContain('[зачёркнуто]');
|
||
});
|
||
it('hidden / steps / nextSvId не трогаются', () => {
|
||
const p = { decisions: [], hidden: [{ id: 'СВ-1' }], steps: [{ turn: 1 }], nextSvId: 5 };
|
||
const out = collapseProtocol(p);
|
||
expect(out.hidden).toEqual([{ id: 'СВ-1' }]);
|
||
expect(out.steps).toEqual([{ turn: 1 }]);
|
||
expect(out.nextSvId).toBe(5);
|
||
});
|
||
it('контроль смысла: ни одна уникальная строка не теряется и не выдумывается', () => {
|
||
const fp = (p) => {
|
||
const set = new Set();
|
||
for (const e of p.decisions || []) set.add(`${e.text}|${e.why}|${e.struck ? 1 : 0}`);
|
||
return set;
|
||
};
|
||
const p = { decisions: [
|
||
{ text: 'D', why: 'W', struck: true, turns: [3] },
|
||
{ text: 'D', why: 'W', struck: true, turns: [9] },
|
||
{ text: 'E', why: 'V', struck: false, turns: [4] },
|
||
] };
|
||
const out = collapseProtocol(p);
|
||
// E/V и D/W(struck) — два разных смысла; оба обязаны остаться, лишнего не появиться
|
||
expect(out.decisions).toHaveLength(2);
|
||
expect(fp(out)).toEqual(new Set(['D|W|1', 'E|V|0']));
|
||
});
|
||
});
|
||
|
||
describe('reconcileTurn — diag сообщает причину срыва (видимый сигнал)', () => {
|
||
const proto = { subject: 'дело', decisions: [{ text: 'A', turns: [1], session: 's0' }], will: [], open: [{ text: 'Q?' }], doneNext: [], history: [] };
|
||
const ex = { user: 'ответ', assistant: 'ок', actions: [] };
|
||
|
||
it('успех — diag НЕ зовётся', async () => {
|
||
const calls = [];
|
||
const callModel = async () => '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[{"text":"Q?","struck":true}], "will":[], "doneNext":[] }';
|
||
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel, diag: (i) => calls.push(i) });
|
||
expect(out).not.toBeNull();
|
||
expect(calls).toEqual([]);
|
||
});
|
||
|
||
it('исключение модели → diag reason model-threw + null', async () => {
|
||
const calls = [];
|
||
const callModel = async () => { throw new Error('таймаут'); };
|
||
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel, diag: (i) => calls.push(i) });
|
||
expect(out).toBeNull();
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].reason).toBe('model-threw');
|
||
expect(calls[0].error).toContain('таймаут');
|
||
});
|
||
|
||
it('кривой JSON → diag reason bad-json + null', async () => {
|
||
const calls = [];
|
||
const callModel = async () => 'не json';
|
||
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel, diag: (i) => calls.push(i) });
|
||
expect(out).toBeNull();
|
||
expect(calls).toEqual([{ reason: 'bad-json' }]);
|
||
});
|
||
|
||
it('потеря строк → diag reason guard-restored + протокол не null (восстановление)', async () => {
|
||
const calls = [];
|
||
const callModel = async () => '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[], "will":[], "doneNext":[] }';
|
||
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel, diag: (i) => calls.push(i) });
|
||
expect(out).not.toBeNull();
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].reason).toBe('guard-restored');
|
||
expect(calls[0].lost).toContain('Q?');
|
||
});
|
||
|
||
it('без diag — поведение прежнее (null без падения)', async () => {
|
||
const callModel = async () => 'не json';
|
||
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel });
|
||
expect(out).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('mergeTurnIntoProtocol — шаг пишется всегда (целостность Шагов)', () => {
|
||
it('успех reconcile — база updated, шаг добавлен', () => {
|
||
const proto = { subject: 'old', steps: [{ turn: 1 }] };
|
||
const updated = { subject: 'new', steps: [{ turn: 1 }] };
|
||
const r = mergeTurnIntoProtocol({ proto, updated, step: { turn: 2, text: 'Ход 2' } });
|
||
expect(r.subject).toBe('new');
|
||
expect(r.steps).toEqual([{ turn: 1 }, { turn: 2, text: 'Ход 2' }]);
|
||
});
|
||
it('срыв reconcile (updated=null) — база прежняя, шаг всё равно добавлен', () => {
|
||
const proto = { subject: 'old', steps: [{ turn: 1 }] };
|
||
const r = mergeTurnIntoProtocol({ proto, updated: null, step: { turn: 2, text: 'Ход 2' } });
|
||
expect(r.subject).toBe('old');
|
||
expect(r.steps).toEqual([{ turn: 1 }, { turn: 2, text: 'Ход 2' }]);
|
||
});
|
||
it('не мутирует входной proto', () => {
|
||
const proto = { steps: [{ turn: 1 }] };
|
||
mergeTurnIntoProtocol({ proto, updated: null, step: { turn: 2 } });
|
||
expect(proto.steps).toEqual([{ turn: 1 }]);
|
||
});
|
||
});
|
||
|
||
describe('formatReconcileLogLine — строка лога причины', () => {
|
||
it('reason + ход', () => {
|
||
expect(formatReconcileLogLine({ turn: 12, reason: 'bad-json', time: 'T' })).toBe('T ход 12: bad-json');
|
||
});
|
||
it('ошибка добавляется деталью', () => {
|
||
const s = formatReconcileLogLine({ turn: 12, reason: 'model-threw', error: 'таймаут', time: 'T' });
|
||
expect(s).toContain('model-threw');
|
||
expect(s).toContain('таймаут');
|
||
});
|
||
it('потери — счётчик строк', () => {
|
||
const s = formatReconcileLogLine({ turn: 12, reason: 'guard-lost', lost: ['a', 'b'], time: 'T' });
|
||
expect(s).toContain('guard-lost');
|
||
expect(s).toContain('2');
|
||
});
|
||
});
|
||
|
||
describe('restoreLostLines — возврат потерянных строк (модель-агностичный reconcile)', () => {
|
||
it('пропавшая старая строка возвращается в свой раздел со struck/why', () => {
|
||
const old = { decisions: [{ text: 'A', why: 'w', struck: false }], open: [{ text: 'Q?', struck: false }], alternatives: [], consequences: [], will: [], doneNext: [] };
|
||
const returned = { decisions: [{ text: 'A', why: 'w', struck: false }], open: [], alternatives: [], consequences: [], will: [], doneNext: [] };
|
||
const merged = restoreLostLines(old, returned);
|
||
expect(merged.open.map((e) => e.text)).toContain('Q?');
|
||
});
|
||
it('зачёркнутая потерянная строка возвращается со struck:true', () => {
|
||
const old = { decisions: [{ text: 'D', struck: true }], open: [], alternatives: [], consequences: [], will: [], doneNext: [] };
|
||
const returned = { decisions: [], open: [], alternatives: [], consequences: [], will: [], doneNext: [] };
|
||
const merged = restoreLostLines(old, returned);
|
||
expect(merged.decisions.find((e) => e.text === 'D').struck).toBe(true);
|
||
});
|
||
it('ничего не потеряно → состав returned сохранён (новый статус остаётся)', () => {
|
||
const old = { decisions: [{ text: 'A', struck: false }], open: [], alternatives: [], consequences: [], will: [], doneNext: [] };
|
||
const returned = { decisions: [{ text: 'A', struck: true }], open: [], alternatives: [], consequences: [], will: [], doneNext: [] };
|
||
const merged = restoreLostLines(old, returned);
|
||
expect(merged.decisions).toHaveLength(1);
|
||
expect(merged.decisions[0].struck).toBe(true);
|
||
});
|
||
});
|