/ test / makeDocumentProjection.test.tsx
makeDocumentProjection.test.tsx
  1  import {PeerId, Repo, type DocHandle} from "@automerge/automerge-repo"
  2  import {renderHook, testEffect} from "@solidjs/testing-library"
  3  import {describe, expect, it, vi} from "vitest"
  4  import {
  5  	createEffect,
  6  	createRoot,
  7  	createSignal,
  8  	type ParentComponent,
  9  } from "solid-js"
 10  import makeDocumentProjection from "../src/makeDocumentProjection.js"
 11  import {RepoContext} from "../src/context.js"
 12  
 13  describe("makeDocumentProjection", () => {
 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  		}
 45  	}
 46  
 47  	it("should notify on a property change", async () => {
 48  		const {handle} = setup()
 49  		const {result: doc, owner} = renderHook(
 50  			makeDocumentProjection as (handle: DocHandle<ExampleDoc>) => ExampleDoc,
 51  			{
 52  				initialProps: [handle],
 53  			}
 54  		)
 55  
 56  		const done = testEffect(done => {
 57  			createEffect((run: number = 0) => {
 58  				if (run == 0) {
 59  					expect(doc.key).toBe("value")
 60  					handle.change(doc => (doc.key = "hello world!"))
 61  				} else if (run == 1) {
 62  					expect(doc.key).toBe("hello world!")
 63  					handle.change(doc => (doc.key = "friday night!"))
 64  				} else if (run == 2) {
 65  					expect(doc.key).toBe("friday night!")
 66  					done()
 67  				}
 68  				return run + 1
 69  			})
 70  		}, owner!)
 71  		return done
 72  	})
 73  
 74  	it("should not apply patches multiple times just because there are multiple projections of the same handle", async () => {
 75  		const {handle} = setup()
 76  		const {result: one, owner: owner1} = renderHook(
 77  			makeDocumentProjection as (handle: DocHandle<ExampleDoc>) => ExampleDoc,
 78  			{
 79  				initialProps: [handle],
 80  			}
 81  		)
 82  		const {result: two, owner: owner2} = renderHook(
 83  			makeDocumentProjection as (handle: DocHandle<ExampleDoc>) => ExampleDoc,
 84  			{
 85  				initialProps: [handle],
 86  			}
 87  		)
 88  
 89  		const done2 = testEffect(done => {
 90  			createEffect((run: number = 0) => {
 91  				if (run == 0) {
 92  					expect(two.array).toEqual([1, 2, 3])
 93  				} else if (run == 1) {
 94  					expect(two.array).toEqual([1, 2, 3, 4])
 95  				} else if (run == 2) {
 96  					expect(two.array).toEqual([1, 2, 3, 4, 5])
 97  					done()
 98  				}
 99  				return run + 1
100  			})
101  		}, owner2!)
102  
103  		const done1 = testEffect(done => {
104  			createEffect((run: number = 0) => {
105  				if (run == 0) {
106  					expect(one.array).toEqual([1, 2, 3])
107  					handle.change(doc => doc.array.push(4))
108  				} else if (run == 1) {
109  					expect(one.array).toEqual([1, 2, 3, 4])
110  					handle.change(doc => doc.array.push(5))
111  				} else if (run == 2) {
112  					expect(one.array).toEqual([1, 2, 3, 4, 5])
113  					done()
114  				}
115  				return run + 1
116  			})
117  		}, owner1!)
118  
119  		return Promise.allSettled([done1, done2])
120  	})
121  
122  	it("should notify on a deep property change", async () => {
123  		const {handle} = setup()
124  		return createRoot(() => {
125  			const doc = makeDocumentProjection<ExampleDoc>(handle)
126  			return testEffect(done => {
127  				createEffect((run: number = 0) => {
128  					if (run == 0) {
129  						expect(doc.projects[0].title).toBe("one")
130  						handle.change(doc => (doc.projects[0].title = "hello world!"))
131  					} else if (run == 1) {
132  						expect(doc.projects[0].title).toBe("hello world!")
133  						handle.change(doc => (doc.projects[0].title = "friday night!"))
134  					} else if (run == 2) {
135  						expect(doc.projects[0].title).toBe("friday night!")
136  						done()
137  					}
138  					return run + 1
139  				})
140  			})
141  		})
142  	})
143  
144  	it("should not clean up when it should not clean up", async () => {
145  		const {handle} = setup()
146  
147  		return createRoot(() => {
148  			const [one, clean1] = createRoot(c => [makeDocumentProjection(handle), c])
149  			const [two, clean2] = createRoot(c => [makeDocumentProjection(handle), c])
150  			const [three, clean3] = createRoot(c => [
151  				makeDocumentProjection(handle),
152  				c,
153  			])
154  			const [signal, setSignal] = createSignal(0)
155  			return testEffect(done => {
156  				createEffect((run: number = 0) => {
157  					signal()
158  					expect(one.projects[0].title).not.toBeUndefined()
159  					expect(two.projects[0].title).not.toBeUndefined()
160  					expect(three.projects[0].title).not.toBeUndefined()
161  					if (run == 0) {
162  						// immediately clean up the first projection. updates should
163  						// carry on because there is still another reference
164  						clean1()
165  						expect(one.projects[0].title).toBe("one")
166  						expect(two.projects[0].title).toBe("one")
167  						expect(three.projects[0].title).toBe("one")
168  						handle.change(doc => (doc.projects[0].title = "hello world!"))
169  					} else if (run == 1) {
170  						// clean up another projection. updates should carry on
171  						// because there is still one left
172  						clean3()
173  						expect(one.projects[0].title).toBe("hello world!")
174  						expect(two.projects[0].title).toBe("hello world!")
175  						expect(three.projects[0].title).toBe("hello world!")
176  					} else if (run == 2) {
177  						// now all the stores are cleaned up so further updates
178  						// should not show in the store
179  						clean2()
180  						setSignal(1)
181  					} else if (run == 3) {
182  						handle.change(doc => (doc.projects[0].title = "friday night!"))
183  						// force the test to run again
184  						setSignal(2)
185  					} else if (run == 4) {
186  						expect(one.projects[0].title).toBe("hello world!")
187  						expect(two.projects[0].title).toBe("hello world!")
188  						expect(three.projects[0].title).toBe("hello world!")
189  						done()
190  					}
191  					return run + 1
192  				})
193  			})
194  		})
195  	})
196  
197  	it("should not notify on properties nobody cares about", async () => {
198  		const {handle} = setup()
199  		let fn = vi.fn()
200  
201  		const {result: doc, owner} = renderHook(
202  			makeDocumentProjection as (handle: DocHandle<ExampleDoc>) => ExampleDoc,
203  			{
204  				initialProps: [handle],
205  			}
206  		)
207  		testEffect(() => {
208  			createEffect(() => {
209  				fn(doc?.projects[1].title)
210  			})
211  		})
212  		const arrayDotThree = testEffect(done => {
213  			createEffect((run: number = 0) => {
214  				if (run == 0) {
215  					expect(doc.array[3]).toBeUndefined()
216  					handle.change(doc => (doc.array[2] = 22))
217  					handle.change(doc => (doc.key = "hello world!"))
218  					handle.change(doc => (doc.array[1] = 11))
219  					handle.change(doc => (doc.array[3] = 145))
220  				} else if (run == 1) {
221  					expect(doc?.array[3]).toBe(145)
222  					handle.change(doc => (doc.projects[0].title = "hello world!"))
223  					handle.change(
224  						doc => (doc.projects[0].items[0].title = "hello world!")
225  					)
226  					handle.change(doc => (doc.array[3] = 147))
227  				} else if (run == 2) {
228  					expect(doc?.array[3]).toBe(147)
229  					done()
230  				}
231  				return run + 1
232  			})
233  		}, owner!)
234  		const projectZeroItemZeroTitle = testEffect(done => {
235  			createEffect((run: number = 0) => {
236  				if (run == 0) {
237  					expect(doc?.projects[0].items[0].title).toBe("hello world!")
238  					done()
239  				}
240  				return run + 1
241  			})
242  		}, owner!)
243  
244  		expect(fn).toHaveBeenCalledOnce()
245  		expect(fn).toHaveBeenCalledWith("two")
246  
247  		return Promise.all([arrayDotThree, projectZeroItemZeroTitle])
248  	})
249  })
250  
251  interface ExampleDoc {
252  	key: string
253  	array: number[]
254  	hellos: {hello: string}[]
255  	projects: {
256  		title: string
257  		items: {title: string; complete?: number}[]
258  	}[]
259  }