Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / docs-shared / lib / Doc / DocState.spec.ts
blob54fde8c49a4db145b21b08fc82a2456bfe213014
1 import type { LoggerInterface } from '@proton/utils/logs'
2 import { DocState, DocUpdateOrigin, PRESENCE_UPDATE_REPEAT_INTERVAL } from './DocState'
3 import type { DocStateCallbacks } from './DocStateCallbacks'
4 import type { DocsUserState } from './DocsAwareness'
5 import { BroadcastSource } from '../Bridge/BroadcastSource'
6 import { decodeUpdate } from 'yjs'
8 const EmptyAwarenessUpdate = {
9   added: [],
10   updated: [],
11   removed: [],
14 jest.mock('yjs', () => ({
15   ...jest.requireActual('yjs'),
16   decodeUpdate: jest.fn(),
17   mergeUpdates: jest.fn(),
18 }))
19 ;(decodeUpdate as jest.Mock).mockImplementation(() => ({
20   structs: [{ id: { clock: 0 } }],
21 }))
23 describe('DocState', () => {
24   let state: DocState
26   beforeEach(() => {
27     jest.useFakeTimers()
28     state = new DocState(
29       {
30         handleAwarenessStateUpdate: jest.fn(),
31         docStateRequestsPropagationOfUpdate: jest.fn(),
32       } as unknown as DocStateCallbacks,
33       {
34         debug: jest.fn(),
35         info: jest.fn(),
36       } as unknown as LoggerInterface,
37     )
38   })
40   afterEach(() => {
41     state.destroy()
42     jest.clearAllMocks()
43     jest.useRealTimers()
44   })
46   describe('broadcastPresenceInterval', () => {
47     it('should broadcast awareness state if it needs an update', () => {
48       state.broadcastCurrentAwarenessState = jest.fn()
50       state.needsPresenceBroadcast = BroadcastSource.AwarenessInterval
52       jest.advanceTimersByTime(PRESENCE_UPDATE_REPEAT_INTERVAL + 1)
54       expect(state.broadcastCurrentAwarenessState).toHaveBeenCalled()
55     })
57     it('should not broadcast awareness state if it needs an update', () => {
58       state.broadcastCurrentAwarenessState = jest.fn()
60       jest.advanceTimersByTime(PRESENCE_UPDATE_REPEAT_INTERVAL + 1)
62       expect(state.broadcastCurrentAwarenessState).not.toHaveBeenCalled()
63     })
64   })
66   describe('consumeIsInConversionFromOtherFormat', () => {
67     it('should consume the conversion', () => {
68       state.isInConversionFromOtherFormatFlow = true
70       const value = state.consumeIsInConversionFromOtherFormat()
72       expect(value).toBe(true)
73       expect(state.isInConversionFromOtherFormatFlow).toBe(false)
74     })
75   })
77   describe('handleAwarenessUpdateOrChange', () => {
78     it('should invoke removeDuplicateClients', () => {
79       const removeDuplicateClientsSpy = jest.spyOn(state.awareness, 'removeDuplicateClients')
81       state.handleAwarenessUpdateOrChange(EmptyAwarenessUpdate, {})
83       expect(removeDuplicateClientsSpy).toHaveBeenCalled()
84     })
86     it('should notify callbacks about the change', () => {
87       const callbackSpy = jest.spyOn(state.callbacks, 'handleAwarenessStateUpdate')
89       state.handleAwarenessUpdateOrChange(EmptyAwarenessUpdate, {})
91       expect(callbackSpy).toHaveBeenCalled()
92     })
94     it('should not broadcast awareness state if there is no change', () => {
95       state.lastEmittedClients = [1]
96       state.lastEmittedMyState = { name: 'user1', awarenessData: {} } as DocsUserState
97       state.doc.clientID = 1
99       const broadcastCurrentAwarenessStateSpy = jest.spyOn(state, 'broadcastCurrentAwarenessState')
101       state.awareness.getClientIds = jest.fn(() => [1])
102       state.awareness.getStates = jest.fn(() => new Map([[1, { name: 'user1', awarenessData: {} } as DocsUserState]]))
103       state.awareness.meta = new Map([[1, { lastUpdated: 0, clock: 0 }]])
105       state.handleAwarenessUpdateOrChange(
106         {
107           added: [],
108           updated: [],
109           removed: [],
110         },
111         {},
112       )
114       expect(broadcastCurrentAwarenessStateSpy).not.toHaveBeenCalled()
115     })
117     it('should broadcast awareness state if there is a change', () => {
118       state.lastEmittedClients = [1, 2]
119       state.lastEmittedMyState = { name: 'user1', awarenessData: {} } as DocsUserState
120       state.doc.clientID = 1
122       const spy = jest.spyOn(state, 'setNeedsBroadcastCurrentAwarenessState')
124       state.awareness.getClientIds = jest.fn(() => [1, 2])
125       state.awareness.getStates = jest.fn(
126         () =>
127           new Map([
128             [
129               1,
130               {
131                 name: 'user1',
132                 awarenessData: {
133                   foo: 'bar',
134                 },
135               } as DocsUserState,
136             ],
137             [2, { name: 'user2', awarenessData: {} } as DocsUserState],
138           ]),
139       )
140       state.awareness.meta = new Map([
141         [1, { lastUpdated: 0, clock: 0 }],
142         [2, { lastUpdated: 0, clock: 0 }],
143       ])
145       state.handleAwarenessUpdateOrChange(
146         {
147           added: [],
148           updated: [1],
149           removed: [],
150         },
151         {},
152       )
154       expect(spy).toHaveBeenCalled()
155     })
157     it("should not broadcast awareness state if the change is someone else's", () => {
158       state.lastEmittedClients = [1, 2]
159       state.lastEmittedMyState = { name: 'user1', awarenessData: {} } as DocsUserState
160       state.doc.clientID = 1
162       const spy = jest.spyOn(state, 'setNeedsBroadcastCurrentAwarenessState')
164       state.awareness.getClientIds = jest.fn(() => [1, 2])
165       state.awareness.getStates = jest.fn(
166         () =>
167           new Map([
168             [
169               1,
170               {
171                 name: 'user1',
172                 awarenessData: {
173                   foo: 'bar',
174                 },
175               } as DocsUserState,
176             ],
177             [2, { name: 'user2', awarenessData: {} } as DocsUserState],
178           ]),
179       )
180       state.awareness.meta = new Map([
181         [1, { lastUpdated: 0, clock: 0 }],
182         [2, { lastUpdated: 0, clock: 0 }],
183       ])
185       state.handleAwarenessUpdateOrChange(
186         {
187           added: [],
188           updated: [2],
189           removed: [],
190         },
191         {},
192       )
194       expect(spy).not.toHaveBeenCalled()
195     })
197     it('should always broadcast if origin is local regardless of whether there are changes', () => {
198       state.lastEmittedClients = [1]
199       state.lastEmittedMyState = { name: 'user1', awarenessData: {} } as DocsUserState
200       state.doc.clientID = 1
202       const spy = jest.spyOn(state, 'setNeedsBroadcastCurrentAwarenessState')
204       state.awareness.getClientIds = jest.fn(() => [1])
205       state.awareness.getStates = jest.fn(() => new Map([[1, { name: 'user1', awarenessData: {} } as DocsUserState]]))
206       state.awareness.meta = new Map([[1, { lastUpdated: 0, clock: 0 }]])
208       state.handleAwarenessUpdateOrChange(
209         {
210           added: [],
211           updated: [1],
212           removed: [],
213         },
214         'local',
215       )
217       expect(spy).toHaveBeenCalled()
218     })
219   })
221   describe('setNeedsBroadcastCurrentAwarenessState', () => {
222     it('should bypass debouncing if the source is ExternalCallerRequestingUsToBroadcastOurState', () => {
223       state.broadcastCurrentAwarenessState = jest.fn()
225       state.setNeedsBroadcastCurrentAwarenessState(BroadcastSource.ExternalCallerRequestingUsToBroadcastOurState)
227       expect(state.broadcastCurrentAwarenessState).toHaveBeenCalled()
228     })
230     it('should not bypass debouncing if the source is not ExternalCallerRequestingUsToBroadcastOurState', () => {
231       state.broadcastCurrentAwarenessState = jest.fn()
233       state.setNeedsBroadcastCurrentAwarenessState(BroadcastSource.AwarenessUpdateHandler)
235       expect(state.broadcastCurrentAwarenessState).not.toHaveBeenCalled()
236     })
237   })
239   describe('handleDocBeingUpdatedByLexical', () => {
240     /** A self origin indicates the change was made through internal shuffling, rather than by the user editing directly */
241     it('should abort if origin is self', () => {
242       const docStateRequestsPropagationOfUpdateSpy = jest.spyOn(state.callbacks, 'docStateRequestsPropagationOfUpdate')
244       state.handleDocBeingUpdatedByLexical(new Uint8Array(), state)
246       expect(docStateRequestsPropagationOfUpdateSpy).not.toHaveBeenCalled()
247     })
249     /** InitialLoad origin is when the document is initially populated; we don't want to trigger a broadcast event with this data */
250     it('should abort if origin is InitialLoad', () => {
251       const docStateRequestsPropagationOfUpdateSpy = jest.spyOn(state.callbacks, 'docStateRequestsPropagationOfUpdate')
253       state.handleDocBeingUpdatedByLexical(new Uint8Array(), DocUpdateOrigin.InitialLoad)
255       expect(docStateRequestsPropagationOfUpdateSpy).not.toHaveBeenCalled()
256     })
258     it('should hold back editor initialization update and merge with the next update', () => {
259       const docStateRequestsPropagationOfUpdateSpy = jest.spyOn(state.callbacks, 'docStateRequestsPropagationOfUpdate')
261       state.docWasInitializedWithEmptyNode = true
262       ;(decodeUpdate as jest.Mock).mockImplementationOnce(() => ({
263         structs: [{ id: { clock: 0 } }],
264       }))
266       state.handleDocBeingUpdatedByLexical(new Uint8Array(), {})
268       expect(docStateRequestsPropagationOfUpdateSpy).not.toHaveBeenCalled()
269       ;(decodeUpdate as jest.Mock).mockImplementationOnce(() => ({
270         structs: [{ id: { clock: 1 } }],
271       }))
273       state.handleDocBeingUpdatedByLexical(new Uint8Array(), {})
275       expect(docStateRequestsPropagationOfUpdateSpy).toHaveBeenCalled()
276     })
278     it('should propagate update as conversion if is in conversion flow', () => {
279       const docStateRequestsPropagationOfUpdateSpy = jest.spyOn(state.callbacks, 'docStateRequestsPropagationOfUpdate')
281       state.isInConversionFromOtherFormatFlow = true
283       state.handleDocBeingUpdatedByLexical(new Uint8Array(), {})
285       expect(docStateRequestsPropagationOfUpdateSpy).toHaveBeenCalledWith(
286         expect.objectContaining({
287           type: { wrapper: 'conversion' },
288         }),
289         expect.anything(),
290       )
291     })
293     it('should not propagate update as conversion if is not in conversion flow', () => {
294       const docStateRequestsPropagationOfUpdateSpy = jest.spyOn(state.callbacks, 'docStateRequestsPropagationOfUpdate')
296       state.isInConversionFromOtherFormatFlow = false
298       state.handleDocBeingUpdatedByLexical(new Uint8Array(), {})
300       expect(docStateRequestsPropagationOfUpdateSpy).toHaveBeenCalledWith(
301         expect.objectContaining({
302           type: { wrapper: 'du' },
303         }),
304         expect.anything(),
305       )
306     })
307   })