1import {
2 isPlainData,
3 isPlainObject,
4 mapPlainData,
5 stripNullFields,
6 stripUndefinedFields,
7 unwrapRedacted,
8} from "@/Util/data";
9import * as Context from "effect/Context";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Redacted from "effect/Redacted";
14import { describe, expect, test } from "alchemy-test";
15
16describe("data utilities", { tags: ["unit", "local"] }, () => {
17 test("isPlainObject accepts object literals", () => {
18 expect(isPlainObject({})).toBe(true);
19 });
20
21 test("isPlainObject rejects arrays and object instances", () => {
22 expect(isPlainObject([])).toBe(false);
23 expect(isPlainObject(Object.create(null))).toBe(false);
24 expect(isPlainObject(new Date())).toBe(false);
25 expect(isPlainObject(Redacted.make("secret"))).toBe(false);
26 });
27
28 test("stripNullFields removes nulls recursively from arrays and records", () => {
29 expect(
30 stripNullFields({
31 a: null,
32 b: undefined,
33 c: [{ d: null, e: 1 }],
34 }),
35 ).toEqual({
36 b: undefined,
37 c: [{ e: 1 }],
38 });
39 });
40
41 test("stripUndefinedFields removes undefined recursively from arrays and records", () => {
42 expect(
43 stripUndefinedFields({
44 a: null,
45 b: undefined,
46 c: [{ d: undefined, e: 1 }],
47 }),
48 ).toEqual({
49 a: null,
50 c: [{ e: 1 }],
51 });
52 });
53
54 test("unwrapRedacted unwraps only arrays and plain records recursively", () => {
55 const date = new Date("2026-05-20T00:00:00.000Z");
56
57 expect(
58 unwrapRedacted({
59 value: Redacted.make("secret"),
60 nested: [Redacted.make("nested")],
61 date,
62 }),
63 ).toEqual({
64 value: "secret",
65 nested: ["nested"],
66 date,
67 });
68 });
69});
70
71// The engine's traversal rule (#1082): plain data is walked; every class
72// instance is a leaf. isPlainData is the single gate, mapPlainData the
73// single cycle-guarded rebuild — pin their exact semantics here.
74describe("isPlainData", { tags: ["unit", "local"] }, () => {
75 test("accepts arrays, object literals, and null-prototype objects", () => {
76 expect(isPlainData([])).toBe(true);
77 expect(isPlainData([1, 2, 3])).toBe(true);
78 expect(isPlainData({})).toBe(true);
79 expect(isPlainData({ a: 1 })).toBe(true);
80 expect(isPlainData(Object.create(null))).toBe(true);
81 });
82
83 test("rejects primitives and functions", () => {
84 expect(isPlainData(undefined)).toBe(false);
85 expect(isPlainData(null)).toBe(false);
86 expect(isPlainData(0)).toBe(false);
87 expect(isPlainData("s")).toBe(false);
88 expect(isPlainData(true)).toBe(false);
89 expect(isPlainData(Symbol("s"))).toBe(false);
90 expect(isPlainData(10n)).toBe(false);
91 expect(isPlainData(() => {})).toBe(false);
92 });
93
94 test("rejects every class instance — built-in, effect, and user-defined", () => {
95 class Custom {}
96 expect(isPlainData(new Custom())).toBe(false);
97 expect(isPlainData(new Date())).toBe(false);
98 expect(isPlainData(new Map())).toBe(false);
99 expect(isPlainData(new Set())).toBe(false);
100 expect(isPlainData(new URL("https://example.com"))).toBe(false);
101 expect(isPlainData(/regex/)).toBe(false);
102 expect(isPlainData(Buffer.from("x"))).toBe(false);
103 expect(isPlainData(Effect.succeed(1))).toBe(false);
104 expect(isPlainData(Layer.empty)).toBe(false);
105 expect(isPlainData(Context.empty())).toBe(false);
106 expect(isPlainData(Redacted.make("s"))).toBe(false);
107 expect(isPlainData(Duration.seconds(1))).toBe(false);
108 });
109});
110
111describe("mapPlainData", { tags: ["unit", "local"] }, () => {
112 const identity = (ancestors: WeakSet<object>) => {
113 const walk = (child: unknown): unknown =>
114 isPlainData(child) ? mapPlainData(child, ancestors, walk) : child;
115 return walk;
116 };
117
118 test("rebuilds arrays as arrays and objects as objects", () => {
119 const ancestors = new WeakSet<object>();
120 const rebuilt: any = mapPlainData(
121 { a: [1, { b: 2 }], c: { d: [3] } },
122 ancestors,
123 identity(ancestors),
124 );
125 expect(rebuilt).toEqual({ a: [1, { b: 2 }], c: { d: [3] } });
126 expect(Array.isArray(rebuilt.a)).toBe(true);
127 expect(Array.isArray(rebuilt.c.d)).toBe(true);
128 });
129
130 test("cuts a self-referencing object to undefined", () => {
131 const cyclic: any = { name: "x" };
132 cyclic.self = cyclic;
133 const ancestors = new WeakSet<object>();
134 const rebuilt: any = mapPlainData(cyclic, ancestors, identity(ancestors));
135 expect(rebuilt.name).toBe("x");
136 expect(rebuilt.self).toBeUndefined();
137 });
138
139 test("cuts a mutual object<->array cycle to undefined", () => {
140 const obj: any = { tag: "obj" };
141 const arr: any[] = [obj];
142 obj.arr = arr;
143 const ancestors = new WeakSet<object>();
144 const rebuilt: any = mapPlainData(
145 { root: obj },
146 ancestors,
147 identity(ancestors),
148 );
149 expect(rebuilt.root.tag).toBe("obj");
150 expect(rebuilt.root.arr[0]).toBeUndefined();
151 });
152
153 test("preserves diamonds — the same object referenced twice is rebuilt twice", () => {
154 const shared = { v: 1 };
155 const ancestors = new WeakSet<object>();
156 const rebuilt: any = mapPlainData(
157 { left: shared, right: shared, list: [shared] },
158 ancestors,
159 identity(ancestors),
160 );
161 expect(rebuilt.left).toEqual({ v: 1 });
162 expect(rebuilt.right).toEqual({ v: 1 });
163 expect(rebuilt.list[0]).toEqual({ v: 1 });
164 });
165
166 test("leaves the ancestors set empty afterwards (guard is path-scoped)", () => {
167 const value = { a: { b: [1] } };
168 const ancestors = new WeakSet<object>();
169 mapPlainData(value, ancestors, identity(ancestors));
170 // Re-walking the same value must not see stale ancestors.
171 const again: any = mapPlainData(value, ancestors, identity(ancestors));
172 expect(again).toEqual({ a: { b: [1] } });
173 });
174});