social components inlay-proto.up.railway.app/
atproto components sdui
at main 255 lines 7.6 kB view raw
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});