core.spec.ts 14 KB

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