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