chat-store.spec.ts 3.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. // @vitest-environment jsdom
  2. /** Chat-store actions, scoped persistence, and instance isolation. */
  3. import { beforeEach, describe, expect, it } from 'vitest'
  4. import { createChatStore } from '../src/client/stores.ts'
  5. const KEY = 'dsh.conversation.chat'
  6. beforeEach(() => {
  7. localStorage.clear()
  8. })
  9. describe('createChatStore', () => {
  10. it('init shape: empty selection/draft/view', () => {
  11. const store = createChatStore().create()
  12. expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
  13. })
  14. it('actions cover the declared write set', () => {
  15. const store = createChatStore().create()
  16. store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
  17. expect(store.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
  18. store.actions.select(null)
  19. expect(store.store.getSnapshot().selection).toBeNull()
  20. store.actions.setDraft('hello')
  21. expect(store.store.getSnapshot().draft).toBe('hello')
  22. store.actions.clearDraft()
  23. expect(store.store.getSnapshot().draft).toBe('')
  24. store.actions.setView('chat')
  25. expect(store.store.getSnapshot().view).toBe('chat')
  26. })
  27. it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
  28. const store = createChatStore().create()
  29. // Rollback path: draft was cleared by send, nothing typed since.
  30. store.actions.restoreDraft('failed text')
  31. expect(store.store.getSnapshot().draft).toBe('failed text')
  32. // The user typed something new before the failure landed: keep theirs.
  33. store.actions.setDraft('newer input')
  34. store.actions.restoreDraft('stale text')
  35. expect(store.store.getSnapshot().draft).toBe('newer input')
  36. })
  37. it('persists per scope key and rehydrates a fresh instance', () => {
  38. const handle = createChatStore()
  39. const s1 = handle.create('sess-1')
  40. s1.actions.setDraft('draft for one')
  41. s1.actions.select({ turnSeq: 1 })
  42. // Scope-suffixed key: each session persists separately.
  43. expect(localStorage.getItem(`${KEY}.sess-1`)).not.toBeNull()
  44. expect(localStorage.getItem(`${KEY}.sess-2`)).toBeNull()
  45. // A rebuilt instance under the same scope key rehydrates the state.
  46. const again = createChatStore().create('sess-1')
  47. expect(again.store.getSnapshot().draft).toBe('draft for one')
  48. expect(again.store.getSnapshot().selection).toEqual({ turnSeq: 1 })
  49. // A sibling scope starts clean.
  50. const other = createChatStore().create('sess-2')
  51. expect(other.store.getSnapshot().draft).toBe('')
  52. })
  53. it('clearPersisted removes the scope entry (session-death cleanup hook)', () => {
  54. const store = createChatStore().create('sess-9')
  55. store.actions.setDraft('doomed')
  56. expect(localStorage.getItem(`${KEY}.sess-9`)).not.toBeNull()
  57. store.clearPersisted()
  58. expect(localStorage.getItem(`${KEY}.sess-9`)).toBeNull()
  59. })
  60. it('every create() is an independent instance; the factory holds no singleton', () => {
  61. const handle = createChatStore()
  62. const a = handle.create()
  63. const b = handle.create()
  64. a.actions.setDraft('only in a')
  65. expect(b.store.getSnapshot().draft).toBe('')
  66. // Two factory calls likewise share no LIVE state (identity is per handle
  67. // VALUE, not per module — the sharing contract lives in the framework's
  68. // handle x scope-key resolution, not in module state). Persistence is the
  69. // one sanctioned cross-instance channel: clear it so this assertion sees
  70. // memory identity, not rehydration (covered by the persist case above).
  71. localStorage.clear()
  72. const c = createChatStore().create()
  73. expect(c.store.getSnapshot().draft).toBe('')
  74. })
  75. })