goal-command-input.client.spec.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. // @vitest-environment jsdom
  2. import { cleanup, render, within } from '@testing-library/react'
  3. import { afterEach, describe, expect, it } from 'vitest'
  4. import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
  5. import type {
  6. ConversationNodeDefinition, ConversationViewDefinition,
  7. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  8. import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
  9. import type {
  10. ChatConversationViewNode, ChatSnapshot,
  11. } from '@deepseek-ai/dsh-client-ui-chat/client'
  12. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  13. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  14. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  15. import { commandDefinition } from '@deepseek-ai/dsh-client-ui-chat/src/client/conversation-nodes/command.ts'
  16. import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts'
  17. import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx'
  18. import {
  19. goalCommandInputDefinition, goalCommandText,
  20. } from '../src/client/goal-command-input.ts'
  21. import { zh } from '../src/client/locales.ts'
  22. afterEach(cleanup)
  23. class TestEventDefinitions {
  24. entries(): readonly ConversationNodeDefinition[] {
  25. return [commandDefinition, goalCommandInputDefinition]
  26. }
  27. fallbackEntry(): undefined {
  28. return undefined
  29. }
  30. }
  31. class TestViewDefinitions {
  32. entries(): readonly ConversationViewDefinition[] {
  33. return [chatViewDefinition]
  34. }
  35. }
  36. function entry(seq: number, type: string, data: unknown): SessionLiveEventEntry {
  37. return {
  38. type: 'event',
  39. event: { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent,
  40. }
  41. }
  42. function snapshot(entries: readonly SessionLiveEventEntry[], hasMore = false): ChatSnapshot {
  43. const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
  44. assembler.replaceWindow(entries, hasMore)
  45. assembler.activateTarget('chat')
  46. const value = assembler.snapshot('chat') as ChatSnapshot | undefined
  47. if (value === undefined) throw new Error('chat view was not registered')
  48. return value
  49. }
  50. function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
  51. return value.nodes.values().find(candidate => candidate.kind === kind)
  52. }
  53. describe('goal command input projection', () => {
  54. it('builds a separate input Node before the generic command result and restores it on replay', () => {
  55. const run = entry(1, 'command/run', {
  56. commandId: 'command-goal', name: 'goal', args: ' ', source: { kind: 'user' },
  57. })
  58. const done = entry(2, 'command/done', {
  59. commandId: 'command-goal', kind: 'success', text: 'No goal is currently set.',
  60. })
  61. const value = snapshot([run, done])
  62. expect(value.order.map(key => value.nodes.get(key)?.kind)).toEqual(['command-input', 'command'])
  63. expect(node(value, 'command-input')).toMatchObject({
  64. anchorSeq: 0.9,
  65. data: { commandId: 'command-goal', text: '/goal' },
  66. })
  67. expect(node(value, 'command')?.data).toMatchObject({
  68. name: 'goal', args: ' ', outcome: { kind: 'success', text: 'No goal is currently set.' },
  69. })
  70. const doneOnly = snapshot([done], true)
  71. expect(node(doneOnly, 'command-input')).toBeUndefined()
  72. expect(node(doneOnly, 'command')?.data).toMatchObject({ name: null, args: null })
  73. })
  74. it('ignores other commands and preserves internal multiline arguments', () => {
  75. const plan = entry(1, 'command/run', {
  76. commandId: 'command-plan', name: 'plan', args: '', source: { kind: 'user' },
  77. })
  78. const goal = entry(2, 'command/run', {
  79. commandId: 'command-goal', name: 'goal', args: '\nfirst line\nsecond line \n', source: { kind: 'user' },
  80. })
  81. expect(goalCommandInputDefinition.match(plan.event)).toBeNull()
  82. expect(goalCommandText(goal.event as SessionEvent<'command/run'>))
  83. .toBe('/goal\nfirst line\nsecond line')
  84. })
  85. it('keeps the Definition total across required interface and window fallback paths', () => {
  86. const run = entry(3, 'command/run', {
  87. commandId: 'command-goal', name: 'goal', source: { kind: 'user' },
  88. })
  89. const match = {
  90. ...run,
  91. role: 'start' as const,
  92. location: { kind: 'session' as const },
  93. }
  94. const state = goalCommandInputDefinition.start({} as never, match, {} as never)
  95. expect(state.text).toBe('/goal')
  96. expect(goalCommandInputDefinition.update({ state } as never, match)).toBe(state)
  97. expect(goalCommandInputDefinition.buildViewNode!({ state: undefined } as never)).toBeNull()
  98. expect(goalCommandInputDefinition.buildViewNode!({
  99. key: 'goal-command-input', id: 'command-goal', state, start: undefined,
  100. } as never)).toMatchObject({ location: { kind: 'unresolved' } })
  101. const done = entry(4, 'command/done', { commandId: 'command-goal', kind: 'success' })
  102. expect(() => goalCommandInputDefinition.start({} as never, {
  103. ...done, role: 'start', location: { kind: 'session' },
  104. } as never, {} as never)).toThrow('goal-command-input start requires command/run')
  105. })
  106. it('renders the user-style command bubble without ordinary message actions', () => {
  107. const t = makeTranslate(zh, commonZh)
  108. const props = {
  109. node: {
  110. key: 'goal-command-input:one',
  111. data: { commandId: 'command-goal', text: '/goal ship it', time: 1_700_000_000_000 },
  112. },
  113. t,
  114. } as unknown as Parameters<typeof GoalCommandInputView>[0]
  115. const view = render(<GoalCommandInputView {...props} />)
  116. const bubble = view.getByRole('group', { name: '指令输入' })
  117. expect(bubble.textContent).toBe('/goal ship it')
  118. expect(within(bubble).queryByRole('button')).toBeNull()
  119. // The executed command token reads as a reference chip; the objective stays plain text.
  120. const chips = [...bubble.querySelectorAll('[data-ref-chip]')]
  121. expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']])
  122. })
  123. it('renders a bare /goal as one command chip and nothing else', () => {
  124. const t = makeTranslate(zh, commonZh)
  125. const props = {
  126. node: {
  127. key: 'goal-command-input:bare',
  128. data: { commandId: 'command-goal', text: '/goal', time: 1_700_000_000_000 },
  129. },
  130. t,
  131. } as unknown as Parameters<typeof GoalCommandInputView>[0]
  132. const view = render(<GoalCommandInputView {...props} />)
  133. const bubble = view.getByRole('group', { name: '指令输入' })
  134. expect(bubble.textContent).toBe('/goal')
  135. expect([...bubble.querySelectorAll('[data-ref-chip]')].map(chip => chip.textContent)).toEqual(['/goal'])
  136. })
  137. it('decorates only the leading command token: a /goal inside the objective stays plain', () => {
  138. const t = makeTranslate(zh, commonZh)
  139. const props = {
  140. node: {
  141. key: 'goal-command-input:two',
  142. data: { commandId: 'command-goal', text: '/goal 检查 /goal 的语法', time: 1_700_000_000_000 },
  143. },
  144. t,
  145. } as unknown as Parameters<typeof GoalCommandInputView>[0]
  146. const view = render(<GoalCommandInputView {...props} />)
  147. const bubble = view.getByRole('group', { name: '指令输入' })
  148. expect(bubble.textContent).toBe('/goal 检查 /goal 的语法')
  149. const chips = [...bubble.querySelectorAll('[data-ref-chip]')]
  150. expect(chips.map(chip => chip.textContent)).toEqual(['/goal'])
  151. })
  152. })