// detectTrigger word-boundary, position, guard-tier, and span behavior // (design §5.1). URL rule pinned here: '/' is dead when its predecessor is // another '/' (second slash of '//') or a ':' itself preceded by a // non-whitespace char (scheme separator) — this is the concrete rule chosen // to honor "no trigger inside URLs". import { describe, expect, it } from 'vitest' import { detectTrigger } from '../src/core/detect.ts' import type { TriggerGuard } from '../src/types.ts' const plain: TriggerGuard = { tier: 'plain' } const claimed: TriggerGuard = { tier: 'claimed' } const frozen: TriggerGuard = { tier: 'frozen' } /** Hit at the end of the draft under the plain tier. */ const atEnd = (draft: string, guard: TriggerGuard = plain) => detectTrigger(draft, draft.length, guard) describe('detectTrigger word boundaries', () => { it('triggers at start of draft', () => { expect(atEnd('/go')).toMatchObject({ trigger: '/', query: 'go', position: 'leading' }) expect(atEnd('@wo')).toMatchObject({ trigger: '@', query: 'wo', position: 'leading' }) }) it('triggers after whitespace, newline, and punctuation', () => { expect(atEnd('say /co')).toMatchObject({ trigger: '/', query: 'co' }) expect(atEnd('line1\n/go')).toMatchObject({ trigger: '/', query: 'go', position: 'inline' }) expect(atEnd('see (/go')).toMatchObject({ trigger: '/', query: 'go' }) expect(atEnd('ping @wo')).toMatchObject({ trigger: '@', query: 'wo' }) }) it('does not trigger after a word character', () => { expect(atEnd('user@host')).toBeNull() expect(atEnd('a/b')).toBeNull() expect(atEnd('foo_1@bar')).toBeNull() }) it('does not trigger on URL slashes', () => { // Both '//' slashes: first blocked by the ':' rule, second by the '/' rule. expect(atEnd('https://example')).toBeNull() expect(atEnd('see https://example')).toBeNull() // Path slashes deeper in the URL sit after word chars. expect(atEnd('https://a.b/c/d')).toBeNull() // Single slash after a scheme-like colon (mailto:/, C:/). expect(atEnd('C:/path')).toBeNull() }) it('still triggers when a colon is not a scheme separator', () => { // ':' preceded by whitespace / at index 0 is ordinary punctuation. expect(atEnd('note: /go')).toMatchObject({ trigger: '/', query: 'go' }) expect(atEnd(':/go')).toMatchObject({ trigger: '/', query: 'go' }) }) it('stops the backward scan at whitespace', () => { // Space after the token: no trigger at the caret anymore. expect(atEnd('/goal x')).toBeNull() expect(atEnd('@worker done')).toBeNull() }) it('finds the nearest trigger left of the caret', () => { expect(atEnd('/goal @wor')).toMatchObject({ trigger: '@', query: 'wor' }) }) }) describe('detectTrigger position', () => { it('treats a draft whose leading trim (incl. newlines) starts at the token as leading', () => { expect(atEnd('\n\n/goal')).toMatchObject({ position: 'leading' }) expect(atEnd(' \n /goal')).toMatchObject({ position: 'leading' }) }) it('treats a token after non-whitespace text as inline', () => { expect(atEnd('第一行\n/goal')).toMatchObject({ position: 'inline' }) expect(atEnd('a /goal')).toMatchObject({ position: 'inline' }) }) }) describe('detectTrigger guard tiers', () => { it('claimed suppresses "/" everywhere but keeps "@"', () => { expect(atEnd('/co', claimed)).toBeNull() expect(atEnd('args /path', claimed)).toBeNull() expect(atEnd('/goal @wor', claimed)).toMatchObject({ trigger: '@', query: 'wor' }) }) it('a suppressed "/" is scanned through like an ordinary char', () => { // '/x' right of the caret path: scan passes the dead '/' and hits nothing. expect(detectTrigger('/goal /x', 8, claimed)).toBeNull() }) it('frozen suppresses both triggers', () => { expect(atEnd('/co', frozen)).toBeNull() expect(atEnd('@wo', frozen)).toBeNull() }) }) describe('detectTrigger span and query', () => { it('spans trigger char to caret with a placeholder draftRev', () => { const hit = detectTrigger('say /goal', 9, plain) expect(hit?.span).toEqual({ start: 4, end: 9, draftRev: 0 }) expect(hit?.query).toBe('goal') }) it('cuts the query at a mid-token caret', () => { const hit = detectTrigger('/goal', 3, plain) expect(hit).toMatchObject({ query: 'go', span: { start: 0, end: 3 } }) }) it('returns null at caret 0 and on empty drafts', () => { expect(detectTrigger('', 0, plain)).toBeNull() expect(detectTrigger('/goal', 0, plain)).toBeNull() }) it('handles multi-line drafts with the token on a later line', () => { const draft = 'first line\nsecond /com' const hit = detectTrigger(draft, draft.length, plain) expect(hit).toMatchObject({ trigger: '/', query: 'com', position: 'inline', span: { start: 18, end: 22 } }) }) })