fix(feedback): address review findings in the controller and controls
Resolve the note-erasure race the review found: a control that rendered before the first list read held no item and passed note=undefined, so switching a rating silently dropped the stored note. The controller now owns note resolution and toggle-vs-retract, deciding from the committed item inside the serialized mutation, and clearNote expresses deletion. Also from review: - serialize the reconnect re-read behind queued mutations (resync), so a list reply cannot resurrect a version a newer mutation replaced - re-check disposal after the seeding read, so an unloaded fiber never reaches the wire - drop Object.freeze on Maps, which does not prevent set/delete - declare the @deepseek-ai/dsh-client-connection dependency it imports - surface a failed list load in the controls - drop the /client value exports that had no consumer - state the per-turn render scope in the README and subsystem pages - align the package version with the root
This commit is contained in:
@@ -145,6 +145,21 @@ describe('ui-feedback browser plugin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes toggle and clearNote to the controller', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const face = b.entry()!.inject!(sid('s1'))
|
||||
expect(await face.toggle(MSG, 'negative')).toEqual({ ok: true })
|
||||
expect(await face.clearNote(MSG)).toEqual({ ok: true })
|
||||
|
||||
// The seeded item is positive with no note, so a negative toggle replaces it
|
||||
// through put, and clearNote has nothing to drop and touches no wire.
|
||||
const puts = b.calls.filter(call => call.method === 'put').map(call => call.request)
|
||||
expect(puts).toHaveLength(1)
|
||||
expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'negative' })
|
||||
})
|
||||
|
||||
it('refreshes only Sessions already read when the connection resets', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
@@ -474,4 +474,142 @@ describe('FeedbackController', () => {
|
||||
})
|
||||
expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
|
||||
})
|
||||
|
||||
it('preserves a stored note when a rating switch omits one', async () => {
|
||||
// Regression: a control that rendered before the first list read holds no
|
||||
// item, so it passes note=undefined; that must not erase the stored note.
|
||||
const stored = item({ version: version('v1'), rating: 'positive', note: 'keep me' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
|
||||
|
||||
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
|
||||
expect(put.note).toBe('keep me')
|
||||
expect(put.rating).toBe('negative')
|
||||
})
|
||||
|
||||
it('toggle retracts when the committed rating already matches', async () => {
|
||||
const stored = item({ version: version('v1'), rating: 'positive' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
|
||||
|
||||
expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
|
||||
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
})
|
||||
|
||||
it('toggle decides from the committed item, not a cold view', async () => {
|
||||
// The click lands before any list read: the cold view knows no item, yet the
|
||||
// stored rating matches, so the toggle must retract rather than re-put.
|
||||
const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
expect(controller.getSnapshot().status).toBe('cold')
|
||||
|
||||
expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
|
||||
|
||||
expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('toggle replaces the opposite rating and carries the note forward', async () => {
|
||||
const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.toggle(MSG, 'negative')).toEqual({ ok: true })
|
||||
|
||||
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
|
||||
expect(put).toMatchObject({ rating: 'negative', note: 'kept', ifVersion: version('v1') })
|
||||
})
|
||||
|
||||
it('clearNote drops the note and keeps the rating', async () => {
|
||||
const stored = item({ version: version('v1'), rating: 'negative', note: 'remove me' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.clearNote(MSG)).toEqual({ ok: true })
|
||||
|
||||
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
|
||||
expect(put.rating).toBe('negative')
|
||||
expect(put).not.toHaveProperty('note')
|
||||
})
|
||||
|
||||
it('clearNote is a no-op when there is no note to drop', async () => {
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.clearNote(MSG)).toEqual({ ok: true })
|
||||
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('resync serializes behind an in-flight mutation', async () => {
|
||||
// Regression: an unserialized reconnect read could land after a newer put
|
||||
// and resurrect the version that put had already replaced.
|
||||
const order: string[] = []
|
||||
let releasePut = (): void => {}
|
||||
const putGate = new Promise<void>((r) => { releasePut = r })
|
||||
const { remote } = fakeRemote({
|
||||
list: () => {
|
||||
order.push('list')
|
||||
return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } })
|
||||
},
|
||||
put: async () => {
|
||||
order.push('put:start')
|
||||
await putGate
|
||||
order.push('put:end')
|
||||
return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) }
|
||||
},
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
|
||||
const rating = controller.rate(MSG, 'negative')
|
||||
const resync = controller.resync()
|
||||
releasePut()
|
||||
await Promise.all([rating, resync])
|
||||
|
||||
// The reconnect read runs only after the mutation settled.
|
||||
expect(order.indexOf('list', 1)).toBeGreaterThan(order.indexOf('put:end'))
|
||||
})
|
||||
|
||||
it('refuses a mutation disposed while its seeding read is in flight', async () => {
|
||||
// Dispose only once the seeding list call has actually started, so the
|
||||
// mutation is already past the admission check and must be stopped by the
|
||||
// second guard that runs after ensure() resolves.
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((r) => { release = r })
|
||||
let started = (): void => {}
|
||||
const listStarted = new Promise<void>((r) => { started = r })
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: async () => {
|
||||
started()
|
||||
await gate
|
||||
return { ok: true, value: { items: [] } }
|
||||
},
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
const pending = controller.rate(MSG, 'positive')
|
||||
|
||||
await listStarted
|
||||
controller.dispose()
|
||||
release()
|
||||
|
||||
expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
|
||||
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,9 @@ 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 type {
|
||||
MessageFeedbackItem, MessageFeedbackRating, 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'
|
||||
@@ -38,20 +40,29 @@ function mount(options: {
|
||||
current?: MessageFeedbackItem | undefined
|
||||
rateResult?: FeedbackActionResult
|
||||
clearResult?: FeedbackActionResult
|
||||
status?: FeedbackView['status']
|
||||
} = {}) {
|
||||
const view: FeedbackView = {
|
||||
status: 'ready',
|
||||
status: options.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 rate = vi.fn((_id: MessageId, _rating: MessageFeedbackRating, _note?: string) =>
|
||||
Promise.resolve(options.rateResult ?? { ok: true as const }))
|
||||
const clear = vi.fn((_id: MessageId) =>
|
||||
Promise.resolve(options.clearResult ?? { ok: true as const }))
|
||||
// The controller owns retract-vs-replace, so the double stands in for it:
|
||||
// matching the shown rating retracts, anything else replaces.
|
||||
const toggle = vi.fn((id: MessageId, next: MessageFeedbackRating) =>
|
||||
(options.current?.rating === next ? clear(id) : rate(id, next)))
|
||||
const clearNote = vi.fn((_id: MessageId) =>
|
||||
Promise.resolve(options.rateResult ?? { 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
|
||||
const props = { messageId: MSG, ensure, rate, toggle, clearNote, clear, useFeedback, t } as unknown as
|
||||
Parameters<typeof FeedbackActions>[0]
|
||||
return { ...render(<FeedbackActions {...props} />), ensure, rate, clear }
|
||||
return { ...render(<FeedbackActions {...props} />), ensure, rate, clear, toggle, clearNote }
|
||||
}
|
||||
|
||||
describe('FeedbackActions', () => {
|
||||
@@ -91,7 +102,7 @@ describe('FeedbackActions', () => {
|
||||
|
||||
fireEvent.click(ui.getByLabelText(zh['action.like']))
|
||||
|
||||
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', undefined) })
|
||||
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'positive') })
|
||||
expect(ui.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -100,7 +111,7 @@ describe('FeedbackActions', () => {
|
||||
|
||||
fireEvent.click(ui.getByLabelText(zh['action.dislike']))
|
||||
|
||||
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'negative', 'keep me') })
|
||||
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'negative') })
|
||||
})
|
||||
|
||||
it('retracts the feedback when the active rating is clicked again', async () => {
|
||||
@@ -108,8 +119,9 @@ describe('FeedbackActions', () => {
|
||||
|
||||
fireEvent.click(ui.getByLabelText(zh['action.likeActive']))
|
||||
|
||||
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'positive') })
|
||||
// The double routes a matching rating to clear(), mirroring the controller.
|
||||
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 () => {
|
||||
@@ -130,7 +142,7 @@ describe('FeedbackActions', () => {
|
||||
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) })
|
||||
await waitFor(() => { expect(ui.clearNote).toHaveBeenCalledWith(MSG) })
|
||||
})
|
||||
|
||||
it('seeds the editor with the recorded note and abandons it on cancel', () => {
|
||||
@@ -197,6 +209,8 @@ describe('FeedbackActions', () => {
|
||||
messageId: MSG,
|
||||
ensure: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
|
||||
rate: vi.fn(() => gate),
|
||||
toggle: vi.fn(() => gate),
|
||||
clearNote: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
|
||||
clear: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
|
||||
useFeedback,
|
||||
t,
|
||||
@@ -214,4 +228,22 @@ describe('FeedbackActions', () => {
|
||||
window.removeEventListener('error', onError)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a failed list load next to the controls', async () => {
|
||||
const ui = mount({ status: 'error' })
|
||||
|
||||
expect(ui.getByText(zh['error.load'])).toBeTruthy()
|
||||
})
|
||||
|
||||
it('prefers the action failure over the load notice', async () => {
|
||||
const ui = mount({
|
||||
status: 'error',
|
||||
rateResult: { ok: false, error: { code: 'target-not-found', message: 'gone' } },
|
||||
})
|
||||
|
||||
fireEvent.click(ui.getByLabelText(zh['action.like']))
|
||||
|
||||
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
|
||||
expect(ui.queryByText(zh['error.load'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user