refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
104
packages/client/ui-input-trigger/tests/apply.client.spec.ts
Normal file
104
packages/client/ui-input-trigger/tests/apply.client.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* apply wiring on a real cordis Context + SlotRegistry: InputTriggerService mounts
|
||||
* as ctx.inputTriggers once its sessions dependency is up; the MenuView overlay
|
||||
* registration follows the slot declaration, resolves the per-session controller from the slot's
|
||||
* sessionId, and unregisters on fiber teardown.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const slots = ctx.get('slots') as SlotRegistry
|
||||
// Stand-in for the ui-conversation composer entry: declare the overlay
|
||||
// slot without providing ConversationController, which is not its lifecycle
|
||||
// signal.
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
// Sessions face: mint one real scope for session 'a' and resolve it by id.
|
||||
const scope = createScope(ctx, sid('a'))
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return { ctx, slots, locale }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => {
|
||||
expect(inject).toEqual(['sessions', 'locale'])
|
||||
})
|
||||
|
||||
it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => {
|
||||
const { ctx, locale } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const t = locale.bind('slash.menu')
|
||||
expect(t('command')).toBe('命令')
|
||||
locale.setLocale('en')
|
||||
expect(t('skill')).toBe('Skills')
|
||||
expect(t('subagent')).toBe('Subagents')
|
||||
expect(t('loading')).toBe('Loading…')
|
||||
})
|
||||
|
||||
it('mounts ctx.inputTriggers once sessions is up, before any conversation service exists', async () => {
|
||||
const { ctx } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(ctx.get('inputTriggers')).toBeInstanceOf(InputTriggerService)
|
||||
})
|
||||
|
||||
it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
|
||||
const entries = slots.entries('conversation.input.overlay')
|
||||
expect(entries[0]!.options.id).toBe('slash-menu')
|
||||
// Copy rides the standard locale seat, not the business face.
|
||||
expect(entries[0]!.locale).toBe('slash.menu')
|
||||
|
||||
const inputTriggers = ctx.get('inputTriggers') as InputTriggerService
|
||||
// StoredEntry.inject is declaration-typed ((...args: never[]) shape);
|
||||
// the erased registration widens it past a direct cast, so hop unknown.
|
||||
const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected
|
||||
const injected = injectEntry(sid('a'))
|
||||
const controller = inputTriggers.sessionOf(
|
||||
(ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
|
||||
)
|
||||
expect(injected.menu).toBe(controller.menu)
|
||||
// The pick face routes into the controller pipeline (closed menu → no-op).
|
||||
injected.onPick('command', 0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
// The dismiss face routes into the controller too (closed menu → no-op).
|
||||
injected.onDismiss()
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
// An unknown session id fails loud (no silent scope miss).
|
||||
expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
|
||||
})
|
||||
|
||||
it('fiber teardown removes the overlay entry', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
|
||||
expect(ctx.get('inputTriggers')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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('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 } })
|
||||
})
|
||||
})
|
||||
215
packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
Normal file
215
packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
// menuReduce generation gating, auto-close, silent group removal, cyclic
|
||||
// highlight movement, stale/no-op reference identity; exactMatch lookup.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MenuState, TriggerHit } from '../src/core/contract.ts'
|
||||
import { exactMatch, MENU_CLOSED, menuReduce, seedGroups } from '../src/core/menu.ts'
|
||||
|
||||
const hit = (query = ''): TriggerHit => ({
|
||||
trigger: '/',
|
||||
query,
|
||||
position: 'leading',
|
||||
span: { start: 0, end: 1 + query.length, draftRev: 1 },
|
||||
})
|
||||
|
||||
/** Seed sources onto the closed state and open a first generation. */
|
||||
function open(sources: readonly string[], h: TriggerHit = hit()): MenuState {
|
||||
return menuReduce(seedGroups(MENU_CLOSED, sources), { type: 'hit', hit: h })
|
||||
}
|
||||
|
||||
const item = (name: string) => ({ name })
|
||||
|
||||
describe('menuReduce hit', () => {
|
||||
it('opens a new generation with all groups pending', () => {
|
||||
const s = open(['command', 'skill'])
|
||||
expect(s.open).toBe(true)
|
||||
expect(s.generation).toBe(1)
|
||||
expect(s.groups).toEqual([
|
||||
{ source: 'command', status: 'pending', items: [] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
])
|
||||
expect(s.highlight).toBeNull()
|
||||
})
|
||||
|
||||
it('re-hit resets ready groups to pending under a bumped generation', () => {
|
||||
let s = open(['command'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
s = menuReduce(s, { type: 'hit', hit: hit('g') })
|
||||
expect(s.generation).toBe(2)
|
||||
expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [] }])
|
||||
expect(s.highlight).toBeNull()
|
||||
})
|
||||
|
||||
it('null hit closes; closing an already-closed state is a no-op reference', () => {
|
||||
const s = open(['command'])
|
||||
const c = menuReduce(s, { type: 'hit', hit: null })
|
||||
expect(c.open).toBe(false)
|
||||
expect(c.groups).toEqual([])
|
||||
expect(menuReduce(c, { type: 'hit', hit: null })).toBe(c)
|
||||
})
|
||||
})
|
||||
|
||||
describe('menuReduce source-settled', () => {
|
||||
it('marks the group ready and highlights the first item', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
expect(s.groups[1]).toEqual({ source: 'skill', status: 'ready', items: [item('commit')] })
|
||||
expect(s.groups[0]!.status).toBe('pending')
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('keeps an existing valid highlight when a later group settles', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('drops settlements from a stale generation by reference', () => {
|
||||
let s = open(['command'])
|
||||
s = menuReduce(s, { type: 'hit', hit: hit('g') }) // generation 2
|
||||
const next = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
expect(next).toBe(s)
|
||||
})
|
||||
|
||||
it('drops settlements while closed and for unknown sources by reference', () => {
|
||||
const closed = menuReduce(open(['command']), { type: 'close' })
|
||||
expect(menuReduce(closed, { type: 'source-settled', generation: 1, source: 'command', items: [] })).toBe(closed)
|
||||
const s = open(['command'])
|
||||
expect(menuReduce(s, { type: 'source-settled', generation: 1, source: 'ghost', items: [] })).toBe(s)
|
||||
})
|
||||
|
||||
it('treats omitted items as empty', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command' })
|
||||
expect(s.groups[0]).toEqual({ source: 'command', status: 'ready', items: [] })
|
||||
expect(s.open).toBe(true) // skill still pending
|
||||
})
|
||||
|
||||
it('auto-closes when every group settles ready and empty', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [] })
|
||||
expect(s.open).toBe(false)
|
||||
expect(s.groups).toEqual([])
|
||||
})
|
||||
|
||||
it('stays open when one group is empty but another has items', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
expect(s.open).toBe(true)
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('menuReduce source-failed', () => {
|
||||
it('silently removes the failed group', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
||||
expect(s.groups.map(g => g.source)).toEqual(['skill'])
|
||||
expect(s.open).toBe(true)
|
||||
})
|
||||
|
||||
it('closes when the last group fails', () => {
|
||||
let s = open(['command'])
|
||||
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
||||
expect(s.open).toBe(false)
|
||||
})
|
||||
|
||||
it('closes when the surviving groups are all ready and empty', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [] })
|
||||
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
||||
expect(s.open).toBe(false)
|
||||
})
|
||||
|
||||
it('moves the highlight off the failed group', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
expect(s.highlight).toEqual({ source: 'command', index: 0 })
|
||||
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('drops stale-generation and unknown-source failures by reference', () => {
|
||||
const s = open(['command'])
|
||||
expect(menuReduce(s, { type: 'source-failed', generation: 0, source: 'command' })).toBe(s)
|
||||
expect(menuReduce(s, { type: 'source-failed', generation: 1, source: 'ghost' })).toBe(s)
|
||||
})
|
||||
})
|
||||
|
||||
describe('menuReduce move', () => {
|
||||
/** Two ready groups: command [goal, model], skill [commit]. */
|
||||
function ready(): MenuState {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] })
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
return s
|
||||
}
|
||||
|
||||
it('cycles forward across groups and wraps', () => {
|
||||
let s = ready()
|
||||
s = menuReduce(s, { type: 'move', dir: 1 })
|
||||
expect(s.highlight).toEqual({ source: 'command', index: 1 })
|
||||
s = menuReduce(s, { type: 'move', dir: 1 })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
s = menuReduce(s, { type: 'move', dir: 1 })
|
||||
expect(s.highlight).toEqual({ source: 'command', index: 0 })
|
||||
})
|
||||
|
||||
it('cycles backward and wraps to the last item', () => {
|
||||
let s = ready()
|
||||
s = menuReduce(s, { type: 'move', dir: -1 })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('skips pending groups', () => {
|
||||
let s = open(['command', 'skill'])
|
||||
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
||||
s = menuReduce(s, { type: 'move', dir: 1 })
|
||||
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('enters from null highlight at either end', () => {
|
||||
const base = { ...ready(), highlight: null }
|
||||
expect(menuReduce(base, { type: 'move', dir: 1 }).highlight).toEqual({ source: 'command', index: 0 })
|
||||
expect(menuReduce(base, { type: 'move', dir: -1 }).highlight).toEqual({ source: 'skill', index: 0 })
|
||||
})
|
||||
|
||||
it('is a no-op reference when closed, without positions, or single-item', () => {
|
||||
const closed = menuReduce(ready(), { type: 'close' })
|
||||
expect(menuReduce(closed, { type: 'move', dir: 1 })).toBe(closed)
|
||||
const pending = open(['command'])
|
||||
expect(menuReduce(pending, { type: 'move', dir: 1 })).toBe(pending)
|
||||
let single = open(['command'])
|
||||
single = menuReduce(single, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
||||
expect(menuReduce(single, { type: 'move', dir: 1 })).toBe(single)
|
||||
})
|
||||
})
|
||||
|
||||
describe('menuReduce close', () => {
|
||||
it('clears everything but keeps the generation for stale-drop', () => {
|
||||
let s = open(['command'])
|
||||
s = menuReduce(s, { type: 'close' })
|
||||
expect(s).toMatchObject({ open: false, hit: null, groups: [], highlight: null, generation: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('exactMatch', () => {
|
||||
const groups: MenuState['groups'] = [
|
||||
{ source: 'command', status: 'ready', items: [item('goal'), item('model')] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
]
|
||||
|
||||
it('finds an exact name in a ready group', () => {
|
||||
expect(exactMatch(groups, 'command', 'model')).toEqual(item('model'))
|
||||
})
|
||||
|
||||
it('returns null on name miss, non-ready group, and unknown source', () => {
|
||||
expect(exactMatch(groups, 'command', 'goa')).toBeNull()
|
||||
expect(exactMatch(groups, 'skill', 'commit')).toBeNull()
|
||||
expect(exactMatch(groups, 'ghost', 'goal')).toBeNull()
|
||||
})
|
||||
})
|
||||
198
packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
Normal file
198
packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MenuView rendering spec, props-direct: closed store
|
||||
* renders null, groups render in roster order under localized title rows
|
||||
* (unknown sources fall back to the raw name) with pending rows as loading,
|
||||
* pointer picks route (source, index) back without stealing focus, the
|
||||
* highlight is exposed through aria-activedescendant + aria-selected, and
|
||||
* the list height clamps to the space above the composer.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import { MenuView } from '../src/client/MenuView.tsx'
|
||||
|
||||
const hit: TriggerHit = {
|
||||
trigger: '/',
|
||||
query: 'g',
|
||||
position: 'leading',
|
||||
span: { start: 0, end: 2, draftRev: 1 },
|
||||
}
|
||||
|
||||
const CLOSED: MenuState = { open: false, hit: null, generation: 0, groups: [], highlight: null }
|
||||
|
||||
function openState(partial?: Partial<MenuState>): MenuState {
|
||||
return {
|
||||
open: true,
|
||||
hit,
|
||||
generation: 1,
|
||||
groups: [
|
||||
{ source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: '⚑' }, { name: 'plan' }] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
],
|
||||
highlight: { source: 'command', index: 0 },
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
// jsdom has no scrollIntoView; the view calls it on the highlighted option.
|
||||
const scrollIntoView = vi.fn()
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
scrollIntoView.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// The framework-injected t seat, stubbed over the zh dictionaries (the
|
||||
// default locale); the stub mirrors the LocaleRuntime key fallback, so an
|
||||
// unknown source comes back verbatim (its raw name).
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
function mount(state: MenuState) {
|
||||
const menu = createSnapshotStore<MenuState>(state)
|
||||
const onPick = vi.fn()
|
||||
const onDismiss = vi.fn()
|
||||
const view = render(<MenuView menu={menu} onPick={onPick} onDismiss={onDismiss} t={t} />)
|
||||
return { menu, onPick, onDismiss, view }
|
||||
}
|
||||
|
||||
/** The non-interactive group title rows (role=presentation), in document order. */
|
||||
function titles(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('div[role="presentation"][data-source]')]
|
||||
.map(el => el.textContent ?? '')
|
||||
}
|
||||
|
||||
describe('MenuView', () => {
|
||||
it('renders null while closed and appears when the store opens', () => {
|
||||
const { menu, view } = mount(CLOSED)
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
act(() => { menu.set(openState()) })
|
||||
expect(screen.queryByRole('listbox')).not.toBeNull()
|
||||
act(() => { menu.set(CLOSED) })
|
||||
expect(view.container.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('renders ready groups as option rows and pending groups as loading rows', () => {
|
||||
mount(openState())
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan'])
|
||||
expect(screen.queryByText('正在加载…')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
|
||||
const { view } = mount(openState({
|
||||
groups: [
|
||||
{ source: 'command', status: 'ready', items: [{ name: 'goal' }] },
|
||||
{ source: 'hollow', status: 'ready', items: [] },
|
||||
{ source: 'mystery', status: 'ready', items: [{ name: 'x' }] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
],
|
||||
}))
|
||||
expect(titles(view.container)).toEqual(['命令', 'mystery', '技能'])
|
||||
})
|
||||
|
||||
it('exposes the highlight via aria-activedescendant and aria-selected', () => {
|
||||
mount(openState({ highlight: { source: 'command', index: 1 } }))
|
||||
const listbox = screen.getByRole('listbox')
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options[1]!.id).toBeTruthy()
|
||||
expect(listbox.getAttribute('aria-activedescendant')).toBe(options[1]!.id)
|
||||
expect(options[1]!.getAttribute('aria-selected')).toBe('true')
|
||||
expect(options[0]!.getAttribute('aria-selected')).toBe('false')
|
||||
})
|
||||
|
||||
it('omits aria-activedescendant without a highlight', () => {
|
||||
mount(openState({ highlight: null }))
|
||||
expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull()
|
||||
})
|
||||
|
||||
it('scrolls the highlighted option into view when the highlight moves', () => {
|
||||
const { menu } = mount(openState())
|
||||
scrollIntoView.mockClear()
|
||||
act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) })
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
|
||||
})
|
||||
|
||||
it('caps the list height at the design maximum when the composer sits low enough', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
})
|
||||
|
||||
it('clamps the list height to the space above the composer minus the safe margin', () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('188px')
|
||||
})
|
||||
|
||||
it('re-fits the height when the window resizes', () => {
|
||||
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
rect.mockReturnValue({ bottom: 800 } as DOMRect)
|
||||
mount(openState())
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('320px')
|
||||
rect.mockReturnValue({ bottom: 100 } as DOMRect)
|
||||
act(() => { window.dispatchEvent(new Event('resize')) })
|
||||
expect(screen.getByRole('listbox').style.maxHeight).toBe('88px')
|
||||
})
|
||||
|
||||
it('pointerdown outside the menu (no composer card ancestor) dismisses', () => {
|
||||
const { onDismiss } = mount(openState())
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('pointerdown inside the list does not dismiss', () => {
|
||||
const { onDismiss } = mount(openState())
|
||||
fireEvent.pointerDown(screen.getAllByRole('option')[0]!)
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pointerdown inside the surrounding composer card does not dismiss; outside it does', () => {
|
||||
const menu = createSnapshotStore<MenuState>(openState())
|
||||
const onDismiss = vi.fn()
|
||||
render(
|
||||
<div data-composer-card="">
|
||||
<MenuView menu={menu} onPick={vi.fn()} onDismiss={onDismiss} t={t} />
|
||||
<button type="button" data-testid="composer-button" />
|
||||
</div>,
|
||||
)
|
||||
fireEvent.pointerDown(screen.getByTestId('composer-button'))
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a pointerdown whose target is not a DOM node', () => {
|
||||
const { onDismiss } = mount(openState())
|
||||
const ev = new Event('pointerdown', { bubbles: true })
|
||||
Object.defineProperty(ev, 'target', { value: {} })
|
||||
document.dispatchEvent(ev)
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closing the menu removes the dismiss listener', () => {
|
||||
const { menu, onDismiss } = mount(openState())
|
||||
act(() => { menu.set(CLOSED) })
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mousedown on a row picks (source, index) and prevents the focus steal', () => {
|
||||
const { onPick } = mount(openState())
|
||||
const options = screen.getAllByRole('option')
|
||||
const notPrevented = fireEvent.mouseDown(options[1]!)
|
||||
// fireEvent returns false when preventDefault was called.
|
||||
expect(notPrevented).toBe(false)
|
||||
expect(onPick).toHaveBeenCalledWith('command', 1)
|
||||
})
|
||||
})
|
||||
831
packages/client/ui-input-trigger/tests/service.client.spec.ts
Normal file
831
packages/client/ui-input-trigger/tests/service.client.spec.ts
Normal file
@@ -0,0 +1,831 @@
|
||||
/**
|
||||
* Slash pipeline spec over the split architecture. InputTriggerService keeps only
|
||||
* the source roster (duplicate throw, disposal dropping live menu groups in
|
||||
* every session controller) and per-session controller resolution; all
|
||||
* interaction — track → menu store, pick execution via the scoped input
|
||||
* events, keyboard arbitration, space/enter adjudication, and the
|
||||
* scope-birth roster warm — is InputTriggerController behavior, tested on a real
|
||||
* session scope (createScope).
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { InputTriggerController, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type {
|
||||
BeginCommandRequest, ClientSessionContext, CommandClaim, InsertReferenceRequest, PickOutcome,
|
||||
ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SourceRoster, TriggerChar,
|
||||
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
interface PendingFetch {
|
||||
resolve: (items: readonly InputTriggerCandidate[]) => void
|
||||
reject: (err: unknown) => void
|
||||
query: string
|
||||
signal: AbortSignal
|
||||
session: ClientSessionContext
|
||||
}
|
||||
|
||||
/** Deferred-candidates source: settle each fetch by hand; warm is a spy. */
|
||||
function deferredSource(trigger: TriggerChar, name: string, over: Partial<InputTriggerSource> = {}) {
|
||||
const pending: PendingFetch[] = []
|
||||
const warm = vi.fn()
|
||||
const source: InputTriggerSource = {
|
||||
trigger,
|
||||
name,
|
||||
candidates: (session, req) => new Promise<readonly InputTriggerCandidate[]>((resolve, reject) => {
|
||||
pending.push({ resolve, reject, query: req.query, signal: req.signal, session })
|
||||
}),
|
||||
onPick: () => undefined,
|
||||
warm,
|
||||
...over,
|
||||
}
|
||||
return { source, pending, warm }
|
||||
}
|
||||
|
||||
/** Source whose candidates resolve immediately; picks are recorded. */
|
||||
function readySource(
|
||||
trigger: TriggerChar, name: string, items: readonly InputTriggerCandidate[], onPick?: (pick: InputTriggerPick) => PickOutcome,
|
||||
) {
|
||||
const picks: InputTriggerPick[] = []
|
||||
const source: InputTriggerSource = {
|
||||
trigger,
|
||||
name,
|
||||
candidates: () => Promise.resolve(items),
|
||||
onPick: (pick) => {
|
||||
picks.push(pick)
|
||||
return onPick?.(pick)
|
||||
},
|
||||
}
|
||||
return { source, picks }
|
||||
}
|
||||
|
||||
const claimOf = (token: string): CommandClaim =>
|
||||
({ token, submit: () => Promise.resolve({ kind: 'success' }) })
|
||||
|
||||
/** One microtask hop: lets settled candidate promises flow into the store. */
|
||||
const tick = () => Promise.resolve()
|
||||
|
||||
/** Direct controller bench: real scope tag + live roster array. */
|
||||
function controllerBench(sources: InputTriggerSource[] = [], key = 'a') {
|
||||
const root = new Context()
|
||||
const scope = createScope(root, sid(key))
|
||||
const roster: SourceRoster = {
|
||||
sources: trigger => sources.filter(s => s.trigger === trigger),
|
||||
all: () => sources,
|
||||
}
|
||||
const controller = new InputTriggerController({ actx: scope.ctx, sessionId: sid(key), roster })
|
||||
return { root, actx: scope.ctx, controller, sources }
|
||||
}
|
||||
|
||||
/** Real-service bench: a sessions face resolving scope tags to session ids. */
|
||||
async function serviceBench() {
|
||||
const root = new Context()
|
||||
root.provide('sessions', {
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
})
|
||||
await root.plugin(InputTriggerService).await()
|
||||
const inputTriggers = root.get('inputTriggers') as InputTriggerService
|
||||
const mint = (key: string) => {
|
||||
const scope = createScope(root, sid(key))
|
||||
return { actx: scope.ctx, fiber: scope.fiber }
|
||||
}
|
||||
return { root, inputTriggers, mint }
|
||||
}
|
||||
|
||||
describe('registerSource', () => {
|
||||
it('throws on a duplicate (trigger, name); same name across triggers is fine', async () => {
|
||||
const { inputTriggers } = await serviceBench()
|
||||
inputTriggers.registerSource(readySource('/', 'command', []).source)
|
||||
expect(() => inputTriggers.registerSource(readySource('/', 'command', []).source))
|
||||
.toThrow(/already registered/)
|
||||
inputTriggers.registerSource(readySource('@', 'command', []).source)
|
||||
})
|
||||
|
||||
it('disposal frees the name and drops the live menu group in every session controller', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const a = readySource('/', 'alpha', [{ name: 'one' }])
|
||||
const b = deferredSource('/', 'beta')
|
||||
inputTriggers.registerSource(a.source)
|
||||
const disposeB = inputTriggers.registerSource(b.source)
|
||||
|
||||
const ca = inputTriggers.sessionOf(mint('a').actx)
|
||||
const cb = inputTriggers.sessionOf(mint('b').actx)
|
||||
ca.track('/o', 2, { tier: 'plain' }, 1)
|
||||
cb.track('/o', 2, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
|
||||
expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
|
||||
|
||||
disposeB()
|
||||
expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
|
||||
expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
|
||||
// The name is free again, and a stale double-dispose stays a no-op.
|
||||
disposeB()
|
||||
inputTriggers.registerSource(deferredSource('/', 'beta').source)
|
||||
})
|
||||
|
||||
it('a source registered after controller birth warms in every live controller', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const ca = inputTriggers.sessionOf(mint('a').actx)
|
||||
const cb = inputTriggers.sessionOf(mint('b').actx)
|
||||
const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
|
||||
inputTriggers.registerSource(late.source)
|
||||
expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
|
||||
expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
|
||||
expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('HMR shape: dispose of the registering fiber removes the source', async () => {
|
||||
const { root, inputTriggers, mint } = await serviceBench()
|
||||
const controller = inputTriggers.sessionOf(mint('a').actx)
|
||||
const fiber = root.plugin({
|
||||
apply(pluginCtx: Context) {
|
||||
pluginCtx.effect(
|
||||
() => inputTriggers.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source),
|
||||
'test: slash source',
|
||||
)
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
|
||||
await fiber.dispose()
|
||||
// Group dropped with the fiber; a fresh track finds no sources → closed.
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionOf', () => {
|
||||
it('resolves lazily: same scope → same resident controller; another session → its own', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const a = mint('a')
|
||||
const first = inputTriggers.sessionOf(a.actx)
|
||||
expect(inputTriggers.sessionOf(a.actx)).toBe(first)
|
||||
expect(inputTriggers.sessionOf(mint('b').actx)).not.toBe(first)
|
||||
})
|
||||
|
||||
it('throws off an unscoped context', async () => {
|
||||
const { root, inputTriggers } = await serviceBench()
|
||||
expect(() => inputTriggers.sessionOf(root)).toThrow(/requires a session scope/)
|
||||
})
|
||||
|
||||
it('warms the roster once at controller birth with the session projection', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const sub = deferredSource('@', 'subagent')
|
||||
inputTriggers.registerSource(cmd.source)
|
||||
inputTriggers.registerSource(sub.source)
|
||||
const a = mint('a')
|
||||
inputTriggers.sessionOf(a.actx)
|
||||
expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
||||
expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
||||
// Re-resolution of the resident controller never re-warms.
|
||||
inputTriggers.sessionOf(a.actx)
|
||||
expect(cmd.warm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the scope disposer removes and disposes the controller; a re-mint resolves fresh', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
inputTriggers.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source)
|
||||
const a = mint('a')
|
||||
const controller = inputTriggers.sessionOf(a.actx)
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
|
||||
await a.fiber.dispose()
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
|
||||
const again = mint('a')
|
||||
expect(inputTriggers.sessionOf(again.actx)).not.toBe(controller)
|
||||
})
|
||||
|
||||
it('two sessions are isolated: one menu opening never touches the other', async () => {
|
||||
const { inputTriggers, mint } = await serviceBench()
|
||||
const src = deferredSource('/', 'command')
|
||||
inputTriggers.registerSource(src.source)
|
||||
const ca = inputTriggers.sessionOf(mint('a').actx)
|
||||
const cb = inputTriggers.sessionOf(mint('b').actx)
|
||||
|
||||
ca.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(ca.menu.getSnapshot().open).toBe(true)
|
||||
expect(cb.menu.getSnapshot().open).toBe(false)
|
||||
|
||||
src.pending[0]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
expect(ca.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
|
||||
expect(cb.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('track', () => {
|
||||
it('drives seed → pending → ready through the store', async () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const skill = deferredSource('/', 'skill')
|
||||
const { controller } = controllerBench([cmd.source, skill.source])
|
||||
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
let state = controller.menu.getSnapshot()
|
||||
expect(state.open).toBe(true)
|
||||
expect(state.groups).toEqual([
|
||||
{ source: 'command', status: 'pending', items: [] },
|
||||
{ source: 'skill', status: 'pending', items: [] },
|
||||
])
|
||||
|
||||
cmd.pending[0]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
state = controller.menu.getSnapshot()
|
||||
expect(state.groups[0]).toEqual({ source: 'command', status: 'ready', items: [{ name: 'goal' }] })
|
||||
expect(state.groups[1]!.status).toBe('pending')
|
||||
expect(state.highlight).toEqual({ source: 'command', index: 0 })
|
||||
})
|
||||
|
||||
it('stamps the caller draftRev into the hit span', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 7)
|
||||
expect(controller.menu.getSnapshot().hit!.span).toEqual({ start: 0, end: 2, draftRev: 7 })
|
||||
})
|
||||
|
||||
it('candidates receive the session projection, identity only', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(cmd.pending[0]!.session).toEqual({ sessionId: sid('a') })
|
||||
})
|
||||
|
||||
it('query refinement supersedes the old generation and aborts its fetch', async () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
const gen1 = controller.menu.getSnapshot().generation
|
||||
controller.track('/go', 3, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().generation).toBe(gen1 + 1)
|
||||
expect(cmd.pending[0]!.signal.aborted).toBe(true)
|
||||
|
||||
// A late settle of the aborted fetch is dropped even before the
|
||||
// generation gate: the group stays pending until the live fetch lands.
|
||||
cmd.pending[0]!.resolve([{ name: 'stale' }])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().groups[0]!.status).toBe('pending')
|
||||
cmd.pending[1]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
|
||||
})
|
||||
|
||||
it('same hit re-track refreshes the span stamp without refetching', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
// Same token under the caret, later revision (an edit past the caret).
|
||||
controller.track('/g x', 2, { tier: 'plain' }, 2)
|
||||
expect(cmd.pending).toHaveLength(1)
|
||||
expect(controller.menu.getSnapshot().generation).toBe(1)
|
||||
})
|
||||
|
||||
it('no live trigger closes the menu and aborts the fetch', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
controller.track('hello', 5, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
expect(cmd.pending[0]!.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('a trigger with no registered sources never opens', () => {
|
||||
const { controller } = controllerBench([readySource('/', 'command', [{ name: 'goal' }]).source])
|
||||
controller.track('@w', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('trigger switch reseeds the roster', () => {
|
||||
const { controller } = controllerBench([
|
||||
deferredSource('/', 'command').source,
|
||||
deferredSource('@', 'subagent').source,
|
||||
])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['command'])
|
||||
controller.track('@w', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['subagent'])
|
||||
})
|
||||
|
||||
it('all sources settling empty auto-closes; a later settle of a gone generation is silent', async () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const skill = deferredSource('/', 'skill')
|
||||
const { controller } = controllerBench([cmd.source, skill.source])
|
||||
controller.track('/zzz', 4, { tier: 'plain' }, 1)
|
||||
cmd.pending[0]!.resolve([])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
skill.pending[0]!.resolve([])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('a rejecting source logs and silently drops its group', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const skill = deferredSource('/', 'skill')
|
||||
const { controller } = controllerBench([cmd.source, skill.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
skill.pending[0]!.reject(new Error('boom'))
|
||||
cmd.pending[0]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
const state = controller.menu.getSnapshot()
|
||||
expect(state.groups.map(g => g.source)).toEqual(['command'])
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('skill'), expect.any(Error))
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('programmatic source launcher', () => {
|
||||
it('opens only the requested source and reuses its ordinary pick span', async () => {
|
||||
const command = readySource('/', 'command', [{ name: 'goal' }])
|
||||
const skill = readySource('/', 'skill', [{ name: 'review' }])
|
||||
const { controller } = controllerBench([command.source, skill.source])
|
||||
const hit = {
|
||||
trigger: '/' as const,
|
||||
query: '',
|
||||
position: 'leading' as const,
|
||||
span: { start: 2, end: 5, draftRev: 7 },
|
||||
}
|
||||
|
||||
controller.toggleSource('command', hit)
|
||||
await tick()
|
||||
|
||||
expect(controller.launcher.getSnapshot()).toBe('command')
|
||||
expect(controller.menu.getSnapshot()).toMatchObject({
|
||||
open: true,
|
||||
hit,
|
||||
groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }],
|
||||
})
|
||||
controller.pick('command', 0)
|
||||
expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span })
|
||||
expect(skill.picks).toHaveLength(0)
|
||||
expect(controller.launcher.getSnapshot()).toBeNull()
|
||||
})
|
||||
|
||||
it('toggles closed, and typed tracking returns to the full trigger roster', async () => {
|
||||
const command = readySource('/', 'command', [{ name: 'goal' }])
|
||||
const skill = readySource('/', 'skill', [{ name: 'review' }])
|
||||
const { controller } = controllerBench([command.source, skill.source])
|
||||
const hit = {
|
||||
trigger: '/' as const,
|
||||
query: '',
|
||||
position: 'leading' as const,
|
||||
span: { start: 0, end: 0, draftRev: 1 },
|
||||
}
|
||||
|
||||
controller.toggleSource('command', hit)
|
||||
controller.toggleSource('command', hit)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
expect(controller.launcher.getSnapshot()).toBeNull()
|
||||
|
||||
controller.toggleSource('command', hit)
|
||||
controller.track('/g', 2, { tier: 'plain' }, 2)
|
||||
await tick()
|
||||
expect(controller.launcher.getSnapshot()).toBeNull()
|
||||
expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope-birth warm', () => {
|
||||
it('construction warms every source once with the session projection', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const sub = deferredSource('@', 'subagent')
|
||||
controllerBench([cmd.source, sub.source])
|
||||
expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
||||
expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
||||
})
|
||||
|
||||
it('hook-less sources are skipped', () => {
|
||||
const bare: InputTriggerSource = {
|
||||
trigger: '/',
|
||||
name: 'bare',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
}
|
||||
const cmd = deferredSource('/', 'command')
|
||||
// No throw on the hook-less source; the implementing one still warms.
|
||||
controllerBench([bare, cmd.source])
|
||||
expect(cmd.warm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispose inerts every verb', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
controller.dispose()
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
expect(cmd.pending[0]!.signal.aborted).toBe(true)
|
||||
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
expect(controller.arbitrate('down', false)).toBe('pass')
|
||||
expect(controller.onSpace()).toBe(false)
|
||||
controller.pick('command', 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick / scoped input events', () => {
|
||||
function pickBench(outcomeOf: (pick: InputTriggerPick) => PickOutcome) {
|
||||
const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], outcomeOf)
|
||||
const bench = controllerBench([cmd.source])
|
||||
const begins: BeginCommandRequest[] = []
|
||||
const inserts: InsertReferenceRequest[] = []
|
||||
bench.actx.on('slash/input-begin-command', (req) => {
|
||||
begins.push(req)
|
||||
return true
|
||||
})
|
||||
bench.actx.on('slash/input-insert-reference', (req) => {
|
||||
inserts.push(req)
|
||||
return true
|
||||
})
|
||||
bench.controller.track('/g', 2, { tier: 'plain' }, 3)
|
||||
return { ...bench, cmd, begins, inserts }
|
||||
}
|
||||
|
||||
it('routes a claim outcome through the scoped begin-command event and closes the menu', async () => {
|
||||
const claim = claimOf('/goal ')
|
||||
const { controller, cmd, begins } = pickBench(() => ({ claim }))
|
||||
await tick()
|
||||
controller.pick('command', 0)
|
||||
expect(cmd.picks).toHaveLength(1)
|
||||
expect(cmd.picks[0]).toMatchObject({
|
||||
candidate: { name: 'goal' },
|
||||
session: { sessionId: sid('a') },
|
||||
position: 'leading',
|
||||
via: 'menu',
|
||||
span: { start: 0, end: 2, draftRev: 3 },
|
||||
})
|
||||
expect(begins).toEqual([{ claim, span: { start: 0, end: 2, draftRev: 3 } }])
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('routes an insert outcome through the scoped insert-reference event', async () => {
|
||||
const insert: ReferenceInsert = { source: 'skill', ref: 'x', label: 'x', clipboardText: '/x' }
|
||||
const { controller, inserts } = pickBench(() => ({ insert }))
|
||||
await tick()
|
||||
controller.pick('command', 1)
|
||||
expect(inserts).toEqual([{ reference: insert, span: { start: 0, end: 2, draftRev: 3 } }])
|
||||
})
|
||||
|
||||
it('routes a text outcome through the scoped insert-text event and closes the menu', async () => {
|
||||
const { controller, actx } = pickBench(() => ({ text: '/goal ' }))
|
||||
const texts: Array<{ text: string; span: unknown }> = []
|
||||
actx.on('slash/input-insert-text', (req) => {
|
||||
texts.push(req)
|
||||
return true
|
||||
})
|
||||
await tick()
|
||||
controller.pick('command', 0)
|
||||
expect(texts).toEqual([{ text: '/goal ', span: { start: 0, end: 2, draftRev: 3 } }])
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('a text outcome the input declines answers false on the space path', async () => {
|
||||
const src: InputTriggerSource = {
|
||||
trigger: '/',
|
||||
name: 'command',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
matchSpace: () => ({ text: '/goal ' }),
|
||||
}
|
||||
const { controller, actx } = controllerBench([src])
|
||||
actx.on('slash/input-insert-text', () => undefined) // input declines (CAS miss)
|
||||
controller.track('/goal', 5, { tier: 'plain' }, 1)
|
||||
expect(controller.onSpace()).toBe(false)
|
||||
})
|
||||
|
||||
it('scope carrier routing: a foreign session\'s listener never hears the dispatch, untagged root does', async () => {
|
||||
const claim = claimOf('/goal ')
|
||||
const cmd = readySource('/', 'command', [{ name: 'goal' }], () => ({ claim }))
|
||||
const { root, controller } = controllerBench([cmd.source])
|
||||
const foreign: BeginCommandRequest[] = []
|
||||
const rootSeen: BeginCommandRequest[] = []
|
||||
createScope(root, sid('b')).ctx.on('slash/input-begin-command', (req) => {
|
||||
foreign.push(req)
|
||||
return true
|
||||
})
|
||||
// Untagged root listeners are admitted globally (the carrier contract).
|
||||
root.on('slash/input-begin-command', (req) => { rootSeen.push(req) })
|
||||
controller.track('/g', 2, { tier: 'plain' }, 3)
|
||||
await tick()
|
||||
controller.pick('command', 0)
|
||||
expect(foreign).toHaveLength(0)
|
||||
expect(rootSeen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("'handled' and undefined outcomes only close the menu", async () => {
|
||||
const { controller, begins, inserts } = pickBench(() => 'handled')
|
||||
await tick()
|
||||
controller.pick('command', 0)
|
||||
expect(begins).toHaveLength(0)
|
||||
expect(inserts).toHaveLength(0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('closed menu / vanished candidate picks are no-ops', async () => {
|
||||
const { controller, cmd } = pickBench(() => undefined)
|
||||
await tick()
|
||||
controller.pick('command', 9)
|
||||
controller.pick('ghost', 0)
|
||||
expect(cmd.picks).toHaveLength(0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lexicon', () => {
|
||||
function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): InputTriggerSource {
|
||||
return {
|
||||
trigger,
|
||||
name,
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
...(hasHook ? { lexicon: () => roll } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
it('aggregates hook-implementing sources by trigger with the session projection; hookless ones are skipped', () => {
|
||||
const seen: unknown[] = []
|
||||
const skill: InputTriggerSource = {
|
||||
trigger: '/',
|
||||
name: 'skill',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
lexicon: (projection) => {
|
||||
seen.push(projection)
|
||||
return ['commit-helper', 'review']
|
||||
},
|
||||
}
|
||||
const { controller } = controllerBench([
|
||||
lexSource('/', 'command', undefined, false), // no hook: not polled
|
||||
skill,
|
||||
lexSource('@', 'subagent', ['worker-1']),
|
||||
])
|
||||
const rolls = controller.lexicon.getSnapshot()
|
||||
expect([...rolls.keys()]).toEqual(['/', '@'])
|
||||
expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
|
||||
expect(rolls.get('@')).toEqual(['worker-1'])
|
||||
expect(seen).toEqual([{ sessionId: sid('a') }])
|
||||
})
|
||||
|
||||
it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
|
||||
const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('two sources on one trigger concatenate in registration order', () => {
|
||||
const { controller } = controllerBench([
|
||||
lexSource('/', 'skill', ['b', 'a']),
|
||||
lexSource('/', 'prompt', ['c']),
|
||||
lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
|
||||
])
|
||||
const rolls = controller.lexicon.getSnapshot()
|
||||
expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
|
||||
expect(rolls.has('@')).toBe(false)
|
||||
})
|
||||
|
||||
it('a source lexicon notification republishes the aggregated store', () => {
|
||||
let roll: readonly string[] | undefined = undefined
|
||||
let notify: (() => void) | undefined
|
||||
const source: InputTriggerSource = {
|
||||
trigger: '/',
|
||||
name: 'skill',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
lexicon: () => roll,
|
||||
subscribeLexicon: (_session, listener) => {
|
||||
notify = listener
|
||||
return () => { notify = undefined }
|
||||
},
|
||||
}
|
||||
const { controller } = controllerBench([source])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
const seen: number[] = []
|
||||
controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
|
||||
roll = ['commit-helper']
|
||||
notify?.()
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
|
||||
expect(seen).toEqual([1])
|
||||
controller.dispose()
|
||||
expect(notify).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
|
||||
const { controller, sources } = controllerBench([])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
const warm = vi.fn()
|
||||
const late: InputTriggerSource = {
|
||||
trigger: '/',
|
||||
name: 'late',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
warm,
|
||||
lexicon: () => ['fresh'],
|
||||
}
|
||||
sources.push(late)
|
||||
controller.sourceAdded(late)
|
||||
expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('a removed source leaves the aggregated lexicon', () => {
|
||||
const src = lexSource('/', 'skill', ['gone'])
|
||||
const { controller, sources } = controllerBench([src])
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
|
||||
sources.splice(sources.indexOf(src), 1)
|
||||
controller.sourceRemoved(src)
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('arbitrate', () => {
|
||||
async function menuBench() {
|
||||
const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], () => undefined)
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
await tick()
|
||||
return { controller, cmd }
|
||||
}
|
||||
|
||||
it('up/down move the highlight and are consumed', async () => {
|
||||
const { controller } = await menuBench()
|
||||
expect(controller.arbitrate('down', false)).toBe('consumed')
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
|
||||
expect(controller.arbitrate('up', false)).toBe('consumed')
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
|
||||
})
|
||||
|
||||
it('enter picks the highlight through the pipeline', async () => {
|
||||
const { controller, cmd } = await menuBench()
|
||||
expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
|
||||
expect(cmd.picks[0]!.candidate.name).toBe('goal')
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('escape closes and consumes', async () => {
|
||||
const { controller } = await menuBench()
|
||||
expect(controller.arbitrate('escape', false)).toBe('consumed')
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('IME composition passes every key untouched', async () => {
|
||||
const { controller } = await menuBench()
|
||||
for (const key of ['up', 'down', 'enter', 'escape'] as const) {
|
||||
expect(controller.arbitrate(key, true)).toBe('pass')
|
||||
}
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
})
|
||||
|
||||
it('closed menu passes; an open menu without a highlight passes enter', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
expect(controller.arbitrate('enter', false)).toBe('pass')
|
||||
// Open with the only group still pending: nothing to pick yet.
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.arbitrate('enter', false)).toBe('pass')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSpace', () => {
|
||||
function spaceSource(name: string, answer: PickOutcome, calls: string[]): InputTriggerSource {
|
||||
return {
|
||||
trigger: '/',
|
||||
name,
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
matchSpace: (_session, token) => {
|
||||
calls.push(`${name}:${token}`)
|
||||
return answer
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('polls matchSpace in registration order; the first non-undefined wins and true = applied', () => {
|
||||
const calls: string[] = []
|
||||
const claim = claimOf('/goal ')
|
||||
const { controller, actx } = controllerBench([
|
||||
// Hook-less source: never polled, so it must not shadow the order below.
|
||||
{ trigger: '/', name: 'nohook', candidates: () => Promise.resolve([]), onPick: () => undefined },
|
||||
spaceSource('first', undefined, calls),
|
||||
spaceSource('second', { claim }, calls),
|
||||
spaceSource('third', { claim: claimOf('/x ') }, calls),
|
||||
])
|
||||
const begins: BeginCommandRequest[] = []
|
||||
actx.on('slash/input-begin-command', (req) => {
|
||||
begins.push(req)
|
||||
return true
|
||||
})
|
||||
controller.track('/goal', 5, { tier: 'plain' }, 1)
|
||||
expect(controller.onSpace()).toBe(true)
|
||||
expect(calls).toEqual(['first:/goal', 'second:/goal'])
|
||||
expect(begins).toEqual([{ claim, span: { start: 0, end: 5, draftRev: 1 } }])
|
||||
})
|
||||
|
||||
it('answers false when the input declines the claim; handled outcomes are true without a dispatch', () => {
|
||||
const calls: string[] = []
|
||||
const declined = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
|
||||
declined.actx.on('slash/input-begin-command', () => undefined)
|
||||
declined.controller.track('/goal', 5, { tier: 'plain' }, 1)
|
||||
expect(declined.controller.onSpace()).toBe(false)
|
||||
|
||||
const handled = controllerBench([spaceSource('command', 'handled', calls)])
|
||||
const begins: BeginCommandRequest[] = []
|
||||
handled.actx.on('slash/input-begin-command', (req) => {
|
||||
begins.push(req)
|
||||
return true
|
||||
})
|
||||
handled.controller.track('/goal', 5, { tier: 'plain' }, 1)
|
||||
expect(handled.controller.onSpace()).toBe(true)
|
||||
expect(begins).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('answers false off a non-leading hit or with no tracked hit', () => {
|
||||
const calls: string[] = []
|
||||
const { controller } = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
|
||||
expect(controller.onSpace()).toBe(false)
|
||||
|
||||
controller.track('say /goal', 9, { tier: 'plain' }, 1)
|
||||
expect(controller.onSpace()).toBe(false)
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('adjudicate', () => {
|
||||
const enterSource = (
|
||||
trigger: TriggerChar, name: string,
|
||||
matchEnter?: InputTriggerSource['matchEnter'],
|
||||
): InputTriggerSource => ({
|
||||
trigger,
|
||||
name,
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
...(matchEnter !== undefined ? { matchEnter } : {}),
|
||||
})
|
||||
|
||||
it('polls matchEnter in registration order with the projection and full line; first non-undefined wins', async () => {
|
||||
const calls: string[] = []
|
||||
const claim = claimOf('/goal ')
|
||||
const { controller } = controllerBench([
|
||||
enterSource('/', 'silent'),
|
||||
enterSource('/', 'first', (session, line) => {
|
||||
expect(session).toEqual({ sessionId: sid('a') })
|
||||
calls.push(`first:${line}`)
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
enterSource('/', 'second', (_session, line) => {
|
||||
calls.push(`second:${line}`)
|
||||
return Promise.resolve({ claim })
|
||||
}),
|
||||
enterSource('/', 'third', () => {
|
||||
calls.push('third')
|
||||
return Promise.resolve('handled')
|
||||
}),
|
||||
])
|
||||
const result = await controller.adjudicate('/goal make it fast', new AbortController().signal)
|
||||
expect(result).toEqual({ claim })
|
||||
expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast'])
|
||||
})
|
||||
|
||||
it('skips sources of another trigger; all-undefined answers undefined', async () => {
|
||||
const atHook = vi.fn(() => Promise.resolve('handled' as const))
|
||||
const { controller } = controllerBench([
|
||||
enterSource('@', 'subagent', atHook),
|
||||
enterSource('/', 'command', () => Promise.resolve(undefined)),
|
||||
])
|
||||
await expect(controller.adjudicate('/xyz', new AbortController().signal)).resolves.toBeUndefined()
|
||||
expect(atHook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a rejecting source rejects the whole adjudication', async () => {
|
||||
const { controller } = controllerBench([
|
||||
enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
|
||||
enterSource('/', 'late', () => Promise.resolve('handled')),
|
||||
])
|
||||
await expect(controller.adjudicate('/goal x', new AbortController().signal))
|
||||
.rejects.toThrow('warmup failed')
|
||||
})
|
||||
|
||||
it('an aborted attempt signal stops the poll', async () => {
|
||||
const hook = vi.fn(() => Promise.resolve(undefined))
|
||||
const { controller } = controllerBench([enterSource('/', 'command', hook)])
|
||||
const abort = new AbortController()
|
||||
abort.abort(new Error('attempt released'))
|
||||
await expect(controller.adjudicate('/goal', abort.signal)).rejects.toThrow('attempt released')
|
||||
expect(hook).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user