| 1 | // Views (docs/views.md): the fold's clamps and the pure schema expansion. |
| 2 | // The vectors in test/event-vectors/views.json pin the everyday folds; this |
| 3 | // file covers the bounds a vector file would bloat on (caps, eviction, byte |
| 4 | // limits) and proves the expanded schema drives the shared engine. |
| 5 | import assert from "assert"; |
| 6 | import { viewsApp, viewSchema } from "../web/static/apps/views.js"; |
| 7 | import { listModel, rowModel, initialState, foldSchemaEvent } from "../web/static/schema-engine.js"; |
| 8 | |
| 9 | let passes = 0; |
| 10 | let fails = 0; |
| 11 | function test(name, fn) { |
| 12 | try { |
| 13 | fn(); |
| 14 | console.log(`PASS ${name}`); |
| 15 | passes++; |
| 16 | } catch (e) { |
| 17 | console.error(`FAIL ${name}`); |
| 18 | console.error(e.message); |
| 19 | fails++; |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | const fold = (state, event) => viewsApp.events[event.type](state, event); |
| 24 | const create = (id, ts, extra = {}) => ({ |
| 25 | type: "view.create", |
| 26 | id, |
| 27 | title: "T " + id, |
| 28 | items: [{ id: "a", title: "Row" }], |
| 29 | name: "Eleven", |
| 30 | ts, |
| 31 | ...extra, |
| 32 | }); |
| 33 | |
| 34 | test("item cap: 51 items fold to 50", () => { |
| 35 | const items = Array.from({ length: 51 }, (_, i) => ({ title: "row " + i })); |
| 36 | const s = fold({ views: [] }, create("vw-1", 1, { items })); |
| 37 | assert.strictEqual(s.views[0].items.length, 50); |
| 38 | }); |
| 39 | |
| 40 | test("string clamps: oversized fields are cut, never refused", () => { |
| 41 | const s = fold( |
| 42 | { views: [] }, |
| 43 | create("vw-1", 1, { |
| 44 | title: "x".repeat(200), |
| 45 | items: [{ title: "t".repeat(400), subtitle: "s".repeat(400), note: "n".repeat(600) }], |
| 46 | }) |
| 47 | ); |
| 48 | const v = s.views[0]; |
| 49 | assert.strictEqual(v.title.length, 80); |
| 50 | assert.strictEqual(v.items[0].title.length, 300); |
| 51 | assert.strictEqual(v.items[0].subtitle.length, 300); |
| 52 | assert.strictEqual(v.items[0].note.length, 500); |
| 53 | }); |
| 54 | |
| 55 | test("byte cap: a view over 32KB folds to a no-op", () => { |
| 56 | const items = Array.from({ length: 50 }, (_, i) => ({ |
| 57 | title: "t".repeat(300), |
| 58 | subtitle: "s".repeat(300), |
| 59 | note: "n".repeat(500), |
| 60 | })); |
| 61 | const s = fold({ views: [] }, create("vw-big", 1, { items })); |
| 62 | assert.deepStrictEqual(s.views, []); |
| 63 | }); |
| 64 | |
| 65 | test("eviction: the 21st view evicts the oldest", () => { |
| 66 | let s = { views: [] }; |
| 67 | for (let i = 1; i <= 21; i++) s = fold(s, create("vw-" + i, i)); |
| 68 | assert.strictEqual(s.views.length, 20); |
| 69 | assert.ok(!s.views.some((v) => v.id === "vw-1"), "oldest evicted"); |
| 70 | assert.strictEqual(s.views[19].id, "vw-21"); |
| 71 | }); |
| 72 | |
| 73 | test("items without titles drop; an all-dropped event is a no-op", () => { |
| 74 | const s = fold({ views: [] }, create("vw-1", 1, { items: [{ subtitle: "no title" }, { url: "https://x.example" }] })); |
| 75 | assert.deepStrictEqual(s.views, []); |
| 76 | }); |
| 77 | |
| 78 | test("bad event shapes never throw", () => { |
| 79 | for (const e of [ |
| 80 | { type: "view.create" }, |
| 81 | { type: "view.create", id: "x!", title: "t", items: [{ title: "r" }] }, |
| 82 | { type: "view.create", id: "ok", title: "t", items: "nope" }, |
| 83 | { type: "view.update", id: 7 }, |
| 84 | { type: "view.remove" }, |
| 85 | ]) { |
| 86 | const s = fold({ views: [] }, e); |
| 87 | assert.deepStrictEqual(s.views, []); |
| 88 | } |
| 89 | }); |
| 90 | |
| 91 | test("update keeps the view's position and original ts when the event has none", () => { |
| 92 | let s = { views: [] }; |
| 93 | s = fold(s, create("vw-1", 100)); |
| 94 | s = fold(s, create("vw-2", 200)); |
| 95 | s = fold(s, { type: "view.update", id: "vw-1", title: "Fresh", items: [{ title: "new" }], ts: 0 }); |
| 96 | assert.strictEqual(s.views[0].id, "vw-1", "position kept"); |
| 97 | assert.strictEqual(s.views[0].title, "Fresh"); |
| 98 | assert.strictEqual(s.views[0].ts, 100, "original ts kept when the update carries none"); |
| 99 | }); |
| 100 | |
| 101 | // ---- the schema expansion drives the real engine --------------------------- |
| 102 | |
| 103 | const sample = { |
| 104 | id: "vw-1", |
| 105 | title: "Top GitHub issues", |
| 106 | glyph: "⬢", |
| 107 | items: [ |
| 108 | { id: "412", title: "Crash on login", subtitle: "#412 · amy", note: "urgent", url: "https://github.com/o/r/issues/412", accent: true }, |
| 109 | { id: "9", title: "Dark mode flicker", subtitle: "", note: "", url: "", accent: false }, |
| 110 | ], |
| 111 | by: "Eleven", |
| 112 | ts: 1700000000000, |
| 113 | }; |
| 114 | |
| 115 | test("viewSchema: the engine lists every item, in order, in one section", () => { |
| 116 | const schema = viewSchema(sample); |
| 117 | const model = listModel(schema, { items: sample.items }, Date.now(), { query: "", collapsed: {} }); |
| 118 | assert.strictEqual(model.sections.length, 1); |
| 119 | assert.deepStrictEqual( |
| 120 | model.sections[0].rows.map((r) => r.item.id), |
| 121 | ["412", "9"] |
| 122 | ); |
| 123 | }); |
| 124 | |
| 125 | test("viewSchema: rowModel resolves title/subtitle/note and the accent flag", () => { |
| 126 | const schema = viewSchema(sample); |
| 127 | const row = rowModel(schema, sample.items[0], Date.now()); |
| 128 | assert.strictEqual(row.title, "Crash on login"); |
| 129 | assert.strictEqual(row.subtitle, "#412 · amy"); |
| 130 | assert.strictEqual(row.note, "urgent"); |
| 131 | assert.strictEqual(row.accent, true); |
| 132 | const bare = rowModel(schema, sample.items[1], Date.now()); |
| 133 | assert.strictEqual(bare.subtitle, "", "empty subtitle stays empty"); |
| 134 | }); |
| 135 | |
| 136 | test("viewSchema: search narrows across title/subtitle/note", () => { |
| 137 | const schema = viewSchema(sample); |
| 138 | const model = listModel(schema, { items: sample.items }, Date.now(), { query: "flicker", collapsed: {} }); |
| 139 | assert.deepStrictEqual( |
| 140 | model.sections[0].rows.map((r) => r.item.id), |
| 141 | ["9"] |
| 142 | ); |
| 143 | }); |
| 144 | |
| 145 | test("viewSchema: declares no events, no composer, no ticker — inert by construction", () => { |
| 146 | const schema = viewSchema(sample); |
| 147 | assert.deepStrictEqual(schema.events, {}); |
| 148 | assert.strictEqual(schema.views.main.composer, undefined); |
| 149 | assert.strictEqual(schema.ticker, undefined); |
| 150 | // And the engine accepts it end-to-end: initial state + a stray event no-op. |
| 151 | const s0 = initialState(schema); |
| 152 | assert.deepStrictEqual(s0, { items: [] }); |
| 153 | assert.deepStrictEqual(foldSchemaEvent(schema, s0, { type: "view.create" }), s0); |
| 154 | }); |
| 155 | |
| 156 | console.log(`\n${passes} passed, ${fails} failed`); |
| 157 | if (fails) process.exit(1); |
| 158 | |