store.spec.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { createSnapshotStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
  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('shallowEqual', () => {
  115. it('matches one-level-equal objects and rejects deeper drift', () => {
  116. const leaf = { deep: 1 }
  117. expect(shallowEqual({ x: 1, y: leaf }, { x: 1, y: leaf })).toBe(true)
  118. expect(shallowEqual({ x: 1, y: { deep: 1 } }, { x: 1, y: { deep: 1 } })).toBe(false)
  119. expect(shallowEqual([1, 2], [1, 2])).toBe(true)
  120. expect(shallowEqual([1, 2], [2, 1])).toBe(false)
  121. })
  122. })