refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ui-message-feedback browser half on a real cordis Context with fake slots/remote
|
||||
* faces: the plugin registers the feedback entry at
|
||||
* conversation.chat.assistant-actions, one controller per Session backs every
|
||||
* message in that Session, a reconnect refreshes only Sessions that were
|
||||
* already read, and registration plus controller disposal ride the plugin
|
||||
* fiber (HMR safety). The node half and the invariant companion are exercised
|
||||
* over the same Context.
|
||||
*/
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
|
||||
import type { MessageFeedbackInjected } from '../src/client/slots.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
const MSG = 'm-1' as MessageId
|
||||
|
||||
const seeded: MessageFeedbackItem = {
|
||||
messageId: MSG,
|
||||
rating: 'positive',
|
||||
version: 'v1' as MessageFeedbackVersion,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake faces; the Remote namespace records every call. */
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const calls: { method: string; request: unknown }[] = []
|
||||
// The generated face wraps every business result in the carrier envelope.
|
||||
const carried = <T,>(value: T) => Promise.resolve({ ok: true as const, value })
|
||||
const messageFeedback = {
|
||||
list: (request: unknown) => {
|
||||
calls.push({ method: 'list', request })
|
||||
return carried({ ok: true as const, value: { items: [seeded] } })
|
||||
},
|
||||
put: (request: unknown) => {
|
||||
calls.push({ method: 'put', request })
|
||||
return carried({ ok: true as const, value: seeded })
|
||||
},
|
||||
delete: (request: unknown) => {
|
||||
calls.push({ method: 'delete', request })
|
||||
return carried({ ok: true as const, value: { absent: true as const } })
|
||||
},
|
||||
}
|
||||
class RemoteService extends Service {
|
||||
constructor(serviceCtx: Context) {
|
||||
super(serviceCtx, 'remote')
|
||||
}
|
||||
}
|
||||
new RemoteService(ctx)
|
||||
ctx.provide('remote.messageFeedback', messageFeedback)
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' } },
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
return {
|
||||
ctx,
|
||||
fiber,
|
||||
calls,
|
||||
entry: () => {
|
||||
const entry = ctx.slots.entries('conversation.chat.assistant-actions')[0]
|
||||
if (entry === undefined) return undefined
|
||||
return {
|
||||
...entry.options,
|
||||
locale: entry.locale,
|
||||
inject: entry.inject as unknown as ((sessionId: SessionId) => MessageFeedbackInjected) | undefined,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-message-feedback browser plugin', () => {
|
||||
it('registers the feedback entry with the documented id, order, and locale', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
expect(b.entry()).toMatchObject({ id: 'feedback', order: 10, locale: 'feedback' })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exposes the feedback hook plus the ensure/rate/clear verbs', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const face = b.entry()!.inject!(sid('s1'))
|
||||
expect(face.hooks.feedback.getSnapshot()).toMatchObject({ status: 'cold' })
|
||||
expect(face.ensure).toBeTypeOf('function')
|
||||
expect(face.rate).toBeTypeOf('function')
|
||||
expect(face.clear).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('shares one controller across every message in the same Session', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const first = b.entry()!.inject!(sid('s1'))
|
||||
const second = b.entry()!.inject!(sid('s1'))
|
||||
expect(first.hooks.feedback).toBe(second.hooks.feedback)
|
||||
|
||||
await first.ensure()
|
||||
await second.ensure()
|
||||
expect(b.calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps separate Sessions on separate controllers', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const one = b.entry()!.inject!(sid('s1'))
|
||||
const two = b.entry()!.inject!(sid('s2'))
|
||||
expect(one.hooks.feedback).not.toBe(two.hooks.feedback)
|
||||
|
||||
await one.ensure()
|
||||
await two.ensure()
|
||||
expect(b.calls.filter(call => call.method === 'list').map(call => call.request)).toEqual([
|
||||
{ sessionId: 's1' },
|
||||
{ sessionId: 's2' },
|
||||
])
|
||||
})
|
||||
|
||||
it('routes rate and clear to the Remote with the addressed message', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const face = b.entry()!.inject!(sid('s1'))
|
||||
expect(await face.rate(MSG, 'negative', 'wrong answer')).toEqual({ ok: true })
|
||||
expect(await face.clear(MSG)).toEqual({ ok: true })
|
||||
|
||||
expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
|
||||
sessionId: 's1', messageId: MSG, rating: 'negative', note: 'wrong answer',
|
||||
})
|
||||
expect(b.calls.filter(call => call.method === 'delete')[0]?.request).toMatchObject({
|
||||
sessionId: 's1', messageId: MSG,
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
const warm = b.entry()!.inject!(sid('warm'))
|
||||
await warm.ensure()
|
||||
b.entry()!.inject!(sid('cold'))
|
||||
const before = b.calls.filter(call => call.method === 'list').length
|
||||
|
||||
b.ctx.emit('connection/reset')
|
||||
await Promise.resolve()
|
||||
|
||||
const reads = b.calls.filter(call => call.method === 'list')
|
||||
expect(reads).toHaveLength(before + 1)
|
||||
expect(reads.at(-1)?.request).toEqual({ sessionId: 'warm' })
|
||||
})
|
||||
|
||||
it('withdraws the registration and disposes controllers with the plugin fiber', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const face = b.entry()!.inject!(sid('s1'))
|
||||
await face.ensure()
|
||||
|
||||
await b.fiber.dispose()
|
||||
|
||||
expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(0)
|
||||
// A disposed controller refuses further mutations, so no request outlives the fiber.
|
||||
const before = b.calls.length
|
||||
expect(await face.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
|
||||
expect(b.calls).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('re-registers cleanly when the plugin is reloaded', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
await b.fiber.dispose()
|
||||
|
||||
const reloaded = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await reloaded.await()
|
||||
|
||||
expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(1)
|
||||
expect(b.entry()).toMatchObject({ id: 'feedback' })
|
||||
})
|
||||
|
||||
it('the node half applies without host-side behavior', () => {
|
||||
// The invariant companion is mounted by the vitest-wide invariant host on
|
||||
// every Context this suite creates; its registration is covered there.
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* MessageFeedbackController: the browser-local object layer over one Session's
|
||||
* message-feedback sidecar. These specs pin the per-item compare-and-set
|
||||
* contract — every mutation sends the version last observed, a conflict
|
||||
* reconciles from the authoritative item carried by the reply, mutations
|
||||
* serialize per Session, and a disposed controller stops publishing.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
MessageFeedbackItem, MessageFeedbackVersion,
|
||||
} from '@deepseek-ai/dsh-message-feedback/types'
|
||||
import { MessageFeedbackController, type MessageFeedbackRemote } from '../src/client/controller.ts'
|
||||
|
||||
const SESSION = 's-1' as SessionId
|
||||
const MSG = 'm-1' as MessageId
|
||||
const OTHER = 'm-2' as MessageId
|
||||
|
||||
const version = (v: string): MessageFeedbackVersion => v as MessageFeedbackVersion
|
||||
|
||||
function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem {
|
||||
return {
|
||||
messageId: MSG,
|
||||
rating: 'positive',
|
||||
version: version('v1'),
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A recording fake Remote whose per-method answers are scripted per call. */
|
||||
type Script = {
|
||||
list?: (request: unknown) => Promise<unknown>
|
||||
put?: (request: unknown) => Promise<unknown>
|
||||
delete?: (request: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* A recording fake Remote. Scripts return the *business* result; this wraps it
|
||||
* in the carrier envelope the generated face uses, so specs stay readable. A
|
||||
* script may also return an already-enveloped `{ok:false,error:{code,message,
|
||||
* details}}` to exercise a carrier failure.
|
||||
*/
|
||||
function fakeRemote(script: Script = {}) {
|
||||
const calls: { method: string; request: unknown }[] = []
|
||||
const isCarrier = (v: unknown): boolean =>
|
||||
typeof v === 'object' && v !== null && 'ok' in v && v.ok === false
|
||||
&& 'error' in v && 'details' in ((v as { error: object }).error ?? {})
|
||||
const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) =>
|
||||
(request: never): Promise<never> => {
|
||||
calls.push({ method, request })
|
||||
const business = real === undefined ? Promise.resolve(fallback) : real(request)
|
||||
return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise<never>
|
||||
}
|
||||
const remote = {
|
||||
list: record('list', script.list, { ok: true, value: { items: [] } }),
|
||||
put: record('put', script.put, { ok: true, value: item() }),
|
||||
delete: record('delete', script.delete, { ok: true, value: { absent: true } }),
|
||||
} as unknown as MessageFeedbackRemote
|
||||
return { remote, calls }
|
||||
}
|
||||
|
||||
describe('MessageFeedbackController', () => {
|
||||
it('seeds the view from one list read and keys items by message id', async () => {
|
||||
const seeded = item({ note: 'good' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(controller.getSnapshot().status).toBe('cold')
|
||||
expect(await controller.ensure()).toEqual({ ok: true })
|
||||
|
||||
const view = controller.getSnapshot()
|
||||
expect(view.status).toBe('ready')
|
||||
expect(view.items.get(MSG)).toEqual(seeded)
|
||||
expect(calls).toEqual([{ method: 'list', request: { sessionId: SESSION } }])
|
||||
})
|
||||
|
||||
it('collapses concurrent loads onto one in-flight read', async () => {
|
||||
const { remote, calls } = fakeRemote()
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends ifVersion null for a first rating and the observed version afterwards', async () => {
|
||||
const first = item({ version: version('v1') })
|
||||
const second = item({ version: version('v2'), rating: 'negative' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
put: request => Promise.resolve({
|
||||
ok: true,
|
||||
value: (request as { rating: string }).rating === 'positive' ? first : second,
|
||||
}),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true })
|
||||
expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
|
||||
|
||||
const puts = calls.filter(call => call.method === 'put').map(call => call.request)
|
||||
expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'positive', ifVersion: null })
|
||||
expect(puts[1]).toMatchObject({ messageId: MSG, rating: 'negative', ifVersion: version('v1') })
|
||||
expect(controller.getSnapshot().items.get(MSG)).toEqual(second)
|
||||
})
|
||||
|
||||
it('forwards an optional note and omits the field when absent', async () => {
|
||||
const { remote, calls } = fakeRemote()
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
await controller.rate(MSG, 'positive', 'helpful')
|
||||
await controller.rate(OTHER, 'negative')
|
||||
|
||||
const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
|
||||
expect(puts[0]?.note).toBe('helpful')
|
||||
expect(puts[1]).not.toHaveProperty('note')
|
||||
})
|
||||
|
||||
it('reconciles a version conflict from the authoritative item without refetching', async () => {
|
||||
const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
put: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', current: authoritative },
|
||||
}),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', message: 'feedback changed elsewhere' },
|
||||
})
|
||||
|
||||
expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative)
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops the local item when a conflict reports the feedback is gone', async () => {
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
|
||||
delete: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', current: null },
|
||||
}),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
|
||||
expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes with the observed version and removes the item on success', async () => {
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
|
||||
expect(await controller.clear(MSG)).toEqual({ ok: true })
|
||||
|
||||
expect(calls.filter(call => call.method === 'delete')[0]?.request)
|
||||
.toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') })
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats clearing an unrated message as already satisfied without a call', async () => {
|
||||
const { remote, calls } = fakeRemote()
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.clear(MSG)).toEqual({ ok: true })
|
||||
expect(calls.filter(call => call.method === 'delete')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('serializes mutations so each one compares against the committed version', async () => {
|
||||
let inFlight = 0
|
||||
let overlapped = false
|
||||
const versions = [version('v1'), version('v2')]
|
||||
let index = 0
|
||||
const { remote, calls } = fakeRemote({
|
||||
put: async () => {
|
||||
inFlight += 1
|
||||
if (inFlight > 1) overlapped = true
|
||||
await Promise.resolve()
|
||||
inFlight -= 1
|
||||
const next = versions[index] ?? version('vN')
|
||||
index += 1
|
||||
return { ok: true, value: item({ version: next }) }
|
||||
},
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')])
|
||||
|
||||
expect(overlapped).toBe(false)
|
||||
const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
|
||||
expect(puts[0]?.ifVersion).toBeNull()
|
||||
expect(puts[1]?.ifVersion).toBe(version('v1'))
|
||||
})
|
||||
|
||||
it('publishes an error status when the list read is rejected by the Host', async () => {
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'this session is no longer persisted',
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a transport throw as a result instead of rejecting', async () => {
|
||||
const { remote } = fakeRemote({ list: () => Promise.reject(new Error('socket closed')) })
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.ensure()).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'transport', message: 'socket closed' },
|
||||
})
|
||||
expect(controller.getSnapshot().status).toBe('error')
|
||||
})
|
||||
|
||||
it('settles a mutation transport throw without corrupting the view', async () => {
|
||||
const { remote } = fakeRemote({ put: () => Promise.reject(new Error('socket closed')) })
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'transport', message: 'socket closed' },
|
||||
})
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies subscribers on publication and stops after unsubscribe', async () => {
|
||||
const { remote } = fakeRemote()
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = controller.subscribe(listener)
|
||||
|
||||
await controller.ensure()
|
||||
const seen = listener.mock.calls.length
|
||||
expect(seen).toBeGreaterThan(0)
|
||||
|
||||
unsubscribe()
|
||||
await controller.rate(MSG, 'positive')
|
||||
expect(listener).toHaveBeenCalledTimes(seen)
|
||||
})
|
||||
|
||||
it('contains a throwing subscriber at the observable boundary', async () => {
|
||||
const { remote } = fakeRemote()
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
controller.subscribe(() => { throw new Error('subscriber exploded') })
|
||||
const healthy = vi.fn()
|
||||
controller.subscribe(healthy)
|
||||
|
||||
await controller.ensure()
|
||||
|
||||
expect(healthy).toHaveBeenCalled()
|
||||
expect(spy).toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('refuses mutations and stops publishing once disposed', async () => {
|
||||
const { remote, calls } = fakeRemote()
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
const listener = vi.fn()
|
||||
controller.subscribe(listener)
|
||||
|
||||
controller.dispose()
|
||||
const before = calls.length
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
|
||||
expect(calls).toHaveLength(before)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a human explanation for every business failure code', async () => {
|
||||
const codes = [
|
||||
['session-not-found', 'this session is no longer persisted'],
|
||||
['target-not-found', 'this message is not a persisted assistant message'],
|
||||
['note-blank', 'a note must contain a non-whitespace character'],
|
||||
['note-too-large', 'the note is too long'],
|
||||
] as const
|
||||
for (const [code, message] of codes) {
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } })
|
||||
expect(controller.getSnapshot().error).toBe(message)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the raw code for an unrecognized failure', async () => {
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } })
|
||||
expect(controller.getSnapshot().error).toBe('brand-new-code')
|
||||
})
|
||||
|
||||
it('publishes nothing when the list settles after disposal', async () => {
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const { remote } = fakeRemote({
|
||||
list: async () => {
|
||||
await gate
|
||||
return { ok: true, value: { items: [item()] } }
|
||||
},
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
const pending = controller.ensure()
|
||||
const listener = vi.fn()
|
||||
controller.subscribe(listener)
|
||||
|
||||
controller.dispose()
|
||||
release()
|
||||
|
||||
expect(await pending).toEqual({ ok: true })
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows a rejected list that settles after disposal', async () => {
|
||||
let reject = (): void => {}
|
||||
const gate = new Promise<void>((_resolve, rejectFn) => { reject = () => { rejectFn(new Error('late')) } })
|
||||
const { remote } = fakeRemote({ list: () => gate })
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
const pending = controller.ensure()
|
||||
|
||||
controller.dispose()
|
||||
reject()
|
||||
|
||||
expect(await pending).toEqual({ ok: true })
|
||||
expect(controller.getSnapshot().status).not.toBe('error')
|
||||
})
|
||||
|
||||
it('describes a non-Error list rejection with a stable message', async () => {
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
|
||||
const { remote } = fakeRemote({ list: () => Promise.reject('socket string') })
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.ensure()).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'transport', message: 'message feedback list failed' },
|
||||
})
|
||||
})
|
||||
|
||||
it('describes a non-Error mutation rejection with a stable message', async () => {
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
|
||||
const { remote } = fakeRemote({ put: () => Promise.reject('nope') })
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'transport', message: 'message feedback mutation failed' },
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates a failed load to a queued mutation without calling the wire', async () => {
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'session-not-found' },
|
||||
})
|
||||
expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps a later mutation running after an earlier one settles as a failure', async () => {
|
||||
let first = true
|
||||
const { remote } = fakeRemote({
|
||||
put: () => {
|
||||
if (first) {
|
||||
first = false
|
||||
return Promise.reject(new Error('first blew up'))
|
||||
}
|
||||
return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) })
|
||||
},
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
controller.rate(MSG, 'positive'),
|
||||
controller.rate(MSG, 'negative'),
|
||||
])
|
||||
|
||||
expect(a).toMatchObject({ ok: false, error: { code: 'transport' } })
|
||||
expect(b).toEqual({ ok: true })
|
||||
expect(controller.getSnapshot().items.get(MSG)?.rating).toBe('negative')
|
||||
})
|
||||
|
||||
it('ignores a conflict reconciliation that lands after disposal', async () => {
|
||||
// The mutate() guard only refuses work admitted after disposal, so this
|
||||
// exercises commit()'s own guard: the call is already in flight when the
|
||||
// fiber unloads, and its authoritative item must not be published.
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }),
|
||||
put: async () => {
|
||||
await gate
|
||||
return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } }
|
||||
},
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
const listener = vi.fn()
|
||||
controller.subscribe(listener)
|
||||
const pending = controller.rate(MSG, 'negative')
|
||||
|
||||
controller.dispose()
|
||||
release()
|
||||
await pending
|
||||
|
||||
// publish() drops its listener set on dispose, so no subscriber is told.
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a delete conflict reconciliation once disposed mid-flight', async () => {
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
|
||||
delete: async () => {
|
||||
await gate
|
||||
return { ok: false, error: { code: 'version-conflict', current: null } }
|
||||
},
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
const pending = controller.clear(MSG)
|
||||
|
||||
const listener = vi.fn()
|
||||
controller.subscribe(listener)
|
||||
controller.dispose()
|
||||
release()
|
||||
await pending
|
||||
|
||||
// The reconciliation still computes, but no subscriber is notified.
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => {
|
||||
const existing = item({ version: version('v3'), rating: 'positive' })
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
|
||||
put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
|
||||
expect(await controller.rate(MSG, 'negative', 'far too long')).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'note-too-large' },
|
||||
})
|
||||
expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
|
||||
})
|
||||
|
||||
it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => {
|
||||
const existing = item({ version: version('v4') })
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
|
||||
delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
|
||||
expect(await controller.clear(MSG)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'session-not-found' },
|
||||
})
|
||||
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 MessageFeedbackController(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 MessageFeedbackController(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 MessageFeedbackController(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 MessageFeedbackController(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 MessageFeedbackController(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 MessageFeedbackController(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 MessageFeedbackController(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 MessageFeedbackController(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)
|
||||
})
|
||||
|
||||
it('renders a carrier failure from the Remote envelope', async () => {
|
||||
// The generated face folds transport faults into ok:false with a
|
||||
// RemoteFailure, so the controller reads them as values, not rejections.
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
|
||||
}),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.ensure()).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed' },
|
||||
})
|
||||
expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('renders a carrier failure on a mutation without touching the view', async () => {
|
||||
const { remote } = fakeRemote({
|
||||
put: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
|
||||
}),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed' },
|
||||
})
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a carrier failure on a delete', async () => {
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
|
||||
delete: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
|
||||
}),
|
||||
})
|
||||
const controller = new MessageFeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
|
||||
expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'carrier-closed' } })
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
// @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.
|
||||
*/
|
||||
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, 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 { 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?: MessageFeedbackActionResult
|
||||
clearResult?: MessageFeedbackActionResult
|
||||
status?: MessageFeedbackView['status']
|
||||
} = {}) {
|
||||
const view: MessageFeedbackView = {
|
||||
status: options.status ?? 'ready',
|
||||
items: new Map(options.current === undefined ? [] : [[MSG, options.current]]),
|
||||
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 }))
|
||||
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
|
||||
Parameters<typeof MessageFeedbackActions>[0]
|
||||
return { ...render(<MessageFeedbackActions {...props} />), ensure, rate, clear, toggle, clearNote }
|
||||
}
|
||||
|
||||
describe('MessageFeedbackActions', () => {
|
||||
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.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') })
|
||||
})
|
||||
|
||||
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.toggle).toHaveBeenCalledWith(MSG, 'positive') })
|
||||
// The double routes a matching rating to clear(), mirroring the controller.
|
||||
await waitFor(() => { expect(ui.clear).toHaveBeenCalledWith(MSG) })
|
||||
})
|
||||
|
||||
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.clearNote).toHaveBeenCalledWith(MSG) })
|
||||
})
|
||||
|
||||
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() })
|
||||
})
|
||||
|
||||
it('keeps the editor open when the note fails to save', 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() })
|
||||
// The draft survives so the human can shorten it instead of retyping.
|
||||
expect(ui.getByLabelText(zh['note.aria'])).toBeTruthy()
|
||||
})
|
||||
|
||||
it('publishes no state after the row unmounts mid-flight', async () => {
|
||||
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(), 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(() => gate),
|
||||
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} />)
|
||||
const errors: unknown[] = []
|
||||
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('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