✅ test: 覆盖弹窗生命周期、存储合并与插件页的时长选项
This commit is contained in:
@@ -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.
|
||||
|
||||
+36
-1
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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('.')}`
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user