Files
dsh-context/tests/host/settings.spec.ts
T
bowenliang123 a4add07664 feat: add a placement preference (tab / sidebar / both) for the Context view
Closes #58: the Context panel hung off two registrations at once (the
conversation tab and the right-Sidebar guide entry), with no way to keep
just one. A new per-user `defaultPlacement` preference — the first row of
the Plugin configuration card — picks who carries the view: the
conversation tab, the right Sidebar, or both (the default, so existing
deployments see no change).

- shared: `DefaultPlacement` vocabulary (`all` | `tab` | `sidebar`) on the
  settings namespace; the Host schema gains a loose `defaultPlacement`
  field so a stale persisted value degrades to `all`
- client: `placement.ts` mounts each registration through its own factory
  and, on a preference flip, disposes exactly what the new value drops
  (the harness's own dynamic-mount idiom); `watchSidebarContextTab`
  returns its deferred-inject disposer so the sidebar mount can be taken
  down and put back; the settings card leads with the placement row
2026-09-11 13:44:21 +08:00

130 lines
5.5 KiB
TypeScript

// Integration tests for the plugin settings namespace (src/host/settings.ts)
// against the REAL cordis context and the REAL dsh-settings provider base —
// the dsh-canonical harness pattern: an in-memory SettingsProvider subclass,
// mounted as a plugin, with installSettings layering the namespace on top.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { SettingsProvider } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { installSettings, SETTINGS_NAMESPACE } from '../../src/host/settings'
import type { PluginSettings } from '../../src/host/settings'
/** A provider implementing only the two storage primitives; the Service Definition owns the rest. */
class MemorySettings extends SettingsProvider {
doc: Record<string, unknown>
constructor(ctx: ConstructorParameters<typeof SettingsProvider>[0], options?: { doc?: Record<string, unknown> }) {
super(ctx)
this.doc = structuredClone(options?.doc ?? {})
}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc[String(ns)] = structuredClone(section)
return Promise.resolve()
}
}
// The raw string is what the dsh settings `register` accepts at runtime; the
// branded cast satisfies the SettingsNamespace type face (see
// src/host/settings.ts).
const ns = SETTINGS_NAMESPACE as SettingsNamespace
/** Poll until the inject callback inside installSettings has registered the namespace. */
async function untilRegistered(ctx: Context): Promise<void> {
for (let i = 0; i < 200; i++) {
if (ctx.settings.get(ns) !== undefined) return
await new Promise(resolve => setTimeout(resolve, 1))
}
assert.fail('the dsh-context settings namespace was never registered')
}
async function boot(doc?: Record<string, unknown>) {
const ctx = new Context()
await ctx.plugin(MemorySettings, doc === undefined ? undefined : { doc })
installSettings(ctx)
await untilRegistered(ctx)
return { ctx, provider: ctx.get('settings') as MemorySettings }
}
describe('installSettings', () => {
test('the namespace is the plugin short name', () => {
assert.equal(SETTINGS_NAMESPACE, 'dsh-context')
})
test('registers the dsh-context namespace with schema defaults', async () => {
const { ctx } = await boot()
const descriptors = ctx.settings.describe()
assert.ok(descriptors.some(d => String(d.ns) === 'dsh-context'), 'the namespace is registered')
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'all',
defaultGranularity: 'step',
defaultTrendMode: 'total',
defaultFileSort: 'count',
}, 'schema defaults resolve')
})
test('updates flow through the real scope; invalid values reject', async () => {
const { ctx, provider } = await boot()
await ctx.settings.update(ns, { defaultGranularity: 'turn' })
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'all',
defaultGranularity: 'turn',
defaultTrendMode: 'total',
defaultFileSort: 'count',
}, 'the update resolves over the schema defaults')
assert.deepEqual(provider.doc['dsh-context'], { defaultGranularity: 'turn' }, 'the provider persisted the section')
await ctx.settings.update(ns, { defaultPlacement: 'sidebar', defaultTrendMode: 'delta', defaultFileSort: 'path' })
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'sidebar',
defaultGranularity: 'turn',
defaultTrendMode: 'delta',
defaultFileSort: 'path',
}, 'every preference field resolves independently')
await assert.rejects(
ctx.settings.update(ns, { defaultGranularity: 'week' }),
'an unknown granularity fails validation before anything persists',
)
// The loose fields degrade instead of rejecting: a stale file sort or
// placement resolves to the default.
await ctx.settings.update(ns, { defaultFileSort: 'net', defaultPlacement: 'window' })
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'all',
defaultGranularity: 'turn',
defaultTrendMode: 'delta',
defaultFileSort: 'count',
}, 'a stale value degrades to the schema default')
assert.deepEqual(
provider.doc['dsh-context'],
{ defaultPlacement: 'window', defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'net' },
'the stale value stays raw in storage and degrades at read',
)
})
test('a stale persisted preference degrades to the default (loose)', async () => {
const { ctx } = await boot({ 'dsh-context': { defaultPlacement: 'window', defaultTrendMode: 'net', defaultFileSort: 'alpha' } })
const value = ctx.settings.get(ns) as PluginSettings
assert.equal(value.defaultPlacement, 'all', 'the stale placement falls back instead of breaking the section')
assert.equal(value.defaultTrendMode, 'total', 'the stale value falls back instead of breaking the section')
assert.equal(value.defaultFileSort, 'count', 'the stale file sort falls back instead of breaking the section')
assert.equal(value.defaultGranularity, 'step')
})
test('without a settings provider the install is inert', () => {
const ctx = new Context()
assert.doesNotThrow(() => installSettings(ctx))
assert.equal(ctx.get('settings'), undefined, 'no provider composed, nothing registered')
})
})