Skip to content

Commit 8124168

Browse files
committed
test(core): add coverage for transforms in union variants and MovableList of unions
- Transform inside union variant fields: verifies Date decode works for fields with .transform() inside a union variant, both on initial push and same-variant update - MovableList of unions: verifies push, in-place mutation, variant switch, and removal for union items inside a LoroMovableList
1 parent 22ee093 commit 8124168

File tree

1 file changed

+211
-4
lines changed

1 file changed

+211
-4
lines changed

packages/core/tests/schema-union.test.ts

Lines changed: 211 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, it, expect, beforeEach } from "vitest";
22
import { 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";
49
import { Mirror } from "../src/core/mirror.js";
510
import {
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(/must not contain the discriminant key/);
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

Comments
 (0)