input-matrix.spec.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. // @vitest-environment jsdom
  2. /**
  3. * Impact-matrix projection tests (design §5.2 影响矩阵, 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 { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  11. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  12. import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  13. import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
  14. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  15. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  16. import { SessionInputShell } from '../src/client/input/facade.ts'
  17. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  18. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  19. import { zh } from '../src/client/locales.ts'
  20. afterEach(cleanup)
  21. const SCTX = {} as ClientContext
  22. const SID = 's1' as SessionId
  23. /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
  24. function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
  25. const session = createSnapshotStore<ConversationSnapshot>({
  26. sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
  27. pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
  28. removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
  29. loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
  30. })
  31. const props: InputBarProps = {
  32. sessionId: SID,
  33. SessionProvider: ({ children }) => children(SID),
  34. useSession: bindSnapshotSelector(session),
  35. useSessions: bindSnapshotSelector(createSnapshotStore({
  36. ids: [], byId: {}, current: undefined, phase: 'ready',
  37. subagentsByParent: {}, currentAddress: undefined,
  38. })),
  39. useWorkspaces: bindSnapshotSelector(createSnapshotStore({
  40. items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
  41. baselinesReady: true, recentWorkspaceId: undefined,
  42. })),
  43. useProjection: (() => undefined),
  44. useInput: bindSnapshotSelector(shell.state),
  45. inputActions: shell.actions,
  46. keyboard: shell,
  47. resolveSubmitMode: () => 'queue',
  48. toggleCommandMenu: vi.fn(),
  49. useNotices: bindSnapshotSelector(shell.notices),
  50. useLexicon: bindSnapshotSelector(shell.lexicon),
  51. useMenuLauncher: bindSnapshotSelector(createSnapshotStore<string | null>(null)),
  52. renderSlot: (() => null) as InputBarProps['renderSlot'],
  53. stop: vi.fn(),
  54. command: () => Promise.resolve(true),
  55. // Mirrors the real lookup chain (conversation namespace, then common).
  56. t: makeTranslate(zh, commonZh),
  57. variant: 'composer',
  58. }
  59. return render(<InputBar {...props} />)
  60. }
  61. function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise<SubmitOutcome> }) {
  62. const sink = vi.fn()
  63. const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink })
  64. const wiring = shell
  65. const view = mountBar(shell, over)
  66. const textarea = view.container.querySelector('textarea')!
  67. const claim = (token = '/goal ', hint = '目标') => {
  68. act(() => {
  69. shell.setDraft(token)
  70. shell.beginCommand(
  71. {
  72. token, hint,
  73. submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })),
  74. },
  75. { start: 0, end: token.length, draftRev: shell.snapshot.draftRev },
  76. )
  77. })
  78. }
  79. return { view, textarea, shell, wiring, sink, claim }
  80. }
  81. describe('matrix row: plain', () => {
  82. it('enter falls to the default sink; no claim on the currency; edits free', () => {
  83. const { textarea, shell, sink } = bench()
  84. fireEvent.change(textarea, { target: { value: '普通消息' } })
  85. expect(shell.snapshot.claim).toBeUndefined()
  86. fireEvent.keyDown(textarea, { key: 'Enter' })
  87. expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
  88. expect(shell.snapshot.phase).toBe('plain')
  89. })
  90. })
  91. describe('matrix row: claimed', () => {
  92. it('publishes the claim currency, colors the token, hints while args are blank, and edits stay free', () => {
  93. const { view, textarea, shell, claim } = bench()
  94. claim()
  95. expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
  96. expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
  97. // The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior).
  98. expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行')
  99. expect((textarea).readOnly).toBe(false)
  100. // Free editing beyond the token: hint drops, claim holds.
  101. fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
  102. expect(shell.snapshot.phase).toBe('claimed')
  103. expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
  104. })
  105. it('enter routes to claim.submit (command lane, never the queue sink)', async () => {
  106. const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const, text: '完成', source: 'command', name: 'goal' }))
  107. const { view, textarea, sink, claim } = bench({ submit })
  108. claim()
  109. fireEvent.change(textarea, { target: { value: '/goal 发布' } })
  110. fireEvent.keyDown(textarea, { key: 'Enter' })
  111. expect(sink).not.toHaveBeenCalled()
  112. await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
  113. // Commit: draft cleared, notice surfaced, back to plain.
  114. await vi.waitFor(() => { expect((textarea).value).toBe('') })
  115. expect(view.getByText('完成')).toBeTruthy()
  116. })
  117. it('backspacing the token auto-releases to plain and the visuals vanish (scenario H)', () => {
  118. const { view, textarea, shell, claim } = bench()
  119. claim()
  120. fireEvent.change(textarea, { target: { value: '/goa 发布' } }) // token broken
  121. expect(shell.snapshot.phase).toBe('plain')
  122. expect(shell.snapshot.claim).toBeUndefined()
  123. expect(view.container.querySelector('[data-decoration="token"]')).toBeNull()
  124. })
  125. })
  126. describe('matrix row: submitting', () => {
  127. it('locks enter, renders pending + read-only, keeps the claim snapshot on the currency', async () => {
  128. const submit = vi.fn(() => new Promise<SubmitOutcome>(() => {})) // never settles
  129. const { textarea, shell, sink, claim } = bench({ submit })
  130. claim()
  131. fireEvent.keyDown(textarea, { key: 'Enter' })
  132. expect(shell.snapshot.phase).toBe('submitting')
  133. expect(shell.snapshot.claim).toBeDefined()
  134. expect((textarea).readOnly).toBe(true)
  135. // Enter is dead inside the lock (submit dispatch is microtask-deferred).
  136. await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
  137. fireEvent.keyDown(textarea, { key: 'Enter' })
  138. await Promise.resolve()
  139. expect(submit).toHaveBeenCalledTimes(1)
  140. expect(sink).not.toHaveBeenCalled()
  141. })
  142. it('rollback with unchanged draft returns to claimed with the notice; drifted draft only notices', async () => {
  143. let rejectSubmit!: (e: Error) => void
  144. const submit = vi.fn(() => new Promise<SubmitOutcome>((_res, rej) => { rejectSubmit = rej }))
  145. const first = bench({ submit })
  146. first.claim()
  147. fireEvent.keyDown(first.textarea, { key: 'Enter' })
  148. await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
  149. act(() => { rejectSubmit(new Error('执行失败')) })
  150. await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
  151. expect((first.textarea).value).toBe('/goal ')
  152. expect(first.view.getByText('执行失败')).toBeTruthy()
  153. cleanup()
  154. // Drift: typing during flight wins; no restore, plain, notice only.
  155. const submit2 = vi.fn(() => new Promise<SubmitOutcome>((_res, rej) => { rejectSubmit = rej }))
  156. const second = bench({ submit: submit2 })
  157. second.claim()
  158. fireEvent.keyDown(second.textarea, { key: 'Enter' })
  159. await vi.waitFor(() => { expect(submit2).toHaveBeenCalled() })
  160. act(() => { second.shell.setDraft('用户飞行中打的新稿') })
  161. act(() => { rejectSubmit(new Error('晚到失败')) })
  162. await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
  163. expect((second.textarea).value).toBe('用户飞行中打的新稿')
  164. expect(second.view.getByText('晚到失败')).toBeTruthy()
  165. })
  166. })
  167. describe('matrix row: locked (session disabled)', () => {
  168. it('disables the textarea and chrome; the machine currency is untouched', () => {
  169. const { view, textarea, shell } = bench({ disabled: true })
  170. expect((textarea).disabled).toBe(true)
  171. expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
  172. expect(shell.snapshot.phase).toBe('plain')
  173. })
  174. it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
  175. const { textarea, sink } = bench({ running: true })
  176. expect((textarea).disabled).toBe(false)
  177. fireEvent.change(textarea, { target: { value: '排队' } })
  178. fireEvent.keyDown(textarea, { key: 'Enter' })
  179. expect(sink).toHaveBeenCalledWith('排队', 'queue')
  180. })
  181. })
  182. describe('matrix row: takeover (orthogonal axis)', () => {
  183. it('the machine state survives outside the render tree (claim lives on the shell, not the DOM)', () => {
  184. const { view, shell, claim } = bench()
  185. claim()
  186. // Takeover hides the composer (overlay chain keeps it mounted-but-hidden);
  187. // even a full unmount keeps the claim: state lives on the resident shell.
  188. view.unmount()
  189. expect(shell.snapshot.phase).toBe('claimed')
  190. expect(shell.snapshot.claim?.token).toBe('/goal ')
  191. expect(shell.snapshot.draft).toBe('/goal ')
  192. })
  193. })