diff --git a/client/space_lua/render_lua_html.test.ts b/client/space_lua/render_lua_html.test.ts
new file mode 100644
index 00000000..7c006c8c
--- /dev/null
+++ b/client/space_lua/render_lua_html.test.ts
@@ -0,0 +1,343 @@
+import { expect, test } from "vitest";
+import { renderResultToHtml } from "./render_lua_html.ts";
+import { LuaTable } from "./runtime.ts";
+import { makeLuaFloat } from "./numeric.ts";
+import { LIQ_NULL } from "./liq_null.ts";
+
+// ── Nil / empty values ──────────────────────────────────────────────
+
+test("null renders as empty span with dataType nil", async () => {
+ const r = await renderResultToHtml(null);
+ expect(r).toEqual({ html: ``, dataType: "nil" });
+});
+
+test("undefined renders as empty span with dataType nil", async () => {
+ const r = await renderResultToHtml(undefined);
+ expect(r).toEqual({ html: ``, dataType: "nil" });
+});
+
+test("LIQ_NULL (SQL NULL) renders as empty span with dataType nil", async () => {
+ const r = await renderResultToHtml(LIQ_NULL);
+ expect(r).toEqual({ html: ``, dataType: "nil" });
+});
+
+// ── Strings ─────────────────────────────────────────────────────────
+
+test("string renders with default escaping", async () => {
+ const r = await renderResultToHtml("hello world");
+ expect(r).toEqual({ html: "hello world", dataType: "string" });
+});
+
+test("empty string is rendered (not treated as nil)", async () => {
+ const r = await renderResultToHtml("");
+ expect(r).toEqual({ html: "", dataType: "string" });
+});
+
+test("string with HTML special chars is escaped", async () => {
+ const r = await renderResultToHtml('"Tom & Jerry"');
+ expect(r).toEqual({
+ html: '<b>"Tom & Jerry"</b>',
+ dataType: "string",
+ });
+});
+
+// ── Numbers ─────────────────────────────────────────────────────────
+
+test("integer number", async () => {
+ const r = await renderResultToHtml(42);
+ expect(r).toEqual({ html: "42", dataType: "number" });
+});
+
+test("zero", async () => {
+ const r = await renderResultToHtml(0);
+ expect(r).toEqual({ html: "0", dataType: "number" });
+});
+
+test("non-integer float", async () => {
+ const r = await renderResultToHtml(1.5);
+ expect(r).toEqual({ html: "1.5", dataType: "number" });
+});
+
+test("tagged float (integer-valued)", async () => {
+ const r = await renderResultToHtml(makeLuaFloat(2));
+ expect(r).toEqual({ html: "2.0", dataType: "number" });
+});
+
+// ── Booleans ────────────────────────────────────────────────────────
+
+test("boolean true", async () => {
+ const r = await renderResultToHtml(true);
+ expect(r).toEqual({ html: "true", dataType: "boolean" });
+});
+
+test("boolean false", async () => {
+ const r = await renderResultToHtml(false);
+ expect(r).toEqual({ html: "false", dataType: "boolean" });
+});
+
+// ── Fallback (unknown object type) ──────────────────────────────────
+
+test("non-matching object is stringified via renderInline", async () => {
+ const d = new Date("2024-01-15T00:00:00.000Z");
+ const r = await renderResultToHtml(d);
+ expect(r.dataType).toBe("string");
+ expect(r.html).toContain("2024"); // toString includes year
+});
+
+// ── Custom renderInline ─────────────────────────────────────────────
+
+test("custom renderInline is used for strings", async () => {
+ const custom = (s: string) => `${s}`;
+ const r = await renderResultToHtml("hello", custom);
+ expect(r).toEqual({ html: "hello", dataType: "string" });
+});
+
+// ── LuaTable: empty ─────────────────────────────────────────────────
+
+test("empty LuaTable", async () => {
+ const r = await renderResultToHtml(new LuaTable());
+ expect(r).toEqual({
+ html: `
`,
+ dataType: "table",
+ });
+});
+
+// ── LuaTable: pure array of scalars ─────────────────────────────────
+
+test("LuaTable array of scalars renders as ", async () => {
+ const tbl = new LuaTable();
+ tbl.rawSet(1, "alpha");
+ tbl.rawSet(2, "beta");
+ tbl.rawSet(3, "gamma");
+
+ const r = await renderResultToHtml(tbl);
+ expect(r.dataType).toBe("list");
+ expect(r.html).toBe(
+ '- alpha
' +
+ '- beta
' +
+ '- gamma
',
+ );
+});
+
+// ── LuaTable: array of LuaTables → multi-row table ─────────────────
+
+test("LuaTable array of LuaTables renders as multi-row table", async () => {
+ const row1 = new LuaTable();
+ row1.rawSet("name", "Alice");
+ row1.rawSet("age", 30);
+
+ const row2 = new LuaTable();
+ row2.rawSet("name", "Bob");
+ row2.rawSet("age", 25);
+
+ const tbl = new LuaTable();
+ tbl.rawSet(1, row1);
+ tbl.rawSet(2, row2);
+
+ const r = await renderResultToHtml(tbl);
+ expect(r.dataType).toBe("list");
+ expect(r.html).toBe(
+ "" +
+ "| name | age |
" +
+ "" +
+ '| Alice | 30 |
' +
+ '| Bob | 25 |
' +
+ "" +
+ "
",
+ );
+});
+
+// ── LuaTable: record (string keys) ─────────────────────────────────
+
+test("LuaTable with string keys renders as single-row table", async () => {
+ const tbl = new LuaTable();
+ tbl.rawSet("x", 10);
+ tbl.rawSet("y", 20);
+
+ const r = await renderResultToHtml(tbl);
+ expect(r.dataType).toBe("table");
+ expect(r.html).toBe(
+ "" +
+ "| x | y |
" +
+ "" +
+ '| 10 | ' +
+ '20 | ' +
+ "
" +
+ "
",
+ );
+});
+
+// ── LuaTable: mixed keys ────────────────────────────────────────────
+
+test("LuaTable with mixed keys uses keys order in header", async () => {
+ const tbl = new LuaTable();
+ tbl.rawSet(1, "first");
+ tbl.rawSet("label", "test");
+
+ const r = await renderResultToHtml(tbl);
+ expect(r.dataType).toBe("table");
+ // hasStringKeys → record path; keys() returns both 1 and "label"
+ expect(r.html).toContain("1 | ");
+ expect(r.html).toContain("label | ");
+ expect(r.html).toContain("first");
+ expect(r.html).toContain("test");
+});
+
+// ── JS arrays ───────────────────────────────────────────────────────
+
+test("empty JS array", async () => {
+ const r = await renderResultToHtml([]);
+ expect(r).toEqual({
+ html: ``,
+ dataType: "table",
+ });
+});
+
+test("JS array of plain objects renders as multi-row table", async () => {
+ const r = await renderResultToHtml([
+ { id: 1, name: "Alice" },
+ { id: 2, name: "Bob" },
+ ]);
+ expect(r.dataType).toBe("table");
+ expect(r.html).toBe(
+ "" +
+ "| id | name |
" +
+ "" +
+ '| 1 | Alice |
' +
+ '| 2 | Bob |
' +
+ "" +
+ "
",
+ );
+});
+
+test("JS array of scalars renders as ", async () => {
+ const r = await renderResultToHtml([10, 20, 30]);
+ expect(r.dataType).toBe("list");
+ expect(r.html).toBe(
+ '',
+ );
+});
+
+// ── JS objects ──────────────────────────────────────────────────────
+
+test("empty JS object", async () => {
+ const r = await renderResultToHtml({});
+ expect(r).toEqual({
+ html: ``,
+ dataType: "table",
+ });
+});
+
+test("single JS plain object renders as single-row table", async () => {
+ const r = await renderResultToHtml({ color: "red", count: 5 });
+ expect(r.dataType).toBe("table");
+ expect(r.html).toBe(
+ "" +
+ "| color | count |
" +
+ "" +
+ '| red | ' +
+ '5 | ' +
+ "
" +
+ "
",
+ );
+});
+
+// ── Null / empty values inside cells ────────────────────────────────
+
+test("null value in a table cell gets data-table-cell-empty", async () => {
+ const r = await renderResultToHtml([{ a: 1, b: null }]);
+ expect(r.html).toContain('data-table-cell-empty');
+ expect(r.html).toContain("b | ");
+});
+
+test("LIQ_NULL in a table cell gets data-table-cell-empty", async () => {
+ const r = await renderResultToHtml([{ a: 1, b: LIQ_NULL }]);
+ expect(r.html).toContain('data-table-cell-empty');
+});
+
+test("null item in a list gets data-list-item-empty", async () => {
+ const r = await renderResultToHtml([1, null, 3]);
+ expect(r.dataType).toBe("list");
+ expect(r.html).toContain("data-list-item-empty");
+});
+
+test("LIQ_NULL in LuaTable cell gets data-table-cell-empty", async () => {
+ const row = new LuaTable();
+ row.rawSet("val", LIQ_NULL);
+
+ const tbl = new LuaTable();
+ tbl.rawSet(1, row);
+
+ const r = await renderResultToHtml(tbl);
+ expect(r.html).toContain("data-table-cell-empty");
+});
+
+// ── Sparse / missing key handling ───────────────────────────────────
+
+test("JS object array with different keys shows union of headers", async () => {
+ const r = await renderResultToHtml([
+ { a: 1, b: 2 },
+ { b: 3, c: 4 },
+ ]);
+ expect(r.dataType).toBe("table");
+ expect(r.html).toContain("a | ");
+ expect(r.html).toContain("b | ");
+ expect(r.html).toContain("c | ");
+ // Row 1 has no "c", row 2 has no "a" → both get empty cells
+ // Count occurrences of data-table-cell-empty
+ const empties = r.html.match(/data-table-cell-empty/g);
+ expect(empties).toHaveLength(2);
+});
+
+test("LuaTable array with different keys shows union of headers", async () => {
+ const row1 = new LuaTable();
+ row1.rawSet("x", 1);
+
+ const row2 = new LuaTable();
+ row2.rawSet("y", 2);
+
+ const tbl = new LuaTable();
+ tbl.rawSet(1, row1);
+ tbl.rawSet(2, row2);
+
+ const r = await renderResultToHtml(tbl);
+ expect(r.html).toContain("x | ");
+ expect(r.html).toContain("y | ");
+ const empties = r.html.match(/data-table-cell-empty/g);
+ expect(empties).toHaveLength(2);
+});
+
+// ── Nested rendering ────────────────────────────────────────────────
+
+test("nested LuaTable in a cell renders recursively", async () => {
+ const inner = new LuaTable();
+ inner.rawSet("nested", "value");
+
+ const tbl = new LuaTable();
+ tbl.rawSet("data", inner);
+
+ const r = await renderResultToHtml(tbl);
+ expect(r.dataType).toBe("table");
+ // The outer td should contain a nested
+ expect(r.html).toContain('| ');
+ expect(r.html).toContain(" | nested | ");
+ expect(r.html).toContain("value");
+});
+
+test("nested JS array in a plain object cell", async () => {
+ const r = await renderResultToHtml({ items: [1, 2, 3] });
+ expect(r.dataType).toBe("table");
+ expect(r.html).toContain('');
+ expect(r.html).toContain("");
+ expect(r.html).toContain("- {
+ const tbl = new LuaTable();
+ tbl.rawSet("child", new LuaTable());
+
+ const r = await renderResultToHtml(tbl);
+ expect(r.html).toContain("data-table-empty");
+});
|