|
| 1 | +/** |
| 2 | + * Size-aware renderMarkdown — the six-tier density contract. |
| 3 | + * |
| 4 | + * icon = identity only, row = one line, small…xlarge = budgeted full |
| 5 | + * renders, no opts = legacy full render (unchanged output). |
| 6 | + */ |
| 7 | + |
| 8 | +import { test, expect, describe } from "bun:test"; |
| 9 | +import { renderMarkdown } from "./markdown.js"; |
| 10 | +import { renderText } from "./text.js"; |
| 11 | +import type { WidgetData, ListWidget, TableWidget, PlanWidget } from "../widgets.js"; |
| 12 | + |
| 13 | +const list = (n: number): ListWidget => ({ |
| 14 | + type: "list", |
| 15 | + title: "Inbox", |
| 16 | + items: Array.from({ length: n }, (_, i) => ({ |
| 17 | + title: `Mail ${i + 1}`, |
| 18 | + subtitle: `sender${i + 1}@example.com`, |
| 19 | + })), |
| 20 | +}); |
| 21 | + |
| 22 | +const table = (n: number): TableWidget => ({ |
| 23 | + type: "table", |
| 24 | + title: "Orders", |
| 25 | + columns: ["id", "amount"], |
| 26 | + rows: Array.from({ length: n }, (_, i) => ({ id: `o${i + 1}`, amount: i * 10 })), |
| 27 | +}); |
| 28 | + |
| 29 | +const plan: PlanWidget = { |
| 30 | + type: "plan", |
| 31 | + title: "Deploy", |
| 32 | + steps: [ |
| 33 | + { label: "build", status: "completed" }, |
| 34 | + { label: "test", status: "in_progress" }, |
| 35 | + { label: "ship", status: "pending" }, |
| 36 | + ], |
| 37 | +}; |
| 38 | + |
| 39 | +describe("legacy (no opts) is unchanged", () => { |
| 40 | + test("list caps at 30 with more-footer", () => { |
| 41 | + const md = renderMarkdown(list(40)); |
| 42 | + expect(md).toContain("**Mail 30**"); |
| 43 | + expect(md).not.toContain("**Mail 31**"); |
| 44 | + expect(md).toContain("_… 10 more_"); |
| 45 | + }); |
| 46 | + |
| 47 | + test("document truncates at 600 chars", () => { |
| 48 | + const md = renderMarkdown({ type: "document", title: "Doc", body: "x".repeat(700) }); |
| 49 | + expect(md).toContain("x".repeat(600) + "…"); |
| 50 | + }); |
| 51 | +}); |
| 52 | + |
| 53 | +describe("icon: identity only", () => { |
| 54 | + test("list → title + count, no items", () => { |
| 55 | + const md = renderMarkdown(list(12), { size: "icon" }); |
| 56 | + expect(md).toBe("**Inbox** · 12 items"); |
| 57 | + }); |
| 58 | + |
| 59 | + test("plan → progress", () => { |
| 60 | + expect(renderMarkdown(plan, { size: "icon" })).toBe("**Deploy** · 1/3 done"); |
| 61 | + }); |
| 62 | + |
| 63 | + test("status → state + message", () => { |
| 64 | + const md = renderMarkdown({ type: "status", state: "ok", message: "live" }, { size: "icon" }); |
| 65 | + expect(md).toBe("✓ **live**"); |
| 66 | + }); |
| 67 | +}); |
| 68 | + |
| 69 | +describe("row: one line", () => { |
| 70 | + test("list → count + first titles, single line", () => { |
| 71 | + const md = renderMarkdown(list(12), { size: "row" }); |
| 72 | + expect(md).toContain("12 items"); |
| 73 | + expect(md).toContain("Mail 1"); |
| 74 | + expect(md).not.toContain("\n"); |
| 75 | + expect(md.length).toBeLessThanOrEqual(161); // 160 + ellipsis |
| 76 | + }); |
| 77 | + |
| 78 | + test("table → dimensions, no rows", () => { |
| 79 | + const md = renderMarkdown(table(45), { size: "row" }); |
| 80 | + expect(md).toBe("**Orders** · 45 rows × 2 cols"); |
| 81 | + }); |
| 82 | + |
| 83 | + test("plan → progress + current step", () => { |
| 84 | + const md = renderMarkdown(plan, { size: "row" }); |
| 85 | + expect(md).toBe("**Deploy** · 1/3 done — current: test"); |
| 86 | + }); |
| 87 | + |
| 88 | + test("calendar → next event", () => { |
| 89 | + const md = renderMarkdown({ |
| 90 | + type: "calendar", title: "Week", |
| 91 | + events: [{ title: "Standup", startsAt: "2026-06-12T09:00:00Z" }], |
| 92 | + }, { size: "row" }); |
| 93 | + expect(md).toContain("next:"); |
| 94 | + expect(md).toContain("Standup"); |
| 95 | + }); |
| 96 | +}); |
| 97 | + |
| 98 | +describe("small…xlarge budgets", () => { |
| 99 | + test("small list shows 4", () => { |
| 100 | + const md = renderMarkdown(list(12), { size: "small" }); |
| 101 | + expect(md).toContain("**Mail 4**"); |
| 102 | + expect(md).not.toContain("**Mail 5**"); |
| 103 | + expect(md).toContain("_… 8 more_"); |
| 104 | + }); |
| 105 | + |
| 106 | + test("medium table shows 6 rows", () => { |
| 107 | + const md = renderMarkdown(table(20), { size: "medium" }); |
| 108 | + expect(md).toContain("| o6 |"); |
| 109 | + expect(md).not.toContain("| o7 |"); |
| 110 | + expect(md).toContain("_… 14 more rows_"); |
| 111 | + }); |
| 112 | + |
| 113 | + test("xlarge list shows 30", () => { |
| 114 | + const md = renderMarkdown(list(40), { size: "xlarge" }); |
| 115 | + expect(md).toContain("**Mail 30**"); |
| 116 | + expect(md).toContain("_… 10 more_"); |
| 117 | + }); |
| 118 | + |
| 119 | + test("totalItems drives the footer when items are pre-sliced", () => { |
| 120 | + const w = { ...list(8), totalItems: 100 }; |
| 121 | + const md = renderMarkdown(w, { size: "medium" }); |
| 122 | + expect(md).toContain("_… 92 more_"); |
| 123 | + }); |
| 124 | + |
| 125 | + test("stack threads budget to children", () => { |
| 126 | + const w: WidgetData = { type: "stack", header: { title: "Wrap" }, body: [list(12)] }; |
| 127 | + const md = renderMarkdown(w, { size: "small" }); |
| 128 | + expect(md).toContain("**Mail 4**"); |
| 129 | + expect(md).not.toContain("**Mail 5**"); |
| 130 | + }); |
| 131 | +}); |
| 132 | + |
| 133 | +describe("renderText passes size through", () => { |
| 134 | + test("row stays one line, markdown stripped", () => { |
| 135 | + const txt = renderText(list(12), { size: "row" }); |
| 136 | + expect(txt).not.toContain("**"); |
| 137 | + expect(txt).toContain("12 items"); |
| 138 | + }); |
| 139 | +}); |
| 140 | + |
| 141 | +describe("every type renders at every size without throwing", () => { |
| 142 | + const samples: WidgetData[] = [ |
| 143 | + { type: "icon", glyph: "<svg/>", color: "blue", label: "App", badge: 3 }, |
| 144 | + { type: "stack", header: { title: "S" }, body: [list(2)] }, |
| 145 | + list(3), |
| 146 | + table(3), |
| 147 | + { type: "metric", label: "MRR", value: 100, unit: "€", trend: "up", trendValue: "+5%" }, |
| 148 | + { type: "metric_grid", metrics: [{ type: "metric", label: "A", value: 1 }, { type: "metric", label: "B", value: 2 }] }, |
| 149 | + { type: "key_value", title: "KV", pairs: [{ key: "k", value: "v" }] }, |
| 150 | + { type: "status", state: "warn", message: "degraded", details: [{ key: "region", value: "eu" }] }, |
| 151 | + { type: "document", title: "D", body: "hello\nworld" }, |
| 152 | + { type: "calendar", title: "C", events: [{ title: "E", startsAt: "2026-06-12T09:00:00Z" }] }, |
| 153 | + plan, |
| 154 | + { type: "empty", message: "nothing here" }, |
| 155 | + { type: "model_3d", uri: "https://x/m.glb", format: "glb", name: "Part" }, |
| 156 | + ]; |
| 157 | + const sizes = ["icon", "row", "small", "medium", "large", "xlarge", undefined] as const; |
| 158 | + |
| 159 | + for (const w of samples) { |
| 160 | + for (const size of sizes) { |
| 161 | + test(`${w.type} @ ${size ?? "legacy"}`, () => { |
| 162 | + const md = renderMarkdown(w, size ? { size } : undefined); |
| 163 | + expect(typeof md).toBe("string"); |
| 164 | + expect(md.length).toBeGreaterThan(0); |
| 165 | + if (size === "row") expect(md).not.toContain("\n"); |
| 166 | + }); |
| 167 | + } |
| 168 | + } |
| 169 | +}); |
0 commit comments