test(tui): cover inline skill references end to end

inlineSlashTrigger and completionRequestForInput are driven through the
mid-message shape, the position-0 shape, and the path cases that must not
claim a slash (/usr/local/bin, src/foo/bar, and/or, 3 /4).

splitSlashSkillRefs asserts the round-trip invariant — the segments always
rejoin to the exact input — rather than freezing a segment list.

The applyCompletion cases reproduce the reported bug rather than restating
the implementation: reverting the fix fails with
`expected 'please run //clean' to be 'please run /clean'`.
This commit is contained in:
Brooklyn Nicholson 2026-07-25 21:19:20 -05:00
parent 07244c5ead
commit ec6fa9bdb2
2 changed files with 124 additions and 0 deletions

View file

@ -16,6 +16,18 @@ describe('applyCompletion', () => {
it('replaces an argument token after a space (subcommand completion)', () => {
expect(applyCompletion('/cron ad', 'add', 6)).toBe('/cron add')
})
it('applies an inline skill pick without disturbing the prose in front of it', () => {
// The gateway returns bare names for `complete.slash`; a mid-message
// reference replaces from just after its own `/`, so "please run " stays.
expect(applyCompletion('please run /cle', 'clean', 12)).toBe('please run /clean')
})
it('drops the row slash based on the character before the replace point, not the input start', () => {
// Widget-app rows carry a leading slash. Mid-message the input does NOT
// start with `/`, so a start-anchored check would double it up.
expect(applyCompletion('please run /cle', '/clean', 12)).toBe('please run /clean')
})
})
describe('completionToApplyOnSubmit', () => {

View file

@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { inlineSlashTrigger, splitSlashSkillRefs } from '../domain/slash.js'
import { completionRequestForInput } from '../hooks/useCompletion.js'
describe('inlineSlashTrigger', () => {
it('detects a slash typed mid-message', () => {
// The reported bug: only a position-0 slash offered anything, so
// "please run /cle" completed nothing.
expect(inlineSlashTrigger('please run /cle')).toEqual({ query: 'cle', start: 11 })
})
it('detects a bare slash after whitespace, before any name is typed', () => {
expect(inlineSlashTrigger('please run /')).toEqual({ query: '', start: 11 })
})
it('fires after a newline, not just a space', () => {
expect(inlineSlashTrigger('text\n/skill')).toEqual({ query: 'skill', start: 5 })
})
it('does not fire at position 0 — that is a command invocation', () => {
expect(inlineSlashTrigger('/clean')).toBeNull()
expect(inlineSlashTrigger('/')).toBeNull()
})
it('leaves file paths alone', () => {
expect(inlineSlashTrigger('look at /usr/local/bin')).toBeNull()
expect(inlineSlashTrigger('check src/foo/bar')).toBeNull()
expect(inlineSlashTrigger('and/or')).toBeNull()
})
it('stops at the command token — an inline reference takes no args', () => {
// Only a position-0 slash is a real invocation, so `/personality alic`
// mid-message is prose with a reference in it, already ended.
expect(inlineSlashTrigger('hello there /personality alic')).toBeNull()
})
it('reports a start index that replaces only the typed token', () => {
const text = 'please run /cle'
const trigger = inlineSlashTrigger(text)!
expect(text.slice(0, trigger.start)).toBe('please run ')
expect(text.slice(trigger.start)).toBe('/cle')
})
})
describe('completionRequestForInput — inline skill references', () => {
it('asks for skills only when the slash is mid-message', () => {
const request = completionRequestForInput('please run /cle')
expect(request).toMatchObject({
method: 'complete.slash',
params: { text: '/cle' },
replaceFrom: 12,
skillsOnly: true
})
})
it('keeps the full command set at position 0', () => {
expect(completionRequestForInput('/cle')).toEqual({
method: 'complete.slash',
params: { text: '/cle' },
replaceFrom: 1
})
})
it('routes a real mid-message path to path completion, not skills', () => {
expect(completionRequestForInput('open src/foo/ba')).toMatchObject({ method: 'complete.path' })
expect(completionRequestForInput('open /usr/lo')).toMatchObject({ method: 'complete.path' })
})
})
describe('splitSlashSkillRefs', () => {
it('marks a skill referenced mid-prose', () => {
expect(splitSlashSkillRefs('clean this up with /clean')).toEqual([
{ ref: false, text: 'clean this up with ' },
{ ref: true, text: '/clean' }
])
})
it('keeps the prose on both sides of the reference', () => {
expect(splitSlashSkillRefs('run /clean then ship')).toEqual([
{ ref: false, text: 'run ' },
{ ref: true, text: '/clean' },
{ ref: false, text: ' then ship' }
])
})
it('does not mark paths', () => {
for (const text of ['look at /usr/local/bin', 'check src/foo/bar', 'a 3 /4 b']) {
expect(splitSlashSkillRefs(text)).toEqual([{ ref: false, text }])
}
})
it('does not mark a leading slash — that is a command, not a reference', () => {
expect(splitSlashSkillRefs('/clean')).toEqual([{ ref: false, text: '/clean' }])
})
it('round-trips the input exactly', () => {
for (const text of ['run /clean then /work ok', 'plain text', '', 'look at /usr/local/bin']) {
expect(
splitSlashSkillRefs(text)
.map(s => s.text)
.join('')
).toBe(text)
}
})
it('always returns at least one segment', () => {
expect(splitSlashSkillRefs('')).toEqual([{ ref: false, text: '' }])
})
})