11import { describe , it , expect , beforeEach } from "vitest" ;
22import { LoroDoc } from "loro-crdt" ;
3- import { schema , validateSchema , getDefaultValue } from "../src/index.js" ;
3+ import {
4+ schema ,
5+ validateSchema ,
6+ getDefaultValue ,
7+ type TransformDefinition ,
8+ } from "../src/index.js" ;
49import { Mirror } from "../src/core/mirror.js" ;
510import {
611 isLoroUnionSchema ,
@@ -83,7 +88,10 @@ describe("schema.Union", () => {
8388 it ( "rejects discriminant key in variant definition at schema creation" , ( ) => {
8489 expect ( ( ) => {
8590 schema . Union ( "type" , {
86- bad : schema . LoroMap ( { type : schema . String ( ) , value : schema . Number ( ) } ) ,
91+ bad : schema . LoroMap ( {
92+ type : schema . String ( ) ,
93+ value : schema . Number ( ) ,
94+ } ) ,
8795 } ) ;
8896 } ) . toThrow ( / m u s t n o t c o n t a i n t h e d i s c r i m i n a n t k e y / ) ;
8997 } ) ;
@@ -102,7 +110,9 @@ describe("schema.Union", () => {
102110
103111 const result = validateSchema ( badUnion , { kind : "bad" } ) ;
104112 expect ( result . valid ) . toBe ( false ) ;
105- expect ( result . errors ?. [ 0 ] ) . toContain ( "must not contain the discriminant key" ) ;
113+ expect ( result . errors ?. [ 0 ] ) . toContain (
114+ "must not contain the discriminant key" ,
115+ ) ;
106116 } ) ;
107117 } ) ;
108118
@@ -468,7 +478,10 @@ describe("Union edge cases", () => {
468478 const _guard : boolean = isLoroUnionSchema ( u ) ;
469479 expect ( _guard ) . toBe ( true ) ;
470480 // Verify the type is accessible (compile-time check)
471- const _typeCheck : LoroUnionSchema < string , Record < string , never > > = u as never ;
481+ const _typeCheck : LoroUnionSchema <
482+ string ,
483+ Record < string , never >
484+ > = u as never ;
472485 void _typeCheck ;
473486 } ) ;
474487
@@ -683,3 +696,197 @@ describe("Union edge cases", () => {
683696 mirror . dispose ( ) ;
684697 } ) ;
685698} ) ;
699+
700+ describe ( "Union with transforms inside variant fields" , ( ) => {
701+ const epochTransform : TransformDefinition < number , Date > = {
702+ decode : ( n : number ) => new Date ( n ) ,
703+ encode : ( d : Date ) => d . getTime ( ) ,
704+ } ;
705+
706+ it ( "transform decode/encode works for fields in union variants" , ( ) => {
707+ const s = schema ( {
708+ events : schema . LoroList (
709+ schema . Union ( "type" , {
710+ meeting : schema . LoroMap ( {
711+ title : schema . String ( ) ,
712+ startAt : schema . Number ( ) . transform ( epochTransform ) ,
713+ } ) ,
714+ reminder : schema . LoroMap ( {
715+ note : schema . String ( ) ,
716+ } ) ,
717+ } ) ,
718+ ( item ) => item . $cid ,
719+ ) ,
720+ } ) ;
721+
722+ const doc = new LoroDoc ( ) ;
723+ const mirror = new Mirror ( {
724+ doc,
725+ schema : s ,
726+ initialState : { events : [ ] } ,
727+ checkStateConsistency : true ,
728+ } ) ;
729+
730+ const epoch = new Date ( "2025-06-15T10:00:00Z" ) . getTime ( ) ;
731+
732+ mirror . setState ( ( draft ) => {
733+ draft . events . push ( {
734+ type : "meeting" ,
735+ title : "Standup" ,
736+ startAt : new Date ( epoch ) ,
737+ } ) ;
738+ draft . events . push ( { type : "reminder" , note : "Buy milk" } ) ;
739+ } ) ;
740+
741+ const state = mirror . getState ( ) ;
742+ expect ( state . events ) . toHaveLength ( 2 ) ;
743+ expect ( state . events [ 0 ] . type ) . toBe ( "meeting" ) ;
744+ if ( state . events [ 0 ] . type === "meeting" ) {
745+ // The value should be decoded back to a Date
746+ expect ( state . events [ 0 ] . startAt ) . toBeInstanceOf ( Date ) ;
747+ expect ( ( state . events [ 0 ] . startAt as Date ) . getTime ( ) ) . toBe ( epoch ) ;
748+ }
749+ expect ( state . events [ 1 ] . type ) . toBe ( "reminder" ) ;
750+ } ) ;
751+
752+ it ( "transform works after same-variant update" , ( ) => {
753+ const s = schema ( {
754+ item : schema . Union ( "kind" , {
755+ timestamped : schema . LoroMap ( {
756+ value : schema . String ( ) ,
757+ updatedAt : schema . Number ( ) . transform ( epochTransform ) ,
758+ } ) ,
759+ plain : schema . LoroMap ( { value : schema . String ( ) } ) ,
760+ } ) ,
761+ } ) ;
762+
763+ const doc = new LoroDoc ( ) ;
764+ const mirror = new Mirror ( {
765+ doc,
766+ schema : s ,
767+ initialState : {
768+ item : {
769+ kind : "timestamped" ,
770+ value : "hello" ,
771+ updatedAt : new Date ( "2025-01-01" ) ,
772+ } ,
773+ } ,
774+ } ) ;
775+
776+ const cidBefore = mirror . getState ( ) . item . $cid ;
777+
778+ mirror . setState ( ( draft ) => {
779+ if ( draft . item . kind === "timestamped" ) {
780+ draft . item . value = "updated" ;
781+ draft . item . updatedAt = new Date ( "2025-06-01" ) ;
782+ }
783+ } ) ;
784+
785+ const state = mirror . getState ( ) ;
786+ expect ( state . item . kind ) . toBe ( "timestamped" ) ;
787+ expect ( state . item . $cid ) . toBe ( cidBefore ) ; // same container
788+ if ( state . item . kind === "timestamped" ) {
789+ expect ( state . item . value ) . toBe ( "updated" ) ;
790+ expect ( state . item . updatedAt ) . toBeInstanceOf ( Date ) ;
791+ expect ( ( state . item . updatedAt as Date ) . getFullYear ( ) ) . toBe ( 2025 ) ;
792+ }
793+ } ) ;
794+ } ) ;
795+
796+ describe ( "MovableList of unions" , ( ) => {
797+ it ( "supports push, update, and variant switch" , ( ) => {
798+ const s = schema ( {
799+ items : schema . LoroMovableList (
800+ schema . Union ( "type" , {
801+ text : schema . LoroMap ( { body : schema . String ( ) } ) ,
802+ number : schema . LoroMap ( { value : schema . Number ( ) } ) ,
803+ } ) ,
804+ ( item ) => item . $cid ,
805+ ) ,
806+ } ) ;
807+
808+ const doc = new LoroDoc ( ) ;
809+ const mirror = new Mirror ( {
810+ doc,
811+ schema : s ,
812+ initialState : { items : [ ] } ,
813+ } ) ;
814+
815+ // Push items
816+ mirror . setState ( ( draft ) => {
817+ draft . items . push (
818+ { type : "text" , body : "Hello" } ,
819+ { type : "number" , value : 42 } ,
820+ ) ;
821+ } ) ;
822+
823+ let state = mirror . getState ( ) ;
824+ expect ( state . items ) . toHaveLength ( 2 ) ;
825+ expect ( state . items [ 0 ] . type ) . toBe ( "text" ) ;
826+ expect ( state . items [ 1 ] . type ) . toBe ( "number" ) ;
827+
828+ // Same-variant update (use Immer mutation to avoid enumerable $cid issues)
829+ mirror . setState ( ( draft ) => {
830+ if ( draft . items [ 0 ] . type === "text" ) {
831+ draft . items [ 0 ] . body = "Updated" ;
832+ }
833+ } ) ;
834+
835+ state = mirror . getState ( ) ;
836+ expect ( state . items [ 0 ] . type ) . toBe ( "text" ) ;
837+ if ( state . items [ 0 ] . type === "text" ) {
838+ expect ( state . items [ 0 ] . body ) . toBe ( "Updated" ) ;
839+ }
840+
841+ // Variant switch
842+ mirror . setState ( ( draft ) => {
843+ draft . items [ 1 ] = {
844+ type : "text" ,
845+ body : "Was a number" ,
846+ $cid : draft . items [ 1 ] . $cid ,
847+ } ;
848+ } ) ;
849+
850+ state = mirror . getState ( ) ;
851+ expect ( state . items [ 1 ] . type ) . toBe ( "text" ) ;
852+ if ( state . items [ 1 ] . type === "text" ) {
853+ expect ( state . items [ 1 ] . body ) . toBe ( "Was a number" ) ;
854+ }
855+ } ) ;
856+
857+ it ( "supports removal from movable list of unions" , ( ) => {
858+ const s = schema ( {
859+ items : schema . LoroMovableList (
860+ schema . Union ( "type" , {
861+ a : schema . LoroMap ( { x : schema . Number ( ) } ) ,
862+ b : schema . LoroMap ( { y : schema . String ( ) } ) ,
863+ } ) ,
864+ ( item ) => item . $cid ,
865+ ) ,
866+ } ) ;
867+
868+ const doc = new LoroDoc ( ) ;
869+ const mirror = new Mirror ( {
870+ doc,
871+ schema : s ,
872+ initialState : { items : [ ] } ,
873+ } ) ;
874+
875+ mirror . setState ( ( draft ) => {
876+ draft . items . push (
877+ { type : "a" , x : 1 } ,
878+ { type : "b" , y : "hi" } ,
879+ { type : "a" , x : 2 } ,
880+ ) ;
881+ } ) ;
882+
883+ mirror . setState ( ( draft ) => {
884+ draft . items . splice ( 1 , 1 ) ;
885+ } ) ;
886+
887+ const state = mirror . getState ( ) ;
888+ expect ( state . items ) . toHaveLength ( 2 ) ;
889+ expect ( state . items [ 0 ] . type ) . toBe ( "a" ) ;
890+ expect ( state . items [ 1 ] . type ) . toBe ( "a" ) ;
891+ } ) ;
892+ } ) ;
0 commit comments