core.client.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  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('publishes Factory child mutations only after every sibling declaration is installed', () => {
  73. const core = new SlotCore()
  74. const observed: unknown[] = []
  75. core.onMutate((key) => {
  76. if (key === 'test.single') observed.push(core.specDynamic('test.session'))
  77. })
  78. const registerFactory = core.registerFactory as unknown as (
  79. options: object,
  80. component: unknown,
  81. ) => () => void
  82. registerFactory({
  83. name: 'test.atomic-factory',
  84. scope: 'root',
  85. children: {
  86. 'test.single': { kind: 'single', scope: 'root' },
  87. 'test.session': { kind: 'single', scope: 'session' },
  88. },
  89. }, Comp)
  90. expect(observed).toEqual([{ kind: 'single', scope: 'session' }])
  91. })
  92. it('disposing a declaring entry collapses child slots and their contributions recursively', () => {
  93. const core = new SlotCore()
  94. const disposeFrame = mountFrame(core)
  95. const disposeChild = core.register(
  96. { name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
  97. core.register({ name: 'test.grandchild' }, Comp)
  98. expect(core.entries('test.grandchild')).toHaveLength(1)
  99. disposeFrame()
  100. expect(core.specDynamic('test.single')).toBeUndefined()
  101. expect(core.specDynamic('test.grandchild')).toBeUndefined()
  102. expect(core.entries('test.single')).toHaveLength(0)
  103. expect(core.entries('test.grandchild')).toHaveLength(0)
  104. // Stale disposer of a cascaded-away entry is a no-op.
  105. expect(() => { disposeChild() }).not.toThrow()
  106. // Slots return to undeclared: contributing again throws until redeclared.
  107. expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
  108. })
  109. it('registration disposers are idempotent', () => {
  110. const core = new SlotCore()
  111. const dispose = mountFrame(core)
  112. dispose()
  113. dispose()
  114. expect(core.entries('root')).toHaveLength(0)
  115. // Redeclare works after collapse.
  116. mountFrame(core)
  117. expect(core.specDynamic('test.single')).toBeDefined()
  118. })
  119. it('isLive tracks ledger membership across dispose', () => {
  120. const core = new SlotCore()
  121. mountFrame(core)
  122. const dispose = core.register({ name: 'test.single' }, Comp)
  123. const entry = core.entries('test.single')[0]!
  124. expect(core.isLive(entry)).toBe(true)
  125. dispose()
  126. expect(core.isLive(entry)).toBe(false)
  127. })
  128. })
  129. describe('kind semantics', () => {
  130. it('keyed: duplicate key throws, missing key throws', () => {
  131. const core = new SlotCore()
  132. mountFrame(core)
  133. core.register({ name: 'test.keyed', key: 'a' }, Comp)
  134. expect(() => core.register({ name: 'test.keyed', key: 'a' }, Comp)).toThrow('key "a"')
  135. // Statically rejected (KindOptions); runtime guard stays for dynamic callers.
  136. // @ts-expect-error keyed registration requires options.key
  137. expect(() => core.register({ name: 'test.keyed' }, Comp)).toThrow('requires options.key')
  138. expect(() => core.register({ name: 'test.keyed', key: 'b' }, Comp)).not.toThrow()
  139. })
  140. it('list: duplicate id throws, missing id throws, entries sort by order stably', () => {
  141. const core = new SlotCore()
  142. mountFrame(core)
  143. core.register({ name: 'test.list', id: 'c', order: 10 }, Comp)
  144. core.register({ name: 'test.list', id: 'a' }, Comp)
  145. core.register({ name: 'test.list', id: 'b' }, Comp)
  146. expect(() => core.register({ name: 'test.list', id: 'a' }, Comp)).toThrow('id "a"')
  147. // @ts-expect-error list registration requires options.id
  148. expect(() => core.register({ name: 'test.list' }, Comp)).toThrow('requires options.id')
  149. expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
  150. })
  151. it('chain: missing select throws; select and priority land on the stored entry', () => {
  152. const core = new SlotCore()
  153. mountFrame(core)
  154. // Statically rejected (KindOptions); runtime guard stays for dynamic callers.
  155. // @ts-expect-error chain registration requires options.select
  156. expect(() => core.register({ name: 'test.chain' }, Comp)).toThrow('requires options.select')
  157. const select = ({ tags }: { tags: string[] }) => tags[0] ?? null
  158. core.register({ name: 'test.chain', select, priority: 5 }, Comp as never)
  159. const entry = core.entries('test.chain')[0]!
  160. expect(entry.select).toBe(select)
  161. expect(entry.options.priority).toBe(5)
  162. })
  163. it('chain: entries sort by priority ascending, ties keep registration order', () => {
  164. const core = new SlotCore()
  165. mountFrame(core)
  166. const sel = () => null
  167. core.register({ name: 'test.chain', select: sel, priority: 10, registrant: 'late' }, Comp as never)
  168. core.register({ name: 'test.chain', select: sel, registrant: 'default-a' }, Comp as never)
  169. core.register({ name: 'test.chain', select: sel, registrant: 'default-b' }, Comp as never)
  170. core.register({ name: 'test.chain', select: sel, priority: -1, registrant: 'first' }, Comp as never)
  171. expect(core.entries('test.chain').map(e => e.registrant))
  172. .toEqual(['first', 'default-a', 'default-b', 'late'])
  173. })
  174. it('single: second registration throws, disposer frees the seat', () => {
  175. const core = new SlotCore()
  176. mountFrame(core)
  177. const dispose = core.register({ name: 'test.single' }, Comp)
  178. expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('already has a registration')
  179. dispose()
  180. expect(core.entries('test.single')).toHaveLength(0)
  181. expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
  182. })
  183. })
  184. describe('store scope pinning', () => {
  185. it('one shared handle under two scopes throws at load', () => {
  186. const core = new SlotCore()
  187. mountFrame(core)
  188. const handle = fakeHandle()
  189. core.register({ name: 'test.session', store: handle }, Comp as never)
  190. expect(() => core.register({ name: 'test.single', store: handle }, Comp as never))
  191. .toThrow('one handle, one scope')
  192. })
  193. it('same handle under same scope is fine; full unmount releases the pin', () => {
  194. const core = new SlotCore()
  195. mountFrame(core)
  196. const handle = fakeHandle()
  197. const d1 = core.register({ name: 'test.list', id: 'x', store: handle }, Comp as never)
  198. const d2 = core.register({ name: 'test.list', id: 'y', store: handle }, Comp as never)
  199. d1()
  200. // Still mounted once — scope stays pinned.
  201. expect(() => core.register({ name: 'test.session', store: handle }, Comp as never))
  202. .toThrow('one handle, one scope')
  203. d2()
  204. // All mounts gone: the handle may pin a new scope.
  205. expect(() => core.register({ name: 'test.session', store: handle }, Comp as never)).not.toThrow()
  206. })
  207. it('factories are exempt from pinning (no shared identity)', () => {
  208. const core = new SlotCore()
  209. mountFrame(core)
  210. const factory = () => fakeHandle()
  211. core.register({ name: 'test.session', store: factory }, Comp as never)
  212. expect(() => core.register({ name: 'test.single', store: factory }, Comp as never)).not.toThrow()
  213. })
  214. it('cascade releases store pins of collapsed child entries', () => {
  215. const core = new SlotCore()
  216. const disposeFrame = mountFrame(core)
  217. const handle = fakeHandle()
  218. core.register({ name: 'test.session', store: handle }, Comp as never)
  219. disposeFrame()
  220. mountFrame(core)
  221. expect(() => core.register({ name: 'test.single', store: handle }, Comp as never)).not.toThrow()
  222. })
  223. })
  224. describe('subscription API', () => {
  225. it('tracks declaration epochs separately from ordinary entry mutations', () => {
  226. const core = new SlotCore()
  227. expect(core.declarationEpoch('root')).toBe(1)
  228. expect(core.declarationEpoch('test.list')).toBe(0)
  229. const disposeFrame = mountFrame(core)
  230. const declared = core.declarationEpoch('test.list')
  231. expect(declared).toBe(1)
  232. const disposeEntry = core.register({ name: 'test.list', id: 'a' }, Comp)
  233. disposeEntry()
  234. expect(core.declarationEpoch('test.list')).toBe(declared)
  235. disposeFrame()
  236. expect(core.declarationEpoch('test.list')).toBe(declared + 1)
  237. mountFrame(core)
  238. expect(core.declarationEpoch('test.list')).toBe(declared + 2)
  239. })
  240. it('entries() returns a stable cached reference between mutations', () => {
  241. const core = new SlotCore()
  242. mountFrame(core)
  243. core.register({ name: 'test.list', id: 'a' }, Comp)
  244. const first = core.entries('test.list')
  245. expect(core.entries('test.list')).toBe(first)
  246. core.register({ name: 'test.list', id: 'b' }, Comp)
  247. expect(core.entries('test.list')).not.toBe(first)
  248. })
  249. it('bumps version synchronously but batches notifications per microtask', async () => {
  250. const core = new SlotCore()
  251. mountFrame(core)
  252. const fn = vi.fn()
  253. core.subscribe('test.list', fn)
  254. const before = core.getVersion('test.list')
  255. core.register({ name: 'test.list', id: 'a' }, Comp)
  256. core.register({ name: 'test.list', id: 'b' }, Comp)
  257. expect(core.getVersion('test.list')).toBe(before + 2)
  258. expect(fn).not.toHaveBeenCalled()
  259. await flushMicrotasks()
  260. expect(fn).toHaveBeenCalledTimes(1)
  261. core.register({ name: 'test.list', id: 'c' }, Comp)
  262. await flushMicrotasks()
  263. expect(fn).toHaveBeenCalledTimes(2)
  264. })
  265. it('declaration itself notifies child-key subscribers (subscribe-ahead allowed)', async () => {
  266. const core = new SlotCore()
  267. const fn = vi.fn()
  268. core.subscribe('test.single', fn)
  269. mountFrame(core)
  270. await flushMicrotasks()
  271. expect(fn).toHaveBeenCalledTimes(1)
  272. })
  273. it('notifies declaration subscribers synchronously, excluding entries, until unsubscribe', () => {
  274. const core = new SlotCore()
  275. const fn = vi.fn()
  276. const unsubscribe = core.subscribeDeclaration('test.list', fn)
  277. const disposeFrame = mountFrame(core)
  278. expect(fn).toHaveBeenCalledTimes(1)
  279. core.register({ name: 'test.list', id: 'ordinary' }, Comp)
  280. expect(fn).toHaveBeenCalledTimes(1)
  281. disposeFrame()
  282. expect(fn).toHaveBeenCalledTimes(2)
  283. unsubscribe()
  284. mountFrame(core)
  285. expect(fn).toHaveBeenCalledTimes(2)
  286. })
  287. it('commits sibling declarations before notifying declaration subscribers', () => {
  288. const core = new SlotCore()
  289. let duplicateDeclaration: unknown
  290. const unsubscribe = core.subscribeDeclaration('test.single', () => {
  291. core.register({ name: 'test.list', id: 'from-listener' }, Comp)
  292. try {
  293. core.register({
  294. name: 'test.single',
  295. children: { 'test.list': { kind: 'list', scope: 'root' } },
  296. }, Comp as never)
  297. } catch (error) {
  298. duplicateDeclaration = error
  299. }
  300. })
  301. const disposeFrame = mountFrame(core)
  302. expect(core.entries('test.list')).toHaveLength(1)
  303. expect(String(duplicateDeclaration)).toContain('already declared')
  304. unsubscribe()
  305. disposeFrame()
  306. expect(core.specDynamic('test.list')).toBeUndefined()
  307. })
  308. it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
  309. const core = new SlotCore()
  310. mountFrame(core)
  311. await flushMicrotasks()
  312. const single = vi.fn()
  313. const list = vi.fn()
  314. core.subscribe('test.single', single)
  315. const unsubscribe = core.subscribe('test.list', list)
  316. core.register({ name: 'test.single' }, Comp)
  317. await flushMicrotasks()
  318. expect(single).toHaveBeenCalledTimes(1)
  319. expect(list).not.toHaveBeenCalled()
  320. unsubscribe()
  321. core.register({ name: 'test.list', id: 'a' }, Comp)
  322. await flushMicrotasks()
  323. expect(list).not.toHaveBeenCalled()
  324. })
  325. it('a mutation from inside a flush re-schedules instead of being lost', async () => {
  326. const core = new SlotCore()
  327. mountFrame(core)
  328. await flushMicrotasks()
  329. const seen: number[] = []
  330. let reentered = false
  331. core.subscribe('test.list', () => {
  332. seen.push(core.getVersion('test.list'))
  333. if (!reentered) {
  334. reentered = true
  335. core.register({ name: 'test.list', id: 'reentrant' }, Comp)
  336. }
  337. })
  338. core.register({ name: 'test.list', id: 'a' }, Comp)
  339. await flushMicrotasks()
  340. await flushMicrotasks()
  341. expect(seen).toHaveLength(2)
  342. expect(core.entries('test.list')).toHaveLength(2)
  343. })
  344. it('getVersion is 0 for untouched keys and monotonic across redeclaration', () => {
  345. const core = new SlotCore()
  346. expect(core.getVersion('test.single')).toBe(0)
  347. const dispose = mountFrame(core)
  348. dispose()
  349. const after = core.getVersion('test.single')
  350. mountFrame(core)
  351. expect(core.getVersion('test.single')).toBeGreaterThan(after)
  352. })
  353. it('onMutate fires synchronously per mutation with the touched key', () => {
  354. const core = new SlotCore()
  355. const keys: string[] = []
  356. const off = core.onMutate(key => keys.push(key))
  357. mountFrame(core)
  358. // Contribution first, then each declared child key.
  359. expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed', 'test.chain'])
  360. keys.length = 0
  361. core.register({ name: 'test.list', id: 'a' }, Comp)
  362. expect(keys).toEqual(['test.list'])
  363. off()
  364. core.register({ name: 'test.list', id: 'b' }, Comp)
  365. expect(keys).toHaveLength(1)
  366. })
  367. })