✅ 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.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
@@ -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()
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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('.')}`
|
||||||
|
|||||||
@@ -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