AskUserQuestionItem.detail is part of the user-interaction seam contract but the web frame schema dropped it and the composer never rendered it (review r3635427108). askUserQuestionItemSchema now forwards detail, the composer renders it under the title in the description text style, and the fixture's multi-select question carries one.
266 lines
13 KiB
TypeScript
266 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
|
|
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
|
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
|
import { PendingQuestion } from '../src/client/contract/slots.ts'
|
|
import {
|
|
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
|
|
} from '../src/client/QuestionComposer.tsx'
|
|
|
|
afterEach(cleanup)
|
|
|
|
const SID = 's1' as SessionId
|
|
|
|
/** Framework standard-kit stubs: the composer consumes none of them, the
|
|
* composed props type mandates their delivery (framework hooks are plain
|
|
* stubs per the client testing discipline). */
|
|
const kit = {
|
|
sessionId: SID,
|
|
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
|
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
|
}
|
|
|
|
const QUESTIONS = [
|
|
{
|
|
id: 'profile', header: '偏好', question: '选择候选人类型',
|
|
detail: '按当前空缺岗位的优先级选择。',
|
|
options: [
|
|
{ label: '工程落地型 (Recommended)', description: '优先工程交付。' },
|
|
{ label: '研究潜力型', description: '优先研究能力。' },
|
|
],
|
|
},
|
|
{
|
|
id: 'detail', question: '补充你的要求',
|
|
},
|
|
{
|
|
id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
|
|
options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
|
|
},
|
|
]
|
|
|
|
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
|
|
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
|
|
const carrier = new PendingWait(
|
|
'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
|
|
return { carrier, respond }
|
|
}
|
|
|
|
/** The client-response envelope respond must have received for an answer batch. */
|
|
function answeredEnvelope(rpcId: string, answers: object[]) {
|
|
return {
|
|
type: 'client-response', rpcId: RpcId(rpcId),
|
|
result: { ok: true, value: { sessionId: SID, answer: { answers } } },
|
|
}
|
|
}
|
|
|
|
describe('QuestionComposer', () => {
|
|
it('collects single, custom, and multi-select answers before one batch submit', () => {
|
|
const { carrier, respond } = wait()
|
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
|
|
|
expect(screen.getByText('1 / 3')).toBeTruthy()
|
|
expect(screen.getByText('推荐')).toBeTruthy()
|
|
expect(screen.getByText('工程落地型')).toBeTruthy()
|
|
expect(screen.getByText('按当前空缺岗位的优先级选择。')).toBeTruthy()
|
|
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
|
|
expect(respond).not.toHaveBeenCalled()
|
|
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
|
|
|
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
|
// detail is per-question: the second question carries none.
|
|
expect(screen.queryByText('按当前空缺岗位的优先级选择。')).toBeNull()
|
|
expect(screen.queryByRole('button', { name: '填写答案' })).toBeNull()
|
|
const custom = screen.getByPlaceholderText('输入你的答案')
|
|
fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
|
|
fireEvent.keyDown(custom, { key: 'Enter' })
|
|
|
|
expect(screen.getByText('3 / 3')).toBeTruthy()
|
|
expect(screen.getByText('选择重要信号')).toBeTruthy()
|
|
expect(screen.getByText('可多选')).toBeTruthy()
|
|
expect(screen.queryByText('(可多选)')).toBeNull()
|
|
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
|
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
|
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
|
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
|
|
fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
|
|
|
|
// The domain face encoded the whole batch into one carrier envelope.
|
|
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
|
|
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
|
|
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
|
{ id: 'signals', selected: ['系统设计', '代码质量'] },
|
|
]))
|
|
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
|
|
it('skips individual questions without discarding earlier answers', () => {
|
|
const { carrier, respond } = wait()
|
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
|
|
|
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
|
|
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
|
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
|
|
expect(screen.getByText('3 / 3')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
|
|
|
|
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
|
|
{ id: 'profile', selected: ['研究潜力型'] },
|
|
{ id: 'detail', selected: [] },
|
|
{ id: 'signals', selected: [] },
|
|
]))
|
|
})
|
|
|
|
it('keeps IME Enter inside the custom input until composition finishes', () => {
|
|
const { carrier, respond } = wait()
|
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
|
|
|
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
|
|
const custom = screen.getByPlaceholderText('输入你的答案')
|
|
fireEvent.change(custom, { target: { value: '中文输入' } })
|
|
|
|
fireEvent.keyDown(custom, { key: 'Enter', isComposing: true })
|
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
|
expect(respond).not.toHaveBeenCalled()
|
|
|
|
fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
|
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
|
expect(respond).not.toHaveBeenCalled()
|
|
|
|
fireEvent.keyDown(custom, { key: 'Enter' })
|
|
expect(screen.getByText('3 / 3')).toBeTruthy()
|
|
})
|
|
|
|
it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
|
|
const { carrier, respond } = wait()
|
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
|
|
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
|
|
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
|
|
fireEvent.keyDown(emptyCustom, { key: 'Enter', shiftKey: true })
|
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
|
fireEvent.keyDown(emptyCustom, { key: 'Enter' })
|
|
expect(screen.getByText('请选择一个选项或填写自定义答案。')).toBeTruthy()
|
|
|
|
fireEvent.click(screen.getByLabelText('下一题'))
|
|
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
|
|
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
|
expect(screen.getByText('请先完成这道问题。')).toBeTruthy()
|
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
|
fireEvent.click(screen.getByLabelText('上一题'))
|
|
expect(screen.getByText('1 / 3')).toBeTruthy()
|
|
expect(respond).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
|
|
const respond = vi.fn()
|
|
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
|
|
.mockRejectedValueOnce(new Error('第二次取消失败'))
|
|
const { carrier } = wait('question-1', respond)
|
|
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
|
|
|
// Receipt rejection surfaces through the domain face's thrown message.
|
|
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
|
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
|
|
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
|
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
|
|
})
|
|
|
|
it('surfaces transport rejection and resets local drafts for a different request', async () => {
|
|
const respond = vi.fn()
|
|
.mockRejectedValueOnce(new Error('网络中断'))
|
|
.mockRejectedValueOnce('字符串错误')
|
|
const first = wait('first', respond)
|
|
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
|
|
|
|
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
|
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
|
const second = wait('second', respond)
|
|
view.rerender(<QuestionComposer matched={second.carrier} interactions={[second.carrier]} {...kit} />)
|
|
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
|
|
|
|
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
|
|
const custom = screen.getByPlaceholderText('输入你的答案')
|
|
fireEvent.change(custom, { target: { value: 'x' } })
|
|
fireEvent.keyDown(custom, { key: 'Enter' })
|
|
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
|
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
|
expect(await screen.findByText('网络中断')).toBeTruthy()
|
|
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
|
expect(await screen.findByText('字符串错误')).toBeTruthy()
|
|
})
|
|
|
|
it('same-key carrier replacement (baseline replay) keeps drafts', () => {
|
|
const first = wait('same-id')
|
|
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
|
|
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
|
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
|
// Replay mints a NEW carrier for the same request; same key = no remount.
|
|
const replayed = wait('same-id')
|
|
view.rerender(<QuestionComposer matched={replayed.carrier} interactions={[replayed.carrier]} {...kit} />)
|
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('PendingQuestion domain face', () => {
|
|
it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
|
|
const respond = vi.fn()
|
|
.mockResolvedValueOnce({ accepted: true })
|
|
.mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
|
|
const question = new PendingQuestion(wait('rq', respond).carrier)
|
|
const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
|
|
await expect(question.answer(batch)).resolves.toBeUndefined()
|
|
expect(respond).toHaveBeenCalledWith(answeredEnvelope('rq', batch.answers))
|
|
await expect(question.answer(batch)).rejects.toThrow(/question response rejected: not-pending/)
|
|
})
|
|
|
|
it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => {
|
|
const respond = vi.fn()
|
|
.mockResolvedValueOnce({ accepted: true })
|
|
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
|
|
const question = new PendingQuestion(wait('rc', respond).carrier)
|
|
await expect(question.cancel()).resolves.toBeUndefined()
|
|
expect(respond).toHaveBeenCalledWith({
|
|
type: 'client-response', rpcId: RpcId('rc'),
|
|
result: {
|
|
ok: false,
|
|
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
|
|
},
|
|
})
|
|
await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/)
|
|
})
|
|
|
|
it('forwards key and questions from the carrier', () => {
|
|
const question = new PendingQuestion(wait('rk').carrier)
|
|
expect(question.key).toBe('q:rk')
|
|
expect(question.questions).toBe(wait('rk').carrier.payload.questions)
|
|
})
|
|
})
|
|
|
|
describe('parseRecommendedLabel', () => {
|
|
it('recognizes English and Chinese suffixes without changing ordinary labels', () => {
|
|
expect(parseRecommendedLabel('Fast (Recommended)')).toEqual({ label: 'Fast', recommended: true })
|
|
expect(parseRecommendedLabel('稳妥(推荐)')).toEqual({ label: '稳妥', recommended: true })
|
|
expect(parseRecommendedLabel('稳妥 (推荐)')).toEqual({ label: '稳妥', recommended: true })
|
|
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
|
|
})
|
|
})
|
|
|
|
describe('parseQuestionTitle', () => {
|
|
it('removes Chinese and ASCII multi-select suffixes', () => {
|
|
expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
|
|
expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
|
|
expect(parseQuestionTitle('选择信号')).toBe('选择信号')
|
|
})
|
|
})
|