test: harden housekeeping shell contracts
This commit is contained in:
1 parent
addc9c7b1a
commit
52ec48ffe4
3 files changed
+154
-55
No files matched your search
@@ -40,7 +40,8 @@ describe("HousekeepingPageState", () => {
|
||||
);
|
||||
|
||||
expect(loading).toContain('aria-live="polite"');
|
||||
expect(partial).toContain('data-state-tone="warning"');
|
||||
expect(partial).toContain("--admin-warning-border");
|
||||
expect(partial).toContain("--admin-warning-subtle");
|
||||
expect(partial).toContain(">Partial title<");
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function HousekeepingPageState({
|
||||
isError
|
||||
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
|
||||
: isPartial
|
||||
? "border-[var(--admin-warning)] bg-[var(--admin-surface)]"
|
||||
? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
|
||||
: "border-[var(--admin-border)] bg-[var(--admin-surface)]"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -67,14 +67,73 @@ const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const taskFiles = [
|
||||
"src/features/housekeeping/foundation/shell/housekeeping-shell.tsx",
|
||||
"src/features/housekeeping/foundation/shell/domain-rail.tsx",
|
||||
"src/features/housekeeping/foundation/shell/context-nav.tsx",
|
||||
"src/features/housekeeping/foundation/shell/command-trigger.tsx",
|
||||
"src/features/housekeeping/foundation/shell/operator-summary.tsx",
|
||||
"src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx",
|
||||
"src/features/housekeeping/foundation/page/housekeeping-page-state.tsx",
|
||||
"src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx",
|
||||
"src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx",
|
||||
] as const;
|
||||
|
||||
const componentTaskFiles = taskFiles.slice(0, 7);
|
||||
|
||||
const structuralBoundaryRules = [
|
||||
{ label: "client directive", pattern: new RegExp("use " + "client") },
|
||||
{
|
||||
label: "palette color",
|
||||
pattern:
|
||||
/(?:bg|text|border|from|to)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}/,
|
||||
},
|
||||
{ label: "hex color", pattern: new RegExp("#" + "[0-9a-fA-F]{3,8}") },
|
||||
{
|
||||
label: "rgb or hsl color",
|
||||
pattern: new RegExp("(?:r" + "gb|h" + "sl)\\("),
|
||||
},
|
||||
];
|
||||
|
||||
const componentBoundaryRules = [
|
||||
{
|
||||
label: "React hook",
|
||||
pattern:
|
||||
/\buse(?:State|Effect|Memo|Callback|Ref|Reducer|LayoutEffect|Transition)(?:\s*\(|\s*(?:,|}))/,
|
||||
},
|
||||
{ label: "click handler", pattern: /\bonClick\s*=/ },
|
||||
{ label: "keyboard handler", pattern: /\bonKey(?:Down|Up|Press)\s*=/ },
|
||||
{ label: "event listener", pattern: new RegExp("addEvent" + "Listener") },
|
||||
{
|
||||
label: "database or action import",
|
||||
pattern: new RegExp(
|
||||
"(?:from\\s*|import\\s*\\()\\s*['\"][^'\"]*(?:@/lib/" +
|
||||
"db|drizzle(?:-orm)?|/" +
|
||||
"actions?)[^'\"]*['\"]",
|
||||
),
|
||||
},
|
||||
{ label: "command package", pattern: new RegExp("cmd" + "k", "i") },
|
||||
{ label: "localization import", pattern: new RegExp("next" + "-intl") },
|
||||
{
|
||||
label: "search or mutation runtime",
|
||||
pattern: /\b(?:fetch|useQuery|useMutation)\s*\(/,
|
||||
},
|
||||
{
|
||||
label: "persistence storage",
|
||||
pattern:
|
||||
/\b(?:localStorage|sessionStorage|indexedDB)\b|\.(?:setItem|getItem|removeItem)\s*\(/,
|
||||
},
|
||||
];
|
||||
|
||||
function renderShell(
|
||||
activeDomainId: HousekeepingNavigationDomain["id"] = "operations",
|
||||
availableDomains: readonly HousekeepingNavigationDomain[] = domains,
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<HousekeepingShell
|
||||
actor={{ id: 7, username: "Nora", rank: 6 }}
|
||||
activeDomainId={activeDomainId}
|
||||
domains={domains}
|
||||
domains={availableDomains}
|
||||
labels={labels}
|
||||
>
|
||||
<p>Deck body</p>
|
||||
@@ -82,10 +141,30 @@ function renderShell(
|
||||
);
|
||||
}
|
||||
|
||||
function anchors(html: string): readonly string[] {
|
||||
return html.match(/<a\b[^>]*>/g) ?? [];
|
||||
}
|
||||
|
||||
function anchorForHref(html: string, href: string): string {
|
||||
const anchor = anchors(html).find((candidate) =>
|
||||
candidate.includes(`href="${href}"`),
|
||||
);
|
||||
if (!anchor) throw new Error(`missing anchor for ${href}`);
|
||||
return anchor;
|
||||
}
|
||||
|
||||
function boundaryViolations(
|
||||
source: string,
|
||||
rules: readonly { label: string; pattern: RegExp }[],
|
||||
): readonly string[] {
|
||||
return rules
|
||||
.filter((rule) => rule.pattern.test(source))
|
||||
.map((rule) => rule.label);
|
||||
}
|
||||
|
||||
describe("HousekeepingShell", () => {
|
||||
it("renders the accessible command deck landmarks and supplied shell content", () => {
|
||||
const html = renderShell();
|
||||
|
||||
expect(html).toContain('href="#housekeeping-content"');
|
||||
expect(html).toContain(">Skip to housekeeping content<");
|
||||
expect(html).toContain("<aside");
|
||||
@@ -94,9 +173,6 @@ describe("HousekeepingShell", () => {
|
||||
expect(html).toContain('<nav aria-label="Operations navigation"');
|
||||
expect(html).toContain('<main id="housekeeping-content"');
|
||||
expect(html).toContain(">Deck body</p>");
|
||||
expect(html).toMatch(
|
||||
/<a(?=[^>]*href="\/admin-next\/operations")(?=[^>]*aria-current="page")/,
|
||||
);
|
||||
expect(html).toContain('href="/admin-next/operations/queue"');
|
||||
expect(html).toContain(">Queue<");
|
||||
expect(html).toContain(">Nora<");
|
||||
@@ -109,66 +185,88 @@ describe("HousekeepingShell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps every authorized domain icon and only marks the active domain", () => {
|
||||
it("maps all six authorized icons and exactly one active domain", () => {
|
||||
const html = renderShell("people");
|
||||
|
||||
for (const iconId of [
|
||||
"inbox",
|
||||
"users",
|
||||
"file-text",
|
||||
"gem",
|
||||
"hotel",
|
||||
"settings",
|
||||
const allAnchors = anchors(html);
|
||||
const activeAnchors = allAnchors.filter((anchor) =>
|
||||
anchor.includes('aria-current="page"'),
|
||||
);
|
||||
const peopleAnchor = anchorForHref(html, "/admin-next/people");
|
||||
for (const iconClass of [
|
||||
"lucide-inbox",
|
||||
"lucide-users",
|
||||
"lucide-file-text",
|
||||
"lucide-gem",
|
||||
"lucide-hotel",
|
||||
"lucide-settings",
|
||||
]) {
|
||||
expect(html).toContain(`data-domain-icon="${iconId}"`);
|
||||
expect(html).toContain(`class="lucide ${iconClass}"`);
|
||||
}
|
||||
expect(activeAnchors).toHaveLength(1);
|
||||
expect(peopleAnchor).toContain('aria-current="page"');
|
||||
for (const anchor of allAnchors.filter(
|
||||
(anchor) => anchor !== peopleAnchor,
|
||||
)) {
|
||||
expect(anchor).not.toContain('aria-current="page"');
|
||||
}
|
||||
expect(html).toMatch(
|
||||
/<a(?=[^>]*href="\/admin-next\/people")(?=[^>]*aria-current="page")/,
|
||||
);
|
||||
expect(html).not.toContain(
|
||||
'href="/admin-next/operations" aria-current="page"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no contextual items when the active domain is absent", () => {
|
||||
const html = renderShell("system");
|
||||
|
||||
it("does not throw or render contextual items when a valid active domain is absent", () => {
|
||||
const domainsWithoutSystem = domains.filter(
|
||||
(domain) => domain.id !== "system",
|
||||
);
|
||||
expect(() => renderShell("system", domainsWithoutSystem)).not.toThrow();
|
||||
const html = renderShell("system", domainsWithoutSystem);
|
||||
expect(html).not.toContain('href="/admin-next/operations/queue"');
|
||||
expect(html).not.toContain(">Queue<");
|
||||
});
|
||||
|
||||
it("keeps Task 10 modules within the server-rendered source boundary", () => {
|
||||
const taskFiles = [
|
||||
"src/features/housekeeping/foundation/shell/housekeeping-shell.tsx",
|
||||
"src/features/housekeeping/foundation/shell/domain-rail.tsx",
|
||||
"src/features/housekeeping/foundation/shell/context-nav.tsx",
|
||||
"src/features/housekeeping/foundation/shell/command-trigger.tsx",
|
||||
"src/features/housekeeping/foundation/shell/operator-summary.tsx",
|
||||
"src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx",
|
||||
"src/features/housekeeping/foundation/page/housekeeping-page-state.tsx",
|
||||
"src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx",
|
||||
"src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx",
|
||||
];
|
||||
const prohibited = [
|
||||
new RegExp("use " + "client"),
|
||||
new RegExp("cmd" + "k", "i"),
|
||||
new RegExp("addEvent" + "Listener"),
|
||||
new RegExp(
|
||||
"from\\s+['\"][^'\"]*(?:@/lib/" +
|
||||
"db|drizzle|/" +
|
||||
"actions)[^'\"]*['\"]",
|
||||
),
|
||||
new RegExp("next" + "-intl"),
|
||||
/(?:bg|text|border|from|to)-(?:red|blue|green|yellow|orange|purple|pink|gray|slate|zinc|neutral)-\d{2,3}/,
|
||||
new RegExp("#" + "[0-9a-fA-F]{3,8}"),
|
||||
new RegExp("(?:r" + "gb|h" + "sl)\\("),
|
||||
];
|
||||
|
||||
for (const path of taskFiles) {
|
||||
const source = readFileSync(resolve(process.cwd(), path), "utf8");
|
||||
for (const pattern of prohibited) {
|
||||
expect(source).not.toMatch(pattern);
|
||||
}
|
||||
expect(boundaryViolations(source, structuralBoundaryRules)).toEqual([]);
|
||||
}
|
||||
for (const path of componentTaskFiles) {
|
||||
const source = readFileSync(resolve(process.cwd(), path), "utf8");
|
||||
expect(boundaryViolations(source, componentBoundaryRules)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("detects representative prohibited source mutations", () => {
|
||||
const componentMutations = [
|
||||
["React hook", 'import { useState } from "react";'],
|
||||
["click handler", "<button onClick={() => undefined} />"],
|
||||
["keyboard handler", "<input onKeyDown={() => undefined} />"],
|
||||
[
|
||||
"event listener",
|
||||
'window.addEventListener("keydown", () => undefined);',
|
||||
],
|
||||
["database or action import", 'import { db } from "@/lib/db";'],
|
||||
["database or action import", 'await import("@/app/actions");'],
|
||||
["command package", 'import { Command } from "cmdk";'],
|
||||
["localization import", 'import { useTranslations } from "next-intl";'],
|
||||
["search or mutation runtime", "useMutation();"],
|
||||
["persistence storage", "localStorage.setItem('key', 'value');"],
|
||||
] as const;
|
||||
for (const [expectedViolation, source] of componentMutations) {
|
||||
expect(boundaryViolations(source, componentBoundaryRules)).toContain(
|
||||
expectedViolation,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
boundaryViolations(
|
||||
'const color = "bg-' + 'rose-500";',
|
||||
structuralBoundaryRules,
|
||||
),
|
||||
).toContain("palette color");
|
||||
expect(
|
||||
boundaryViolations('const color = "#' + 'abc";', structuralBoundaryRules),
|
||||
).toContain("hex color");
|
||||
expect(
|
||||
boundaryViolations(
|
||||
'const color = "r' + 'gb(1, 2, 3)";',
|
||||
structuralBoundaryRules,
|
||||
),
|
||||
).toContain("rgb or hsl color");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user