From 5efb311194f25f78a371a4c04589b7eff9c616c2 Mon Sep 17 00:00:00 2001 From: Pan <503103216@qq.com> Date: Mon, 5 Oct 2026 00:29:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test:=20=E8=A6=86=E7=9B=96=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E3=80=81=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=90=88=E5=B9=B6=E4=B8=8E=E6=8F=92=E4=BB=B6=E9=A1=B5?= =?UTF-8?q?=E7=9A=84=E6=97=B6=E9=95=BF=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/bundle.test.js | 2 + tests/render.test.js | 37 +++++++++++++- tests/unit/copy.test.js | 7 +-- tests/unit/i18n.test.js | 2 +- tests/unit/storage.test.js | 53 +++++++++++++++++--- tests/unit/styles.test.js | 4 +- tests/unit/toasts.test.js | 100 +++++++++++++++++++++++++++++++++++++ 7 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 tests/unit/toasts.test.js diff --git a/tests/bundle.test.js b/tests/bundle.test.js index e3fb7fb..b31c912 100644 --- a/tests/bundle.test.js +++ b/tests/bundle.test.js @@ -137,6 +137,8 @@ test('applying the plugin registers its three seats without touching the DOM', ( assert.equal(typeof configProps.testAny, 'function') assert.equal(typeof configProps.testSystem, 'function') assert.equal(typeof configProps.setKind, 'function') + assert.equal(typeof configProps.setDismiss, 'function') + assert.equal(configProps.store.getSnapshot().dismissAfterMs, 6000, 'a browser with no stored choice gets the shipped lifetime') assert.equal(configProps.replayCount(), 0) // No locale service in this fake context, so the plugin's own translator falls // back to its English dictionary. diff --git a/tests/render.test.js b/tests/render.test.js index 4fa288b..02e26db 100644 --- a/tests/render.test.js +++ b/tests/render.test.js @@ -122,6 +122,11 @@ test('the plugin page reads as three groups with copy, not raw keys', () => { 'Every alert comes from this page', 'What to notify about', 'The switches are stored in this browser', + 'Popup', + 'Auto-close', + '6s', + 'Never', + 'Hovering a card pauses the countdown', 'System notification permission', 'This environment cannot show system notifications', platformHint, @@ -149,6 +154,8 @@ test('the plugin page reads as three groups with copy, not raw keys', () => { 'dsn-row', 'dsn-row-title', 'dsn-state', + 'dsn-chips', + 'dsn-chip', 'dsn-facts', 'dsn-fact-key', 'dsn-fact-value', @@ -156,13 +163,41 @@ test('the plugin page reads as three groups with copy, not raw keys', () => { ]) { assert.ok(classes.includes(className), `the page is missing .${className}`) } - // Three switches, one status dot, and the readings as key/value pairs. + // Three switches, five lifetime choices, one status dot, and the readings as + // key/value pairs. assert.equal(classes.filter((className) => className === 'dsn-switch').length, 3) + assert.equal(classes.filter((className) => className === 'dsn-chip').length, 5) assert.equal(classes.filter((className) => className === 'dsn-dot is-muted').length, 1) assert.equal(classes.filter((className) => className === 'dsn-fact-key').length, 4) assert.equal(classes.filter((className) => className === 'dsn-fact-value').length, 4) }) +test('the popup lifetime is a real choice: the page reads it and writes it back', () => { + const { config } = mount() + const props = config.definition.inject() + + const chips = (componentProps) => walk(config.component({ ...componentProps, view: 'page' })) + .elements.filter((element) => element.props.className === 'dsn-chip') + const labelOf = (chip) => walk(chip).text.join('') + const checkedOf = (list) => list.filter((chip) => chip.props['aria-checked'] === true).map(labelOf) + + const before = chips(props) + assert.deepEqual(before.map(labelOf), ['3s', '6s', '10s', '30s', 'Never']) + assert.deepEqual(checkedOf(before), ['6s'], 'the shipped default is preselected') + assert.equal(before[0].props.role, 'radio') + assert.equal(walk(config.component({ ...props, view: 'page' })).elements.find((element) => element.props.className === 'dsn-chips').props.role, 'radiogroup') + + // "Never" is the zero choice, and choosing it goes through the plugin's own + // action: the store the popup stack reads is updated, not just the button. + before[4].props.onClick() + assert.equal(props.store.getSnapshot().dismissAfterMs, 0) + assert.deepEqual(checkedOf(chips(props)), ['Never']) + + before[0].props.onClick() + assert.equal(props.store.getSnapshot().dismissAfterMs, 3000) + assert.deepEqual(checkedOf(chips(props)), ['3s']) +}) + test('the page follows the store: switches, permission state and delivery facts', () => { const { config } = mount() const props = config.definition.inject() diff --git a/tests/unit/copy.test.js b/tests/unit/copy.test.js index b196da7..a2ae326 100644 --- a/tests/unit/copy.test.js +++ b/tests/unit/copy.test.js @@ -15,16 +15,17 @@ 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', () => { +test('a finished conversation says what happened in the heading, and the conversation in the description', () => { + // The heading already carries "finished", so the description is the title alone. assert.deepEqual(copyFor(candidate()), { kind: 'completion', title: '会话已完成', - body: '标题 已完成这一轮回答', + body: '标题', }) }) test('an empty title falls back to the untitled copy', () => { - assert.equal(copyFor(candidate({ title: '' })).body, '未命名会话 已完成这一轮回答') + assert.equal(copyFor(candidate({ title: '' })).body, '未命名会话') }) test('an approval names the waiting tool when the client publishes one', () => { diff --git a/tests/unit/i18n.test.js b/tests/unit/i18n.test.js index 4259cd8..001d069 100644 --- a/tests/unit/i18n.test.js +++ b/tests/unit/i18n.test.js @@ -63,7 +63,7 @@ test('a locale service that throws on every call keeps the copy renderable', () throw new Error('no snapshot') }, }) - assert.equal(t('settings.title'), 'Session notifications') + assert.equal(t('body.untitled'), 'Untitled conversation') }) test('registration prefers the typed form and falls back to one call per locale', () => { diff --git a/tests/unit/storage.test.js b/tests/unit/storage.test.js index f51a63a..367918f 100644 --- a/tests/unit/storage.test.js +++ b/tests/unit/storage.test.js @@ -1,10 +1,18 @@ -// 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. +// The browser-local preferences: the three trigger switches and the popup +// lifetime. Storage is foreign input: it can be absent, hold another version's +// shape, or refuse to write, and none of that may stop the plugin — nor may +// writing one preference ever clear another. import { afterEach, test } from 'node:test' import assert from 'node:assert/strict' -import { STORAGE_KEY, readStoredKinds, writeStoredKinds } from '../../src/client/core/storage.js' +import { + STORAGE_KEY, + readStoredDismissMs, + readStoredKinds, + writeStoredDismissMs, + writeStoredKinds, +} from '../../src/client/core/storage.js' +import { TOAST_DURATION_MS } from '../../src/client/constants.js' /** Install a storage double, optionally one that throws. */ function useStorage(behaviour) { @@ -26,20 +34,22 @@ afterEach(() => { delete globalThis.localStorage }) -test('everything is on when this browser has no stored choice', () => { +test('everything is on, with the shipped popup lifetime, when this browser has no stored choice', () => { useStorage() assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) + assert.equal(readStoredDismissMs(), TOAST_DURATION_MS) }) test('without storage at all the defaults still apply', () => { assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) + assert.equal(readStoredDismissMs(), TOAST_DURATION_MS) assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: true, question: true })) + assert.doesNotThrow(() => writeStoredDismissMs(0)) }) 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') @@ -52,10 +62,41 @@ test('a stored switch survives a round trip, and unknown or broken values do not assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) }) +test('the popup lifetime survives a round trip, including "never"', () => { + const data = useStorage() + writeStoredDismissMs(30000) + assert.equal(readStoredDismissMs(), 30000) + + writeStoredDismissMs(0) + assert.equal(readStoredDismissMs(), 0, 'zero means the card waits for the user') + + // A value this version does not offer is ignored rather than trusted. + data.set(STORAGE_KEY, JSON.stringify({ dismissAfterMs: 1234 })) + assert.equal(readStoredDismissMs(), TOAST_DURATION_MS) + data.set(STORAGE_KEY, JSON.stringify({ dismissAfterMs: -1 })) + assert.equal(readStoredDismissMs(), TOAST_DURATION_MS) + data.set(STORAGE_KEY, JSON.stringify({ dismissAfterMs: '6000' })) + assert.equal(readStoredDismissMs(), TOAST_DURATION_MS) +}) + +test('writing one preference keeps every other one', () => { + useStorage() + writeStoredKinds({ completion: false, approval: false, question: false }) + writeStoredDismissMs(0) + assert.deepEqual(readStoredKinds(), { completion: false, approval: false, question: false }) + assert.equal(readStoredDismissMs(), 0) + + // …and the other way round: the switches do not reset the lifetime. + writeStoredKinds({ completion: true, approval: true, question: true }) + assert.equal(readStoredDismissMs(), 0) +}) + test('storage that refuses to be read or written never throws', () => { useStorage('read-throws') assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) + assert.equal(readStoredDismissMs(), TOAST_DURATION_MS) useStorage('write-throws') assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false })) + assert.doesNotThrow(() => writeStoredDismissMs(3000)) }) diff --git a/tests/unit/styles.test.js b/tests/unit/styles.test.js index d890e51..1262721 100644 --- a/tests/unit/styles.test.js +++ b/tests/unit/styles.test.js @@ -71,8 +71,8 @@ test('every class the components render has a rule, so nothing falls back to def 'dsn-stack', 'dsn-toast', 'dsn-toast-icon', 'dsn-toast-icon is-completion', 'dsn-toast-icon is-question', 'dsn-toast-text', 'dsn-toast-title', 'dsn-toast-desc', 'dsn-toast-actions', 'dsn-toast-action', 'dsn-toast-close', 'dsn-page', 'dsn-lede', 'dsn-group', 'dsn-group-title', 'dsn-group-note', 'dsn-list', 'dsn-row', 'dsn-row-text', - 'dsn-row-title', 'dsn-row-sub', 'dsn-state', 'dsn-dot', 'dsn-facts', 'dsn-fact-key', 'dsn-fact', 'dsn-fact-value', - 'dsn-fact-title', 'dsn-switch', 'dsn-thumb', 'dsn-button', + 'dsn-row-title', 'dsn-row-sub', 'dsn-state', 'dsn-dot', 'dsn-chips', 'dsn-chip', 'dsn-facts', 'dsn-fact-key', + 'dsn-fact', 'dsn-fact-value', 'dsn-fact-title', 'dsn-switch', 'dsn-thumb', 'dsn-button', ] for (const className of expected) { const selector = `.${className.split(' ').join('.')}` diff --git a/tests/unit/toasts.test.js b/tests/unit/toasts.test.js new file mode 100644 index 0000000..9c9e89a --- /dev/null +++ b/tests/unit/toasts.test.js @@ -0,0 +1,100 @@ +// The popup stack's lifetime: the user's choice decides it, and "never" really +// means the card waits for the user rather than for a timer. +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { createStore } from '../../src/client/core/store.js' +import { createToastStack } from '../../src/client/core/toasts.js' +import { createCopy } from '../../src/client/core/copy.js' +import { createTranslator } from '../../src/client/i18n/index.js' +import { TOAST_DURATION_MS } from '../../src/client/constants.js' + +const tr = createTranslator({ getSnapshot: () => ({ active: 'zh' }) }) +const candidate = { kind: 'completion', sessionId: 's1', title: '标题', detail: '', pendingKind: '' } + +/** A stack over a store whose only interesting field is the popup lifetime. */ +function stack(dismissAfterMs) { + const store = createStore({ dismissAfterMs, toasts: [] }) + return { store, toasts: createToastStack({ store, copy: createCopy(tr) }) } +} + +const ids = (store) => store.getSnapshot().toasts.map((toast) => toast.id) + +test('a card closes itself after the configured lifetime', (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }) + const { store, toasts } = stack(6000) + toasts.showToast(candidate) + + assert.equal(ids(store).length, 1) + context.mock.timers.tick(5999) + assert.equal(ids(store).length, 1, 'the card must outlive its countdown') + context.mock.timers.tick(1) + assert.deepEqual(ids(store), []) +}) + +test('a lifetime of zero leaves the card until it is dismissed', (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }) + const { store, toasts } = stack(0) + toasts.showToast(candidate) + + context.mock.timers.tick(60 * 60 * 1000) + assert.equal(ids(store).length, 1, 'nothing may close a "never" card') + + toasts.closeToast(ids(store)[0]) + assert.deepEqual(ids(store), []) +}) + +test('hovering holds the card, and the lifetime chosen while held applies afterwards', (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }) + const { store, toasts } = stack(3000) + toasts.showToast(candidate) + const id = ids(store)[0] + + toasts.holdToast(id) + context.mock.timers.tick(30 * 1000) + assert.equal(ids(store).length, 1, 'a held card has no timer left') + + // The settings page switches to "never" while the pointer is still on the card. + store.set({ ...store.getSnapshot(), dismissAfterMs: 0 }) + toasts.armToast(id) + context.mock.timers.tick(60 * 60 * 1000) + assert.equal(ids(store).length, 1) +}) + +test('a lifetime change applies to the cards already on screen, but never to a held one', (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }) + const { store, toasts } = stack(30000) + toasts.showToast(candidate) + toasts.showToast({ ...candidate, sessionId: 's2' }) + const [newest, oldest] = ids(store) + toasts.holdToast(oldest) + + // The settings page shortens the lifetime while both cards are visible. + store.set({ ...store.getSnapshot(), dismissAfterMs: 3000 }) + toasts.rearmAll() + context.mock.timers.tick(3000) + assert.deepEqual(ids(store), [oldest], 'the visible card followed the new choice, the held one did not') + + toasts.armToast(oldest) + context.mock.timers.tick(3000) + assert.deepEqual(ids(store), [], 'once the pointer leaves, the held card counts down again') + void newest +}) + +test('a lifetime this version cannot use falls back to the shipped default', (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }) + const { store, toasts } = stack(undefined) + toasts.showToast(candidate) + + context.mock.timers.tick(TOAST_DURATION_MS) + assert.deepEqual(ids(store), []) +}) + +test('the stack never grows past its bound, however long the cards live', (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }) + const { store, toasts } = stack(0) + for (let index = 0; index < 5; index += 1) toasts.showToast({ ...candidate, sessionId: `s${index}` }) + + assert.equal(ids(store).length, 3) + assert.deepEqual(ids(store), ['dsn-5', 'dsn-4', 'dsn-3'], 'newest first') +})