|
@@ -1,11 +1,16 @@
|
|
|
// @vitest-environment jsdom
|
|
// @vitest-environment jsdom
|
|
|
-// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
|
|
|
|
|
-// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
|
|
|
|
|
-// semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock,
|
|
|
|
|
-// decoration backdrop, error banners, status strips, and the focus-keeping mousedown.
|
|
|
|
|
|
|
+// InputBar behavior over the editor + submit-machine wiring: Enter-send
|
|
|
|
|
+// semantics (IME guard, Shift newline, busy Enter policy, Ctrl/Meta steering,
|
|
|
|
|
+// repeat suppression), running semantics (input stays free; continuable
|
|
|
|
|
+// children keep Send beside Stop), the machine pending lock, chip decorators,
|
|
|
|
|
+// error banners, status strips, and the focus-keeping mousedown. Keyboard
|
|
|
|
|
+// gestures dispatch real KeyboardEvents at the contenteditable (the Lexical
|
|
|
|
|
+// root listener routes them through the keymap commands); draft writes drive
|
|
|
|
|
+// the shell (jsdom's beforeinput lacks the ranges Lexical needs).
|
|
|
|
|
|
|
|
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
|
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
|
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
|
|
|
|
+import { $getRoot, $isTextNode } from 'lexical'
|
|
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
import {
|
|
import {
|
|
|
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
|
|
createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS,
|
|
@@ -15,6 +20,7 @@ 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 type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
|
import { SessionInputShell } from '../src/client/input/facade.ts'
|
|
import { SessionInputShell } from '../src/client/input/facade.ts'
|
|
|
|
|
+import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts'
|
|
|
import type {
|
|
import type {
|
|
|
ComposerAttachment, ComposerAttachmentsOwnerProps,
|
|
ComposerAttachment, ComposerAttachmentsOwnerProps,
|
|
|
} from '../src/client/contract/slots.ts'
|
|
} from '../src/client/contract/slots.ts'
|
|
@@ -32,11 +38,6 @@ afterEach(cleanup)
|
|
|
const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
|
|
const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
|
|
|
Range.prototype.getBoundingClientRect = ZERO_RECT
|
|
Range.prototype.getBoundingClientRect = ZERO_RECT
|
|
|
|
|
|
|
|
-// Read through the descriptor so the native method is never referenced unbound;
|
|
|
|
|
-// the reveal case below wraps it to record what it was asked to measure.
|
|
|
|
|
-const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
|
|
|
|
|
- .value as (this: Range, node: Node, offset: number) => void
|
|
|
|
|
-
|
|
|
|
|
const SCTX = {} as ClientContext
|
|
const SCTX = {} as ClientContext
|
|
|
const SID = 's1' as SessionId
|
|
const SID = 's1' as SessionId
|
|
|
|
|
|
|
@@ -134,6 +135,7 @@ function bench(over?: BenchOptions) {
|
|
|
...(lex !== undefined
|
|
...(lex !== undefined
|
|
|
? {
|
|
? {
|
|
|
inputTriggers: (() => ({
|
|
inputTriggers: (() => ({
|
|
|
|
|
+ track: () => {},
|
|
|
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
|
|
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
|
|
|
})) as unknown as NonNullable<ShellDeps['inputTriggers']>,
|
|
})) as unknown as NonNullable<ShellDeps['inputTriggers']>,
|
|
|
}
|
|
}
|
|
@@ -201,7 +203,7 @@ function bench(over?: BenchOptions) {
|
|
|
...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
|
|
...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
|
|
|
}
|
|
}
|
|
|
const view = render(<InputBar {...props} />)
|
|
const view = render(<InputBar {...props} />)
|
|
|
- const textarea = view.container.querySelector('textarea')!
|
|
|
|
|
|
|
+ const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
|
|
|
const primaryStops = over?.running === true && over.subagent === undefined
|
|
const primaryStops = over?.running === true && over.subagent === undefined
|
|
|
const button = view.container.querySelector<HTMLButtonElement>(
|
|
const button = view.container.querySelector<HTMLButtonElement>(
|
|
|
`button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
|
|
`button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
|
|
@@ -211,6 +213,8 @@ function bench(over?: BenchOptions) {
|
|
|
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
|
|
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
|
|
|
menuLauncher,
|
|
menuLauncher,
|
|
|
steerQueue: over?.steerQueue,
|
|
steerQueue: over?.steerQueue,
|
|
|
|
|
+ get placeholder() { return placeholderOf(view.container) },
|
|
|
|
|
+ get inputDisabled() { return textarea.getAttribute('aria-disabled') === 'true' },
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -222,8 +226,23 @@ function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]):
|
|
|
throw new Error('attachment slot was not rendered')
|
|
throw new Error('attachment slot was not rendered')
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** The state's placeholder copy (the textarea.placeholder equivalent; the visible layer renders it only while empty). */
|
|
|
|
|
+function placeholderOf(container: HTMLElement): string {
|
|
|
|
|
+ return container.querySelector('[data-composer-input]')?.getAttribute('data-placeholder') ?? ''
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Whether the composer surface accepts edits right now (setEditable's DOM face). */
|
|
|
|
|
+function editableOf(input: HTMLElement): boolean {
|
|
|
|
|
+ return input.getAttribute('contenteditable') === 'true'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Write the draft through the shell inside act (the seed path; caret lands at the end). */
|
|
|
|
|
+function writeDraft(shell: SessionInputShell, text: string): void {
|
|
|
|
|
+ act(() => { shell.setDraft(text) })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
describe('image draft rail', () => {
|
|
describe('image draft rail', () => {
|
|
|
- it('collects clipboard files while preserving text from a mixed paste', () => {
|
|
|
|
|
|
|
+ it('collects clipboard files while preserving text from a mixed paste', async () => {
|
|
|
const addImages = vi.fn(() => null)
|
|
const addImages = vi.fn(() => null)
|
|
|
const { textarea, shell } = bench({ addImages })
|
|
const { textarea, shell } = bench({ addImages })
|
|
|
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
|
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
|
@@ -237,7 +256,8 @@ describe('image draft rail', () => {
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
expect(addImages).toHaveBeenCalledWith([image])
|
|
expect(addImages).toHaveBeenCalledWith([image])
|
|
|
- expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
|
|
|
|
|
|
+ // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away.
|
|
|
|
|
+ await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') })
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
|
|
it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
|
|
@@ -404,14 +424,14 @@ describe('image draft rail', () => {
|
|
|
|
|
|
|
|
describe('Enter semantics', () => {
|
|
describe('Enter semantics', () => {
|
|
|
it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
|
|
it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
|
|
|
- const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
|
|
|
|
|
- expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
|
|
|
|
|
|
|
+ const { placeholder } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
|
|
|
|
|
+ expect(placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
|
|
it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
|
|
|
- expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息')
|
|
|
|
|
- expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息')
|
|
|
|
|
- expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息')
|
|
|
|
|
|
|
+ expect(bench({ running: true }).placeholder).toBe('给智能体发消息')
|
|
|
|
|
+ expect(bench({ queue: [row('q-1')] }).placeholder).toBe('给智能体发消息')
|
|
|
|
|
+ expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('给智能体发消息')
|
|
|
expect(bench({
|
|
expect(bench({
|
|
|
running: true,
|
|
running: true,
|
|
|
queue: [row('q-1')],
|
|
queue: [row('q-1')],
|
|
@@ -419,26 +439,26 @@ describe('Enter semantics', () => {
|
|
|
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
|
|
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
|
|
|
parentAvailable: true,
|
|
parentAvailable: true,
|
|
|
},
|
|
},
|
|
|
- }).textarea.placeholder).toBe('给智能体发消息')
|
|
|
|
|
|
|
+ }).placeholder).toBe('给智能体发消息')
|
|
|
expect(bench({
|
|
expect(bench({
|
|
|
running: true,
|
|
running: true,
|
|
|
queue: [row('q-1')],
|
|
queue: [row('q-1')],
|
|
|
placeholder: '上层指定提示',
|
|
placeholder: '上层指定提示',
|
|
|
- }).textarea.placeholder).toBe('上层指定提示')
|
|
|
|
|
|
|
+ }).placeholder).toBe('上层指定提示')
|
|
|
// The command menu owns Enter while open: neither the hint nor the
|
|
// The command menu owns Enter while open: neither the hint nor the
|
|
|
// gesture may claim the chord.
|
|
// gesture may claim the chord.
|
|
|
expect(bench({
|
|
expect(bench({
|
|
|
running: true,
|
|
running: true,
|
|
|
queue: [row('q-1')],
|
|
queue: [row('q-1')],
|
|
|
commandMenuOpen: true,
|
|
commandMenuOpen: true,
|
|
|
- }).textarea.placeholder).toBe('给智能体发消息')
|
|
|
|
|
|
|
+ }).placeholder).toBe('给智能体发消息')
|
|
|
// The steer hint intentionally outranks the plan placeholder: while it
|
|
// The steer hint intentionally outranks the plan placeholder: while it
|
|
|
// shows, the whole-queue gesture is genuinely available in plan mode.
|
|
// shows, the whole-queue gesture is genuinely available in plan mode.
|
|
|
expect(bench({
|
|
expect(bench({
|
|
|
running: true,
|
|
running: true,
|
|
|
queue: [row('q-1')],
|
|
queue: [row('q-1')],
|
|
|
plan: { active: true, pending: false },
|
|
plan: { active: true, pending: false },
|
|
|
- }).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
|
|
|
|
|
|
|
+ }).placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('an open command menu withholds the whole-queue steering gesture', () => {
|
|
it('an open command menu withholds the whole-queue steering gesture', () => {
|
|
@@ -570,11 +590,26 @@ describe('Enter semantics', () => {
|
|
|
|
|
|
|
|
it('platform undo/redo chords route to the machine, never the browser stack', () => {
|
|
it('platform undo/redo chords route to the machine, never the browser stack', () => {
|
|
|
const { textarea, shell } = bench({ draft: '' })
|
|
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 })
|
|
|
|
|
|
|
+ vi.useFakeTimers()
|
|
|
|
|
+ onTestFinished(() => { vi.useRealTimers() })
|
|
|
|
|
+ writeDraft(shell, 'first')
|
|
|
|
|
+ vi.advanceTimersByTime(1100) // beyond the history merge window: a separate undo step
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ shell.editor.update(() => {
|
|
|
|
|
+ const first = $getRoot().getAllTextNodes()[0]
|
|
|
|
|
+ if ($isTextNode(first)) first.select(5, 5).insertText(' second')
|
|
|
|
|
+ }, { discrete: true })
|
|
|
|
|
+ })
|
|
|
|
|
+ expect(shell.snapshot.draft).toBe('first second')
|
|
|
|
|
+ // Lexical's chord detection reads keyCode (90 = z); a history restore
|
|
|
|
|
+ // commits on the next flush (setEditorState defers inside the command
|
|
|
|
|
+ // update).
|
|
|
|
|
+ const flush = (): void => { act(() => { shell.editor.update(() => {}, { discrete: true }) }) }
|
|
|
|
|
+ fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true })
|
|
|
|
|
+ flush()
|
|
|
|
|
+ expect(shell.snapshot.draft).toBe('first')
|
|
|
|
|
+ fireEvent.keyDown(textarea, { key: 'z', keyCode: 90, ctrlKey: true, shiftKey: true })
|
|
|
|
|
+ flush()
|
|
|
expect(shell.snapshot.draft).toBe('first second')
|
|
expect(shell.snapshot.draft).toBe('first second')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
@@ -602,9 +637,9 @@ describe('Enter semantics', () => {
|
|
|
|
|
|
|
|
describe('running and lock semantics', () => {
|
|
describe('running and lock semantics', () => {
|
|
|
it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
|
|
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)
|
|
|
|
|
- fireEvent.change(textarea, { target: { value: '排队消息2' } })
|
|
|
|
|
|
|
+ const { textarea, button, stop, sink, shell } = bench({ running: true, draft: '排队消息' })
|
|
|
|
|
+ expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
|
|
|
|
|
+ writeDraft(shell, '排队消息2')
|
|
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
|
expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal))
|
|
expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal))
|
|
|
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
|
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
|
@@ -643,7 +678,7 @@ describe('running and lock semantics', () => {
|
|
|
})
|
|
})
|
|
|
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
|
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
|
|
expect(interruptButton).not.toBeNull()
|
|
expect(interruptButton).not.toBeNull()
|
|
|
- expect(textarea.disabled).toBe(false)
|
|
|
|
|
|
|
+ expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
|
|
|
fireEvent.click(button)
|
|
fireEvent.click(button)
|
|
|
expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
|
|
expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
|
|
|
fireEvent.click(interruptButton!)
|
|
fireEvent.click(interruptButton!)
|
|
@@ -663,8 +698,8 @@ describe('running and lock semantics', () => {
|
|
|
parentAvailable: false,
|
|
parentAvailable: false,
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
- expect(textarea.disabled).toBe(true)
|
|
|
|
|
- expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
|
|
|
|
|
|
|
+ expect(textarea.getAttribute('aria-disabled')).toBe('true')
|
|
|
|
|
+ expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
|
|
|
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
|
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
|
|
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
|
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
|
|
expect(button.disabled).toBe(true)
|
|
expect(button.disabled).toBe(true)
|
|
@@ -711,8 +746,8 @@ describe('running and lock semantics', () => {
|
|
|
|
|
|
|
|
it('disabled (session removed) locks the textarea and chrome', () => {
|
|
it('disabled (session removed) locks the textarea and chrome', () => {
|
|
|
const { textarea, view } = bench({ disabled: true })
|
|
const { textarea, view } = bench({ disabled: true })
|
|
|
- expect(textarea.disabled).toBe(true)
|
|
|
|
|
- expect(textarea.placeholder).toBe('会话不可用')
|
|
|
|
|
|
|
+ expect(textarea.getAttribute('aria-disabled')).toBe('true')
|
|
|
|
|
+ expect(placeholderOf(view.container)).toBe('会话不可用')
|
|
|
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
|
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
@@ -724,21 +759,22 @@ describe('running and lock semantics', () => {
|
|
|
expect(empty.button.disabled).toBe(true)
|
|
expect(empty.button.disabled).toBe(true)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
|
|
|
|
|
|
|
+ it('unlock refocuses the surface; mousedown on the button keeps focus', () => {
|
|
|
const first = bench({ disabled: true, draft: 'x' })
|
|
const first = bench({ disabled: true, draft: 'x' })
|
|
|
|
|
+ const textarea = first.view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
|
|
|
|
|
+ const focused: (boolean | undefined)[] = []
|
|
|
|
|
+ textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
|
|
|
act(() => { first.session.set(snapshotOf({ removed: false })) })
|
|
act(() => { first.session.set(snapshotOf({ removed: false })) })
|
|
|
- const textarea = first.view.container.querySelector('textarea')!
|
|
|
|
|
- expect(document.activeElement).toBe(textarea)
|
|
|
|
|
- textarea.blur()
|
|
|
|
|
|
|
+ expect(focused).toEqual([true])
|
|
|
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
|
|
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="发送消息"]')!)
|
|
|
- expect(document.activeElement).toBe(textarea)
|
|
|
|
|
|
|
+ expect(focused).toEqual([true, true])
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('typing forwards through the machine (draft state echoes back)', () => {
|
|
|
|
|
- const { textarea, wiring } = bench()
|
|
|
|
|
- fireEvent.change(textarea, { target: { value: 'typed' } })
|
|
|
|
|
|
|
+ it('a programmatic draft write echoes back through the state and the editor DOM', () => {
|
|
|
|
|
+ const { textarea, wiring, shell } = bench()
|
|
|
|
|
+ writeDraft(shell, 'typed')
|
|
|
expect(wiring.state.getSnapshot().draft).toBe('typed')
|
|
expect(wiring.state.getSnapshot().draft).toBe('typed')
|
|
|
- expect((textarea).value).toBe('typed')
|
|
|
|
|
|
|
+ expect(textarea.textContent).toBe('typed')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('wheel over a non-overflowing draft forwards to the conversation host', () => {
|
|
it('wheel over a non-overflowing draft forwards to the conversation host', () => {
|
|
@@ -792,270 +828,45 @@ describe('running and lock semantics', () => {
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('the caret layer and the glyph layer ride one scrollport', () => {
|
|
|
|
|
- const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
|
|
|
|
|
|
|
+ it('one editable surface rides the scrollport (no mirror or backdrop layers)', () => {
|
|
|
|
|
+ const { view, textarea } = bench({ draft: 'line\n'.repeat(40).trimEnd() })
|
|
|
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
|
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
|
|
- const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
|
|
|
|
- // The caret is the textarea's and every visible glyph is the backdrop's, so
|
|
|
|
|
- // one box has to carry both or an offset can exist in one and not the other.
|
|
|
|
|
- // jsdom has no layout and loads no stylesheet — which box scrolls is the
|
|
|
|
|
- // browser scenario's to assert; what is checkable here is that the
|
|
|
|
|
- // scrollport element holds both layers.
|
|
|
|
|
expect(scroll.contains(textarea)).toBe(true)
|
|
expect(scroll.contains(textarea)).toBe(true)
|
|
|
- expect(scroll.contains(backdrop)).toBe(true)
|
|
|
|
|
- // The glyph layer carries the draft and nothing else — no height padding
|
|
|
|
|
- // to a second box's scroll extent.
|
|
|
|
|
- expect(backdrop.textContent).toBe('line\n'.repeat(40))
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- it('repairs Safari native overflow after the mirror shrinks the draft', () => {
|
|
|
|
|
- const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
|
|
|
|
|
- const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
|
|
|
|
- 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
|
|
|
|
|
- )
|
|
|
|
|
- onTestFinished(() => {
|
|
|
|
|
- vendor.mockRestore()
|
|
|
|
|
- userAgent.mockRestore()
|
|
|
|
|
- })
|
|
|
|
|
- const { textarea } = bench({ draft: 'two wrapped lines' })
|
|
|
|
|
- const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
|
|
|
|
|
- let inputRepaired = false
|
|
|
|
|
- let scrollportRepaired = false
|
|
|
|
|
- const inputLayouts: string[] = []
|
|
|
|
|
- const scrollportLayouts: string[] = []
|
|
|
|
|
- Object.defineProperty(textarea, 'clientHeight', {
|
|
|
|
|
- configurable: true,
|
|
|
|
|
- get: () => textarea.style.height === '29px' ? 29 : 28,
|
|
|
|
|
- })
|
|
|
|
|
- Object.defineProperty(textarea, 'scrollHeight', {
|
|
|
|
|
- configurable: true,
|
|
|
|
|
- get: () => inputRepaired ? 28 : 52,
|
|
|
|
|
- })
|
|
|
|
|
- Object.defineProperty(textarea, 'offsetHeight', {
|
|
|
|
|
- configurable: true,
|
|
|
|
|
- get: () => {
|
|
|
|
|
- inputLayouts.push(textarea.style.height)
|
|
|
|
|
- if (textarea.style.height === '') inputRepaired = true
|
|
|
|
|
- return textarea.clientHeight
|
|
|
|
|
- },
|
|
|
|
|
- })
|
|
|
|
|
- Object.defineProperty(scrollport, 'clientHeight', {
|
|
|
|
|
- configurable: true,
|
|
|
|
|
- get: () => {
|
|
|
|
|
- if (scrollport.style.height === '53px') return 53
|
|
|
|
|
- if (inputRepaired && !scrollportRepaired) return 52
|
|
|
|
|
- return 28
|
|
|
|
|
- },
|
|
|
|
|
- })
|
|
|
|
|
- Object.defineProperty(scrollport, 'offsetHeight', {
|
|
|
|
|
- configurable: true,
|
|
|
|
|
- get: () => {
|
|
|
|
|
- scrollportLayouts.push(scrollport.style.height)
|
|
|
|
|
- if (scrollport.style.height === '') scrollportRepaired = true
|
|
|
|
|
- return scrollport.clientHeight
|
|
|
|
|
- },
|
|
|
|
|
- })
|
|
|
|
|
- textarea.setSelectionRange(5, 5)
|
|
|
|
|
-
|
|
|
|
|
- fireEvent.change(textarea, { target: { value: 'one line' } })
|
|
|
|
|
-
|
|
|
|
|
- expect(inputLayouts).toEqual(['29px', ''])
|
|
|
|
|
- expect(scrollportLayouts).toEqual(['53px', ''])
|
|
|
|
|
- expect(textarea.style.height).toBe('')
|
|
|
|
|
- expect(scrollport.style.height).toBe('')
|
|
|
|
|
- expect(textarea.scrollHeight).toBe(textarea.clientHeight)
|
|
|
|
|
- expect(scrollport.clientHeight).toBe(28)
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- it('does not force the Safari recovery for another iOS browser', () => {
|
|
|
|
|
- const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
|
|
|
|
|
- const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
|
|
|
|
- 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1',
|
|
|
|
|
- )
|
|
|
|
|
- onTestFinished(() => {
|
|
|
|
|
- vendor.mockRestore()
|
|
|
|
|
- userAgent.mockRestore()
|
|
|
|
|
- })
|
|
|
|
|
- const { textarea } = bench({ draft: 'two wrapped lines' })
|
|
|
|
|
- const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
|
|
|
|
|
- Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 28 })
|
|
|
|
|
- Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 52 })
|
|
|
|
|
- Object.defineProperty(textarea, 'offsetHeight', {
|
|
|
|
|
- configurable: true,
|
|
|
|
|
- get: () => { throw new Error('non-Safari browser must not force textarea layout') },
|
|
|
|
|
- })
|
|
|
|
|
- Object.defineProperty(scrollport, 'offsetHeight', {
|
|
|
|
|
- configurable: true,
|
|
|
|
|
- get: () => { throw new Error('non-Safari browser must not force scrollport layout') },
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- fireEvent.change(textarea, { target: { value: 'one line' } })
|
|
|
|
|
-
|
|
|
|
|
- expect(scrollport.style.height).toBe('')
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- it('does not read Safari layout while a native edit grows the draft', () => {
|
|
|
|
|
- const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
|
|
|
|
|
- const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
|
|
|
|
- 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
|
|
|
|
|
- )
|
|
|
|
|
- onTestFinished(() => {
|
|
|
|
|
- vendor.mockRestore()
|
|
|
|
|
- userAgent.mockRestore()
|
|
|
|
|
- })
|
|
|
|
|
- const { textarea, shell } = bench({ draft: 'one line' })
|
|
|
|
|
- Object.defineProperty(textarea, 'clientHeight', {
|
|
|
|
|
- configurable: true,
|
|
|
|
|
- get: () => { throw new Error('growing Safari input must not read layout') },
|
|
|
|
|
- })
|
|
|
|
|
- Object.defineProperty(textarea, 'scrollHeight', {
|
|
|
|
|
- configurable: true,
|
|
|
|
|
- get: () => { throw new Error('growing Safari input must not read layout') },
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- fireEvent.change(textarea, { target: { value: 'one line grows' } })
|
|
|
|
|
-
|
|
|
|
|
- expect(shell.snapshot.draft).toBe('one line grows')
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- it('an edit the composer performs itself scrolls the caret back into view', async () => {
|
|
|
|
|
- // Paste and cut suppress the native edit, so no engine reveals the caret
|
|
|
|
|
- // for them. jsdom has no layout: the rects are stubbed,
|
|
|
|
|
- // and what is asserted is the arithmetic — minimal scroll, in both
|
|
|
|
|
- // directions, and nothing at all for a caret already inside the box.
|
|
|
|
|
- const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
|
|
|
|
|
- const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
|
|
|
|
- const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
|
|
|
|
- expect(mirror.firstChild).toBeInstanceOf(Text)
|
|
|
|
|
- scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
|
|
|
|
- // jsdom reports scrollHeight === clientHeight for every element, which is
|
|
|
|
|
- // the composer's own "nothing to reveal" case; a scrollable box is what
|
|
|
|
|
- // puts the reveal on the table at all.
|
|
|
|
|
- Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
|
|
|
|
- Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
|
|
|
|
- Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
|
|
|
|
- onTestFinished(() => {
|
|
|
|
|
- Range.prototype.getBoundingClientRect = ZERO_RECT
|
|
|
|
|
- Range.prototype.setStart = NATIVE_SET_START
|
|
|
|
|
- })
|
|
|
|
|
- // Which layer the caret is measured against, and at which index: the stub
|
|
|
|
|
- // records `setStart` so a helper that measured the backdrop instead, or
|
|
|
|
|
- // always collapsed at 0, fails here rather than only in the browser lane.
|
|
|
|
|
- let measured: { node: Node; offset: number } | null = null
|
|
|
|
|
- Range.prototype.setStart = function setStart(node: Node, offset: number): void {
|
|
|
|
|
- measured = { node, offset }
|
|
|
|
|
- NATIVE_SET_START.call(this, node, offset)
|
|
|
|
|
- }
|
|
|
|
|
- const caretAt = (top: number): void => {
|
|
|
|
|
- Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
|
|
|
|
|
- }
|
|
|
|
|
- const settle = async (): Promise<void> => {
|
|
|
|
|
- await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
|
|
|
|
|
- }
|
|
|
|
|
- // Pasted text lands below the fold: scroll down by exactly the overshoot.
|
|
|
|
|
- caretAt(500)
|
|
|
|
|
- fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } })
|
|
|
|
|
- await settle()
|
|
|
|
|
- expect(scroll.scrollTop).toBe(88) // 524 - 436
|
|
|
|
|
- // Measured on the mirror's own text, at the index the paste left the caret
|
|
|
|
|
- // (an empty draft's selection start, 0, plus the pasted length).
|
|
|
|
|
- expect(measured!.node).toBe(mirror.firstChild)
|
|
|
|
|
- expect(measured!.offset).toBe('pasted'.length)
|
|
|
|
|
- // A caret already inside the box does not move it.
|
|
|
|
|
- caretAt(200)
|
|
|
|
|
- fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } })
|
|
|
|
|
- await settle()
|
|
|
|
|
- expect(scroll.scrollTop).toBe(88)
|
|
|
|
|
- // Above the fold (a cut can leave it there): scroll back up.
|
|
|
|
|
- caretAt(60)
|
|
|
|
|
- fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } })
|
|
|
|
|
- await settle()
|
|
|
|
|
- expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
|
|
|
|
|
- // A caret straight after a newline has nothing on its line to measure, so
|
|
|
|
|
- // the newline it just left is measured instead and one line is added.
|
|
|
|
|
- // chromium reports no client rects at all for the collapsed position.
|
|
|
|
|
- mirror.style.lineHeight = '24px'
|
|
|
|
|
- caretAt(500)
|
|
|
|
|
- fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } })
|
|
|
|
|
- await settle()
|
|
|
|
|
- // The four pastes accumulate at the draft's head, so the caret is at the
|
|
|
|
|
- // end of what they inserted — and the measured index is the newline before it.
|
|
|
|
|
- expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
|
|
|
|
|
- expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
|
|
|
|
|
- // The composer DOM is reused across sessions, so the previous session's
|
|
|
|
|
- // offset survives while the value swap puts the caret at the new draft's
|
|
|
|
|
- // end. `preventScroll` keeps the browser from revealing it through the
|
|
|
|
|
- // conversation scrollport, which leaves the reveal to the effect itself.
|
|
|
|
|
- const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
|
|
|
|
|
- const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
|
|
|
|
- const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
|
|
|
|
- onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
|
|
|
|
|
- scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
|
|
|
|
- Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
|
|
|
|
- Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
|
|
|
|
- Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
|
|
|
|
- Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
|
|
|
|
|
- // The draft ends in a newline, so the reveal takes the after-newline path
|
|
|
|
|
- // and needs a resolvable line-height (jsdom computes `normal`).
|
|
|
|
|
- mirror.style.lineHeight = '24px'
|
|
|
|
|
- // Which index the effect reveals at, not merely that it scrolled: a
|
|
|
|
|
- // revealCaret(0) would land the same offset without this.
|
|
|
|
|
- onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
|
|
|
|
|
- let measured: { node: Node; offset: number } | null = null
|
|
|
|
|
- Range.prototype.setStart = function setStart(node: Node, offset: number): void {
|
|
|
|
|
- measured = { node, offset }
|
|
|
|
|
- NATIVE_SET_START.call(this, node, offset)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // The three-layer stack is gone: the editable surface owns glyphs, caret,
|
|
|
|
|
+ // and height at once.
|
|
|
|
|
+ expect(view.container.querySelector('[data-input-backdrop]')).toBeNull()
|
|
|
|
|
+ expect(view.container.querySelector('[data-input-mirror]')).toBeNull()
|
|
|
|
|
+ // DOM textContent joins paragraphs without separators; the projection
|
|
|
|
|
+ // carries the newlines.
|
|
|
|
|
+ expect(textarea.textContent).toBe('line'.repeat(40))
|
|
|
|
|
+ expect(bench({ draft: 'a\nb' }).shell.snapshot.draft).toBe('a\nb')
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('a session switch refocuses the editable surface with preventScroll', () => {
|
|
|
|
|
+ const { view, textarea, props } = bench({ draft: 'line one' })
|
|
|
const focused: (boolean | undefined)[] = []
|
|
const focused: (boolean | undefined)[] = []
|
|
|
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
|
|
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
|
|
|
- textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
|
|
|
|
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
|
|
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
|
|
|
expect(focused).toEqual([true])
|
|
expect(focused).toEqual([true])
|
|
|
- expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
|
|
|
|
|
- // The draft ends in a newline, so the rule measures that newline: the
|
|
|
|
|
- // caret's own index is the mirror text's length minus its sentinel.
|
|
|
|
|
- expect(measured!.node).toBe(mirror.firstChild)
|
|
|
|
|
- expect(measured!.offset).toBe(textarea.value.length - 1)
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- it('a persisted draft adopted after mount gets its caret revealed too', () => {
|
|
|
|
|
- // ConversationSession seeds the stored draft in its own mount effect, which
|
|
|
|
|
- // runs after this component's: the first reveal measures an empty mirror,
|
|
|
|
|
- // so the draft's arrival has to run it again without reclaiming focus.
|
|
|
|
|
- const { view, textarea, shell } = bench()
|
|
|
|
|
- const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
|
|
|
|
- const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
|
|
|
|
- // The restored draft ends in a newline, so the reveal takes the
|
|
|
|
|
- // after-newline path and needs a resolvable line-height (jsdom says `normal`).
|
|
|
|
|
- mirror.style.lineHeight = '24px'
|
|
|
|
|
- onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
|
|
|
|
|
- scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
|
|
|
|
|
- Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
|
|
|
|
|
- Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
|
|
|
|
- Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
|
|
|
|
- Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
|
|
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('a persisted draft adopted after mount does not steal focus from another control', () => {
|
|
|
|
|
+ const { shell } = bench()
|
|
|
const other = document.createElement('input')
|
|
const other = document.createElement('input')
|
|
|
document.body.appendChild(other)
|
|
document.body.appendChild(other)
|
|
|
onTestFinished(() => { other.remove() })
|
|
onTestFinished(() => { other.remove() })
|
|
|
other.focus()
|
|
other.focus()
|
|
|
- expect(scroll.scrollTop).toBe(0)
|
|
|
|
|
act(() => { shell.setDraft('restored\n'.repeat(40)) })
|
|
act(() => { shell.setDraft('restored\n'.repeat(40)) })
|
|
|
expect(document.activeElement).toBe(other)
|
|
expect(document.activeElement).toBe(other)
|
|
|
- // The caret the machine left at the draft's end, revealed once the draft exists.
|
|
|
|
|
- expect(textarea.selectionStart).toBe(textarea.value.length)
|
|
|
|
|
- expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
|
|
|
|
|
|
|
+ expect(shell.snapshot.draft).toBe('restored\n'.repeat(40))
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
|
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
|
|
- const { textarea } = bench({ disabled: true })
|
|
|
|
|
- expect(textarea.placeholder).toBe('会话不可用')
|
|
|
|
|
|
|
+ expect(bench({ disabled: true }).placeholder).toBe('会话不可用')
|
|
|
const live = bench()
|
|
const live = bench()
|
|
|
- expect(live.textarea.placeholder).toBe('给智能体发消息')
|
|
|
|
|
|
|
+ expect(live.placeholder).toBe('给智能体发消息')
|
|
|
const custom = bench({ placeholder: 'Custom placeholder' })
|
|
const custom = bench({ placeholder: 'Custom placeholder' })
|
|
|
- expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
|
|
|
|
|
|
+ expect(custom.placeholder).toBe('Custom placeholder')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('the inert textarea opens the Workspace picker by pointer or keyboard', () => {
|
|
it('the inert textarea opens the Workspace picker by pointer or keyboard', () => {
|
|
@@ -1066,8 +877,8 @@ describe('running and lock semantics', () => {
|
|
|
onRequestWorkspace,
|
|
onRequestWorkspace,
|
|
|
placeholder: '选择一个工作区开始',
|
|
placeholder: '选择一个工作区开始',
|
|
|
})
|
|
})
|
|
|
- expect(textarea.disabled).toBe(false)
|
|
|
|
|
- expect(textarea.readOnly).toBe(true)
|
|
|
|
|
|
|
+ expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
|
|
|
|
|
+ expect(editableOf(textarea)).toBe(false)
|
|
|
expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
|
|
expect(textarea.getAttribute('aria-haspopup')).toBe('menu')
|
|
|
expect(textarea.getAttribute('aria-expanded')).toBe('false')
|
|
expect(textarea.getAttribute('aria-expanded')).toBe('false')
|
|
|
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
|
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
|
@@ -1094,16 +905,16 @@ describe('running and lock semantics', () => {
|
|
|
|
|
|
|
|
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
|
|
it('the plan projection swaps the placeholder while its effective target is plan mode', () => {
|
|
|
const active = bench({ plan: { active: true, pending: false } })
|
|
const active = bench({ plan: { active: true, pending: false } })
|
|
|
- expect(active.textarea.placeholder).toBe('描述你的任务以生成计划')
|
|
|
|
|
|
|
+ expect(active.placeholder).toBe('描述你的任务以生成计划')
|
|
|
// /plan just ran: pending entry already reads as the plan target.
|
|
// /plan just ran: pending entry already reads as the plan target.
|
|
|
const entering = bench({ plan: { active: false, pending: true } })
|
|
const entering = bench({ plan: { active: false, pending: true } })
|
|
|
- expect(entering.textarea.placeholder).toBe('描述你的任务以生成计划')
|
|
|
|
|
|
|
+ expect(entering.placeholder).toBe('描述你的任务以生成计划')
|
|
|
// Pending exit: target is default again.
|
|
// Pending exit: target is default again.
|
|
|
const leaving = bench({ plan: { active: true, pending: true } })
|
|
const leaving = bench({ plan: { active: true, pending: true } })
|
|
|
- expect(leaving.textarea.placeholder).toBe('给智能体发消息')
|
|
|
|
|
|
|
+ expect(leaving.placeholder).toBe('给智能体发消息')
|
|
|
// Owner placeholder outranks the plan swap.
|
|
// Owner placeholder outranks the plan swap.
|
|
|
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
|
|
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
|
|
|
- expect(custom.textarea.placeholder).toBe('Custom placeholder')
|
|
|
|
|
|
|
+ expect(custom.placeholder).toBe('Custom placeholder')
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
|
|
|
|
@@ -1123,34 +934,40 @@ describe('machine pending lock', () => {
|
|
|
shell.submit()
|
|
shell.submit()
|
|
|
})
|
|
})
|
|
|
expect(shell.snapshot.phase).toBe('submitting')
|
|
expect(shell.snapshot.phase).toBe('submitting')
|
|
|
- const textarea = view.container.querySelector('textarea')!
|
|
|
|
|
- expect(textarea.readOnly).toBe(true)
|
|
|
|
|
|
|
+ const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
|
|
|
|
|
+ expect(editableOf(textarea)).toBe(false)
|
|
|
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
|
|
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="发送消息"]')!.disabled).toBe(true)
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
describe('decorations', () => {
|
|
describe('decorations', () => {
|
|
|
- it('claimed token renders the mirror highlight and the blank-args hint', () => {
|
|
|
|
|
|
|
+ /** The claim-token styled leaf (the transform's inline warn color). */
|
|
|
|
|
+ function tokenSpanOf(container: HTMLElement): HTMLElement | null {
|
|
|
|
|
+ return container.querySelector('[data-lexical-text][style*="warn-label"]')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ it('claimed token styles the leading leaf and sets the blank-args hint variable', () => {
|
|
|
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
|
|
// Dictionary-less stub: an unmatched hint key keeps the machine's raw hint.
|
|
|
- const { view, shell } = bench({ t: makeTranslate({}) })
|
|
|
|
|
|
|
+ const { view, shell, textarea } = bench({ t: makeTranslate({}) })
|
|
|
act(() => {
|
|
act(() => {
|
|
|
shell.setDraft('/goal ')
|
|
shell.setDraft('/goal ')
|
|
|
shell.beginCommand(
|
|
shell.beginCommand(
|
|
|
{ token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
|
{ token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
|
|
|
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
|
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
|
|
)
|
|
)
|
|
|
|
|
+ shell.editor.update(() => {}, { discrete: true }) // flush the queued decoration refresh
|
|
|
})
|
|
})
|
|
|
- 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.
|
|
|
|
|
|
|
+ expect(tokenSpanOf(view.container)?.textContent).toBe('/goal ')
|
|
|
|
|
+ expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('目标内容'))
|
|
|
|
|
+ // Args typed: the hint disappears, the token style stays.
|
|
|
act(() => { shell.setDraft('/goal 发布') })
|
|
act(() => { shell.setDraft('/goal 发布') })
|
|
|
- expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
|
|
|
|
|
- expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
|
|
|
|
|
|
|
+ act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
|
|
|
|
|
+ expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
|
|
|
|
|
+ expect(tokenSpanOf(view.container)).not.toBeNull()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
|
|
it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => {
|
|
|
- const { view, shell } = bench()
|
|
|
|
|
|
|
+ const { shell, textarea } = bench()
|
|
|
act(() => {
|
|
act(() => {
|
|
|
shell.setDraft('/goal ')
|
|
shell.setDraft('/goal ')
|
|
|
shell.beginCommand(
|
|
shell.beginCommand(
|
|
@@ -1158,10 +975,10 @@ describe('decorations', () => {
|
|
|
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
|
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
|
|
)
|
|
)
|
|
|
})
|
|
})
|
|
|
- expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
|
|
|
|
|
|
|
+ expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('an inserted reference decorates its complete inline display range', () => {
|
|
|
|
|
|
|
+ it('an inserted reference renders a real chip capsule with its icon and label', () => {
|
|
|
const { view, shell } = bench()
|
|
const { view, shell } = bench()
|
|
|
const reference = {
|
|
const reference = {
|
|
|
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
|
|
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
|
|
@@ -1173,115 +990,115 @@ describe('decorations', () => {
|
|
|
{ start: 3, end: 6, draftRev: shell.snapshot.draftRev },
|
|
{ start: 3, end: 6, draftRev: shell.snapshot.draftRev },
|
|
|
)
|
|
)
|
|
|
})
|
|
})
|
|
|
- const chip = view.container.querySelector('[data-decoration="chip"]')
|
|
|
|
|
- expect(chip?.textContent).toBe('@会话一')
|
|
|
|
|
- expect(chip?.getAttribute('data-reference-appearance')).toBe('session')
|
|
|
|
|
|
|
+ const chip = view.container.querySelector('[data-composer-chip]')
|
|
|
|
|
+ expect(chip?.textContent).toBe('会话一')
|
|
|
expect(chip?.querySelector('svg')).not.toBeNull()
|
|
expect(chip?.querySelector('svg')).not.toBeNull()
|
|
|
|
|
+ expect(chip?.getAttribute('contenteditable')).toBe('false')
|
|
|
expect(shell.snapshot.occurrences).toHaveLength(1)
|
|
expect(shell.snapshot.occurrences).toHaveLength(1)
|
|
|
- expect(shell.snapshot.draft).toBe('参考 @会话一 内容')
|
|
|
|
|
- expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 4 })
|
|
|
|
|
|
|
+ // The draft IS the clipboard projection; the label lives in the chip DOM.
|
|
|
|
|
+ expect(shell.snapshot.draft).toBe('参考 @w1 内容')
|
|
|
|
|
+ expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 3 })
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('keeps the textarea glyph layer transparent when a structured reference becomes disabled', () => {
|
|
|
|
|
|
|
+ it('keeps the chip decorator mounted when the session becomes disabled', () => {
|
|
|
const { view, shell, session, textarea } = bench()
|
|
const { view, shell, session, textarea } = bench()
|
|
|
act(() => {
|
|
act(() => {
|
|
|
shell.setDraft('@w1')
|
|
shell.setDraft('@w1')
|
|
|
shell.insertReference({
|
|
shell.insertReference({
|
|
|
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
|
|
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
|
|
|
}, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
|
|
}, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
|
|
|
- session.set(snapshotOf({ removed: true }))
|
|
|
|
|
})
|
|
})
|
|
|
- const backdrop = view.container.querySelector('[data-input-backdrop]')
|
|
|
|
|
- expect(textarea.disabled).toBe(true)
|
|
|
|
|
- expect(backdrop?.getAttribute('data-disabled')).toBe('true')
|
|
|
|
|
- expect(backdrop?.querySelector('[data-decoration="chip"] svg')).not.toBeNull()
|
|
|
|
|
|
|
+ const chip = view.container.querySelector('[data-composer-chip]')
|
|
|
|
|
+ expect(chip?.querySelector('svg')).not.toBeNull()
|
|
|
|
|
+ act(() => { session.set(snapshotOf({ removed: true })) })
|
|
|
|
|
+ expect(textarea.getAttribute('aria-disabled')).toBe('true')
|
|
|
|
|
+ expect(view.container.querySelector('[data-composer-chip]')).toBe(chip)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('Backspace and Delete remove a reference as one range at its boundaries', () => {
|
|
|
|
|
- const reference = {
|
|
|
|
|
- source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1',
|
|
|
|
|
- }
|
|
|
|
|
- const backspace = bench()
|
|
|
|
|
|
|
+ it('a chip leaves the document as one unit (keyboard deletion is asserted in the browser lane)', () => {
|
|
|
|
|
+ // jsdom lacks the non-standard Selection.modify Lexical's character
|
|
|
|
|
+ // deletion crosses nodes with, so the Backspace/Delete gesture itself is
|
|
|
|
|
+ // an e2e assertion; the structural unit — one span covering the chip
|
|
|
|
|
+ // removes the whole occurrence — is checkable here.
|
|
|
|
|
+ const { shell } = bench()
|
|
|
act(() => {
|
|
act(() => {
|
|
|
- backspace.shell.setDraft('前 @w1 后')
|
|
|
|
|
- backspace.shell.insertReference(
|
|
|
|
|
- reference,
|
|
|
|
|
- { start: 2, end: 5, draftRev: backspace.shell.snapshot.draftRev },
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ shell.setDraft('前 @w1 后')
|
|
|
|
|
+ shell.insertReference({
|
|
|
|
|
+ source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
|
|
|
|
|
+ }, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
|
|
|
})
|
|
})
|
|
|
- backspace.textarea.setSelectionRange(6, 6)
|
|
|
|
|
- fireEvent.keyDown(backspace.textarea, { key: 'Backspace' })
|
|
|
|
|
- expect(backspace.shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
|
|
|
|
|
-
|
|
|
|
|
- const forwardDelete = bench()
|
|
|
|
|
|
|
+ expect(shell.snapshot.occurrences).toHaveLength(1)
|
|
|
act(() => {
|
|
act(() => {
|
|
|
- forwardDelete.shell.setDraft('前 @w1 后')
|
|
|
|
|
- forwardDelete.shell.insertReference(
|
|
|
|
|
- reference,
|
|
|
|
|
- { start: 2, end: 5, draftRev: forwardDelete.shell.snapshot.draftRev },
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ shell.editor.update(() => {
|
|
|
|
|
+ expect($replaceDetectSpanWithText({ start: 2, end: 3 }, '')).toBe(true)
|
|
|
|
|
+ }, { discrete: true })
|
|
|
})
|
|
})
|
|
|
- forwardDelete.textarea.setSelectionRange(2, 2)
|
|
|
|
|
- fireEvent.keyDown(forwardDelete.textarea, { key: 'Delete' })
|
|
|
|
|
- expect(forwardDelete.shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
|
|
|
|
|
|
|
+ expect(shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('copy and cut expand a partial reference selection to its structured range', () => {
|
|
|
|
|
|
|
+ it('copy and cut expand a selected chip to its clipboard projection natively', async () => {
|
|
|
const { shell, textarea } = bench()
|
|
const { shell, textarea } = bench()
|
|
|
act(() => {
|
|
act(() => {
|
|
|
shell.setDraft('前 @w1 后')
|
|
shell.setDraft('前 @w1 后')
|
|
|
shell.insertReference({
|
|
shell.insertReference({
|
|
|
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
|
|
source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
|
|
|
}, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
|
|
}, { start: 2, end: 5, draftRev: shell.snapshot.draftRev })
|
|
|
|
|
+ // Select the chip plus its flanking spaces: detect [1, 4).
|
|
|
|
|
+ shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
|
|
|
})
|
|
})
|
|
|
const setData = vi.fn()
|
|
const setData = vi.fn()
|
|
|
- textarea.setSelectionRange(3, 4)
|
|
|
|
|
- fireEvent.copy(textarea, { clipboardData: { setData } })
|
|
|
|
|
- expect(setData).toHaveBeenCalledWith('text/plain', '@w1')
|
|
|
|
|
- expect(shell.snapshot.draft).toBe('前 @会话一 后')
|
|
|
|
|
-
|
|
|
|
|
- textarea.setSelectionRange(3, 4)
|
|
|
|
|
- fireEvent.cut(textarea, { clipboardData: { setData } })
|
|
|
|
|
- expect(setData).toHaveBeenLastCalledWith('text/plain', '@w1')
|
|
|
|
|
- expect(shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] })
|
|
|
|
|
|
|
+ fireEvent.copy(textarea, { clipboardData: { setData, getData: () => '' } })
|
|
|
|
|
+ expect(setData).toHaveBeenCalledWith('text/plain', ' @w1 ')
|
|
|
|
|
+ expect(shell.snapshot.draft).toBe('前 @w1 后')
|
|
|
|
|
+
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ shell.editor.update(() => { $selectDetectSpan({ start: 1, end: 4 }) }, { discrete: true })
|
|
|
|
|
+ })
|
|
|
|
|
+ fireEvent.cut(textarea, { clipboardData: { setData, getData: () => '' } })
|
|
|
|
|
+ await vi.waitFor(() => {
|
|
|
|
|
+ expect(shell.snapshot).toMatchObject({ draft: '前后', occurrences: [] })
|
|
|
|
|
+ })
|
|
|
|
|
+ expect(setData).toHaveBeenLastCalledWith('text/plain', ' @w1 ')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('a lexicon-matched plain token renders the text-ref mark', () => {
|
|
|
|
|
|
|
+ it('a lexicon-matched plain token renders the text-ref node', () => {
|
|
|
const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
|
|
const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
|
|
|
const { view, shell } = bench({ lexicon })
|
|
const { view, shell } = bench({ lexicon })
|
|
|
act(() => { shell.setDraft('use /fixture-demo now') })
|
|
act(() => { shell.setDraft('use /fixture-demo now') })
|
|
|
- const mark = view.container.querySelector('[data-decoration="text-ref"]')
|
|
|
|
|
|
|
+ const mark = view.container.querySelector('[data-composer-text-ref]')
|
|
|
expect(mark?.textContent).toBe('/fixture-demo')
|
|
expect(mark?.textContent).toBe('/fixture-demo')
|
|
|
// Editing the token out of match shape drops the decoration.
|
|
// Editing the token out of match shape drops the decoration.
|
|
|
act(() => { shell.setDraft('use /fixture-dem now') })
|
|
act(() => { shell.setDraft('use /fixture-dem now') })
|
|
|
- expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
|
|
|
|
|
|
|
+ expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('a directory completion renders a folder glyph without changing its plain text', () => {
|
|
|
|
|
|
|
+ it('a directory completion carries the folder appearance without changing its plain text', () => {
|
|
|
const { view, shell } = bench()
|
|
const { view, shell } = bench()
|
|
|
act(() => { shell.setDraft('see @src/components/') })
|
|
act(() => { shell.setDraft('see @src/components/') })
|
|
|
- const mark = view.container.querySelector('[data-decoration="text-ref"]')
|
|
|
|
|
|
|
+ const mark = view.container.querySelector('[data-composer-text-ref]')
|
|
|
expect(mark?.textContent).toBe('@src/components/')
|
|
expect(mark?.textContent).toBe('@src/components/')
|
|
|
- expect(mark?.querySelector('svg')).not.toBeNull()
|
|
|
|
|
|
|
+ expect(mark?.getAttribute('data-ref-appearance')).toBe('folder')
|
|
|
expect(shell.snapshot.draft).toBe('see @src/components/')
|
|
expect(shell.snapshot.draft).toBe('see @src/components/')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('a plain-text reference keeps its nodes while earlier text shifts its offset', () => {
|
|
|
|
|
- const { view, textarea, shell } = bench()
|
|
|
|
|
|
|
+ it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => {
|
|
|
|
|
+ const { view, shell } = bench()
|
|
|
act(() => { shell.setDraft('see @src/components/ here') })
|
|
act(() => { shell.setDraft('see @src/components/ here') })
|
|
|
- const backdrop = view.container.querySelector('[data-input-backdrop]')!
|
|
|
|
|
- const mark = backdrop.querySelector('[data-decoration="text-ref"]')!
|
|
|
|
|
- const icon = mark.querySelector('svg')!
|
|
|
|
|
- act(() => { fireEvent.change(textarea, { target: { value: 'X see @src/components/ here' } }) })
|
|
|
|
|
- // Node identity, not text: an offset-derived key remounts the mark and its
|
|
|
|
|
- // icon on every keystroke landing ahead of the range.
|
|
|
|
|
- expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBe(mark)
|
|
|
|
|
- expect(icon.isConnected).toBe(true)
|
|
|
|
|
|
|
+ const mark = view.container.querySelector('[data-composer-text-ref]')!
|
|
|
|
|
+ // Type ahead through the node API (the transaction path typing takes).
|
|
|
|
|
+ act(() => {
|
|
|
|
|
+ shell.editor.update(() => {
|
|
|
|
|
+ const first = $getRoot().getAllTextNodes()[0]
|
|
|
|
|
+ if ($isTextNode(first)) first.spliceText(0, 0, 'X ')
|
|
|
|
|
+ }, { discrete: true })
|
|
|
|
|
+ })
|
|
|
|
|
+ // Node identity, not text: the entity node survives edits ahead of it.
|
|
|
|
|
+ expect(view.container.querySelector('[data-composer-text-ref]')).toBe(mark)
|
|
|
expect(mark.textContent).toBe('@src/components/')
|
|
expect(mark.textContent).toBe('@src/components/')
|
|
|
|
|
+ expect(shell.snapshot.draft).toBe('X see @src/components/ here')
|
|
|
// A token edited out of match shape still loses its decoration.
|
|
// A token edited out of match shape still loses its decoration.
|
|
|
- act(() => { fireEvent.change(textarea, { target: { value: 'X see X@src/components/ here' } }) })
|
|
|
|
|
- expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBeNull()
|
|
|
|
|
- expect(shell.snapshot.draft).toBe('X see X@src/components/ here')
|
|
|
|
|
|
|
+ act(() => { shell.setDraft('X see X@src/components/ here') })
|
|
|
|
|
+ expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull()
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
|
|
|
|
@@ -1364,8 +1181,9 @@ describe('command launcher chrome and control seats', () => {
|
|
|
expect(view.getByLabelText('命令')).toBeTruthy()
|
|
expect(view.getByLabelText('命令')).toBeTruthy()
|
|
|
// Capability absent (no projection value): the chip renders nothing.
|
|
// Capability absent (no projection value): the chip renders nothing.
|
|
|
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
|
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
|
|
- // Every seat dispatched, nothing rendered.
|
|
|
|
|
- expect(slotCalls.map(c => c.key)).toEqual([
|
|
|
|
|
|
|
+ // Every seat dispatched, nothing rendered (render passes may repeat; the
|
|
|
|
|
+ // seat set is the contract).
|
|
|
|
|
+ expect([...new Set(slotCalls.map(c => c.key))]).toEqual([
|
|
|
'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
|
|
'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
|
|
|
])
|
|
])
|
|
|
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
|
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
|
@@ -1374,8 +1192,8 @@ describe('command launcher chrome and control seats', () => {
|
|
|
|
|
|
|
|
it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
|
|
it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => {
|
|
|
const toggleCommandMenu = vi.fn()
|
|
const toggleCommandMenu = vi.fn()
|
|
|
- const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
|
|
|
|
|
- textarea.setSelectionRange(2, 7)
|
|
|
|
|
|
|
+ const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu })
|
|
|
|
|
+ act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) })
|
|
|
const launcher = view.getByLabelText('命令')
|
|
const launcher = view.getByLabelText('命令')
|
|
|
expect(launcher.getAttribute('aria-expanded')).toBe('false')
|
|
expect(launcher.getAttribute('aria-expanded')).toBe('false')
|
|
|
fireEvent.click(launcher)
|
|
fireEvent.click(launcher)
|