|
|
@@ -1,40 +1,58 @@
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
|
|
|
-import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
|
|
+import { Context } from '@deepseek-ai/cordis'
|
|
|
+import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
|
|
|
+import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
|
|
+import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
|
|
|
+import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
|
|
import {
|
|
|
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
|
|
} from '../src/onboarding-copy.ts'
|
|
|
|
|
|
+const schemaService = new SettingsSchemaService(new Context())
|
|
|
+
|
|
|
let rpc = 0
|
|
|
function ok<T>(value: T): RpcResponse<T> {
|
|
|
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
|
|
|
}
|
|
|
|
|
|
-function namespace(version?: string) {
|
|
|
+function namespace(value: unknown = {}, revision = 0) {
|
|
|
return {
|
|
|
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
|
schema: {},
|
|
|
- value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
|
|
- base: {},
|
|
|
- user: {},
|
|
|
+ value,
|
|
|
applies: 'live' as const,
|
|
|
secrets: [],
|
|
|
- revision: 0,
|
|
|
+ revision,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function deferred<T>() {
|
|
|
- let resolve!: (value: T) => void
|
|
|
- let reject!: (reason: unknown) => void
|
|
|
- const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
|
|
- return { promise, resolve, reject }
|
|
|
+function acknowledgedNamespace(version: string, revision = 1) {
|
|
|
+ return namespace({ [WELCOME_NOTICE_ACK_FIELD]: version }, revision)
|
|
|
+}
|
|
|
+
|
|
|
+/** The welcome store over a real mirror-derived scope and a fake wire. */
|
|
|
+function buildWelcome(
|
|
|
+ api: { describe?: ReturnType<typeof vi.fn>; mutate?: ReturnType<typeof vi.fn> },
|
|
|
+ persistence: 'host' | 'memory' = 'host',
|
|
|
+) {
|
|
|
+ const wire = { settings: api } as never
|
|
|
+ const mirror = new SettingsDescribeMirror(wire, persistence)
|
|
|
+ const scope = new SettingsScopeController(
|
|
|
+ wire,
|
|
|
+ { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection },
|
|
|
+ mirror,
|
|
|
+ persistence,
|
|
|
+ schemaService,
|
|
|
+ )
|
|
|
+ return { mirror, controller: new WelcomeNoticeStore(scope) }
|
|
|
}
|
|
|
|
|
|
describe('WelcomeNoticeStore', () => {
|
|
|
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
|
|
|
- const describe = vi.fn()
|
|
|
+ const describeCall = vi.fn()
|
|
|
const mutate = vi.fn()
|
|
|
- const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
|
|
|
+ const { controller } = buildWelcome({ describe: describeCall, mutate }, 'memory')
|
|
|
|
|
|
await controller.load()
|
|
|
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
|
|
|
@@ -42,7 +60,7 @@ describe('WelcomeNoticeStore', () => {
|
|
|
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
|
|
await controller.load()
|
|
|
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
|
|
- expect(describe).not.toHaveBeenCalled()
|
|
|
+ expect(describeCall).not.toHaveBeenCalled()
|
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
@@ -52,148 +70,101 @@ describe('WelcomeNoticeStore', () => {
|
|
|
['older-copy', false],
|
|
|
[WELCOME_NOTICE_VERSION, true],
|
|
|
] as const) {
|
|
|
- const api = {
|
|
|
- settings: {
|
|
|
- describe: vi.fn(() => Promise.resolve(ok({
|
|
|
- writable: true, hasDocument: false, namespaces: [namespace(version)],
|
|
|
- }))),
|
|
|
- },
|
|
|
- }
|
|
|
- const controller = new WelcomeNoticeStore(api as never)
|
|
|
+ const describeCall = vi.fn(() => Promise.resolve(ok({
|
|
|
+ writable: true,
|
|
|
+ hasDocument: false,
|
|
|
+ namespaces: [version === undefined ? namespace() : acknowledgedNamespace(version)],
|
|
|
+ })))
|
|
|
+ const { mirror, controller } = buildWelcome({ describe: describeCall })
|
|
|
+ await mirror.load()
|
|
|
await controller.load()
|
|
|
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('persists the owner version through one idempotent path mutation', async () => {
|
|
|
- const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
|
|
|
- const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
|
|
|
+ it('persists the owner version through one revision-fenced mutation', async () => {
|
|
|
+ const describeCall = vi.fn(() => Promise.resolve(ok({
|
|
|
+ writable: true, hasDocument: false, namespaces: [namespace({}, 3)],
|
|
|
+ })))
|
|
|
+ const mutate = vi.fn(() => Promise.resolve(ok(acknowledgedNamespace(WELCOME_NOTICE_VERSION, 4))))
|
|
|
+ const { mirror, controller } = buildWelcome({ describe: describeCall, mutate })
|
|
|
+ await mirror.load()
|
|
|
+ await controller.load()
|
|
|
await expect(controller.acknowledge()).resolves.toBe(true)
|
|
|
expect(mutate).toHaveBeenCalledWith({
|
|
|
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
|
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
|
|
+ expectedRevision: 3,
|
|
|
})
|
|
|
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
|
|
+ // The write answer folded into the mirror; no re-read followed.
|
|
|
+ expect(describeCall).toHaveBeenCalledTimes(1)
|
|
|
})
|
|
|
|
|
|
- it('keeps the notice pending when loading or persistence fails', async () => {
|
|
|
- const load = new WelcomeNoticeStore({
|
|
|
- settings: { describe: () => Promise.reject(new Error('offline')) },
|
|
|
- } as never)
|
|
|
- await load.load()
|
|
|
- expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
|
|
|
-
|
|
|
- const save = new WelcomeNoticeStore({
|
|
|
- settings: { mutate: () => Promise.reject(new Error('disk full')) },
|
|
|
- } as never)
|
|
|
- await expect(save.acknowledge()).resolves.toBe(false)
|
|
|
- expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
|
|
|
+ it('keeps the notice pending while the settings read has not answered', async () => {
|
|
|
+ const describeCall = vi.fn(() => Promise.reject(new Error('offline')))
|
|
|
+ const { mirror, controller } = buildWelcome({ describe: describeCall })
|
|
|
+ await mirror.load()
|
|
|
+ await controller.load()
|
|
|
+ // No answer stands, so the step renders nothing and never acknowledges.
|
|
|
+ expect(controller.store.getSnapshot()).toEqual({ status: 'loading', acknowledged: false, error: null })
|
|
|
+ })
|
|
|
|
|
|
- const nonError = new WelcomeNoticeStore({
|
|
|
- // Durable/wire failures are unknown; exercise containment of a non-Error rejection.
|
|
|
- settings: { describe: () => Promise.reject(new Error('offline string')) },
|
|
|
- } as never)
|
|
|
- await nonError.load()
|
|
|
- expect(nonError.store.getSnapshot().error).toBe('offline string')
|
|
|
+ it('reports a failed or refused persistence attempt after its recovery read', async () => {
|
|
|
+ const describeCall = vi.fn(() => Promise.resolve(ok({
|
|
|
+ writable: true, hasDocument: false, namespaces: [namespace()],
|
|
|
+ })))
|
|
|
+ const mutate = vi.fn(() => Promise.reject(new Error('disk full')))
|
|
|
+ const { mirror, controller } = buildWelcome({ describe: describeCall, mutate })
|
|
|
+ await mirror.load()
|
|
|
+ await controller.load()
|
|
|
+ await expect(controller.acknowledge()).resolves.toBe(false)
|
|
|
+ expect(controller.store.getSnapshot()).toMatchObject({
|
|
|
+ status: 'error',
|
|
|
+ acknowledged: false,
|
|
|
+ error: 'the acknowledgement did not persist',
|
|
|
+ })
|
|
|
+ // The failed latest write triggered one mirror recovery read.
|
|
|
+ expect(describeCall).toHaveBeenCalledTimes(2)
|
|
|
})
|
|
|
|
|
|
- it('reports business failures, missing namespaces, and malformed durable values', async () => {
|
|
|
- for (const describe of [
|
|
|
- () => Promise.resolve({
|
|
|
- rpcId: 'failed' as never,
|
|
|
- result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
|
|
|
- }),
|
|
|
- () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
|
|
|
- ]) {
|
|
|
- const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
|
|
- await controller.load()
|
|
|
- expect(controller.store.getSnapshot().status).toBe('error')
|
|
|
- }
|
|
|
+ it('reports a missing namespace as an error instead of a silent skip', async () => {
|
|
|
+ const describeCall = vi.fn(() => Promise.resolve(ok({
|
|
|
+ writable: true, hasDocument: false, namespaces: [],
|
|
|
+ })))
|
|
|
+ const { mirror, controller } = buildWelcome({ describe: describeCall })
|
|
|
+ await mirror.load()
|
|
|
+ await controller.load()
|
|
|
+ expect(controller.store.getSnapshot()).toMatchObject({
|
|
|
+ status: 'error',
|
|
|
+ error: 'welcome acknowledgement settings are unavailable',
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
+ it('reads malformed durable values as unacknowledged', async () => {
|
|
|
for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
|
|
|
- const controller = new WelcomeNoticeStore({
|
|
|
- settings: { describe: () => Promise.resolve(ok({
|
|
|
- writable: true,
|
|
|
- hasDocument: false,
|
|
|
- namespaces: [{ ...namespace(), value }],
|
|
|
- })) },
|
|
|
- } as never)
|
|
|
+ const describeCall = vi.fn(() => Promise.resolve(ok({
|
|
|
+ writable: true, hasDocument: false, namespaces: [namespace(value)],
|
|
|
+ })))
|
|
|
+ const { mirror, controller } = buildWelcome({ describe: describeCall })
|
|
|
+ await mirror.load()
|
|
|
await controller.load()
|
|
|
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
|
|
}
|
|
|
-
|
|
|
- const save = new WelcomeNoticeStore({
|
|
|
- settings: { mutate: () => Promise.resolve({
|
|
|
- rpcId: 'failed-save' as never,
|
|
|
- result: {
|
|
|
- ok: false,
|
|
|
- error: {
|
|
|
- code: 'settings-rejected',
|
|
|
- message: 'denied',
|
|
|
- details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE },
|
|
|
- },
|
|
|
- },
|
|
|
- }) },
|
|
|
- } as never)
|
|
|
- await expect(save.acknowledge()).resolves.toBe(false)
|
|
|
- expect(save.store.getSnapshot().error).toBe('denied')
|
|
|
- })
|
|
|
-
|
|
|
- it('lets the latest load win over stale success and failure', async () => {
|
|
|
- const first = deferred<ReturnType<typeof ok>>()
|
|
|
- const describe = vi.fn()
|
|
|
- .mockImplementationOnce(() => first.promise)
|
|
|
- .mockImplementationOnce(() => Promise.resolve(ok({
|
|
|
- writable: true, hasDocument: false, namespaces: [namespace()],
|
|
|
- })))
|
|
|
- const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
|
|
- const stale = controller.load()
|
|
|
- await controller.load()
|
|
|
- first.resolve(ok({
|
|
|
- writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
|
|
- }))
|
|
|
- await stale
|
|
|
- expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
|
|
-
|
|
|
- const failed = deferred<ReturnType<typeof ok>>()
|
|
|
- describe
|
|
|
- .mockImplementationOnce(() => failed.promise)
|
|
|
- .mockImplementationOnce(() => Promise.resolve(ok({
|
|
|
- writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
|
|
- })))
|
|
|
- const staleFailure = controller.load()
|
|
|
- await controller.load()
|
|
|
- failed.reject('stale failure')
|
|
|
- await staleFailure
|
|
|
- expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
|
|
|
})
|
|
|
|
|
|
- it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
|
|
- const write = deferred<ReturnType<typeof ok>>()
|
|
|
- const describe = vi.fn(() => Promise.resolve(ok({
|
|
|
- writable: true, hasDocument: false, namespaces: [namespace()],
|
|
|
- })))
|
|
|
- const controller = new WelcomeNoticeStore({
|
|
|
- settings: { mutate: () => write.promise, describe },
|
|
|
- } as never)
|
|
|
- refreshWelcomeIfLoaded(controller)
|
|
|
- expect(describe).not.toHaveBeenCalled()
|
|
|
- const staleWrite = controller.acknowledge()
|
|
|
+ it('follows a later document change without an own read', async () => {
|
|
|
+ const describeCall = vi.fn()
|
|
|
+ .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [namespace()] }))
|
|
|
+ .mockResolvedValueOnce(ok({
|
|
|
+ writable: true, hasDocument: false,
|
|
|
+ namespaces: [acknowledgedNamespace(WELCOME_NOTICE_VERSION)],
|
|
|
+ }))
|
|
|
+ const { mirror, controller } = buildWelcome({ describe: describeCall })
|
|
|
+ await mirror.load()
|
|
|
await controller.load()
|
|
|
- write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
|
|
|
- await expect(staleWrite).resolves.toBe(true)
|
|
|
- expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
|
|
- refreshWelcomeIfLoaded(controller)
|
|
|
- await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
|
|
|
-
|
|
|
- const failedWrite = deferred<ReturnType<typeof ok>>()
|
|
|
- const staleFailure = new WelcomeNoticeStore({
|
|
|
- settings: { mutate: () => failedWrite.promise, describe },
|
|
|
- } as never)
|
|
|
- const pending = staleFailure.acknowledge()
|
|
|
- await staleFailure.load()
|
|
|
- failedWrite.reject('late failure')
|
|
|
- await expect(pending).resolves.toBe(false)
|
|
|
- expect(staleFailure.store.getSnapshot().status).toBe('ready')
|
|
|
+ expect(controller.store.getSnapshot()).toMatchObject({ acknowledged: false })
|
|
|
+ await mirror.load()
|
|
|
+ expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
|
|
})
|
|
|
})
|