Files
bowenliang123 6f8dd889de feat(overview): auto-open the trend overview at a step threshold
Add the per-user overviewAutoSteps preference (100/200/300 steps, or off; 200 by default) to the plugin settings card. Once a session's retained step count reaches the threshold, the trend switches to its Overview ribbon and clears the mutually exclusive DNA/Adaptive faces; the auto activation fires once per session so a reader's own toggle-off sticks. The threshold counts raw retained steps, independent of the step/turn granularity.
2026-10-21 11:53:38 +08:00

514 lines
21 KiB
TypeScript

// Settings binding (src/client/settings.ts): the local-echo store over the harness configForms section.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { createContextSettings, overviewThresholdOf, type SettingsScopeLike } from '../../src/client/settings'
/** A faithful in-memory settings form (the harness configForms.get 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',
deltaBase: 'step',
toolSort: 'count',
fileSort: 'count',
insightsEntry: 'show',
durationCurve: 'show',
overviewAutoSteps: '200',
fleetTab: 'show',
promptPrefix: '',
promptSuffix: '',
writable: false,
})
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultDeltaBase(), 'step')
assert.equal(s.defaultToolSort(), 'count')
assert.equal(s.defaultFileSort(), 'count')
assert.equal(s.insightsEntry(), 'show')
assert.equal(s.defaultDurationCurve(), 'show')
assert.equal(s.overviewAutoSteps(), '200')
assert.equal(s.fleetTab(), 'show')
})
})
describe('overviewThresholdOf', () => {
test('maps the numeric steps to a threshold and off to null', () => {
assert.equal(overviewThresholdOf('100'), 100)
assert.equal(overviewThresholdOf('200'), 200)
assert.equal(overviewThresholdOf('300'), 300)
assert.equal(overviewThresholdOf('off'), null)
})
})
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')
s.set('defaultDeltaBase', 'turn')
assert.equal(s.defaultDeltaBase(), 'turn')
s.set('promptPrefix', 'Be terse.')
assert.equal(s.store.getSnapshot().promptPrefix, 'Be terse.')
s.set('overviewAutoSteps', 'off')
assert.equal(s.overviewAutoSteps(), 'off')
})
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')
})
test('a rejected insights-entry write degrades to show when the scope carries no valid value', async () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
s.attach(scope)
scope.failSet = true
s.set('insightsEntry', 'hide')
assert.equal(s.insightsEntry(), 'hide', 'the optimistic echo lands first')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.insightsEntry(), 'show', 'the echo degrades instead of staying unpersisted')
})
test('a rejected insights-entry write rolls back to the scope\'s valid truth', async () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: { insightsEntry: 'hide' }, writable: true })
s.attach(scope)
scope.failSet = true
s.set('insightsEntry', 'show')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.insightsEntry(), 'hide')
})
test('a rejected fleet-tab write degrades to show when the scope carries no valid value', async () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
s.attach(scope)
scope.failSet = true
s.set('fleetTab', 'hide')
assert.equal(s.fleetTab(), 'hide', 'the optimistic echo lands first')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.fleetTab(), 'show', 'an unpersisted hide must not keep the tab away')
})
test('a rejected fleet-tab write rolls back to the scope\'s valid truth', async () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: { fleetTab: 'hide' }, writable: true })
s.attach(scope)
scope.failSet = true
s.set('fleetTab', 'show')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.fleetTab(), 'hide')
})
test('a rejected prompt write rolls back to the scope truth', async () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: { promptPrefix: 'kept' }, writable: true })
s.attach(scope)
scope.failSet = true
s.set('promptPrefix', 'draft')
assert.equal(s.store.getSnapshot().promptPrefix, 'draft', 'the optimistic echo lands first')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(s.store.getSnapshot().promptPrefix, 'kept', 'the echo rolls back to the scope truth')
})
})
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',
defaultDeltaBase: 'turn',
defaultToolSort: 'name',
defaultFileSort: 'path',
insightsEntry: 'hide',
defaultDurationCurve: 'hide',
overviewAutoSteps: '300',
fleetTab: 'hide',
promptPrefix: 'Be terse.',
promptSuffix: 'Sign as Atlas.',
},
writable: true,
})
s.attach(scope)
assert.deepEqual(s.store.getSnapshot(), {
status: 'ready',
placement: 'sidebar',
granularity: 'turn',
mode: 'delta',
deltaBase: 'turn',
toolSort: 'name',
fileSort: 'path',
insightsEntry: 'hide',
durationCurve: 'hide',
overviewAutoSteps: '300',
fleetTab: 'hide',
promptPrefix: 'Be terse.',
promptSuffix: 'Sign as Atlas.',
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',
deltaBase: 'step',
toolSort: 'count',
fileSort: 'count',
insightsEntry: 'show',
durationCurve: 'show',
overviewAutoSteps: '200',
fleetTab: 'show',
promptPrefix: '',
promptSuffix: '',
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, defaultDeltaBase: 'bogus', defaultToolSort: 'alpha', defaultFileSort: 'alpha', insightsEntry: 42, defaultDurationCurve: 'always', overviewAutoSteps: 200, fleetTab: 7, promptPrefix: 9, promptSuffix: {} },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultDeltaBase(), 'step')
assert.equal(s.defaultToolSort(), 'count')
assert.equal(s.defaultFileSort(), 'count')
assert.equal(s.insightsEntry(), 'show')
assert.equal(s.defaultDurationCurve(), 'show')
assert.equal(s.overviewAutoSteps(), '200')
assert.equal(s.fleetTab(), 'show')
assert.equal(s.store.getSnapshot().promptPrefix, '', 'a non-string prompt value degrades to the empty default')
assert.equal(s.store.getSnapshot().promptSuffix, '')
})
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('an invalid scope insights entry degrades to show instead of keeping the current one', () => {
// Fail open on the read path too: a config problem must not leave the panel's entry hidden.
const s = createContextSettings()
s.set('insightsEntry', 'hide')
assert.equal(s.insightsEntry(), 'hide')
const scope = new TestSettingsScope({ status: 'ready', value: { insightsEntry: 'visible' }, writable: false })
s.attach(scope)
assert.equal(s.insightsEntry(), 'show')
})
test('an invalid scope duration-curve value degrades to show instead of keeping the current one', () => {
const s = createContextSettings()
s.set('defaultDurationCurve', 'hide')
assert.equal(s.defaultDurationCurve(), 'hide')
const scope = new TestSettingsScope({ status: 'ready', value: { defaultDurationCurve: 'always' }, writable: false })
s.attach(scope)
assert.equal(s.defaultDurationCurve(), 'show')
})
test('an invalid scope auto-overview value degrades to the default instead of keeping the current one', () => {
const s = createContextSettings()
s.set('overviewAutoSteps', '100')
assert.equal(s.overviewAutoSteps(), '100')
const scope = new TestSettingsScope({ status: 'ready', value: { overviewAutoSteps: '50' }, writable: false })
s.attach(scope)
assert.equal(s.overviewAutoSteps(), '200')
})
test('explicit schema-default values are accepted', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
status: 'ready',
value: { defaultPlacement: 'all', defaultGranularity: 'step', defaultTrendMode: 'total', defaultDeltaBase: 'step', defaultToolSort: 'count', defaultFileSort: 'count', insightsEntry: 'show', overviewAutoSteps: '200' },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultDeltaBase(), 'step')
assert.equal(s.defaultToolSort(), 'count')
assert.equal(s.defaultFileSort(), 'count')
assert.equal(s.insightsEntry(), 'show')
assert.equal(s.overviewAutoSteps(), '200')
})
test('missing fields keep the current state', () => {
const s = createContextSettings()
s.set('defaultPlacement', 'tab')
s.set('defaultToolSort', 'size')
s.set('insightsEntry', 'hide')
s.set('defaultDurationCurve', 'hide')
s.set('overviewAutoSteps', '100')
s.set('promptPrefix', 'drafted')
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.defaultToolSort(), 'size', 'the in-session tool sort survives a section without the field')
assert.equal(s.defaultFileSort(), 'latest')
assert.equal(s.insightsEntry(), 'hide', 'the in-session entry choice survives a section without the field')
assert.equal(s.defaultDurationCurve(), 'hide', 'the in-session curve choice survives a section without the field')
assert.equal(s.overviewAutoSteps(), '100', 'the in-session auto-overview choice survives a section without the field')
assert.equal(s.store.getSnapshot().promptPrefix, 'drafted', 'the in-session prompt text survives a section without the field')
})
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: 'name' }, writable: true })
assert.equal(calls, 3)
assert.equal(s.defaultFileSort(), 'name')
scope.emit({ status: 'ready', value: { defaultPlacement: 'sidebar' }, writable: true })
assert.equal(calls, 4)
assert.equal(s.defaultPlacement(), 'sidebar')
scope.emit({ status: 'ready', value: { defaultToolSort: 'name' }, writable: true })
assert.equal(calls, 5)
assert.equal(s.defaultToolSort(), 'name')
scope.emit({ status: 'ready', value: { defaultDeltaBase: 'turn' }, writable: true })
assert.equal(calls, 6)
assert.equal(s.defaultDeltaBase(), 'turn')
scope.emit({ status: 'ready', value: { defaultFileSort: 'path' }, writable: false })
assert.equal(calls, 7)
assert.equal(s.store.getSnapshot().writable, false)
scope.emit({ status: 'ready', value: { insightsEntry: 'hide' }, writable: false })
assert.equal(calls, 8)
assert.equal(s.insightsEntry(), 'hide')
})
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')
})
test('a set after the disposer runs never reaches the dead scope', () => {
const s = createContextSettings()
const scope = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
const detach = s.attach(scope)
detach()
s.set('defaultTrendMode', 'delta')
assert.deepEqual(scope.sets, [])
})
test('disposing a superseded attach keeps the newer scope bound', () => {
const s = createContextSettings()
const first = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
const second = new TestSettingsScope({ status: 'ready', value: {}, writable: true })
const detachFirst = s.attach(first)
s.attach(second)
detachFirst()
s.set('defaultTrendMode', 'delta')
assert.deepEqual(first.sets, [])
assert.deepEqual(second.sets, [{ field: 'defaultTrendMode', value: 'delta' }])
})
test('a snapshot value that throws on property access keeps the current state', () => {
const s = createContextSettings()
const hostile = new Proxy({}, { get() { throw new Error('hostile') } })
s.attach(new TestSettingsScope({ status: 'ready', value: hostile, writable: true }))
assert.deepEqual(s.store.getSnapshot(), {
status: 'ready',
placement: 'all',
granularity: 'step',
mode: 'total',
deltaBase: 'step',
toolSort: 'count',
fileSort: 'count',
insightsEntry: 'show',
durationCurve: 'show',
overviewAutoSteps: '200',
fleetTab: 'show',
promptPrefix: '',
promptSuffix: '',
writable: true,
})
})
})