|
|
@@ -2,10 +2,9 @@
|
|
|
/**
|
|
|
* ask_user_question toolview acceptance: `waiting` summary while running,
|
|
|
* answered-count from the result JSON once settled (skipped answers
|
|
|
- * excluded), the cancelled/interrupted verdicts off ASK_CANCELLED and
|
|
|
- * ASK_ABORTED, shared ToolRow state
|
|
|
- * semantics for interrupted/failed calls, and generic fallbacks on
|
|
|
- * malformed results.
|
|
|
+ * excluded), readable question lists for ASK_CANCELLED and ASK_ABORTED,
|
|
|
+ * shared ToolRow state semantics for interrupted/failed calls, and generic
|
|
|
+ * fallbacks on malformed results.
|
|
|
*/
|
|
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
@@ -19,6 +18,11 @@ import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.t
|
|
|
afterEach(cleanup)
|
|
|
|
|
|
const ARGS = JSON.stringify({ questions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] })
|
|
|
+const READABLE_ARGS = JSON.stringify({ questions: [
|
|
|
+ { id: 'goal', question: 'What do you want to accomplish?' },
|
|
|
+ { id: 'scope', question: 'Which project should this apply to?' },
|
|
|
+ { id: 'notes', question: 'Anything else?' },
|
|
|
+] })
|
|
|
|
|
|
const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
|
|
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
|
|
@@ -60,6 +64,26 @@ describe('AskQuestionRow', () => {
|
|
|
expect(screen.getByText('3/3 已回答')).toBeTruthy()
|
|
|
})
|
|
|
|
|
|
+ it('expands a successful result as paired questions and readable answer lines', () => {
|
|
|
+ render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, answers([
|
|
|
+ { id: 'scope', selected: ['deepseek-harness'] },
|
|
|
+ { id: 'goal', selected: ['Develop a feature'], custom: 'Keep the API small' },
|
|
|
+ { id: 'notes', selected: [] },
|
|
|
+ ])))} />)
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
|
+
|
|
|
+ expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
|
|
|
+ expect(screen.getByText('Develop a feature')).toBeTruthy()
|
|
|
+ expect(screen.getByText('Keep the API small')).toBeTruthy()
|
|
|
+ expect(screen.getByText('Which project should this apply to?')).toBeTruthy()
|
|
|
+ expect(screen.getByText('deepseek-harness')).toBeTruthy()
|
|
|
+ expect(screen.getByText('Anything else?')).toBeTruthy()
|
|
|
+ expect(screen.getByText('未回答')).toBeTruthy()
|
|
|
+ expect(screen.queryByText(/"questions"/)).toBeNull()
|
|
|
+ expect(screen.queryByText(/"answers"/)).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
it('skipped questions (no selection, no custom) stay out of the answered count', () => {
|
|
|
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
|
|
|
{ id: 'a', selected: ['x'] },
|
|
|
@@ -82,20 +106,85 @@ describe('AskQuestionRow', () => {
|
|
|
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
|
|
|
})
|
|
|
|
|
|
- it('user cancellation names the verdict instead of the generic failed shape', () => {
|
|
|
+ it.each([
|
|
|
+ { label: 'missing id', value: { selected: ['x'] } },
|
|
|
+ { label: 'non-array selection', value: { id: 'a', selected: 'x' } },
|
|
|
+ { label: 'non-string selection', value: { id: 'a', selected: [1] } },
|
|
|
+ { label: 'non-string custom text', value: { id: 'a', selected: [], custom: 1 } },
|
|
|
+ ])('falls back to generic JSON for a result with $label', ({ value }) => {
|
|
|
+ const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, answers([value])))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
|
+ expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each([
|
|
|
+ { label: 'non-JSON args', args: 'oops' },
|
|
|
+ { label: 'non-object args', args: '[]' },
|
|
|
+ { label: 'missing question array', args: '{}' },
|
|
|
+ { label: 'non-object question', args: '{"questions":[null]}' },
|
|
|
+ { label: 'non-string question id', args: '{"questions":[{"id":1,"question":"Q"}]}' },
|
|
|
+ { label: 'non-string question text', args: '{"questions":[{"id":"a","question":1}]}' },
|
|
|
+ { label: 'duplicate question ids', args: '{"questions":[{"id":"a","question":"Q1"},{"id":"a","question":"Q2"}]}' },
|
|
|
+ { label: 'different question count', args: '{"questions":[]}' },
|
|
|
+ ])('keeps raw details when answer pairing sees $label', ({ args }) => {
|
|
|
+ const view = render(<AskQuestionRow {...rowProps(resultNode(args, answers([
|
|
|
+ { id: 'a', selected: ['x'] },
|
|
|
+ ])))} />)
|
|
|
+ expect(screen.getByText('1/1 已回答')).toBeTruthy()
|
|
|
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
|
+ expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each([
|
|
|
+ { label: 'duplicate answer ids', values: [{ id: 'a', selected: ['x'] }, { id: 'a', selected: ['y'] }] },
|
|
|
+ { label: 'unknown answer id', values: [{ id: 'other', selected: ['x'] }] },
|
|
|
+ ])('keeps raw details for $label', ({ values }) => {
|
|
|
+ const args = JSON.stringify({ questions: values.map((_, index) => ({
|
|
|
+ id: String.fromCharCode(97 + index), question: `Question ${String(index + 1)}`,
|
|
|
+ })) })
|
|
|
+ const view = render(<AskQuestionRow {...rowProps(resultNode(args, answers(values)))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
|
+ expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('user cancellation shows the original questions without raw JSON or an error body', () => {
|
|
|
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
|
|
|
- const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
|
|
+ const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
|
|
|
{ isError: true, error: { name: 'UserQuestionError', code: 'ASK_CANCELLED' } }))} />)
|
|
|
expect(screen.getByText('已取消')).toBeTruthy()
|
|
|
- expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
|
|
+ expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
|
|
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
|
+ expect(screen.getByText('本轮已取消,未提交回答')).toBeTruthy()
|
|
|
+ expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
|
|
|
+ expect(screen.getByText('Which project should this apply to?')).toBeTruthy()
|
|
|
+ expect(screen.getByText('Anything else?')).toBeTruthy()
|
|
|
+ expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
|
|
|
+ expect(screen.queryByText(/"questions"/)).toBeNull()
|
|
|
+ expect(screen.queryByText(/the user cancelled ask_user_question/)).toBeNull()
|
|
|
})
|
|
|
|
|
|
- it('a turn abort while pending reads interrupted with stopped semantics', () => {
|
|
|
+ it('a turn abort shows the original questions with stopped semantics', () => {
|
|
|
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
|
|
|
- const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
|
|
+ const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
|
|
|
{ isError: true, error: { name: 'UserQuestionError', code: 'ASK_ABORTED' } }))} />)
|
|
|
expect(screen.getByText('已中断')).toBeTruthy()
|
|
|
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
|
|
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
|
+ expect(screen.getByText('本轮已中断,未提交回答')).toBeTruthy()
|
|
|
+ expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
|
|
|
+ expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each([
|
|
|
+ { label: 'non-JSON args', args: 'oops' },
|
|
|
+ { label: 'an empty question set', args: '{"questions":[]}' },
|
|
|
+ { label: 'a question without visible text', args: '{"questions":[{"id":"a"}]}' },
|
|
|
+ ])('cancelled result keeps raw diagnostics for $label', ({ args }) => {
|
|
|
+ const view = render(<AskQuestionRow {...rowProps(resultNode(args, null,
|
|
|
+ { isError: true, error: { name: 'UserQuestionError', code: 'ASK_CANCELLED' } }))} />)
|
|
|
+ expect(screen.getByText('已取消')).toBeTruthy()
|
|
|
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
|
|
|
+ expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
|
|
|
})
|
|
|
|
|
|
it('an interrupted turn reads as stopped, not cancelled', () => {
|