Files
dsh-context/tests/client.spec.mjs
T
bowenliang123 c003916360 feat(client): two equal-width image attachment columns with token badges
The attachment grid becomes a two-column equal-width grid
(repeat(2, minmax(0, 1fr))) and every image card gains a token-cost
line (≈N tokens, with an explanatory tooltip) estimated through the
official DeepSeek image-size conversion on the stored dimensions.
2026-08-22 16:31:13 +08:00

184 lines
9.7 KiB
JavaScript

/**
* Registration-surface spec for the packaged client bundle: simulates the
* web boot handoff (window.__ModuleLoader__.load), the module-table require,
* and the client ctx (locale/slots/effect + a fake DOM), then asserts the
* plugin registers its dictionaries, styles, the conversation.view tab
* entry, and the /context command trigger source.
*/
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'vitest'
import { bootViewBed, FAKE_PRIMITIVES, makeFakeDoc } from './helpers/viewBed.mjs'
const bundle = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
// ---- fake browser environment ----
const { fakeDoc, registered } = makeFakeDoc()
const fakeReact = {
createElement: (...args) => ({ kind: 'element', args }),
useState: (init) => [init, () => {}],
useEffect: () => {},
memo: (fn) => fn,
// The ErrorBoundary extends React.Component; a minimal base keeps the
// class construction working (props assigned, state merge via setState).
Component: class Component {
constructor(props) { this.props = props }
setState(partial) { this.state = { ...this.state, ...partial } }
},
}
let handoff = null
globalThis.window = {
__ModuleLoader__: {
load(h) {
assert.ok(h.id === 'dsh-context', 'handoff id must be the package name')
assert.equal(typeof h.factory, 'function')
handoff = h
},
},
}
globalThis.document = fakeDoc
const require = (spec) => {
assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives',
`bundle must only require platform modules (got "${spec}")`)
return spec === 'react' ? fakeReact : FAKE_PRIMITIVES
}
// ---- materialize the bundle the way the loader does ----
// Run the bundle verbatim: it registers itself through the boot handoff
// (window.__ModuleLoader__.load), and the loader materializes the plugin by
// invoking the captured factory with the module-table require. The factory
// body declares its own module/exports (the bundle intro), so no string
// surgery on the artifact — the test stays decoupled from bundler layout.
new Function(bundle)()
assert.ok(handoff !== null, 'bundle must register through __ModuleLoader__.load')
const pluginExports = handoff.factory(require)
test('client bundle: handoff, dicts, styles, slot registration, /context command', async () => {
assert.equal(pluginExports.name, 'dsh-context')
assert.deepEqual(pluginExports.inject, ['slots', 'locale'])
// ---- apply the client plugin ----
const localeRegistrations = []
const slotInjections = []
const effects = []
const fakeCtx = {
get: () => undefined,
effect(fn) { effects.push(fn); fn(); return () => {} }, // Cordis runs the effect body immediately
locale: {
register: (ns, dicts) => { localeRegistrations.push([ns, dicts]); return () => {} },
bind: (ns) => (key, vars) => {
const dict = localeRegistrations[0][1].zh
let s = dict[key] !== undefined ? dict[key] : key
if (vars) for (const k in vars) s = s.replace('{' + k + '}', String(vars[k]))
return s
},
},
slots: {
inject: (name, fn) => { slotInjections.push([name, fn]) },
register: (opts, component) => {
assert.equal(typeof component, 'function')
return opts
},
},
}
pluginExports.apply(fakeCtx)
assert.equal(effects.length, 3, 'dictionaries + loadOlderHistory prop + /context command effects (styles inject at bundle materialization)')
assert.deepEqual(localeRegistrations[0][0], 'dsh-context')
assert.ok(localeRegistrations[0][1].zh && localeRegistrations[0][1].en, 'bilingual dicts')
// The sheet rides the bundle's CSS channel: injected at factory execution,
// lightningcss-minified (`120ms ease` -> `.12s`), tagged data-plugin.
const styleTag = registered.get('dsh-context')
assert.ok(styleTag, 'plugin-owned <style data-plugin="dsh-context"> injected')
assert.equal(styleTag.attrs['data-plugin-css'], 'dsh-context/styles.css', 'style tag carries the official data-plugin-css tag id')
assert.ok(styleTag.textContent.includes('.lc-root'), 'styles content present')
assert.ok(styleTag.textContent.includes('.lc-br-elem-row') && styleTag.textContent.includes('transition:background-color'), 'row hovers ease in/out')
assert.ok(styleTag.textContent.includes('.lc-stacked-seg') && styleTag.textContent.includes('transition:filter'), 'composition bar hover eases in/out')
assert.ok(styleTag.textContent.includes('.lc-bar-tip-on'), 'composition tooltip fades in and out')
assert.ok(styleTag.textContent.includes('.lc-stat-tip'), 'stats cell tooltip bubble styles present')
assert.ok(styleTag.textContent.includes('.lc-stat-tipped:hover .lc-stat-tip'), 'stats tooltip reveals on cell hover')
assert.ok(styleTag.textContent.includes('.lc-occupied-box-on'), 'occupied frame fades in and out')
assert.ok(styleTag.textContent.includes('grid-template-columns:repeat(2,minmax(0,1fr))'), 'image attachment grid is two equal-width columns')
assert.equal(slotInjections.length, 2, 'view tab + input overlay injections')
assert.equal(slotInjections[0][0], 'conversation.view')
const registeredOpts = slotInjections[0][1]() // slots.inject callback returns the register result
assert.equal(registeredOpts.name, 'conversation.view')
assert.equal(registeredOpts.id, 'context')
assert.equal(registeredOpts.order, 20)
assert.equal(typeof registeredOpts.label, 'function')
assert.equal(registeredOpts.label(), '上下文', 'tab label localized')
assert.equal(slotInjections[1][0], 'conversation.input.overlay')
const overlayOpts = slotInjections[1][1]()
assert.equal(overlayOpts.name, 'conversation.input.overlay')
assert.equal(overlayOpts.id, 'context-modal')
assert.ok(typeof overlayOpts.inject === 'function', 'overlay injects the modal store hook')
const injected = overlayOpts.inject('s1')
assert.equal(typeof injected.hooks.contextModal.getSnapshot, 'function', 'hooks compartment carries the modal store')
assert.equal(injected.hooks.contextModal.getSnapshot(), false, 'modal closed initially')
// ---- /context command: the plugin's own '/' trigger source ----
// The first fake ctx has no inputTriggers service: the command effect must
// stay inert (soft dependency), which the apply above already proved by not
// throwing. Now apply again with the trigger service present.
{
const sources = []
let localeDict = null
const ctx3 = {
get: (name) => name === 'inputTriggers'
? { registerSource: (s) => { sources.push(s); return () => {} } }
: undefined,
effect: (fn) => { fn(); return () => {} },
locale: {
register: (ns, dicts) => { localeDict = dicts.zh; return () => {} },
bind: () => (key, vars) => {
let s = localeDict !== null && localeDict[key] !== undefined ? localeDict[key] : key
if (vars) for (const k in vars) s = s.replace('{' + k + '}', String(vars[k]))
return s
},
subscribe: () => () => {},
},
slots: { inject: () => {}, register: () => ({}) },
}
pluginExports.apply(ctx3)
assert.equal(sources.length, 1, 'one /context trigger source registered')
const src = sources[0]
assert.equal(src.trigger, '/')
assert.equal(src.name, 'context')
// Candidates: leading-only, prefix-filtered, description localized.
const req = (query, position = 'leading') => ({ query, position, signal: new AbortController().signal })
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('')), [{ name: 'context', description: '查看当前上下文构成,浏览各步骤组成' }])
assert.deepEqual((await src.candidates({ sessionId: 's1' }, req('cont'))).length, 1, 'prefix match')
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('xyz')), [], 'non-prefix miss')
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('', 'inline')), [], 'inline positions never offer the command')
// Menu pick: opens the session's modal and answers 'handled'; the token
// stays in the composer while the modal is open (consumed on close).
const outcome = src.onPick({ candidate: { name: 'context' }, session: { sessionId: 's1' }, position: 'leading', via: 'menu', span: { start: 0, end: 8, draftRev: 1 } })
assert.equal(outcome, 'handled', 'menu pick is handled internally')
assert.equal(injected.hooks.contextModal.getSnapshot(), true, 'menu pick opens the modal')
// Bare enter: also 'handled' (nothing is submitted, the draft keeps the
// token until the modal closes).
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/context', new AbortController().signal), 'handled', 'bare enter opens the modal without submitting')
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/context now', new AbortController().signal), undefined, 'argued lines miss')
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/compact', new AbortController().signal), undefined, 'other commands miss')
}
})
test('sessions.provide contributes the loadOlderHistory prop', async () => {
// The contribution rides the harness's sessions.provide channel: one
// declared prop, resolved per session to the session's own
// history-pagination verb.
const bed = await bootViewBed()
assert.equal(bed.provideDescriptors.length, 1, 'one sessions.provide contribution registered')
assert.deepEqual(bed.provideDescriptors[0].props, ['loadOlderHistory'], 'the contributed prop is loadOlderHistory')
let olderPulled = 0
const contribution = bed.provideDescriptors[0].resolve({ session: { loadOlder: async () => { olderPulled += 1 } } })
assert.equal(typeof contribution.props.loadOlderHistory, 'function', 'resolved prop is a function')
await contribution.props.loadOlderHistory()
assert.equal(olderPulled, 1, 'the prop delegates to session.loadOlder()')
})