import { describe, it, expect } from 'vitest' import { APPROVE_PHRASES, REJECT_PHRASES, MIN_APPROVE_CONFIDENCE, NEGATION_PREFIXES, normalizeTranscript, startsWithNegation, matchCommand, type VoiceMatchContext, } from '../public/voice-commands.js' /** Default context: a held tool-gate permission (the common case under test). */ function pendingToolCtx(overrides: Partial = {}): VoiceMatchContext { return { pendingApproval: true, gate: 'tool', ...overrides } } describe('normalizeTranscript', () => { it('lowercases', () => { expect(normalizeTranscript('YES')).toBe('yes') }) it('strips trailing ASCII punctuation', () => { expect(normalizeTranscript('confirm.')).toBe('confirm') }) it('strips CJK punctuation/quotes', () => { expect(normalizeTranscript('「确认」')).toBe('确认') }) it('strips apostrophes so contractions collapse (dont)', () => { expect(normalizeTranscript("don't")).toBe('dont') }) it('collapses whitespace and trims', () => { expect(normalizeTranscript(' ')).toBe('') expect(normalizeTranscript(' go ahead ')).toBe('go ahead') }) it('is idempotent (already-normalized input passes through)', () => { expect(normalizeTranscript('go ahead')).toBe('go ahead') expect(normalizeTranscript('确认')).toBe('确认') }) }) describe('startsWithNegation', () => { it('detects CJK negation prefixes', () => { expect(startsWithNegation('不可以')).toBe(true) expect(startsWithNegation('别继续')).toBe(true) expect(startsWithNegation('没关系')).toBe(true) expect(startsWithNegation('未确认')).toBe(true) expect(startsWithNegation('勿动')).toBe(true) }) it('detects English negation prefixes', () => { expect(startsWithNegation('no way')).toBe(true) expect(startsWithNegation('not now')).toBe(true) expect(startsWithNegation('dont approve')).toBe(true) expect(startsWithNegation('cannot proceed')).toBe(true) expect(startsWithNegation('wont continue')).toBe(true) expect(startsWithNegation('never mind')).toBe(true) }) it('does not flag ordinary phrases', () => { expect(startsWithNegation('确认')).toBe(false) expect(startsWithNegation('yes')).toBe(false) expect(startsWithNegation('now')).toBe(false) }) }) describe('matchCommand — context gating (decision 1)', () => { it('returns text when pendingApproval is false, even for a command word', () => { expect(matchCommand('yes', { pendingApproval: false, gate: null })).toBe('text') expect(matchCommand('确认', { pendingApproval: false, gate: null })).toBe('text') }) it('returns text for an empty/whitespace transcript', () => { expect(matchCommand(' ', pendingToolCtx())).toBe('text') expect(matchCommand('', pendingToolCtx())).toBe('text') }) }) describe('matchCommand — plan gate (decision A)', () => { it('falls through to dictation when gate is plan, not tool', () => { expect(matchCommand('confirm', { pendingApproval: true, gate: 'plan' })).toBe('text') expect(matchCommand('stop', { pendingApproval: true, gate: 'plan' })).toBe('text') }) }) describe('matchCommand — held tool approve', () => { const approvePhrases = [ '确认', '批准', '同意', '好', '好的', '好吧', '行', '可以', 'yes', 'ok', 'confirm', 'approve', 'go ahead', ] for (const phrase of approvePhrases) { it(`approves "${phrase}"`, () => { expect(matchCommand(phrase, pendingToolCtx())).toBe('approve') }) } }) describe('matchCommand — held tool reject', () => { const rejectPhrases = ['拒绝', '取消', '不行', 'no', 'nope', 'cancel', 'deny', 'abort', 'stop'] for (const phrase of rejectPhrases) { it(`rejects "${phrase}"`, () => { expect(matchCommand(phrase, pendingToolCtx())).toBe('reject') }) } }) describe('matchCommand — negation/filler regression (MUST NOT approve)', () => { it('不可以 rejects (negated approve phrase is a listed reject phrase)', () => { expect(matchCommand('不可以', pendingToolCtx())).toBe('reject') }) it('不允许/不通过/不同意/不批准 reject', () => { expect(matchCommand('不允许', pendingToolCtx())).toBe('reject') expect(matchCommand('不通过', pendingToolCtx())).toBe('reject') expect(matchCommand('不同意', pendingToolCtx())).toBe('reject') expect(matchCommand('不批准', pendingToolCtx())).toBe('reject') }) it('不继续 is negated but not a listed reject phrase -> text', () => { expect(matchCommand('不继续', pendingToolCtx())).toBe('text') }) it('English negated phrases fall through to text', () => { expect(matchCommand("don't approve", pendingToolCtx())).toBe('text') expect(matchCommand('do not confirm', pendingToolCtx())).toBe('text') expect(matchCommand('cannot proceed', pendingToolCtx())).toBe('text') expect(matchCommand("won't continue", pendingToolCtx())).toBe('text') }) it('"ok let\'s not do that" is not a whole-utterance match -> text', () => { expect(matchCommand("ok let's not do that", pendingToolCtx())).toBe('text') }) it('"ok can you also check the logs" is not a whole-utterance match -> text', () => { expect(matchCommand('ok can you also check the logs', pendingToolCtx())).toBe('text') }) it('我觉得可以先看看 is not a whole-utterance match -> text', () => { expect(matchCommand('我觉得可以先看看', pendingToolCtx())).toBe('text') }) it('请继续 is not a whole-utterance match (unequal to 继续) -> text', () => { expect(matchCommand('请继续', pendingToolCtx())).toBe('text') }) it('now is not a listed phrase (unequal to no) -> text', () => { expect(matchCommand('now', pendingToolCtx())).toBe('text') }) it('我不知道 is not a whole-utterance match -> text', () => { expect(matchCommand('我不知道', pendingToolCtx())).toBe('text') }) }) describe('matchCommand — confidence gate (decision D)', () => { it('low-confidence approve falls through to text', () => { expect(matchCommand('confirm', pendingToolCtx({ confidence: 0.4 }))).toBe('text') }) it('high-confidence approve succeeds', () => { expect(matchCommand('confirm', pendingToolCtx({ confidence: 0.9 }))).toBe('approve') }) it('confidence at exactly MIN_APPROVE_CONFIDENCE is sufficient', () => { expect(matchCommand('confirm', pendingToolCtx({ confidence: MIN_APPROVE_CONFIDENCE }))).toBe( 'approve', ) }) it('reject is unaffected by low confidence', () => { expect(matchCommand('no', pendingToolCtx({ confidence: 0.2 }))).toBe('reject') }) it('approve with no confidence supplied succeeds (confidence optional)', () => { expect(matchCommand('confirm', pendingToolCtx())).toBe('approve') }) }) describe('phrase-set integrity invariants', () => { it('every phrase is already normalized (normalize(p) === p)', () => { for (const p of [...APPROVE_PHRASES, ...REJECT_PHRASES]) { expect(normalizeTranscript(p)).toBe(p) } }) it('APPROVE_PHRASES and REJECT_PHRASES are disjoint', () => { const rejectSet = new Set(REJECT_PHRASES) const overlap = APPROVE_PHRASES.filter((p) => rejectSet.has(p)) expect(overlap).toEqual([]) }) it('no APPROVE_PHRASES entry starts with a negation prefix', () => { for (const p of APPROVE_PHRASES) { expect(startsWithNegation(p)).toBe(false) } }) it('NEGATION_PREFIXES is non-empty and covers both CJK and English', () => { expect(NEGATION_PREFIXES.length).toBeGreaterThan(0) expect(NEGATION_PREFIXES).toContain('不') expect(NEGATION_PREFIXES).toContain('no') }) })