@terpjs/react-core 0.8.0 → 0.10.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 (106) hide show
  1. package/README.md +62 -22
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.tsx +35 -75
  11. package/src/ModuleNav.test.tsx +26 -0
  12. package/src/ModuleNav.tsx +45 -38
  13. package/src/Page.test.tsx +9 -6
  14. package/src/Page.tsx +37 -39
  15. package/src/ProfileView.test.tsx +15 -0
  16. package/src/ProfileView.tsx +9 -36
  17. package/src/ResourceList.tsx +13 -24
  18. package/src/SettingsPage.tsx +50 -0
  19. package/src/SplitPage.tsx +150 -0
  20. package/src/UserMenu.test.tsx +28 -5
  21. package/src/UserMenu.tsx +15 -9
  22. package/src/admin/AuditLogAdmin.tsx +21 -16
  23. package/src/admin/GroupCreate.tsx +18 -4
  24. package/src/admin/GroupDetail.tsx +50 -15
  25. package/src/admin/GroupsAdmin.tsx +13 -5
  26. package/src/admin/UserCreate.tsx +41 -12
  27. package/src/admin/UserDetail.tsx +4 -1
  28. package/src/admin/UsersAdmin.tsx +14 -6
  29. package/src/admin/admin.test.tsx +238 -3
  30. package/src/admin/fieldErrors.ts +45 -0
  31. package/src/bootstrap.test.tsx +208 -0
  32. package/src/bootstrap.tsx +121 -5
  33. package/src/breakpoints.ts +41 -0
  34. package/src/dataview/DataView.tsx +12 -5
  35. package/src/dataview/DataViewCardList.tsx +8 -7
  36. package/src/dataview/DataViewPagination.tsx +15 -8
  37. package/src/dataview/DataViewTable.tsx +32 -21
  38. package/src/dataview/README.md +13 -2
  39. package/src/dataview/index.ts +1 -0
  40. package/src/dataview/internal.tsx +31 -1
  41. package/src/dataview/types.ts +26 -3
  42. package/src/download.test.tsx +153 -0
  43. package/src/download.tsx +132 -0
  44. package/src/files.tsx +2 -11
  45. package/src/format.test.tsx +213 -0
  46. package/src/format.ts +150 -0
  47. package/src/icons.tsx +67 -5
  48. package/src/index.ts +63 -7
  49. package/src/layout.manifest.json +118 -0
  50. package/src/layout.manifest.test.ts +205 -0
  51. package/src/layout.test.tsx +198 -1
  52. package/src/layout.tsx +208 -11
  53. package/src/layoutContract.test.tsx +311 -2
  54. package/src/layoutContract.ts +44 -3
  55. package/src/layoutDeclaration.test.ts +435 -0
  56. package/src/layoutDeclaration.ts +531 -0
  57. package/src/locale.tsx +3 -0
  58. package/src/markers.test.ts +141 -15
  59. package/src/nav.test.ts +234 -4
  60. package/src/nav.ts +180 -6
  61. package/src/navActive.test.ts +115 -0
  62. package/src/navActive.ts +119 -0
  63. package/src/navLink.tsx +20 -2
  64. package/src/previewBridge.test.ts +327 -0
  65. package/src/previewBridge.ts +278 -0
  66. package/src/raw.d.ts +14 -2
  67. package/src/review.test.tsx +272 -0
  68. package/src/routeSearch.ts +73 -0
  69. package/src/routeTypes.ts +50 -6
  70. package/src/router.test.tsx +766 -3
  71. package/src/router.tsx +277 -28
  72. package/src/sso.test.tsx +6 -3
  73. package/src/styles.test.ts +518 -27
  74. package/src/styles.ts +1287 -66
  75. package/src/theme.test.tsx +29 -0
  76. package/src/theme.themes.test.ts +13 -7
  77. package/src/theme.tsx +30 -33
  78. package/src/themes.ts +54 -0
  79. package/src/toast.tsx +2 -1
  80. package/src/tokens.guard.test.ts +192 -0
  81. package/src/typography.test.tsx +213 -0
  82. package/src/typography.tsx +255 -0
  83. package/src/ui/Avatar.test.tsx +63 -0
  84. package/src/ui/Avatar.tsx +65 -0
  85. package/src/ui/Button.test.tsx +71 -3
  86. package/src/ui/Button.tsx +57 -4
  87. package/src/ui/Card.test.tsx +13 -0
  88. package/src/ui/Card.tsx +28 -1
  89. package/src/ui/Checkbox.tsx +10 -2
  90. package/src/ui/Combobox.test.tsx +49 -0
  91. package/src/ui/Combobox.tsx +8 -2
  92. package/src/ui/DatePicker.tsx +28 -5
  93. package/src/ui/Input.test.tsx +123 -0
  94. package/src/ui/Input.tsx +65 -2
  95. package/src/ui/Menu.tsx +16 -5
  96. package/src/ui/Popover.tsx +13 -0
  97. package/src/ui/Radio.tsx +10 -5
  98. package/src/ui/Select.test.tsx +232 -0
  99. package/src/ui/Select.tsx +177 -8
  100. package/src/ui/Switch.tsx +10 -2
  101. package/src/ui/Tabs.tsx +16 -6
  102. package/src/ui/Tooltip.test.tsx +56 -1
  103. package/src/ui/Tooltip.tsx +69 -6
  104. package/src/uiText.tsx +9 -0
  105. package/src/unwrap.test.ts +132 -0
  106. package/src/unwrap.ts +118 -32
