|
|
@@ -0,0 +1,134 @@
|
|
|
+// @vitest-environment jsdom
|
|
|
+import { cleanup, render, within } from '@testing-library/react'
|
|
|
+import { afterEach, describe, expect, it } from 'vitest'
|
|
|
+import type {
|
|
|
+ ChatConversationViewNode, ChatSnapshot, ConversationEventInput,
|
|
|
+ ConversationNodeDefinition, ConversationViewDefinition,
|
|
|
+} from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
+import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
|
|
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
|
|
+import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts'
|
|
|
+import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts'
|
|
|
+import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx'
|
|
|
+import {
|
|
|
+ goalCommandInputDefinition, goalCommandText,
|
|
|
+} from '../src/client/goal-command-input.ts'
|
|
|
+import { zh } from '../src/client/locales.ts'
|
|
|
+
|
|
|
+afterEach(cleanup)
|
|
|
+
|
|
|
+class TestEventDefinitions {
|
|
|
+ entries(): readonly ConversationNodeDefinition[] {
|
|
|
+ return [commandDefinition, goalCommandInputDefinition]
|
|
|
+ }
|
|
|
+
|
|
|
+ fallbackEntry(): undefined {
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class TestViewDefinitions {
|
|
|
+ entries(): readonly ConversationViewDefinition[] {
|
|
|
+ return [chatViewDefinition]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function entry(seq: number, type: string, data: unknown): ConversationEventInput {
|
|
|
+ return {
|
|
|
+ event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'],
|
|
|
+ view: undefined,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function snapshot(entries: readonly ConversationEventInput[], hasMore = false): ChatSnapshot {
|
|
|
+ const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
|
|
+ assembler.replaceWindow(entries, hasMore)
|
|
|
+ assembler.flush()
|
|
|
+ const value = assembler.snapshot('chat') as ChatSnapshot | undefined
|
|
|
+ if (value === undefined) throw new Error('chat view was not registered')
|
|
|
+ return value
|
|
|
+}
|
|
|
+
|
|
|
+function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
|
|
|
+ return value.nodes.values().find(candidate => candidate.kind === kind)
|
|
|
+}
|
|
|
+
|
|
|
+describe('goal command input projection', () => {
|
|
|
+ it('builds a separate input Node before the generic command result and restores it on replay', () => {
|
|
|
+ const run = entry(1, 'command/run', {
|
|
|
+ commandId: 'command-goal', name: 'goal', args: ' ', source: { kind: 'user' },
|
|
|
+ })
|
|
|
+ const done = entry(2, 'command/done', {
|
|
|
+ commandId: 'command-goal', kind: 'success', text: 'No goal is currently set.',
|
|
|
+ })
|
|
|
+ const value = snapshot([run, done])
|
|
|
+
|
|
|
+ expect(value.order.map(key => value.nodes.get(key)?.kind)).toEqual(['command-input', 'command'])
|
|
|
+ expect(node(value, 'command-input')).toMatchObject({
|
|
|
+ anchorSeq: 0.9,
|
|
|
+ data: { commandId: 'command-goal', text: '/goal' },
|
|
|
+ })
|
|
|
+ expect(node(value, 'command')?.data).toMatchObject({
|
|
|
+ name: 'goal', args: ' ', outcome: { kind: 'success', text: 'No goal is currently set.' },
|
|
|
+ })
|
|
|
+
|
|
|
+ const doneOnly = snapshot([done], true)
|
|
|
+ expect(node(doneOnly, 'command-input')).toBeUndefined()
|
|
|
+ expect(node(doneOnly, 'command')?.data).toMatchObject({ name: null, args: null })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('ignores other commands and preserves internal multiline arguments', () => {
|
|
|
+ const plan = entry(1, 'command/run', {
|
|
|
+ commandId: 'command-plan', name: 'plan', args: '', source: { kind: 'user' },
|
|
|
+ })
|
|
|
+ const goal = entry(2, 'command/run', {
|
|
|
+ commandId: 'command-goal', name: 'goal', args: '\nfirst line\nsecond line \n', source: { kind: 'user' },
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(goalCommandInputDefinition.match(plan.event)).toBeNull()
|
|
|
+ expect(goalCommandText(goal.event as SessionEvent<'command/run'>))
|
|
|
+ .toBe('/goal\nfirst line\nsecond line')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps the Definition total across required interface and window fallback paths', () => {
|
|
|
+ const run = entry(3, 'command/run', {
|
|
|
+ commandId: 'command-goal', name: 'goal', source: { kind: 'user' },
|
|
|
+ })
|
|
|
+ const match = {
|
|
|
+ ...run,
|
|
|
+ role: 'start' as const,
|
|
|
+ location: { kind: 'session' as const },
|
|
|
+ }
|
|
|
+ const state = goalCommandInputDefinition.start({} as never, match, {} as never)
|
|
|
+
|
|
|
+ expect(state.text).toBe('/goal')
|
|
|
+ expect(goalCommandInputDefinition.update({ state } as never, match)).toBe(state)
|
|
|
+ expect(goalCommandInputDefinition.buildViewNode!({ state: undefined } as never)).toBeNull()
|
|
|
+ expect(goalCommandInputDefinition.buildViewNode!({
|
|
|
+ key: 'goal-command-input', id: 'command-goal', state, start: undefined,
|
|
|
+ } as never)).toMatchObject({ location: { kind: 'unresolved' } })
|
|
|
+
|
|
|
+ const done = entry(4, 'command/done', { commandId: 'command-goal', kind: 'success' })
|
|
|
+ expect(() => goalCommandInputDefinition.start({} as never, {
|
|
|
+ ...done, role: 'start', location: { kind: 'session' },
|
|
|
+ } as never, {} as never)).toThrow('goal-command-input start requires command/run')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders the user-style command bubble without ordinary message actions', () => {
|
|
|
+ const t = makeTranslate(zh, commonZh)
|
|
|
+ const props = {
|
|
|
+ node: {
|
|
|
+ key: 'goal-command-input:one',
|
|
|
+ data: { commandId: 'command-goal', text: '/goal ship it', time: 1_700_000_000_000 },
|
|
|
+ },
|
|
|
+ t,
|
|
|
+ } as unknown as Parameters<typeof GoalCommandInputView>[0]
|
|
|
+ const view = render(<GoalCommandInputView {...props} />)
|
|
|
+ const bubble = view.getByRole('group', { name: '命令输入' })
|
|
|
+
|
|
|
+ expect(bubble.textContent).toBe('/goal ship it')
|
|
|
+ expect(within(bubble).queryByRole('button')).toBeNull()
|
|
|
+ })
|
|
|
+})
|