1
0

input-matrix.client.spec.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. // @vitest-environment jsdom
  2. /**
  3. * Impact-matrix projection tests (row by row): what each
  4. * phase projects onto the InputBar — enter routing, visuals (token color /
  5. * hint / pending), edit freedom, and the published currency's claim seat.
  6. * React over jsdom per the client testing discipline; the machine is real.
  7. */
  8. import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
  9. import { afterEach, describe, expect, it, vi } from 'vitest'
  10. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  11. import type { Context } from '@deepseek-ai/cordis'
  12. import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
  13. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  14. import {
  15. bindSnapshotSelector, conversationSnapshot, sessionSnapshot,
  16. } from '@deepseek-ai/dsh-client-test-runtime'
  17. import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
  18. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  19. import type { SubmitAttachment, SubmitOutcome } from '../src/client/contract/input.ts'
  20. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  21. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  22. import type { DraftAttachmentId } from '../src/client/contract/input.ts'
  23. import { SessionInputShell } from '../src/client/input/facade.ts'
  24. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  25. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  26. import { zh } from '../src/client/locales.ts'
  27. // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
  28. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
  29. // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
  30. // caret with one once the surface is genuinely contenteditable).
  31. Range.prototype.getBoundingClientRect = () => ({
  32. top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
  33. })
  34. afterEach(cleanup)
  35. const SCTX = {} as Context
  36. const SID = 's1' as SessionId
  37. /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
  38. function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
  39. const session = createSnapshotStore<SessionSnapshot>({
  40. ...sessionSnapshot(SID),
  41. running: over?.running ?? false,
  42. removed: over?.disabled ?? false,
  43. })
  44. const props: InputBarProps = {
  45. usePanelInfo: selector => selector({ activePanelId: null }),
  46. sessionId: SID,
  47. SessionProvider: ({ children }) => children,
  48. useSession: bindSnapshotSelector(session),
  49. useSessions: bindSnapshotSelector(createSnapshotStore({
  50. ids: [], byId: {}, current: undefined, phase: 'ready',
  51. subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
  52. })),
  53. useSessionStatus: bindSnapshotSelector(
  54. createSnapshotStore<SessionStatusSnapshot>(new Map()),
  55. ),
  56. useSessionRetainInfo: () => undefined,
  57. useResource,
  58. useWorkspaces: bindSnapshotSelector(createSnapshotStore({
  59. items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  60. baselinesReady: true, recentWorkspaceId: undefined,
  61. })),
  62. useProjection: (() => undefined),
  63. useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())),
  64. useInput: bindSnapshotSelector(shell.state),
  65. inputActions: shell.actions,
  66. keyboard: shell,
  67. addFiles: () => null,
  68. useFileUploads: bindSnapshotSelector(createSnapshotStore({})),
  69. retryFileUpload: undefined,
  70. removeAttachment: () => {},
  71. // Every id resolves so the bar's registry prune never drops a test image.
  72. resolveDraftAttachments: ids => ids.map(id => ({
  73. kind: 'image' as const, id,
  74. file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }),
  75. previewUrl: `blob:${id}`,
  76. })),
  77. toggleCommandMenu: vi.fn(),
  78. useBusyEnter: bindSnapshotSelector(createSnapshotStore<'queue' | 'steer'>('queue')),
  79. useNotices: bindSnapshotSelector(shell.notices),
  80. useLexicon: bindSnapshotSelector(shell.lexicon),
  81. useMenuLauncher: bindSnapshotSelector(createSnapshotStore<string | null>(null)),
  82. renderSlot: (() => null) as InputBarProps['renderSlot'],
  83. stop: vi.fn(),
  84. t: makeTranslate(zh, commonZh),
  85. variant: 'composer',
  86. }
  87. return render(<InputBar {...props} />)
  88. }
  89. function bench(over?: {
  90. running?: boolean
  91. disabled?: boolean
  92. submit?: (args: string) => Promise<SubmitOutcome>
  93. serialize?: (ids: readonly DraftAttachmentId[]) => Promise<readonly SubmitAttachment[]>
  94. }) {
  95. const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
  96. const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve<readonly SubmitAttachment[]>([])))
  97. const release = vi.fn()
  98. const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } })
  99. const wiring = shell
  100. const view = mountBar(shell, over)
  101. const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
  102. const claim = (token = '/goal ', hint = '目标', attachments?: true) => {
  103. act(() => {
  104. shell.setDraft(token)
  105. shell.beginCommand(
  106. {
  107. name: token.slice(1).trim(), token, hint,
  108. ...(attachments === true ? { attachments: true } : {}),
  109. submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })),
  110. },
  111. { start: 0, end: token.length, draftRev: shell.snapshot.draftRev },
  112. )
  113. })
  114. }
  115. return { view, textarea, shell, wiring, sink, claim, serialize, release }
  116. }
  117. describe('matrix row: plain', () => {
  118. it('unsubscribes from the Inbox projection when disposed', () => {
  119. const unsubscribe = vi.fn()
  120. const subscribe = vi.fn(() => unsubscribe)
  121. const shell = new SessionInputShell({
  122. actx: SCTX,
  123. defaultSink: () => Promise.resolve({ kind: 'success' }),
  124. inbox: { getSnapshot: () => undefined, subscribe },
  125. commandAttachments: {
  126. serialize: () => Promise.resolve([]),
  127. release: () => {},
  128. unsupportedNotice: token => `${token.trim()} attachments-unsupported`,
  129. },
  130. })
  131. expect(subscribe).toHaveBeenCalledOnce()
  132. shell.dispose()
  133. expect(unsubscribe).toHaveBeenCalledOnce()
  134. })
  135. it('enter falls to the default sink; no claim on the currency; edits free', async () => {
  136. const { textarea, shell, sink } = bench()
  137. act(() => { shell.setDraft('普通消息') })
  138. expect(shell.snapshot.claim).toBeUndefined()
  139. fireEvent.keyDown(textarea, { key: 'Enter' })
  140. expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal))
  141. // The detached default send never freezes the composer.
  142. expect(shell.snapshot.phase).toBe('plain')
  143. expect(shell.snapshot.draft).toBe('')
  144. expect(shell.snapshot.claim).toBeUndefined()
  145. })
  146. })
  147. describe('matrix row: claimed', () => {
  148. it('publishes the claim currency, colors the token, hints while args are blank, and edits stay free', () => {
  149. const { view, textarea, shell, claim } = bench()
  150. claim()
  151. act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
  152. expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标' })
  153. expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ')
  154. // The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
  155. expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行'))
  156. expect(textarea.getAttribute('contenteditable')).toBe('true')
  157. // Free editing beyond the token: hint drops, claim holds.
  158. act(() => { shell.setDraft('/goal 发布版本') })
  159. expect(shell.snapshot.phase).toBe('claimed')
  160. expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('')
  161. })
  162. it('enter routes to claim.submit (command lane, never the queue sink)', async () => {
  163. const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const, text: '完成', source: 'command', name: 'goal' }))
  164. const { view, textarea, shell, sink, claim } = bench({ submit })
  165. claim()
  166. act(() => { shell.setDraft('/goal 发布') })
  167. fireEvent.keyDown(textarea, { key: 'Enter' })
  168. expect(sink).not.toHaveBeenCalled()
  169. await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX, []) })
  170. // Commit: draft cleared, notice surfaced, back to plain.
  171. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') })
  172. expect(view.getByText('完成')).toBeTruthy()
  173. })
  174. it('backspacing the token auto-releases to plain and the visuals vanish (scenario H)', () => {
  175. const { view, shell, claim } = bench()
  176. claim()
  177. act(() => { shell.setDraft('/goa 发布') }) // token broken
  178. expect(shell.snapshot.phase).toBe('plain')
  179. expect(shell.snapshot.claim).toBeUndefined()
  180. act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh
  181. expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')).toBeNull()
  182. })
  183. })
  184. describe('matrix row: claimed with attachments', () => {
  185. const img = 'img-1' as DraftAttachmentId
  186. it('a claim without attachment acceptance blocks enter: one notice and the full draft retained', async () => {
  187. const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
  188. const { view, textarea, shell, sink, claim } = bench({ submit })
  189. claim()
  190. act(() => { shell.addAttachments([img]) })
  191. fireEvent.keyDown(textarea, { key: 'Enter' })
  192. await Promise.resolve()
  193. expect(shell.snapshot.phase).toBe('claimed')
  194. expect(submit).not.toHaveBeenCalled()
  195. expect(sink).not.toHaveBeenCalled()
  196. expect(view.getByText('/goal attachments-unsupported')).toBeTruthy()
  197. expect(shell.snapshot.attachmentIds).toEqual([img])
  198. expect(shell.snapshot.draft).toBe('/goal ')
  199. })
  200. it('an accepting claim serializes and forwards a mixed batch; success consumes and clears', async () => {
  201. const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
  202. const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' }
  203. const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' }
  204. const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png, file]) })
  205. claim('/goal ', '目标', true)
  206. // The claim currency carries the acceptance flag the pre-gate reads.
  207. expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标', attachments: true })
  208. act(() => { shell.addAttachments([img]) })
  209. fireEvent.keyDown(textarea, { key: 'Enter' })
  210. await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png, file]) })
  211. expect(serialize).toHaveBeenCalledWith([img])
  212. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') })
  213. expect(release).toHaveBeenCalledWith([img])
  214. expect(shell.snapshot.attachmentIds).toEqual([])
  215. expect(shell.snapshot.phase).toBe('plain')
  216. })
  217. it('a handler error keeps a serialized file unreleased beside the notice and draft', async () => {
  218. const submit = vi.fn(() => Promise.resolve({ kind: 'error' as const, text: '处理失败' }))
  219. const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' }
  220. const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.resolve([file]) })
  221. claim('/goal ', '目标', true)
  222. act(() => { shell.addAttachments([img]) })
  223. fireEvent.keyDown(textarea, { key: 'Enter' })
  224. await vi.waitFor(() => { expect(view.getByText('处理失败')).toBeTruthy() })
  225. expect(shell.snapshot.phase).toBe('claimed')
  226. expect(shell.snapshot.attachmentIds).toEqual([img])
  227. expect(release).not.toHaveBeenCalled()
  228. expect(shell.snapshot.draft).toBe('/goal ')
  229. })
  230. it('a serialize rejection blocks the transaction: notice, no submit call, images kept', async () => {
  231. const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
  232. const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.reject(new Error('附件已失效')) })
  233. claim('/goal ', '目标', true)
  234. act(() => { shell.addAttachments([img]) })
  235. fireEvent.keyDown(textarea, { key: 'Enter' })
  236. await vi.waitFor(() => { expect(view.getByText('附件已失效')).toBeTruthy() })
  237. expect(submit).not.toHaveBeenCalled()
  238. expect(shell.snapshot.attachmentIds).toEqual([img])
  239. expect(release).not.toHaveBeenCalled()
  240. expect(shell.snapshot.phase).toBe('claimed')
  241. })
  242. it('a disposed shell never lets a pending serialization reach claim.submit', async () => {
  243. const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
  244. let resolveSerialize!: (images: readonly SubmitAttachment[]) => void
  245. const { shell, textarea, claim } = bench({
  246. submit,
  247. serialize: () => new Promise((resolve) => { resolveSerialize = resolve }),
  248. })
  249. claim('/goal ', '目标', true)
  250. act(() => { shell.addAttachments([img]) })
  251. fireEvent.keyDown(textarea, { key: 'Enter' })
  252. await vi.waitFor(() => { expect(resolveSerialize).toBeDefined() })
  253. shell.dispose()
  254. resolveSerialize([{ type: 'image', mediaType: 'image/png', data: 'AA==' }])
  255. await Promise.resolve()
  256. await Promise.resolve()
  257. expect(submit).not.toHaveBeenCalled()
  258. })
  259. it('image removal is refused while a command submit is in flight', async () => {
  260. const submit = vi.fn(() => new Promise<SubmitOutcome>(() => {})) // never settles
  261. const { shell, textarea, claim } = bench({ submit, serialize: () => Promise.resolve([]) })
  262. claim('/goal ', '目标', true)
  263. act(() => { shell.addAttachments([img]) })
  264. fireEvent.keyDown(textarea, { key: 'Enter' })
  265. expect(shell.snapshot.phase).toBe('submitting')
  266. let removed = true
  267. act(() => { removed = shell.removeAttachment(img) })
  268. expect(removed).toBe(false)
  269. expect(shell.snapshot.attachmentIds).toEqual([img])
  270. })
  271. })
  272. describe('matrix row: submitting', () => {
  273. it('locks enter, renders pending + read-only, keeps the claim snapshot on the currency', async () => {
  274. const submit = vi.fn(() => new Promise<SubmitOutcome>(() => {})) // never settles
  275. const { textarea, shell, sink, claim } = bench({ submit })
  276. claim()
  277. fireEvent.keyDown(textarea, { key: 'Enter' })
  278. expect(shell.snapshot.phase).toBe('submitting')
  279. expect(shell.snapshot.claim).toBeDefined()
  280. expect(textarea.getAttribute('contenteditable')).toBe('false')
  281. // Enter is dead inside the lock (submit dispatch is microtask-deferred).
  282. await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
  283. fireEvent.keyDown(textarea, { key: 'Enter' })
  284. await Promise.resolve()
  285. expect(submit).toHaveBeenCalledTimes(1)
  286. expect(sink).not.toHaveBeenCalled()
  287. })
  288. it('rollback with unchanged draft returns to claimed with the notice; drifted draft only notices', async () => {
  289. let rejectSubmit!: (e: Error) => void
  290. const submit = vi.fn(() => new Promise<SubmitOutcome>((_res, rej) => { rejectSubmit = rej }))
  291. const first = bench({ submit })
  292. first.claim()
  293. fireEvent.keyDown(first.textarea, { key: 'Enter' })
  294. await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
  295. act(() => { rejectSubmit(new Error('执行失败')) })
  296. await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
  297. expect(first.shell.snapshot.draft).toBe('/goal ')
  298. expect(first.view.getByText('执行失败')).toBeTruthy()
  299. cleanup()
  300. // Drift: typing during flight wins; no restore, plain, notice only.
  301. const submit2 = vi.fn(() => new Promise<SubmitOutcome>((_res, rej) => { rejectSubmit = rej }))
  302. const second = bench({ submit: submit2 })
  303. second.claim()
  304. fireEvent.keyDown(second.textarea, { key: 'Enter' })
  305. await vi.waitFor(() => { expect(submit2).toHaveBeenCalled() })
  306. act(() => { second.shell.setDraft('用户飞行中打的新稿') })
  307. act(() => { rejectSubmit(new Error('晚到失败')) })
  308. await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
  309. expect(second.shell.snapshot.draft).toBe('用户飞行中打的新稿')
  310. expect(second.view.getByText('晚到失败')).toBeTruthy()
  311. })
  312. })
  313. describe('matrix row: locked (session disabled)', () => {
  314. it('disables the textarea and chrome; the machine currency is untouched', () => {
  315. const { view, textarea, shell } = bench({ disabled: true })
  316. expect(textarea.getAttribute('aria-disabled')).toBe('true')
  317. expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true)
  318. expect(shell.snapshot.phase).toBe('plain')
  319. })
  320. it('running does NOT lock: typing and enter-queue stay live', () => {
  321. const { textarea, shell, sink } = bench({ running: true })
  322. expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
  323. act(() => { shell.setDraft('排队') })
  324. fireEvent.keyDown(textarea, { key: 'Enter' })
  325. expect(sink).toHaveBeenCalledWith('排队', [], 'queue', expect.any(AbortSignal))
  326. })
  327. })
  328. describe('matrix row: takeover (orthogonal axis)', () => {
  329. it('the machine state survives outside the render tree (claim lives on the shell, not the DOM)', () => {
  330. const { view, shell, claim } = bench()
  331. claim()
  332. // Takeover hides the composer (overlay chain keeps it mounted-but-hidden);
  333. // even a full unmount keeps the claim: state lives on the resident shell.
  334. view.unmount()
  335. expect(shell.snapshot.phase).toBe('claimed')
  336. expect(shell.snapshot.claim?.token).toBe('/goal ')
  337. expect(shell.snapshot.draft).toBe('/goal ')
  338. })
  339. })