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).
312 lines
12 KiB
TypeScript
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')
|
|
})
|
|
})
|