@loadbare/app 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +3 -4
  2. package/dist/build/assemble.d.ts +1 -1
  3. package/dist/build/assemble.d.ts.map +1 -1
  4. package/dist/build/assemble.js +80 -7
  5. package/dist/build/cli.d.ts +2 -2
  6. package/dist/build/cli.js +2 -2
  7. package/dist/build/expand.d.ts.map +1 -1
  8. package/dist/build/expand.js +30 -34
  9. package/dist/build/locations.d.ts +3 -3
  10. package/dist/build/locations.d.ts.map +1 -1
  11. package/dist/build/locations.js +15 -3
  12. package/dist/build/origins.d.ts +0 -13
  13. package/dist/build/origins.d.ts.map +1 -1
  14. package/dist/build/origins.js +32 -8
  15. package/dist/build/pages.d.ts +3 -3
  16. package/dist/build/pages.d.ts.map +1 -1
  17. package/dist/build/pages.js +9 -7
  18. package/dist/core/lb-constants.d.ts +17 -12
  19. package/dist/core/lb-constants.d.ts.map +1 -1
  20. package/dist/core/lb-constants.js +106 -43
  21. package/dist/core/lb-types.d.ts +103 -62
  22. package/dist/core/lb-types.d.ts.map +1 -1
  23. package/dist/core/lb-types.js +11 -3
  24. package/dist/hub/lb-apply.d.ts +18 -4
  25. package/dist/hub/lb-apply.d.ts.map +1 -1
  26. package/dist/hub/lb-apply.js +184 -34
  27. package/dist/hub/lb-hub.browser.d.ts +1 -1
  28. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  29. package/dist/hub/lb-hub.browser.js +165 -83
  30. package/dist/server/lb-express.d.ts +7 -4
  31. package/dist/server/lb-express.d.ts.map +1 -1
  32. package/dist/server/lb-express.js +45 -33
  33. package/dist/server/lb-server.d.ts +67 -45
  34. package/dist/server/lb-server.d.ts.map +1 -1
  35. package/dist/server/lb-server.js +55 -18
  36. package/dist/tests/assemble.test.js +154 -2
  37. package/dist/tests/expand.test.js +30 -3
  38. package/dist/tests/helpers/hub.d.ts +73 -0
  39. package/dist/tests/helpers/hub.d.ts.map +1 -0
  40. package/dist/tests/helpers/hub.js +151 -0
  41. package/dist/tests/lb-apply.test.js +86 -62
  42. package/dist/tests/lb-express.test.d.ts +1 -1
  43. package/dist/tests/lb-express.test.js +43 -38
  44. package/dist/tests/lb-hub.test.d.ts +14 -0
  45. package/dist/tests/lb-hub.test.d.ts.map +1 -0
  46. package/dist/tests/lb-hub.test.js +319 -0
  47. package/dist/tests/{lb-rows.test.d.ts → lb-list.test.d.ts} +1 -1
  48. package/dist/tests/lb-list.test.d.ts.map +1 -0
  49. package/dist/tests/{lb-rows.test.js → lb-list.test.js} +109 -106
  50. package/dist/tests/lb-server.test.js +151 -100
  51. package/dist/tests/origins.test.js +19 -1
  52. package/dist/tests/pages.test.d.ts +1 -1
  53. package/dist/tests/pages.test.js +64 -14
  54. package/docs/TECHREF-1.0.md +1000 -0
  55. package/docs/reference/builder.md +4 -4
  56. package/docs/reference/chrome.md +56 -5
  57. package/docs/reference/custom-elements.md +59 -36
  58. package/docs/reference/data-binding.md +142 -84
  59. package/docs/reference/overview.md +1 -1
  60. package/docs/reference/page-files.md +64 -49
  61. package/docs/reference/server.md +7 -6
  62. package/docs/reference/widgets.md +43 -32
  63. package/docs/roadmap.md +68 -22
  64. package/docs/testing.md +47 -17
  65. package/docs/theory.md +2 -2
  66. package/docs/tutorials/010-pages-and-navigation.md +14 -8
  67. package/docs/tutorials/040-displaying-data.md +9 -9
  68. package/docs/tutorials/050-actions.md +5 -5
  69. package/docs/tutorials/060-custom-element-code.md +1 -1
  70. package/docs/tutorials/065-conditional-rendering.md +4 -4
  71. package/docs/tutorials/070-displaying-a-list.md +24 -47
  72. package/docs/tutorials/072-inserting-into-a-list.md +17 -17
  73. package/docs/tutorials/074-deleting-from-a-list.md +15 -17
  74. package/docs/tutorials/076-updating-a-list-item.md +20 -22
  75. package/docs/tutorials/080-widget-requests.md +27 -23
  76. package/docs/tutorials/090-using-widget-libraries.md +23 -1
  77. package/package.json +1 -2
  78. package/dist/hub/lb-rows.d.ts +0 -18
  79. package/dist/hub/lb-rows.d.ts.map +0 -1
  80. package/dist/hub/lb-rows.js +0 -106
  81. package/dist/tests/lb-rows.test.d.ts.map +0 -1
