Files
dsh-context/tests/client/settings.spec.ts
T
bowenliang123 5322d1e6d4 fix(client): fail the placement open to 全部 on unreadable or unpersisted config
A config problem must never leave a Context entry hidden:

- a scope value the plugin cannot understand (present but invalid)
  degrades the placement to `all` instead of stranding the previously
  chosen one — the read path now distinguishes invalid from missing,
  and only a genuinely absent field (older Host half) keeps the
  current state;
- a placement write that fails to persist no longer keeps the
  optimistic echo in-session: unless the scope's truth itself carries
  a valid placement, the rollback degrades to `all` (other fields
  keep the existing keep-the-echo behavior).
2026-09-11 14:02:35 +08:00

312 lines
12 KiB
TypeScript

// Settings binding (src/client/settings.ts): defaults, the observable store,
// scope attach/sync, preference parsing, and the local-echo set path.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { createContextSettings, type SettingsScopeLike } from '../../src/client/settings'
/**
* A faithful in-memory settings scope (the harness settingsScope.bind
* contract: getSnapshot/subscribe/set), not a mock of plugin code.
*/
class TestSettingsScope implements SettingsScopeLike {
private snapshot: { status: string; value: unknown; writable: boolean }
private readonly listeners = new Set<() => void>()
readonly sets: { field: string; value: unknown }[] = []
constructor(snapshot: { status: string; value: unknown; writable: boolean }) {
this.snapshot = snapshot
}
getSnapshot(): { status: string; value: unknown; writable: boolean } {
return this.snapshot
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/** When true the write rejects, like a transport failure on the wire call. */
failSet = false
set(field: string, value: unknown): Promise<void> {
this.sets.push({ field, value })
return this.failSet ? Promise.reject(new Error('write failed')) : Promise.resolve()
}
/** Push a new snapshot, like the Host delivering a section update. */
emit(snapshot: { status: string; value: unknown; writable: boolean }): void {
this.snapshot = snapshot
for (const listener of this.listeners) listener()
}
}
describe('createContextSettings defaults', () => {
test('starts loading with schema defaults and not writable', () => {
const s = createContextSettings()
assert.deepEqual(s.store.getSnapshot(), {
status: 'loading',
placement: 'all',
granularity: 'step',
mode: 'total',
fileSort: 'count',
writable: false,
})
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultFileSort(), 'count')
})
})
describe('store', () => {
test('subscribers are notified on change and unsubscribe stops', () => {
const s = createContextSettings()
let calls = 0
const unsubscribe = s.store.subscribe(() => { calls++ })
s.set('defaultTrendMode', 'delta')
assert.equal(calls, 1)
assert.equal(s.store.getSnapshot().mode, 'delta')
unsubscribe()
s.set('defaultTrendMode', 'total')
assert.equal(calls, 1)
})
})
describe('set', () => {
test('without attach it echoes locally and never throws', () => {
const s = createContextSettings()
s.set('defaultGranularity', 'turn')
assert.equal(s.defaultGranularity(), 'turn')
s.set('defaultPlacement', 'sidebar')
assert.equal(s.defaultPlacement(), 'sidebar')
})
test('an unchanged value does not notify listeners', () => {
const s = createContextSettings()
let calls = 0
s.store.subscribe(() => { calls++ })
s.set('defaultGranularity', 'step')
assert.equal(calls, 0)
})
test('an invalid value is dropped without notifying', () => {
const s = createContextSettings()
let calls = 0
s.store.subscribe(() => { calls++ })
s.set('defaultGranularity', 'bogus')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(calls, 0)
})
test('with attach it echoes locally and writes through the scope', () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
s.attach(scope)
s.set('defaultTrendMode', 'delta')
assert.equal(s.defaultTrendMode(), 'delta')
assert.deepEqual(scope.sets, [{ field: 'defaultTrendMode', value: 'delta' }])
})
test('a rejected scope write settles handled and rolls the echo back to the scope truth', async () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: { defaultTrendMode: 'total' }, writable: true })
s.attach(scope)
scope.failSet = true
s.set('defaultTrendMode', 'delta')
assert.equal(s.defaultTrendMode(), 'delta', 'the optimistic echo lands first')
// Let the rejection settle: the catch re-syncs from the scope's snapshot.
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.defaultTrendMode(), 'total', 'the echo rolls back to the scope truth')
})
test('a rejected write keeps a preference the scope does not carry', async () => {
// The scope's snapshot lacks the field entirely (older Host half): the
// rollback sync keeps the in-session choice rather than dropping it.
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
s.attach(scope)
scope.failSet = true
s.set('defaultFileSort', 'path')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.defaultFileSort(), 'path')
})
test('a rejected placement write degrades to all when the scope carries no valid value', async () => {
// Fail open: the unpersisted echo must not keep hiding an entry until
// the next reload — with nothing valid in the scope's truth, fall back
// to `all`.
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
s.attach(scope)
scope.failSet = true
s.set('defaultPlacement', 'sidebar')
assert.equal(s.defaultPlacement(), 'sidebar', 'the optimistic echo lands first')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.defaultPlacement(), 'all', 'the echo degrades instead of staying unpersisted')
})
test('a rejected placement write rolls back to the scope\'s valid truth', async () => {
// The scope truth itself is a valid placement: the rollback restores it,
// no forced degrade.
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: { defaultPlacement: 'sidebar' }, writable: true })
s.attach(scope)
scope.failSet = true
s.set('defaultPlacement', 'tab')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.defaultPlacement(), 'sidebar')
})
})
describe('attach', () => {
test('syncs a ready snapshot with parsed preferences', () => {
const s = createContextSettings()
const scope = new TestSettingsScope({
status: 'ready',
value: {
defaultPlacement: 'sidebar',
defaultGranularity: 'turn',
defaultTrendMode: 'delta',
defaultFileSort: 'path',
},
writable: true,
})
s.attach(scope)
assert.deepEqual(s.store.getSnapshot(), {
status: 'ready',
placement: 'sidebar',
granularity: 'turn',
mode: 'delta',
fileSort: 'path',
writable: true,
})
})
test('an unavailable snapshot keeps parsed preferences', () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'unavailable', value: {}, writable: false })
s.attach(scope)
assert.equal(s.store.getSnapshot().status, 'unavailable')
})
test('any other snapshot status reads as loading', () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'pending', value: {}, writable: false })
s.attach(scope)
assert.equal(s.store.getSnapshot().status, 'loading')
})
test('null/non-object section values keep the defaults', () => {
for (const value of [null, 42]) {
const s = createContextSettings()
s.attach(new TestSettingsScope({ status: 'ready', value, writable: false }))
assert.deepEqual(s.store.getSnapshot(), {
status: 'ready',
placement: 'all',
granularity: 'step',
mode: 'total',
fileSort: 'count',
writable: false,
})
}
})
test('invalid preference values keep the defaults', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
status: 'ready',
value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultFileSort: 'alpha' },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultFileSort(), 'count')
})
test('an invalid scope placement degrades to all instead of keeping the current one', () => {
// Fail open on the read path too: a value the plugin cannot understand
// must not strand a previously chosen placement.
const s = createContextSettings()
s.set('defaultPlacement', 'tab')
assert.equal(s.defaultPlacement(), 'tab')
const scope = new TestSettingsScope({ status: 'ready', value: { defaultPlacement: 42 }, writable: false })
s.attach(scope)
assert.equal(s.defaultPlacement(), 'all')
})
test('explicit schema-default values are accepted', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
status: 'ready',
value: { defaultPlacement: 'all', defaultGranularity: 'step', defaultTrendMode: 'total', defaultFileSort: 'count' },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultFileSort(), 'count')
})
test('missing fields keep the current state', () => {
const s = createContextSettings()
s.set('defaultPlacement', 'tab')
s.attach(new TestSettingsScope({ status: 'ready', value: { defaultFileSort: 'latest' }, writable: false }))
assert.equal(s.defaultPlacement(), 'tab', 'the in-session choice survives a section without the field')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultFileSort(), 'latest')
})
test('scope updates republish to subscribers', () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
s.attach(scope)
let calls = 0
s.store.subscribe(() => { calls++ })
scope.emit({ status: 'ready', value: { defaultGranularity: 'turn' }, writable: true })
assert.equal(calls, 1)
assert.equal(s.defaultGranularity(), 'turn')
scope.emit({ status: 'ready', value: { defaultGranularity: 'turn', defaultTrendMode: 'delta' }, writable: true })
assert.equal(calls, 2)
assert.equal(s.defaultTrendMode(), 'delta')
scope.emit({ status: 'ready', value: { defaultFileSort: 'path' }, writable: true })
assert.equal(calls, 3)
assert.equal(s.defaultFileSort(), 'path')
scope.emit({ status: 'ready', value: { defaultPlacement: 'sidebar' }, writable: true })
assert.equal(calls, 4)
assert.equal(s.defaultPlacement(), 'sidebar')
scope.emit({ status: 'ready', value: { defaultFileSort: 'path' }, writable: false })
assert.equal(calls, 5)
assert.equal(s.store.getSnapshot().writable, false)
})
test('an identical scope snapshot does not notify listeners', () => {
const s = createContextSettings()
const scope = new TestSettingsScope({
status: 'ready',
value: { defaultGranularity: 'turn', defaultFileSort: 'latest' },
writable: true,
})
s.attach(scope)
let calls = 0
s.store.subscribe(() => { calls++ })
scope.emit({ status: 'ready', value: { defaultGranularity: 'turn', defaultFileSort: 'latest' }, writable: true })
assert.equal(calls, 0)
})
test('the returned disposer detaches the scope subscription', () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
const detach = s.attach(scope)
detach()
let calls = 0
s.store.subscribe(() => { calls++ })
scope.emit({ status: 'ready', value: { defaultGranularity: 'turn' }, writable: true })
assert.equal(calls, 0)
assert.equal(s.defaultGranularity(), 'step')
})
})