activation-source.client.spec.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  3. import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
  4. import type { GoalActivationChanged, GoalId, GoalProjection, GoalView } from '@deepseek-ai/dsh-goal/client'
  5. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  6. import { createGoalActivationSource } from '../src/client/activation-source.ts'
  7. const GOAL_ID = 'g-1' as GoalId
  8. function projection(): GoalProjection {
  9. return {
  10. goal: {
  11. id: GOAL_ID,
  12. revision: 1,
  13. objective: 'ship it',
  14. phase: 'active',
  15. maxGoalRounds: 8,
  16. },
  17. roundsStarted: 0,
  18. createdAt: 1,
  19. updatedAt: 1,
  20. }
  21. }
  22. function goalView(activation: 'armed' | 'disarmed'): GoalView {
  23. return { ...projection().goal, roundsStarted: 0, createdAt: 1, updatedAt: 1, activation }
  24. }
  25. describe('goal activation source', () => {
  26. it('does not let a stale read overwrite a later activation event', async () => {
  27. const projectionStore = createSnapshotStore<GoalProjection | null | undefined>(projection())
  28. const session = createSnapshotStore({ running: false })
  29. let resolveRead!: (value: RemoteResult<GoalView | undefined>) => void
  30. const getGoal = vi.fn(() => new Promise<RemoteResult<GoalView | undefined>>((resolve) => {
  31. resolveRead = resolve
  32. }))
  33. let activationListener: ((goal: GoalActivationChanged['goal']) => void) | undefined
  34. const source = createGoalActivationSource({
  35. projection: projectionStore,
  36. session,
  37. getGoal,
  38. subscribeActivation: (listener) => {
  39. activationListener = listener
  40. return () => { activationListener = undefined }
  41. },
  42. subscribeReset: () => () => {},
  43. })
  44. const dispose = source.subscribe(() => {})
  45. activationListener?.({ id: GOAL_ID, revision: 1, activation: 'disarmed' })
  46. resolveRead({ ok: true, value: goalView('armed') })
  47. await Promise.resolve()
  48. expect(source.getSnapshot()).toMatchObject({ id: 'g-1', revision: 1, activation: 'disarmed' })
  49. dispose()
  50. })
  51. it('refreshes on the running edge without clearing the last activation', async () => {
  52. const projectionStore = createSnapshotStore<GoalProjection | null | undefined>(projection())
  53. const session = createSnapshotStore({ running: false })
  54. const getGoal = vi.fn()
  55. .mockResolvedValueOnce({ ok: true as const, value: goalView('disarmed') })
  56. .mockImplementationOnce(() => new Promise<RemoteResult<GoalView | undefined>>(() => {}))
  57. const source = createGoalActivationSource({
  58. projection: projectionStore,
  59. session,
  60. getGoal,
  61. subscribeActivation: () => () => {},
  62. subscribeReset: () => () => {},
  63. })
  64. const dispose = source.subscribe(() => {})
  65. await Promise.resolve()
  66. await Promise.resolve()
  67. expect(source.getSnapshot().activation).toBe('disarmed')
  68. session.set({ running: true })
  69. expect(source.getSnapshot().activation).toBe('disarmed')
  70. dispose()
  71. })
  72. it('merges lifecycle edges and releases subscriptions with the last observer', async () => {
  73. const projectionStore = createSnapshotStore<GoalProjection | null | undefined>(projection())
  74. const session = createSnapshotStore({ running: false })
  75. const getGoal = vi.fn(() => Promise.resolve({
  76. ok: true as const,
  77. value: goalView('armed'),
  78. }))
  79. let emitActivation: ((goal: GoalActivationChanged['goal']) => void) | undefined
  80. let emitReset: (() => void) | undefined
  81. const disposeProjection: Array<() => void> = []
  82. const disposeSession: Array<() => void> = []
  83. const source = createGoalActivationSource({
  84. projection: {
  85. getSnapshot: () => projectionStore.getSnapshot(),
  86. subscribe: (listener) => {
  87. disposeProjection.push(listener)
  88. return projectionStore.subscribe(listener)
  89. },
  90. },
  91. session: {
  92. getSnapshot: () => session.getSnapshot(),
  93. subscribe: (listener) => {
  94. disposeSession.push(listener)
  95. return session.subscribe(listener)
  96. },
  97. },
  98. getGoal,
  99. subscribeActivation: (listener) => {
  100. emitActivation = listener
  101. return () => { emitActivation = undefined }
  102. },
  103. subscribeReset: (listener) => {
  104. emitReset = listener
  105. return () => { emitReset = undefined }
  106. },
  107. })
  108. const first = source.subscribe(() => {})
  109. const second = source.subscribe(() => {})
  110. emitActivation?.({ id: GOAL_ID, revision: 1, activation: 'disarmed' })
  111. emitActivation?.({ id: GOAL_ID, revision: 1, activation: 'disarmed' })
  112. emitActivation?.(undefined)
  113. emitActivation?.({ id: GOAL_ID, revision: 1, activation: 'armed' })
  114. projectionStore.set(null)
  115. session.set({ running: false })
  116. session.set({ running: true })
  117. await Promise.resolve()
  118. emitReset?.()
  119. await Promise.resolve()
  120. projectionStore.set(projection())
  121. await Promise.resolve()
  122. expect(source.getSnapshot()).toMatchObject({ id: 'g-1', revision: 1, activation: 'armed' })
  123. expect(disposeProjection.length).toBeGreaterThan(0)
  124. expect(disposeSession.length).toBeGreaterThan(0)
  125. first()
  126. expect(emitActivation).toBeDefined()
  127. second()
  128. expect(emitActivation).toBeUndefined()
  129. expect(emitReset).toBeUndefined()
  130. })
  131. it('leaves the ref unarmed when an authoritative read fails', async () => {
  132. const projectionStore = createSnapshotStore<GoalProjection | null | undefined>(projection())
  133. const session = createSnapshotStore({ running: false })
  134. const source = createGoalActivationSource({
  135. projection: projectionStore,
  136. session,
  137. getGoal: () => Promise.resolve({
  138. ok: false,
  139. error: new RemoteError('gateway/internal', 'no', {}),
  140. }),
  141. subscribeActivation: () => () => {},
  142. subscribeReset: () => () => {},
  143. })
  144. const dispose = source.subscribe(() => {})
  145. await Promise.resolve()
  146. await Promise.resolve()
  147. expect(source.getSnapshot()).toMatchObject({ id: 'g-1', revision: 1 })
  148. expect(source.getSnapshot().activation).toBeUndefined()
  149. dispose()
  150. })
  151. it('does not republish an unchanged active ref or an already-empty projection', async () => {
  152. const emptyProjection = createSnapshotStore<GoalProjection | null | undefined>(null)
  153. const emptySession = createSnapshotStore({ running: false })
  154. const emptySource = createGoalActivationSource({
  155. projection: emptyProjection,
  156. session: emptySession,
  157. getGoal: () => Promise.resolve({ ok: true, value: undefined }),
  158. subscribeActivation: () => () => {},
  159. subscribeReset: () => () => {},
  160. })
  161. const emptyDispose = emptySource.subscribe(() => {})
  162. expect(emptySource.getSnapshot()).toEqual({})
  163. emptyDispose()
  164. const projectionStore = createSnapshotStore<GoalProjection | null | undefined>(projection())
  165. const session = createSnapshotStore({ running: false })
  166. const source = createGoalActivationSource({
  167. projection: projectionStore,
  168. session,
  169. getGoal: () => Promise.resolve({ ok: true, value: goalView('armed') }),
  170. subscribeActivation: () => () => {},
  171. subscribeReset: () => () => {},
  172. })
  173. const dispose = source.subscribe(() => {})
  174. await Promise.resolve()
  175. await Promise.resolve()
  176. let notifications = 0
  177. const disposeObserver = source.subscribe(() => { notifications++ })
  178. projectionStore.set(projection())
  179. await Promise.resolve()
  180. expect(notifications).toBe(0)
  181. expect(source.getSnapshot()).toMatchObject({ id: 'g-1', revision: 1, activation: 'armed' })
  182. disposeObserver()
  183. dispose()
  184. })
  185. })