@loadbare/app 0.4.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 (171) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +95 -0
  3. package/dist/build/assemble.d.ts +31 -0
  4. package/dist/build/assemble.d.ts.map +1 -0
  5. package/dist/build/assemble.js +53 -0
  6. package/dist/build/cli.d.ts +30 -0
  7. package/dist/build/cli.d.ts.map +1 -0
  8. package/dist/build/cli.js +107 -0
  9. package/dist/build/elements.d.ts +61 -0
  10. package/dist/build/elements.d.ts.map +1 -0
  11. package/dist/build/elements.js +158 -0
  12. package/dist/build/expand.d.ts +45 -0
  13. package/dist/build/expand.d.ts.map +1 -0
  14. package/dist/build/expand.js +386 -0
  15. package/dist/build/format.d.ts +28 -0
  16. package/dist/build/format.d.ts.map +1 -0
  17. package/dist/build/format.js +42 -0
  18. package/dist/build/locations.d.ts +87 -0
  19. package/dist/build/locations.d.ts.map +1 -0
  20. package/dist/build/locations.js +173 -0
  21. package/dist/build/package-root.d.ts +9 -0
  22. package/dist/build/package-root.d.ts.map +1 -0
  23. package/dist/build/package-root.js +24 -0
  24. package/dist/build/pages.d.ts +25 -0
  25. package/dist/build/pages.d.ts.map +1 -0
  26. package/dist/build/pages.js +54 -0
  27. package/dist/build/styles.d.ts +13 -0
  28. package/dist/build/styles.d.ts.map +1 -0
  29. package/dist/build/styles.js +18 -0
  30. package/dist/client.js +522 -0
  31. package/dist/core/lb-constants.d.ts +23 -0
  32. package/dist/core/lb-constants.d.ts.map +1 -0
  33. package/dist/core/lb-constants.js +95 -0
  34. package/dist/core/lb-types.d.ts +88 -0
  35. package/dist/core/lb-types.d.ts.map +1 -0
  36. package/dist/core/lb-types.js +5 -0
  37. package/dist/demo-static/src/widgets/app-box.d.ts +15 -0
  38. package/dist/demo-static/src/widgets/app-box.d.ts.map +1 -0
  39. package/dist/demo-static/src/widgets/app-box.js +19 -0
  40. package/dist/hub/lb-apply.d.ts +13 -0
  41. package/dist/hub/lb-apply.d.ts.map +1 -0
  42. package/dist/hub/lb-apply.js +77 -0
  43. package/dist/hub/lb-hub.d.ts +2 -0
  44. package/dist/hub/lb-hub.d.ts.map +1 -0
  45. package/dist/hub/lb-hub.js +242 -0
  46. package/dist/hub/lb-rows.d.ts +18 -0
  47. package/dist/hub/lb-rows.d.ts.map +1 -0
  48. package/dist/hub/lb-rows.js +106 -0
  49. package/dist/server/lb-express.d.ts +28 -0
  50. package/dist/server/lb-express.d.ts.map +1 -0
  51. package/dist/server/lb-express.js +77 -0
  52. package/dist/server/lb-server.d.ts +174 -0
  53. package/dist/server/lb-server.d.ts.map +1 -0
  54. package/dist/server/lb-server.js +79 -0
  55. package/dist/tests/assemble.test.d.ts +8 -0
  56. package/dist/tests/assemble.test.d.ts.map +1 -0
  57. package/dist/tests/assemble.test.js +51 -0
  58. package/dist/tests/elements.test.d.ts +8 -0
  59. package/dist/tests/elements.test.d.ts.map +1 -0
  60. package/dist/tests/elements.test.js +111 -0
  61. package/dist/tests/expand.test.d.ts +10 -0
  62. package/dist/tests/expand.test.d.ts.map +1 -0
  63. package/dist/tests/expand.test.js +226 -0
  64. package/dist/tests/fixtures/elements/collision/elements.d.ts +5 -0
  65. package/dist/tests/fixtures/elements/collision/elements.d.ts.map +1 -0
  66. package/dist/tests/fixtures/elements/collision/elements.js +3 -0
  67. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts +2 -0
  68. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts.map +1 -0
  69. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.js +1 -0
  70. package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts +2 -0
  71. package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts.map +1 -0
  72. package/dist/tests/fixtures/elements/local/widgets/app-box.js +1 -0
  73. package/dist/tests/fixtures/elements/manifest/elements.d.ts +5 -0
  74. package/dist/tests/fixtures/elements/manifest/elements.d.ts.map +1 -0
  75. package/dist/tests/fixtures/elements/manifest/elements.js +3 -0
  76. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts +5 -0
  77. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts.map +1 -0
  78. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.js +3 -0
  79. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts +5 -0
  80. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts.map +1 -0
  81. package/dist/tests/fixtures/elements/manifest-bad-value/elements.js +3 -0
  82. package/dist/tests/golden.test.d.ts +19 -0
  83. package/dist/tests/golden.test.d.ts.map +1 -0
  84. package/dist/tests/golden.test.js +60 -0
  85. package/dist/tests/helpers/console.d.ts +20 -0
  86. package/dist/tests/helpers/console.d.ts.map +1 -0
  87. package/dist/tests/helpers/console.js +28 -0
  88. package/dist/tests/helpers/dom.d.ts +18 -0
  89. package/dist/tests/helpers/dom.d.ts.map +1 -0
  90. package/dist/tests/helpers/dom.js +22 -0
  91. package/dist/tests/helpers/window.d.ts +43 -0
  92. package/dist/tests/helpers/window.d.ts.map +1 -0
  93. package/dist/tests/helpers/window.js +78 -0
  94. package/dist/tests/lb-apply.test.d.ts +8 -0
  95. package/dist/tests/lb-apply.test.d.ts.map +1 -0
  96. package/dist/tests/lb-apply.test.js +153 -0
  97. package/dist/tests/lb-express.test.d.ts +14 -0
  98. package/dist/tests/lb-express.test.d.ts.map +1 -0
  99. package/dist/tests/lb-express.test.js +238 -0
  100. package/dist/tests/lb-input.test.d.ts +9 -0
  101. package/dist/tests/lb-input.test.d.ts.map +1 -0
  102. package/dist/tests/lb-input.test.js +78 -0
  103. package/dist/tests/lb-list.test.d.ts +12 -0
  104. package/dist/tests/lb-list.test.d.ts.map +1 -0
  105. package/dist/tests/lb-list.test.js +44 -0
  106. package/dist/tests/lb-options.test.d.ts +10 -0
  107. package/dist/tests/lb-options.test.d.ts.map +1 -0
  108. package/dist/tests/lb-options.test.js +121 -0
  109. package/dist/tests/lb-picker.test.d.ts +14 -0
  110. package/dist/tests/lb-picker.test.d.ts.map +1 -0
  111. package/dist/tests/lb-picker.test.js +59 -0
  112. package/dist/tests/lb-rows.test.d.ts +12 -0
  113. package/dist/tests/lb-rows.test.d.ts.map +1 -0
  114. package/dist/tests/lb-rows.test.js +336 -0
  115. package/dist/tests/lb-select.test.d.ts +9 -0
  116. package/dist/tests/lb-select.test.d.ts.map +1 -0
  117. package/dist/tests/lb-select.test.js +71 -0
  118. package/dist/tests/lb-server.test.d.ts +9 -0
  119. package/dist/tests/lb-server.test.d.ts.map +1 -0
  120. package/dist/tests/lb-server.test.js +495 -0
  121. package/dist/tests/lb-table.test.d.ts +15 -0
  122. package/dist/tests/lb-table.test.d.ts.map +1 -0
  123. package/dist/tests/lb-table.test.js +205 -0
  124. package/dist/tests/pages.test.d.ts +6 -0
  125. package/dist/tests/pages.test.d.ts.map +1 -0
  126. package/dist/tests/pages.test.js +98 -0
  127. package/dist/tests/styles.test.d.ts +7 -0
  128. package/dist/tests/styles.test.d.ts.map +1 -0
  129. package/dist/tests/styles.test.js +73 -0
  130. package/dist/widgets/index.d.ts +7 -0
  131. package/dist/widgets/index.d.ts.map +1 -0
  132. package/dist/widgets/index.js +6 -0
  133. package/dist/widgets/lb-input.d.ts +2 -0
  134. package/dist/widgets/lb-input.d.ts.map +1 -0
  135. package/dist/widgets/lb-input.js +48 -0
  136. package/dist/widgets/lb-list.d.ts +2 -0
  137. package/dist/widgets/lb-list.d.ts.map +1 -0
  138. package/dist/widgets/lb-list.js +17 -0
  139. package/dist/widgets/lb-options.d.ts +26 -0
  140. package/dist/widgets/lb-options.d.ts.map +1 -0
  141. package/dist/widgets/lb-options.js +72 -0
  142. package/dist/widgets/lb-picker.d.ts +2 -0
  143. package/dist/widgets/lb-picker.d.ts.map +1 -0
  144. package/dist/widgets/lb-picker.js +25 -0
  145. package/dist/widgets/lb-select.d.ts +2 -0
  146. package/dist/widgets/lb-select.d.ts.map +1 -0
  147. package/dist/widgets/lb-select.js +43 -0
  148. package/dist/widgets/lb-table.d.ts +2 -0
  149. package/dist/widgets/lb-table.d.ts.map +1 -0
  150. package/dist/widgets/lb-table.js +113 -0
  151. package/docs/application-chrome.md +36 -0
  152. package/docs/building-html-pages.md +130 -0
  153. package/docs/getting-started.md +120 -0
  154. package/docs/guide.md +1164 -0
  155. package/docs/hosting.md +218 -0
  156. package/docs/latent-risks.md +20 -0
  157. package/docs/theory.md +226 -0
  158. package/package.json +85 -0
  159. package/widgets/index.ts +6 -0
  160. package/widgets/lb-input.html +1 -0
  161. package/widgets/lb-input.ts +64 -0
  162. package/widgets/lb-list.html +1 -0
  163. package/widgets/lb-list.ts +21 -0
  164. package/widgets/lb-options.html +4 -0
  165. package/widgets/lb-options.ts +88 -0
  166. package/widgets/lb-picker.html +7 -0
  167. package/widgets/lb-picker.ts +27 -0
  168. package/widgets/lb-select.html +4 -0
  169. package/widgets/lb-select.ts +55 -0
  170. package/widgets/lb-table.html +8 -0
  171. package/widgets/lb-table.ts +126 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * lb-options — see docs/testing.md, tier 4.
