|
|
@@ -1,14 +1,15 @@
|
|
|
// @vitest-environment jsdom
|
|
|
/**
|
|
|
* MessageFeedbackActions 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.
|
|
|
+ * shared view with the filled glyph for a recorded rating, Like records at once
|
|
|
+ * and raises the acknowledgement only when a rating was recorded, Dislike opens
|
|
|
+ * the Session's dialog unless it retracts a recorded Dislike, 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 { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
|
|
+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-api-remotes/client'
|
|
|
@@ -16,7 +17,9 @@ import type {
|
|
|
MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion,
|
|
|
} from '@deepseek-ai/dsh-message-feedback/types'
|
|
|
import { MessageFeedbackActions } from '../src/client/MessageFeedbackActions.tsx'
|
|
|
-import type { MessageFeedbackActionResult, MessageFeedbackView } from '../src/client/controller.ts'
|
|
|
+import type {
|
|
|
+ MessageFeedbackActionResult, MessageFeedbackToggleResult, MessageFeedbackView,
|
|
|
+} from '../src/client/controller.ts'
|
|
|
import { zh } from '../src/client/locales.ts'
|
|
|
|
|
|
afterEach(cleanup)
|
|
|
@@ -38,8 +41,9 @@ function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem
|
|
|
/** Render the controls over a fixed view and recording verbs. */
|
|
|
function mount(options: {
|
|
|
current?: MessageFeedbackItem | undefined
|
|
|
- rateResult?: MessageFeedbackActionResult
|
|
|
- clearResult?: MessageFeedbackActionResult
|
|
|
+ /** The controller's committed item when it differs from the rendered view (a cold row). */
|
|
|
+ committed?: MessageFeedbackItem
|
|
|
+ toggleResult?: MessageFeedbackToggleResult
|
|
|
status?: MessageFeedbackView['status']
|
|
|
} = {}) {
|
|
|
const view: MessageFeedbackView = {
|
|
|
@@ -48,21 +52,19 @@ function mount(options: {
|
|
|
error: null,
|
|
|
}
|
|
|
const ensure = vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true }))
|
|
|
- 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 }))
|
|
|
+ // The controller owns record-vs-retract, so the double stands in for it:
|
|
|
+ // matching the shown rating retracts, anything else records.
|
|
|
+ const toggle = vi.fn((_id: MessageId, next: MessageFeedbackRating) =>
|
|
|
+ Promise.resolve<MessageFeedbackToggleResult>(options.toggleResult
|
|
|
+ ?? { ok: true, rating: options.current?.rating === next ? null : next }))
|
|
|
+ const openDialog = vi.fn((_id: MessageId) => {})
|
|
|
+ const acknowledge = vi.fn(() => {})
|
|
|
+ const current = vi.fn((_id: MessageId) => options.committed ?? options.current)
|
|
|
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
|
|
useSyncExternalStore(() => () => {}, () => select(view))) as never
|
|
|
- const props = { messageId: MSG, ensure, rate, toggle, clearNote, clear, useFeedback, t } as unknown as
|
|
|
+ const props = { messageId: MSG, ensure, current, toggle, openDialog, acknowledge, useFeedback, t } as unknown as
|
|
|
Parameters<typeof MessageFeedbackActions>[0]
|
|
|
- return { ...render(<MessageFeedbackActions {...props} />), ensure, rate, clear, toggle, clearNote }
|
|
|
+ return { ...render(<MessageFeedbackActions {...props} />), ensure, toggle, openDialog, acknowledge }
|
|
|
}
|
|
|
|
|
|
describe('MessageFeedbackActions', () => {
|
|
|
@@ -73,11 +75,14 @@ describe('MessageFeedbackActions', () => {
|
|
|
expect(ui.getByLabelText(zh['action.dislike']).getAttribute('aria-pressed')).toBe('false')
|
|
|
})
|
|
|
|
|
|
- it('marks the recorded rating pressed and offers to retract it', () => {
|
|
|
+ it('marks the recorded rating pressed, fills its glyph, and offers to retract it', () => {
|
|
|
const ui = mount({ current: item({ rating: 'negative' }) })
|
|
|
|
|
|
- expect(ui.getByLabelText(zh['action.dislikeActive']).getAttribute('aria-pressed')).toBe('true')
|
|
|
+ const dislike = ui.getByLabelText(zh['action.dislikeActive'])
|
|
|
+ expect(dislike.getAttribute('aria-pressed')).toBe('true')
|
|
|
+ expect(dislike.hasAttribute('data-active')).toBe(true)
|
|
|
expect(ui.getByLabelText(zh['action.like']).getAttribute('aria-pressed')).toBe('false')
|
|
|
+ expect(ui.getByLabelText(zh['action.like']).hasAttribute('data-active')).toBe(false)
|
|
|
})
|
|
|
|
|
|
it('reads the Session feedback on first interaction, once', () => {
|
|
|
@@ -97,84 +102,77 @@ describe('MessageFeedbackActions', () => {
|
|
|
expect(ui.ensure).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- it('rates a message that has no feedback yet', async () => {
|
|
|
+ it('records a Like at once and acknowledges it', async () => {
|
|
|
const ui = mount()
|
|
|
|
|
|
fireEvent.click(ui.getByLabelText(zh['action.like']))
|
|
|
|
|
|
await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'positive') })
|
|
|
- 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.toggle).toHaveBeenCalledWith(MSG, 'negative') })
|
|
|
+ await waitFor(() => { expect(ui.acknowledge).toHaveBeenCalledTimes(1) })
|
|
|
+ expect(ui.openDialog).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- it('retracts the feedback when the active rating is clicked again', async () => {
|
|
|
+ it('retracts a recorded Like on click without acknowledging', async () => {
|
|
|
const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
|
|
|
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) })
|
|
|
+ await waitFor(() => { expect(ui.getByLabelText(zh['action.likeActive']).hasAttribute('disabled')).toBe(false) })
|
|
|
+ expect(ui.acknowledge).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- it('saves a typed note through the rate verb and closes the editor', async () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
+ it('opens the dialog for a Dislike instead of recording it', async () => {
|
|
|
+ const ui = mount()
|
|
|
|
|
|
- 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']))
|
|
|
+ fireEvent.click(ui.getByLabelText(zh['action.dislike']))
|
|
|
|
|
|
- await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', 'precise and short') })
|
|
|
- await waitFor(() => { expect(ui.queryByLabelText(zh['note.aria'])).toBeNull() })
|
|
|
+ await waitFor(() => { expect(ui.openDialog).toHaveBeenCalledWith(MSG) })
|
|
|
+ expect(ui.toggle).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- it('clears the note when the editor is emptied', async () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
|
|
|
+ it('opens the dialog for a Dislike that replaces a recorded Like', async () => {
|
|
|
+ const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
|
|
|
- fireEvent.click(ui.getByText('old note'))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: ' ' } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
+ fireEvent.click(ui.getByLabelText(zh['action.dislike']))
|
|
|
|
|
|
- await waitFor(() => { expect(ui.clearNote).toHaveBeenCalledWith(MSG) })
|
|
|
+ await waitFor(() => { expect(ui.openDialog).toHaveBeenCalledWith(MSG) })
|
|
|
+ expect(ui.toggle).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- it('seeds the editor with the recorded note and abandons it on cancel', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
|
|
|
+ it('decides a Dislike from the committed item, so a cold row retracts a stored Dislike', async () => {
|
|
|
+ const ui = mount({ committed: item({ rating: 'negative' }) })
|
|
|
|
|
|
- fireEvent.click(ui.getByText('old note'))
|
|
|
- expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('old note')
|
|
|
+ fireEvent.click(ui.getByLabelText(zh['action.dislike']))
|
|
|
|
|
|
- fireEvent.click(ui.getByText(zh['note.cancel']))
|
|
|
- expect(ui.queryByLabelText(zh['note.aria'])).toBeNull()
|
|
|
- expect(ui.rate).not.toHaveBeenCalled()
|
|
|
+ await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'negative') })
|
|
|
+ expect(ui.openDialog).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- it('offers no note editor before a rating is recorded', () => {
|
|
|
- const ui = mount()
|
|
|
+ it('retracts a recorded Dislike on click without the dialog', async () => {
|
|
|
+ const ui = mount({ current: item({ rating: 'negative' }) })
|
|
|
|
|
|
- expect(ui.queryByText(zh['note.open'])).toBeNull()
|
|
|
+ fireEvent.click(ui.getByLabelText(zh['action.dislikeActive']))
|
|
|
+
|
|
|
+ await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'negative') })
|
|
|
+ expect(ui.openDialog).not.toHaveBeenCalled()
|
|
|
+ expect(ui.acknowledge).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
it('reports a lost race with the conflict copy', async () => {
|
|
|
const ui = mount({
|
|
|
- rateResult: { ok: false, error: { code: 'version-conflict', message: 'feedback changed elsewhere' } },
|
|
|
+ toggleResult: { 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() })
|
|
|
+ expect(ui.acknowledge).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
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' } },
|
|
|
+ toggleResult: { ok: false, error: { code: 'target-not-found', message: 'no such message' } },
|
|
|
})
|
|
|
|
|
|
fireEvent.click(ui.getByLabelText(zh['action.like']))
|
|
|
@@ -182,24 +180,20 @@ describe('MessageFeedbackActions', () => {
|
|
|
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
|
|
|
})
|
|
|
|
|
|
- it('keeps the editor open when the note fails to save', async () => {
|
|
|
+ it('reports a failed retraction of a recorded Dislike', async () => {
|
|
|
const ui = mount({
|
|
|
- current: item({ rating: 'positive' }),
|
|
|
- rateResult: { ok: false, error: { code: 'note-too-large', message: 'too long' } },
|
|
|
+ current: item({ rating: 'negative' }),
|
|
|
+ toggleResult: { ok: false, error: { code: 'version-conflict', message: 'feedback changed elsewhere' } },
|
|
|
})
|
|
|
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'x'.repeat(20) } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
+ fireEvent.click(ui.getByLabelText(zh['action.dislikeActive']))
|
|
|
|
|
|
- await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
|
|
|
- // The draft survives so the human can shorten it instead of retyping.
|
|
|
- expect(ui.getByLabelText(zh['note.aria'])).toBeTruthy()
|
|
|
+ await waitFor(() => { expect(ui.getByText(zh['error.conflict'])).toBeTruthy() })
|
|
|
})
|
|
|
|
|
|
it('publishes no state after the row unmounts mid-flight', async () => {
|
|
|
let release = (): void => {}
|
|
|
- const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
|
|
|
+ const gate = new Promise<MessageFeedbackToggleResult>((resolve) => {
|
|
|
release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
|
|
|
})
|
|
|
const view: MessageFeedbackView = { status: 'ready', items: new Map(), error: null }
|
|
|
@@ -208,10 +202,9 @@ describe('MessageFeedbackActions', () => {
|
|
|
const props = {
|
|
|
messageId: MSG,
|
|
|
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- rate: vi.fn(() => gate),
|
|
|
toggle: vi.fn(() => gate),
|
|
|
- clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
+ openDialog: vi.fn(),
|
|
|
+ acknowledge: vi.fn(),
|
|
|
useFeedback,
|
|
|
t,
|
|
|
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
|
|
@@ -229,126 +222,23 @@ describe('MessageFeedbackActions', () => {
|
|
|
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()
|
|
|
- })
|
|
|
-
|
|
|
- it('portals the note editor to the document body, not into the actions row', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
-
|
|
|
- // The editor must float above the transcript (escaping the conversation
|
|
|
- // column's overflow clip), so it renders through a portal to document.body
|
|
|
- // rather than inline inside the component's own container.
|
|
|
- const panel = ui.getByRole('dialog')
|
|
|
- expect(panel).toBeTruthy()
|
|
|
- expect(ui.container.querySelector('[role="dialog"]')).toBeNull()
|
|
|
- expect(document.body.contains(panel)).toBe(true)
|
|
|
- })
|
|
|
-
|
|
|
- it('closes the note popover on Escape', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
-
|
|
|
- fireEvent.keyDown(document, { key: 'Escape' })
|
|
|
-
|
|
|
- expect(ui.queryByRole('dialog')).toBeNull()
|
|
|
- })
|
|
|
-
|
|
|
- it('closes the note popover on an outside pointer-down', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
-
|
|
|
- fireEvent.pointerDown(document.body)
|
|
|
-
|
|
|
- expect(ui.queryByRole('dialog')).toBeNull()
|
|
|
- })
|
|
|
-
|
|
|
- it('keeps the note popover open on a pointer-down inside it', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- const panel = ui.getByRole('dialog')
|
|
|
- expect(panel).toBeTruthy()
|
|
|
-
|
|
|
- fireEvent.pointerDown(panel)
|
|
|
-
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
- })
|
|
|
-
|
|
|
- it('does not close the note popover on a pointer-down on its trigger', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
-
|
|
|
- // The trigger is inside the panel's own region, so pressing it must not be
|
|
|
- // treated as an outside click; the toggle click below then closes it.
|
|
|
- fireEvent.pointerDown(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
- })
|
|
|
-
|
|
|
- it('toggles the note popover closed and open from its trigger', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
- expect(ui.getByLabelText(zh['note.aria'])).toBeTruthy()
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.queryByRole('dialog')).toBeNull()
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
- })
|
|
|
-
|
|
|
- it('ignores keys other than Escape while the popover is open', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
-
|
|
|
- fireEvent.keyDown(document, { key: 'Enter' })
|
|
|
-
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
- })
|
|
|
-
|
|
|
- it('publishes no rating-state after the row unmounts mid-flight', async () => {
|
|
|
- // Directly exercise the early-return of a rating settle once the control has
|
|
|
- // unmounted: the promise resolution must not touch React state.
|
|
|
+ it('publishes no state after the row unmounts mid-retraction', async () => {
|
|
|
let release = (): void => {}
|
|
|
- const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
|
|
|
- release = () => { resolve({ ok: true as const }) }
|
|
|
+ const gate = new Promise<MessageFeedbackToggleResult>((resolve) => {
|
|
|
+ release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
|
|
|
})
|
|
|
- const view: MessageFeedbackView = { status: 'ready', items: new Map(), error: null }
|
|
|
+ const view: MessageFeedbackView = {
|
|
|
+ status: 'ready', items: new Map([[MSG, item({ rating: 'negative' })]]), error: null,
|
|
|
+ }
|
|
|
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
|
|
useSyncExternalStore(() => () => {}, () => select(view))) as never
|
|
|
const props = {
|
|
|
messageId: MSG,
|
|
|
ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- rate: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
+ current: () => item({ rating: 'negative' }),
|
|
|
toggle: vi.fn(() => gate),
|
|
|
- clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
+ openDialog: vi.fn(),
|
|
|
+ acknowledge: vi.fn(),
|
|
|
useFeedback,
|
|
|
t,
|
|
|
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
|
|
@@ -357,43 +247,7 @@ describe('MessageFeedbackActions', () => {
|
|
|
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
|
|
|
window.addEventListener('error', onError)
|
|
|
|
|
|
- fireEvent.click(ui.getByLabelText(zh['action.like']))
|
|
|
- ui.unmount()
|
|
|
- release()
|
|
|
- await gate
|
|
|
-
|
|
|
- window.removeEventListener('error', onError)
|
|
|
- expect(errors).toEqual([])
|
|
|
- })
|
|
|
-
|
|
|
- it('publishes no note-state after the row unmounts mid-save', async () => {
|
|
|
- // Same unmount early-return for the note-save settle path: resolving the
|
|
|
- // save promise after unmount must not touch React state.
|
|
|
- let release = (): void => {}
|
|
|
- const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
|
|
|
- release = () => { resolve({ ok: true as const }) }
|
|
|
- })
|
|
|
- const view: MessageFeedbackView = { status: 'ready', items: new Map([[MSG, item({ rating: 'positive' })]]), error: null }
|
|
|
- const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
|
|
- useSyncExternalStore(() => () => {}, () => select(view))) as never
|
|
|
- const props = {
|
|
|
- messageId: MSG,
|
|
|
- ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- rate: vi.fn(() => gate),
|
|
|
- toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- useFeedback,
|
|
|
- t,
|
|
|
- } as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
|
|
- const errors: unknown[] = []
|
|
|
- const onError = (event: ErrorEvent): void => { errors.push(event.error) }
|
|
|
- window.addEventListener('error', onError)
|
|
|
-
|
|
|
- const ui = render(<MessageFeedbackActions {...props} />)
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
+ fireEvent.click(ui.getByLabelText(zh['action.dislikeActive']))
|
|
|
ui.unmount()
|
|
|
release()
|
|
|
await gate
|
|
|
@@ -402,338 +256,21 @@ describe('MessageFeedbackActions', () => {
|
|
|
expect(errors).toEqual([])
|
|
|
})
|
|
|
|
|
|
- it('ignores a pointer-down whose target is not a DOM node', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
-
|
|
|
- // The outside-click guard returns without closing when the event target is
|
|
|
- // not a DOM node. `document.dispatchEvent` delivers straight to the
|
|
|
- // document listener, and a non-Node target is not `instanceof Node`.
|
|
|
- const event = new MouseEvent('pointerdown', { bubbles: true })
|
|
|
- Object.defineProperty(event, 'target', { configurable: true, value: { notANode: true } })
|
|
|
- document.dispatchEvent(event)
|
|
|
-
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
- })
|
|
|
-
|
|
|
- it('returns focus to the trigger when the popover closes', () => {
|
|
|
- const ui = mount({ current: item({ rating: 'positive' }) })
|
|
|
- const trigger = ui.getByText(zh['note.open'])
|
|
|
-
|
|
|
- fireEvent.click(trigger)
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
- fireEvent.keyDown(document, { key: 'Escape' })
|
|
|
-
|
|
|
- // Closing hands focus back, so a keyboard user resumes on the row they
|
|
|
- // came from rather than at the document root.
|
|
|
- expect(ui.queryByRole('dialog')).toBeNull()
|
|
|
- expect(document.activeElement).toBe(trigger)
|
|
|
- })
|
|
|
-
|
|
|
- it('does not pull focus when an already-rated message mounts', () => {
|
|
|
- // The `wasOpen` guard exists for this: a transcript of already-rated
|
|
|
- // messages must not drag focus into an action row as each one mounts.
|
|
|
- // Only a real open-then-close returns focus.
|
|
|
- const elsewhere = document.createElement('button')
|
|
|
- document.body.append(elsewhere)
|
|
|
- elsewhere.focus()
|
|
|
-
|
|
|
- mount({ current: item({ rating: 'positive' }) })
|
|
|
+ it('surfaces a failed list load next to the controls', () => {
|
|
|
+ const ui = mount({ status: 'error' })
|
|
|
|
|
|
- expect(document.activeElement).toBe(elsewhere)
|
|
|
- elsewhere.remove()
|
|
|
+ expect(ui.getByText(zh['error.load'])).toBeTruthy()
|
|
|
})
|
|
|
|
|
|
- it('drops a stale save failure when the popover is reopened', async () => {
|
|
|
- // The failure belongs to the editing session that produced it: reopening
|
|
|
- // reseeds the draft from the stored note, so a carried-over error would
|
|
|
- // describe an attempt the new draft never made.
|
|
|
+ it('prefers the action failure over the load notice', async () => {
|
|
|
const ui = mount({
|
|
|
- current: item({ rating: 'positive' }),
|
|
|
- rateResult: { ok: false, error: { code: 'note-too-large', message: 'too long' } },
|
|
|
- })
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'x'.repeat(20) } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
- await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
|
|
|
-
|
|
|
- fireEvent.keyDown(document, { key: 'Escape' })
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
-
|
|
|
- expect(ui.queryByText(zh['error.generic'])).toBeNull()
|
|
|
- })
|
|
|
-
|
|
|
- it('keeps a save failure visible when the rating disappears underneath it', async () => {
|
|
|
- // Another client retracts the feedback while the editor is open: the
|
|
|
- // controller commits `current: null`, the item goes away, and the panel
|
|
|
- // unmounts. The failure must not vanish with it, so it falls back to the row.
|
|
|
- let release = (): void => {}
|
|
|
- const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
|
|
|
- release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
|
|
|
+ status: 'error',
|
|
|
+ toggleResult: { ok: false, error: { code: 'target-not-found', message: 'gone' } },
|
|
|
})
|
|
|
- const view: MessageFeedbackView = {
|
|
|
- status: 'ready',
|
|
|
- items: new Map([[MSG, item({ rating: 'positive' })]]),
|
|
|
- error: null,
|
|
|
- }
|
|
|
- let notify = (): void => {}
|
|
|
- const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
|
|
- useSyncExternalStore((cb) => { notify = cb; return () => {} }, () => select(view))) as never
|
|
|
- const props = {
|
|
|
- messageId: MSG,
|
|
|
- ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- rate: vi.fn(() => gate),
|
|
|
- toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- useFeedback,
|
|
|
- t,
|
|
|
- } as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
|
|
- const ui = render(<MessageFeedbackActions {...props} />)
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
|
|
|
- // The retract lands first, then the save rejects.
|
|
|
- view.items = new Map()
|
|
|
- notify()
|
|
|
- release()
|
|
|
- await gate
|
|
|
+ fireEvent.click(ui.getByLabelText(zh['action.like']))
|
|
|
|
|
|
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
|
|
|
- expect(ui.queryByRole('dialog')).toBeNull()
|
|
|
- })
|
|
|
-
|
|
|
- it('ignores a save that resolves after its editing session ended', async () => {
|
|
|
- // Closing and reopening starts a new session. A late success from the old
|
|
|
- // one must not shut the panel the human just opened.
|
|
|
- let release = (): void => {}
|
|
|
- const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
|
|
|
- release = () => { resolve({ ok: true as const }) }
|
|
|
- })
|
|
|
- const view: MessageFeedbackView = {
|
|
|
- status: 'ready',
|
|
|
- items: new Map([[MSG, item({ rating: 'positive' })]]),
|
|
|
- error: null,
|
|
|
- }
|
|
|
- const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
|
|
- useSyncExternalStore(() => () => {}, () => select(view))) as never
|
|
|
- const props = {
|
|
|
- messageId: MSG,
|
|
|
- ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- rate: vi.fn(() => gate),
|
|
|
- toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- useFeedback,
|
|
|
- t,
|
|
|
- } as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
|
|
- const ui = render(<MessageFeedbackActions {...props} />)
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'first' } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
-
|
|
|
- // Abandon that session and start another before the save lands.
|
|
|
- fireEvent.keyDown(document, { key: 'Escape' })
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
-
|
|
|
- release()
|
|
|
- await gate
|
|
|
- // Flush the `.then` continuation and the render it would cause. Asserted
|
|
|
- // directly rather than through `waitFor`, which would retry past a panel
|
|
|
- // that the stale result closed.
|
|
|
- await act(async () => { await Promise.resolve() })
|
|
|
-
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
- // The reply is discarded, but the request is no longer in flight, so the
|
|
|
- // controls must not stay disabled: `pending` gates the rating buttons and
|
|
|
- // Save, and leaving it set locks this message's row until it remounts.
|
|
|
- expect(ui.getByLabelText(zh['action.likeActive']).hasAttribute('disabled')).toBe(false)
|
|
|
- expect(ui.getByLabelText(zh['action.dislike']).hasAttribute('disabled')).toBe(false)
|
|
|
- expect(ui.getByText(zh['note.save']).hasAttribute('disabled')).toBe(false)
|
|
|
- })
|
|
|
-
|
|
|
- it('reports a save that fails after the human closed the panel', async () => {
|
|
|
- // A slow save that rejects once the panel is gone must not be swallowed:
|
|
|
- // the human would otherwise believe the note was stored. With no panel to
|
|
|
- // show it in, the row carries the notice.
|
|
|
- let release = (): void => {}
|
|
|
- const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
|
|
|
- release = () => {
|
|
|
- resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } })
|
|
|
- }
|
|
|
- })
|
|
|
- const view: MessageFeedbackView = {
|
|
|
- status: 'ready',
|
|
|
- items: new Map([[MSG, item({ rating: 'positive' })]]),
|
|
|
- error: null,
|
|
|
- }
|
|
|
- const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
|
|
- useSyncExternalStore(() => () => {}, () => select(view))) as never
|
|
|
- const props = {
|
|
|
- messageId: MSG,
|
|
|
- ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- rate: vi.fn(() => gate),
|
|
|
- toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- useFeedback,
|
|
|
- t,
|
|
|
- } as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
|
|
- const ui = render(<MessageFeedbackActions {...props} />)
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
-
|
|
|
- // Walk away before the reply lands, and leave it closed.
|
|
|
- fireEvent.keyDown(document, { key: 'Escape' })
|
|
|
- expect(ui.queryByRole('dialog')).toBeNull()
|
|
|
-
|
|
|
- release()
|
|
|
- await gate
|
|
|
- await act(async () => { await Promise.resolve() })
|
|
|
-
|
|
|
- expect(ui.getByText(zh['error.generic'])).toBeTruthy()
|
|
|
- })
|
|
|
-
|
|
|
- it('does not write an abandoned session\'s failure into a reopened panel', async () => {
|
|
|
- // The old request rejects after the panel was closed and reopened, so the
|
|
|
- // new session owns the panel. Its draft was not the one that failed, so the
|
|
|
- // stale error must not be shown there; it belongs to the abandoned session.
|
|
|
- let release = (): void => {}
|
|
|
- const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
|
|
|
- release = () => {
|
|
|
- resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } })
|
|
|
- }
|
|
|
- })
|
|
|
- const view: MessageFeedbackView = {
|
|
|
- status: 'ready',
|
|
|
- items: new Map([[MSG, item({ rating: 'positive' })]]),
|
|
|
- error: null,
|
|
|
- }
|
|
|
- const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
|
|
- useSyncExternalStore(() => () => {}, () => select(view))) as never
|
|
|
- const props = {
|
|
|
- messageId: MSG,
|
|
|
- ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- rate: vi.fn(() => gate),
|
|
|
- toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- useFeedback,
|
|
|
- t,
|
|
|
- } as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
|
|
- const ui = render(<MessageFeedbackActions {...props} />)
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'first' } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
-
|
|
|
- // Abandon that session and start another before the save rejects; unlike
|
|
|
- // the closed-and-left case, a new panel is now on screen.
|
|
|
- fireEvent.keyDown(document, { key: 'Escape' })
|
|
|
- fireEvent.click(ui.getByText(zh['note.open']))
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
-
|
|
|
- release()
|
|
|
- await gate
|
|
|
- await act(async () => { await Promise.resolve() })
|
|
|
-
|
|
|
- // The stale failure names a draft the new session never sent, so it stays
|
|
|
- // out of the reopened panel's status area.
|
|
|
- expect(ui.queryByText(zh['error.generic'])).toBeNull()
|
|
|
- expect(ui.getByRole('dialog')).toBeTruthy()
|
|
|
- })
|
|
|
-
|
|
|
- it('resyncs an untouched reopened draft to the note that just committed', async () => {
|
|
|
- // The reopened session seeded from the note as it read before the save
|
|
|
- // committed, so an untouched draft would show stale text and the next save
|
|
|
- // could overwrite what just landed.
|
|
|
- let release = (): void => {}
|
|
|
- const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
|
|
|
- release = () => { resolve({ ok: true as const }) }
|
|
|
- })
|
|
|
- const view: MessageFeedbackView = {
|
|
|
- status: 'ready',
|
|
|
- items: new Map([[MSG, item({ rating: 'positive', note: 'old' })]]),
|
|
|
- error: null,
|
|
|
- }
|
|
|
- const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
|
|
- useSyncExternalStore(() => () => {}, () => select(view))) as never
|
|
|
- const props = {
|
|
|
- messageId: MSG,
|
|
|
- ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- rate: vi.fn(() => gate),
|
|
|
- toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- useFeedback,
|
|
|
- t,
|
|
|
- } as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
|
|
- const ui = render(<MessageFeedbackActions {...props} />)
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText('old'))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'saved text' } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
-
|
|
|
- // Close and reopen before the save lands: the new draft is seeded from the
|
|
|
- // still-stale stored note.
|
|
|
- fireEvent.keyDown(document, { key: 'Escape' })
|
|
|
- fireEvent.click(ui.getByText('old'))
|
|
|
- expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('old')
|
|
|
-
|
|
|
- release()
|
|
|
- await gate
|
|
|
- await act(async () => { await Promise.resolve() })
|
|
|
-
|
|
|
- expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('saved text')
|
|
|
- })
|
|
|
-
|
|
|
- it('leaves a reopened draft alone once the human has edited it', async () => {
|
|
|
- // The opposite arm: an edited draft belongs to the human, so a late save
|
|
|
- // must not overwrite what they are typing.
|
|
|
- let release = (): void => {}
|
|
|
- const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
|
|
|
- release = () => { resolve({ ok: true as const }) }
|
|
|
- })
|
|
|
- const view: MessageFeedbackView = {
|
|
|
- status: 'ready',
|
|
|
- items: new Map([[MSG, item({ rating: 'positive', note: 'old' })]]),
|
|
|
- error: null,
|
|
|
- }
|
|
|
- const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
|
|
- useSyncExternalStore(() => () => {}, () => select(view))) as never
|
|
|
- const props = {
|
|
|
- messageId: MSG,
|
|
|
- ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- rate: vi.fn(() => gate),
|
|
|
- toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
|
|
|
- useFeedback,
|
|
|
- t,
|
|
|
- } as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
|
|
- const ui = render(<MessageFeedbackActions {...props} />)
|
|
|
-
|
|
|
- fireEvent.click(ui.getByText('old'))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'saved text' } })
|
|
|
- fireEvent.click(ui.getByText(zh['note.save']))
|
|
|
-
|
|
|
- fireEvent.keyDown(document, { key: 'Escape' })
|
|
|
- fireEvent.click(ui.getByText('old'))
|
|
|
- fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'my new words' } })
|
|
|
-
|
|
|
- release()
|
|
|
- await gate
|
|
|
- await act(async () => { await Promise.resolve() })
|
|
|
-
|
|
|
- expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('my new words')
|
|
|
+ expect(ui.queryByText(zh['error.load'])).toBeNull()
|
|
|
})
|
|
|
})
|