273 lines
9.6 KiB
TypeScript
273 lines
9.6 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { readdir, readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { parse } from "../../compiler/src/index.ts";
|
|
|
|
const componentsDir = join(import.meta.dir, "..", "components");
|
|
|
|
const replacementComponents = [
|
|
"Container",
|
|
"Grid",
|
|
"Columns",
|
|
"Card",
|
|
"Link",
|
|
"Image",
|
|
"SearchBox",
|
|
"Map",
|
|
"List",
|
|
"Marquee",
|
|
"Tabs",
|
|
"Timeline",
|
|
"Footer",
|
|
"Typography",
|
|
"Divider",
|
|
] as const;
|
|
|
|
const newComponents = [
|
|
"Section",
|
|
"SectionHeader",
|
|
"MarketingSectionHeader",
|
|
"PageHeader",
|
|
"TextLink",
|
|
"FeatureGrid",
|
|
"FeatureCard",
|
|
"FeatureIconCard",
|
|
"Hero",
|
|
"SplitHero",
|
|
"HeroActions",
|
|
"AnnouncementBar",
|
|
"MetricCard",
|
|
"MetricGrid",
|
|
"StatsBar",
|
|
"CTASection",
|
|
"BackToTop",
|
|
"PublicPageShell",
|
|
] as const;
|
|
|
|
const allComponents = [...replacementComponents, ...newComponents];
|
|
|
|
function openingTags(source: string): string[] {
|
|
const tags: string[] = [];
|
|
let index = 0;
|
|
|
|
while (index < source.length) {
|
|
const start = source.indexOf("<", index);
|
|
if (start === -1) break;
|
|
|
|
let cursor = start + 1;
|
|
let quote: "'" | '"' | null = null;
|
|
|
|
while (cursor < source.length) {
|
|
const char = source[cursor];
|
|
if (quote) {
|
|
if (char === quote) quote = null;
|
|
} else if (char === "'" || char === '"') {
|
|
quote = char;
|
|
} else if (char === ">") {
|
|
tags.push(source.slice(start, cursor + 1));
|
|
cursor += 1;
|
|
break;
|
|
}
|
|
cursor += 1;
|
|
}
|
|
|
|
index = cursor;
|
|
}
|
|
|
|
return tags;
|
|
}
|
|
|
|
describe("@wrnexus/ui redesign component pack", () => {
|
|
test("Hero full bleed does not use scrollbar-sensitive viewport units", async () => {
|
|
const source = await readFile(join(componentsDir, "Hero.wrn"), "utf8");
|
|
expect(source).not.toContain("width: 100vw");
|
|
expect(source).not.toContain("width: 100dvw");
|
|
expect(source).toContain('.wrn-hero[data-full-bleed="true"]');
|
|
});
|
|
|
|
test("Timeline supports numbered data items without styling every row as interactive", async () => {
|
|
const source = await readFile(join(componentsDir, "Timeline.wrn"), "utf8");
|
|
expect(source).toContain("numbered: boolean = false");
|
|
expect(source).toContain("data-interactive");
|
|
expect(source).toContain('data-interactive="true"');
|
|
expect(source).toContain('.wrn-timeline__item[data-interactive="true"] { cursor: pointer; }');
|
|
});
|
|
|
|
test("Blockquote provides constrained contained presentation", async () => {
|
|
const source = await readFile(join(componentsDir, "Blockquote.wrn"), "utf8");
|
|
expect(source).toContain('maxWidth: string = "xl"');
|
|
expect(source).toContain('data-variant="spotlight"');
|
|
expect(source).toContain('data-max-width="full"');
|
|
});
|
|
|
|
test("contains every replacement and new component", async () => {
|
|
const files = new Set(await readdir(componentsDir));
|
|
|
|
for (const name of allComponents) {
|
|
expect(files.has(`${name}.wrn`)).toBe(true);
|
|
}
|
|
});
|
|
|
|
for (const name of allComponents) {
|
|
test(`${name} has a valid component shell`, async () => {
|
|
const source = await readFile(join(componentsDir, `${name}.wrn`), "utf8");
|
|
|
|
expect(source).toContain(`component ${name} {`);
|
|
expect(source).toContain("props {");
|
|
expect(source).toContain("view {");
|
|
expect(source).toContain(`data-ui-component="${name}"`);
|
|
expect(source).not.toContain("React");
|
|
expect(source).not.toContain("useState");
|
|
expect(source).not.toContain("<script");
|
|
});
|
|
}
|
|
|
|
test("opening tags do not contain duplicate class attributes", async () => {
|
|
for (const name of allComponents) {
|
|
const source = await readFile(join(componentsDir, `${name}.wrn`), "utf8");
|
|
|
|
for (const tag of openingTags(source)) {
|
|
const plainClassAttributes = tag.match(/(?<![:\\w-])class\\s*=/g) ?? [];
|
|
expect(plainClassAttributes.length).toBeLessThanOrEqual(1);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("dynamic attribute expressions stay single quoted", async () => {
|
|
for (const name of allComponents) {
|
|
const source = await readFile(join(componentsDir, `${name}.wrn`), "utf8");
|
|
expect(source).not.toMatch(/=\\"\{[^\n]*\}\\"/);
|
|
}
|
|
});
|
|
|
|
test("Navbar only displays its utility bar when the topbar slot has rendered content", async () => {
|
|
const source = await readFile(join(componentsDir, "Navbar.wrn"), "utf8");
|
|
|
|
expect(source).toContain(".wrn-navbar__topbar:not(:has(> *))");
|
|
expect(source).toContain(".wrn-navbar__topbar:has(> *)");
|
|
expect(source).not.toContain(".wrn-navbar__topbar:not(:empty)");
|
|
});
|
|
|
|
test("Navbar renders its brand prop independently from the optional brand slot", async () => {
|
|
const source = await readFile(join(componentsDir, "Navbar.wrn"), "utf8");
|
|
|
|
expect(source).toContain('<slot name="brand" />');
|
|
expect(source).toContain("brand.logo || brand.icon || brand.label || brand.description");
|
|
expect(source.indexOf("brand.logo || brand.icon || brand.label || brand.description")).toBeLessThan(
|
|
source.indexOf('<slot name="brand" />'),
|
|
);
|
|
});
|
|
|
|
test("Navbar renders slotted utilities before its built-in actions", async () => {
|
|
const source = await readFile(join(componentsDir, "Navbar.wrn"), "utf8");
|
|
const actionsStart = source.indexOf('<div class="wrn-navbar__actions">');
|
|
const slotIndex = source.indexOf('<slot name="actions" />', actionsStart);
|
|
const actionsLoop = source.indexOf("{#each actions as item}", actionsStart);
|
|
|
|
expect(slotIndex).toBeGreaterThan(actionsStart);
|
|
expect(slotIndex).toBeLessThan(actionsLoop);
|
|
});
|
|
|
|
test("Navbar passes structured MegaMenu props as expressions", async () => {
|
|
const source = await readFile(join(componentsDir, "Navbar.wrn"), "utf8");
|
|
|
|
expect(source).toContain("columns={megaColumns(item)}");
|
|
expect(source).toContain("rail={megaData(item).rail || []}");
|
|
expect(source).toContain("featured={megaData(item).featured || {}}");
|
|
expect(source).toContain("fullWidth={megaData(item).fullWidth || false}");
|
|
expect(source).not.toContain('columns="{megaColumns(item)}"');
|
|
});
|
|
|
|
test("Navbar replaces mega panels with direct links in collapsed navigation", async () => {
|
|
const source = await readFile(join(componentsDir, "Navbar.wrn"), "utf8");
|
|
|
|
expect(source).toContain("wrn-navbar__mobile-mega-link");
|
|
expect(source).toContain("item.href || megaData(item).actionHref");
|
|
expect(source).toContain('.wrn-navbar__mega { display: none; }');
|
|
expect(source).toContain('.wrn-navbar__mobile-mega-link { display: flex; }');
|
|
});
|
|
|
|
test("Navbar supports in-flow floating and elevated menu panels", async () => {
|
|
const source = await readFile(join(componentsDir, "Navbar.wrn"), "utf8");
|
|
const ast = parse(source);
|
|
const defaults = new Map(ast.props.map((prop) => [prop.name, prop.default]));
|
|
|
|
expect(defaults.get("floating")).toBe("false");
|
|
expect(defaults.get("startFloating")).toBe('"0px"');
|
|
expect(source).toContain("wrn-navbar--floating");
|
|
expect(source).toContain("--wrn-navbar-floating-start");
|
|
expect(source).toContain("z-index: 1100");
|
|
});
|
|
|
|
test("CTASection exposes responsive section spacing", async () => {
|
|
const source = await readFile(join(componentsDir, "CTASection.wrn"), "utf8");
|
|
const ast = parse(source);
|
|
const defaults = new Map(ast.props.map((prop) => [prop.name, prop.default]));
|
|
|
|
expect(defaults.get("spacing")).toBe('"none"');
|
|
expect(source).toContain("data-spacing='{spacing}'");
|
|
expect(source).toContain('.wrn-cta-section[data-spacing="sm"]');
|
|
expect(source).toContain('.wrn-cta-section[data-spacing="md"]');
|
|
expect(source).toContain('.wrn-cta-section[data-spacing="lg"]');
|
|
});
|
|
|
|
test("existing public defaults remain release compatible", async () => {
|
|
const expectations: Record<string, string[]> = {
|
|
Container: [
|
|
'size = "default"',
|
|
'color = "primary"',
|
|
"columns = 2",
|
|
'gap = "md"',
|
|
'maxWidth = "xl"',
|
|
],
|
|
Grid: [
|
|
'size = "default"',
|
|
'color = "primary"',
|
|
"columns = 2",
|
|
'gap = "md"',
|
|
'maxWidth = "xl"',
|
|
],
|
|
Columns: [
|
|
'size = "default"',
|
|
'color = "primary"',
|
|
"columns = 2",
|
|
'gap = "md"',
|
|
'maxWidth = "xl"',
|
|
],
|
|
Link: ['label = "Link"', 'href = "#"', "external = false"],
|
|
Image: ['src = ""', 'alt = ""', 'loading = "lazy"', "rounded = false"],
|
|
SearchBox: [
|
|
'label = "Search Box"',
|
|
'type = "search"',
|
|
"disabled = false",
|
|
"required = false",
|
|
],
|
|
Map: ['title = "Map"', "items = []", 'variant = "default"'],
|
|
List: ['title = "List"', "items = []", 'variant = "default"'],
|
|
Marquee: ['title = "Marquee"', "items = []", 'variant = "default"'],
|
|
Tabs: ['label = "Tabs"', "items = []", 'active = ""', 'orientation = "horizontal"'],
|
|
Timeline: ['title = "Timeline"', "items = []", 'variant = "default"'],
|
|
Footer: ['label = "Footer navigation"', "items = []", "columns = 3", 'maxWidth = "compact"'],
|
|
Typography: [
|
|
'size = "default"',
|
|
'color = "primary"',
|
|
"columns = 2",
|
|
'gap = "md"',
|
|
'maxWidth = "xl"',
|
|
],
|
|
Divider: ['label = ""', 'orientation = "horizontal"'],
|
|
};
|
|
|
|
for (const [name, tokens] of Object.entries(expectations)) {
|
|
const source = await readFile(join(componentsDir, `${name}.wrn`), "utf8");
|
|
const ast = parse(source);
|
|
const defaults = new Map(ast.props.map((prop) => [prop.name, prop.default]));
|
|
for (const token of tokens) {
|
|
const match = /^([A-Za-z_$][\w$]*)\s*=\s*([\s\S]+)$/.exec(token);
|
|
expect(match).not.toBeNull();
|
|
expect(defaults.get(match![1]!)).toBe(match![2]!);
|
|
}
|
|
}
|
|
});
|
|
});
|