@@ -38,9 +38,6 @@ function defs(entries) {
38
38
  // that must not produce output. Each asserts on the message as well as the
39
39
  // throw: an author reads the message, and it is the whole of the diagnosis.
40
40
  describe("expansion rejects", () => {
41
- it("a hub tag with no definition", () => {
42
- assert.throws(() => expand(`<lb-nope></lb-nope>`, defs({})), /<lb-nope> has no definition/);
43
- });
44
41
  it("a parameter the definition never declared", () => {
45
42
  assert.throws(() => expand(`<lb-thing exp-label="Name"></lb-thing>`, defs({ "lb-thing": `<div></div>` })), /given exp-label, but its definition has no \{\{label\}\}/);
46
43
  });
@@ -101,6 +98,30 @@ describe("substitution", () => {
101
98
  const html = expand(`<lb-thing></lb-thing>`, defs({ "lb-thing": `<label>{{label}}</label>` }));
102
99
  assert.match(html, /<label><\/label>/);
103
100
  });
101
+ it("falls back to a default the definition wrote", () => {
102
+ const html = expand(`<lb-thing></lb-thing>`, defs({ "lb-thing": `<button>{{text|OK}}</button>` }));
103
+ assert.match(html, /<button>OK<\/button>/);
104
+ });
105
+ it("lets a supplied value win over a default", () => {
106
+ const html = expand(`<lb-thing exp-text="Go"></lb-thing>`, defs({ "lb-thing": `<button>{{text|OK}}</button>` }));
107
+ assert.match(html, /<button>Go<\/button>/);
108
+ });
109
+ it("trims whitespace around a name and its default", () => {
110
+ // A spaced form that shipped as literal braces would be a silent failure
111
+ // found in the browser, so the space is not part of either side.
112
+ const html = expand(`<lb-thing></lb-thing>`, defs({
113
+ "lb-thing": `<button title="{{ tip | Press me }}">{{ text | OK }}</button>`,
114
+ }));
115
+ assert.match(html, /<button title="Press me">OK<\/button>/);
116
+ });
117
+ it("emits an attribute whose default is empty", () => {
118
+ // The one case a default distinguishes from no default: presence.
119
+ // readonly="{{readonly|}}" is a boolean attribute on unless a tag turns
120
+ // it off — which, since a supplied value is a string, a tag cannot do;
121
+ // the default is for a definition that wants the attribute there.
122
+ const html = expand(`<lb-thing></lb-thing>`, defs({ "lb-thing": `<input readonly="{{readonly|}}" />` }));
123
+ assert.match(html, /<input readonly="">/);
124
+ });
104
125
  it("reaches placeholders inside template content", () => {
105
126
  // lb-picker ships a row template of its own, so {{key}} written inside
106
127
  // one has to be a placeholder rather than literal text that ships.
@@ -202,6 +223,12 @@ describe("placement", () => {
202
223
  const html = expand(`<app-box>hi</app-box>`, defs({}));
203
224
  assert.match(html, /<app-box>hi<\/app-box>/);
204
225
  });
226
+ it("leaves a tag with no definition alone, whatever its name", () => {
227
+ // No name is special, the hub's own tag included. A tag that has neither
228
+ // a definition nor a script is refused once, in build/elements.ts.
229
+ const html = expand(`<lb-nope></lb-nope><lb-hub></lb-hub>`, defs({}));
230
+ assert.match(html, /<lb-nope><\/lb-nope><lb-hub><\/lb-hub>/);
231
+ });
205
232
  });
206
233
  // --- Loading -------------------------------------------------------------
207
234
  describe("loadDefinitions", () => {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A window for tier 4 — see docs/testing.md.
3
+ *
4
+ * Tier 3 needed no globals: the landing code is handed a root and calls
5
+ * methods on it. The hub is the opposite. It extends `HTMLElement`, it
6
+ * registers itself with `customElements` when its module loads, and it
7
+ * reaches `document`, `history` and `location` on its own. So the window has
8
+ * to exist before the module does, which is why the hub is imported
9
+ * dynamically, after `installWindow()` has run:
10
+ *
11
+ * installWindow();
12
+ * await import("../hub/lb-hub.browser");
13
+ *
14
+ * One window per test file, since registration is global and permanent.
15
+ *
16
+ * Two things the hub reaches for do not exist in jsdom 25 and are supplied
17
+ * here. `fetch` is absent, and a scripted one is what a test wants in any
18
+ * case. `HTMLDialogElement.showModal` is absent, so it is recorded rather
19
+ * than performed. Everything else — `history.pushState`, `popstate`, event
20
+ * delegation, form controls — is jsdom's own.
21
+ */
22
+ import type { HubData, HubRequest } from "../../core/lb-types";
23
+ /** One round trip the hub asked for. */
24
+ export interface Sent {
25
+ page: string;
26
+ request: HubRequest | Record<string, never>;
27
+ }
28
+ export interface Server {
29
+ /** Every round trip, in order. */
30
+ sent: Sent[];
31
+ /** What the next round trip answers with. Replace it per test. */
32
+ answer: HubData;
33
+ /** Set to fail the next round trip instead of answering it. */
34
+ fail?: string;
35
+ }
36
+ /** The scripted server every test in a file shares. */
37
+ export declare const server: Server;
38
+ export declare function installWindow(): void;
39
+ /**
40
+ * Put a built document in place and hand back the hub in it.
41
+ *
42
+ * `pages` are the page hosts the builder would have written, keyed by stub —
43
+ * see build/assemble.ts. `chrome` is what goes inside `<lb-hub>` beside
44
+ * `<main>`. `path` is where the browser is when the hub connects, and it is
45
+ * reset for every mount so no test inherits where the last one navigated to.
46
+ *
47
+ * Connecting the hub is what runs `connectedCallback`, and that navigates,
48
+ * so the caller awaits the first round trip before asserting.
49
+ */
50
+ export declare function mountHub(chrome: string, pages?: Record<string, string>, path?: string): Promise<Element>;
51
+ /**
52
+ * Let the hub's pending round trip finish.
53
+ *
54
+ * `navigate` is async and nothing hands a test its promise, so a test waits
55
+ * on the microtask queue the way the page does. Two turns: one for the fetch
56
+ * and one for reading its body.
57
+ */
58
+ export declare function settle(): Promise<void>;
59
+ /**
60
+ * Step back and let the hub answer.
61
+ *
62
+ * `history.back()` is queued rather than synchronous, so a test waits for
63
+ * the `popstate` the hub is listening for and then for the round trip it
64
+ * starts.
65
+ */
66
+ export declare function back(): Promise<void>;
67
+ /** Fire the event a person's click fires. */
68
+ export declare function click(el: Element): Promise<void>;
69
+ /** Fire the event a form's submit fires. */
70
+ export declare function submit(form: Element): Promise<void>;
71
+ /** Dispatch a request the way a widget does. */
72
+ export declare function send(el: Element, detail: HubRequest | undefined): Promise<void>;
73
+ //# sourceMappingURL=hub.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hub.d.ts","sourceRoot":"","sources":["../../../tests/helpers/hub.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAyB/D,wCAAwC;AACxC,MAAM,WAAW,IAAI;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,MAAM;IACrB,kCAAkC;IAClC,IAAI,EAAE,IAAI,EAAE,CAAC;IACb,kEAAkE;IAClE,MAAM,EAAE,OAAO,CAAC;IAChB,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,uDAAuD;AACvD,eAAO,MAAM,MAAM,EAAE,MAAiC,CAAC;AAEvD,wBAAgB,aAAa,IAAI,IAAI,CAgDpC;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,MAAM,EACd,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAiB,EAC7C,IAAI,SAAM,GACT,OAAO,CAAC,OAAO,CAAC,CAQlB;AAED;;;;;;GAMG;AACH,wBAAsB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAG5C;AAED;;;;;;GAMG;AACH,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAO1C;AAED,6CAA6C;AAC7C,wBAAsB,KAAK,CAAC,EAAE,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAKtD;AAED,4CAA4C;AAC5C,wBAAsB,MAAM,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAGzD;AAED,gDAAgD;AAChD,wBAAsB,IAAI,CACxB,EAAE,EAAE,OAAO,EACX,MAAM,EAAE,UAAU,GAAG,SAAS,GAC7B,OAAO,CAAC,IAAI,CAAC,CAGf"}
@@ -0,0 +1,151 @@
1
+ /**
2
+ * A window for tier 4 — see docs/testing.md.
3
+ *
4
+ * Tier 3 needed no globals: the landing code is handed a root and calls
5
+ * methods on it. The hub is the opposite. It extends `HTMLElement`, it
6
+ * registers itself with `customElements` when its module loads, and it
7
+ * reaches `document`, `history` and `location` on its own. So the window has
8
+ * to exist before the module does, which is why the hub is imported
9
+ * dynamically, after `installWindow()` has run:
10
+ *
11
+ * installWindow();
12
+ * await import("../hub/lb-hub.browser");
13
+ *
14
+ * One window per test file, since registration is global and permanent.
15
+ *
16
+ * Two things the hub reaches for do not exist in jsdom 25 and are supplied
17
+ * here. `fetch` is absent, and a scripted one is what a test wants in any
18
+ * case. `HTMLDialogElement.showModal` is absent, so it is recorded rather
19
+ * than performed. Everything else — `history.pushState`, `popstate`, event
20
+ * delegation, form controls — is jsdom's own.
21
+ */
22
+ import { JSDOM, VirtualConsole } from "jsdom";
23
+ /** The globals the hub reaches for as it loads and runs. */
24
+ const GLOBALS = [
25
+ "window",
26
+ "document",
27
+ "customElements",
28
+ "HTMLElement",
29
+ "HTMLFormElement",
30
+ "HTMLInputElement",
31
+ "HTMLSelectElement",
32
+ "HTMLTextAreaElement",
33
+ "HTMLTemplateElement",
34
+ "HTMLDialogElement",
35
+ "HTMLAnchorElement",
36
+ "Element",
37
+ "Node",
38
+ "Event",
39
+ "CustomEvent",
40
+ "MouseEvent",
41
+ "DocumentFragment",
42
+ "history",
43
+ "location",
44
+ ];
45
+ /** The scripted server every test in a file shares. */
46
+ export const server = { sent: [], answer: {} };
47
+ export function installWindow() {
48
+ // jsdom reports a real anchor's navigation as an unimplemented feature.
49
+ // That is the point of the test that clicks one — the hub left it to the
50
+ // browser — so the complaint is dropped rather than printed.
51
+ const quiet = new VirtualConsole();
52
+ quiet.sendTo(console, { omitJSDOMErrors: true });
53
+ const { window } = new JSDOM(`<!doctype html><body></body>`, {
54
+ url: "http://localhost/",
55
+ virtualConsole: quiet,
56
+ });
57
+ for (const name of GLOBALS) {
58
+ globalThis[name] = window[name];
59
+ }
60
+ // The hub registers `popstate` on the global rather than on a window it
61
+ // was handed, so the global's listener registry has to be the window's.
62
+ globalThis.addEventListener = window.addEventListener.bind(window);
63
+ globalThis.removeEventListener = window.removeEventListener.bind(window);
64
+ // Recorded, not performed: jsdom has <dialog> but none of its behavior.
65
+ // The hub only ever asks whether it is open and opens it, so that pair is
66
+ // the whole of what a test needs.
67
+ const dialog = window.HTMLDialogElement.prototype;
68
+ dialog.showModal = function () {
69
+ this.setAttribute("open", "");
70
+ };
71
+ dialog.close = function () {
72
+ this.removeAttribute("open");
73
+ };
74
+ server.sent = [];
75
+ server.answer = {};
76
+ delete server.fail;
77
+ globalThis.fetch = (async (url, init) => {
78
+ server.sent.push({
79
+ page: new URL(url, "http://localhost/").searchParams.get("page") ?? "",
80
+ request: JSON.parse(init.body),
81
+ });
82
+ if (server.fail)
83
+ throw new Error(server.fail);
84
+ return {
85
+ ok: true,
86
+ json: async () => server.answer,
87
+ };
88
+ });
89
+ }
90
+ /**
91
+ * Put a built document in place and hand back the hub in it.
92
+ *
93
+ * `pages` are the page hosts the builder would have written, keyed by stub —
94
+ * see build/assemble.ts. `chrome` is what goes inside `<lb-hub>` beside
95
+ * `<main>`. `path` is where the browser is when the hub connects, and it is
96
+ * reset for every mount so no test inherits where the last one navigated to.
97
+ *
98
+ * Connecting the hub is what runs `connectedCallback`, and that navigates,
99
+ * so the caller awaits the first round trip before asserting.
100
+ */
101
+ export async function mountHub(chrome, pages = { index: "" }, path = "/") {
102
+ history.replaceState(null, "", path);
103
+ const hosts = Object.entries(pages)
104
+ .map(([name, html]) => `<template lb-page="${name}">${html}</template>`)
105
+ .join("");
106
+ document.body.innerHTML = `<lb-hub><main></main>${chrome}</lb-hub>${hosts}`;
107
+ await settle();
108
+ return document.body.firstElementChild;
109
+ }
110
+ /**
111
+ * Let the hub's pending round trip finish.
112
+ *
113
+ * `navigate` is async and nothing hands a test its promise, so a test waits
114
+ * on the microtask queue the way the page does. Two turns: one for the fetch
115
+ * and one for reading its body.
116
+ */
117
+ export async function settle() {
118
+ for (let i = 0; i < 4; i++)
119
+ await Promise.resolve();
120
+ await new Promise((r) => setTimeout(r, 0));
121
+ }
122
+ /**
123
+ * Step back and let the hub answer.
124
+ *
125
+ * `history.back()` is queued rather than synchronous, so a test waits for
126
+ * the `popstate` the hub is listening for and then for the round trip it
127
+ * starts.
128
+ */
129
+ export async function back() {
130
+ const stepped = new Promise((resolve) => {
131
+ globalThis.addEventListener("popstate", () => resolve(), { once: true });
132
+ });
133
+ history.back();
134
+ await stepped;
135
+ await settle();
136
+ }
137
+ /** Fire the event a person's click fires. */
138
+ export async function click(el) {
139
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
140
+ await settle();
141
+ }
142
+ /** Fire the event a form's submit fires. */
143
+ export async function submit(form) {
144
+ form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
145
+ await settle();
146
+ }
147
+ /** Dispatch a request the way a widget does. */
148
+ export async function send(el, detail) {
149
+ el.dispatchEvent(new CustomEvent("lb-request", { bubbles: true, detail }));
150
+ await settle();
151
+ }
@@ -6,21 +6,14 @@
6
6
  */
7
7
  import assert from "node:assert/strict";
8
8
  import { describe, it } from "node:test";
9
- import { applyData, applyTuple } from "../hub/lb-apply";
9
+ import { applyData, applyRow } from "../hub/lb-apply";
10
10
  import { body, one } from "./helpers/dom";
11
11
  import { captured } from "./helpers/console";
12
- /** An element that is a list widget as far as the hub is concerned. */
13
- function rowHost(html) {
14
- const el = one(html);
15
- el.seen = [];
16
- el.acceptRows = (result) => void el.seen.push(result);
17
- return el;
18
- }
19
- // --- One tuple into one scope -------------------------------------------
20
- describe("applyTuple", () => {
12
+ // --- One row into one scope ---------------------------------------------
13
+ describe("applyRow", () => {
21
14
  it("gives a native element its value as text", () => {
22
15
  const scope = one(`<div><span lb-cell="name"></span></div>`);
23
- applyTuple(scope, { name: "Ada" });
16
+ applyRow(scope, { name: "Ada" });
24
17
  assert.equal(scope.querySelector("span").textContent, "Ada");
25
18
  });
26
19
  it("gives a widget its value as an attribute", () => {
@@ -28,7 +21,7 @@ describe("applyTuple", () => {
28
21
  // value and renders it itself. The test is lexical — a hyphen in the tag
29
22
  // name — and the hub holds no knowledge of any particular element.
30
23
  const scope = one(`<div><lb-input lb-cell="name"></lb-input></div>`);
31
- applyTuple(scope, { name: "Ada" });
24
+ applyRow(scope, { name: "Ada" });
32
25
  const widget = scope.querySelector("lb-input");
33
26
  assert.equal(widget.getAttribute("lb-value"), "Ada");
34
27
  assert.equal(widget.textContent, "", "the widget's children are its own");
@@ -38,12 +31,12 @@ describe("applyTuple", () => {
38
31
  // it displays. Requiring a wrapper would require an element HTML does
39
32
  // not allow.
40
33
  const scope = one(`<option lb-cell="name"></option>`);
41
- applyTuple(scope, { name: "Ada" });
34
+ applyRow(scope, { name: "Ada" });
42
35
  assert.equal(scope.textContent, "Ada");
43
36
  });
44
37
  it("fills every element naming the same cell", () => {
45
38
  const scope = one(`<div><b lb-cell="name"></b><i lb-cell="name"></i></div>`);
46
- applyTuple(scope, { name: "Ada" });
39
+ applyRow(scope, { name: "Ada" });
47
40
  assert.equal(scope.querySelector("b").textContent, "Ada");
48
41
  assert.equal(scope.querySelector("i").textContent, "Ada");
49
42
  });
@@ -56,7 +49,7 @@ describe("applyTuple", () => {
56
49
  // model. But nothing stops an author writing a cell on a <tr> that has
57
50
  // cells inside it, and this is what they would get.
58
51
  const scope = body(`<table><tbody><tr lb-cell="line"><td lb-cell="amount"></td></tr></tbody></table>`).querySelector("tr");
59
- applyTuple(scope, { line: "Total", amount: "10" });
52
+ applyRow(scope, { line: "Total", amount: "10" });
60
53
  assert.equal(scope.textContent, "Total");
61
54
  assert.equal(scope.children.length, 0, "the amount cell is gone");
62
55
  });
@@ -65,88 +58,119 @@ describe("applyTuple", () => {
65
58
  // widget receives its value as an attribute and renders it itself. The
66
59
  // asymmetry follows from what a cell means on each kind of element.
67
60
  const scope = one(`<lb-input lb-cell="line"><input /></lb-input>`);
68
- applyTuple(scope, { line: "Total" });
61
+ applyRow(scope, { line: "Total" });
69
62
  assert.equal(scope.getAttribute("lb-value"), "Total");
70
63
  assert.equal(scope.children.length, 1);
71
64
  });
72
- it("leaves a cell the tuple did not name alone", () => {
65
+ it("leaves a cell the row did not name alone", () => {
73
66
  const scope = one(`<div><span lb-cell="name">Ada</span><span lb-cell="role">lead</span></div>`);
74
- applyTuple(scope, { role: "staff" });
67
+ applyRow(scope, { role: "staff" });
75
68
  assert.equal(scope.querySelector(`[lb-cell="name"]`).textContent, "Ada");
76
69
  assert.equal(scope.querySelector(`[lb-cell="role"]`).textContent, "staff");
77
70
  });
78
71
  it("says nothing about a cell with nowhere to land", () => {
79
72
  const scope = one(`<div><span lb-cell="name"></span></div>`);
80
- assert.doesNotThrow(() => applyTuple(scope, { absent: "x" }));
73
+ assert.doesNotThrow(() => applyRow(scope, { absent: "x" }));
81
74
  });
82
75
  it("lands a value as text, so data cannot become markup", () => {
83
76
  // The same guarantee expansion makes at build time, made here at run
84
77
  // time and by the same means: the DOM API rather than a string.
85
78
  const scope = one(`<div><span lb-cell="name"></span></div>`);
86
- applyTuple(scope, { name: `<img src=x onerror=alert(1)>` });
79
+ applyRow(scope, { name: `<img src=x onerror=alert(1)>` });
87
80
  assert.equal(scope.querySelectorAll("img").length, 0);
88
81
  assert.equal(scope.querySelector("span").textContent, `<img src=x onerror=alert(1)>`);
89
82
  });
90
83
  it("lands the empty string as the empty string", () => {
91
84
  const scope = one(`<div><span lb-cell="name">Ada</span></div>`);
92
- applyTuple(scope, { name: "" });
85
+ applyRow(scope, { name: "" });
93
86
  assert.equal(scope.querySelector("span").textContent, "");
94
87
  });
95
88
  });
96
89
  // --- A whole response ----------------------------------------------------
97
90
  describe("applyData", () => {
98
- it("lands each result in the scope its query names", async () => {
99
- const root = body(`<div lb-query="greeting"><span lb-cell="text"></span></div>` +
100
- `<div lb-query="count"><span lb-cell="n"></span></div>`);
91
+ it("lands each result in the scope its name binds", () => {
92
+ const root = body(`<div lb-row="greeting"><span lb-cell="text"></span></div>` +
93
+ `<div lb-row="count"><span lb-cell="n"></span></div>`);
101
94
  applyData(root, { greeting: { text: "hello" }, count: { n: "3" } });
102
- assert.equal(root.querySelector(`[lb-query="greeting"] span`).textContent, "hello");
103
- assert.equal(root.querySelector(`[lb-query="count"] span`).textContent, "3");
104
- });
105
- it("fills every scope naming the same query", () => {
106
- // Two projections of one query on one page: the hub resolves by name and
107
- // does not care how many places asked.
108
- const root = body(`<div lb-query="total"><span lb-cell="n"></span></div>` +
109
- `<p lb-query="total"><span lb-cell="n"></span></p>`);
95
+ assert.equal(root.querySelector(`[lb-row="greeting"] span`).textContent, "hello");
96
+ assert.equal(root.querySelector(`[lb-row="count"] span`).textContent, "3");
97
+ });
98
+ it("fills every scope naming the same name", () => {
99
+ // One name shown in two places: the hub resolves by name and does not
100
+ // care how many places asked.
101
+ const root = body(`<div lb-row="total"><span lb-cell="n"></span></div>` +
102
+ `<p lb-row="total"><span lb-cell="n"></span></p>`);
110
103
  applyData(root, { total: { n: "7" } });
111
104
  for (const el of root.querySelectorAll("span")) {
112
105
  assert.equal(el.textContent, "7");
113
106
  }
114
107
  });
115
- it("reports a query with no scope, and lands the rest anyway", async () => {
116
- const root = body(`<div lb-query="count"><span lb-cell="n"></span></div>`);
108
+ it("reports a name with no scope, and lands the rest anyway", async () => {
109
+ const root = body(`<div lb-row="count"><span lb-cell="n"></span></div>`);
117
110
  const { warn } = await captured(() => applyData(root, { ghost: { x: "1" }, count: { n: "3" } }));
118
- assert.match(warn.join("\n"), /no scope for query 'ghost', skipping/);
111
+ assert.match(warn.join("\n"), /no scope for 'ghost', skipping/);
119
112
  assert.equal(root.querySelector("span").textContent, "3");
120
113
  });
121
- it("hands a projection to the widget, whole", () => {
122
- const host = rowHost(`<lb-list lb-query="members"></lb-list>`);
123
- const root = body("");
124
- root.append(host);
125
- const result = { op: "rows", rows: [{ id: "1" }] };
126
- applyData(root, { members: result });
127
- assert.deepEqual(host.seen, [result]);
128
- });
129
- it("reports a projection landing on something that is not a list", async () => {
130
- // A native element has one destination for a value and no way to acquire
131
- // children, so a list is not something it can be asked to show.
132
- const root = body(`<div lb-query="members"></div>`);
133
- const { error } = await captured(() => applyData(root, { members: { op: "rows", rows: [{ id: "1" }] } }));
134
- assert.match(error.join("\n"), /query 'members' returned rows, but <div> is not a list widget/);
135
- });
136
- it("reads a tuple carrying a cell called op as a projection", async () => {
137
- // The reserved name, from the browser's side. Stated in
138
- // core/lb-types.ts and worth failing loudly if it ever moves.
139
- const root = body(`<div lb-query="members"></div>`);
140
- const { error } = await captured(() => applyData(root, { members: { op: "rows" } }));
141
- assert.match(error.join("\n"), /is not a list widget/);
142
- });
143
- it("lands a tuple whose op cell is not a projection word", () => {
144
- const root = body(`<div lb-query="row"><span lb-cell="op"></span></div>`);
145
- applyData(root, { row: { op: "deleted" } });
146
- assert.equal(root.querySelector("span").textContent, "deleted");
114
+ it("reconciles a list scope, with no widget in it at all", () => {
115
+ // The repeater widget is gone: a plain element carrying lb-list and a
116
+ // row template inside it is the whole of a list.
117
+ const root = body(`<ul lb-list="members">` +
118
+ `<template lb-key="id"><li lb-cell="name"></li></template>` +
119
+ `</ul>`);
120
+ applyData(root, {
121
+ members: [
122
+ { id: "1", name: "Ada" },
123
+ { id: "2", name: "Bab" },
124
+ ],
125
+ });
126
+ assert.deepEqual([...root.querySelectorAll("li")].map((el) => el.textContent), ["Ada", "Bab"]);
127
+ });
128
+ it("lands a patch in a list scope, because the name declared the shape", () => {
129
+ // A patch and a row are both objects. What tells them apart is the
130
+ // attribute that asked, not the value that arrived.
131
+ const root = body(`<ul lb-list="members">` +
132
+ `<template lb-key="id"><li lb-cell="name"></li></template>` +
133
+ `</ul>`);
134
+ applyData(root, { members: [{ id: "1", name: "Ada" }] });
135
+ applyData(root, { members: { rows: [{ id: "2", name: "Bab" }] } });
136
+ assert.deepEqual([...root.querySelectorAll("li")].map((el) => el.textContent), ["Ada", "Bab"]);
137
+ });
138
+ it("refuses a name bound as a list in one place and a row in another", async () => {
139
+ // Cardinality is a property of the name, so this is a mistake in the
140
+ // markup rather than a case to reconcile.
141
+ const root = body(`<ul lb-list="members">` +
142
+ `<template lb-key="id"><li lb-cell="name"></li></template>` +
143
+ `</ul>` +
144
+ `<div lb-row="members"><span lb-cell="name"></span></div>`);
145
+ const { error } = await captured(() => applyData(root, { members: [{ id: "1", name: "Ada" }] }));
146
+ assert.match(error.join("\n"), /a list in one place and a row in another/);
147
+ assert.equal(root.querySelectorAll("li").length, 0, "nothing landed");
148
+ });
149
+ it("refuses rows arriving at a row scope", async () => {
150
+ const root = body(`<div lb-row="members"><span lb-cell="n"></span></div>`);
151
+ const { error } = await captured(() => applyData(root, { members: [{ id: "1" }] }));
152
+ assert.match(error.join("\n"), /bound with lb-row but answered with rows/);
153
+ });
154
+ it("does not carry a row into a nested scope", () => {
155
+ // A nested scope of either kind begins a new one, so a cell named alike
156
+ // inside it belongs to the inner name.
157
+ const root = body(`<div lb-row="outer"><span lb-cell="name"></span>` +
158
+ `<p lb-row="inner"><span lb-cell="name"></span></p></div>`);
159
+ applyData(root, { inner: { name: "in" }, outer: { name: "out" } });
160
+ const [first, second] = root.querySelectorAll("span");
161
+ assert.equal(first.textContent, "out");
162
+ assert.equal(second.textContent, "in");
163
+ });
164
+ it("lands a column called op like any other", () => {
165
+ // It used to be reserved, because the hub read it to tell a row from a
166
+ // set. Cardinality is declared now, so nothing inspects a value and no
167
+ // column name is spoken for.
168
+ const root = body(`<div lb-row="thing"><span lb-cell="op"></span></div>`);
169
+ applyData(root, { thing: { op: "rows" } });
170
+ assert.equal(root.querySelector("span").textContent, "rows");
147
171
  });
148
172
  it("does nothing with an empty response", () => {
149
- const root = body(`<div lb-query="count"><span lb-cell="n">3</span></div>`);
173
+ const root = body(`<div lb-row="count"><span lb-cell="n">3</span></div>`);
150
174
  applyData(root, {});
151
175
  assert.equal(root.querySelector("span").textContent, "3");
152
176
  });
@@ -2,7 +2,7 @@
2
2
  * The Express adapter — see docs/testing.md, tier 2.
3
3
  *
4
4
  * server/lb-express.ts is the only file that knows both HTTP and Hub. What
5
- * it owes is small and exact: two endpoints and no third, the page from the
5
+ * it owes is small and exact: one endpoint and no second, the page from the
6
6
  * query string, and an operation it does not yet handle refused rather than
7
7
  * half-handled.
8
8
  *