From 99bbf0cc1ef1f6205493a3953d56fa25a403eb3a Mon Sep 17 00:00:00 2001 From: Pan <503103216@qq.com> Date: Sun, 4 Oct 2026 19:01:23 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test:=20=E8=A1=A5=E4=B8=8A=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E3=80=81=E4=BA=A7=E7=89=A9=E5=86=92=E7=83=9F?= =?UTF-8?q?=E4=B8=8E=E7=BB=84=E4=BB=B6=E6=B8=B2=E6=9F=93=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=8C=E5=AE=88=E6=8A=A4=E4=BA=A7=E7=89=A9=E6=96=B0=E9=B2=9C?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/bundle.test.js | 156 ++++++++++++++++++++++ tests/render.test.js | 260 +++++++++++++++++++++++++++++++++++++ tests/unit/copy.test.js | 87 +++++++++++++ tests/unit/i18n.test.js | 93 +++++++++++++ tests/unit/session.test.js | 74 +++++++++++ tests/unit/storage.test.js | 61 +++++++++ 6 files changed, 731 insertions(+) create mode 100644 tests/bundle.test.js create mode 100644 tests/render.test.js create mode 100644 tests/unit/copy.test.js create mode 100644 tests/unit/i18n.test.js create mode 100644 tests/unit/session.test.js create mode 100644 tests/unit/storage.test.js diff --git a/tests/bundle.test.js b/tests/bundle.test.js new file mode 100644 index 0000000..b2f58f5 --- /dev/null +++ b/tests/bundle.test.js @@ -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') +}) diff --git a/tests/render.test.js b/tests/render.test.js new file mode 100644 index 0000000..e07c9ec --- /dev/null +++ b/tests/render.test.js @@ -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']) +}) diff --git a/tests/unit/copy.test.js b/tests/unit/copy.test.js new file mode 100644 index 0000000..dfc74f8 --- /dev/null +++ b/tests/unit/copy.test.js @@ -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 条') +}) diff --git a/tests/unit/i18n.test.js b/tests/unit/i18n.test.js new file mode 100644 index 0000000..4259cd8 --- /dev/null +++ b/tests/unit/i18n.test.js @@ -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') +}) diff --git a/tests/unit/session.test.js b/tests/unit/session.test.js new file mode 100644 index 0000000..8829818 --- /dev/null +++ b/tests/unit/session.test.js @@ -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) +}) diff --git a/tests/unit/storage.test.js b/tests/unit/storage.test.js new file mode 100644 index 0000000..f51a63a --- /dev/null +++ b/tests/unit/storage.test.js @@ -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 })) +})