input-matrix.client.spec.tsx 15 KB

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