✅ test: 补上纯函数、产物冒烟与组件渲染测试,守护产物新鲜度
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
// The built artifact, exercised the way the page exercises it: the module table
|
||||||
|
// calls `window.__ModuleLoader__.load`, materializes the factory, and hands the
|
||||||
|
// result to Cordis. This runs under Node with stubs, which is exactly the point:
|
||||||
|
// the bundle may not need a DOM before its components render, and `require` may
|
||||||
|
// only ever ask for modules the module table actually holds.
|
||||||
|
//
|
||||||
|
// The freshness case is the guard against the one real maintenance hazard of
|
||||||
|
// this layout: editing src/client/** and committing without rebuilding.
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const artifactPath = resolve(repoRoot, 'client.js')
|
||||||
|
const source = await readFile(artifactPath, 'utf8')
|
||||||
|
|
||||||
|
/** The smallest React the bundle can be handed: property access only. */
|
||||||
|
const reactStub = {
|
||||||
|
createElement: () => null,
|
||||||
|
Fragment: Symbol('Fragment'),
|
||||||
|
useCallback: (callback) => callback,
|
||||||
|
useEffect: () => {},
|
||||||
|
useSyncExternalStore: () => undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the artifact against a stub module table and return its registration. */
|
||||||
|
function materialize(moduleTable = { react: reactStub }) {
|
||||||
|
let registration
|
||||||
|
const previous = globalThis.window
|
||||||
|
globalThis.window = { __ModuleLoader__: { load: (value) => { registration = value } } }
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new-func -- executing the artifact IS the test
|
||||||
|
new Function(source)()
|
||||||
|
} finally {
|
||||||
|
globalThis.window = previous
|
||||||
|
}
|
||||||
|
assert.ok(registration, 'the bundle never registered a factory')
|
||||||
|
const requested = []
|
||||||
|
const exports = registration.factory((specifier) => {
|
||||||
|
requested.push(specifier)
|
||||||
|
if (!Object.hasOwn(moduleTable, specifier)) {
|
||||||
|
throw new Error(`the bundle asked the module table for "${specifier}"`)
|
||||||
|
}
|
||||||
|
return moduleTable[specifier]
|
||||||
|
})
|
||||||
|
return { registration, exports, requested }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The smallest client root context that lets `apply` run without a page. */
|
||||||
|
function fakeContext() {
|
||||||
|
const effects = []
|
||||||
|
const slots = []
|
||||||
|
const registrations = []
|
||||||
|
return {
|
||||||
|
effects,
|
||||||
|
slots,
|
||||||
|
registrations,
|
||||||
|
ctx: {
|
||||||
|
get: () => undefined,
|
||||||
|
effect: (callback, label) => {
|
||||||
|
effects.push(label)
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
inject: (name, setup) => {
|
||||||
|
slots.push(name)
|
||||||
|
setup()
|
||||||
|
},
|
||||||
|
register: (definition, component) => {
|
||||||
|
registrations.push({ definition, component })
|
||||||
|
return { dispose() {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the artifact is a generated, self-contained script', () => {
|
||||||
|
assert.match(source.split('\n')[0], /^\/\/ Generated by scripts\/build-client\.mjs/)
|
||||||
|
assert.ok(!source.includes('\r'), 'the artifact must be LF-only (see .gitattributes)')
|
||||||
|
assert.ok(source.includes('window.__ModuleLoader__.load({'), 'missing the registration wrapper')
|
||||||
|
assert.ok(source.includes("id: '@dsh-plugin/session-notify',"), 'the registration id must be the package name')
|
||||||
|
assert.ok(source.trimEnd().endsWith('})'), 'the registration wrapper is not closed')
|
||||||
|
|
||||||
|
const factoryAt = source.indexOf('factory(require) {')
|
||||||
|
assert.ok(factoryAt > 0, 'the factory is missing')
|
||||||
|
const requires = [...source.matchAll(/\brequire\(\s*"([^"]+)"\s*\)/g)]
|
||||||
|
assert.ok(requires.length > 0, 'the bundle never requires react')
|
||||||
|
for (const [whole, specifier] of requires) {
|
||||||
|
assert.equal(specifier, 'react', 'only platform seeds may be requested synchronously')
|
||||||
|
assert.ok(source.indexOf(whole) > factoryAt, 'require must sit inside the factory, or it would load eagerly')
|
||||||
|
}
|
||||||
|
assert.ok(!/^\s*(?:import|export)\s/m.test(source), 'a self-contained bundle has no top-level import/export')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('materializing the factory yields the plugin the module table expects', () => {
|
||||||
|
const { registration, exports, requested } = materialize()
|
||||||
|
assert.equal(registration.id, '@dsh-plugin/session-notify')
|
||||||
|
assert.deepEqual(exports.inject, ['slots', 'locale'])
|
||||||
|
assert.equal(typeof exports.apply, 'function')
|
||||||
|
assert.deepEqual([...new Set(requested)], ['react'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applying the plugin registers its three seats without touching the DOM', () => {
|
||||||
|
const { exports } = materialize()
|
||||||
|
const { ctx, effects, slots, registrations } = fakeContext()
|
||||||
|
exports.apply(ctx)
|
||||||
|
|
||||||
|
assert.deepEqual(effects, [
|
||||||
|
'session-notify: dictionaries',
|
||||||
|
'session-notify: window focus',
|
||||||
|
'session-notify: disposal',
|
||||||
|
])
|
||||||
|
assert.deepEqual(slots, ['shell.overlay', 'plugins.bundle.config', 'shell.overlay'])
|
||||||
|
assert.deepEqual(registrations.map(({ definition }) => definition.id ?? definition.key), [
|
||||||
|
'session-notify-toasts',
|
||||||
|
'@dsh-plugin/session-notify',
|
||||||
|
'session-notify-watch',
|
||||||
|
])
|
||||||
|
assert.deepEqual(registrations.map(({ definition }) => definition.order), [100, undefined, 101])
|
||||||
|
for (const { component } of registrations) assert.equal(typeof component, 'function')
|
||||||
|
|
||||||
|
const [toasts, config] = registrations
|
||||||
|
const toastProps = toasts.definition.inject()
|
||||||
|
assert.deepEqual(toastProps.store.getSnapshot().kinds, { completion: true, approval: true, question: true })
|
||||||
|
assert.equal(toastProps.store.getSnapshot().permission, 'unsupported')
|
||||||
|
assert.equal(toastProps.platform, undefined)
|
||||||
|
for (const operation of ['dismiss', 'arm', 'hold', 'open']) {
|
||||||
|
assert.equal(typeof toastProps[operation], 'function', `${operation} is not a function`)
|
||||||
|
}
|
||||||
|
assert.equal(typeof toastProps.fallbackT, 'function')
|
||||||
|
|
||||||
|
const configProps = config.definition.inject()
|
||||||
|
assert.equal(typeof configProps.ask, 'function')
|
||||||
|
assert.equal(typeof configProps.testAny, 'function')
|
||||||
|
assert.equal(typeof configProps.testSystem, 'function')
|
||||||
|
assert.equal(typeof configProps.setKind, 'function')
|
||||||
|
assert.equal(configProps.replayCount(), 0)
|
||||||
|
// No locale service in this fake context, so the plugin's own translator falls
|
||||||
|
// back to its English dictionary.
|
||||||
|
assert.equal(configProps.copy.describeDelivery(null, configProps.fallbackT), 'nothing delivered yet')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the committed artifact is exactly what src/client builds', async (t) => {
|
||||||
|
const { buildClient } = await import('../scripts/build-client.mjs')
|
||||||
|
let built
|
||||||
|
try {
|
||||||
|
built = await buildClient({ write: false })
|
||||||
|
} catch (error) {
|
||||||
|
t.skip(`cannot rebuild without devDependencies: ${error.message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.equal(built, source, 'client.js is stale — run `pnpm run build` and commit the result')
|
||||||
|
})
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// The components, rendered through a tiny React double.
|
||||||
|
//
|
||||||
|
// Why a double instead of React: this repository ships no runtime dependency at
|
||||||
|
// all, and the whole point of these tests is the wiring the refactor introduced —
|
||||||
|
// which component reads which prop through which seat. The double implements the
|
||||||
|
// four React entry points this package uses, so a component render is exercised
|
||||||
|
// end to end (hooks included) without a DOM or a real React.
|
||||||
|
//
|
||||||
|
// It runs against the BUILT artifact, because that is what the page loads.
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const source = await readFile(resolve(repoRoot, 'client.js'), 'utf8')
|
||||||
|
|
||||||
|
/** The smallest React this package can run on, plus a way to run effects on demand. */
|
||||||
|
function createReactDouble({ runEffects = false } = {}) {
|
||||||
|
const pending = []
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
React: {
|
||||||
|
Fragment: Symbol('Fragment'),
|
||||||
|
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
|
||||||
|
useCallback: (callback) => callback,
|
||||||
|
useEffect: (effect) => {
|
||||||
|
pending.push(effect)
|
||||||
|
// Components here may only touch the DOM from their own effects, so this
|
||||||
|
// is opt-in: ToastLayer's escape-key effect needs a document.
|
||||||
|
if (runEffects) effect()
|
||||||
|
},
|
||||||
|
useSyncExternalStore: (subscribe, getSnapshot) => getSnapshot(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Materialize the artifact, then install it, and hand back its three seats. */
|
||||||
|
function mount({ runEffects = false } = {}) {
|
||||||
|
const { React, pending } = createReactDouble({ runEffects })
|
||||||
|
let registration
|
||||||
|
const previous = globalThis.window
|
||||||
|
globalThis.window = { __ModuleLoader__: { load: (value) => { registration = value } } }
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new-func -- executing the artifact IS the test
|
||||||
|
new Function(source)()
|
||||||
|
} finally {
|
||||||
|
globalThis.window = previous
|
||||||
|
}
|
||||||
|
assert.ok(registration, 'the bundle never registered a factory')
|
||||||
|
const exports = registration.factory((specifier) => {
|
||||||
|
assert.equal(specifier, 'react', 'only platform seeds may be requested')
|
||||||
|
return React
|
||||||
|
})
|
||||||
|
|
||||||
|
const effects = []
|
||||||
|
const registrations = []
|
||||||
|
exports.apply({
|
||||||
|
get: () => undefined,
|
||||||
|
effect: (callback, label) => {
|
||||||
|
effects.push(label)
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
inject: (name, setup) => setup(),
|
||||||
|
register: (definition, component) => {
|
||||||
|
registrations.push({ definition, component })
|
||||||
|
return { dispose() {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [toasts, config, observer] = registrations
|
||||||
|
return { toasts, config, observer, pending, effects }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk a rendered element tree, collecting text, host types and every element. */
|
||||||
|
function walk(node, collected = { text: [], types: [], elements: [] }) {
|
||||||
|
if (node === null || node === undefined || typeof node === 'boolean') return collected
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const child of node) walk(child, collected)
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
if (typeof node === 'string' || typeof node === 'number') {
|
||||||
|
collected.text.push(String(node))
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
if (typeof node.type === 'function') {
|
||||||
|
walk(node.type(node.props), collected)
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
collected.types.push(node.type)
|
||||||
|
collected.elements.push(node)
|
||||||
|
// The double stores a `createElement` call's children on the element itself,
|
||||||
|
// exactly where React's own elements keep them.
|
||||||
|
walk(node.children, collected)
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatten = (node) => walk(node).text.join(' ')
|
||||||
|
|
||||||
|
test('the plugin page renders every section with copy, not raw keys', () => {
|
||||||
|
const { config } = mount()
|
||||||
|
const props = { ...config.definition.inject(), view: 'page' }
|
||||||
|
const rendered = walk(config.component(props))
|
||||||
|
const text = rendered.text.join(' ')
|
||||||
|
|
||||||
|
// No locale service in this context, so the seat falls back to English.
|
||||||
|
for (const copy of [
|
||||||
|
'Every alert comes from this page',
|
||||||
|
'What to notify about',
|
||||||
|
'The switches are stored in this browser',
|
||||||
|
'System notification permission',
|
||||||
|
'The buttons here go straight to the system channel',
|
||||||
|
'Status',
|
||||||
|
'nothing delivered yet',
|
||||||
|
'nothing waiting to be replayed',
|
||||||
|
'Test alert',
|
||||||
|
]) {
|
||||||
|
assert.ok(text.includes(copy), `the page is missing: ${copy}`)
|
||||||
|
}
|
||||||
|
assert.ok(!text.includes('config.'), 'a raw translation key reached the page')
|
||||||
|
assert.ok(!text.includes('settings.'), 'a raw translation key reached the page')
|
||||||
|
|
||||||
|
const classes = rendered.elements.map((element) => element.props.className ?? '')
|
||||||
|
for (const className of ['dsn-sections', 'dsn-section', 'dsn-section-title', 'dsn-row', 'dsn-button', 'dsn-status']) {
|
||||||
|
assert.ok(classes.includes(className), `the page is missing .${className}`)
|
||||||
|
}
|
||||||
|
assert.equal(classes.filter((className) => className === 'dsn-switch').length, 3)
|
||||||
|
|
||||||
|
// The live window fact is rendered, not remembered.
|
||||||
|
assert.match(text, /Window: (in front \(popup is used\)|not in front \(system notification is used\))/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the page reads the store: switches, permission and diagnostics follow it', () => {
|
||||||
|
const { config } = mount()
|
||||||
|
const props = config.definition.inject()
|
||||||
|
props.store.set({
|
||||||
|
...props.store.getSnapshot(),
|
||||||
|
kinds: { completion: false, approval: true, question: true },
|
||||||
|
lastDelivery: { outcome: 'system', shown: true, at: '17:52:30', title: 'Conversation finished' },
|
||||||
|
})
|
||||||
|
const rendered = walk(config.component({ ...props, view: 'page' }))
|
||||||
|
const switches = rendered.elements
|
||||||
|
.filter((element) => element.props.className === 'dsn-switch')
|
||||||
|
.map((element) => element.props['aria-checked'])
|
||||||
|
assert.deepEqual(switches, [false, true, true])
|
||||||
|
assert.match(rendered.text.join(' '), /Last delivery: 17:52:30 · system notification \(confirmed on screen\) · Conversation finished/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the summary view is the one-line description the plugin row shows', () => {
|
||||||
|
const { config } = mount()
|
||||||
|
const text = flatten(config.component({ ...config.definition.inject(), view: 'summary' }))
|
||||||
|
assert.equal(text.trim(), 'A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the popup stack renders nothing while empty, and one card per alert', () => {
|
||||||
|
const { toasts } = mount()
|
||||||
|
const props = toasts.definition.inject()
|
||||||
|
assert.equal(toasts.component(props), null)
|
||||||
|
|
||||||
|
props.store.set({
|
||||||
|
...props.store.getSnapshot(),
|
||||||
|
toasts: [{ id: 'dsn-1', kind: 'approval', title: 'Approval required', body: 'Conversation: Bash is waiting for your approval', sessionId: 'session-1' }],
|
||||||
|
})
|
||||||
|
const rendered = walk(toasts.component(props))
|
||||||
|
const text = rendered.text.join(' ')
|
||||||
|
assert.ok(text.includes('Approval required'))
|
||||||
|
assert.ok(text.includes('Bash is waiting for your approval'))
|
||||||
|
assert.ok(text.includes('View'))
|
||||||
|
|
||||||
|
const classes = rendered.elements.map((element) => element.props.className ?? '')
|
||||||
|
for (const className of ['dsn-stack', 'dsn-toast', 'dsn-toast-icon is-approval', 'dsn-toast-title', 'dsn-toast-desc', 'dsn-toast-action', 'dsn-toast-close']) {
|
||||||
|
assert.ok(classes.includes(className), `the popup is missing .${className}`)
|
||||||
|
}
|
||||||
|
// The close control is a square hit area with an icon only, so its label is
|
||||||
|
// the only accessible name it has.
|
||||||
|
assert.equal(
|
||||||
|
rendered.elements.find((element) => element.props.className === 'dsn-toast-close').props['aria-label'],
|
||||||
|
'Dismiss',
|
||||||
|
)
|
||||||
|
// The stack announces itself politely, and an approval is an alert, not a status.
|
||||||
|
assert.equal(rendered.elements.find((element) => element.props.className === 'dsn-stack').props['aria-live'], 'polite')
|
||||||
|
assert.equal(rendered.elements.find((element) => element.props.className === 'dsn-toast').props.role, 'alert')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the popup buttons call back into the plugin, and the card pauses its own lifetime', () => {
|
||||||
|
const { toasts } = mount()
|
||||||
|
const called = []
|
||||||
|
const props = {
|
||||||
|
...toasts.definition.inject(),
|
||||||
|
dismiss: (id) => called.push(['dismiss', id]),
|
||||||
|
open: (sessionId) => called.push(['open', sessionId]),
|
||||||
|
arm: (id) => called.push(['arm', id]),
|
||||||
|
hold: (id) => called.push(['hold', id]),
|
||||||
|
}
|
||||||
|
props.store.set({
|
||||||
|
...props.store.getSnapshot(),
|
||||||
|
toasts: [{ id: 'dsn-7', kind: 'question', title: 'Answer needed', body: 'A · continue?', sessionId: 'session-9' }],
|
||||||
|
})
|
||||||
|
const rendered = walk(toasts.component(props))
|
||||||
|
const card = rendered.elements.find((element) => element.props.className === 'dsn-toast')
|
||||||
|
const [view, close] = rendered.elements.filter((element) => typeof element.props.onClick === 'function')
|
||||||
|
|
||||||
|
card.props.onMouseEnter()
|
||||||
|
card.props.onMouseLeave()
|
||||||
|
view.props.onClick()
|
||||||
|
close.props.onClick()
|
||||||
|
|
||||||
|
assert.deepEqual(called, [
|
||||||
|
['hold', 'dsn-7'],
|
||||||
|
['arm', 'dsn-7'],
|
||||||
|
['dismiss', 'dsn-7'],
|
||||||
|
['open', 'session-9'],
|
||||||
|
['dismiss', 'dsn-7'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the observer reports its own health once the session hooks arrive', () => {
|
||||||
|
const { observer, toasts, effects } = mount({ runEffects: true })
|
||||||
|
const store = toasts.definition.inject().store
|
||||||
|
const observation = observer.definition.inject().observation
|
||||||
|
|
||||||
|
// Health changes that are not `watching` also reach the tagged console, so the
|
||||||
|
// diagnostic is captured and asserted instead of polluting the test output.
|
||||||
|
// Note which way round it fires: the observer logs when it recovers from a
|
||||||
|
// degraded state, and it logs the initial transition, but a healthy observer
|
||||||
|
// turning silent reaches the console only as the settings page's own error line.
|
||||||
|
const logged = []
|
||||||
|
const originalError = console.error
|
||||||
|
console.error = (message) => logged.push(String(message))
|
||||||
|
try {
|
||||||
|
observer.component({
|
||||||
|
observation,
|
||||||
|
useSessions: (select) => select({ byId: {} }),
|
||||||
|
useSessionStatus: (select) => select(new Map()),
|
||||||
|
})
|
||||||
|
assert.equal(store.getSnapshot().health.state, 'watching')
|
||||||
|
|
||||||
|
observer.component({ observation, useSessions: undefined, useSessionStatus: undefined })
|
||||||
|
assert.equal(store.getSnapshot().health.state, 'noHooks')
|
||||||
|
|
||||||
|
observer.component({
|
||||||
|
observation,
|
||||||
|
useSessions: (select) => select({ byId: {} }),
|
||||||
|
useSessionStatus: (select) => select(new Map()),
|
||||||
|
})
|
||||||
|
assert.equal(store.getSnapshot().health.state, 'watching')
|
||||||
|
} finally {
|
||||||
|
console.error = originalError
|
||||||
|
}
|
||||||
|
assert.equal(logged.length, 2, `expected two health diagnostics, got ${JSON.stringify(logged)}`)
|
||||||
|
for (const line of logged) assert.match(line, /session-notify: observer health: watching\b/)
|
||||||
|
|
||||||
|
// The observer owns no visible UI, and it is registered without a seat locale.
|
||||||
|
assert.equal(observer.definition.id, 'session-notify-watch')
|
||||||
|
assert.equal(observer.definition.order, 101)
|
||||||
|
assert.equal(observer.component({ observation }), null)
|
||||||
|
assert.deepEqual(effects, ['session-notify: dictionaries', 'session-notify: window focus', 'session-notify: disposal'])
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// The copy table: what a candidate becomes on screen, and how the settings page
|
||||||
|
// describes the newest delivery. Assertions use the real Chinese dictionary, so
|
||||||
|
// a key rename in the dictionaries and a rename here cannot drift apart.
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { createCopy } from '../../src/client/core/copy.js'
|
||||||
|
import { createTranslator } from '../../src/client/i18n/index.js'
|
||||||
|
|
||||||
|
const t = createTranslator({ getSnapshot: () => ({ active: 'zh' }) })
|
||||||
|
const { copyFor, describeDelivery, describeReplay } = createCopy(t)
|
||||||
|
|
||||||
|
/** One trigger candidate with every field a caller may leave empty. */
|
||||||
|
function candidate(overrides) {
|
||||||
|
return { kind: 'completion', sessionId: 'session-1', title: '标题', detail: '', pendingKind: '', ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a finished conversation names the trigger as the title and the conversation as the body', () => {
|
||||||
|
assert.deepEqual(copyFor(candidate()), {
|
||||||
|
kind: 'completion',
|
||||||
|
title: '会话已完成',
|
||||||
|
body: '标题 已完成这一轮回答',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an empty title falls back to the untitled copy', () => {
|
||||||
|
assert.equal(copyFor(candidate({ title: '' })).body, '未命名会话 已完成这一轮回答')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an approval names the waiting tool when the client publishes one', () => {
|
||||||
|
const notice = copyFor(candidate({ kind: 'approval', pendingKind: 'approval', detail: 'Bash' }))
|
||||||
|
assert.deepEqual(notice, {
|
||||||
|
kind: 'approval',
|
||||||
|
title: '需要授权',
|
||||||
|
body: '标题:工具 Bash 正在等待你的授权',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an approval without a tool name still says what is happening', () => {
|
||||||
|
const notice = copyFor(candidate({ kind: 'approval', pendingKind: 'approval', detail: '' }))
|
||||||
|
assert.equal(notice.body, '标题:有工具正在等待你的授权')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a question carries the question text, and a plan review the plan\'s own text', () => {
|
||||||
|
const question = copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' }))
|
||||||
|
assert.deepEqual(question, { kind: 'question', title: '需要回答', body: '标题 · 要继续吗?' })
|
||||||
|
assert.equal(copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '' })).body, '标题:正在等待你的回答')
|
||||||
|
|
||||||
|
// v1.0.6 behavior, pinned on purpose: a plan review is queued with
|
||||||
|
// `kind: 'question'`, so the question branch above answers it and the
|
||||||
|
// `body.planReview` copy is unreachable. See the note in core/copy.js — fixing
|
||||||
|
// it changes what a plan review says, so it is not part of this refactor.
|
||||||
|
const review = copyFor(candidate({ kind: 'question', pendingKind: 'plan-review', detail: '' }))
|
||||||
|
assert.equal(review.kind, 'question')
|
||||||
|
assert.equal(review.body, '标题:正在等待你的回答')
|
||||||
|
assert.notEqual(review.body, t('body.planReview', { title: '标题' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a delivery that never happened is described as such', () => {
|
||||||
|
assert.equal(describeDelivery(null, t), '还没有投递过')
|
||||||
|
assert.equal(describeDelivery(undefined, t), '还没有投递过')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the newest delivery is described by channel, confirmation and local time', () => {
|
||||||
|
const system = describeDelivery({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t)
|
||||||
|
assert.equal(system, '最近一次投递: 17:52:30 · 系统通知(系统已确认弹出) · 会话已完成')
|
||||||
|
|
||||||
|
const unconfirmed = describeDelivery({ outcome: 'system', shown: undefined, at: '17:52:30', title: 'X' }, t)
|
||||||
|
assert.match(unconfirmed, /系统没有回报/)
|
||||||
|
|
||||||
|
const refused = describeDelivery({ outcome: 'system', shown: false, at: '17:52:30', title: 'X' }, t)
|
||||||
|
assert.match(refused, /没弹出来/)
|
||||||
|
|
||||||
|
const fellBack = describeDelivery({ outcome: 'system-refused-threw', shown: undefined, at: '17:52:30', title: 'X' }, t)
|
||||||
|
assert.match(fellBack, /系统通知被拒绝,改用了轻弹窗/)
|
||||||
|
|
||||||
|
const replayed = describeDelivery({ outcome: 'replay', at: '17:52:30', title: 'X' }, t)
|
||||||
|
assert.match(replayed, /应用内轻弹窗(回到窗口时补发)/)
|
||||||
|
|
||||||
|
const popup = describeDelivery({ outcome: 'popup', at: '17:52:30', title: 'X' }, t)
|
||||||
|
assert.match(popup, /· 应用内轻弹窗 ·/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the replay line counts what is still waiting', () => {
|
||||||
|
assert.equal(describeReplay(0, t), '没有待补发的提醒')
|
||||||
|
assert.equal(describeReplay(3, t), '等你回到窗口时补发:3 条')
|
||||||
|
})
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// The dictionaries and the translator. The key sets are asserted equal because
|
||||||
|
// the translator falls back to whichever dictionary has a key: an asymmetric key
|
||||||
|
// would silently switch language instead of failing, and a key nobody reads is a
|
||||||
|
// defect this repository has removed once already.
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { createTranslator, dictionaries, registerDictionaries } from '../../src/client/i18n/index.js'
|
||||||
|
import { zh } from '../../src/client/i18n/zh.js'
|
||||||
|
import { en } from '../../src/client/i18n/en.js'
|
||||||
|
|
||||||
|
test('both dictionaries carry exactly the same keys', () => {
|
||||||
|
assert.deepEqual(Object.keys(zh).sort(), Object.keys(en).sort())
|
||||||
|
assert.deepEqual(Object.keys(dictionaries).sort(), ['en', 'zh'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every key carries copy in both languages', () => {
|
||||||
|
for (const [key, value] of Object.entries(zh)) {
|
||||||
|
assert.equal(typeof value, 'string', `${key} is not a string in zh`)
|
||||||
|
assert.notEqual(value, '', `${key} is empty in zh`)
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(en)) {
|
||||||
|
assert.equal(typeof value, 'string', `${key} is not a string in en`)
|
||||||
|
assert.notEqual(value, '', `${key} is empty in en`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the seat translation wins, and a key it does not know falls back to the dictionary', () => {
|
||||||
|
const locale = {
|
||||||
|
bind: (namespace) => {
|
||||||
|
assert.equal(namespace, 'session-notify')
|
||||||
|
return (key, params) => (key === 'notification.completion' ? `seat:${params?.title ?? ''}` : key)
|
||||||
|
},
|
||||||
|
getSnapshot: () => ({ active: 'zh' }),
|
||||||
|
}
|
||||||
|
const t = createTranslator(locale)
|
||||||
|
assert.equal(t('notification.completion', { title: 'X' }), 'seat:X')
|
||||||
|
assert.equal(t('body.untitled'), '未命名会话')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the active language selects the dictionary, and an unknown key renders itself', () => {
|
||||||
|
const t = createTranslator({ bind: () => undefined, getSnapshot: () => ({ active: 'zh-CN' }) })
|
||||||
|
assert.equal(t('body.untitled'), '未命名会话')
|
||||||
|
|
||||||
|
const english = createTranslator(undefined)
|
||||||
|
assert.equal(english('body.untitled'), 'Untitled conversation')
|
||||||
|
assert.equal(english('nope.not.a.key'), 'nope.not.a.key')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('interpolation replaces every known placeholder and leaves unknown ones alone', () => {
|
||||||
|
const t = createTranslator({ getSnapshot: () => ({ active: 'en' }) })
|
||||||
|
assert.equal(t('body.approval', { title: 'A', tool: 'Bash' }), 'A: Bash is waiting for your approval')
|
||||||
|
assert.equal(t('body.approval', { title: 'A' }), 'A: {tool} is waiting for your approval')
|
||||||
|
assert.equal(t('body.untitled', { unused: 'x' }), 'Untitled conversation')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a locale service that throws on every call keeps the copy renderable', () => {
|
||||||
|
const t = createTranslator({
|
||||||
|
bind: () => {
|
||||||
|
throw new Error('no such namespace')
|
||||||
|
},
|
||||||
|
getSnapshot: () => {
|
||||||
|
throw new Error('no snapshot')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(t('settings.title'), 'Session notifications')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registration prefers the typed form and falls back to one call per locale', () => {
|
||||||
|
const typed = []
|
||||||
|
const disposeTyped = registerDictionaries({ register: (ns, dict) => { typed.push([ns, dict]); return () => {} } })
|
||||||
|
assert.deepEqual(typed, [['session-notify', dictionaries]])
|
||||||
|
assert.equal(typeof disposeTyped, 'function')
|
||||||
|
|
||||||
|
const single = []
|
||||||
|
let disposed = 0
|
||||||
|
const disposeSingle = registerDictionaries({
|
||||||
|
register: (ns, id, dict) => {
|
||||||
|
if (typeof id === 'object') throw new Error('typed form unsupported')
|
||||||
|
single.push([ns, id, dict])
|
||||||
|
return () => { disposed += 1 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.deepEqual(single.map(([ns, id]) => [ns, id]), [['session-notify', 'zh'], ['session-notify', 'en']])
|
||||||
|
disposeSingle()
|
||||||
|
assert.equal(disposed, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a profile without a locale service is not an error', () => {
|
||||||
|
assert.equal(typeof registerDictionaries(undefined), 'function')
|
||||||
|
assert.doesNotThrow(() => registerDictionaries(undefined)())
|
||||||
|
assert.equal(createTranslator(undefined)('body.untitled'), 'Untitled conversation')
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// The pure derivations over the client's own session state: what counts as a
|
||||||
|
// served pending interaction, what a title or a preview becomes, which
|
||||||
|
// conversation is on screen, and whether a queued alert is still owed.
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
isOnScreen,
|
||||||
|
preview,
|
||||||
|
questionText,
|
||||||
|
servedPendingKind,
|
||||||
|
stillWorth,
|
||||||
|
titleOf,
|
||||||
|
} from '../../src/client/core/session.js'
|
||||||
|
|
||||||
|
test('only the three interaction kinds the Harness renders are served', () => {
|
||||||
|
assert.equal(servedPendingKind({ kind: 'approval' }), 'approval')
|
||||||
|
assert.equal(servedPendingKind({ kind: 'question' }), 'question')
|
||||||
|
assert.equal(servedPendingKind({ kind: 'plan-review' }), 'plan-review')
|
||||||
|
assert.equal(servedPendingKind({ kind: 'something-else' }), undefined)
|
||||||
|
assert.equal(servedPendingKind({ kind: 42 }), undefined)
|
||||||
|
assert.equal(servedPendingKind(undefined), undefined)
|
||||||
|
assert.equal(servedPendingKind(null), undefined)
|
||||||
|
assert.equal(servedPendingKind('approval'), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a preview collapses whitespace and never exceeds one popup line', () => {
|
||||||
|
assert.equal(preview(' a\n\t b '), 'a b')
|
||||||
|
assert.equal(preview(undefined), '')
|
||||||
|
assert.equal(preview('x'.repeat(80)), 'x'.repeat(80))
|
||||||
|
assert.equal(preview('x'.repeat(81)), `${'x'.repeat(79)}…`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the first question text is read across every shape the client publishes', () => {
|
||||||
|
assert.equal(questionText({ questions: [{ question: '继续吗?' }] }), '继续吗?')
|
||||||
|
assert.equal(questionText({ questions: [{ text: '继续吗?' }] }), '继续吗?')
|
||||||
|
assert.equal(questionText({ question: '继续吗?' }), '继续吗?')
|
||||||
|
assert.equal(questionText({ prompt: '继续吗?' }), '继续吗?')
|
||||||
|
assert.equal(questionText({ displayReason: { text: '继续吗?' } }), '继续吗?')
|
||||||
|
assert.equal(questionText({ reason: { text: '继续吗?' } }), '继续吗?')
|
||||||
|
assert.equal(questionText({}), '')
|
||||||
|
assert.equal(questionText(null), '')
|
||||||
|
assert.equal(questionText('继续吗?'), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a session title falls back to its identity', () => {
|
||||||
|
assert.equal(titleOf({ title: ' 会话 A ' }, 'abcdef123456'), '会话 A')
|
||||||
|
assert.equal(titleOf({ title: '' }, 'abcdef123456'), 'abcdef12')
|
||||||
|
assert.equal(titleOf(undefined, 'abcdef123456'), 'abcdef12')
|
||||||
|
assert.equal(titleOf({ title: 42 }, 'abcdef123456'), 'abcdef12')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the conversation on screen is the one the main view retains', () => {
|
||||||
|
const list = { byId: { a: { retainedBy: { mainView: 1 } }, b: { retainedBy: { mainView: 0 } } } }
|
||||||
|
assert.equal(isOnScreen('a', list), true)
|
||||||
|
assert.equal(isOnScreen('b', list), false)
|
||||||
|
assert.equal(isOnScreen('c', list), false)
|
||||||
|
assert.equal(isOnScreen('a', undefined), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a queued alert is dropped when its conversation resumed inside the settle window', () => {
|
||||||
|
const candidate = { kind: 'completion', sessionId: 'a', test: false }
|
||||||
|
const live = (byId, status) => ({ list: { byId }, status })
|
||||||
|
|
||||||
|
assert.equal(stillWorth(candidate, { list: undefined, status: undefined }), true)
|
||||||
|
assert.equal(stillWorth(candidate, live({ a: { running: false } })), true)
|
||||||
|
assert.equal(stillWorth(candidate, live({})), true)
|
||||||
|
assert.equal(stillWorth(candidate, live({ a: { running: true } })), false)
|
||||||
|
// The per-session selector wins over the list summary, which still carries the
|
||||||
|
// pre-transition flag.
|
||||||
|
assert.equal(stillWorth(candidate, { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: false }]]) }), true)
|
||||||
|
// A test alert is owed no matter what the live state says.
|
||||||
|
assert.equal(stillWorth({ ...candidate, test: true }, live({ a: { running: true } })), true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// The browser-local trigger switches. Storage is foreign input: it can be
|
||||||
|
// absent, hold another version's shape, or refuse to write, and none of those
|
||||||
|
// may stop the plugin from working with its defaults.
|
||||||
|
import { afterEach, test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { STORAGE_KEY, readStoredKinds, writeStoredKinds } from '../../src/client/core/storage.js'
|
||||||
|
|
||||||
|
/** Install a storage double, optionally one that throws. */
|
||||||
|
function useStorage(behaviour) {
|
||||||
|
const data = new Map()
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: (key) => {
|
||||||
|
if (behaviour === 'read-throws') throw new Error('denied')
|
||||||
|
return data.has(key) ? data.get(key) : null
|
||||||
|
},
|
||||||
|
setItem: (key, value) => {
|
||||||
|
if (behaviour === 'write-throws') throw new Error('quota')
|
||||||
|
data.set(key, String(value))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.localStorage
|
||||||
|
})
|
||||||
|
|
||||||
|
test('everything is on when this browser has no stored choice', () => {
|
||||||
|
useStorage()
|
||||||
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('without storage at all the defaults still apply', () => {
|
||||||
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
||||||
|
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: true, question: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a stored switch survives a round trip, and unknown or broken values do not', () => {
|
||||||
|
const data = useStorage()
|
||||||
|
writeStoredKinds({ completion: false, approval: true, question: false })
|
||||||
|
assert.equal(data.get(STORAGE_KEY), JSON.stringify({ completion: false, approval: true, question: false }))
|
||||||
|
assert.deepEqual(readStoredKinds(), { completion: false, approval: true, question: false })
|
||||||
|
|
||||||
|
data.set(STORAGE_KEY, '{ not json')
|
||||||
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
||||||
|
|
||||||
|
data.set(STORAGE_KEY, JSON.stringify({ completion: 'yes', approval: false }))
|
||||||
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: false, question: true })
|
||||||
|
|
||||||
|
data.set(STORAGE_KEY, JSON.stringify({ somethingElse: false }))
|
||||||
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('storage that refuses to be read or written never throws', () => {
|
||||||
|
useStorage('read-throws')
|
||||||
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
||||||
|
|
||||||
|
useStorage('write-throws')
|
||||||
|
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false }))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user