Review caught a real divergence the earlier measurements missed: mirroring an offset is only correct while both layers can reach it, and for a draft ending in a newline the backdrop could not. A textarea reserves a line box for the caret after a final newline. `white-space: pre-wrap` collapses a text node's trailing newline and generates none. So a draft ending in a newline made the backdrop exactly one line shorter than the textarea — measured 628 against 652 — and the mirrored assignment clamped, leaving the glyphs one line behind the caret at the very bottom of the draft. The backdrop now carries the same trailing-line sentinel the mirror div has carried all along: its content is the decoration walk plus one newline. The same pre-wrap collapse absorbs it when the draft does not end in a newline, so it costs no height in the ordinary case, and it supplies the missing line box when it does. Verified in isolation first: a bare pre-wrap div measures 180/180/198 against a textarea's 180/198/216 for zero, one and two trailing newlines, and 180/198/216 with the sentinel. Coverage for the shape that exposed it: the browser scenario asserts the two extents are equal before asserting the glyphs reach the end, observing each layer's maximum by asking for an impossible offset and reading back the clamp rather than computing it from scrollHeight, and the golden records the relation. The unit spec pins the backdrop's text as the draft plus exactly one newline. Removing the sentinel fails both, the e2e with the same 628 against 652. The scrollbar-gutter half of the same review point does not reproduce here: both layers measure clientWidth 776 against a border box of 776 while the draft overflows, so this engine's textarea scrollbar is an overlay and takes no width out of the wrap.
550 lines
25 KiB
TypeScript
550 lines
25 KiB
TypeScript
// @vitest-environment jsdom
|
|
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
|
|
// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running
|
|
// semantics (input stays free; primary turns stop), the machine pending lock,
|
|
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-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 type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { SessionInputShell } from '../src/client/input/facade.ts'
|
|
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
|
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
|
import { zh } from '../src/client/locales.ts'
|
|
|
|
afterEach(cleanup)
|
|
|
|
const SCTX = {} as ClientContext
|
|
const SID = 's1' as SessionId
|
|
|
|
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
|
return {
|
|
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
|
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
|
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
|
promptError: null, blank: false, lastAgentError: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
interface BenchOptions {
|
|
planEntry?: React.ReactNode
|
|
/** The `plan` projection value the standard-kit useProjection serves. */
|
|
plan?: { active: boolean; pending: boolean }
|
|
modelEntry?: React.ReactNode
|
|
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
|
|
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
|
|
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
|
|
draft?: string
|
|
running?: boolean
|
|
disabled?: boolean
|
|
promptError?: ConversationSnapshot['promptError']
|
|
variant?: 'hero' | 'composer'
|
|
placeholder?: string
|
|
t?: InputBarProps['t']
|
|
accessory?: React.ReactNode
|
|
overlay?: React.ReactNode
|
|
leftItems?: React.ReactNode
|
|
rightItems?: React.ReactNode
|
|
commandMenuOpen?: boolean
|
|
toggleCommandMenu?: (selection: { start: number; end: number }) => void
|
|
}
|
|
|
|
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
|
|
function bench(over?: BenchOptions) {
|
|
const sink = vi.fn()
|
|
const lex = over?.lexicon
|
|
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
|
|
const shell = new SessionInputShell({
|
|
actx: SCTX,
|
|
defaultSink: sink,
|
|
// Lexicon-only stub: adjudication untouched (undefined slash methods are
|
|
// never reached — these benches drive plain-draft flows only).
|
|
...(lex !== undefined
|
|
? {
|
|
slash: (() => ({
|
|
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
|
|
})) as unknown as NonNullable<ShellDeps['slash']>,
|
|
}
|
|
: {}),
|
|
})
|
|
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
|
|
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
|
|
running: over?.running ?? false,
|
|
removed: over?.disabled ?? false,
|
|
promptError: over?.promptError ?? null,
|
|
}))
|
|
const stop = vi.fn()
|
|
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
|
|
const slotCalls: { key: string; owner: unknown }[] = []
|
|
const renderSlot = ((key: string, owner: object) => {
|
|
slotCalls.push({ key, owner })
|
|
if (key === 'conversation.input.plan') return over?.planEntry ?? null
|
|
if (key === 'conversation.input.model') return over?.modelEntry ?? null
|
|
return null
|
|
}) as InputBarProps['renderSlot']
|
|
const props: InputBarProps = {
|
|
sessionId: SID,
|
|
SessionProvider: ({ children }) => children(SID),
|
|
useSession: bindSnapshotSelector(session),
|
|
useSessions: bindSnapshotSelector(createSnapshotStore({
|
|
ids: [], byId: {}, current: undefined, phase: 'ready',
|
|
})),
|
|
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
|
items: [], state: 'idle', phase: 'ready', error: null,
|
|
baselinesReady: true, recentWorkspaceId: undefined,
|
|
})),
|
|
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
|
|
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
|
|
useInput: bindSnapshotSelector(shell.state),
|
|
inputActions: shell.actions,
|
|
keyboard: shell,
|
|
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
|
|
useNotices: bindSnapshotSelector(shell.notices),
|
|
useLexicon: bindSnapshotSelector(shell.lexicon),
|
|
useMenuLauncher: bindSnapshotSelector(menuLauncher),
|
|
stop,
|
|
command: () => Promise.resolve(true),
|
|
// Mirrors the real lookup chain (conversation namespace, then common).
|
|
t: over?.t ?? makeTranslate(zh, commonZh),
|
|
renderSlot,
|
|
variant: over?.variant ?? 'composer',
|
|
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
|
|
...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
|
|
...(over?.overlay !== undefined ? { overlay: over.overlay } : {}),
|
|
...(over?.leftItems !== undefined ? { leftItems: over.leftItems } : {}),
|
|
...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
|
|
}
|
|
const view = render(<InputBar {...props} />)
|
|
const textarea = view.container.querySelector('textarea')!
|
|
// aria-label (not role name): title carries the same label and would double-match.
|
|
const button = view.container.querySelector<HTMLButtonElement>(
|
|
`button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`,
|
|
)!
|
|
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
|
|
}
|
|
|
|
describe('Enter semantics', () => {
|
|
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
|
|
const { textarea, sink } = bench({ draft: 'hello' })
|
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
expect(sink).toHaveBeenCalledWith('hello', 'queue')
|
|
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
|
|
expect(sink).toHaveBeenCalledTimes(1)
|
|
const empty = bench({ draft: ' ' })
|
|
fireEvent.keyDown(empty.textarea, { key: 'Enter' })
|
|
expect(empty.sink).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
|
|
const { textarea, sink } = bench({ draft: 'hello' })
|
|
fireEvent.keyDown(textarea, { key: 'a' })
|
|
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
|
|
expect(sink).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('Shift+Enter newline wins even inside IME composition (unconditional precedence)', () => {
|
|
const { textarea, sink } = bench({ draft: 'hello' })
|
|
fireEvent.compositionStart(textarea)
|
|
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
|
|
expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
|
|
})
|
|
|
|
it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => {
|
|
const { textarea, shell, sink } = bench({ draft: 'hello' })
|
|
textarea.setSelectionRange(5, 5)
|
|
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
|
|
expect(shell.snapshot.draft).toBe('hello\n')
|
|
expect(sink).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('platform undo/redo chords route to the machine, never the browser stack', () => {
|
|
const { textarea, shell } = bench({ draft: '' })
|
|
fireEvent.change(textarea, { target: { value: 'first' } })
|
|
fireEvent.change(textarea, { target: { value: 'first second' } })
|
|
fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true })
|
|
expect(shell.snapshot.draft).not.toBe('first second')
|
|
fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true, shiftKey: true })
|
|
expect(shell.snapshot.draft).toBe('first second')
|
|
})
|
|
|
|
it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { textarea, sink } = bench({ draft: 'hello' })
|
|
fireEvent.compositionStart(textarea)
|
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
expect(sink).not.toHaveBeenCalled()
|
|
fireEvent.compositionEnd(textarea)
|
|
// Safari delivers the closing keydown before the deferred clear.
|
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
expect(sink).not.toHaveBeenCalled()
|
|
vi.advanceTimersByTime(20)
|
|
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
|
|
expect(sink).not.toHaveBeenCalled()
|
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
expect(sink).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('running and lock semantics (queue cut 1)', () => {
|
|
it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
|
|
const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
|
|
expect(textarea.disabled).toBe(false) // running no longer locks
|
|
fireEvent.change(textarea, { target: { value: '排队消息2' } })
|
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
|
|
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
|
fireEvent.click(button)
|
|
expect(stop).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('disabled (session removed) locks the textarea and chrome', () => {
|
|
const { textarea, view } = bench({ disabled: true })
|
|
expect(textarea.disabled).toBe(true)
|
|
expect(textarea.placeholder).toBe('会话不可用')
|
|
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
|
|
it('idle primary sends and disables on empty draft', () => {
|
|
const { button, sink } = bench({ draft: 'go' })
|
|
fireEvent.click(button)
|
|
expect(sink).toHaveBeenCalledWith('go', 'queue')
|
|
const empty = bench()
|
|
expect(empty.button.disabled).toBe(true)
|
|
})
|
|
|
|
it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
|
|
const first = bench({ disabled: true, draft: 'x' })
|
|
act(() => { first.session.set(snapshotOf({ removed: false })) })
|
|
const textarea = first.view.container.querySelector('textarea')!
|
|
expect(document.activeElement).toBe(textarea)
|
|
textarea.blur()
|
|
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
|
|
expect(document.activeElement).toBe(textarea)
|
|
})
|
|
|
|
it('typing forwards through the machine (draft state echoes back)', () => {
|
|
const { textarea, wiring } = bench()
|
|
fireEvent.change(textarea, { target: { value: 'typed' } })
|
|
expect(wiring.state.getSnapshot().draft).toBe('typed')
|
|
expect((textarea).value).toBe('typed')
|
|
})
|
|
|
|
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
|
|
const host = document.createElement('div')
|
|
host.setAttribute('data-conversation-scroll', '')
|
|
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
|
const { view, textarea } = bench()
|
|
host.appendChild(view.container)
|
|
document.body.appendChild(host)
|
|
try {
|
|
const wheeled = fireEvent.wheel(textarea, { deltaY: 30 })
|
|
expect(wheeled).toBe(false) // preventDefault
|
|
expect(host.scrollTop).toBe(70)
|
|
} finally {
|
|
host.remove()
|
|
}
|
|
})
|
|
|
|
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
|
|
const host = document.createElement('div')
|
|
host.setAttribute('data-conversation-scroll', '')
|
|
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
|
const { view, textarea } = bench()
|
|
host.appendChild(view.container)
|
|
document.body.appendChild(host)
|
|
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
|
|
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
|
|
let scrollTop = 150
|
|
Object.defineProperty(textarea, 'scrollTop', {
|
|
configurable: true,
|
|
get: () => scrollTop,
|
|
set: (value: number) => { scrollTop = value },
|
|
})
|
|
try {
|
|
// Mid-draft: both directions stay local — host must not move.
|
|
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true)
|
|
expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true)
|
|
expect(host.scrollTop).toBe(40)
|
|
// At the bottom edge, further down-scroll forwards to the host.
|
|
scrollTop = 300
|
|
expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false)
|
|
expect(host.scrollTop).toBe(70)
|
|
// At the top edge, further up-scroll forwards to the host.
|
|
scrollTop = 0
|
|
host.scrollTop = 70
|
|
expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false)
|
|
expect(host.scrollTop).toBe(50)
|
|
} finally {
|
|
host.remove()
|
|
}
|
|
})
|
|
|
|
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
|
|
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
|
|
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
|
Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
|
|
Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
|
|
// A scrolled draft: the textarea moves, the clipped backdrop must follow.
|
|
textarea.scrollTop = 120
|
|
fireEvent.scroll(textarea)
|
|
expect(backdrop.scrollTop).toBe(120)
|
|
// Every later move tracks too, including back to the top — a one-shot
|
|
// mirror would leave the glyphs parked at the first offset it saw.
|
|
textarea.scrollTop = 0
|
|
fireEvent.scroll(textarea)
|
|
expect(backdrop.scrollTop).toBe(0)
|
|
})
|
|
|
|
it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
|
|
// jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
|
|
// (the browser scenario owns that); what is checkable is that the backdrop's
|
|
// text is the draft plus exactly one newline. A textarea reserves a line box
|
|
// after a final newline and `pre-wrap` collapses one, so without the
|
|
// sentinel a draft ending in a newline leaves the backdrop a line short and
|
|
// the mirrored offset clamps.
|
|
const withNewline = bench({ draft: 'alpha\nbeta\n' })
|
|
const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
|
expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
|
|
const withoutNewline = bench({ draft: 'alpha\nbeta' })
|
|
const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
|
expect(plain.textContent).toBe('alpha\nbeta\n')
|
|
})
|
|
|
|
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
|
const { textarea } = bench({ disabled: true })
|
|
expect(textarea.placeholder).toBe('会话不可用')
|
|
const live = bench()
|
|
expect(live.textarea.placeholder).toBe('给智能体发消息')
|
|
const custom = bench({ placeholder: 'Custom placeholder' })
|
|
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
|
})
|
|
|
|
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
|
|
const active = bench({ plan: { active: true, pending: false } })
|
|
expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
|
|
// /plan just ran: pending entry already reads as the plan target.
|
|
const entering = bench({ plan: { active: false, pending: true } })
|
|
expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
|
|
// Pending exit: target is default again.
|
|
const leaving = bench({ plan: { active: true, pending: true } })
|
|
expect(leaving.textarea.placeholder).toBe('给智能体发消息')
|
|
// Owner placeholder outranks the plan swap.
|
|
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
|
|
expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
|
})
|
|
})
|
|
|
|
describe('machine pending lock', () => {
|
|
it('submitting renders read-only textarea, pending dot, and a disabled primary', () => {
|
|
const { view, shell } = bench()
|
|
// Drive the machine into submitting through a claim + enter.
|
|
act(() => {
|
|
shell.setDraft('/goal ')
|
|
shell.beginCommand(
|
|
{
|
|
token: '/goal ',
|
|
submit: () => new Promise<never>(() => {}), // never settles: stays submitting
|
|
},
|
|
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
|
)
|
|
shell.submit('queue')
|
|
})
|
|
expect(shell.snapshot.phase).toBe('submitting')
|
|
const textarea = view.container.querySelector('textarea')!
|
|
expect(textarea.readOnly).toBe(true)
|
|
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('decorations', () => {
|
|
it('claimed token renders the mirror highlight and the blank-args hint', () => {
|
|
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
|
|
const { view, shell } = bench({ t: makeTranslate({}) })
|
|
act(() => {
|
|
shell.setDraft('/goal ')
|
|
shell.beginCommand(
|
|
{ token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
|
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
|
)
|
|
})
|
|
const token = view.container.querySelector('[data-decoration="token"]')
|
|
expect(token?.textContent).toBe('/goal ')
|
|
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
|
|
// Args typed: the hint disappears, the token highlight stays.
|
|
act(() => { shell.setDraft('/goal 发布') })
|
|
expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
|
|
expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
|
|
})
|
|
|
|
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
|
|
const { view, shell } = bench()
|
|
act(() => {
|
|
shell.setDraft('/goal ')
|
|
shell.beginCommand(
|
|
{ token: '/goal ', hint: '[<objective>|clear|edit <objective>|pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
|
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
|
)
|
|
})
|
|
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
|
|
})
|
|
|
|
it('an inserted reference renders as a chip at its placeholder offset', () => {
|
|
const { view, shell } = bench()
|
|
act(() => {
|
|
shell.setDraft('参考 @w1 内容')
|
|
shell.insertReference(
|
|
{ source: 'subagent', ref: 'w1', label: '@w1', clipboardText: '@w1' },
|
|
{ start: 3, end: 6, draftRev: shell.snapshot.draftRev },
|
|
)
|
|
})
|
|
const chip = view.container.querySelector('[data-decoration="chip"]')
|
|
expect(chip?.textContent).toBe('@w1')
|
|
expect(shell.snapshot.occurrences).toHaveLength(1)
|
|
// The draft carries exactly one placeholder char where the token was.
|
|
expect(shell.snapshot.draft).toBe('参考 \uFFFC 内容')
|
|
})
|
|
|
|
it('a lexicon-matched plain token renders the text-ref mark (decision 21)', () => {
|
|
const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
|
|
const { view, shell } = bench({ lexicon })
|
|
act(() => { shell.setDraft('use /fixture-demo now') })
|
|
const mark = view.container.querySelector('[data-decoration="text-ref"]')
|
|
expect(mark?.textContent).toBe('/fixture-demo')
|
|
// Editing the token out of match shape drops the decoration.
|
|
act(() => { shell.setDraft('use /fixture-dem now') })
|
|
expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('insertText (decision 21 scoped event body)', () => {
|
|
it('splices plain text over the span and reports success as true', () => {
|
|
const { shell } = bench({ draft: '/fix' })
|
|
const ok = shell.insertText('/fixture-demo ', { start: 0, end: 4, draftRev: shell.snapshot.draftRev })
|
|
expect(ok).toBe(true)
|
|
expect(shell.snapshot.draft).toBe('/fixture-demo ')
|
|
expect(shell.snapshot.occurrences).toEqual([])
|
|
})
|
|
|
|
it('a stale draftRev refuses whole: false, draft untouched', () => {
|
|
const { shell } = bench({ draft: '/fix' })
|
|
const span = { start: 0, end: 4, draftRev: shell.snapshot.draftRev }
|
|
act(() => { shell.setDraft('/fixX') })
|
|
expect(shell.insertText('/fixture-demo ', span)).toBe(false)
|
|
expect(shell.snapshot.draft).toBe('/fixX')
|
|
})
|
|
})
|
|
|
|
describe('strips and variants', () => {
|
|
it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
|
|
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
|
|
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
|
|
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
|
|
})
|
|
|
|
it('renders the notice strip from the machine notice store', () => {
|
|
const { view, shell } = bench()
|
|
act(() => { shell.notify('error', '命令失败了') })
|
|
expect(view.getByText('命令失败了')).toBeTruthy()
|
|
})
|
|
|
|
it('hero variant adds the hero class and accessory row renders', () => {
|
|
const { view } = bench({ variant: 'hero', accessory: <i data-testid="acc" /> })
|
|
expect(view.getByTestId('acc')).toBeTruthy()
|
|
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
|
|
})
|
|
|
|
it('renders overlay anchor and left/right slot items', () => {
|
|
const { view } = bench({
|
|
overlay: <i data-testid="ov" />,
|
|
leftItems: <i data-testid="li" />,
|
|
rightItems: <i data-testid="ri" />,
|
|
})
|
|
expect(view.getByTestId('ov')).toBeTruthy()
|
|
expect(view.getByTestId('li')).toBeTruthy()
|
|
expect(view.getByTestId('ri')).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('command launcher chrome and control seats', () => {
|
|
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
|
|
const { view, slotCalls } = bench()
|
|
expect(view.getByLabelText('命令')).toBeTruthy()
|
|
// Capability absent (no projection value): the chip renders nothing.
|
|
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
|
// Both seats dispatched, nothing rendered.
|
|
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
|
|
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
|
expect(view.queryByLabelText('Model')).toBeNull()
|
|
})
|
|
|
|
it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
|
|
const toggleCommandMenu = vi.fn()
|
|
const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
|
|
textarea.setSelectionRange(2, 7)
|
|
const launcher = view.getByLabelText('命令')
|
|
expect(launcher.getAttribute('aria-expanded')).toBe('false')
|
|
fireEvent.click(launcher)
|
|
expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 })
|
|
act(() => { menuLauncher.set('command') })
|
|
expect(launcher.getAttribute('aria-expanded')).toBe('true')
|
|
})
|
|
|
|
it('the Access chip renders the projection value and submits /permission on pick', async () => {
|
|
const permissions = {
|
|
options: [
|
|
{ value: 'workspace-write', name: 'workspace-write' },
|
|
{ value: 'danger-full-access', name: 'danger-full-access' },
|
|
],
|
|
currentValue: 'workspace-write',
|
|
}
|
|
const { view } = bench({ permissions })
|
|
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
|
// Title-case display is presentation only; the menu ids stay machine names.
|
|
expect(trigger.textContent).toBe('Workspace Write')
|
|
fireEvent.click(trigger)
|
|
const items = view.getAllByRole('menuitem')
|
|
expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
|
|
fireEvent.click(items[1]!)
|
|
// Optimistic pick + disable until admission resolves (command stub resolves true).
|
|
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
|
|
expect(busy.textContent).toBe('Danger Full Access')
|
|
expect(busy.disabled).toBe(true)
|
|
await act(async () => {})
|
|
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
|
|
})
|
|
|
|
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
|
const { view, slotCalls } = bench({
|
|
disabled: true,
|
|
planEntry: <i data-testid="plan-entry" />,
|
|
modelEntry: <i data-testid="model-entry" />,
|
|
})
|
|
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
|
expect(view.getByTestId('model-entry')).toBeTruthy()
|
|
// The bar hands its chrome disable state to the filling entry.
|
|
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
|
cleanup()
|
|
const live = bench({ running: true })
|
|
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
|
})
|
|
|
|
it('disabled locks the Access chip and command launcher (running does not)', () => {
|
|
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
|
|
const { view } = bench({ disabled: true, permissions })
|
|
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
|
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true)
|
|
cleanup()
|
|
const live = bench({ running: true, permissions })
|
|
expect((live.view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
|
|
})
|
|
})
|