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 }