✅ 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
+4 -3
View File
@@ -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', () => {
+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')
},
})
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', () => {
+47 -6
View File
@@ -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))
})
+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-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('.')}`
+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')
})