Files
brain/tools/askuser-answer-parser.test.mjs

328 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { verifyReceipt as receiptVerify } from './receipt-sign.mjs';
describe('R-31: расписка одобрения подписана в своём домене (approval)', () => {
it('домен-агностичная проверка отвергает, approval-проверка принимает', () => {
const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 1 });
const signed = signApprovalRecord(rec, 'receipt-key-xyz');
expect(receiptVerify(signed, 'receipt-key-xyz')).toBe(false);
expect(verifyApprovalRecord(signed, 'receipt-key-xyz')).toBe(true);
});
});
import {
stripInvisible,
normalizeAnswer,
normalizeCommand,
STOP_KEYWORDS,
isStopAnswer,
detectStopWithFallback,
parseAskUserResult,
matchesApproval,
detectOtherSocialEng,
buildApprovalRecord,
toApprovalRecord,
} from './askuser-answer-parser.mjs';
import { signApprovalRecord, verifyApprovalRecord } from './askuser-answer-parser.mjs';
describe('signed approval records (P10-c)', () => {
const KEY = 'receipt-key-xyz';
it('signApprovalRecord attaches a valid sig', () => {
const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 100 });
const signed = signApprovalRecord(rec, KEY);
expect(signed.sig).toMatch(/^[0-9a-f]{64}$/);
expect(verifyApprovalRecord(signed, KEY)).toBe(true);
});
it('verifyApprovalRecord false when the approved pattern is tampered', () => {
const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 100 });
const signed = signApprovalRecord(rec, KEY);
expect(verifyApprovalRecord({ ...signed, approved_action_pattern: 'git push --force' }, KEY)).toBe(false);
});
it('verifyApprovalRecord false for an unsigned record (forged by controller)', () => {
const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 100 });
expect(verifyApprovalRecord(rec, KEY)).toBe(false);
});
it('signApprovalRecord returns sig:null when no key (fail-closed downstream)', () => {
const rec = buildApprovalRecord({ kind: 'x', pattern: 'git status', sessionId: 'S', nowMs: 1 });
const signed = signApprovalRecord(rec, null);
expect(signed.sig).toBe(null);
expect(verifyApprovalRecord(signed, KEY)).toBe(false);
});
});
describe('askuser-answer-parser / stripInvisible (E33)', () => {
it('strips ZWSP inside a word', () => {
// "вы<ZWSP>полнение" → "выполнение"
expect(stripInvisible('вы​полнение')).toBe('выполнение');
});
it('strips ZWNJ, ZWJ, RTL override, BOM, soft hyphen', () => {
expect(stripInvisible('abc­d')).toBe('abcd');
});
it('leaves normal text untouched', () => {
expect(stripInvisible('обычный текст')).toBe('обычный текст');
});
it('handles non-string by returning empty string', () => {
expect(stripInvisible(null)).toBe('');
expect(stripInvisible(undefined)).toBe('');
});
});
describe('askuser-answer-parser / normalizeAnswer', () => {
it('lowercases, strips invisible, collapses whitespace, trims', () => {
expect(normalizeAnswer(' СТО​П сейчас ')).toBe('стоп сейчас');
});
it('returns empty string for non-string', () => {
expect(normalizeAnswer(42)).toBe('');
});
});
describe('askuser-answer-parser / normalizeCommand (E34)', () => {
it('collapses internal whitespace runs to single space', () => {
expect(normalizeCommand('git rebase main')).toBe('git rebase main');
});
it('trims leading/trailing whitespace, keeps case', () => {
expect(normalizeCommand(' git Rebase main ')).toBe('git Rebase main');
});
it('returns empty string for non-string', () => {
expect(normalizeCommand(null)).toBe('');
});
});
describe('askuser-answer-parser / STOP_KEYWORDS (S27)', () => {
it('includes core Russian + English stop tokens', () => {
for (const kw of ['стоп', 'отмена', 'хватит', 'не надо', 'cancel', 'abort', 'stop', 'halt', 'quit']) {
expect(STOP_KEYWORDS).toContain(kw);
}
});
it('has at least 40 entries (S27 +25 variants)', () => {
expect(STOP_KEYWORDS.length).toBeGreaterThanOrEqual(40);
});
});
describe('askuser-answer-parser / isStopAnswer', () => {
it('matches exact single-word stop', () => {
expect(isStopAnswer('стоп')).toBe(true);
expect(isStopAnswer('Отмена')).toBe(true);
});
it('matches stop word surrounded by other tokens', () => {
expect(isStopAnswer('нет, стоп пожалуйста')).toBe(true);
});
it('matches multi-word stop phrase', () => {
expect(isStopAnswer('на этом всё')).toBe(true);
expect(isStopAnswer('всё, поехали назад')).toBe(true);
});
it('matches even with invisible Unicode injected', () => {
expect(isStopAnswer('сто​п')).toBe(true);
});
it('does not match a normal approval answer', () => {
expect(isStopAnswer('да, выполняй вариант A')).toBe(false);
});
it('does not false-match substring inside unrelated word', () => {
// "нетворкинг" contains "нет" as substring but not as token
expect(isStopAnswer('нетворкинг событие')).toBe(false);
});
it('returns false for non-string', () => {
expect(isStopAnswer(null)).toBe(false);
});
it('matches a stop token with a trailing comma', () => {
expect(isStopAnswer('нет, это лишнее')).toBe(true);
expect(isStopAnswer('стоп.')).toBe(true);
});
it('still matches multi-word phrase without the comma', () => {
expect(isStopAnswer('всё поехали назад')).toBe(true);
});
});
describe('askuser-answer-parser / detectStopWithFallback', () => {
it('returns true on keyword match without calling LLM', async () => {
let called = false;
const judge = async () => { called = true; return true; };
const r = await detectStopWithFallback('отмена', { llmJudge: judge });
expect(r).toBe(true);
expect(called).toBe(false);
});
it('default stub returns false for ambiguous text', async () => {
const r = await detectStopWithFallback('может не сейчас');
expect(r).toBe(false);
});
it('uses injected llmJudge for ambiguous text', async () => {
const judge = async (text) => text.includes('не сейчас');
const r = await detectStopWithFallback('может не сейчас', { llmJudge: judge });
expect(r).toBe(true);
});
it('fails closed-safe (false) if llmJudge throws', async () => {
const judge = async () => { throw new Error('llm down'); };
const r = await detectStopWithFallback('что-то непонятное', { llmJudge: judge });
expect(r).toBe(false);
});
});
describe('askuser-answer-parser / parseAskUserResult', () => {
it('extracts a single selected answer label', () => {
const r = parseAskUserResult({
answers: { 'Какой вариант?': 'Вариант A' },
});
expect(r.selections).toEqual(['Вариант A']);
expect(r.stop).toBe(false);
});
it('handles multiSelect (array of selections) and flattens all text', () => {
const r = parseAskUserResult({
answers: { 'Что включить?': ['Фича 1', 'Фича 2'] },
});
expect(r.selections).toEqual(['Фича 1', 'Фича 2']);
});
it('pulls annotations notes into allText (approval source S15)', () => {
const r = parseAskUserResult({
answers: { Q: 'Other' },
annotations: { Q: { notes: 'git rebase main' } },
});
expect(r.allText).toContain('git rebase main');
});
it('flags stop when a selection is a stop keyword', () => {
const r = parseAskUserResult({ answers: { Q: 'стоп' } });
expect(r.stop).toBe(true);
});
it('returns empty structure for malformed input', () => {
const r = parseAskUserResult(null);
expect(r.selections).toEqual([]);
expect(r.allText).toEqual([]);
expect(r.stop).toBe(false);
});
});
describe('askuser-answer-parser / matchesApproval (E34)', () => {
it('matches identical commands', () => {
expect(matchesApproval('git rebase main', 'git rebase main')).toBe(true);
});
it('matches across whitespace differences', () => {
expect(matchesApproval('git rebase main', 'git rebase main')).toBe(true);
});
it('does not match different commands', () => {
expect(matchesApproval('git rebase main', 'git reset --hard main')).toBe(false);
});
it('is case-sensitive (commands differ by case are different)', () => {
expect(matchesApproval('git rebase Main', 'git rebase main')).toBe(false);
});
});
describe('askuser-answer-parser / detectOtherSocialEng (E29 + v4.0 RU)', () => {
it('flags English "type in Other" suggestion', () => {
expect(detectOtherSocialEng('please type in Other: recovery').flagged).toBe(true);
});
it('flags Russian "впиши в Другое" variants', () => {
expect(detectOtherSocialEng('впиши в "Другое" recovery').flagged).toBe(true);
expect(detectOtherSocialEng('нажми "Другое" и впиши команду').flagged).toBe(true);
expect(detectOtherSocialEng('укажи в графе Другое').flagged).toBe(true);
});
it('does not flag innocent text', () => {
expect(detectOtherSocialEng('выбери подходящий вариант').flagged).toBe(false);
});
it('handles non-string', () => {
expect(detectOtherSocialEng(null).flagged).toBe(false);
});
});
describe('askuser-answer-parser / buildApprovalRecord', () => {
it('builds a pure record with normalized pattern', () => {
const rec = buildApprovalRecord({
kind: 'approve_git_operation',
pattern: 'git rebase main',
sessionId: 'sess-1',
nowMs: 1000,
});
expect(rec.kind).toBe('approve_git_operation');
expect(rec.approved_action_pattern).toBe('git rebase main');
expect(rec.session_id).toBe('sess-1');
expect(rec.approved_at_ms).toBe(1000);
});
});
describe('toApprovalRecord (Stream H Task 6 — schema sync)', () => {
it('returns null for non-git-pattern answer', () => {
expect(toApprovalRecord('cancel', { question: 'continue?' })).toBeNull();
});
it('returns {type, command, ts} for approved git push pattern', () => {
const r = toApprovalRecord('подтверди git push origin main', {
question: 'разрешить git push?',
nowMs: 1700000000000,
});
expect(r).toMatchObject({ type: 'approve_git_operation', command: 'git push origin main', ts: 1700000000000 });
});
it('returns {type, command, ts} for approved git commit pattern', () => {
const r = toApprovalRecord('git commit -m "fix: x"', {
question: 'разрешить коммит?',
nowMs: 1700000000000,
});
expect(r).toMatchObject({ type: 'approve_git_operation', command: 'git commit -m "fix: x"', ts: 1700000000000 });
});
it('uses current ms when nowMs not provided', () => {
const before = Date.now();
const r = toApprovalRecord('git add tools/x.mjs', { question: 'разрешить add?' });
const after = Date.now();
expect(r).not.toBeNull();
expect(r.ts).toBeGreaterThanOrEqual(before);
expect(r.ts).toBeLessThanOrEqual(after);
});
it('returns null for non-string answer', () => {
expect(toApprovalRecord(null)).toBeNull();
expect(toApprovalRecord(undefined)).toBeNull();
expect(toApprovalRecord(42)).toBeNull();
});
});
import { toFloorEscapeRecord } from './askuser-answer-parser.mjs';
describe('askuser-answer-parser toFloorEscapeRecord', () => {
it('распознаёт FLOOR-ESCAPE токен → запись floor_escape', () => {
const r = toFloorEscapeRecord('Разрешаю FLOOR-ESCAPE: bash:git push --force', { nowMs: 5 });
expect(r).toEqual({ type: 'floor_escape', action: 'bash:git push --force', ts: 5 });
});
it('без токена → null', () => {
expect(toFloorEscapeRecord('просто да', { nowMs: 5 })).toBe(null);
});
it('stop-намерение → null', () => {
expect(toFloorEscapeRecord('отмена FLOOR-ESCAPE: bash:x', { nowMs: 5 })).toBe(null);
});
});
// sub-plan E Task 2 (✅O13): skill-канон lowercase — обе стороны совпадают
describe('toFloorEscapeRecord — skill-канон (✅O13)', () => {
it('skill:-действие приводится к нижнему регистру (совпадает с canonicalAction)', () => {
const r = toFloorEscapeRecord('FLOOR-ESCAPE: skill:Audit-Context-Building:Audit-Context-Building', { nowMs: 1 });
expect(r.action).toBe('skill:audit-context-building:audit-context-building');
});
it('write:-действие НЕ меняет регистр (поведение как раньше)', () => {
const r = toFloorEscapeRecord('FLOOR-ESCAPE: write:c:/Моя/Path.md', { nowMs: 1 });
expect(r.action).toBe('write:c:/Моя/Path.md');
});
});