@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.
- package/README.md +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +141 -15
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +71 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- 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
|
+
});
|