| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126 |
- // detectTrigger word-boundary, position, guard-tier, and span behavior.
- // 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('keeps an open quoted @file token active across spaces', () => {
- const draft = 'read @"docs/design notes'
- expect(atEnd(draft)).toMatchObject({
- trigger: '@',
- query: 'docs/design notes',
- quoted: true,
- position: 'inline',
- span: { start: 5, end: draft.length },
- })
- })
- 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 } })
- })
- })
|