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 }