Merge remote-tracking branch 'origin/master' into mergebot/pr883
# Conflicts: # packages/client/ui-question/package.json # packages/client/ui-question/src/client/QuestionComposer.tsx # packages/client/ui-question/src/client/contract/slots.ts # packages/client/ui-question/src/client/index.ts # packages/client/ui-question/src/client/locales.ts # packages/client/ui-question/tests/browser-plugin.spec.ts # packages/client/ui-question/tests/question-composer.spec.tsx
This commit is contained in:
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -36,7 +36,6 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
@@ -46,11 +45,13 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
|
||||
import {
|
||||
PendingQuestion,
|
||||
type QuestionAnswer, type QuestionComposerProps,
|
||||
} from './contract/slots.ts'
|
||||
import css from './QuestionComposer.module.css'
|
||||
|
||||
interface DraftAnswer {
|
||||
@@ -16,11 +17,12 @@ interface DraftAnswer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Displayed feedback: validation feedback is stored as a dictionary key so a
|
||||
* locale flip re-translates it; carrier failures arrive as raw (untranslated)
|
||||
* messages and display verbatim.
|
||||
* Displayed feedback: validation feedback is stored as a dictionary KEY and
|
||||
* translated at render, so already-shown feedback follows a locale switch;
|
||||
* runtime failure messages (finished strings from the wire) pass through
|
||||
* verbatim.
|
||||
*/
|
||||
type Feedback = { key: 'error.incomplete' | 'error.empty' } | { message: string }
|
||||
type Feedback = { key: 'error.incomplete' | 'error.unanswered' } | { text: string }
|
||||
|
||||
/**
|
||||
* Split the conventional recommendation suffix without changing the answer value.
|
||||
@@ -51,17 +53,10 @@ export function QuestionComposer(props: QuestionComposerProps) {
|
||||
// Domain-face mint rides the carrier's stable identity (never minted in a
|
||||
// select/render dispatch — per-dispatch minting would churn memo identity).
|
||||
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
|
||||
return <QuestionFlow key={question.key} pending={question} t={props.t} useLocale={props.useLocale} />
|
||||
return <QuestionFlow key={question.key} pending={question} t={props.t} />
|
||||
}
|
||||
|
||||
function QuestionFlow({ pending, t, useLocale }: {
|
||||
pending: PendingQuestion
|
||||
t: Translate
|
||||
useLocale: SnapshotSelectorHook<LocaleSnapshot>
|
||||
}) {
|
||||
// Subscription only: t reads the active locale at call time, so the
|
||||
// revision selector exists to re-render this tree on locale flips.
|
||||
useLocale(snapshot => snapshot.revision)
|
||||
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
|
||||
const questions = pending.questions
|
||||
const [index, setIndex] = useState(0)
|
||||
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
|
||||
@@ -81,7 +76,7 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
setError(null)
|
||||
void pending.cancel().catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError({ message: cause instanceof Error ? cause.message : String(cause) })
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,13 +127,13 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
setError(null)
|
||||
void pending.answer(answer).catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError({ message: cause instanceof Error ? cause.message : String(cause) })
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
const continueFlow = (): void => {
|
||||
if (!answered(draft)) {
|
||||
setError({ key: 'error.empty' })
|
||||
setError({ key: 'error.unanswered' })
|
||||
return
|
||||
}
|
||||
if (index < questions.length - 1) {
|
||||
@@ -190,8 +185,8 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('dismiss')}
|
||||
title={t('dismiss')}
|
||||
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
|
||||
title={t('nav.cancel')}
|
||||
disabled={busy !== null} onClick={cancelFlow}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
@@ -231,7 +226,9 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.optionLine}>
|
||||
<span className={css.optionLabel}>{display.label}</span>
|
||||
{display.recommended && <span className={css.badge}>{t('option.recommended')}</span>}
|
||||
{display.recommended && (
|
||||
<span className={css.badge}>{t('option.recommended')}</span>
|
||||
)}
|
||||
{option.description !== undefined && (
|
||||
<span className={css.description}>{option.description}</span>
|
||||
)}
|
||||
@@ -287,7 +284,7 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
<footer className={css.footer}>
|
||||
<div className={css.pager}>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('pager.prev')}
|
||||
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
||||
disabled={index === 0 || busy !== null}
|
||||
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||
>
|
||||
@@ -295,7 +292,7 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
</button>
|
||||
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('pager.next')}
|
||||
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
||||
disabled={index === questions.length - 1 || busy !== null}
|
||||
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||
>
|
||||
@@ -303,7 +300,7 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.feedback} role="status">
|
||||
{error === null ? null : 'key' in error ? t(error.key) : error.message}
|
||||
{error === null ? null : 'key' in error ? t(error.key) : error.text}
|
||||
</div>
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
|
||||
@@ -314,8 +311,8 @@ function QuestionFlow({ pending, t, useLocale }: {
|
||||
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
? t('action.submitting')
|
||||
: t(index === questions.length - 1 ? 'action.submit' : 'action.next')}
|
||||
? t('submitting')
|
||||
: index === questions.length - 1 ? t('submit') : t('action.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
* cancelled error encoding, receipt checks — lives HERE, with the package
|
||||
* that consumes it.
|
||||
*/
|
||||
import type { HostObservable, InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
|
||||
// entry) into every program that sees this contract, so PropsRuntime resolves.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
/** The pending question carrier the owner dispatches into the composer slot. */
|
||||
export type QuestionWait = PendingWait<'question'>
|
||||
@@ -68,26 +67,12 @@ export class PendingQuestion {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-injected share: the `question`-namespace translator plus the
|
||||
* locale snapshot as a hooks-compartment source. `t` reads the active locale
|
||||
* at call time; the bound `useLocale` subscription is what re-renders the
|
||||
* composer when the locale flips.
|
||||
*/
|
||||
export interface QuestionComposerInjected {
|
||||
/** Translator bound to the `question` namespace. */
|
||||
t: Translate
|
||||
hooks: {
|
||||
/** Live locale snapshot (bound to the `useLocale` selector hook). */
|
||||
locale: HostObservable<LocaleSnapshot>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component props: the framework runtime share (chain currency +
|
||||
* session/global standard kit), the injected locale share, and the chain
|
||||
* `matched` share — the entry's selector result, already narrowed to the
|
||||
* question carrier. Data and verbs ride the carrier plus the domain face.
|
||||
* session/global standard kit) plus the chain `matched` share — the entry's
|
||||
* selector result, already narrowed to the question carrier — plus the
|
||||
* standard locale seat; the carrier plus the domain face above carry the
|
||||
* whole behavior surface.
|
||||
*/
|
||||
export type QuestionComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & InjectFace<QuestionComposerInjected> & { matched: QuestionWait }
|
||||
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
/**
|
||||
* Web question plugin, browser half: QuestionComposer registered as a
|
||||
* selector-routed entry of the conversation-declared composer chain. The
|
||||
* selector narrows the owner's currency to the question carrier (matched
|
||||
* prop); answer/cancel behavior rides the carrier (domain encoding in
|
||||
* contract/slots.ts PendingQuestion); the inject face carries only the
|
||||
* locale share (bound translator + snapshot source). Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
* selector-routed entry of the conversation-declared composer chain, plus the
|
||||
* `question` dictionaries. The selector narrows the owner's currency to the
|
||||
* question carrier (matched prop), and the whole behavior surface rides the
|
||||
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
|
||||
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { QuestionComposerInjected, QuestionWait } from './contract/slots.ts'
|
||||
import { en, QUESTION_NS, zh } from './locales.ts'
|
||||
import type { QuestionWait } from './contract/slots.ts'
|
||||
import { QuestionComposer } from './QuestionComposer.tsx'
|
||||
import { en, zh, type QuestionKey } from './locales.ts'
|
||||
|
||||
export { PendingQuestion } from './contract/slots.ts'
|
||||
export type { QuestionAnswer, QuestionComposerInjected, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
|
||||
export { QUESTION_NS } from './locales.ts'
|
||||
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
|
||||
export type { QuestionKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The question composer's copy. */
|
||||
question: QuestionKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'question'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). 'conversation' is an ordering
|
||||
@@ -33,33 +42,17 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: register the composer's bilingual copy and the question
|
||||
* composer itself into the composer chain. The inject face hands the entry
|
||||
* its namespace-bound translator plus the locale snapshot source; data and
|
||||
* verbs live on the matched carrier.
|
||||
* Client plugin body: register the `question` dictionaries and the question
|
||||
* composer into the composer chain. Zero business face — data and verbs live
|
||||
* on the matched carrier; t rides the standard locale seat.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(QUESTION_NS, 'zh', zh),
|
||||
ctx.locale.register(QUESTION_NS, 'en', en),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-question: composer dictionaries')
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries')
|
||||
|
||||
const injected = (): QuestionComposerInjected => ({
|
||||
t: ctx.locale.bind(QUESTION_NS),
|
||||
hooks: {
|
||||
locale: {
|
||||
getSnapshot: () => ctx.locale.getLocale(),
|
||||
subscribe: fn => ctx.on('locale/change', fn),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.effect(
|
||||
() => ctx.slots.register(
|
||||
{ name: 'conversation.composer', select: selectQuestion, inject: injected },
|
||||
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
|
||||
QuestionComposer,
|
||||
),
|
||||
'ui-question: composer chain registration',
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
/**
|
||||
* Bilingual copy of the question composer, registered under the `question`
|
||||
* namespace. Question/option text itself arrives from the model verbatim —
|
||||
* these dictionaries cover only the chrome around it.
|
||||
*/
|
||||
import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
|
||||
/** `question` namespace dictionaries. */
|
||||
|
||||
/** Namespace owning the question-composer copy. */
|
||||
export const QUESTION_NS = 'question'
|
||||
|
||||
/** Simplified Chinese dictionary (the fallback locale). */
|
||||
export const zh: LocaleDict = {
|
||||
'dismiss': '放弃整组问题',
|
||||
'pager.prev': '上一题',
|
||||
'pager.next': '下一题',
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'error.incomplete': '请先完成这道问题。',
|
||||
'error.unanswered': '请选择一个选项或填写自定义答案。',
|
||||
'nav.prev': '上一题',
|
||||
'nav.next': '下一题',
|
||||
'nav.cancel': '放弃整组问题',
|
||||
'option.recommended': '推荐',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'error.incomplete': '请先完成这道问题。',
|
||||
'error.empty': '请选择一个选项或填写自定义答案。',
|
||||
'action.skip': '跳过本题',
|
||||
'action.next': '下一题',
|
||||
'action.submit': '提交',
|
||||
'action.submitting': '正在提交…',
|
||||
}
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** English dictionary. */
|
||||
export const en: LocaleDict = {
|
||||
'dismiss': 'Dismiss all questions',
|
||||
'pager.prev': 'Previous question',
|
||||
'pager.next': 'Next question',
|
||||
/** The question namespace key union. */
|
||||
export type QuestionKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'error.incomplete': 'Please complete this question first.',
|
||||
'error.unanswered': 'Please select an option or enter a custom answer.',
|
||||
'nav.prev': 'Previous question',
|
||||
'nav.next': 'Next question',
|
||||
'nav.cancel': 'Dismiss all questions',
|
||||
'option.recommended': 'Recommended',
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'error.incomplete': 'Please finish this question first.',
|
||||
'error.empty': 'Choose an option or type a custom answer.',
|
||||
'action.skip': 'Skip this question',
|
||||
'action.next': 'Next',
|
||||
'action.submit': 'Submit',
|
||||
'action.submitting': 'Submitting…',
|
||||
}
|
||||
} satisfies Record<QuestionKey, string>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
/**
|
||||
* apply wiring on a real cordis Context + SlotsService + LocaleService:
|
||||
* QuestionComposer registered as the `question` entry of the
|
||||
* conversation-declared composer slot, bilingual dictionaries registered
|
||||
* under the `question` namespace, the locale share handed through the inject
|
||||
* face, load-order fail-loud, and fiber-teardown unregistration. Component
|
||||
* and domain-face behavior is covered props-direct in
|
||||
* question-composer.spec.tsx; no renderer machinery here.
|
||||
* apply wiring on a real cordis Context + SlotsService: QuestionComposer
|
||||
* registered as the `question` entry of the conversation-declared composer
|
||||
* slot with ZERO business face (data and verbs ride the dispatched carrier),
|
||||
* load-order fail-loud, and fiber-teardown unregistration. Component and
|
||||
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
|
||||
* no renderer machinery here.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { QuestionComposerInjected } from '../src/client/contract/slots.ts'
|
||||
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
|
||||
import { apply, inject, QUESTION_NS } from '../src/client/index.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
@@ -27,9 +25,8 @@ async function bench() {
|
||||
// 'conversation' inject is an ordering edge (the declaring plugin provides
|
||||
// it after declaring the chain); the bench declares the chain itself.
|
||||
ctx.provide('conversation', {})
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return { ctx, slots, locale }
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
return { ctx, slots }
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
@@ -48,41 +45,29 @@ describe('apply', () => {
|
||||
.rejects.toThrow(/slot "conversation.composer" is not declared/)
|
||||
})
|
||||
|
||||
it('registers the question entry: routing selector plus the locale share face', async () => {
|
||||
const { ctx, slots, locale } = await bench()
|
||||
it('registers the question entry: routing selector, no inject face', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = slots.entries('conversation.composer')[0]!
|
||||
expect(entry.component).toBe(QuestionComposer)
|
||||
// The whole behavior surface rides the matched carrier: no business face;
|
||||
// copy rides the standard locale seat.
|
||||
expect(entry.inject).toBeUndefined()
|
||||
expect(entry.locale).toBe('question')
|
||||
// The selector narrows the chain currency: question wait in → that wait; none → null.
|
||||
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
|
||||
const question = { kind: 'question' }
|
||||
expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question)
|
||||
expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull()
|
||||
expect(select({ interactions: [] })).toBeNull()
|
||||
// The inject face carries the namespace-bound translator and the live
|
||||
// locale snapshot source (subscription rides locale/change).
|
||||
const face = (entry.inject as unknown as () => QuestionComposerInjected)()
|
||||
expect(face.t('action.submit')).toBe('提交')
|
||||
expect(face.hooks.locale.getSnapshot()).toBe(locale.getLocale())
|
||||
const changed = vi.fn()
|
||||
const off = face.hooks.locale.subscribe(changed)
|
||||
locale.setLocale('en')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
expect(face.t('action.submit')).toBe('Submit')
|
||||
off()
|
||||
locale.setLocale('zh')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('teardown unregisters the slot entry and the dictionaries', async () => {
|
||||
const { ctx, slots, locale } = await bench()
|
||||
it('teardown unregisters the slot entry', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.composer')).toHaveLength(1)
|
||||
expect(locale.bind(QUESTION_NS)('action.submit')).toBe('提交')
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.composer')).toHaveLength(0)
|
||||
// Unregistered namespace: the lookup chain bottoms out at the key itself.
|
||||
expect(locale.bind(QUESTION_NS)('action.submit')).toBe('action.submit')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,35 +8,33 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { LocaleDict, LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { PendingQuestion } from '../src/client/contract/slots.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
|
||||
import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Dictionary-backed translate stub (the lookup chain is the locale package's contract, not re-tested here). */
|
||||
const translateOver = (dict: LocaleDict): Translate => key => dict[key] ?? key
|
||||
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
|
||||
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
|
||||
(key => dict[key] ?? common[key] ?? key)
|
||||
|
||||
/** Locale-share stub: static snapshot, no subscription machinery. */
|
||||
const useLocale: SnapshotSelectorHook<LocaleSnapshot> = select =>
|
||||
select({ active: 'zh', locales: [], revision: 0 })
|
||||
|
||||
/** Framework standard-kit stubs: the composer consumes only the locale share;
|
||||
/** Framework standard-kit stubs: the composer consumes only the locale seat;
|
||||
* the composed props type mandates delivery of the rest (framework hooks are
|
||||
* plain stubs per the client testing discipline). */
|
||||
const kit = {
|
||||
sessionId: SID,
|
||||
t: translateOver(zh),
|
||||
useLocale,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
|
||||
useProjection: (() => undefined) as never,
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
|
||||
// The seat's key domain is question ∪ common.
|
||||
t: seatOver(zh, commonZh),
|
||||
}
|
||||
|
||||
const QUESTIONS = [
|
||||
@@ -245,7 +243,7 @@ describe('QuestionComposer', () => {
|
||||
const respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))
|
||||
const carrier = new PendingWait(
|
||||
'question', RpcId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={translateOver(en)} />)
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
|
||||
expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
|
||||
expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
|
||||
|
||||
Reference in New Issue
Block a user