Merge remote-tracking branch 'origin/master' into worktree/web-session-titles
# Conflicts: # .agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/selection-survival.spec.ts # packages/client/ui-conversation/tests/skeleton-branches.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-layout/tests/service.spec.ts # packages/client/ui-sidebar/tests/apply.spec.tsx # packages/client/ui-sidebar/tests/store.spec.ts # packages/client/ui-trajectory/tests/views.spec.tsx # packages/client/web/src/app.tsx # packages/client/web/tests/boot.spec.tsx # packages/host/runtime/README.md # packages/host/runtime/tests/host-runtime.spec.ts
This commit is contained in:
@@ -37,6 +37,9 @@ describe('runtime client apply', () => {
|
||||
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
|
||||
const bench = await mount()
|
||||
expect(bench.ctx.get('slots') !== undefined).toBe(true)
|
||||
// The built-in 'root' declaration ships with this package's SlotsService
|
||||
// (the SlotMap 'root' merge lives here since the slot-parity rework).
|
||||
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const sessions = bench.ctx.get('sessions')
|
||||
expect(sessions !== undefined).toBe(true)
|
||||
expect(bench.sinks).toBeDefined()
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Real-bundle smoke: the actual tsdown client bundle of ui-layout runs
|
||||
* through the loader chain (execute → handoff → factory(require) → apply →
|
||||
* export re-registration). Skips when the bundle is not built (lib/client.js is a
|
||||
* build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import * as uiSlots from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import * as webReact from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createClientLoader } from '../src/client/loader/index.ts'
|
||||
import type { ClientPluginHandoff } from '../src/client/loader/index.ts'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { SlotsService } from '../src/client/slots.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
|
||||
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
|
||||
|
||||
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; window?: unknown }
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Win).DSHClientProxy
|
||||
delete (globalThis as Win).window
|
||||
})
|
||||
|
||||
function readLayoutBundle(): string | undefined {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
return readFileSync(require.resolve(`${LAYOUT_ID}/client`), 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
describe('real tsdown bundle through the loader', () => {
|
||||
const code = readLayoutBundle()
|
||||
|
||||
it.skipIf(code === undefined)('loads ui-layout lib/client.js: handoff, DI require, apply, export surface', async () => {
|
||||
// The bundle banner addresses window.DSHClientProxy; node has no window —
|
||||
// alias it to globalThis so the loader-installed proxy is reachable.
|
||||
;(globalThis as Win).window = globalThis
|
||||
const ctx = new Context()
|
||||
// The layout apply consumes the slots + sessions services; the real chain
|
||||
// loads the runtime bundle first — stand both up directly here.
|
||||
ctx.plugin(SlotsService)
|
||||
await ctx.fiber.await()
|
||||
new SessionsService(ctx, new FakeApiClient())
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
// The real bundle externals resolved from the seeded table. React is a
|
||||
// type-only import in the layout bundle today, but jsx-runtime is real.
|
||||
modules: {
|
||||
'react': await import('react'),
|
||||
'react/jsx-runtime': await import('react/jsx-runtime'),
|
||||
'@deepseek-ai/dsh-client-ui-slots': uiSlots,
|
||||
'@deepseek-ai/dsh-client-web-react': webReact,
|
||||
},
|
||||
boot: { plugins: [{ id: LAYOUT_ID, url: `/plugins/${LAYOUT_ID}/client.js`, inject: [] }] },
|
||||
fetchBundle: () => Promise.resolve(code as string),
|
||||
// node has no DOM: evaluate the bundle body directly (same synchronous
|
||||
// handoff contract as the <script> path).
|
||||
executeBundle: (bundleCode) => {
|
||||
// Node has no <script>: Function-evaluating the built bundle IS the
|
||||
// system under test (same synchronous handoff as the browser path).
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
|
||||
new Function(bundleCode)()
|
||||
},
|
||||
})
|
||||
loader.start()
|
||||
await loader.settled()
|
||||
expect(loader.status.getSnapshot()[LAYOUT_ID]).toBe('active')
|
||||
const surface = loader.requireModule(LAYOUT_ID) as Record<string, unknown>
|
||||
expect(typeof surface.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -25,9 +25,11 @@ describe('runtime slots/changed invariant', () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
|
||||
await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
|
||||
// A real define bumps the version first and re-emits through onMutate —
|
||||
// the audit sees version > 0 and stays quiet.
|
||||
expect(() => ctx.slots.define('t-single', { kind: 'single', scope: 'root' })).not.toThrow()
|
||||
// A real registration bumps the version first and re-emits through
|
||||
// onMutate — the audit sees version > 0 and stays quiet. (Erased call:
|
||||
// the typed register face rides the wave-1 ui-slots types.)
|
||||
const slots = ctx.slots as unknown as { register(options: object, component: unknown): () => void }
|
||||
expect(() => slots.register({ name: 'root' }, () => null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails loud on a missing key and on an emission with no applied mutation', async () => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* SessionsService: list store projection (manager → {ids, byId} with derived
|
||||
* titles), scope-tree lifecycle (lazy mint / frozen survival / removed
|
||||
* teardown with watch deferral), binding identity, ancestry walk, create.
|
||||
* SessionsService: list store projection (manager → {ids, byId, current}
|
||||
* with derived titles), the migrated current-selection account (open
|
||||
* validation, persisted mask semantics, cell resolution), scope-tree
|
||||
* lifecycle (lazy mint / frozen survival / removed teardown with staged
|
||||
* deferral — the stage follows list.current), binding identity, ancestry
|
||||
* walk, create.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
@@ -79,21 +82,21 @@ describe('scope tree', () => {
|
||||
expect(binding?.ctx).toBe(scoped)
|
||||
})
|
||||
|
||||
it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => {
|
||||
it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
const ctx1 = b.svc.scope(sid('s1'))
|
||||
b.svc.binding(sid('s1')) // s1 is watched
|
||||
b.svc.scope(sid('s2')) // s2 scoped but not watched
|
||||
b.svc.open(sid('s1')) // s1 staged (current)
|
||||
b.svc.scope(sid('s2')) // s2 scoped but off stage
|
||||
|
||||
await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down
|
||||
await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
|
||||
expect(b.svc.scope(sid('s2'))).toBeUndefined()
|
||||
|
||||
await feedList(b, []) // s1 removed while watched: deferred, scope survives
|
||||
await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBe(ctx1)
|
||||
|
||||
await feedList(b, [{ id: 's3' }])
|
||||
b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1
|
||||
b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
|
||||
expect(b.svc.scope(sid('s1'))).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -109,14 +112,143 @@ describe('scope tree', () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const scoped = b.svc.scope(sid('s1'))
|
||||
b.svc.binding(sid('s1'))
|
||||
await feedList(b, []) // removed while watched → deferred
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears
|
||||
b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1
|
||||
b.svc.open(sid('s1'))
|
||||
await feedList(b, []) // removed while staged → deferred
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
|
||||
b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
|
||||
expect(b.svc.scope(sid('s1'))).toBe(scoped)
|
||||
})
|
||||
})
|
||||
|
||||
describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
it('open() writes list.current; unknown ids fail loud', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
expect(b.svc.list.getSnapshot().current).toBeUndefined()
|
||||
b.svc.open(sid('s1'))
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1')
|
||||
expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
|
||||
})
|
||||
|
||||
it('masks (not destroys) the selection while its session is off the list', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.open(sid('s1'))
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
|
||||
expect(b.svc.list.getSnapshot().current).toBeUndefined()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1')
|
||||
})
|
||||
|
||||
it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => {
|
||||
const storage = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { storage.set(k, v) },
|
||||
})
|
||||
const first = bench()
|
||||
await feedList(first, [{ id: 's1' }])
|
||||
first.svc.open(sid('s1'))
|
||||
expect(storage.get('dsh.sessions.current')).toContain('s1')
|
||||
// A fresh boot (same storage) recovers the selection once the list holds the session.
|
||||
const second = bench()
|
||||
await feedList(second, [{ id: 's1' }])
|
||||
expect(second.svc.list.getSnapshot().current).toBe('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cell (render-layer session kit)', () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const cell = b.svc.cell('s1')
|
||||
expect(cell).toBeDefined()
|
||||
expect(cell?.sessionId).toBe('s1')
|
||||
// Bare-source form (store migration): the cell carries the Session
|
||||
// observable itself; hook binding happens in the React machinery.
|
||||
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
|
||||
expect(b.svc.cell('s1')).toBe(cell)
|
||||
expect(b.svc.cell('ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.open(sid('s1')) // staged
|
||||
b.svc.cell('s2') // resolution only — must NOT move the stage
|
||||
b.svc.binding(sid('s2'))
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
})
|
||||
|
||||
it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
|
||||
// Resolution is addressing, not staging: no window pull.
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.cell('s1')
|
||||
b.svc.binding(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(0)
|
||||
b.svc.open(sid('s1'))
|
||||
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
|
||||
// Same current again: no second pull.
|
||||
b.svc.open(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(1)
|
||||
// Stage moves: the new occupant opens.
|
||||
b.svc.open(sid('s2'))
|
||||
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2'])
|
||||
})
|
||||
|
||||
it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => {
|
||||
const storage = new Map<string, string>([
|
||||
['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
|
||||
])
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { storage.set(k, v) },
|
||||
})
|
||||
try {
|
||||
const b = bench()
|
||||
expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
|
||||
await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
|
||||
const historyCalls = b.api.calls.filter(c => c.method === 'session.history')
|
||||
expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('slot-store scope prune hook', () => {
|
||||
it('notifies ctx.slots.pruneStoreScope when a scope dies (both teardown paths)', async () => {
|
||||
const b = bench()
|
||||
const pruneStoreScope = vi.fn()
|
||||
b.ctx.reflect.provide('slots', { pruneStoreScope })
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.scope(sid('s2'))
|
||||
b.svc.open(sid('s2')) // s2 staged
|
||||
await feedList(b, []) // s1 off stage → immediate drop; s2 staged → deferred
|
||||
expect(pruneStoreScope).toHaveBeenCalledWith('s1')
|
||||
expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
|
||||
await feedList(b, [{ id: 's3' }])
|
||||
b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2
|
||||
expect(pruneStoreScope).toHaveBeenCalledWith('s2')
|
||||
})
|
||||
|
||||
it('tolerates a slots-less boot (object-layer benches carry no slot service)', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.scope(sid('s1'))
|
||||
await feedList(b, []) // teardown without ctx.slots must not throw
|
||||
expect(b.svc.scope(sid('s1'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ancestry', () => {
|
||||
it('walks parentId links root-first including self; broken links stop the walk', async () => {
|
||||
const b = bench()
|
||||
@@ -155,44 +287,46 @@ describe('coverage tails (branch duals)', () => {
|
||||
expect(byId[sid('no-base')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('binding for an unknown session returns undefined without moving the watch', async () => {
|
||||
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.binding(sid('s1'))
|
||||
b.svc.open(sid('s1'))
|
||||
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
|
||||
// Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
|
||||
// Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
|
||||
await feedList(b, [])
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
})
|
||||
|
||||
it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
|
||||
it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.binding(sid('s1'))
|
||||
await feedList(b, []) // deferred removal of the watched id
|
||||
// Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
|
||||
expect(b.svc.binding(sid('s1'))).toBeDefined()
|
||||
b.svc.open(sid('s1'))
|
||||
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
|
||||
expect(historyCalls()).toHaveLength(1)
|
||||
await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
// Resurfacing re-projects current = s1: same stage occupant, no second pull.
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
expect(historyCalls()).toHaveLength(1)
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1')
|
||||
})
|
||||
|
||||
it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
|
||||
it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 'a' }, { id: 'b' }])
|
||||
b.svc.binding(sid('a'))
|
||||
b.svc.binding(sid('b')) // watch: b; both scoped
|
||||
await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
|
||||
// Move the watch to a THIRD id while b stays deferred: sweep now walks a
|
||||
// set containing b (torn) — and the watched-continue branch fires when the
|
||||
// deferral set still holds the current watch target.
|
||||
b.svc.scope(sid('a'))
|
||||
b.svc.open(sid('b')) // stage: b; both scoped
|
||||
await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
|
||||
// Move the stage to a THIRD id while b stays deferred: sweep walks a set
|
||||
// containing b (torn).
|
||||
await feedList(b, [{ id: 'c' }])
|
||||
b.svc.binding(sid('c'))
|
||||
b.svc.open(sid('c'))
|
||||
expect(b.svc.scope(sid('b'))).toBeUndefined()
|
||||
// Deferral for an id whose record was never minted: force-add via removed
|
||||
// list state (scope teardown raced) — sweep must tolerate the missing record.
|
||||
await feedList(b, [])
|
||||
b.svc.binding(sid('c')) // c now watched+removed → deferred
|
||||
// Deferral for an id whose record was never minted: force the deferral
|
||||
// via removed list state — sweep must tolerate the missing record.
|
||||
await feedList(b, []) // c removed while staged → deferred (scope exists)
|
||||
await feedList(b, [{ id: 'd' }])
|
||||
b.svc.binding(sid('d')) // sweep tears c
|
||||
b.svc.open(sid('d')) // sweep tears c
|
||||
expect(b.svc.scope(sid('c'))).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,80 +1,385 @@
|
||||
/**
|
||||
* SlotsService: cordis Service wrapper semantics — core delegation, the
|
||||
* 'slots/changed' event bridge, and fiber-scoped registration disposal.
|
||||
* SlotsService terminal-design account (design.md §11-3 main landing):
|
||||
* built-in 'root', the three load-time throws (duplicate declaration /
|
||||
* undeclared contribution / cross-scope store handle), the renderer install
|
||||
* seam (double install / not installed / non-root key), store instance
|
||||
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '../src/client/slots.ts'
|
||||
|
||||
// Test-only slot keys (SlotMap is empty in this package; the service is generic over it).
|
||||
// Test-only slot keys (merged so the typed entries/spec faces accept them).
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
't-single': { kind: 'single'; scope: 'root'; props: object }
|
||||
't-list': { kind: 'list'; scope: 'root'; props: object }
|
||||
't.host': { kind: 'single'; scope: 'root' }
|
||||
't.panel': { kind: 'single'; scope: 'session' }
|
||||
't.rows': { kind: 'list'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
|
||||
const C: FC<object> = () => null
|
||||
|
||||
async function boot(): Promise<Context> {
|
||||
/**
|
||||
* Register/install/renderSlot through a type-erased view: the typed register
|
||||
* face rides wave-1 ui-slots types (red until that wave lands); the runtime
|
||||
* semantics under test are final.
|
||||
*/
|
||||
interface ErasedService {
|
||||
register(options: object, component: unknown): () => void
|
||||
install(renderer: object): void
|
||||
renderSlot(key: string, owner: object): unknown
|
||||
}
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
svc: SlotsService
|
||||
erased: ErasedService
|
||||
}
|
||||
|
||||
async function boot(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
ctx.plugin(SlotsService)
|
||||
await ctx.fiber.await()
|
||||
return ctx
|
||||
// Service accessor (ctx.get reads the reflect store, which Service-class
|
||||
// plugins do not write; the accessor is the product path).
|
||||
const svc = ctx.slots
|
||||
return { ctx, svc, erased: svc as unknown as ErasedService }
|
||||
}
|
||||
|
||||
describe('SlotsService', () => {
|
||||
it('proxies define/register/entries/spec/getVersion to the core', async () => {
|
||||
const ctx = await boot()
|
||||
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
|
||||
expect(ctx.slots.spec('t-single')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const v0 = ctx.slots.getVersion('t-single')
|
||||
ctx.slots.register('t-single', C)
|
||||
expect(ctx.slots.entries('t-single')).toHaveLength(1)
|
||||
expect(ctx.slots.getVersion('t-single')).toBeGreaterThan(v0)
|
||||
expect(ctx.slots.core.spec('t-single')).toBeDefined()
|
||||
/** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
|
||||
interface FakeInstance {
|
||||
getSnapshot: () => undefined
|
||||
subscribe: () => () => void
|
||||
actions: Record<string, never>
|
||||
clearPersisted: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
/** Fake store handle factory (create-count and clearPersisted observable). */
|
||||
function fakeHandle() {
|
||||
const created: FakeInstance[] = []
|
||||
const handle = {
|
||||
create: vi.fn((_scopeKey?: string): FakeInstance => {
|
||||
const instance: FakeInstance = {
|
||||
getSnapshot: () => undefined, subscribe: () => () => undefined,
|
||||
actions: {}, clearPersisted: vi.fn(),
|
||||
}
|
||||
created.push(instance)
|
||||
return instance
|
||||
}),
|
||||
}
|
||||
return { handle, created }
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a capturing renderer, occupy 'root' (declaring `children` in the
|
||||
* same call — 'root' is single, so the one occupant is also the declarer),
|
||||
* and pull the host face out through renderSlot('root').
|
||||
*/
|
||||
function captureHost(bench: Bench, children?: object): SlotRendererHost {
|
||||
let host: SlotRendererHost | undefined
|
||||
bench.erased.install({
|
||||
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
|
||||
})
|
||||
bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
|
||||
bench.ctx.reflect.provide('sessions', fakeSessions())
|
||||
bench.erased.renderSlot('root', {})
|
||||
if (host === undefined) throw new Error('renderer never received the host')
|
||||
return host
|
||||
}
|
||||
|
||||
/** Minimal sessions face for the host seam (list observable + cell). */
|
||||
function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
return {
|
||||
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||
cell: (id: string) => (id === 'known'
|
||||
? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
|
||||
: undefined),
|
||||
}
|
||||
}
|
||||
|
||||
describe("built-in 'root'", () => {
|
||||
it('is declared at construction: spec readable, occupancy open, no plugin needed', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.svc.spec('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(() => bench.erased.register({ name: 'root' }, C)).not.toThrow()
|
||||
expect(bench.svc.entries('root')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("re-emits every mutation as 'slots/changed' with the key", async () => {
|
||||
const ctx = await boot()
|
||||
const seen: string[] = []
|
||||
ctx.on('slots/changed', (key) => { seen.push(key) })
|
||||
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
|
||||
ctx.slots.register('t-list', C, { id: 'a' })
|
||||
expect(seen).toEqual(['t-list', 't-list'])
|
||||
it('rejects a second declaration of root, attributing the built-in row', async () => {
|
||||
const bench = await boot()
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 'root': { kind: 'single', scope: 'root' } },
|
||||
}, C)).toThrow(/already declared.*built-in/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('load-time validation', () => {
|
||||
it('throws on contributing into an undeclared slot', async () => {
|
||||
const bench = await boot()
|
||||
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/slot "t.host" is not declared/)
|
||||
})
|
||||
|
||||
it('collects a plugin fiber\'s registrations when the fiber unloads (cascade)', async () => {
|
||||
const ctx = await boot()
|
||||
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
|
||||
const fiber = ctx.plugin({
|
||||
it('throws on a duplicate declaration, naming the slot and the prior declarant', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({ name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } } }, C)
|
||||
bench.erased.register({
|
||||
name: 't.host', children: { 't.rows': { kind: 'list', scope: 'root' } },
|
||||
}, C)
|
||||
expect(() => bench.erased.register({
|
||||
name: 't.rows', id: 'r1', children: { 't.rows': { kind: 'list', scope: 'root' } },
|
||||
}, C)).toThrow(/slot "t.rows" is already declared.*"t.host"/)
|
||||
})
|
||||
|
||||
it('throws when one store handle is bound to two scopes', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
't.host': { kind: 'single', scope: 'root' },
|
||||
't.panel': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, C)
|
||||
const { handle } = fakeHandle()
|
||||
bench.erased.register({ name: 't.host', store: handle }, C)
|
||||
expect(() => bench.erased.register({ name: 't.panel', store: handle }, C))
|
||||
.toThrow(/one handle, one scope/)
|
||||
})
|
||||
|
||||
it('commits nothing when the core rejects the entry (children stay undeclared)', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({ name: 'root' }, C) // 'root' single slot now occupied
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)).toThrow(/already has a registration/)
|
||||
// The failing call's declaration must not have landed.
|
||||
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/is not declared/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderer install seam', () => {
|
||||
it('throws on renderSlot before install (boot-order guidance)', async () => {
|
||||
const bench = await boot()
|
||||
expect(() => bench.erased.renderSlot('root', {})).toThrow(/renderer not installed/)
|
||||
})
|
||||
|
||||
it('throws on double install', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.install({ renderRoot: () => null })
|
||||
expect(() => { bench.erased.install({ renderRoot: () => null }) }).toThrow(/already installed/)
|
||||
})
|
||||
|
||||
it('throws on any non-root key (single ctx-level entry)', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.install({ renderRoot: () => null })
|
||||
expect(() => bench.erased.renderSlot('t.host', {})).toThrow(/only renders 'root'/)
|
||||
})
|
||||
|
||||
it("throws on renderSlot('root') before any root registration", async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.install({ renderRoot: () => null })
|
||||
expect(() => bench.erased.renderSlot('root', {})).toThrow(/no registration/)
|
||||
})
|
||||
|
||||
it('renders through the installed renderer and returns its product', async () => {
|
||||
const bench = await boot()
|
||||
const renderRoot = vi.fn(() => 'tree')
|
||||
bench.erased.install({ renderRoot })
|
||||
bench.erased.register({ name: 'root' }, C)
|
||||
bench.ctx.reflect.provide('sessions', fakeSessions())
|
||||
expect(bench.erased.renderSlot('root', {})).toBe('tree')
|
||||
expect(renderRoot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host face', () => {
|
||||
it('serves entriesOf/specOf/isLive off the ledger and flips isLive on disposal', async () => {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench, { 't.host': { kind: 'single', scope: 'root' } })
|
||||
const dispose = bench.erased.register({ name: 't.host' }, C)
|
||||
const rootEntry = host.entriesOf('root')[0]
|
||||
expect(rootEntry).toBeDefined()
|
||||
expect(rootEntry?.component).toBe(C)
|
||||
expect(host.specOf('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(host.specOf('t.host')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const childEntry = host.entriesOf('t.host')[0]
|
||||
expect(host.isLive(childEntry as never)).toBe(true)
|
||||
dispose()
|
||||
expect(host.isLive(childEntry as never)).toBe(false)
|
||||
expect(host.entriesOf('t.host')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('exposes sessions list/current/cell (current riding the list snapshot)', async () => {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench)
|
||||
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
|
||||
expect(host.sessions.current.getSnapshot()).toBeUndefined()
|
||||
expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.cell('ghost')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('store instance axis', () => {
|
||||
/** Boot with 'root' occupied and the three test children declared. */
|
||||
async function storeBench() {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench, {
|
||||
't.host': { kind: 'single', scope: 'root' },
|
||||
't.rows': { kind: 'list', scope: 'root' },
|
||||
't.panel': { kind: 'single', scope: 'session' },
|
||||
})
|
||||
return { bench, host }
|
||||
}
|
||||
|
||||
it('resolves one instance per (handle x root scope) shared across entries', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const { handle } = fakeHandle()
|
||||
bench.erased.register({ name: 't.host', store: handle }, C)
|
||||
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
|
||||
const [hostEntry] = host.entriesOf('t.host')
|
||||
const [rowEntry] = host.entriesOf('t.rows')
|
||||
const a = host.storeOf(hostEntry as never, undefined)
|
||||
const b = host.storeOf(rowEntry as never, undefined)
|
||||
expect(a).toBeDefined()
|
||||
expect(a).toBe(b) // shared handle, same scope key = same instance
|
||||
expect(handle.create).toHaveBeenCalledTimes(1)
|
||||
expect(handle.create).toHaveBeenCalledWith() // root scope: keyless create
|
||||
})
|
||||
|
||||
it('resolves per-session instances keyed by session id, created with the scope key', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const { handle } = fakeHandle()
|
||||
bench.erased.register({ name: 't.panel', store: handle }, C)
|
||||
const [entry] = host.entriesOf('t.panel')
|
||||
const s1 = host.storeOf(entry as never, 's1')
|
||||
const s2 = host.storeOf(entry as never, 's2')
|
||||
expect(s1).not.toBe(s2)
|
||||
expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key
|
||||
expect(handle.create).toHaveBeenCalledWith('s1')
|
||||
expect(handle.create).toHaveBeenCalledWith('s2')
|
||||
expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
|
||||
})
|
||||
|
||||
it('mints a fresh handle per register for the factory (exclusive) form', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const factory = vi.fn(() => fakeHandle().handle)
|
||||
bench.erased.register({ name: 't.host', store: factory }, C)
|
||||
bench.erased.register({ name: 't.rows', id: 'a', store: factory }, C)
|
||||
expect(factory).toHaveBeenCalledTimes(2)
|
||||
const a = host.storeOf(host.entriesOf('t.host')[0] as never, undefined)
|
||||
const b = host.storeOf(host.entriesOf('t.rows')[0] as never, undefined)
|
||||
expect(a).not.toBe(b) // two mints, two instances
|
||||
})
|
||||
|
||||
it('drops instances with the last holding entry and refuses stale resolution', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const { handle } = fakeHandle()
|
||||
const d1 = bench.erased.register({ name: 't.host', store: handle }, C)
|
||||
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
|
||||
const rowEntry = host.entriesOf('t.rows')[0]
|
||||
const hostEntry = host.entriesOf('t.host')[0]
|
||||
const shared = host.storeOf(rowEntry as never, undefined)
|
||||
d1() // one holder left: record (and instance) survive
|
||||
expect(host.storeOf(rowEntry as never, undefined)).toBe(shared)
|
||||
expect(() => host.storeOf(hostEntry as never, undefined)).not.toThrow() // handle still live via the row entry
|
||||
// Note: dropping the row entry would sever the last reference; stale
|
||||
// resolution is covered through the cascade spec below.
|
||||
})
|
||||
|
||||
it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => {
|
||||
const { bench, host } = await storeBench()
|
||||
const { handle, created } = fakeHandle()
|
||||
bench.erased.register({ name: 't.panel', store: handle }, C)
|
||||
const [entry] = host.entriesOf('t.panel')
|
||||
const s1 = host.storeOf(entry as never, 's1')
|
||||
expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
|
||||
bench.svc.pruneStoreScope('s1')
|
||||
expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
|
||||
expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew
|
||||
// Never-rendered dead session: a transient instance is created just to clear storage.
|
||||
const before = created.length
|
||||
bench.svc.pruneStoreScope('s-never')
|
||||
expect(created.length).toBe(before + 1)
|
||||
expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('entry-unload cascade', () => {
|
||||
it('kills declared children, their contributions, and the ledger rows with the entry', async () => {
|
||||
const bench = await boot()
|
||||
let host: SlotRendererHost | undefined
|
||||
bench.erased.install({
|
||||
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
|
||||
})
|
||||
bench.ctx.reflect.provide('sessions', fakeSessions())
|
||||
// The declarer here is NOT the root occupant: root stays occupied by a
|
||||
// separate entry so disposing the declarer only kills its children.
|
||||
const disposeRoot = bench.erased.register({ name: 'root' }, C)
|
||||
bench.erased.renderSlot('root', {})
|
||||
if (host === undefined) throw new Error('renderer never received the host')
|
||||
disposeRoot()
|
||||
const disposeDeclarer = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.host' }, C)
|
||||
const [childEntry] = host.entriesOf('t.host')
|
||||
expect(childEntry).toBeDefined()
|
||||
|
||||
disposeDeclarer()
|
||||
expect(bench.svc.spec('t.host')).toBeUndefined() // ledger row gone
|
||||
expect(host.specOf('t.host')).toBeUndefined() // outlets now render empty
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0) // contribution cleared
|
||||
expect(host.isLive(childEntry as never)).toBe(false) // stale bindings will throw upstream
|
||||
// The freed key is re-declarable by a new entry (no residue).
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)).not.toThrow()
|
||||
})
|
||||
|
||||
it('cascades through cordis fiber disposal (plugin unload = full cleanup)', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const fiber = bench.ctx.plugin({
|
||||
name: 'occupant',
|
||||
inject: ['slots'],
|
||||
apply: (pluginCtx: Context) => {
|
||||
pluginCtx.slots.register('t-single', C)
|
||||
;(pluginCtx.slots as unknown as ErasedService).register({ name: 't.host' }, C)
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('t-single')).toHaveLength(1)
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(ctx.slots.entries('t-single')).toHaveLength(0)
|
||||
// The slot definition (registered from root) survives; a new occupant may register.
|
||||
expect(() => ctx.slots.register('t-single', C)).not.toThrow()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0)
|
||||
expect(bench.svc.spec('t.host')).toBeDefined() // declarer still live; slot stays declared
|
||||
})
|
||||
|
||||
it('proxies specDynamic/subscribe/getVersion through the core', async () => {
|
||||
const ctx = await boot()
|
||||
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
|
||||
expect(ctx.slots.specDynamic('t-list')).toEqual({ kind: 'list', scope: 'root' })
|
||||
expect(ctx.slots.specDynamic('never-defined')).toBeUndefined()
|
||||
let notified = 0
|
||||
const unsubscribe = ctx.slots.subscribe('t-list', () => { notified += 1 })
|
||||
ctx.slots.register('t-list', C, { id: 'row' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0)) // microtask-batched flush
|
||||
expect(notified).toBeGreaterThan(0)
|
||||
expect(ctx.slots.getVersion('t-list')).toBeGreaterThan(0)
|
||||
unsubscribe()
|
||||
it('disposer is idempotent (stale second call is a no-op)', async () => {
|
||||
const bench = await boot()
|
||||
const dispose = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
dispose()
|
||||
expect(() => { dispose() }).not.toThrow()
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event bridge', () => {
|
||||
it("re-emits entry writes and child declarations as 'slots/changed'", async () => {
|
||||
const bench = await boot()
|
||||
const seen: string[] = []
|
||||
bench.ctx.on('slots/changed', (key) => { seen.push(key) })
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.rows', id: 'a' }, C)
|
||||
expect(seen).toEqual(['root', 't.rows', 't.rows'])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
227
packages/client/runtime/tests/store.spec.ts
Normal file
227
packages/client/runtime/tests/store.spec.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/contract/store.ts'
|
||||
|
||||
interface State {
|
||||
a: { n: number }
|
||||
b: { list: string[] }
|
||||
}
|
||||
|
||||
const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('createSnapshotStore', () => {
|
||||
it('applies update through a draft and preserves untouched branch references', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const before = store.getSnapshot()
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
const after = store.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.a.n).toBe(2)
|
||||
expect(after.b).toBe(before.b)
|
||||
})
|
||||
|
||||
it('notifies synchronously per update by default', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const seen: number[] = []
|
||||
store.subscribe(() => { seen.push(store.getSnapshot().a.n) })
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
expect(seen).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('coalesces a frame of updates into one notification in raf mode', () => {
|
||||
const frame: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frame.push(cb)
|
||||
return frame.length
|
||||
})
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
store.update((d) => { d.b.list.push('y') })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
expect(frame).toHaveLength(1)
|
||||
frame.shift()!(0)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(store.getSnapshot().a.n).toBe(3)
|
||||
// Next frame batches independently.
|
||||
store.update((d) => { d.a.n = 4 })
|
||||
expect(frame).toHaveLength(1)
|
||||
frame.shift()!(0)
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back to microtask batching in raf mode without requestAnimationFrame', async () => {
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
await Promise.resolve()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('unsubscribes raf-mode listeners', () => {
|
||||
const frame: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frame.push(cb)
|
||||
return frame.length
|
||||
})
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
const off = store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
off()
|
||||
frame.shift()!(0)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces state wholesale via set and freezes it outside production', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const next = init()
|
||||
store.set(next)
|
||||
expect(store.getSnapshot()).toBe(next)
|
||||
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
|
||||
})
|
||||
|
||||
it('freezes update produce output outside production (immer dev freeze)', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
|
||||
})
|
||||
|
||||
it('rehydrates primitive state whole, not spread into index keys', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const store = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
|
||||
store.set('hello')
|
||||
const revived = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
|
||||
expect(revived.getSnapshot()).toBe('hello')
|
||||
})
|
||||
|
||||
it('persists to localStorage under the given name and rehydrates', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const store = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
|
||||
store.update((d) => { d.a.n = 42 })
|
||||
expect(backing.has('spec-store')).toBe(true)
|
||||
const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
|
||||
expect(revived.getSnapshot().a.n).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineStore', () => {
|
||||
const declare = () => defineStore({
|
||||
init: () => ({ selection: null as string | null, draft: '' }),
|
||||
actions: {
|
||||
select: (d, target: string) => { d.selection = target },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
|
||||
it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
|
||||
const inst = declare().create()
|
||||
expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
|
||||
inst.actions.setDraft('hello')
|
||||
inst.actions.select('m1')
|
||||
expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
|
||||
inst.actions.clearDraft()
|
||||
expect(inst.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
|
||||
const inst = declare().create()
|
||||
const before = inst.store.getSnapshot()
|
||||
inst.actions.setDraft('x')
|
||||
const after = inst.store.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.selection).toBe(before.selection) // untouched branch preserved (immer path)
|
||||
})
|
||||
|
||||
it('creates independent instances per create() call (the handle is a spec, not a singleton)', () => {
|
||||
const handle = declare()
|
||||
const a = handle.create()
|
||||
const b = handle.create()
|
||||
a.actions.setDraft('only-a')
|
||||
expect(b.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('suffixes the persist key with the scope key: per-session persistence plus clearPersisted cleanup', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const handle = defineStore({
|
||||
init: () => ({ draft: '' }),
|
||||
persist: 'spec.chat',
|
||||
actions: { setDraft: (d, text: string) => { d.draft = text } },
|
||||
})
|
||||
handle.create('s1').actions.setDraft('one')
|
||||
handle.create('s2').actions.setDraft('two')
|
||||
handle.create().actions.setDraft('root')
|
||||
expect(JSON.parse(backing.get('spec.chat.s1')!)).toEqual({ draft: 'one' })
|
||||
expect(JSON.parse(backing.get('spec.chat.s2')!)).toEqual({ draft: 'two' })
|
||||
expect(JSON.parse(backing.get('spec.chat')!)).toEqual({ draft: 'root' })
|
||||
// Rehydration honors the same suffixed key.
|
||||
expect(handle.create('s1').store.getSnapshot().draft).toBe('one')
|
||||
// Scope-death cleanup removes exactly the suffixed key.
|
||||
handle.create('s1').clearPersisted()
|
||||
expect(backing.has('spec.chat.s1')).toBe(false)
|
||||
expect(backing.has('spec.chat.s2')).toBe(true)
|
||||
expect(backing.has('spec.chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('clearPersisted is a no-op without a persist declaration or without storage', () => {
|
||||
const inst = declare().create('s1') // no persist key declared
|
||||
expect(() => { inst.clearPersisted() }).not.toThrow()
|
||||
const persisting = defineStore({
|
||||
init: () => ({ n: 0 }),
|
||||
persist: 'spec.nostorage',
|
||||
actions: { inc: (d) => { d.n += 1 } },
|
||||
}).create()
|
||||
// jsdom-less lane: localStorage may exist here, so simulate its absence.
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
expect(() => { persisting.clearPersisted() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('swallows storage failures in clearPersisted (same non-fatal contract as persistence)', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => { throw new Error('quota / private mode') },
|
||||
})
|
||||
const inst = defineStore({
|
||||
init: () => ({ n: 0 }),
|
||||
persist: 'spec.throwing',
|
||||
actions: { inc: (d) => { d.n += 1 } },
|
||||
}).create()
|
||||
expect(() => { inst.clearPersisted() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shallowEqual', () => {
|
||||
it('matches one-level-equal objects and rejects deeper drift', () => {
|
||||
const leaf = { deep: 1 }
|
||||
expect(shallowEqual({ x: 1, y: leaf }, { x: 1, y: leaf })).toBe(true)
|
||||
expect(shallowEqual({ x: 1, y: { deep: 1 } }, { x: 1, y: { deep: 1 } })).toBe(false)
|
||||
expect(shallowEqual([1, 2], [1, 2])).toBe(true)
|
||||
expect(shallowEqual([1, 2], [2, 1])).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user