@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.
- package/README.md +3 -4
- package/dist/build/assemble.d.ts +1 -1
- package/dist/build/assemble.d.ts.map +1 -1
- package/dist/build/assemble.js +80 -7
- package/dist/build/cli.d.ts +2 -2
- package/dist/build/cli.js +2 -2
- package/dist/build/expand.d.ts.map +1 -1
- package/dist/build/expand.js +30 -34
- package/dist/build/locations.d.ts +3 -3
- package/dist/build/locations.d.ts.map +1 -1
- package/dist/build/locations.js +15 -3
- package/dist/build/origins.d.ts +0 -13
- package/dist/build/origins.d.ts.map +1 -1
- package/dist/build/origins.js +32 -8
- package/dist/build/pages.d.ts +3 -3
- package/dist/build/pages.d.ts.map +1 -1
- package/dist/build/pages.js +9 -7
- package/dist/core/lb-constants.d.ts +17 -12
- package/dist/core/lb-constants.d.ts.map +1 -1
- package/dist/core/lb-constants.js +106 -43
- package/dist/core/lb-types.d.ts +103 -62
- package/dist/core/lb-types.d.ts.map +1 -1
- package/dist/core/lb-types.js +11 -3
- package/dist/hub/lb-apply.d.ts +18 -4
- package/dist/hub/lb-apply.d.ts.map +1 -1
- package/dist/hub/lb-apply.js +184 -34
- package/dist/hub/lb-hub.browser.d.ts +1 -1
- package/dist/hub/lb-hub.browser.d.ts.map +1 -1
- package/dist/hub/lb-hub.browser.js +165 -83
- package/dist/server/lb-express.d.ts +7 -4
- package/dist/server/lb-express.d.ts.map +1 -1
- package/dist/server/lb-express.js +45 -33
- package/dist/server/lb-server.d.ts +67 -45
- package/dist/server/lb-server.d.ts.map +1 -1
- package/dist/server/lb-server.js +55 -18
- package/dist/tests/assemble.test.js +154 -2
- package/dist/tests/expand.test.js +30 -3
- package/dist/tests/helpers/hub.d.ts +73 -0
- package/dist/tests/helpers/hub.d.ts.map +1 -0
- package/dist/tests/helpers/hub.js +151 -0
- package/dist/tests/lb-apply.test.js +86 -62
- package/dist/tests/lb-express.test.d.ts +1 -1
- package/dist/tests/lb-express.test.js +43 -38
- package/dist/tests/lb-hub.test.d.ts +14 -0
- package/dist/tests/lb-hub.test.d.ts.map +1 -0
- package/dist/tests/lb-hub.test.js +319 -0
- package/dist/tests/{lb-rows.test.d.ts → lb-list.test.d.ts} +1 -1
- package/dist/tests/lb-list.test.d.ts.map +1 -0
- package/dist/tests/{lb-rows.test.js → lb-list.test.js} +109 -106
- package/dist/tests/lb-server.test.js +151 -100
- package/dist/tests/origins.test.js +19 -1
- package/dist/tests/pages.test.d.ts +1 -1
- package/dist/tests/pages.test.js +64 -14
- package/docs/TECHREF-1.0.md +1000 -0
- package/docs/reference/builder.md +4 -4
- package/docs/reference/chrome.md +56 -5
- package/docs/reference/custom-elements.md +59 -36
- package/docs/reference/data-binding.md +142 -84
- package/docs/reference/overview.md +1 -1
- package/docs/reference/page-files.md +64 -49
- package/docs/reference/server.md +7 -6
- package/docs/reference/widgets.md +43 -32
- package/docs/roadmap.md +68 -22
- package/docs/testing.md +47 -17
- package/docs/theory.md +2 -2
- package/docs/tutorials/010-pages-and-navigation.md +14 -8
- package/docs/tutorials/040-displaying-data.md +9 -9
- package/docs/tutorials/050-actions.md +5 -5
- package/docs/tutorials/060-custom-element-code.md +1 -1
- package/docs/tutorials/065-conditional-rendering.md +4 -4
- package/docs/tutorials/070-displaying-a-list.md +24 -47
- package/docs/tutorials/072-inserting-into-a-list.md +17 -17
- package/docs/tutorials/074-deleting-from-a-list.md +15 -17
- package/docs/tutorials/076-updating-a-list-item.md +20 -22
- package/docs/tutorials/080-widget-requests.md +27 -23
- package/docs/tutorials/090-using-widget-libraries.md +23 -1
- package/package.json +1 -2
- package/dist/hub/lb-rows.d.ts +0 -18
- package/dist/hub/lb-rows.d.ts.map +0 -1
- package/dist/hub/lb-rows.js +0 -106
- 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,
|
|
9
|
+
import { applyData, applyRow } from "../hub/lb-apply";
|
|
10
10
|
import { body, one } from "./helpers/dom";
|
|
11
11
|
import { captured } from "./helpers/console";
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
99
|
-
const root = body(`<div lb-
|
|
100
|
-
`<div lb-
|
|
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-
|
|
103
|
-
assert.equal(root.querySelector(`[lb-
|
|
104
|
-
});
|
|
105
|
-
it("fills every scope naming the same
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
const root = body(`<div lb-
|
|
109
|
-
`<p lb-
|
|
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
|
|
116
|
-
const root = body(`<div lb-
|
|
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
|
|
111
|
+
assert.match(warn.join("\n"), /no scope for 'ghost', skipping/);
|
|
119
112
|
assert.equal(root.querySelector("span").textContent, "3");
|
|
120
113
|
});
|
|
121
|
-
it("
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
root
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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-
|
|
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:
|
|
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
|
*
|