@@ -0,0 +1,435 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveLayoutDeclaration } from "./layoutDeclaration";
4
+ import { THEMES } from "./themes";
5
+
6
+ describe("resolveLayoutDeclaration", () => {
7
+ it("returns the options untouched when there is no declaration", () => {
8
+ // The property that makes this opt-in: an app that passes no file is on the exact path it
9
+ // was on before this module existed, so adopting the declaration is a choice rather than a
10
+ // migration. Asserted as identity of VALUES rather than of the object, because the router
11
+ // spreads it either way.
12
+ const explicit = {
13
+ contract: "standard",
14
+ density: "compact",
15
+ navPlacement: "header",
16
+ contentWidth: "measured",
17
+ } as const;
18
+ expect(resolveLayoutDeclaration(undefined, explicit)).toEqual(explicit);
19
+ expect(resolveLayoutDeclaration(undefined, {})).toEqual({});
20
+ });
21
+
22
+ it("supplies the contract and every shell key from the file", () => {
23
+ expect(
24
+ resolveLayoutDeclaration(
25
+ {
26
+ contract: "standard",
27
+ shell: { density: "compact", navPlacement: "header", contentWidth: "measured" },
28
+ },
29
+ {},
30
+ ),
31
+ ).toEqual({
32
+ contract: "standard",
33
+ density: "compact",
34
+ navPlacement: "header",
35
+ contentWidth: "measured",
36
+ });
37
+ });
38
+
39
+ it("leaves a key the file does not mention to the options", () => {
40
+ // Partial declarations are the normal state, not an edge case: a file that names only the
41
+ // contract is what every scaffolded app has today, and its shell options must keep working.
42
+ expect(
43
+ resolveLayoutDeclaration({ contract: "standard" }, { density: "compact" }),
44
+ ).toEqual({ contract: "standard", density: "compact" });
45
+ });
46
+
47
+ it("treats an empty shell object as saying nothing", () => {
48
+ expect(resolveLayoutDeclaration({ shell: {} }, { density: "compact" })).toEqual({
49
+ density: "compact",
50
+ });
51
+ });
52
+
53
+ // ---- the three refusals ---------------------------------------------------
54
+
55
+ it("refuses a value outside its enum, naming the alternatives", () => {
56
+ // A JSON file is not typechecked. Without this, `"compakt"` reaches the shell as an
57
+ // attribute value nothing styles — a declaration that silently does nothing, which is the
58
+ // failure the declaration exists to remove.
59
+ expect(() =>
60
+ resolveLayoutDeclaration({ shell: { density: "compakt" } }, {}),
61
+ ).toThrow(/shell\.density is "compakt"; expected one of "comfortable", "compact"/);
62
+ });
63
+
64
+ it("refuses a key this release does not read, at either level", () => {
65
+ // The cost of the choice, and it is the right way round: an app pinned to a release is told
66
+ // the release cannot honour a key, rather than being told nothing while the key sits in the
67
+ // file looking effective.
68
+ expect(() =>
69
+ resolveLayoutDeclaration({ shell: { sidebarWidth: "20rem" } as never }, {}),
70
+ ).toThrow(
71
+ /unknown shell key "sidebarWidth"; this release reads "density", "navPlacement", "contentWidth", "brand", "navGroups"/,
72
+ );
73
+ // "theme", not "defaultTheme": a near-miss of a real key is the shape a hand-edit
74
+ // produces, and it is what the unknown-key refusal is for.
75
+ expect(() => resolveLayoutDeclaration({ theme: "dark" } as never, {})).toThrow(
76
+ /unknown key "theme"; this release reads "contract", "defaultTheme", "shell"/,
77
+ );
78
+ });
79
+
80
+ it("refuses one fact declared twice, and names both sources with their values", () => {
81
+ // Whichever way precedence went, someone editing the losing source would watch their change
82
+ // do nothing — and once the file is what tools edit, the tool becomes the one making a
83
+ // change that does nothing. The message has to carry both values or the reader cannot tell
84
+ // which one they are looking at.
85
+ expect(() =>
86
+ resolveLayoutDeclaration(
87
+ { shell: { density: "compact" } },
88
+ { density: "comfortable" },
89
+ ),
90
+ ).toThrow(/"shell\.density" \(file: "compact", code: "comfortable"\)/);
91
+ expect(() =>
92
+ resolveLayoutDeclaration({ contract: "standard" }, { contract: "bespoke" }),
93
+ ).toThrow(/"contract" \(file: "standard", code: "bespoke"\)/);
94
+ });
95
+
96
+ it("reports every conflicting key at once rather than the first", () => {
97
+ // A one-at-a-time refusal turns adopting the file into a guessing loop: fix, re-run, learn
98
+ // about the next one. Every conflicting key is named at once — how many keys there are is
99
+ // TOP_LEVEL_KEYS plus SHELL_KEYS, not a number written down here, which was already stale
100
+ // one commit after it was written.
101
+ let message = "";
102
+ try {
103
+ resolveLayoutDeclaration(
104
+ {
105
+ contract: "standard",
106
+ defaultTheme: "midnight",
107
+ shell: { density: "compact", navPlacement: "header" },
108
+ },
109
+ {
110
+ contract: "standard",
111
+ defaultTheme: "dark",
112
+ density: "compact",
113
+ navPlacement: "header",
114
+ },
115
+ );
116
+ } catch (error) {
117
+ message = (error as Error).message;
118
+ }
119
+ expect(message).toContain('"contract"');
120
+ expect(message).toContain('"defaultTheme"');
121
+ expect(message).toContain('"shell.density"');
122
+ expect(message).toContain('"shell.navPlacement"');
123
+ });
124
+
125
+ it("refuses a duplicate even when both sources agree", () => {
126
+ // The tempting exemption, declined. Two sources holding the same value today is exactly the
127
+ // state that rots: one of them gets edited, the other does not, and the app silently keeps
128
+ // the stale one. The template shipped precisely this — `layoutContract: "standard"` beside
129
+ // `{"contract": "standard"}` — with "keep the two in sync" written above it.
130
+ expect(() =>
131
+ resolveLayoutDeclaration({ contract: "standard" }, { contract: "standard" }),
132
+ ).toThrow(/both declare/);
133
+ });
134
+
135
+ // ---- the palette the app opens on ---------------------------------------- #
136
+
137
+ it("supplies the palette from the file, narrowed to the published list", () => {
138
+ // The point of the key: which palette an app opens on used to be reachable only by editing
139
+ // that app's own code, which put it out of reach of anything that edits files.
140
+ expect(resolveLayoutDeclaration({ defaultTheme: "midnight" }, {})).toEqual({
141
+ defaultTheme: "midnight",
142
+ });
143
+ });
144
+
145
+ it("accepts every palette the framework publishes, and nothing else", () => {
146
+ // Asserted over the published array rather than over a hand-picked few, because the failure
147
+ // this prevents is a SHIPPED palette an app cannot name — which a spot-check of three would
148
+ // not catch. `THEMES` is the same list the theme control offers, held to the compiled
149
+ // stylesheet by theme.themes.test.ts, so this closes the loop from stylesheet to file.
150
+ expect(THEMES.length).toBeGreaterThan(3);
151
+ for (const theme of THEMES) {
152
+ expect(resolveLayoutDeclaration({ defaultTheme: theme }, {})).toEqual({
153
+ defaultTheme: theme,
154
+ });
155
+ }
156
+ });
157
+
158
+ it("accepts the OS-preference sentinel like any other palette", () => {
159
+ // `"system"` is a real thing to declare — "open on whatever the viewer's platform prefers"
160
+ // — and it goes through the same enum check, the same conflict check and the same output as
161
+ // a named palette. Nothing about it is exempt, which is the point: an earlier version of
162
+ // this test and of ADR 0100 said the file's `"system"` OVERRIDES a passed option, and it
163
+ // does not. Declaring it in both places is refused like every other key, and the second
164
+ // assertion here is what says so.
165
+ expect(resolveLayoutDeclaration({ defaultTheme: "system" }, {})).toEqual({
166
+ defaultTheme: "system",
167
+ });
168
+ expect(() =>
169
+ resolveLayoutDeclaration({ defaultTheme: "system" }, { defaultTheme: "dark" }),
170
+ ).toThrow(/both declare "defaultTheme" \(file: "system", code: "dark"\)/);
171
+ // And an absent key does leave the option in force, which is the half that was true.
172
+ expect(resolveLayoutDeclaration({}, { defaultTheme: "dark" })).toEqual({
173
+ defaultTheme: "dark",
174
+ });
175
+ });
176
+
177
+ it("refuses a palette this release does not ship, naming the ones it does", () => {
178
+ // The alternative is falling back to a palette that does exist, and that is how a
179
+ // declaration ends up doing nothing while looking like it works: `data-theme="midnite"`
180
+ // matches no block in the stylesheet, so the app renders the base palette and nothing
181
+ // anywhere reports that the file was ignored.
182
+ expect(() => resolveLayoutDeclaration({ defaultTheme: "midnite" }, {})).toThrow(
183
+ /"defaultTheme" is "midnite"; expected one of "light", "dark", "midnight", "twilight", "contrast", "system"/,
184
+ );
185
+ });
186
+
187
+ it("refuses the palette declared twice, like every other key", () => {
188
+ expect(() =>
189
+ resolveLayoutDeclaration({ defaultTheme: "midnight" }, { defaultTheme: "dark" }),
190
+ ).toThrow(/"defaultTheme" \(file: "midnight", code: "dark"\)/);
191
+ expect(() =>
192
+ resolveLayoutDeclaration({ defaultTheme: "dark" }, { defaultTheme: "dark" }),
193
+ ).toThrow(/both declare/);
194
+ });
195
+
196
+ // ---- the navigation groups ------------------------------------------------ #
197
+
198
+ it("supplies the groups from the file, with the empty label meaning no label", () => {
199
+ // A group spans modules, so no module can own one — which left the app's own code as the
200
+ // only place one could be declared. `""` is how a file says what `NavGroup` says with
201
+ // `null`: render no label element at all.
202
+ expect(
203
+ resolveLayoutDeclaration(
204
+ {
205
+ shell: {
206
+ navGroups: [
207
+ { id: "work", label: "Workspace", order: 1 },
208
+ { id: "pinned", label: "" },
209
+ ],
210
+ },
211
+ },
212
+ {},
213
+ ),
214
+ ).toEqual({
215
+ navGroups: [
216
+ { id: "work", label: "Workspace", order: 1 },
217
+ { id: "pinned", label: null },
218
+ ],
219
+ });
220
+ });
221
+
222
+ it("omits the sort key when the file omits it, rather than writing a zero", () => {
223
+ // `groupNav` reads `order ?? 0` over a stable sort, so absent and 0 sort identically — but
224
+ // they are not the same statement, and a resolver that materialised one into the other
225
+ // would make "I said nothing about order" indistinguishable from "I said first".
226
+ const [group] = resolveLayoutDeclaration(
227
+ { shell: { navGroups: [{ id: "work", label: "Workspace" }] } },
228
+ {},
229
+ ).navGroups!;
230
+ // `null === null ? "Workspace" : ""` stood here, which is a constant expression dressed
231
+ // up as a choice between the two spellings of a label. It selected nothing.
232
+ expect(group).toEqual({ id: "work", label: "Workspace" });
233
+ expect(Object.hasOwn(group!, "order")).toBe(false);
234
+ });
235
+
236
+ it("treats an empty group list as a declaration of no groups", () => {
237
+ // Not the same as omitting the key: an empty list still conflicts with a passed option,
238
+ // because the app has said something about groups and the option says something else.
239
+ expect(resolveLayoutDeclaration({ shell: { navGroups: [] } }, {})).toEqual({
240
+ navGroups: [],
241
+ });
242
+ expect(() =>
243
+ resolveLayoutDeclaration(
244
+ { shell: { navGroups: [] } },
245
+ { navGroups: [{ id: "work", label: "Workspace" }] },
246
+ ),
247
+ ).toThrow(/"shell\.navGroups" \(file: no groups, code: "work"\)/);
248
+ // Two ids on one side, which no test rendered before: the message used to wrap the whole
249
+ // joined list in one pair of quotes, so this read as a single group named `work, admin` —
250
+ // the opposite of what putting the ids in the message is for.
251
+ expect(() =>
252
+ resolveLayoutDeclaration(
253
+ { shell: { navGroups: [] } },
254
+ {
255
+ navGroups: [
256
+ { id: "work", label: "Workspace" },
257
+ { id: "admin", label: "Admin" },
258
+ ],
259
+ },
260
+ ),
261
+ ).toThrow(/code: "work", "admin"\)/);
262
+ });
263
+
264
+ it("refuses a group the shell would render as nothing", () => {
265
+ // Each of these reaches the shell as a group no `NavItem.group` string can name, so its
266
+ // items fall into the trailing unlabelled bucket and the group never appears: a declaration
267
+ // that silently does nothing, which is what the document exists to remove.
268
+ expect(() =>
269
+ resolveLayoutDeclaration({ shell: { navGroups: [{ id: 7, label: "x" }] } } as never, {}),
270
+ ).toThrow(/shell\.navGroups\[0\]\.id must be a string, got a number/);
271
+ expect(() =>
272
+ resolveLayoutDeclaration({ shell: { navGroups: [{ id: "", label: "x" }] } }, {}),
273
+ ).toThrow(/shell\.navGroups\[0\]\.id is the empty string/);
274
+ expect(() =>
275
+ resolveLayoutDeclaration({ shell: { navGroups: [{ id: "work" }] } } as never, {}),
276
+ ).toThrow(
277
+ /shell\.navGroups\[0\] is missing "label"; every group declares "id" and "label", and "" is a group that renders no label at all/,
278
+ );
279
+ expect(() =>
280
+ resolveLayoutDeclaration({ shell: { navGroups: [{ label: "x" }] } } as never, {}),
281
+ ).toThrow(/shell\.navGroups\[0\] is missing "id"; every group declares "id" and "label"\./);
282
+ expect(() =>
283
+ resolveLayoutDeclaration(
284
+ { shell: { navGroups: [{ id: "work", label: null }] } } as never,
285
+ {},
286
+ ),
287
+ ).toThrow(/shell\.navGroups\[0\]\.label must be a string, got null; use "" for a group/);
288
+ });
289
+
290
+ it("refuses a sort key that is not a whole number, naming what it got", () => {
291
+ expect(() =>
292
+ resolveLayoutDeclaration(
293
+ { shell: { navGroups: [{ id: "work", label: "x", order: 1.5 }] } },
294
+ {},
295
+ ),
296
+ ).toThrow(/shell\.navGroups\[0\]\.order must be a whole number, got 1\.5/);
297
+ expect(() =>
298
+ resolveLayoutDeclaration(
299
+ { shell: { navGroups: [{ id: "work", label: "x", order: "1" }] } } as never,
300
+ {},
301
+ ),
302
+ ).toThrow(/shell\.navGroups\[0\]\.order must be a whole number, got a string/);
303
+ });
304
+
305
+ it("refuses a field on a group this release does not read", () => {
306
+ // Same reasoning one level down from the unknown-key refusal: an "icon" nobody renders sits
307
+ // in the file looking effective.
308
+ expect(() =>
309
+ resolveLayoutDeclaration(
310
+ { shell: { navGroups: [{ id: "work", label: "x", icon: "folder" }] } } as never,
311
+ {},
312
+ ),
313
+ ).toThrow(/shell\.navGroups\[0\] has unknown field "icon"; a group is "id", "label", "order"/);
314
+ });
315
+
316
+ it("refuses a group list that is not a list, and an entry that is not an object", () => {
317
+ expect(() =>
318
+ resolveLayoutDeclaration({ shell: { navGroups: {} } } as never, {}),
319
+ ).toThrow(/shell\.navGroups must be an array, got an object/);
320
+ expect(() =>
321
+ resolveLayoutDeclaration({ shell: { navGroups: ["work"] } } as never, {}),
322
+ ).toThrow(/shell\.navGroups\[0\] must be a JSON object, got a string/);
323
+ });
324
+
325
+ it("does not refuse duplicate ids — the router does, over the resolved list", () => {
326
+ // Deliberate, and the reason is that one refusal must cover both sources. `buildAppRouter`
327
+ // already refuses duplicates and now reads the RESOLVED list, so a duplicate declared in the
328
+ // file is refused by the same check and the same message as one passed as an option.
329
+ // Restating it here would be a second message for one error.
330
+ expect(
331
+ resolveLayoutDeclaration(
332
+ { shell: { navGroups: [{ id: "work", label: "A" }, { id: "work", label: "B" }] } },
333
+ {},
334
+ ).navGroups,
335
+ ).toHaveLength(2);
336
+ });
337
+
338
+ // ---- the mark the app is recognised by ------------------------------------ #
339
+
340
+ it("supplies both marks from the file, as paths", () => {
341
+ // Paths rather than elements, because a file is what a tool can put somewhere and a
342
+ // rendered element is not — which is the whole reason the mark joined the document.
343
+ expect(
344
+ resolveLayoutDeclaration(
345
+ { shell: { brand: { logo: "/logo.png", logoDark: "/logo-dark.png" } } },
346
+ {},
347
+ ),
348
+ ).toEqual({ brand: { logo: "/logo.png", logoDark: "/logo-dark.png" } });
349
+ });
350
+
351
+ it("takes one mark as a complete declaration", () => {
352
+ // An app whose mark survives a dark background has one file and says so. Requiring the
353
+ // counterpart would make every app claim a second asset it may not have.
354
+ expect(resolveLayoutDeclaration({ shell: { brand: { logo: "/logo.svg" } } }, {})).toEqual({
355
+ brand: { logo: "/logo.svg" },
356
+ });
357
+ });
358
+
359
+ it("refuses a mark the shell would render as a broken image", () => {
360
+ // Each of these reaches an `<img>` that cannot load it, which renders WORSE than declaring
361
+ // nothing: an app with no mark gets the framework's placeholder, and this gets a broken one.
362
+ expect(() =>
363
+ resolveLayoutDeclaration({ shell: { brand: { logo: 7 } } } as never, {}),
364
+ ).toThrow(/shell\.brand\.logo must be a path, got a number/);
365
+ expect(() =>
366
+ resolveLayoutDeclaration({ shell: { brand: { logoDark: " " } } }, {}),
367
+ ).toThrow(/shell\.brand\.logoDark is empty; omit it to keep one mark/);
368
+ expect(() =>
369
+ resolveLayoutDeclaration({ shell: { brand: [] } } as never, {}),
370
+ ).toThrow(/shell\.brand must be a JSON object, got an array/);
371
+ });
372
+
373
+ it("refuses a mark slot this release does not read", () => {
374
+ expect(() =>
375
+ resolveLayoutDeclaration({ shell: { brand: { icon: "/icon.png" } } } as never, {}),
376
+ ).toThrow(/shell\.brand has unknown mark "icon"; a brand is "logo" and "logoDark"/);
377
+ });
378
+
379
+ // ---- the file can hold anything JSON can ---------------------------------
380
+
381
+ it("refuses a declaration that is not a JSON object, naming what it got", () => {
382
+ // Every case here was found by PROBING the function rather than by imagining inputs, and
383
+ // each was broken differently. `null` threw a bare "Cannot convert undefined or null to
384
+ // object" with no mention of the file. A string and an array had their character and
385
+ // element indices reported as unknown KEYS. And an array was accepted outright — it has no
386
+ // keys, so it declared nothing and returned silently, which is the exact failure this
387
+ // module exists to prevent, occurring inside it.
388
+ expect(() => resolveLayoutDeclaration(null as never, {})).toThrow(
389
+ /expected a JSON object, got null/,
390
+ );
391
+ expect(() => resolveLayoutDeclaration([] as never, {})).toThrow(
392
+ /expected a JSON object, got an array/,
393
+ );
394
+ expect(() => resolveLayoutDeclaration("standard" as never, {})).toThrow(
395
+ /expected a JSON object, got a string/,
396
+ );
397
+ });
398
+
399
+ it("refuses a shell that is not a JSON object", () => {
400
+ for (const shell of [null, "compact", 7, []]) {
401
+ expect(() => resolveLayoutDeclaration({ shell } as never, {})).toThrow(
402
+ /"shell" must be a JSON object, got /,
403
+ );
404
+ }
405
+ });
406
+
407
+ it("refuses a value that is not a string, by type rather than by enum", () => {
408
+ // `shell.density is "7"` would read as a typo in a string the author never wrote, so the
409
+ // type is named instead of the value being stringified into the enum message.
410
+ expect(() => resolveLayoutDeclaration({ shell: { density: 7 } } as never, {})).toThrow(
411
+ /shell\.density must be a string, got a number/,
412
+ );
413
+ expect(() => resolveLayoutDeclaration({ contract: 7 } as never, {})).toThrow(
414
+ /"contract" must be a string, got a number/,
415
+ );
416
+ expect(() => resolveLayoutDeclaration({ defaultTheme: [] } as never, {})).toThrow(
417
+ /"defaultTheme" must be a string, got an array/,
418
+ );
419
+ });
420
+
421
+ it("names the file rather than the option in every message", () => {
422
+ // The file is the source a person or a tool should edit, so it is the thing a refusal points
423
+ // at. A message naming only the TypeScript option would send the reader to the half this
424
+ // change exists to retire.
425
+ for (const declaration of [
426
+ { theme: "dark" } as never,
427
+ { shell: { density: "nope" } },
428
+ { contract: "standard" },
429
+ ]) {
430
+ expect(() =>
431
+ resolveLayoutDeclaration(declaration, { contract: "standard" }),
432
+ ).toThrow(/frontend\/layout-contract\.json/);
433
+ }
434
+ });
435
+ });