slots-service.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. /**
  2. * SlotsService terminal-design account (design.md §11-3 main landing):
  3. * built-in 'root', the three load-time throws (duplicate declaration /
  4. * undeclared contribution / cross-scope store handle), the renderer install
  5. * seam (double install / not installed / non-root key), store instance
  6. * resolution and lifecycle on the ledger axis, and the entry-unload cascade.
  7. */
  8. import { Context } from 'cordis'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import type { FC } from 'react'
  11. import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
  12. import { SlotsService } from '../src/client/slots.ts'
  13. // Test-only slot keys (merged so the typed entries/spec faces accept them).
  14. declare module '@deepseek-ai/dsh-client-ui-slots' {
  15. interface SlotMap {
  16. 't.host': { kind: 'single'; scope: 'root' }
  17. 't.panel': { kind: 'single'; scope: 'session' }
  18. 't.rows': { kind: 'list'; scope: 'root' }
  19. }
  20. }
  21. const C: FC<object> = () => null
  22. /**
  23. * Register/install/renderSlot through a type-erased view: the typed register
  24. * face rides wave-1 ui-slots types (red until that wave lands); the runtime
  25. * semantics under test are final.
  26. */
  27. interface ErasedService {
  28. register(options: object, component: unknown): () => void
  29. install(renderer: object): void
  30. renderSlot(key: string, owner: object): unknown
  31. }
  32. interface Bench {
  33. ctx: Context
  34. svc: SlotsService
  35. erased: ErasedService
  36. }
  37. async function boot(): Promise<Bench> {
  38. const ctx = new Context()
  39. ctx.plugin(SlotsService)
  40. await ctx.fiber.await()
  41. // Service accessor (ctx.get reads the reflect store, which Service-class
  42. // plugins do not write; the accessor is the product path).
  43. const svc = ctx.slots
  44. return { ctx, svc, erased: svc as unknown as ErasedService }
  45. }
  46. /** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
  47. interface FakeInstance {
  48. getSnapshot: () => undefined
  49. subscribe: () => () => void
  50. actions: Record<string, never>
  51. clearPersisted: ReturnType<typeof vi.fn>
  52. }
  53. /** Fake store handle factory (create-count and clearPersisted observable). */
  54. function fakeHandle() {
  55. const created: FakeInstance[] = []
  56. const handle = {
  57. create: vi.fn((_scopeKey?: string): FakeInstance => {
  58. const instance: FakeInstance = {
  59. getSnapshot: () => undefined, subscribe: () => () => undefined,
  60. actions: {}, clearPersisted: vi.fn(),
  61. }
  62. created.push(instance)
  63. return instance
  64. }),
  65. }
  66. return { handle, created }
  67. }
  68. /**
  69. * Install a capturing renderer, occupy 'root' (declaring `children` in the
  70. * same call — 'root' is single, so the one occupant is also the declarer),
  71. * and pull the host face out through renderSlot('root').
  72. */
  73. function captureHost(bench: Bench, children?: object): SlotRendererHost {
  74. let host: SlotRendererHost | undefined
  75. bench.erased.install({
  76. renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
  77. })
  78. bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
  79. bench.ctx.reflect.provide('sessions', fakeSessions())
  80. bench.erased.renderSlot('root', {})
  81. if (host === undefined) throw new Error('renderer never received the host')
  82. return host
  83. }
  84. /** Minimal sessions face for the host seam (list observable + cell). */
  85. function fakeSessions() {
  86. const state = { ids: [], byId: {}, current: undefined as string | undefined }
  87. return {
  88. list: { getSnapshot: () => state, subscribe: () => () => undefined },
  89. cell: (id: string) => (id === 'known'
  90. ? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
  91. : undefined),
  92. }
  93. }
  94. describe("built-in 'root'", () => {
  95. it('is declared at construction: spec readable, occupancy open, no plugin needed', async () => {
  96. const bench = await boot()
  97. expect(bench.svc.spec('root')).toEqual({ kind: 'single', scope: 'root' })
  98. expect(() => bench.erased.register({ name: 'root' }, C)).not.toThrow()
  99. expect(bench.svc.entries('root')).toHaveLength(1)
  100. })
  101. it('rejects a second declaration of root, attributing the built-in row', async () => {
  102. const bench = await boot()
  103. expect(() => bench.erased.register({
  104. name: 'root', children: { 'root': { kind: 'single', scope: 'root' } },
  105. }, C)).toThrow(/already declared.*built-in/)
  106. })
  107. })
  108. describe('load-time validation', () => {
  109. it('throws on contributing into an undeclared slot', async () => {
  110. const bench = await boot()
  111. expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/slot "t.host" is not declared/)
  112. })
  113. it('throws on a duplicate declaration, naming the slot and the prior declarant', async () => {
  114. const bench = await boot()
  115. bench.erased.register({ name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } } }, C)
  116. bench.erased.register({
  117. name: 't.host', children: { 't.rows': { kind: 'list', scope: 'root' } },
  118. }, C)
  119. expect(() => bench.erased.register({
  120. name: 't.rows', id: 'r1', children: { 't.rows': { kind: 'list', scope: 'root' } },
  121. }, C)).toThrow(/slot "t.rows" is already declared.*"t.host"/)
  122. })
  123. it('throws when one store handle is bound to two scopes', async () => {
  124. const bench = await boot()
  125. bench.erased.register({
  126. name: 'root',
  127. children: {
  128. 't.host': { kind: 'single', scope: 'root' },
  129. 't.panel': { kind: 'single', scope: 'session' },
  130. },
  131. }, C)
  132. const { handle } = fakeHandle()
  133. bench.erased.register({ name: 't.host', store: handle }, C)
  134. expect(() => bench.erased.register({ name: 't.panel', store: handle }, C))
  135. .toThrow(/one handle, one scope/)
  136. })
  137. it('commits nothing when the core rejects the entry (children stay undeclared)', async () => {
  138. const bench = await boot()
  139. bench.erased.register({ name: 'root' }, C) // 'root' single slot now occupied
  140. expect(() => bench.erased.register({
  141. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  142. }, C)).toThrow(/already has a registration/)
  143. // The failing call's declaration must not have landed.
  144. expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/is not declared/)
  145. })
  146. })
  147. describe('renderer install seam', () => {
  148. it('throws on renderSlot before install (boot-order guidance)', async () => {
  149. const bench = await boot()
  150. expect(() => bench.erased.renderSlot('root', {})).toThrow(/renderer not installed/)
  151. })
  152. it('throws on double install', async () => {
  153. const bench = await boot()
  154. bench.erased.install({ renderRoot: () => null })
  155. expect(() => { bench.erased.install({ renderRoot: () => null }) }).toThrow(/already installed/)
  156. })
  157. it('throws on any non-root key (single ctx-level entry)', async () => {
  158. const bench = await boot()
  159. bench.erased.install({ renderRoot: () => null })
  160. expect(() => bench.erased.renderSlot('t.host', {})).toThrow(/only renders 'root'/)
  161. })
  162. it("throws on renderSlot('root') before any root registration", async () => {
  163. const bench = await boot()
  164. bench.erased.install({ renderRoot: () => null })
  165. expect(() => bench.erased.renderSlot('root', {})).toThrow(/no registration/)
  166. })
  167. it('renders through the installed renderer and returns its product', async () => {
  168. const bench = await boot()
  169. const renderRoot = vi.fn(() => 'tree')
  170. bench.erased.install({ renderRoot })
  171. bench.erased.register({ name: 'root' }, C)
  172. bench.ctx.reflect.provide('sessions', fakeSessions())
  173. expect(bench.erased.renderSlot('root', {})).toBe('tree')
  174. expect(renderRoot).toHaveBeenCalledTimes(1)
  175. })
  176. })
  177. describe('host face', () => {
  178. it('serves entriesOf/specOf/isLive off the ledger and flips isLive on disposal', async () => {
  179. const bench = await boot()
  180. const host = captureHost(bench, { 't.host': { kind: 'single', scope: 'root' } })
  181. const dispose = bench.erased.register({ name: 't.host' }, C)
  182. const rootEntry = host.entriesOf('root')[0]
  183. expect(rootEntry).toBeDefined()
  184. expect(rootEntry?.component).toBe(C)
  185. expect(host.specOf('root')).toEqual({ kind: 'single', scope: 'root' })
  186. expect(host.specOf('t.host')).toEqual({ kind: 'single', scope: 'root' })
  187. const childEntry = host.entriesOf('t.host')[0]
  188. expect(host.isLive(childEntry as never)).toBe(true)
  189. dispose()
  190. expect(host.isLive(childEntry as never)).toBe(false)
  191. expect(host.entriesOf('t.host')).toHaveLength(0)
  192. })
  193. it('exposes sessions list/current/cell (current riding the list snapshot)', async () => {
  194. const bench = await boot()
  195. const host = captureHost(bench)
  196. expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
  197. expect(host.sessions.current.getSnapshot()).toBeUndefined()
  198. expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
  199. expect(host.sessions.cell('ghost')).toBeUndefined()
  200. })
  201. })
  202. describe('store instance axis', () => {
  203. /** Boot with 'root' occupied and the three test children declared. */
  204. async function storeBench() {
  205. const bench = await boot()
  206. const host = captureHost(bench, {
  207. 't.host': { kind: 'single', scope: 'root' },
  208. 't.rows': { kind: 'list', scope: 'root' },
  209. 't.panel': { kind: 'single', scope: 'session' },
  210. })
  211. return { bench, host }
  212. }
  213. it('resolves one instance per (handle x root scope) shared across entries', async () => {
  214. const { bench, host } = await storeBench()
  215. const { handle } = fakeHandle()
  216. bench.erased.register({ name: 't.host', store: handle }, C)
  217. bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
  218. const [hostEntry] = host.entriesOf('t.host')
  219. const [rowEntry] = host.entriesOf('t.rows')
  220. const a = host.storeOf(hostEntry as never, undefined)
  221. const b = host.storeOf(rowEntry as never, undefined)
  222. expect(a).toBeDefined()
  223. expect(a).toBe(b) // shared handle, same scope key = same instance
  224. expect(handle.create).toHaveBeenCalledTimes(1)
  225. expect(handle.create).toHaveBeenCalledWith() // root scope: keyless create
  226. })
  227. it('resolves per-session instances keyed by session id, created with the scope key', async () => {
  228. const { bench, host } = await storeBench()
  229. const { handle } = fakeHandle()
  230. bench.erased.register({ name: 't.panel', store: handle }, C)
  231. const [entry] = host.entriesOf('t.panel')
  232. const s1 = host.storeOf(entry as never, 's1')
  233. const s2 = host.storeOf(entry as never, 's2')
  234. expect(s1).not.toBe(s2)
  235. expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key
  236. expect(handle.create).toHaveBeenCalledWith('s1')
  237. expect(handle.create).toHaveBeenCalledWith('s2')
  238. expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
  239. })
  240. it('mints a fresh handle per register for the factory (exclusive) form', async () => {
  241. const { bench, host } = await storeBench()
  242. const factory = vi.fn(() => fakeHandle().handle)
  243. bench.erased.register({ name: 't.host', store: factory }, C)
  244. bench.erased.register({ name: 't.rows', id: 'a', store: factory }, C)
  245. expect(factory).toHaveBeenCalledTimes(2)
  246. const a = host.storeOf(host.entriesOf('t.host')[0] as never, undefined)
  247. const b = host.storeOf(host.entriesOf('t.rows')[0] as never, undefined)
  248. expect(a).not.toBe(b) // two mints, two instances
  249. })
  250. it('drops instances with the last holding entry and refuses stale resolution', async () => {
  251. const { bench, host } = await storeBench()
  252. const { handle } = fakeHandle()
  253. const d1 = bench.erased.register({ name: 't.host', store: handle }, C)
  254. bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
  255. const rowEntry = host.entriesOf('t.rows')[0]
  256. const hostEntry = host.entriesOf('t.host')[0]
  257. const shared = host.storeOf(rowEntry as never, undefined)
  258. d1() // one holder left: record (and instance) survive
  259. expect(host.storeOf(rowEntry as never, undefined)).toBe(shared)
  260. expect(() => host.storeOf(hostEntry as never, undefined)).not.toThrow() // handle still live via the row entry
  261. // Note: dropping the row entry would sever the last reference; stale
  262. // resolution is covered through the cascade spec below.
  263. })
  264. it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => {
  265. const { bench, host } = await storeBench()
  266. const { handle, created } = fakeHandle()
  267. bench.erased.register({ name: 't.panel', store: handle }, C)
  268. const [entry] = host.entriesOf('t.panel')
  269. const s1 = host.storeOf(entry as never, 's1')
  270. expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
  271. bench.svc.pruneStoreScope('s1')
  272. expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
  273. expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew
  274. // Never-rendered dead session: a transient instance is created just to clear storage.
  275. const before = created.length
  276. bench.svc.pruneStoreScope('s-never')
  277. expect(created.length).toBe(before + 1)
  278. expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1)
  279. })
  280. })
  281. describe('entry-unload cascade', () => {
  282. it('kills declared children, their contributions, and the ledger rows with the entry', async () => {
  283. const bench = await boot()
  284. let host: SlotRendererHost | undefined
  285. bench.erased.install({
  286. renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
  287. })
  288. bench.ctx.reflect.provide('sessions', fakeSessions())
  289. // The declarer here is NOT the root occupant: root stays occupied by a
  290. // separate entry so disposing the declarer only kills its children.
  291. const disposeRoot = bench.erased.register({ name: 'root' }, C)
  292. bench.erased.renderSlot('root', {})
  293. if (host === undefined) throw new Error('renderer never received the host')
  294. disposeRoot()
  295. const disposeDeclarer = bench.erased.register({
  296. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  297. }, C)
  298. bench.erased.register({ name: 't.host' }, C)
  299. const [childEntry] = host.entriesOf('t.host')
  300. expect(childEntry).toBeDefined()
  301. disposeDeclarer()
  302. expect(bench.svc.spec('t.host')).toBeUndefined() // ledger row gone
  303. expect(host.specOf('t.host')).toBeUndefined() // outlets now render empty
  304. expect(bench.svc.entries('t.host')).toHaveLength(0) // contribution cleared
  305. expect(host.isLive(childEntry as never)).toBe(false) // stale bindings will throw upstream
  306. // The freed key is re-declarable by a new entry (no residue).
  307. expect(() => bench.erased.register({
  308. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  309. }, C)).not.toThrow()
  310. })
  311. it('cascades through cordis fiber disposal (plugin unload = full cleanup)', async () => {
  312. const bench = await boot()
  313. bench.erased.register({
  314. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  315. }, C)
  316. const fiber = bench.ctx.plugin({
  317. name: 'occupant',
  318. inject: ['slots'],
  319. apply: (pluginCtx: Context) => {
  320. ;(pluginCtx.slots as unknown as ErasedService).register({ name: 't.host' }, C)
  321. },
  322. })
  323. await fiber.await()
  324. expect(bench.svc.entries('t.host')).toHaveLength(1)
  325. await fiber.dispose()
  326. expect(bench.svc.entries('t.host')).toHaveLength(0)
  327. expect(bench.svc.spec('t.host')).toBeDefined() // declarer still live; slot stays declared
  328. })
  329. it('disposer is idempotent (stale second call is a no-op)', async () => {
  330. const bench = await boot()
  331. const dispose = bench.erased.register({
  332. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  333. }, C)
  334. dispose()
  335. expect(() => { dispose() }).not.toThrow()
  336. expect(() => bench.erased.register({
  337. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  338. }, C)).not.toThrow()
  339. })
  340. })
  341. describe('event bridge', () => {
  342. it("re-emits entry writes and child declarations as 'slots/changed'", async () => {
  343. const bench = await boot()
  344. const seen: string[] = []
  345. bench.ctx.on('slots/changed', (key) => { seen.push(key) })
  346. bench.erased.register({
  347. name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
  348. }, C)
  349. bench.erased.register({ name: 't.rows', id: 'a' }, C)
  350. expect(seen).toEqual(['root', 't.rows', 't.rows'])
  351. })
  352. })