@@ -12,7 +12,6 @@ const waitForSync = async () => {
12
12
} ;
13
13
14
14
describe ( "MovableList" , ( ) => {
15
-
16
15
async function initTestMirror ( ) {
17
16
const doc = new LoroDoc ( ) ;
18
17
doc . setPeerId ( 1 ) ;
@@ -349,4 +348,235 @@ describe("MovableList", () => {
349
348
350
349
expect ( mirror . getState ( ) ) . toEqual ( desiredState ) ;
351
350
} ) ;
351
+
352
+ it ( "movable list handles basic moves" , async ( ) => {
353
+ const doc = new LoroDoc ( ) ;
354
+ doc . setPeerId ( 1 ) ;
355
+ const schema_ = schema ( {
356
+ list : schema . LoroMovableList (
357
+ schema . LoroMap ( {
358
+ id : schema . String ( ) ,
359
+ text : schema . LoroText ( ) ,
360
+ } ) ,
361
+ ( item ) => item . id ,
362
+ ) ,
363
+ } ) ;
364
+
365
+ const mirror = new Mirror ( {
366
+ doc,
367
+ schema : schema_ ,
368
+ } ) ;
369
+
370
+ mirror . setState ( {
371
+ list : [
372
+ { id : "0" , text : "" } ,
373
+ { id : "1" , text : "" } ,
374
+ { id : "2" , text : "" } ,
375
+ { id : "3" , text : "" } ,
376
+ ] ,
377
+ } ) ;
378
+ expect ( doc . frontiers ( ) [ 0 ] . counter ) . toBe ( 11 ) ;
379
+ mirror . setState ( {
380
+ list : [
381
+ { id : "1" , text : "" } ,
382
+ { id : "0" , text : "" } ,
383
+ { id : "2" , text : "" } ,
384
+ { id : "3" , text : "" } ,
385
+ ] ,
386
+ } ) ;
387
+ expect ( doc . frontiers ( ) [ 0 ] . counter ) . toBe ( 12 ) ;
388
+ mirror . setState ( {
389
+ list : [
390
+ { id : "0" , text : "" } ,
391
+ { id : "1" , text : "" } ,
392
+ { id : "3" , text : "" } ,
393
+ { id : "2" , text : "" } ,
394
+ ] ,
395
+ } ) ;
396
+ expect ( doc . frontiers ( ) [ 0 ] . counter ) . toBe ( 14 ) ;
397
+ } ) ;
398
+
399
+ it ( "movable list handles delete + reorder without index errors" , async ( ) => {
400
+ const { mirror, doc } = await initTestMirror ( ) ;
401
+
402
+ // Set to four items first
403
+ mirror . setState ( {
404
+ list : [
405
+ { id : "A" , text : "tA" } ,
406
+ { id : "B" , text : "tB" } ,
407
+ { id : "C" , text : "tC" } ,
408
+ { id : "D" , text : "tD" } ,
409
+ ] ,
410
+ } ) ;
411
+ await waitForSync ( ) ;
412
+
413
+ const initial = doc . getDeepValueWithID ( ) ;
414
+ const idToCid = new Map (
415
+ initial . list . value . map ( ( x : any ) => [ x . value . id , x . cid ] ) ,
416
+ ) ;
417
+
418
+ const desired = {
419
+ list : [
420
+ { id : "D" , text : "tD" } ,
421
+ { id : "C" , text : "tC" } ,
422
+ { id : "B" , text : "tB" } ,
423
+ ] ,
424
+ } ;
425
+ mirror . setState ( desired ) ;
426
+ await waitForSync ( ) ;
427
+
428
+ const after = doc . getDeepValueWithID ( ) ;
429
+ expect ( after . list . value . map ( ( x : any ) => x . value . id ) ) . toEqual ( [
430
+ "D" ,
431
+ "C" ,
432
+ "B" ,
433
+ ] ) ;
434
+ // Container IDs preserved for remaining items
435
+ expect ( after . list . value [ 0 ] . cid ) . toBe ( idToCid . get ( "D" ) ) ;
436
+ expect ( after . list . value [ 1 ] . cid ) . toBe ( idToCid . get ( "C" ) ) ;
437
+ expect ( after . list . value [ 2 ] . cid ) . toBe ( idToCid . get ( "B" ) ) ;
438
+
439
+ // Ensure state mirrors correctly
440
+ expect ( mirror . getState ( ) ) . toEqual ( desired ) ;
441
+ } ) ;
442
+
443
+ it ( "movable list handles insert + delete + reorder mix" , async ( ) => {
444
+ const { mirror, doc } = await initTestMirror ( ) ;
445
+
446
+ mirror . setState ( {
447
+ list : [
448
+ { id : "A" , text : "tA" } ,
449
+ { id : "B" , text : "tB" } ,
450
+ { id : "C" , text : "tC" } ,
451
+ ] ,
452
+ } ) ;
453
+ await waitForSync ( ) ;
454
+
455
+ const initial = doc . getDeepValueWithID ( ) ;
456
+ const idToCid = new Map (
457
+ initial . list . value . map ( ( x : any ) => [ x . value . id , x . cid ] ) ,
458
+ ) ;
459
+
460
+ const desired = {
461
+ list : [
462
+ { id : "C" , text : "tc" } ,
463
+ { id : "E" , text : "te" } ,
464
+ { id : "B" , text : "tb" } ,
465
+ ] ,
466
+ } ;
467
+ mirror . setState ( desired ) ;
468
+ await waitForSync ( ) ;
469
+
470
+ const after = doc . getDeepValueWithID ( ) ;
471
+ expect ( after . list . value . map ( ( x : any ) => x . value . id ) ) . toEqual ( [
472
+ "C" ,
473
+ "E" ,
474
+ "B" ,
475
+ ] ) ;
476
+ // C and B preserve container ids; E is new
477
+ expect ( after . list . value [ 0 ] . cid ) . toBe ( idToCid . get ( "C" ) ) ;
478
+ expect ( after . list . value [ 2 ] . cid ) . toBe ( idToCid . get ( "B" ) ) ;
479
+ expect ( after . list . value [ 1 ] . cid ) . not . toBe ( idToCid . get ( "A" ) ) ;
480
+ expect ( after . list . value [ 1 ] . cid ) . not . toBe ( idToCid . get ( "B" ) ) ;
481
+ expect ( after . list . value [ 1 ] . cid ) . not . toBe ( idToCid . get ( "C" ) ) ;
482
+
483
+ // Texts updated accordingly
484
+ expect ( after . list . value [ 0 ] . value . text . value ) . toBe ( "tc" ) ;
485
+ expect ( after . list . value [ 2 ] . value . text . value ) . toBe ( "tb" ) ;
486
+ expect ( after . list . value [ 1 ] . value . text . value ) . toBe ( "te" ) ;
487
+
488
+ expect ( mirror . getState ( ) ) . toEqual ( desired ) ;
489
+ } ) ;
490
+
491
+ it ( "movable list throws on missing item id" , async ( ) => {
492
+ const { mirror } = await initTestMirror ( ) ;
493
+ // @ts -expect-error testing runtime validation
494
+ expect ( ( ) =>
495
+ mirror . setState ( {
496
+ list : [
497
+ // missing id
498
+ { text : "no id" } ,
499
+ ] ,
500
+ } ) ,
501
+ ) . toThrow ( ) ;
502
+ } ) ;
503
+
504
+ it ( "movable list throws on duplicate ids in new state" , async ( ) => {
505
+ const { mirror } = await initTestMirror ( ) ;
506
+ expect ( ( ) =>
507
+ mirror . setState ( {
508
+ list : [
509
+ { id : "X" , text : "1" } ,
510
+ { id : "X" , text : "2" } ,
511
+ ] ,
512
+ } ) ,
513
+ ) . toThrow ( ) ;
514
+ } ) ;
515
+
516
+ it ( "movable list fuzz: large shuffles preserve container ids and text" , async ( ) => {
517
+ const { mirror, doc } = await initTestMirror ( ) ;
518
+
519
+ // Deterministic RNG
520
+ let seed = 0x12345678 ;
521
+ const rand = ( ) => {
522
+ seed = ( 1664525 * seed + 1013904223 ) >>> 0 ;
523
+ return seed / 0x100000000 ;
524
+ } ;
525
+ const shuffle = < T > ( arr : T [ ] ) : T [ ] => {
526
+ const a = arr . slice ( ) ;
527
+ for ( let i = a . length - 1 ; i > 0 ; i -- ) {
528
+ const j = Math . floor ( rand ( ) * ( i + 1 ) ) ;
529
+ [ a [ i ] , a [ j ] ] = [ a [ j ] , a [ i ] ] ;
530
+ }
531
+ return a ;
532
+ } ;
533
+
534
+ const N = 80 ;
535
+ const ROUNDS = 25 ;
536
+
537
+ const makeItem = ( i : number ) => ( {
538
+ id : String ( i + 1 ) ,
539
+ text : `T${ i + 1 } ` ,
540
+ } ) ;
541
+ let current = Array . from ( { length : N } , ( _ , i ) => makeItem ( i ) ) ;
542
+
543
+ mirror . setState ( { list : current } ) ;
544
+ await waitForSync ( ) ;
545
+
546
+ const initial = doc . getDeepValueWithID ( ) ;
547
+ const initialCidById = new Map (
548
+ initial . list . value . map ( ( x : any ) => [ x . value . id , x . cid ] ) ,
549
+ ) ;
550
+
551
+ for ( let r = 0 ; r < ROUNDS ; r ++ ) {
552
+ // Shuffle order
553
+ let next = shuffle ( current ) ;
554
+
555
+ // Randomly update a few items' text to exercise nested updates
556
+ const updates = Math . floor ( rand ( ) * 5 ) ;
557
+ for ( let k = 0 ; k < updates ; k ++ ) {
558
+ const idx = Math . floor ( rand ( ) * next . length ) ;
559
+ const id = next [ idx ] . id ;
560
+ next [ idx ] = { id, text : `T${ id } -r${ r } ` } as any ;
561
+ }
562
+
563
+ mirror . setState ( { list : next } ) ;
564
+ await waitForSync ( ) ;
565
+
566
+ const after = doc . getDeepValueWithID ( ) ;
567
+ // Verify order and IDs
568
+ const ids = after . list . value . map ( ( x : any ) => x . value . id ) ;
569
+ expect ( ids ) . toEqual ( next . map ( ( x ) => x . id ) ) ;
570
+ // Verify container IDs preserved
571
+ after . list . value . forEach ( ( x : any , i : number ) => {
572
+ expect ( x . cid ) . toBe ( initialCidById . get ( next [ i ] . id ) ) ;
573
+ expect ( x . value . text . value ) . toBe ( next [ i ] . text ) ;
574
+ } ) ;
575
+
576
+ // Mirror state reflects next
577
+ expect ( mirror . getState ( ) ) . toEqual ( { list : next } ) ;
578
+
579
+ current = next ;
580
+ }
581
+ } ) ;
352
582
} ) ;
0 commit comments