social components
inlay-proto.up.railway.app/
atproto
components
sdui
1import { describe, it } from "node:test";
2import assert from "node:assert/strict";
3
4import {
5 $,
6 serializeTree,
7 deserializeTree,
8 resolveBindings,
9 isValidElement,
10 type Element,
11} from "@inlay/core";
12
13// --- Branding ---
14
15describe("branding", () => {
16 it("only $-created objects are valid elements", () => {
17 assert.equal(isValidElement($("com.example.Text", { value: "hi" })), true);
18 assert.equal(
19 isValidElement({ type: "com.example.Text", props: { value: "hi" } }),
20 false
21 );
22 });
23
24 it("primitives and nulls are never elements", () => {
25 for (const v of [null, undefined, 0, "", false, 42, "hello"]) {
26 assert.equal(isValidElement(v), false);
27 }
28 });
29});
30
31// --- Round-trip fidelity ---
32
33describe("round-trip", () => {
34 it("serialize then deserialize is identity", () => {
35 const original = $("com.example.Text", { value: "hello", count: 42 });
36 assert.deepEqual(deserializeTree(serializeTree(original)), original);
37 });
38
39 it("survives JSON in between", () => {
40 const original = $(
41 "com.example.Stack",
42 {},
43 $("com.example.Text", { value: "a" }),
44 $("com.example.Text", { value: "b" })
45 );
46 const wire = serializeTree(original);
47 assert.deepEqual(
48 deserializeTree(JSON.parse(JSON.stringify(wire))),
49 original
50 );
51 });
52
53 it("preserves keys", () => {
54 const original = $("com.example.Text", { key: "my-key", value: "hi" });
55 assert.deepEqual(deserializeTree(serializeTree(original)), original);
56 });
57
58 it("handles deep nesting", () => {
59 let tree = $("com.example.Text", { value: "leaf" });
60 for (let i = 0; i < 5; i++) {
61 tree = $("com.example.Box", {}, tree);
62 }
63 assert.deepEqual(deserializeTree(serializeTree(tree)), tree);
64 });
65
66 it("elements nested in plain objects survive", () => {
67 const tree = $("com.example.Card", {
68 header: {
69 title: "Hello",
70 icon: $("com.example.Icon", { name: "star" }),
71 },
72 });
73 assert.deepEqual(deserializeTree(serializeTree(tree)), tree);
74 });
75
76 it("mixed arrays of elements and primitives survive", () => {
77 const tree = $("com.example.RichText", {
78 children: ["Hello ", $("com.example.Bold", { children: "world" }), "!"],
79 });
80 assert.deepEqual(deserializeTree(serializeTree(tree)), tree);
81 });
82});
83
84// --- $ function ---
85
86describe("$ function", () => {
87 it("separates key from props", () => {
88 const node = $("com.example.Text", { key: "k1", value: "hi" });
89 assert.equal(node.key, "k1");
90 assert.equal((node.props as Record<string, unknown>)?.key, undefined);
91 assert.equal((node.props as Record<string, unknown>)?.value, "hi");
92 });
93
94 it("auto-keys static children", () => {
95 const node = $(
96 "com.example.Stack",
97 {},
98 $("com.example.Text", { value: "a" }),
99 $("com.example.Text", { value: "b" })
100 );
101 const children = (node.props as Record<string, unknown>).children as Array<{
102 key?: string;
103 }>;
104 assert.deepEqual(
105 children.map((c) => c.key),
106 ["0", "1"]
107 );
108 });
109
110 it("rejects invalid NSIDs", () => {
111 assert.throws(() => $("not-an-nsid", {}));
112 assert.throws(() => $("", {}));
113 });
114});
115
116// --- Binding resolution ---
117
118// Simple scope lookup for tests — optional chaining, returns undefined on miss
119function resolvePath(scope: Record<string, unknown>, path: string[]): unknown {
120 let current: unknown = scope;
121 for (const seg of path) {
122 if (current == null || typeof current !== "object") return undefined;
123 current = (current as Record<string, unknown>)[seg];
124 }
125 return current;
126}
127
128// Test resolver: looks up path in scope, throws on miss
129function scopeResolver(scope: Record<string, unknown>) {
130 return (path: string[]) => {
131 const value = resolvePath(scope, path);
132 if (value === undefined) {
133 throw new Error(`MISSING:${path.join(".")}`);
134 }
135 return value;
136 };
137}
138
139describe("resolveBindings", () => {
140 // resolveBindings operates on props (plain objects), not on elements directly.
141 // Elements are opaque — it won't recurse into their props.
142
143 it("resolves binding in prop", () => {
144 const props = {
145 value: $("at.inlay.Binding", { path: ["reply", "text"] }),
146 };
147 assert.deepEqual(
148 resolveBindings(props, scopeResolver({ reply: { text: "Hello" } })),
149 { value: "Hello" }
150 );
151 });
152
153 it("resolves binding nested in plain object", () => {
154 const props = {
155 heading: {
156 caption: $("at.inlay.Binding", { path: ["reply", "text"] }),
157 },
158 };
159 assert.deepEqual(
160 resolveBindings(props, scopeResolver({ reply: { text: "Hello" } })),
161 { heading: { caption: "Hello" } }
162 );
163 });
164
165 it("resolves binding in array", () => {
166 const props = {
167 items: [$("at.inlay.Binding", { path: ["user", "name"] }), "literal"],
168 };
169 assert.deepEqual(
170 resolveBindings(props, scopeResolver({ user: { name: "Dan" } })),
171 { items: ["Dan", "literal"] }
172 );
173 });
174
175 it("non-Binding elements are opaque — bindings inside stay unresolved", () => {
176 const binding = $("at.inlay.Binding", { path: ["reply", "text"] });
177 const text = $("com.example.Text", { value: binding });
178 const props = { children: [text] };
179 const result = resolveBindings(
180 props,
181 scopeResolver({ reply: { text: "Hello" } })
182 ) as Record<string, unknown>;
183 // Text element passed through opaque — binding inside is untouched
184 const children = result.children as Element[];
185 const textProps = children[0].props as Record<string, unknown>;
186 assert.ok(
187 isValidElement(textProps.value),
188 "binding inside Text is still an element"
189 );
190 assert.equal((textProps.value as Element).type, "at.inlay.Binding");
191 });
192
193 it("resolver controls missing behavior", () => {
194 const props = {
195 value: $("at.inlay.Binding", { path: ["nonexistent"] }),
196 };
197 assert.throws(
198 () => resolveBindings(props, scopeResolver({})),
199 /MISSING:nonexistent/
200 );
201 });
202
203 it("resolver can return sentinel for missing", () => {
204 const MISSING = Symbol("missing");
205 const props = {
206 value: $("at.inlay.Binding", { path: ["nope"] }),
207 };
208 const result = resolveBindings(props, (path) => {
209 const v = resolvePath({}, path);
210 return v === undefined ? MISSING : v;
211 }) as Record<string, unknown>;
212 assert.equal(result.value, MISSING);
213 });
214
215 it("preserves null scope values", () => {
216 const props = {
217 value: $("at.inlay.Binding", { path: ["nothing"] }),
218 };
219 assert.deepEqual(
220 resolveBindings(props, (path) => resolvePath({ nothing: null }, path)),
221 { value: null }
222 );
223 });
224
225 it("preserves falsy scope values", () => {
226 const scope = { zero: 0, no: false, empty: "" };
227 const props = {
228 a: $("at.inlay.Binding", { path: ["zero"] }),
229 b: $("at.inlay.Binding", { path: ["no"] }),
230 c: $("at.inlay.Binding", { path: ["empty"] }),
231 };
232 assert.deepEqual(
233 resolveBindings(props, (path) => resolvePath(scope, path)),
234 { a: 0, b: false, c: "" }
235 );
236 });
237
238 it("no bindings — identity", () => {
239 const props = { title: "hello", count: 42 };
240 const result = resolveBindings(props, () => {
241 throw new Error("should not be called");
242 });
243 assert.deepEqual(result, props);
244 });
245
246 it("throws on Binding missing path", () => {
247 const props = {
248 value: $("at.inlay.Binding", {}),
249 };
250 assert.throws(
251 () => resolveBindings(props, () => "x"),
252 /Binding missing path/
253 );
254 });
255});