3
+ *
4
+ * The case that shows why placement belongs to the widget. The rows arrive
5
+ * flat and keyed; nothing on the wire knows about <optgroup>, and nothing
6
+ * needs to. Turning a repeated column value into an element is exactly the
7
+ * local knowledge a widget has and the hub does not.
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "node:test";
11
+ import { ATTR_ACTION } from "../core/lb-constants";
12
+ import { change, installWindow, mount, requests } from "./helpers/window";
13
+ installWindow();
14
+ await import("../widgets/lb-options");
15
+ const ROW = `<option lb-cell="name"></option>`;
16
+ /** A lb-options with the row template a page author would write. */
17
+ function options(template) {
18
+ return mount(`<lb-options ${ATTR_ACTION}="pick"><label><select>${template}</select></label></lb-options>`);
19
+ }
20
+ const flat = `<template lb-key="id">${ROW}</template>`;
21
+ const grouped = `<template lb-key="id" lb-group="team">${ROW}</template>`;
22
+ const rows = (list) => ({ op: "rows", rows: list });
23
+ const patch = (change) => ({ op: "patch", ...change });
24
+ /** Every option showing, as label and value. */
25
+ function shown(widget) {
26
+ return [...widget.querySelectorAll("option")].map((o) => [
27
+ o.textContent,
28
+ o.value,
29
+ ]);
30
+ }
31
+ /** The groups showing, as label and the options in each. */
32
+ function groups(widget) {
33
+ return [...widget.querySelectorAll("optgroup")].map((g) => [
34
+ g.label,
35
+ [...g.querySelectorAll("option")].map((o) => o.textContent),
36
+ ]);
37
+ }
38
+ describe("lb-options without grouping", () => {
39
+ it("lands one option per tuple, in the order sent", () => {
40
+ const widget = options(flat);
41
+ widget.acceptRows(rows([
42
+ { id: "1", name: "Ada" },
43
+ { id: "2", name: "Bab" },
44
+ ]));
45
+ assert.deepEqual(shown(widget), [
46
+ ["Ada", "1"],
47
+ ["Bab", "2"],
48
+ ]);
49
+ });
50
+ it("makes the key the value, so the page says it once", () => {
51
+ // The identity of the row is the value of the option.
52
+ const widget = options(flat);
53
+ widget.acceptRows(rows([{ id: "7", name: "Ada" }]));
54
+ const option = widget.querySelector("option");
55
+ assert.equal(option.value, "7");
56
+ assert.equal(option.getAttribute("lb-key"), "7");
57
+ });
58
+ it("puts the options inside the select, not beside it", () => {
59
+ const widget = options(flat);
60
+ widget.acceptRows(rows([{ id: "1", name: "Ada" }]));
61
+ assert.equal(widget.querySelector("option").parentElement.localName, "select");
62
+ });
63
+ });
64
+ describe("lb-options grouping", () => {
65
+ it("builds one optgroup per distinct value of the grouping column", () => {
66
+ const widget = options(grouped);
67
+ widget.acceptRows(rows([
68
+ { id: "1", name: "Ada", team: "Core" },
69
+ { id: "2", name: "Bab", team: "Ops" },
70
+ { id: "3", name: "Cai", team: "Core" },
71
+ ]));
72
+ assert.deepEqual(groups(widget), [
73
+ ["Core", ["Ada", "Cai"]],
74
+ ["Ops", ["Bab"]],
75
+ ]);
76
+ });
77
+ it("removes a group when its last row goes", () => {
78
+ // A group is derived, so it goes when the thing it was derived from does.
79
+ const widget = options(grouped);
80
+ widget.acceptRows(rows([
81
+ { id: "1", name: "Ada", team: "Core" },
82
+ { id: "2", name: "Bab", team: "Ops" },
83
+ ]));
84
+ widget.acceptRows(rows([{ id: "1", name: "Ada", team: "Core" }]));
85
+ assert.deepEqual(groups(widget), [["Core", ["Ada"]]]);
86
+ });
87
+ it("removes a group emptied by a patch", () => {
88
+ const widget = options(grouped);
89
+ widget.acceptRows(rows([
90
+ { id: "1", name: "Ada", team: "Core" },
91
+ { id: "2", name: "Bab", team: "Ops" },
92
+ ]));
93
+ widget.acceptRows(patch({ drop: ["2"] }));
94
+ assert.deepEqual(groups(widget), [["Core", ["Ada"]]]);
95
+ });
96
+ it("groups a tuple with no value for the column under the empty label", () => {
97
+ const widget = options(grouped);
98
+ widget.acceptRows(rows([{ id: "1", name: "Ada" }]));
99
+ assert.deepEqual(groups(widget), [["", ["Ada"]]]);
100
+ });
101
+ it("keeps the template as the marker the groups sit in front of", () => {
102
+ const widget = options(grouped);
103
+ widget.acceptRows(rows([{ id: "1", name: "Ada", team: "Core" }]));
104
+ const select = widget.querySelector("select");
105
+ assert.equal(select.lastElementChild.localName, "template");
106
+ });
107
+ });
108
+ describe("lb-options sending an action", () => {
109
+ it("sends the declared action carrying the choice", () => {
110
+ const seen = requests();
111
+ const widget = options(flat);
112
+ widget.acceptRows(rows([
113
+ { id: "1", name: "Ada" },
114
+ { id: "2", name: "Bab" },
115
+ ]));
116
+ const select = widget.querySelector("select");
117
+ select.value = "2";
118
+ change(select);
119
+ assert.deepEqual(seen, [{ op: "action", name: "pick", value: "2" }]);
120
+ });
121
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * lb-picker — see docs/testing.md, tier 4.
3
+ *
4
+ * The same select as lb-options, with the row template supplied by the
5
+ * definition instead of by the page. The behavior is inherited whole, because
6
+ * it is the same behavior; a distinct class exists only because
7
+ * customElements.define requires one constructor per name.
8
+ *
9
+ * So what is worth testing is exactly that: that nothing was written twice,
10
+ * and that the expanded definition drives the inherited machinery. The
11
+ * template below is what widgets/lb-picker.html expands to.
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=lb-picker.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-picker.test.d.ts","sourceRoot":"","sources":["../../tests/lb-picker.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * lb-picker — see docs/testing.md, tier 4.
3
+ *
4
+ * The same select as lb-options, with the row template supplied by the
5
+ * definition instead of by the page. The behavior is inherited whole, because
6
+ * it is the same behavior; a distinct class exists only because
7
+ * customElements.define requires one constructor per name.
8
+ *
9
+ * So what is worth testing is exactly that: that nothing was written twice,
10
+ * and that the expanded definition drives the inherited machinery. The
11
+ * template below is what widgets/lb-picker.html expands to.
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { describe, it } from "node:test";
15
+ import { ATTR_ACTION } from "../core/lb-constants";
16
+ import { change, installWindow, mount, requests } from "./helpers/window";
17
+ installWindow();
18
+ const { LbOptions } = await import("../widgets/lb-options");
19
+ await import("../widgets/lb-picker");
20
+ /** lb-picker as expansion leaves it, given exp-key, exp-cell and exp-group. */
21
+ function picker() {
22
+ return mount(`<lb-picker ${ATTR_ACTION}="pick"><label>Member:` +
23
+ `<select><template lb-key="id" lb-group="team">` +
24
+ `<option lb-cell="name"></option>` +
25
+ `</template></select></label></lb-picker>`);
26
+ }
27
+ const rows = (list) => ({
28
+ op: "rows",
29
+ rows: list,
30
+ });
31
+ describe("lb-picker", () => {
32
+ it("is a lb-options", () => {
33
+ assert.ok(picker() instanceof LbOptions);
34
+ });
35
+ it("lands rows in groups, from the template its definition supplied", () => {
36
+ const widget = picker();
37
+ widget.acceptRows(rows([
38
+ { id: "1", name: "Ada", team: "Core" },
39
+ { id: "2", name: "Bab", team: "Ops" },
40
+ ]));
41
+ assert.deepEqual([...widget.querySelectorAll("optgroup")].map((g) => g.label), ["Core", "Ops"]);
42
+ assert.deepEqual([...widget.querySelectorAll("option")].map((o) => [
43
+ o.textContent,
44
+ o.value,
45
+ ]), [
46
+ ["Ada", "1"],
47
+ ["Bab", "2"],
48
+ ]);
49
+ });
50
+ it("sends the declared action on a change", () => {
51
+ const seen = requests();
52
+ const widget = picker();
53
+ widget.acceptRows(rows([{ id: "1", name: "Ada", team: "Core" }]));
54
+ const select = widget.querySelector("select");
55
+ select.value = "1";
56
+ change(select);
57
+ assert.deepEqual(seen, [{ op: "action", name: "pick", value: "1" }]);
58
+ });
59
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The row machinery — see docs/testing.md, tier 3.
3
+ *
4
+ * This is the most intricate code in the framework and the most likely to
5
+ * break in ways nobody notices, because what it gets wrong is a row in the
6
+ * wrong place rather than an exception.
7
+ *
8
+ * The last describe holds the two properties. Reconciliation's contract is
9
+ * convergence, and two properties state it better than twenty examples.
10
+ */
11
+ export {};
12
+ //# sourceMappingURL=lb-rows.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-rows.test.d.ts","sourceRoot":"","sources":["../../tests/lb-rows.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The row machinery — see docs/testing.md, tier 3.
3
+ *
4
+ * This is the most intricate code in the framework and the most likely to
5
+ * break in ways nobody notices, because what it gets wrong is a row in the
6
+ * wrong place rather than an exception.
7
+ *
8
+ * The last describe holds the two properties. Reconciliation's contract is
9
+ * convergence, and two properties state it better than twenty examples.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import { applyRows } from "../hub/lb-rows";
14
+ import { body } from "./helpers/dom";
15
+ import { captured } from "./helpers/console";
16
+ /**
17
+ * A list widget's subtree: the template a page author wrote, and nothing
18
+ * else. The default `place` puts rows in front of the template, so the
19
+ * template staying put is part of what is under test.
20
+ */
21
+ function list(template = `<template lb-key="id"><p><span lb-cell="name"></span></p></template>`) {
22
+ return body(`<lb-list>${template}</lb-list>`).firstElementChild;
23
+ }
24
+ /** The keys of the rows showing, in document order. */
25
+ function keys(scope) {
26
+ return [...scope.querySelectorAll(`[lb-key]`)]
27
+ .filter((el) => el.localName !== "template")
28
+ .map((el) => el.getAttribute("lb-key"));
29
+ }
30
+ /** What each row is showing, in document order. */
31
+ function shown(scope) {
32
+ return [...scope.querySelectorAll(`[lb-key]`)]
33
+ .filter((el) => el.localName !== "template")
34
+ .map((el) => el.textContent.trim());
35
+ }
36
+ const rows = (list) => ({ op: "rows", rows: list });
37
+ const patch = (change) => ({ op: "patch", ...change });
38
+ // --- The whole set -------------------------------------------------------
39
+ describe("a rows result", () => {
40
+ it("clones the template once per tuple and fills it", () => {
41
+ const scope = list();
42
+ applyRows(scope, rows([
43
+ { id: "1", name: "Ada" },
44
+ { id: "2", name: "Bab" },
45
+ ]));
46
+ assert.deepEqual(keys(scope), ["1", "2"]);
47
+ assert.deepEqual(shown(scope), ["Ada", "Bab"]);
48
+ });
49
+ it("carries the key value on the live row", () => {
50
+ // lb-key names the key column on the template and carries the key value
51
+ // on a row. One name in two positions, never ambiguous, because a
52
+ // template is not a row.
53
+ const scope = list();
54
+ applyRows(scope, rows([{ id: "7", name: "Ada" }]));
55
+ const row = scope.querySelector("p");
56
+ assert.equal(row.getAttribute("lb-key"), "7");
57
+ });
58
+ it("leaves the template where it was, after the rows", () => {
59
+ const scope = list();
60
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]));
61
+ assert.equal(scope.lastElementChild.localName, "template");
62
+ });
63
+ it("decides membership: a key that did not arrive is gone", () => {
64
+ const scope = list();
65
+ applyRows(scope, rows([
66
+ { id: "1", name: "Ada" },
67
+ { id: "2", name: "Bab" },
68
+ ]));
69
+ applyRows(scope, rows([{ id: "2", name: "Bab" }]));
70
+ assert.deepEqual(keys(scope), ["2"]);
71
+ });
72
+ it("decides order: a row that already exists is placed again", () => {
73
+ const scope = list();
74
+ applyRows(scope, rows([
75
+ { id: "1", name: "Ada" },
76
+ { id: "2", name: "Bab" },
77
+ ]));
78
+ applyRows(scope, rows([
79
+ { id: "2", name: "Bab" },
80
+ { id: "1", name: "Ada" },
81
+ ]));
82
+ assert.deepEqual(keys(scope), ["2", "1"]);
83
+ });
84
+ it("updates a row it keeps rather than replacing it", () => {
85
+ // The row is the same element, so anything a widget put on it survives.
86
+ const scope = list();
87
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]));
88
+ const before = scope.querySelector("p");
89
+ applyRows(scope, rows([{ id: "1", name: "Grace" }]));
90
+ assert.equal(scope.querySelector("p"), before, "same element");
91
+ assert.deepEqual(shown(scope), ["Grace"]);
92
+ });
93
+ it("empties the list when the set is empty", () => {
94
+ const scope = list();
95
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]));
96
+ applyRows(scope, rows([]));
97
+ assert.deepEqual(keys(scope), []);
98
+ assert.equal(scope.querySelector("template"), scope.firstElementChild);
99
+ });
100
+ });
101
+ // --- Only what changed ---------------------------------------------------
102
+ describe("a patch result", () => {
103
+ it("adds a row it names", () => {
104
+ const scope = list();
105
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]));
106
+ applyRows(scope, patch({ rows: [{ id: "2", name: "Bab" }] }));
107
+ assert.deepEqual(keys(scope), ["1", "2"]);
108
+ });
109
+ it("updates a row it names", () => {
110
+ const scope = list();
111
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]));
112
+ applyRows(scope, patch({ rows: [{ id: "1", name: "Grace" }] }));
113
+ assert.deepEqual(shown(scope), ["Grace"]);
114
+ });
115
+ it("drops a row it names", () => {
116
+ const scope = list();
117
+ applyRows(scope, rows([
118
+ { id: "1", name: "Ada" },
119
+ { id: "2", name: "Bab" },
120
+ ]));
121
+ applyRows(scope, patch({ drop: ["1"] }));
122
+ assert.deepEqual(keys(scope), ["2"]);
123
+ });
124
+ it("leaves a row it did not name, in contents and in place", () => {
125
+ const scope = list();
126
+ applyRows(scope, rows([
127
+ { id: "1", name: "Ada" },
128
+ { id: "2", name: "Bab" },
129
+ { id: "3", name: "Cai" },
130
+ ]));
131
+ applyRows(scope, patch({ rows: [{ id: "2", name: "Grace" }] }));
132
+ assert.deepEqual(keys(scope), ["1", "2", "3"]);
133
+ assert.deepEqual(shown(scope), ["Ada", "Grace", "Cai"]);
134
+ });
135
+ it("does not re-place a row that already exists", () => {
136
+ // Today's behavior, and exactly the gap NEEDED-FOR-1.0 names: a row whose
137
+ // sort column changed stays where it was. This test states what is true
138
+ // now, and is the one that flips when that item closes.
139
+ const placed = [];
140
+ const place = (row, _tuple, template) => {
141
+ placed.push(row.getAttribute("lb-key"));
142
+ template.parentElement.insertBefore(row, template);
143
+ };
144
+ const scope = list();
145
+ applyRows(scope, rows([
146
+ { id: "1", name: "Ada" },
147
+ { id: "2", name: "Bab" },
148
+ ]), place);
149
+ placed.length = 0;
150
+ applyRows(scope, patch({ rows: [{ id: "1", name: "Grace" }] }), place);
151
+ assert.deepEqual(placed, [], "an existing row is filled, never placed");
152
+ });
153
+ it("places a row it has not seen before", () => {
154
+ const placed = [];
155
+ const place = (row, _tuple, template) => {
156
+ placed.push(row.getAttribute("lb-key"));
157
+ template.parentElement.insertBefore(row, template);
158
+ };
159
+ const scope = list();
160
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]), place);
161
+ placed.length = 0;
162
+ applyRows(scope, patch({ rows: [{ id: "2", name: "Bab" }] }), place);
163
+ assert.deepEqual(placed, ["2"]);
164
+ });
165
+ it("ignores a drop for a row that is not showing", () => {
166
+ const scope = list();
167
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]));
168
+ assert.doesNotThrow(() => applyRows(scope, patch({ drop: ["9"] })));
169
+ assert.deepEqual(keys(scope), ["1"]);
170
+ });
171
+ it("naming nothing changes nothing", () => {
172
+ const scope = list();
173
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]));
174
+ const before = scope.innerHTML;
175
+ applyRows(scope, patch({}));
176
+ assert.equal(scope.innerHTML, before);
177
+ });
178
+ });
179
+ // --- Placement -----------------------------------------------------------
180
+ describe("placement", () => {
181
+ it("hands the widget the row, the tuple and the template", () => {
182
+ const seen = [];
183
+ const place = (row, tuple, template) => {
184
+ seen.push([row.localName, tuple, template.localName]);
185
+ template.parentElement.insertBefore(row, template);
186
+ };
187
+ const scope = list();
188
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]), place);
189
+ assert.deepEqual(seen, [["p", { id: "1", name: "Ada" }, "template"]]);
190
+ });
191
+ it("fills the row before the widget places it", () => {
192
+ // The attributes are already there when the row upgrades, which is the
193
+ // same thing that makes hydration and refresh one operation everywhere
194
+ // else. A widget that reads a cell to decide where the row goes — every
195
+ // sorting or grouping widget — depends on this ordering.
196
+ let atPlacement = "";
197
+ const place = (row, _tuple, template) => {
198
+ atPlacement = row.textContent.trim();
199
+ template.parentElement.insertBefore(row, template);
200
+ };
201
+ applyRows(list(), rows([{ id: "1", name: "Ada" }]), place);
202
+ assert.equal(atPlacement, "Ada");
203
+ });
204
+ it("hands the widget a row that is not yet in the document", () => {
205
+ let connected = true;
206
+ const place = (row, _tuple, template) => {
207
+ connected = row.parentElement !== null;
208
+ template.parentElement.insertBefore(row, template);
209
+ };
210
+ applyRows(list(), rows([{ id: "1", name: "Ada" }]), place);
211
+ assert.equal(connected, false, "the row arrives detached");
212
+ });
213
+ });
214
+ // --- How many are showing ------------------------------------------------
215
+ describe("the row count", () => {
216
+ it("is stamped on the widget", () => {
217
+ // The one conditional a page cannot be sent: the server answers with rows
218
+ // and says nothing about how many survived reconciliation. It is here so
219
+ // that "no members yet" is a stylesheet rule rather than code in every
220
+ // list widget.
221
+ const scope = list();
222
+ applyRows(scope, rows([
223
+ { id: "1", name: "Ada" },
224
+ { id: "2", name: "Bab" },
225
+ ]));
226
+ assert.equal(scope.getAttribute("data-rows"), "2");
227
+ });
228
+ it("is zero for an empty set", () => {
229
+ const scope = list();
230
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]));
231
+ applyRows(scope, rows([]));
232
+ assert.equal(scope.getAttribute("data-rows"), "0");
233
+ });
234
+ it("reports the same fact the same way after a set and after a patch", () => {
235
+ const viaRows = list();
236
+ applyRows(viaRows, rows([
237
+ { id: "1", name: "Ada" },
238
+ { id: "2", name: "Bab" },
239
+ ]));
240
+ const viaPatch = list();
241
+ applyRows(viaPatch, rows([
242
+ { id: "1", name: "Ada" },
243
+ { id: "3", name: "Cai" },
244
+ ]));
245
+ applyRows(viaPatch, patch({ rows: [{ id: "2", name: "Bab" }], drop: ["3"] }));
246
+ assert.equal(viaPatch.getAttribute("data-rows"), viaRows.getAttribute("data-rows"));
247
+ });
248
+ });
249
+ // --- What it refuses to guess at ----------------------------------------
250
+ describe("applyRows reports and stops", () => {
251
+ it("when the widget has no template to clone", async () => {
252
+ const scope = body(`<lb-list></lb-list>`).firstElementChild;
253
+ const { error } = await captured(() => applyRows(scope, rows([{ id: "1" }])));
254
+ assert.match(error.join("\n"), /<lb-list> has no <template> to clone/);
255
+ assert.equal(scope.children.length, 0, "nothing was invented");
256
+ });
257
+ it("when the template does not name the key column", async () => {
258
+ const scope = list(`<template><p></p></template>`);
259
+ const { error } = await captured(() => applyRows(scope, rows([{ id: "1" }])));
260
+ assert.match(error.join("\n"), /has no lb-key naming the column that identifies a row/);
261
+ assert.deepEqual(keys(scope), []);
262
+ });
263
+ it("does not stamp a count it could not compute", async () => {
264
+ const scope = body(`<lb-list></lb-list>`).firstElementChild;
265
+ await captured(() => applyRows(scope, rows([{ id: "1" }])));
266
+ assert.equal(scope.hasAttribute("data-rows"), false);
267
+ });
268
+ it("when a tuple has no key cell, and lands its siblings anyway", async () => {
269
+ const scope = list();
270
+ const { error } = await captured(() => applyRows(scope, rows([
271
+ { id: "1", name: "Ada" },
272
+ { name: "nameless" },
273
+ { id: "2", name: "Bab" },
274
+ ])));
275
+ assert.match(error.join("\n"), /a tuple for <lb-list> has no 'id' cell/);
276
+ assert.deepEqual(keys(scope), ["1", "2"]);
277
+ });
278
+ it("and a keyless tuple does not take a surviving row down with it", async () => {
279
+ // A tuple with no key contributes no key to the arrived set, so the
280
+ // reconciliation that follows must not read its absence as a removal.
281
+ const scope = list();
282
+ applyRows(scope, rows([{ id: "1", name: "Ada" }]));
283
+ await captured(() => applyRows(scope, rows([{ id: "1", name: "Ada" }, { name: "nameless" }])));
284
+ assert.deepEqual(keys(scope), ["1"]);
285
+ });
286
+ });
287
+ // --- The properties ------------------------------------------------------
288
+ describe("reconciliation converges", () => {
289
+ const SET = [
290
+ { id: "1", name: "Ada" },
291
+ { id: "2", name: "Bab" },
292
+ { id: "3", name: "Cai" },
293
+ ];
294
+ it("so applying the same set twice is applying it once", () => {
295
+ const once = list();
296
+ applyRows(once, rows(SET));
297
+ const twice = list();
298
+ applyRows(twice, rows(SET));
299
+ applyRows(twice, rows(SET));
300
+ assert.equal(twice.innerHTML, once.innerHTML);
301
+ assert.equal(twice.getAttribute("data-rows"), once.getAttribute("data-rows"));
302
+ });
303
+ it("so a set reached through any patches is the set reached from empty", () => {
304
+ // Every history below ends at SET. A reconciler that leaks state — a row
305
+ // it forgot to remove, an order it kept from before, a count it stamped
306
+ // from the wrong branch — fails here and nowhere else.
307
+ const histories = [
308
+ [],
309
+ [rows([])],
310
+ [rows([{ id: "9", name: "Zed" }])],
311
+ [rows(SET.slice().reverse())],
312
+ [
313
+ rows([{ id: "1", name: "Wrong" }]),
314
+ patch({ rows: [{ id: "5", name: "Eve" }] }),
315
+ ],
316
+ [rows(SET), patch({ drop: ["2"] })],
317
+ [
318
+ rows(SET),
319
+ patch({ rows: [{ id: "3", name: "Changed" }] }),
320
+ patch({ drop: ["1"] }),
321
+ ],
322
+ [patch({ rows: [{ id: "4", name: "Dot" }] }), patch({ drop: ["4"] })],
323
+ ];
324
+ const fresh = list();
325
+ applyRows(fresh, rows(SET));
326
+ const expected = fresh.innerHTML;
327
+ for (const [index, history] of histories.entries()) {
328
+ const scope = list();
329
+ for (const step of history)
330
+ applyRows(scope, step);
331
+ applyRows(scope, rows(SET));
332
+ assert.equal(scope.innerHTML, expected, `history ${index} did not converge`);
333
+ assert.equal(scope.getAttribute("data-rows"), String(SET.length));
334
+ }
335
+ });
336
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * lb-select — see docs/testing.md, tier 4.
3
+ *
4
+ * The request vocabulary and the addressing vocabulary running in both
5
+ * directions on one widget: it receives its value like any other cell, and on
6
+ * change it sends the action the page declared for it.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=lb-select.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-select.test.d.ts","sourceRoot":"","sources":["../../tests/lb-select.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * lb-select — see docs/testing.md, tier 4.
3
+ *
4
+ * The request vocabulary and the addressing vocabulary running in both
5
+ * directions on one widget: it receives its value like any other cell, and on
6
+ * change it sends the action the page declared for it.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { ATTR_ACTION, ATTR_VALUE } from "../core/lb-constants";
11
+ import { captured } from "./helpers/console";
12
+ import { change, installWindow, mount, requests } from "./helpers/window";
13
+ installWindow();
14
+ await import("../widgets/lb-select");
15
+ const OPTIONS = `<option value="lead">Lead</option><option value="staff">Staff</option>`;
16
+ describe("lb-select receiving a value", () => {
17
+ it("forwards it to the select it owns", () => {
18
+ const widget = mount(`<lb-select><select>${OPTIONS}</select></lb-select>`);
19
+ widget.setAttribute(ATTR_VALUE, "staff");
20
+ assert.equal(widget.querySelector("select").value, "staff");
21
+ });
22
+ it("takes the value it was already carrying when it upgraded", () => {
23
+ const widget = mount(`<lb-select ${ATTR_VALUE}="staff"><select>${OPTIONS}</select></lb-select>`);
24
+ assert.equal(widget.querySelector("select").value, "staff");
25
+ });
26
+ it("reports when there is no select to receive it", async () => {
27
+ const widget = mount(`<lb-select></lb-select>`);
28
+ const { error } = await captured(() => widget.setAttribute(ATTR_VALUE, "staff"));
29
+ assert.match(error.join("\n"), /lb-select: no <select> to receive/);
30
+ });
31
+ });
32
+ describe("lb-select sending an action", () => {
33
+ it("sends the declared action carrying the choice", () => {
34
+ // The choice is the interaction, so this is the case where an action
35
+ // carries a value. The server still decides what that choice means.
36
+ const seen = requests();
37
+ const widget = mount(`<lb-select ${ATTR_ACTION}="setRole"><select>${OPTIONS}</select></lb-select>`);
38
+ const select = widget.querySelector("select");
39
+ select.value = "staff";
40
+ change(select);
41
+ assert.deepEqual(seen, [{ op: "action", name: "setRole", value: "staff" }]);
42
+ });
43
+ it("carries no argument list beyond that one value", () => {
44
+ // What keeps this from being an RPC endpoint: a name the page declared,
45
+ // and at most the one value a control has.
46
+ const seen = requests();
47
+ const widget = mount(`<lb-select ${ATTR_ACTION}="setRole"><select>${OPTIONS}</select></lb-select>`);
48
+ change(widget.querySelector("select"));
49
+ assert.deepEqual(Object.keys(seen[0]), ["op", "name", "value"]);
50
+ });
51
+ it("sends an event that bubbles, because the hub is an ancestor", () => {
52
+ const seen = requests();
53
+ const widget = mount(`<lb-select ${ATTR_ACTION}="setRole"><select>${OPTIONS}</select></lb-select>`);
54
+ change(widget.querySelector("select"));
55
+ assert.equal(seen.length, 1, "the request reached the body");
56
+ });
57
+ it("reports a change it has no action for, and sends nothing", async () => {
58
+ const seen = requests();
59
+ const widget = mount(`<lb-select><select>${OPTIONS}</select></lb-select>`);
60
+ const { error } = await captured(() => change(widget.querySelector("select")));
61
+ assert.match(error.join("\n"), /lb-select: no lb-action/);
62
+ assert.deepEqual(seen, []);
63
+ });
64
+ it("reports a change with no select, and sends nothing", async () => {
65
+ const seen = requests();
66
+ const widget = mount(`<lb-select ${ATTR_ACTION}="setRole"></lb-select>`);
67
+ const { error } = await captured(() => change(widget));
68
+ assert.match(error.join("\n"), /lb-select: change with no <select>/);
69
+ assert.deepEqual(seen, []);
70
+ });
71
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The engine — see docs/testing.md, tier 2.
3
+ *
4
+ * `createHub` takes a plain object and returns an object. Nothing in
5
+ * server/lb-server.ts opens a socket or knows what a request is, so every
6
+ * fixture here is a counting stub and the whole tier runs in node.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=lb-server.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-server.test.d.ts","sourceRoot":"","sources":["../../tests/lb-server.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}