✅ test: 覆盖弹窗生命周期、存储合并与插件页的时长选项

This commit is contained in:
pyh
2026-10-05 00:29:36 +08:00
parent faa3930ae8
commit 5efb311194
7 changed files with 192 additions and 13 deletions
+2
View File
@@ -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.testAny, 'function')
assert.equal(typeof configProps.testSystem, 'function') assert.equal(typeof configProps.testSystem, 'function')
assert.equal(typeof configProps.setKind, '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) assert.equal(configProps.replayCount(), 0)
// No locale service in this fake context, so the plugin's own translator falls // No locale service in this fake context, so the plugin's own translator falls
// back to its English dictionary. // back to its English dictionary.
+36 -1
View File
@@ -122,6 +122,11 @@ test('the plugin page reads as three groups with copy, not raw keys', () => {
'Every alert comes from this page', 'Every alert comes from this page',
'What to notify about', 'What to notify about',
'The switches are stored in this browser', 'The switches are stored in this browser',
'Popup',
'Auto-close',
'6s',
'Never',
'Hovering a card pauses the countdown',
'System notification permission', 'System notification permission',
'This environment cannot show system notifications', 'This environment cannot show system notifications',
platformHint, platformHint,
@@ -149,6 +154,8 @@ test('the plugin page reads as three groups with copy, not raw keys', () => {
'dsn-row', 'dsn-row',
'dsn-row-title', 'dsn-row-title',
'dsn-state', 'dsn-state',
'dsn-chips',
'dsn-chip',
'dsn-facts', 'dsn-facts',
'dsn-fact-key', 'dsn-fact-key',
'dsn-fact-value', '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}`) 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-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-dot is-muted').length, 1)
assert.equal(classes.filter((className) => className === 'dsn-fact-key').length, 4) assert.equal(classes.filter((className) => className === 'dsn-fact-key').length, 4)
assert.equal(classes.filter((className) => className === 'dsn-fact-value').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', () => { test('the page follows the store: switches, permission state and delivery facts', () => {
const { config } = mount() const { config } = mount()
const props = config.definition.inject() const props = config.definition.inject()
+4 -3
View File
@@ -15,16 +15,17 @@ function candidate(overrides) {
return { kind: 'completion', sessionId: 'session-1', title: '标题', detail: '', pendingKind: '', ...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()), { assert.deepEqual(copyFor(candidate()), {
kind: 'completion', kind: 'completion',
title: '会话已完成', title: '会话已完成',
body: '标题 已完成这一轮回答', body: '标题',
}) })
}) })
test('an empty title falls back to the untitled copy', () => { 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', () => { test('an approval names the waiting tool when the client publishes one', () => {
+1 -1
View File
@@ -63,7 +63,7 @@ test('a locale service that throws on every call keeps the copy renderable', ()
throw new Error('no snapshot') 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', () => { test('registration prefers the typed form and falls back to one call per locale', () => {
+47 -6
View File
@@ -1,10 +1,18 @@
// The browser-local trigger switches. Storage is foreign input: it can be // The browser-local preferences: the three trigger switches and the popup
// absent, hold another version's shape, or refuse to write, and none of those // lifetime. Storage is foreign input: it can be absent, hold another version's
// may stop the plugin from working with its defaults. // 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 { afterEach, test } from 'node:test'
import assert from 'node:assert/strict' 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. */ /** Install a storage double, optionally one that throws. */
function useStorage(behaviour) { function useStorage(behaviour) {
@@ -26,20 +34,22 @@ afterEach(() => {
delete globalThis.localStorage 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() useStorage()
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
}) })
test('without storage at all the defaults still apply', () => { test('without storage at all the defaults still apply', () => {
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) 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(() => 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', () => { test('a stored switch survives a round trip, and unknown or broken values do not', () => {
const data = useStorage() const data = useStorage()
writeStoredKinds({ completion: false, approval: true, question: false }) 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 }) assert.deepEqual(readStoredKinds(), { completion: false, approval: true, question: false })
data.set(STORAGE_KEY, '{ not json') 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 }) 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', () => { test('storage that refuses to be read or written never throws', () => {
useStorage('read-throws') useStorage('read-throws')
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
useStorage('write-throws') useStorage('write-throws')
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false })) assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false }))
assert.doesNotThrow(() => writeStoredDismissMs(3000))
}) })
+2 -2
View File
@@ -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-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-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-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-row-title', 'dsn-row-sub', 'dsn-state', 'dsn-dot', 'dsn-chips', 'dsn-chip', 'dsn-facts', 'dsn-fact-key',
'dsn-fact-title', 'dsn-switch', 'dsn-thumb', 'dsn-button', 'dsn-fact', 'dsn-fact-value', 'dsn-fact-title', 'dsn-switch', 'dsn-thumb', 'dsn-button',
] ]
for (const className of expected) { for (const className of expected) {
const selector = `.${className.split(' ').join('.')}` const selector = `.${className.split(' ').join('.')}`
+100
View File
@@ -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')
})