# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts
440 lines
21 KiB
TypeScript
440 lines
21 KiB
TypeScript
/**
|
||
* SubmitMachine behavior: enter routing, adjudication outcomes, the claimed
|
||
* lifecycle that follows the leading command chip, settlement (commit-draft
|
||
* and claim re-entry decisions), anti-backwash, and per-session isolation.
|
||
* Text-edit semantics live in the editor (lexical-editor-core spec) — the
|
||
* machine only observes the document through event payloads.
|
||
*/
|
||
import { describe, expect, it } from 'vitest'
|
||
import type { CommandClaim } from '../src/client/contract/input.ts'
|
||
import type { DraftAttachmentId, InputEffect, SubmitAttempt } from '../src/client/contract/input.ts'
|
||
import { SubmitMachine } from '../src/client/input/machine.ts'
|
||
import { scanTextRefs } from '../src/client/input/decorations.ts'
|
||
|
||
function attachmentId(id: string): DraftAttachmentId {
|
||
return id as DraftAttachmentId
|
||
}
|
||
|
||
function claimOf(name: string, hint?: string): CommandClaim {
|
||
return {
|
||
name,
|
||
token: `/${name} `,
|
||
...(hint !== undefined ? { hint } : {}),
|
||
submit: async () => ({ kind: 'success' }),
|
||
}
|
||
}
|
||
|
||
function effectAt<T extends InputEffect['type']>(
|
||
effects: readonly InputEffect[], index: number, type: T,
|
||
): Extract<InputEffect, { type: T }> {
|
||
const e = effects[index]
|
||
expect(e?.type).toBe(type)
|
||
return e as Extract<InputEffect, { type: T }>
|
||
}
|
||
|
||
/** Drive plain → adjudicating and hand back the minted attempt. */
|
||
function enterAdjudicating(m: SubmitMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
|
||
const fx = m.dispatch({ type: 'enter', mode, draft, occurrences: [], quotes: [], attachmentIds: [] })
|
||
return effectAt(fx, 0, 'adjudicate').attempt
|
||
}
|
||
|
||
/** Drive plain → claimed → submitting and hand back attempt + claim. */
|
||
function enterSubmitting(m: SubmitMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
|
||
const claim = claimOf(name)
|
||
m.dispatch({ type: 'claim', claim })
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: claim.token + args, occurrences: [], quotes: [], attachmentIds: [] })
|
||
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
|
||
}
|
||
|
||
function staleAttempt(): SubmitAttempt {
|
||
return {
|
||
seq: 9999, signal: new AbortController().signal, draftSnapshot: '', occurrences: [], quotes: [], attachmentIds: [], mode: 'queue',
|
||
}
|
||
}
|
||
|
||
describe('submit-machine: plain × enter', () => {
|
||
it('empty and whitespace-only drafts produce nothing', () => {
|
||
const m = new SubmitMachine()
|
||
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '', occurrences: [], quotes: [], attachmentIds: [] })).toEqual([])
|
||
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n ', occurrences: [], quotes: [], attachmentIds: [] })).toEqual([])
|
||
expect(m.state.phase).toBe('plain')
|
||
})
|
||
|
||
it('non-command text is sent with the draft and mode and stays until the Host accepts it', () => {
|
||
const m = new SubmitMachine()
|
||
const attachmentIds = [attachmentId('image-1')]
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'hello', occurrences: [], quotes: [], attachmentIds })
|
||
expect(fx).toHaveLength(1)
|
||
const sink = effectAt(fx, 0, 'default-sink')
|
||
expect(sink.draft).toBe('hello')
|
||
expect(sink.mode).toBe('queue')
|
||
expect(sink.attempt).toMatchObject({ draftSnapshot: 'hello', attachmentIds })
|
||
expect(m.state.phase).toBe('sending')
|
||
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: 'again', occurrences: [], quotes: [], attachmentIds: [] })).toEqual([])
|
||
|
||
const accepted = m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok: true, outcome: { kind: 'success' } })
|
||
expect(effectAt(accepted, 0, 'commit-draft').retainSuffixOf).toBeNull()
|
||
expect(m.state.phase).toBe('plain')
|
||
})
|
||
|
||
it('returns a refused or failed message to editing unchanged with its notice', () => {
|
||
const m = new SubmitMachine()
|
||
const refused = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'kept', occurrences: [], quotes: [], attachmentIds: [] }), 0, 'default-sink')
|
||
expect(m.dispatch({ type: 'sink-settled', attempt: refused.attempt, ok: false, outcome: { kind: 'error' } })).toEqual([])
|
||
expect(m.state.phase).toBe('plain')
|
||
|
||
const failed = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'kept', occurrences: [], quotes: [], attachmentIds: [] }), 0, 'default-sink')
|
||
expect(m.dispatch({ type: 'sink-settled', attempt: failed.attempt, ok: false, message: 'offline' }))
|
||
.toEqual([{ type: 'notice', level: 'error', text: 'offline' }])
|
||
expect(m.state.phase).toBe('plain')
|
||
|
||
const informed = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'sent', occurrences: [], quotes: [], attachmentIds: [] }), 0, 'default-sink')
|
||
expect(m.dispatch({
|
||
type: 'sink-settled', attempt: informed.attempt, ok: true, outcome: { kind: 'success', text: 'queued' },
|
||
})).toEqual([{ type: 'commit-draft', retainSuffixOf: null }, { type: 'notice', level: 'info', text: 'queued' }])
|
||
expect(m.dispatch({ type: 'sink-settled', attempt: informed.attempt, ok: true })).toEqual([])
|
||
})
|
||
|
||
it('sends an attachment-only draft as a message', () => {
|
||
const m = new SubmitMachine()
|
||
const attachmentIds = [attachmentId('image-only')]
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: ' ', occurrences: [], quotes: [], attachmentIds })
|
||
expect(effectAt(fx, 0, 'default-sink').attempt.attachmentIds).toEqual(attachmentIds)
|
||
expect(m.state.phase).toBe('sending')
|
||
})
|
||
|
||
it('retains an explicit steer mode on the default sink effect', () => {
|
||
const m = new SubmitMachine()
|
||
const fx = m.dispatch({ type: 'enter', mode: 'steer', draft: 'go', occurrences: [], quotes: [], attachmentIds: [] })
|
||
expect(effectAt(fx, 0, 'default-sink').mode).toBe('steer')
|
||
})
|
||
|
||
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
|
||
const m = new SubmitMachine()
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal write tests', occurrences: [], quotes: [], attachmentIds: [] })
|
||
const adjudicate = effectAt(fx, 0, 'adjudicate')
|
||
expect(adjudicate.draft).toBe('/goal write tests')
|
||
expect(adjudicate.attempt.draftSnapshot).toBe('/goal write tests')
|
||
expect(adjudicate.attempt.signal.aborted).toBe(false)
|
||
expect(m.state.phase).toBe('adjudicating')
|
||
})
|
||
|
||
it('leading is judged after trim including newlines', () => {
|
||
const m = new SubmitMachine()
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n /goal x', occurrences: [], quotes: [], attachmentIds: [] })
|
||
expect(effectAt(fx, 0, 'adjudicate').draft).toBe(' \n /goal x')
|
||
})
|
||
|
||
it('a non-whitespace prefix before "/" is not leading — default sink', () => {
|
||
const m = new SubmitMachine()
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'see /goal', occurrences: [], quotes: [], attachmentIds: [] })
|
||
expect(effectAt(fx, 0, 'default-sink').draft).toBe('see /goal')
|
||
})
|
||
})
|
||
|
||
describe('submit-machine: adjudication outcomes', () => {
|
||
it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
|
||
const m = new SubmitMachine()
|
||
const attempt = enterAdjudicating(m, '/goal write x\nand y')
|
||
const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
|
||
const begin = effectAt(fx, 0, 'begin-submit')
|
||
expect(begin.args).toBe('write x\nand y')
|
||
expect(m.state.phase).toBe('submitting')
|
||
expect(m.state.claim?.token).toBe('/goal ')
|
||
})
|
||
|
||
it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
|
||
const m = new SubmitMachine()
|
||
const attempt = enterAdjudicating(m, '/goal')
|
||
const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
|
||
expect(effectAt(fx, 0, 'begin-submit').args).toBe('')
|
||
|
||
const m2 = new SubmitMachine()
|
||
const attempt2 = enterAdjudicating(m2, ' /goal args')
|
||
const fx2 = m2.dispatch({ type: 'adjudicated', attempt: attempt2, outcome: { claim: claimOf('goal') } })
|
||
expect(effectAt(fx2, 0, 'begin-submit').args).toBe('args')
|
||
})
|
||
|
||
it('undefined outcome falls back to the default sink with the snapshot', () => {
|
||
const m = new SubmitMachine()
|
||
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
|
||
const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })
|
||
const sink = effectAt(fx, 0, 'default-sink')
|
||
expect(fx).toHaveLength(1)
|
||
expect(sink.draft).toBe('/unknown thing')
|
||
expect(sink.mode).toBe('steer')
|
||
expect(sink.attempt).toBe(attempt)
|
||
expect(m.state.phase).toBe('sending')
|
||
})
|
||
|
||
it("'handled' lands plain with zero effects (popup shell path)", () => {
|
||
const m = new SubmitMachine()
|
||
const attempt = enterAdjudicating(m, '/model')
|
||
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
|
||
expect(m.state.phase).toBe('plain')
|
||
})
|
||
|
||
it('adjudication failure notices and keeps plain — no silent downgrade', () => {
|
||
const m = new SubmitMachine()
|
||
const attempt = enterAdjudicating(m, '/goal x')
|
||
const fx = m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' })
|
||
expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'warmup failed' })
|
||
expect(m.state.phase).toBe('plain')
|
||
})
|
||
|
||
it('enter is a no-op while adjudicating (pending lock)', () => {
|
||
const m = new SubmitMachine()
|
||
enterAdjudicating(m, '/goal x')
|
||
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x', occurrences: [], quotes: [], attachmentIds: [] })).toEqual([])
|
||
expect(m.state.phase).toBe('adjudicating')
|
||
})
|
||
|
||
it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
|
||
const m = new SubmitMachine()
|
||
enterAdjudicating(m, '/goal x')
|
||
expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: undefined })).toEqual([])
|
||
expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
|
||
expect(m.state.phase).toBe('adjudicating')
|
||
})
|
||
|
||
it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
|
||
const m = new SubmitMachine()
|
||
const attempt = enterAdjudicating(m, '/goal x')
|
||
m.dispatch({ type: 'release' })
|
||
expect(attempt.signal.aborted).toBe(true)
|
||
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
|
||
expect(m.state.phase).toBe('plain')
|
||
})
|
||
})
|
||
|
||
describe('submit-machine: claimed lifecycle', () => {
|
||
it('the claim event enters claimed and snapshots hint and images bits', () => {
|
||
const m = new SubmitMachine()
|
||
m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), attachments: true } })
|
||
expect(m.state.phase).toBe('claimed')
|
||
expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', attachments: true })
|
||
})
|
||
|
||
it('claimed overwrites in place — no stack', () => {
|
||
const m = new SubmitMachine()
|
||
m.dispatch({ type: 'claim', claim: claimOf('goal') })
|
||
m.dispatch({ type: 'claim', claim: claimOf('plan') })
|
||
expect(m.state.claim?.token).toBe('/plan ')
|
||
expect(m.state.phase).toBe('claimed')
|
||
})
|
||
|
||
it('submitting rejects the claim event (lock)', () => {
|
||
const m = new SubmitMachine()
|
||
enterSubmitting(m, 'goal', 'x')
|
||
m.dispatch({ type: 'claim', claim: claimOf('plan') })
|
||
expect(m.state.claim?.token).toBe('/goal ')
|
||
expect(m.state.phase).toBe('submitting')
|
||
})
|
||
|
||
it('holds the claim while the leading command chip stays and releases it when the chip goes', () => {
|
||
const m = new SubmitMachine()
|
||
const claim = claimOf('goal')
|
||
m.dispatch({ type: 'claim', claim })
|
||
m.dispatch({ type: 'draft-changed', command: claim })
|
||
expect(m.state.phase).toBe('claimed')
|
||
m.dispatch({ type: 'draft-changed', command: undefined })
|
||
expect(m.state.phase).toBe('plain')
|
||
expect(m.state.claim).toBeUndefined()
|
||
})
|
||
|
||
it('re-enters claimed when a history restore brings the command chip back', () => {
|
||
const m = new SubmitMachine()
|
||
const claim = { ...claimOf('goal'), token: '/目标 ' }
|
||
m.dispatch({ type: 'draft-changed', command: claim })
|
||
expect(m.state.phase).toBe('claimed')
|
||
expect(m.state.claim?.token).toBe('/目标 ')
|
||
})
|
||
|
||
it.each([
|
||
['goal', '/goal '], ['goal', '/目标 '], ['plan', '/plan '], ['plan', '/计划 '],
|
||
['feedback', '/feedback '], ['feedback', '/反馈 '],
|
||
])('submits %s claimed as %s without its separator with an empty argument', (name, token) => {
|
||
const m = new SubmitMachine()
|
||
const claim = { ...claimOf(name), token }
|
||
m.dispatch({ type: 'claim', claim })
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: token.trimEnd(), occurrences: [], quotes: [], attachmentIds: [] })
|
||
const begin = effectAt(fx, 0, 'begin-submit')
|
||
expect(begin.args).toBe('')
|
||
m.dispatch({ type: 'submit-settled', attempt: begin.attempt, ok: false, command: claim })
|
||
expect(m.state.phase).toBe('claimed')
|
||
expect(m.state.claim?.name).toBe(name)
|
||
})
|
||
|
||
it('reads arguments typed directly against the chip without a separator', () => {
|
||
const m = new SubmitMachine()
|
||
m.dispatch({ type: 'claim', claim: { ...claimOf('goal'), token: '/目标 ' } })
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/目标发布', occurrences: [], quotes: [], attachmentIds: [] })
|
||
expect(effectAt(fx, 0, 'begin-submit').args).toBe('发布')
|
||
})
|
||
|
||
it('explicit release returns to plain when nothing is in flight', () => {
|
||
const m = new SubmitMachine()
|
||
m.dispatch({ type: 'claim', claim: claimOf('goal') })
|
||
m.dispatch({ type: 'release' })
|
||
expect(m.state.phase).toBe('plain')
|
||
expect(m.state.claim).toBeUndefined()
|
||
})
|
||
|
||
it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
|
||
const m = new SubmitMachine()
|
||
m.dispatch({ type: 'claim', claim: claimOf('goal') })
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal line one\nline two', occurrences: [], quotes: [], attachmentIds: [] })
|
||
expect(effectAt(fx, 0, 'begin-submit').args).toBe('line one\nline two')
|
||
})
|
||
})
|
||
|
||
describe('submit-machine: submitting transaction', () => {
|
||
it('enter and claim are locked while submitting; draft-changed is recorded without leaving submitting', () => {
|
||
const m = new SubmitMachine()
|
||
enterSubmitting(m, 'goal', 'x')
|
||
expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x', occurrences: [], quotes: [], attachmentIds: [] })).toEqual([])
|
||
m.dispatch({ type: 'draft-changed', command: undefined })
|
||
expect(m.state.phase).toBe('submitting')
|
||
})
|
||
|
||
it('commit emits commit-draft with the snapshot, releases the claim, and relays the outcome text', () => {
|
||
const m = new SubmitMachine()
|
||
const { attempt } = enterSubmitting(m, 'goal', 'x')
|
||
const fx = m.dispatch({
|
||
type: 'submit-settled', attempt, ok: true, command: undefined,
|
||
outcome: { kind: 'success', text: 'goal saved' },
|
||
})
|
||
expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBe('/goal x')
|
||
expect(effectAt(fx, 1, 'notice')).toMatchObject({ level: 'info', text: 'goal saved' })
|
||
expect(m.state.phase).toBe('plain')
|
||
expect(m.state.claim).toBeUndefined()
|
||
})
|
||
|
||
it('an error-kind outcome text relays as an error notice on success=false settles', () => {
|
||
const m = new SubmitMachine()
|
||
const { attempt } = enterSubmitting(m, 'goal', 'x')
|
||
const fx = m.dispatch({
|
||
type: 'submit-settled', attempt, ok: false, command: undefined,
|
||
outcome: { kind: 'error', text: 'rejected' },
|
||
})
|
||
expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'rejected' })
|
||
expect(m.state.phase).toBe('plain')
|
||
})
|
||
|
||
it('a failed settlement re-enters claimed while the command chip still leads', () => {
|
||
const m = new SubmitMachine()
|
||
const { attempt, claim } = enterSubmitting(m, 'goal', 'x')
|
||
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, command: claim, message: 'transport' })
|
||
expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'transport' })
|
||
expect(m.state.phase).toBe('claimed')
|
||
expect(m.state.claim?.token).toBe('/goal ')
|
||
})
|
||
|
||
it('a failed settlement without the command chip only notices and returns to plain', () => {
|
||
const m = new SubmitMachine()
|
||
const { attempt } = enterSubmitting(m, 'goal', 'x')
|
||
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, command: undefined, message: 'transport' })
|
||
expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'transport' })
|
||
expect(m.state.phase).toBe('plain')
|
||
expect(m.state.claim).toBeUndefined()
|
||
})
|
||
|
||
it('enter-path rollback returns to plain because an adjudicated claim has no command chip', () => {
|
||
const m = new SubmitMachine()
|
||
const attempt = enterAdjudicating(m, '/goal x')
|
||
m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
|
||
m.dispatch({ type: 'submit-settled', attempt, ok: false, command: undefined, message: 'nope' })
|
||
expect(m.state.phase).toBe('plain')
|
||
expect(m.state.claim).toBeUndefined()
|
||
})
|
||
|
||
it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
|
||
const m = new SubmitMachine()
|
||
const { attempt: first, claim } = enterSubmitting(m, 'goal', 'x')
|
||
m.dispatch({ type: 'submit-settled', attempt: first, ok: false, command: claim, message: 'try again' })
|
||
const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x', occurrences: [], quotes: [], attachmentIds: [] })
|
||
const second = effectAt(fx, 0, 'begin-submit').attempt
|
||
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true, command: claim })).toEqual([])
|
||
expect(m.state.phase).toBe('submitting')
|
||
m.dispatch({ type: 'submit-settled', attempt: second, ok: true, command: claim })
|
||
expect(m.state.phase).toBe('plain')
|
||
})
|
||
|
||
it('release mid-flight aborts the attempt and later settles are dropped', () => {
|
||
const m = new SubmitMachine()
|
||
const { attempt } = enterSubmitting(m, 'goal', 'x')
|
||
m.dispatch({ type: 'release' })
|
||
expect(attempt.signal.aborted).toBe(true)
|
||
expect(m.dispatch({ type: 'submit-settled', attempt, ok: true, command: undefined })).toEqual([])
|
||
expect(m.state.phase).toBe('plain')
|
||
})
|
||
|
||
it('drops a settlement for a message the composer released', () => {
|
||
const m = new SubmitMachine()
|
||
const sink = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'gone', occurrences: [], quotes: [], attachmentIds: [] }), 0, 'default-sink')
|
||
m.dispatch({ type: 'release' })
|
||
expect(sink.attempt.signal.aborted).toBe(true)
|
||
expect(m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok: true })).toEqual([])
|
||
expect(m.state.phase).toBe('plain')
|
||
})
|
||
})
|
||
|
||
describe('submit-machine: per-session isolation', () => {
|
||
it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
|
||
const a = new SubmitMachine()
|
||
const b = new SubmitMachine()
|
||
const { attempt } = enterSubmitting(a, 'goal', 'x')
|
||
const fx = b.dispatch({ type: 'enter', mode: 'queue', draft: 'hello', occurrences: [], quotes: [], attachmentIds: [] })
|
||
expect(effectAt(fx, 0, 'default-sink').draft).toBe('hello')
|
||
a.dispatch({ type: 'submit-settled', attempt, ok: true, command: undefined })
|
||
expect(a.state.phase).toBe('plain')
|
||
expect(b.state.phase).toBe('sending')
|
||
})
|
||
})
|
||
|
||
describe('decorations: scanTextRefs', () => {
|
||
const lexicon: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
|
||
['/', ['commit-helper', 'goal'] as readonly string[]],
|
||
['@', ['research'] as readonly string[]],
|
||
])
|
||
|
||
it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
|
||
const out = scanTextRefs('/goal then @research and /commit-helper', lexicon)
|
||
expect(out.map(r => [r.start, r.end, r.trigger])).toEqual([
|
||
[0, 5, '/'], [11, 20, '@'], [25, 39, '/'],
|
||
])
|
||
})
|
||
|
||
it('matches slash names only after their catalog is available', () => {
|
||
expect(scanTextRefs('/goal x', new Map())).toEqual([])
|
||
expect(scanTextRefs('/goal x', new Map([['/', []]]))).toEqual([])
|
||
expect(scanTextRefs('/goal x', new Map([['/', ['goal']]]))).toEqual([{ start: 0, end: 5, trigger: '/' }])
|
||
})
|
||
|
||
it('recognizes directory paths independently of the dynamic lexicon', () => {
|
||
const out = scanTextRefs('see @src/x/ now', new Map())
|
||
expect(out).toEqual([{ start: 4, end: 11, trigger: '@' }])
|
||
})
|
||
|
||
it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
|
||
expect(scanTextRefs('/research @goal', lexicon)).toEqual([])
|
||
})
|
||
|
||
it('a "/" token continued by a path never matches, even when the name is on the lexicon', () => {
|
||
expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([])
|
||
})
|
||
|
||
it('a "/" token glued to punctuation is not a reference: the host gesture is whitespace-bounded', () => {
|
||
expect(scanTextRefs('/goal。 then /goal, now', lexicon)).toEqual([])
|
||
})
|
||
|
||
it('word boundary: a trigger glued to text never matches', () => {
|
||
expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([])
|
||
})
|
||
|
||
it('tokens never cross a newline; a token straight after one matches', () => {
|
||
const out = scanTextRefs('a\n/goal', lexicon)
|
||
expect(out).toEqual([{ start: 2, end: 7, trigger: '/' }])
|
||
})
|
||
})
|