Files
deepseek-harness/packages/client/ui-feedback/tests/feedback-actions.spec.tsx
Chinesezjc 526febc44d feat(feedback): add the Web surface for message feedback
Consume the durable message-feedback sidecar from #2217 in the browser:
per-message Like/Dislike with an optional note, contributed through a
declared assistant-actions slot.

- carry MessageId on finalized AssistantMessageNode so a target is nameable
- declare conversation.chat.assistant-actions and render it in the
  IconActions row between copy and branch
- hold one FeedbackController per Session with per-item ifVersion CAS,
  reconciling a version-conflict from the reply's authoritative item
- mount messageFeedbackRemote alongside goalsRemote
2026-08-11 17:14:21 +08:00

173 lines
6.7 KiB
TypeScript

// @vitest-environment jsdom
/**
* FeedbackActions rendering and gestures: the rating buttons reflect the
* shared view, re-clicking the active rating retracts it, the note editor
* saves through the same rate verb, the Session's feedback is read on first
* interaction rather than on mount, and a rejected mutation surfaces inline
* without losing the authoritative state.
*/
import { useSyncExternalStore } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
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 { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
import { FeedbackActions } from '../src/client/FeedbackActions.tsx'
import type { FeedbackActionResult, FeedbackView } from '../src/client/controller.ts'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const MSG = 'm-1' as MessageId
const t = makeTranslate(zh, commonZh)
function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem {
return {
messageId: MSG,
rating: 'positive',
version: 'v1' as MessageFeedbackVersion,
createdAt: 1,
updatedAt: 1,
...overrides,
}
}
/** Render the controls over a fixed view and recording verbs. */
function mount(options: {
current?: MessageFeedbackItem | undefined
rateResult?: FeedbackActionResult
clearResult?: FeedbackActionResult
} = {}) {
const view: FeedbackView = {
status: 'ready',
items: new Map(options.current === undefined ? [] : [[MSG, options.current]]),
error: null,
}
const ensure = vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true }))
const rate = vi.fn(() => Promise.resolve(options.rateResult ?? { ok: true as const }))
const clear = vi.fn(() => Promise.resolve(options.clearResult ?? { ok: true as const }))
const useFeedback = (<T,>(select: (v: FeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = { messageId: MSG, ensure, rate, clear, useFeedback, t } as unknown as
Parameters<typeof FeedbackActions>[0]
return { ...render(<FeedbackActions {...props} />), ensure, rate, clear }
}
describe('FeedbackActions', () => {
it('renders both rating buttons unpressed with no recorded feedback', () => {
const ui = mount()
expect(ui.getByLabelText(zh['action.like']).getAttribute('aria-pressed')).toBe('false')
expect(ui.getByLabelText(zh['action.dislike']).getAttribute('aria-pressed')).toBe('false')
})
it('marks the recorded rating pressed and offers to retract it', () => {
const ui = mount({ current: item({ rating: 'negative' }) })
expect(ui.getByLabelText(zh['action.dislikeActive']).getAttribute('aria-pressed')).toBe('true')
expect(ui.getByLabelText(zh['action.like']).getAttribute('aria-pressed')).toBe('false')
})
it('reads the Session feedback on first interaction, once', () => {
const ui = mount()
const like = ui.getByLabelText(zh['action.like'])
fireEvent.pointerEnter(like)
fireEvent.pointerEnter(like)
fireEvent.focus(ui.getByLabelText(zh['action.dislike']))
expect(ui.ensure).toHaveBeenCalledTimes(1)
})
it('does not read the Session feedback on mount', () => {
const ui = mount()
expect(ui.ensure).not.toHaveBeenCalled()
})
it('rates a message that has no feedback yet', async () => {
const ui = mount()
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', undefined) })
expect(ui.clear).not.toHaveBeenCalled()
})
it('replaces the opposite rating and carries the existing note forward', async () => {
const ui = mount({ current: item({ rating: 'positive', note: 'keep me' }) })
fireEvent.click(ui.getByLabelText(zh['action.dislike']))
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'negative', 'keep me') })
})
it('retracts the feedback when the active rating is clicked again', async () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByLabelText(zh['action.likeActive']))
await waitFor(() => { expect(ui.clear).toHaveBeenCalledWith(MSG) })
expect(ui.rate).not.toHaveBeenCalled()
})
it('saves a typed note through the rate verb and closes the editor', async () => {
const ui = mount({ current: item({ rating: 'positive' }) })
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: ' precise and short ' } })
fireEvent.click(ui.getByText(zh['note.save']))
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', 'precise and short') })
await waitFor(() => { expect(ui.queryByLabelText(zh['note.aria'])).toBeNull() })
})
it('clears the note when the editor is emptied', async () => {
const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
fireEvent.click(ui.getByText('old note'))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: ' ' } })
fireEvent.click(ui.getByText(zh['note.save']))
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', undefined) })
})
it('seeds the editor with the recorded note and abandons it on cancel', () => {
const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
fireEvent.click(ui.getByText('old note'))
expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('old note')
fireEvent.click(ui.getByText(zh['note.cancel']))
expect(ui.queryByLabelText(zh['note.aria'])).toBeNull()
expect(ui.rate).not.toHaveBeenCalled()
})
it('offers no note editor before a rating is recorded', () => {
const ui = mount()
expect(ui.queryByText(zh['note.open'])).toBeNull()
})
it('reports a lost race with the conflict copy', async () => {
const ui = mount({
rateResult: { ok: false, error: { code: 'version-conflict', message: 'feedback changed elsewhere' } },
})
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.getByText(zh['error.conflict'])).toBeTruthy() })
})
it('reports any other failure with the generic copy', async () => {
const ui = mount({
rateResult: { ok: false, error: { code: 'target-not-found', message: 'no such message' } },
})
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
})
})