core.client.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import { describe, expect, it, vi } from 'vitest'
  2. import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
  3. import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
  4. // 'root' is NOT merged here: ui-renderer owns the built-in row, and
  5. // the client aggregate program would see both merges collide.
  6. declare module '@deepseek-ai/dsh-client-ui-slots' {
  7. interface SlotMap {
  8. 'test.single': { kind: 'single'; scope: 'root' }
  9. 'test.session': { kind: 'single'; scope: 'session' }
  10. 'test.list': { kind: 'list'; scope: 'root' }
  11. 'test.keyed': { kind: 'keyed'; scope: 'session' }
  12. 'test.chain': { kind: 'chain'; scope: 'session'; owner: { tags: string[] } }
  13. 'test.grandchild': { kind: 'single'; scope: 'root' }
  14. }
  15. }
  16. // Wide-accepting fixture: assignable wherever the composed constraint is an
  17. // object type (children-declaring fixtures erase via `as never` instead —
  18. // RendersCheck would demand a renderSlot consumer).
  19. const Comp: SlotComponent<object> = () => null
  20. function fakeHandle(): StoreHandle<{ n: number }, Record<string, (d: { n: number }) => void>> {
  21. return {
  22. spec: { init: () => ({ n: 0 }), actions: {} },
  23. create: () => { throw new Error('not under test') },
  24. }
  25. }
  26. function mountFrame(core: SlotCore) {
  27. return core.register({
  28. name: 'root',
  29. children: {
  30. 'test.single': { kind: 'single', scope: 'root' },
  31. 'test.session': { kind: 'single', scope: 'session' },
  32. 'test.list': { kind: 'list', scope: 'root' },
  33. 'test.keyed': { kind: 'keyed', scope: 'session' },
  34. 'test.chain': { kind: 'chain', scope: 'session' },
  35. },
  36. // Type-level renderSlot presence is proven by the type-chain spec; erasing
  37. // here keeps runtime fixtures terse.
  38. }, Comp as never)
  39. }
  40. const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
  41. describe('a-priori root and declaration gate', () => {
  42. it('seeds root as single/root at construction', () => {
  43. const core = new SlotCore()
  44. expect(core.specDynamic('root')).toEqual({ kind: 'single', scope: 'root' })
  45. })
  46. it('throws on registering into an undeclared slot', () => {
  47. const core = new SlotCore()
  48. expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
  49. })
  50. it('root is single: a second frame registration throws', () => {
  51. const core = new SlotCore()
  52. mountFrame(core)
  53. expect(() => core.register({ name: 'root' }, Comp)).toThrow('already has a registration')
  54. })
  55. it('children declaration makes child slots registerable, with specs recorded', () => {
  56. const core = new SlotCore()
  57. mountFrame(core)
  58. expect(core.specDynamic('test.session')).toEqual({ kind: 'single', scope: 'session' })
  59. expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
  60. })
  61. it('duplicate child declaration throws naming the first declarer', () => {
  62. const core = new SlotCore()
  63. mountFrame(core)
  64. core.register({ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
  65. expect(() => core.register(
  66. { name: 'test.session', children: { 'test.grandchild': { kind: 'single', scope: 'root' } }, registrant: 'imposter' },
  67. Comp as never,
  68. )).toThrow(/already declared.*test\.single/)
  69. })
  70. })
  71. describe('lifecycle cascade (one axis)', () => {
  72. it('disposing a declaring entry collapses child slots and their contributions recursively', () => {
  73. const core = new SlotCore()
  74. const disposeFrame = mountFrame(core)
  75. const disposeChild = core.register(
  76. { name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
  77. core.register({ name: 'test.grandchild' }, Comp)
  78. expect(core.entries('test.grandchild')).toHaveLength(1)
  79. disposeFrame()
  80. expect(core.specDynamic('test.single')).toBeUndefined()
  81. expect(core.specDynamic('test.grandchild')).toBeUndefined()
  82. expect(core.entries('test.single')).toHaveLength(0)
  83. expect(core.entries('test.grandchild')).toHaveLength(0)
  84. // Stale disposer of a cascaded-away entry is a no-op.
  85. expect(() => { disposeChild() }).not.toThrow()
  86. // Slots return to undeclared: contributing again throws until redeclared.
  87. expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
  88. })
  89. it('registration disposers are idempotent', () => {
  90. const core = new SlotCore()
  91. const dispose = mountFrame(core)
  92. dispose()
  93. dispose()
  94. expect(core.entries('root')).toHaveLength(0)
  95. // Redeclare works after collapse.
  96. mountFrame(core)
  97. expect(core.specDynamic('test.single')).toBeDefined()
  98. })
  99. it('isLive tracks ledger membership across dispose', () => {
  100. const core = new SlotCore()
  101. mountFrame(core)
  102. const dispose = core.register({ name: 'test.single' }, Comp)
  103. const entry = core.entries('test.single')[0]!
  104. expect(core.isLive(entry)).toBe(true)
  105. dispose()
  106. expect(core.isLive(entry)).toBe(false)
  107. })
  108. })
  109. describe('kind semantics', () => {
  110. it('keyed: duplicate key throws, missing key throws', () => {
  111. const core = new SlotCore()
  112. mountFrame(core)
  113. core.register({ name: 'test.keyed', key: 'a' }, Comp)
  114. expect(() => core.register({ name: 'test.keyed', key: 'a' }, Comp)).toThrow('key "a"')
  115. // Statically rejected (KindOptions); runtime guard stays for dynamic callers.
  116. // @ts-expect-error keyed registration requires options.key
  117. expect(() => core.register({ name: 'test.keyed' }, Comp)).toThrow('requires options.key')
  118. expect(() => core.register({ name: 'test.keyed', key: 'b' }, Comp)).not.toThrow()
  119. })
  120. it('list: duplicate id throws, missing id throws, entries sort by order stably', () => {
  121. const core = new SlotCore()
  122. mountFrame(core)
  123. core.register({ name: 'test.list', id: 'c', order: 10 }, Comp)
  124. core.register({ name: 'test.list', id: 'a' }, Comp)
  125. core.register({ name: 'test.list', id: 'b' }, Comp)
  126. expect(() => core.register({ name: 'test.list', id: 'a' }, Comp)).toThrow('id "a"')
  127. // @ts-expect-error list registration requires options.id
  128. expect(() => core.register({ name: 'test.list' }, Comp)).toThrow('requires options.id')
  129. expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
  130. })
  131. it('chain: missing select throws; select and priority land on the stored entry', () => {
  132. const core = new SlotCore()
  133. mountFrame(core)
  134. // Statically rejected (KindOptions); runtime guard stays for dynamic callers.
  135. // @ts-expect-error chain registration requires options.select
  136. expect(() => core.register({ name: 'test.chain' }, Comp)).toThrow('requires options.select')
  137. const select = ({ tags }: { tags: string[] }) => tags[0] ?? null
  138. core.register({ name: 'test.chain', select, priority: 5 }, Comp as never)
  139. const entry = core.entries('test.chain')[0]!
  140. expect(entry.select).toBe(select)
  141. expect(entry.options.priority).toBe(5)
  142. })
  143. it('chain: entries sort by priority ascending, ties keep registration order', () => {
  144. const core = new SlotCore()
  145. mountFrame(core)
  146. const sel = () => null
  147. core.register({ name: 'test.chain', select: sel, priority: 10, registrant: 'late' }, Comp as never)
  148. core.register({ name: 'test.chain', select: sel, registrant: 'default-a' }, Comp as never)
  149. core.register({ name: 'test.chain', select: sel, registrant: 'default-b' }, Comp as never)
  150. core.register({ name: 'test.chain', select: sel, priority: -1, registrant: 'first' }, Comp as never)
  151. expect(core.entries('test.chain').map(e => e.registrant))
  152. .toEqual(['first', 'default-a', 'default-b', 'late'])
  153. })
  154. it('single: second registration throws, disposer frees the seat', () => {
  155. const core = new SlotCore()
  156. mountFrame(core)
  157. const dispose = core.register({ name: 'test.single' }, Comp)
  158. expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('already has a registration')
  159. dispose()
  160. expect(core.entries('test.single')).toHaveLength(0)
  161. expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
  162. })
  163. })
  164. describe('store scope pinning', () => {
  165. it('one shared handle under two scopes throws at load', () => {
  166. const core = new SlotCore()
  167. mountFrame(core)
  168. const handle = fakeHandle()
  169. core.register({ name: 'test.session', store: handle }, Comp as never)
  170. expect(() => core.register({ name: 'test.single', store: handle }, Comp as never))
  171. .toThrow('one handle, one scope')
  172. })
  173. it('same handle under same scope is fine; full unmount releases the pin', () => {
  174. const core = new SlotCore()
  175. mountFrame(core)
  176. const handle = fakeHandle()
  177. const d1 = core.register({ name: 'test.list', id: 'x', store: handle }, Comp as never)
  178. const d2 = core.register({ name: 'test.list', id: 'y', store: handle }, Comp as never)
  179. d1()
  180. // Still mounted once — scope stays pinned.
  181. expect(() => core.register({ name: 'test.session', store: handle }, Comp as never))
  182. .toThrow('one handle, one scope')
  183. d2()
  184. // All mounts gone: the handle may pin a new scope.
  185. expect(() => core.register({ name: 'test.session', store: handle }, Comp as never)).not.toThrow()
  186. })
  187. it('factories are exempt from pinning (no shared identity)', () => {
  188. const core = new SlotCore()
  189. mountFrame(core)
  190. const factory = () => fakeHandle()
  191. core.register({ name: 'test.session', store: factory }, Comp as never)
  192. expect(() => core.register({ name: 'test.single', store: factory }, Comp as never)).not.toThrow()
  193. })
  194. it('cascade releases store pins of collapsed child entries', () => {
  195. const core = new SlotCore()
  196. const disposeFrame = mountFrame(core)
  197. const handle = fakeHandle()
  198. core.register({ name: 'test.session', store: handle }, Comp as never)
  199. disposeFrame()
  200. mountFrame(core)
  201. expect(() => core.register({ name: 'test.single', store: handle }, Comp as never)).not.toThrow()
  202. })
  203. })
  204. describe('subscription API', () => {
  205. it('tracks declaration epochs separately from ordinary entry mutations', () => {
  206. const core = new SlotCore()
  207. expect(core.declarationEpoch('root')).toBe(1)
  208. expect(core.declarationEpoch('test.list')).toBe(0)
  209. const disposeFrame = mountFrame(core)
  210. const declared = core.declarationEpoch('test.list')
  211. expect(declared).toBe(1)
  212. const disposeEntry = core.register({ name: 'test.list', id: 'a' }, Comp)
  213. disposeEntry()
  214. expect(core.declarationEpoch('test.list')).toBe(declared)
  215. disposeFrame()
  216. expect(core.declarationEpoch('test.list')).toBe(declared + 1)
  217. mountFrame(core)
  218. expect(core.declarationEpoch('test.list')).toBe(declared + 2)
  219. })
  220. it('entries() returns a stable cached reference between mutations', () => {
  221. const core = new SlotCore()
  222. mountFrame(core)
  223. core.register({ name: 'test.list', id: 'a' }, Comp)
  224. const first = core.entries('test.list')
  225. expect(core.entries('test.list')).toBe(first)
  226. core.register({ name: 'test.list', id: 'b' }, Comp)
  227. expect(core.entries('test.list')).not.toBe(first)
  228. })
  229. it('bumps version synchronously but batches notifications per microtask', async () => {
  230. const core = new SlotCore()
  231. mountFrame(core)
  232. const fn = vi.fn()
  233. core.subscribe('test.list', fn)
  234. const before = core.getVersion('test.list')
  235. core.register({ name: 'test.list', id: 'a' }, Comp)
  236. core.register({ name: 'test.list', id: 'b' }, Comp)
  237. expect(core.getVersion('test.list')).toBe(before + 2)
  238. expect(fn).not.toHaveBeenCalled()
  239. await flushMicrotasks()
  240. expect(fn).toHaveBeenCalledTimes(1)
  241. core.register({ name: 'test.list', id: 'c' }, Comp)
  242. await flushMicrotasks()
  243. expect(fn).toHaveBeenCalledTimes(2)
  244. })
  245. it('declaration itself notifies child-key subscribers (subscribe-ahead allowed)', async () => {
  246. const core = new SlotCore()
  247. const fn = vi.fn()
  248. core.subscribe('test.single', fn)
  249. mountFrame(core)
  250. await flushMicrotasks()
  251. expect(fn).toHaveBeenCalledTimes(1)
  252. })
  253. it('notifies declaration subscribers synchronously, excluding entries, until unsubscribe', () => {
  254. const core = new SlotCore()
  255. const fn = vi.fn()
  256. const unsubscribe = core.subscribeDeclaration('test.list', fn)
  257. const disposeFrame = mountFrame(core)
  258. expect(fn).toHaveBeenCalledTimes(1)
  259. core.register({ name: 'test.list', id: 'ordinary' }, Comp)
  260. expect(fn).toHaveBeenCalledTimes(1)
  261. disposeFrame()
  262. expect(fn).toHaveBeenCalledTimes(2)
  263. unsubscribe()
  264. mountFrame(core)
  265. expect(fn).toHaveBeenCalledTimes(2)
  266. })
  267. it('commits sibling declarations before notifying declaration subscribers', () => {
  268. const core = new SlotCore()
  269. let duplicateDeclaration: unknown
  270. const unsubscribe = core.subscribeDeclaration('test.single', () => {
  271. core.register({ name: 'test.list', id: 'from-listener' }, Comp)
  272. try {
  273. core.register({
  274. name: 'test.single',
  275. children: { 'test.list': { kind: 'list', scope: 'root' } },
  276. }, Comp as never)
  277. } catch (error) {
  278. duplicateDeclaration = error
  279. }
  280. })
  281. const disposeFrame = mountFrame(core)
  282. expect(core.entries('test.list')).toHaveLength(1)
  283. expect(String(duplicateDeclaration)).toContain('already declared')
  284. unsubscribe()
  285. disposeFrame()
  286. expect(core.specDynamic('test.list')).toBeUndefined()
  287. })
  288. it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
  289. const core = new SlotCore()
  290. mountFrame(core)
  291. await flushMicrotasks()
  292. const single = vi.fn()
  293. const list = vi.fn()
  294. core.subscribe('test.single', single)
  295. const unsubscribe = core.subscribe('test.list', list)
  296. core.register({ name: 'test.single' }, Comp)
  297. await flushMicrotasks()
  298. expect(single).toHaveBeenCalledTimes(1)
  299. expect(list).not.toHaveBeenCalled()
  300. unsubscribe()
  301. core.register({ name: 'test.list', id: 'a' }, Comp)
  302. await flushMicrotasks()
  303. expect(list).not.toHaveBeenCalled()
  304. })
  305. it('a mutation from inside a flush re-schedules instead of being lost', async () => {
  306. const core = new SlotCore()
  307. mountFrame(core)
  308. await flushMicrotasks()
  309. const seen: number[] = []
  310. let reentered = false
  311. core.subscribe('test.list', () => {
  312. seen.push(core.getVersion('test.list'))
  313. if (!reentered) {
  314. reentered = true
  315. core.register({ name: 'test.list', id: 'reentrant' }, Comp)
  316. }
  317. })
  318. core.register({ name: 'test.list', id: 'a' }, Comp)
  319. await flushMicrotasks()
  320. await flushMicrotasks()
  321. expect(seen).toHaveLength(2)
  322. expect(core.entries('test.list')).toHaveLength(2)
  323. })
  324. it('getVersion is 0 for untouched keys and monotonic across redeclaration', () => {
  325. const core = new SlotCore()
  326. expect(core.getVersion('test.single')).toBe(0)
  327. const dispose = mountFrame(core)
  328. dispose()
  329. const after = core.getVersion('test.single')
  330. mountFrame(core)
  331. expect(core.getVersion('test.single')).toBeGreaterThan(after)
  332. })
  333. it('onMutate fires synchronously per mutation with the touched key', () => {
  334. const core = new SlotCore()
  335. const keys: string[] = []
  336. const off = core.onMutate(key => keys.push(key))
  337. mountFrame(core)
  338. // Contribution first, then each declared child key.
  339. expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed', 'test.chain'])
  340. keys.length = 0
  341. core.register({ name: 'test.list', id: 'a' }, Comp)
  342. expect(keys).toEqual(['test.list'])
  343. off()
  344. core.register({ name: 'test.list', id: 'b' }, Comp)
  345. expect(keys).toHaveLength(1)
  346. })
  347. })