|
|
@@ -0,0 +1,140 @@
|
|
|
+import { describe, expect, it, vi } from 'vitest'
|
|
|
+import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
|
|
+import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts'
|
|
|
+
|
|
|
+let rpc = 0
|
|
|
+
|
|
|
+function ok<T>(value: T): RpcResponse<T> {
|
|
|
+ return { rpcId: `mirror-${rpc++}` as never, result: { ok: true, value } }
|
|
|
+}
|
|
|
+
|
|
|
+function rejected<T>(message: string): RpcResponse<T> {
|
|
|
+ return {
|
|
|
+ rpcId: `mirror-${rpc++}` as never,
|
|
|
+ result: {
|
|
|
+ ok: false,
|
|
|
+ error: { code: 'settings-rejected', message, details: {} },
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function view(ns: string, revision = 0): SettingsNamespaceView {
|
|
|
+ return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision }
|
|
|
+}
|
|
|
+
|
|
|
+function described(namespaces: SettingsNamespaceView[]): RpcResponse<SettingsDescribeView> {
|
|
|
+ return ok({ writable: true, hasDocument: true, namespaces })
|
|
|
+}
|
|
|
+
|
|
|
+function deferred<T>() {
|
|
|
+ let resolve!: (value: T) => void
|
|
|
+ const promise = new Promise<T>((res) => { resolve = res })
|
|
|
+ return { promise, resolve }
|
|
|
+}
|
|
|
+
|
|
|
+describe('SettingsDescribeMirror', () => {
|
|
|
+ it('folds concurrent load calls into the in-flight read plus one rerun', async () => {
|
|
|
+ const gate = deferred<RpcResponse<SettingsDescribeView>>()
|
|
|
+ const describeCall = vi.fn()
|
|
|
+ .mockReturnValueOnce(gate.promise)
|
|
|
+ .mockResolvedValue(described([view('theme', 1)]))
|
|
|
+ const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
|
|
+ const first = mirror.load()
|
|
|
+ const second = mirror.load()
|
|
|
+ const third = mirror.load()
|
|
|
+ gate.resolve(described([view('theme', 0)]))
|
|
|
+ await Promise.all([first, second, third])
|
|
|
+ expect(describeCall).toHaveBeenCalledTimes(2)
|
|
|
+ expect(mirror.getSnapshot().status).toBe('ready')
|
|
|
+ expect(mirror.namespace('theme')?.revision).toBe(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps the last good view when a later refresh fails, recording the failure', async () => {
|
|
|
+ const describeCall = vi.fn()
|
|
|
+ .mockResolvedValueOnce(described([view('theme', 2)]))
|
|
|
+ .mockRejectedValueOnce(new Error('host gone'))
|
|
|
+ .mockResolvedValueOnce(rejected('busy'))
|
|
|
+ const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
|
|
+ await mirror.load()
|
|
|
+ expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null })
|
|
|
+ await mirror.load()
|
|
|
+ expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'host gone' })
|
|
|
+ expect(mirror.namespace('theme')?.revision).toBe(2)
|
|
|
+ await mirror.load()
|
|
|
+ expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'busy' })
|
|
|
+ expect(mirror.getSnapshot().view?.namespaces).toHaveLength(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('returns to idle after a first read that never succeeded, so ensure retries', async () => {
|
|
|
+ const describeCall = vi.fn()
|
|
|
+ .mockRejectedValueOnce(new Error('offline'))
|
|
|
+ .mockResolvedValueOnce(described([view('theme', 1)]))
|
|
|
+ const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
|
|
+ await mirror.ensure()
|
|
|
+ expect(mirror.getSnapshot()).toMatchObject({ status: 'idle', view: undefined, error: 'offline' })
|
|
|
+ await mirror.ensure()
|
|
|
+ expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null })
|
|
|
+ expect(describeCall).toHaveBeenCalledTimes(2)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('treats ensure as a no-op once ready', async () => {
|
|
|
+ const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)]))
|
|
|
+ const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
|
|
+ await mirror.ensure()
|
|
|
+ await mirror.ensure()
|
|
|
+ await mirror.ensure()
|
|
|
+ expect(describeCall).toHaveBeenCalledTimes(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('memory persistence is terminally unavailable and never touches the wire', async () => {
|
|
|
+ const describeCall = vi.fn()
|
|
|
+ const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never, 'memory')
|
|
|
+ await mirror.ensure()
|
|
|
+ await mirror.load()
|
|
|
+ expect(mirror.getSnapshot()).toEqual({ status: 'unavailable', view: undefined, error: null })
|
|
|
+ expect(describeCall).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('acceptView folds one write answer into the held view without a wire read', async () => {
|
|
|
+ const describeCall = vi.fn()
|
|
|
+ .mockResolvedValueOnce(described([view('theme', 1), view('locale', 4)]))
|
|
|
+ const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
|
|
+ await mirror.load()
|
|
|
+ const seen: number[] = []
|
|
|
+ mirror.subscribe(() => { seen.push(mirror.namespace('theme')?.revision ?? -1) })
|
|
|
+ mirror.acceptView(view('theme', 9))
|
|
|
+ expect(mirror.namespace('theme')?.revision).toBe(9)
|
|
|
+ expect(mirror.namespace('locale')?.revision).toBe(4)
|
|
|
+ expect(seen).toEqual([9])
|
|
|
+ expect(describeCall).toHaveBeenCalledTimes(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('acceptView before any answer is a no-op instead of inventing a document', () => {
|
|
|
+ const describeCall = vi.fn()
|
|
|
+ const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
|
|
+ mirror.acceptView(view('theme', 1))
|
|
|
+ expect(mirror.getSnapshot()).toEqual({ status: 'idle', view: undefined, error: null })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('acceptView appends a namespace the held view has not seen yet', async () => {
|
|
|
+ const describeCall = vi.fn().mockResolvedValueOnce(described([view('theme', 1)]))
|
|
|
+ const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
|
|
+ await mirror.load()
|
|
|
+ mirror.acceptView(view('fresh-ns', 0))
|
|
|
+ expect(mirror.namespace('fresh-ns')).toBeDefined()
|
|
|
+ expect(mirror.getSnapshot().view?.namespaces).toHaveLength(2)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('suppresses a stale answer that lost to a newer generation', async () => {
|
|
|
+ const slow = deferred<RpcResponse<SettingsDescribeView>>()
|
|
|
+ const describeCall = vi.fn()
|
|
|
+ .mockReturnValueOnce(slow.promise)
|
|
|
+ .mockResolvedValue(described([view('theme', 8)]))
|
|
|
+ const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
|
|
|
+ const first = mirror.load()
|
|
|
+ const second = mirror.load()
|
|
|
+ slow.resolve(described([view('theme', 1)]))
|
|
|
+ await Promise.all([first, second])
|
|
|
+ expect(mirror.namespace('theme')?.revision).toBe(8)
|
|
|
+ })
|
|
|
+})
|