store.spec.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/contract/store.ts'
  3. interface State {
  4. a: { n: number }
  5. b: { list: string[] }
  6. }
  7. const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } })
  8. afterEach(() => {
  9. vi.unstubAllGlobals()
  10. })
  11. describe('createSnapshotStore', () => {
  12. it('applies update through a draft and preserves untouched branch references', () => {
  13. const store = createSnapshotStore(init())
  14. const before = store.getSnapshot()
  15. store.update((d) => { d.a.n = 2 })
  16. const after = store.getSnapshot()
  17. expect(after).not.toBe(before)
  18. expect(after.a.n).toBe(2)
  19. expect(after.b).toBe(before.b)
  20. })
  21. it('notifies synchronously per update by default', () => {
  22. const store = createSnapshotStore(init())
  23. const seen: number[] = []
  24. store.subscribe(() => { seen.push(store.getSnapshot().a.n) })
  25. store.update((d) => { d.a.n = 2 })
  26. store.update((d) => { d.a.n = 3 })
  27. expect(seen).toEqual([2, 3])
  28. })
  29. it('coalesces a frame of updates into one notification in raf mode', () => {
  30. const frame: FrameRequestCallback[] = []
  31. vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
  32. frame.push(cb)
  33. return frame.length
  34. })
  35. const store = createSnapshotStore(init(), { flush: 'raf' })
  36. const spy = vi.fn()
  37. store.subscribe(spy)
  38. store.update((d) => { d.a.n = 2 })
  39. store.update((d) => { d.a.n = 3 })
  40. store.update((d) => { d.b.list.push('y') })
  41. expect(spy).not.toHaveBeenCalled()
  42. expect(frame).toHaveLength(1)
  43. frame.shift()!(0)
  44. expect(spy).toHaveBeenCalledTimes(1)
  45. expect(store.getSnapshot().a.n).toBe(3)
  46. // Next frame batches independently.
  47. store.update((d) => { d.a.n = 4 })
  48. expect(frame).toHaveLength(1)
  49. frame.shift()!(0)
  50. expect(spy).toHaveBeenCalledTimes(2)
  51. })
  52. it('falls back to microtask batching in raf mode without requestAnimationFrame', async () => {
  53. const store = createSnapshotStore(init(), { flush: 'raf' })
  54. const spy = vi.fn()
  55. store.subscribe(spy)
  56. store.update((d) => { d.a.n = 2 })
  57. store.update((d) => { d.a.n = 3 })
  58. expect(spy).not.toHaveBeenCalled()
  59. await Promise.resolve()
  60. expect(spy).toHaveBeenCalledTimes(1)
  61. })
  62. it('unsubscribes raf-mode listeners', () => {
  63. const frame: FrameRequestCallback[] = []
  64. vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
  65. frame.push(cb)
  66. return frame.length
  67. })
  68. const store = createSnapshotStore(init(), { flush: 'raf' })
  69. const spy = vi.fn()
  70. const off = store.subscribe(spy)
  71. store.update((d) => { d.a.n = 2 })
  72. off()
  73. frame.shift()!(0)
  74. expect(spy).not.toHaveBeenCalled()
  75. })
  76. it('replaces state wholesale via set and freezes it outside production', () => {
  77. const store = createSnapshotStore(init())
  78. const next = init()
  79. store.set(next)
  80. expect(store.getSnapshot()).toBe(next)
  81. expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
  82. })
  83. it('freezes update produce output outside production (immer dev freeze)', () => {
  84. const store = createSnapshotStore(init())
  85. store.update((d) => { d.a.n = 2 })
  86. expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
  87. })
  88. it('rehydrates primitive state whole, not spread into index keys', () => {
  89. const backing = new Map<string, string>()
  90. vi.stubGlobal('localStorage', {
  91. getItem: (k: string) => backing.get(k) ?? null,
  92. setItem: (k: string, v: string) => { backing.set(k, v) },
  93. removeItem: (k: string) => { backing.delete(k) },
  94. })
  95. const store = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
  96. store.set('hello')
  97. const revived = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
  98. expect(revived.getSnapshot()).toBe('hello')
  99. })
  100. it('persists to localStorage under the given name and rehydrates', () => {
  101. const backing = new Map<string, string>()
  102. vi.stubGlobal('localStorage', {
  103. getItem: (k: string) => backing.get(k) ?? null,
  104. setItem: (k: string, v: string) => { backing.set(k, v) },
  105. removeItem: (k: string) => { backing.delete(k) },
  106. })
  107. const store = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
  108. store.update((d) => { d.a.n = 42 })
  109. expect(backing.has('spec-store')).toBe(true)
  110. const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
  111. expect(revived.getSnapshot().a.n).toBe(42)
  112. })
  113. })
  114. describe('defineStore', () => {
  115. const declare = () => defineStore({
  116. init: () => ({ selection: null as string | null, draft: '' }),
  117. actions: {
  118. select: (d, target: string) => { d.selection = target },
  119. setDraft: (d, text: string) => { d.draft = text },
  120. clearDraft: (d) => { d.draft = '' },
  121. },
  122. })
  123. it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
  124. const inst = declare().create()
  125. expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
  126. inst.actions.setDraft('hello')
  127. inst.actions.select('m1')
  128. expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
  129. inst.actions.clearDraft()
  130. expect(inst.store.getSnapshot().draft).toBe('')
  131. })
  132. it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
  133. const inst = declare().create()
  134. const before = inst.store.getSnapshot()
  135. inst.actions.setDraft('x')
  136. const after = inst.store.getSnapshot()
  137. expect(after).not.toBe(before)
  138. expect(after.selection).toBe(before.selection) // untouched branch preserved (immer path)
  139. })
  140. it('creates independent instances per create() call (the handle is a spec, not a singleton)', () => {
  141. const handle = declare()
  142. const a = handle.create()
  143. const b = handle.create()
  144. a.actions.setDraft('only-a')
  145. expect(b.store.getSnapshot().draft).toBe('')
  146. })
  147. it('suffixes the persist key with the scope key: per-session persistence plus clearPersisted cleanup', () => {
  148. const backing = new Map<string, string>()
  149. vi.stubGlobal('localStorage', {
  150. getItem: (k: string) => backing.get(k) ?? null,
  151. setItem: (k: string, v: string) => { backing.set(k, v) },
  152. removeItem: (k: string) => { backing.delete(k) },
  153. })
  154. const handle = defineStore({
  155. init: () => ({ draft: '' }),
  156. persist: 'spec.chat',
  157. actions: { setDraft: (d, text: string) => { d.draft = text } },
  158. })
  159. handle.create('s1').actions.setDraft('one')
  160. handle.create('s2').actions.setDraft('two')
  161. handle.create().actions.setDraft('root')
  162. expect(JSON.parse(backing.get('spec.chat.s1')!)).toEqual({ draft: 'one' })
  163. expect(JSON.parse(backing.get('spec.chat.s2')!)).toEqual({ draft: 'two' })
  164. expect(JSON.parse(backing.get('spec.chat')!)).toEqual({ draft: 'root' })
  165. // Rehydration honors the same suffixed key.
  166. expect(handle.create('s1').store.getSnapshot().draft).toBe('one')
  167. // Scope-death cleanup removes exactly the suffixed key.
  168. handle.create('s1').clearPersisted()
  169. expect(backing.has('spec.chat.s1')).toBe(false)
  170. expect(backing.has('spec.chat.s2')).toBe(true)
  171. expect(backing.has('spec.chat')).toBe(true)
  172. })
  173. it('clearPersisted is a no-op without a persist declaration or without storage', () => {
  174. const inst = declare().create('s1') // no persist key declared
  175. expect(() => { inst.clearPersisted() }).not.toThrow()
  176. const persisting = defineStore({
  177. init: () => ({ n: 0 }),
  178. persist: 'spec.nostorage',
  179. actions: { inc: (d) => { d.n += 1 } },
  180. }).create()
  181. // jsdom-less lane: localStorage may exist here, so simulate its absence.
  182. vi.stubGlobal('localStorage', undefined)
  183. expect(() => { persisting.clearPersisted() }).not.toThrow()
  184. })
  185. it('swallows storage failures in clearPersisted (same non-fatal contract as persistence)', () => {
  186. vi.stubGlobal('localStorage', {
  187. getItem: () => null,
  188. setItem: () => {},
  189. removeItem: () => { throw new Error('quota / private mode') },
  190. })
  191. const inst = defineStore({
  192. init: () => ({ n: 0 }),
  193. persist: 'spec.throwing',
  194. actions: { inc: (d) => { d.n += 1 } },
  195. }).create()
  196. expect(() => { inst.clearPersisted() }).not.toThrow()
  197. })
  198. })
  199. describe('shallowEqual', () => {
  200. it('matches one-level-equal objects and rejects deeper drift', () => {
  201. const leaf = { deep: 1 }
  202. expect(shallowEqual({ x: 1, y: leaf }, { x: 1, y: leaf })).toBe(true)
  203. expect(shallowEqual({ x: 1, y: { deep: 1 } }, { x: 1, y: { deep: 1 } })).toBe(false)
  204. expect(shallowEqual([1, 2], [1, 2])).toBe(true)
  205. expect(shallowEqual([1, 2], [2, 1])).toBe(false)
  206. })
  207. })