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 = {
14 jest.mock('yjs', () => ({
15 ...jest.requireActual('yjs'),
16 decodeUpdate: jest.fn(),
17 mergeUpdates: jest.fn(),
19 ;(decodeUpdate as jest.Mock).mockImplementation(() => ({
20 structs: [{ id: { clock: 0 } }],
23 describe('DocState', () => {
30 handleAwarenessStateUpdate: jest.fn(),
31 docStateRequestsPropagationOfUpdate: jest.fn(),
32 } as unknown as DocStateCallbacks,
36 } as unknown as LoggerInterface,
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()
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()
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)
77 describe('handleAwarenessUpdateOrChange', () => {
78 it('should invoke removeDuplicateClients', () => {
79 const removeDuplicateClientsSpy = jest.spyOn(state.awareness, 'removeDuplicateClients')
81 state.handleAwarenessUpdateOrChange(EmptyAwarenessUpdate, {})
83 expect(removeDuplicateClientsSpy).toHaveBeenCalled()
86 it('should notify callbacks about the change', () => {
87 const callbackSpy = jest.spyOn(state.callbacks, 'handleAwarenessStateUpdate')
89 state.handleAwarenessUpdateOrChange(EmptyAwarenessUpdate, {})
91 expect(callbackSpy).toHaveBeenCalled()
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(
114 expect(broadcastCurrentAwarenessStateSpy).not.toHaveBeenCalled()
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(
137 [2, { name: 'user2', awarenessData: {} } as DocsUserState],
140 state.awareness.meta = new Map([
141 [1, { lastUpdated: 0, clock: 0 }],
142 [2, { lastUpdated: 0, clock: 0 }],
145 state.handleAwarenessUpdateOrChange(
154 expect(spy).toHaveBeenCalled()
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(
177 [2, { name: 'user2', awarenessData: {} } as DocsUserState],
180 state.awareness.meta = new Map([
181 [1, { lastUpdated: 0, clock: 0 }],
182 [2, { lastUpdated: 0, clock: 0 }],
185 state.handleAwarenessUpdateOrChange(
194 expect(spy).not.toHaveBeenCalled()
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(
217 expect(spy).toHaveBeenCalled()
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()
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()
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()
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()
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 } }],
266 state.handleDocBeingUpdatedByLexical(new Uint8Array(), {})
268 expect(docStateRequestsPropagationOfUpdateSpy).not.toHaveBeenCalled()
269 ;(decodeUpdate as jest.Mock).mockImplementationOnce(() => ({
270 structs: [{ id: { clock: 1 } }],
273 state.handleDocBeingUpdatedByLexical(new Uint8Array(), {})
275 expect(docStateRequestsPropagationOfUpdateSpy).toHaveBeenCalled()
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' },
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' },