/ test / createDocumentProjection.test.tsx
createDocumentProjection.test.tsx
  1  import {
  2  	PeerId,
  3  	Repo,
  4  	type AutomergeUrl,
  5  	type DocHandle,
  6  } from "@automerge/automerge-repo"
  7  import {render, renderHook, testEffect} from "@solidjs/testing-library"
  8  import {describe, expect, it, vi} from "vitest"
  9  import {RepoContext} from "../src/context.js"
 10  import {
 11  	createEffect,
 12  	createSignal,
 13  	type Accessor,
 14  	type ParentComponent,
 15  } from "solid-js"
 16  import useDocHandle from "../src/useDocHandle.js"
 17  import createDocumentProjection from "../src/createDocumentProjection.js"
 18  
 19  describe("createDocumentProjection", () => {
 20  	function setup() {
 21  		const repo = new Repo({
 22  			peerId: "bob" as PeerId,
 23  		})
 24  
 25  		const create = () =>
 26  			repo.create<ExampleDoc>({
 27  				key: "value",
 28  				array: [1, 2, 3],
 29  				hellos: [{hello: "world"}, {hello: "hedgehog"}],
 30  				projects: [
 31  					{title: "one", items: [{title: "go shopping"}]},
 32  					{title: "two", items: []},
 33  				],
 34  			})
 35  
 36  		const handle = create()
 37  		const wrapper: ParentComponent = props => {
 38  			return (
 39  				<RepoContext.Provider value={repo}>
 40  					{props.children}
 41  				</RepoContext.Provider>
 42  			)
 43  		}
 44  
 45  		return {
 46  			repo,
 47  			handle,
 48  			wrapper,
 49  			create,
 50  		}
 51  	}
 52  
 53  	it("should notify on a property change", async () => {
 54  		const {handle} = setup()
 55  		const {result: doc, owner} = renderHook(
 56  			createDocumentProjection<ExampleDoc>,
 57  			{
 58  				initialProps: [() => handle],
 59  			}
 60  		)
 61  
 62  		const done = testEffect(done => {
 63  			createEffect((run: number = 0) => {
 64  				if (run == 0) {
 65  					expect(doc()?.key).toBe("value")
 66  					handle.change(doc => (doc.key = "hello world!"))
 67  				} else if (run == 1) {
 68  					expect(doc()?.key).toBe("hello world!")
 69  					handle.change(doc => (doc.key = "friday night!"))
 70  				} else if (run == 2) {
 71  					expect(doc()?.key).toBe("friday night!")
 72  					done()
 73  				}
 74  				return run + 1
 75  			})
 76  		}, owner!)
 77  		return done
 78  	})
 79  
 80  	it("should not apply patches multiple times just because there are multiple projections", async () => {
 81  		const {handle} = setup()
 82  		const {result: one, owner: owner1} = renderHook(
 83  			createDocumentProjection<ExampleDoc>,
 84  			{
 85  				initialProps: [() => handle],
 86  			}
 87  		)
 88  		const {result: two, owner: owner2} = renderHook(
 89  			createDocumentProjection<ExampleDoc>,
 90  			{
 91  				initialProps: [() => handle],
 92  			}
 93  		)
 94  
 95  		const done2 = testEffect(done => {
 96  			createEffect((run: number = 0) => {
 97  				if (run == 0) {
 98  					expect(two()?.array).toEqual([1, 2, 3])
 99  				} else if (run == 1) {
100  					expect(two()?.array).toEqual([1, 2, 3, 4])
101  				} else if (run == 2) {
102  					expect(two()?.array).toEqual([1, 2, 3, 4, 5])
103  					done()
104  				}
105  				return run + 1
106  			})
107  		}, owner2!)
108  
109  		const done1 = testEffect(done => {
110  			createEffect((run: number = 0) => {
111  				if (run == 0) {
112  					expect(one()?.array).toEqual([1, 2, 3])
113  					handle.change(doc => doc.array.push(4))
114  				} else if (run == 1) {
115  					expect(one()?.array).toEqual([1, 2, 3, 4])
116  					handle.change(doc => doc.array.push(5))
117  				} else if (run == 2) {
118  					expect(one()?.array).toEqual([1, 2, 3, 4, 5])
119  					done()
120  				}
121  				return run + 1
122  			})
123  		}, owner1!)
124  
125  		return Promise.allSettled([done1, done2])
126  	})
127  
128  	it("should work with useDocHandle", async () => {
129  		const {
130  			handle: {url: startingUrl},
131  			wrapper,
132  		} = setup()
133  
134  		const [url, setURL] = createSignal<AutomergeUrl>()
135  
136  		const {result: handle} = renderHook(useDocHandle<ExampleDoc>, {
137  			initialProps: [url],
138  			wrapper,
139  		})
140  
141  		const {result: doc, owner} = renderHook(
142  			createDocumentProjection<ExampleDoc>,
143  			{
144  				initialProps: [handle],
145  			}
146  		)
147  
148  		const done = testEffect(done => {
149  			createEffect((run: number = 0) => {
150  				if (run == 0) {
151  					expect(doc()?.key).toBe(undefined)
152  					setURL(startingUrl)
153  				} else if (run == 1) {
154  					expect(doc()?.key).toBe("value")
155  					handle()?.change(doc => (doc.key = "hello world!"))
156  				} else if (run == 2) {
157  					expect(doc()?.key).toBe("hello world!")
158  					handle()?.change(doc => (doc.key = "friday night!"))
159  				} else if (run == 3) {
160  					expect(doc()?.key).toBe("friday night!")
161  					done()
162  				}
163  
164  				return run + 1
165  			})
166  		}, owner!)
167  
168  		return done
169  	})
170  
171  	it("should work with a signal url", async () => {
172  		const {create, wrapper} = setup()
173  		const [url, setURL] = createSignal<AutomergeUrl>()
174  		const {result: handle} = renderHook(useDocHandle<ExampleDoc>, {
175  			initialProps: [url],
176  			wrapper,
177  		})
178  		const {result: doc, owner} = renderHook(
179  			createDocumentProjection<ExampleDoc>,
180  			{
181  				initialProps: [handle],
182  				wrapper,
183  			}
184  		)
185  		const done = testEffect(done => {
186  			createEffect((run: number = 0) => {
187  				if (run == 0) {
188  					expect(doc()?.key).toBe(undefined)
189  					setURL(create().url)
190  				} else if (run == 1) {
191  					expect(doc()?.key).toBe("value")
192  					handle()?.change(doc => (doc.key = "hello world!"))
193  				} else if (run == 2) {
194  					expect(doc()?.key).toBe("hello world!")
195  					setURL(create().url)
196  				} else if (run == 3) {
197  					expect(doc()?.key).toBe("value")
198  					handle()?.change(doc => (doc.key = "friday night!"))
199  				} else if (run == 4) {
200  					expect(doc()?.key).toBe("friday night!")
201  					done()
202  				}
203  
204  				return run + 1
205  			})
206  		}, owner!)
207  		return done
208  	})
209  
210  	it("should clear the store when the signal returns to nothing", async () => {
211  		const {create, wrapper} = setup()
212  		const [url, setURL] = createSignal<AutomergeUrl>()
213  		const {result: handle} = renderHook(useDocHandle<ExampleDoc>, {
214  			initialProps: [url],
215  			wrapper,
216  		})
217  		const {result: doc, owner} = renderHook(
218  			createDocumentProjection<ExampleDoc>,
219  			{
220  				initialProps: [handle],
221  				wrapper,
222  			}
223  		)
224  
225  		const done = testEffect(done => {
226  			createEffect((run: number = 0) => {
227  				if (run == 0) {
228  					expect(doc()?.key).toBe(undefined)
229  					setURL(create().url)
230  				} else if (run == 1) {
231  					expect(doc()?.key).toBe("value")
232  					setURL(undefined)
233  				} else if (run == 2) {
234  					expect(doc()?.key).toBe(undefined)
235  					setURL(create().url)
236  				} else if (run == 3) {
237  					expect(doc()?.key).toBe("value")
238  					done()
239  				}
240  
241  				return run + 1
242  			})
243  		}, owner!)
244  		return done
245  	})
246  
247  	it("should not return the wrong store when handle changes", async () => {
248  		const {create} = setup()
249  
250  		const h1 = create()
251  		const h2 = create()
252  
253  		const [stableHandle] = createSignal(h1)
254  		// initially handle2 is the same as handle1
255  		const [changingHandle, setChangingHandle] = createSignal(h1)
256  
257  		const result = render(() => {
258  			function Component(props: {
259  				stableHandle: Accessor<DocHandle<ExampleDoc>>
260  				changingHandle: Accessor<DocHandle<ExampleDoc>>
261  			}) {
262  				const stableDoc = createDocumentProjection<ExampleDoc>(
263  					// eslint-disable-next-line solid/reactivity
264  					props.stableHandle
265  				)
266  
267  				const changingDoc = createDocumentProjection<ExampleDoc>(
268  					// eslint-disable-next-line solid/reactivity
269  					props.changingHandle
270  				)
271  
272  				return (
273  					<>
274  						<div data-testid="key-stable">{stableDoc()?.key}</div>
275  						<div data-testid="key-changing">{changingDoc()?.key}</div>
276  					</>
277  				)
278  			}
279  
280  			return (
281  				<Component
282  					stableHandle={stableHandle}
283  					changingHandle={changingHandle}
284  				/>
285  			)
286  		})
287  
288  		return testEffect(async done => {
289  			h2.change(doc => (doc.key = "document-2"))
290  			expect(result.getByTestId("key-stable").textContent).toBe("value")
291  			expect(result.getByTestId("key-changing").textContent).toBe("value")
292  
293  			h1.change(doc => (doc.key = "hello"))
294  			await new Promise<void>(setImmediate)
295  
296  			expect(result.getByTestId("key-stable").textContent).toBe("hello")
297  			expect(result.getByTestId("key-changing").textContent).toBe("hello")
298  
299  			setChangingHandle(() => h2)
300  
301  			expect(result.getByTestId("key-stable").textContent).toBe("hello")
302  			expect(result.getByTestId("key-changing").textContent).toBe("document-2")
303  
304  			setChangingHandle(() => h1)
305  
306  			expect(result.getByTestId("key-stable").textContent).toBe("hello")
307  			expect(result.getByTestId("key-changing").textContent).toBe("hello")
308  			done()
309  
310  			setChangingHandle(h2)
311  			h2.change(doc => (doc.key = "world"))
312  			await new Promise<void>(setImmediate)
313  
314  			expect(result.getByTestId("key-stable").textContent).toBe("hello")
315  			expect(result.getByTestId("key-changing").textContent).toBe("world")
316  			done()
317  		})
318  	})
319  
320  	it("should work ok with a slow handle", async () => {
321  		const {repo} = setup()
322  
323  		const originalFind = repo.find.bind(repo)
324  		repo.find = vi.fn().mockImplementation(async (...args) => {
325  			await new Promise(resolve => setTimeout(resolve, 900))
326  			// @ts-expect-error this is ok
327  			return originalFind(...args)
328  		})
329  
330  		await testEffect(done => {
331  			const handle = useDocHandle<{im: "slow"}>(
332  				() => repo.create({im: "slow"}).url,
333  				{repo}
334  			)
335  			const doc = createDocumentProjection(handle)
336  
337  			createEffect((run: number = 0) => {
338  				if (run == 0) {
339  					expect(doc()?.im).toBe("slow")
340  					done()
341  				}
342  				return run + 1
343  			})
344  		})
345  
346  		repo.find = originalFind
347  	})
348  
349  	it("should not notify on properties nobody cares about", async () => {
350  		const {handle} = setup()
351  		let fn = vi.fn()
352  
353  		const {result: doc, owner} = renderHook(
354  			createDocumentProjection<ExampleDoc>,
355  			{
356  				initialProps: [() => handle],
357  			}
358  		)
359  		testEffect(() => {
360  			createEffect(() => {
361  				fn(doc()?.projects[1].title)
362  			})
363  		})
364  		const arrayDotThree = testEffect(done => {
365  			createEffect((run: number = 0) => {
366  				if (run == 0) {
367  					expect(doc()?.array[3]).toBeUndefined()
368  					handle.change(doc => (doc.array[2] = 22))
369  					handle.change(doc => (doc.key = "hello world!"))
370  					handle.change(doc => (doc.array[1] = 11))
371  					handle.change(doc => (doc.array[3] = 145))
372  				} else if (run == 1) {
373  					expect(doc()?.array[3]).toBe(145)
374  					handle.change(doc => (doc.projects[0].title = "hello world!"))
375  					handle.change(
376  						doc => (doc.projects[0].items[0].title = "hello world!")
377  					)
378  					handle.change(doc => (doc.array[3] = 147))
379  				} else if (run == 2) {
380  					expect(doc()?.array[3]).toBe(147)
381  					done()
382  				}
383  				return run + 1
384  			})
385  		}, owner!)
386  		const projectZeroItemZeroTitle = testEffect(done => {
387  			createEffect((run: number = 0) => {
388  				if (run == 0) {
389  					expect(doc()?.projects[0].items[0].title).toBe("hello world!")
390  					done()
391  				}
392  				return run + 1
393  			})
394  		}, owner!)
395  
396  		expect(fn).toHaveBeenCalledOnce()
397  		expect(fn).toHaveBeenCalledWith("two")
398  
399  		return Promise.all([arrayDotThree, projectZeroItemZeroTitle])
400  	})
401  })
402  
403  interface ExampleDoc {
404  	key: string
405  	array: number[]
406  	hellos: {hello: string}[]
407  	projects: {
408  		title: string
409  		items: {title: string; complete?: number}[]
410  	}[]
411  }