store.spec.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import {
  4. AnonymousEntries,
  5. createScope,
  6. NamedEntries,
  7. ScopedLayers,
  8. type Scope,
  9. type ScopeKey,
  10. type ScopeLayer,
  11. } from '@deepseek-ai/dsh-scope'
  12. class TestLayer implements ScopeLayer {
  13. readonly named: NamedEntries<number>
  14. readonly anonymous = new AnonymousEntries<string>()
  15. constructor(scope: ScopeKey | undefined) {
  16. this.named = new NamedEntries(name =>
  17. new Error(`${scope === undefined ? 'global' : 'scoped'} duplicate: ${name}`))
  18. }
  19. isEmpty(): boolean {
  20. return this.named.isEmpty() && this.anonymous.isEmpty()
  21. }
  22. }
  23. /** Mint one active scope for lifecycle tests. */
  24. async function mintScope(ctx: Context, key: ScopeKey): Promise<Scope> {
  25. let scope!: Scope
  26. await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
  27. return scope
  28. }
  29. describe('NamedEntries', () => {
  30. it('owns duplicate diagnostics, lookup, insertion order, live iteration, and exact idempotent undo', () => {
  31. const duplicate = new Error('caller duplicate')
  32. const duplicateError = vi.fn(() => duplicate)
  33. const entries = new NamedEntries<number>(duplicateError)
  34. const undoA = entries.insert('a', 1)
  35. const values = entries.values()
  36. expect(values.next()).toEqual({ value: 1, done: false })
  37. const undoB = entries.insert('b', 2)
  38. expect([...values]).toEqual([2])
  39. expect([...entries.keys()]).toEqual(['a', 'b'])
  40. expect([...entries.entries()]).toEqual([['a', 1], ['b', 2]])
  41. expect(entries.get('a')).toBe(1)
  42. expect(entries.get('missing')).toBeUndefined()
  43. expect(entries.has('b')).toBe(true)
  44. expect(entries.has('missing')).toBe(false)
  45. expect(entries.isEmpty()).toBe(false)
  46. expect(() => entries.insert('a', 3)).toThrow(duplicate)
  47. expect(duplicateError).toHaveBeenCalledWith('a')
  48. undoA()
  49. entries.insert('a', 3)
  50. undoA()
  51. expect(entries.get('a')).toBe(3)
  52. undoB()
  53. expect([...entries.entries()]).toEqual([['a', 3]])
  54. })
  55. it('starts a fresh iterator generation after the table drains', () => {
  56. const entries = new NamedEntries<number>(name => new Error(`duplicate: ${name}`))
  57. const undo = entries.insert('first', 1)
  58. const values = entries.values()
  59. expect(values.next()).toEqual({ value: 1, done: false })
  60. undo()
  61. entries.insert('replacement', 2)
  62. expect(values.next().done).toBe(true)
  63. expect([...entries.values()]).toEqual([2])
  64. })
  65. })
  66. describe('AnonymousEntries', () => {
  67. it('owns equal values independently with live insertion-ordered iteration and idempotent undo', () => {
  68. const entries = new AnonymousEntries<object>()
  69. const value = {}
  70. const undoFirst = entries.append(value)
  71. const values = entries.values()
  72. expect(values.next()).toEqual({ value, done: false })
  73. const undoSecond = entries.append(value)
  74. expect([...values]).toEqual([value])
  75. expect([...entries.values()]).toEqual([value, value])
  76. undoFirst()
  77. undoFirst()
  78. expect([...entries.values()]).toEqual([value])
  79. undoSecond()
  80. expect(entries.isEmpty()).toBe(true)
  81. })
  82. it('starts a fresh iterator generation after the table drains', () => {
  83. const entries = new AnonymousEntries<number>()
  84. const undo = entries.append(1)
  85. const values = entries.values()
  86. expect(values.next()).toEqual({ value: 1, done: false })
  87. undo()
  88. entries.append(2)
  89. expect(values.next().done).toBe(true)
  90. expect([...entries.values()]).toEqual([2])
  91. })
  92. })
  93. describe('ScopedLayers', () => {
  94. it('constructs global state eagerly while reads stay non-creating and merge named shadows in order', () => {
  95. const created: Array<ScopeKey | undefined> = []
  96. const layers = new ScopedLayers(
  97. (scope) => {
  98. created.push(scope)
  99. return new TestLayer(scope)
  100. },
  101. vi.fn(),
  102. )
  103. const key = {}
  104. layers.global.named.insert('a', 1)
  105. layers.global.named.insert('shared', 2)
  106. expect(created).toEqual([undefined])
  107. expect(layers.peek(undefined)).toBeUndefined()
  108. expect(layers.peek(key)).toBeUndefined()
  109. expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2]])
  110. expect(created).toEqual([undefined])
  111. })
  112. it('uses the same scoped context for lazy visibility and ownership, and reclaims only an empty aggregate', async () => {
  113. const ctx = new Context()
  114. const key = {}
  115. const scope = await mintScope(ctx, key)
  116. const changed = vi.fn()
  117. const created: Array<ScopeKey | undefined> = []
  118. const layers = new ScopedLayers(
  119. (selected) => {
  120. created.push(selected)
  121. return new TestLayer(selected)
  122. },
  123. changed,
  124. )
  125. layers.global.named.insert('a', 1)
  126. layers.global.named.insert('shared', 1)
  127. const removeNamed = layers.effect(
  128. scope.ctx,
  129. layer => layer.named.insert('shared', 2),
  130. { label: 'test.named', notify: false },
  131. )
  132. const removeTail = layers.effect(
  133. scope.ctx,
  134. layer => layer.named.insert('c', 3),
  135. { label: 'test.tail', notify: false },
  136. )
  137. const removeAnonymous = layers.effect(
  138. scope.ctx,
  139. layer => layer.anonymous.append('kept'),
  140. { label: 'test.anonymous', notify: false },
  141. )
  142. expect(created).toEqual([undefined, key])
  143. expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2], ['c', 3]])
  144. expect(changed).not.toHaveBeenCalled()
  145. removeNamed()
  146. expect(layers.peek(key)).toBeDefined()
  147. expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 1], ['c', 3]])
  148. removeTail()
  149. expect(layers.peek(key)).toBeDefined()
  150. removeAnonymous()
  151. expect(layers.peek(key)).toBeUndefined()
  152. await scope.dispose()
  153. })
  154. it('runs action, notification, undo, and disposal notification in order with Cordis idempotence and labels', async () => {
  155. const ctx = new Context()
  156. const events: string[] = []
  157. const layers = new ScopedLayers(
  158. scope => new TestLayer(scope),
  159. () => void events.push('notify'),
  160. )
  161. const dispose = layers.effect(
  162. ctx,
  163. (layer) => {
  164. events.push('action')
  165. const undo = layer.named.insert('x', 1)
  166. return () => {
  167. events.push('undo')
  168. undo()
  169. }
  170. },
  171. { label: 'store.order' },
  172. )
  173. expect(events).toEqual(['action', 'notify'])
  174. expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain('store.order')
  175. dispose()
  176. dispose()
  177. expect(events).toEqual(['action', 'notify', 'undo', 'notify'])
  178. expect(layers.global.isEmpty()).toBe(true)
  179. })
  180. it('returns the exact context effect disposer', () => {
  181. const rawDispose = vi.fn()
  182. const effect = vi.fn(() => rawDispose)
  183. const ctx = { effect } as unknown as Context
  184. const action = vi.fn(() => vi.fn())
  185. const layers = new ScopedLayers(scope => new TestLayer(scope), vi.fn())
  186. const returned = layers.effect(ctx, action, { label: 'store.identity', notify: false })
  187. expect(returned).toBe(rawDispose)
  188. expect(effect).toHaveBeenCalledWith(expect.any(Function), 'store.identity')
  189. expect(action).not.toHaveBeenCalled()
  190. })
  191. it('cleans up failed factories and empty failed actions without discarding an existing layer', async () => {
  192. const ctx = new Context()
  193. const key = {}
  194. const scope = await mintScope(ctx, key)
  195. let failFactory = true
  196. const layers = new ScopedLayers(
  197. (selected) => {
  198. if (selected !== undefined && failFactory) throw new Error('factory failed')
  199. return new TestLayer(selected)
  200. },
  201. vi.fn(),
  202. )
  203. expect(() => layers.effect(
  204. scope.ctx,
  205. layer => layer.named.insert('never', 1),
  206. { label: 'store.factory', notify: false },
  207. )).toThrow('factory failed')
  208. expect(layers.peek(key)).toBeUndefined()
  209. failFactory = false
  210. expect(() => layers.effect(
  211. scope.ctx,
  212. () => { throw new Error('action failed') },
  213. { label: 'store.action', notify: false },
  214. )).toThrow('action failed')
  215. expect(layers.peek(key)).toBeUndefined()
  216. const dispose = layers.effect(
  217. scope.ctx,
  218. layer => layer.named.insert('kept', 1),
  219. { label: 'store.kept', notify: false },
  220. )
  221. expect(() => layers.effect(
  222. scope.ctx,
  223. () => { throw new Error('second action failed') },
  224. { label: 'store.existing-action', notify: false },
  225. )).toThrow('second action failed')
  226. expect(layers.peek(key)?.named.get('kept')).toBe(1)
  227. dispose()
  228. await scope.dispose()
  229. })
  230. it('rolls back a scoped insertion when notification throws', async () => {
  231. const ctx = new Context()
  232. const key = {}
  233. const scope = await mintScope(ctx, key)
  234. const events: string[] = []
  235. let notifications = 0
  236. const layers = new ScopedLayers(
  237. selected => new TestLayer(selected),
  238. () => {
  239. events.push('notify')
  240. if (++notifications === 1) throw new Error('change failed')
  241. },
  242. )
  243. expect(() => layers.effect(
  244. scope.ctx,
  245. (layer) => {
  246. const undo = layer.named.insert('rollback', 1)
  247. return () => {
  248. events.push('undo')
  249. undo()
  250. }
  251. },
  252. { label: 'store.rollback' },
  253. )).toThrow('change failed')
  254. expect(events).toEqual(['notify', 'undo', 'notify'])
  255. expect(layers.peek(key)).toBeUndefined()
  256. await scope.dispose()
  257. })
  258. })