mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-10-27 00:32:51 +08:00
1077 lines
30 KiB
JavaScript
1077 lines
30 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFile } from "node:fs/promises";
|
|
import { test } from "node:test";
|
|
|
|
const source = await readFile(
|
|
new URL("../src/resource/menu-aurora.js", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const header = await readFile(
|
|
new URL("../../ucode/template/themes/aurora/header.ut", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
const getMethodSource = (name) => {
|
|
const start = source.indexOf(` ${name}(`);
|
|
|
|
if (start === -1) return "";
|
|
|
|
const rest = source.slice(start + 2);
|
|
const nextMethod = rest.slice(name.length).search(/^ [A-Za-z_$][\w$]*\(/m);
|
|
|
|
return nextMethod === -1
|
|
? source.slice(start)
|
|
: source.slice(start, start + 2 + name.length + nextMethod);
|
|
};
|
|
|
|
class FakeClassList {
|
|
constructor(element) {
|
|
this.element = element;
|
|
this.values = new Set();
|
|
}
|
|
|
|
add(...classNames) {
|
|
classNames.forEach((className) => this.values.add(className));
|
|
this.sync();
|
|
}
|
|
|
|
contains(className) {
|
|
return this.values.has(className);
|
|
}
|
|
|
|
remove(...classNames) {
|
|
classNames.forEach((className) => this.values.delete(className));
|
|
this.sync();
|
|
}
|
|
|
|
replace(classNames) {
|
|
this.values = new Set(classNames.split(/\s+/).filter(Boolean));
|
|
}
|
|
|
|
sync() {
|
|
this.element.attributes.set("class", [...this.values].join(" "));
|
|
}
|
|
|
|
toggle(className, force) {
|
|
const enabled = force ?? !this.values.has(className);
|
|
|
|
if (enabled) this.values.add(className);
|
|
else this.values.delete(className);
|
|
this.sync();
|
|
|
|
return enabled;
|
|
}
|
|
}
|
|
|
|
class FakeElement {
|
|
constructor(tagName, attributes = {}, children = []) {
|
|
this.tagName = tagName;
|
|
this.attributes = new Map();
|
|
this.children = [];
|
|
this.classList = new FakeClassList(this);
|
|
this.dataset = {};
|
|
this.listeners = new Map();
|
|
this.parentElement = null;
|
|
this.style = {
|
|
display: "",
|
|
properties: new Map(),
|
|
getPropertyValue: (name) => this.style.properties.get(name) ?? "",
|
|
removeProperty: (name) => this.style.properties.delete(name),
|
|
setProperty: (name, value) => this.style.properties.set(name, value),
|
|
};
|
|
|
|
Object.entries(attributes).forEach(([name, value]) => {
|
|
this.setAttribute(name, value);
|
|
});
|
|
children.forEach((child) => this.appendChild(child));
|
|
}
|
|
|
|
appendChild(child) {
|
|
this.children.push(child);
|
|
if (child instanceof FakeElement) child.parentElement = this;
|
|
return child;
|
|
}
|
|
|
|
addEventListener(type, listener) {
|
|
const listeners = this.listeners.get(type) ?? [];
|
|
|
|
listeners.push(listener);
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
|
|
dispatchEvent(event) {
|
|
for (const listener of this.listeners.get(event.type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
|
|
closest(selector) {
|
|
let current = this;
|
|
|
|
while (current) {
|
|
if (current.matches(selector)) return current;
|
|
current = current.parentElement;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
contains(element) {
|
|
if (element === this) return true;
|
|
|
|
return this.children.some(
|
|
(child) => child instanceof FakeElement && child.contains(element),
|
|
);
|
|
}
|
|
|
|
getAttribute(name) {
|
|
return this.attributes.get(name) ?? null;
|
|
}
|
|
|
|
hasAttribute(name) {
|
|
return this.attributes.has(name);
|
|
}
|
|
|
|
listenerCount(type) {
|
|
return this.listeners.get(type)?.length ?? 0;
|
|
}
|
|
|
|
matches(selector) {
|
|
if (selector.startsWith("#")) {
|
|
return this.getAttribute("id") === selector.slice(1);
|
|
}
|
|
|
|
if (selector.startsWith(".")) {
|
|
return selector
|
|
.slice(1)
|
|
.split(".")
|
|
.every((className) => this.classList.contains(className));
|
|
}
|
|
|
|
return this.tagName === selector;
|
|
}
|
|
|
|
querySelector(selector) {
|
|
for (const child of this.children) {
|
|
if (!(child instanceof FakeElement)) continue;
|
|
if (child.matches(selector)) return child;
|
|
|
|
const descendant = child.querySelector(selector);
|
|
if (descendant) return descendant;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
querySelectorAll(selector) {
|
|
const matches = [];
|
|
|
|
for (const child of this.children) {
|
|
if (!(child instanceof FakeElement)) continue;
|
|
if (child.matches(selector)) matches.push(child);
|
|
matches.push(...child.querySelectorAll(selector));
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
removeAttribute(name) {
|
|
this.attributes.delete(name);
|
|
if (name === "class") this.classList.replace("");
|
|
}
|
|
|
|
removeEventListener(type, listener) {
|
|
const listeners = this.listeners.get(type) ?? [];
|
|
|
|
this.listeners.set(
|
|
type,
|
|
listeners.filter((item) => item !== listener),
|
|
);
|
|
}
|
|
|
|
setAttribute(name, value) {
|
|
this.attributes.set(name, String(value));
|
|
if (name === "class") this.classList.replace(String(value));
|
|
if (name.startsWith("data-")) {
|
|
const dataName = name
|
|
.slice(5)
|
|
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
this.dataset[dataName] = String(value);
|
|
}
|
|
}
|
|
|
|
replaceChildren(...children) {
|
|
this.children = [];
|
|
children.forEach((child) => this.appendChild(child));
|
|
}
|
|
|
|
set className(value) {
|
|
this.setAttribute("class", value);
|
|
}
|
|
|
|
set textContent(value) {
|
|
this.children = [String(value)];
|
|
}
|
|
|
|
set innerHTML(value) {
|
|
assert.equal(value, "");
|
|
this.children = [];
|
|
}
|
|
|
|
get textContent() {
|
|
return this.children
|
|
.map((child) =>
|
|
child instanceof FakeElement ? child.textContent : String(child),
|
|
)
|
|
.join("");
|
|
}
|
|
}
|
|
|
|
const E = (tagName, attributes, children) =>
|
|
new FakeElement(tagName, attributes, children);
|
|
|
|
const getMenuChildren = (item) =>
|
|
Object.entries(item?.children ?? {}).map(([name, child]) => ({
|
|
...child,
|
|
name,
|
|
}));
|
|
|
|
const createFakeDocument = ({ elements = {}, navType = "mega-menu" } = {}) => ({
|
|
body: {
|
|
dataset: { navType },
|
|
},
|
|
querySelector(selector) {
|
|
return elements[selector] ?? null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
});
|
|
|
|
const loadMenuModule = ({
|
|
dispatchpath = [],
|
|
document = createFakeDocument(),
|
|
getChildren = getMenuChildren,
|
|
requestpath = [],
|
|
translate = (value) => value,
|
|
window = {},
|
|
sessionStorage = {},
|
|
} = {}) => {
|
|
const baseclass = {
|
|
extend(module) {
|
|
return module;
|
|
},
|
|
};
|
|
const ui = { menu: { getChildren } };
|
|
const L = {
|
|
env: { dispatchpath, requestpath },
|
|
url: (...segments) => `/${segments.join("/")}`,
|
|
};
|
|
|
|
return new Function(
|
|
"baseclass",
|
|
"ui",
|
|
"E",
|
|
"L",
|
|
"_",
|
|
"document",
|
|
"window",
|
|
"localStorage",
|
|
"sessionStorage",
|
|
source,
|
|
)(baseclass, ui, E, L, translate, document, window, {}, sessionStorage);
|
|
};
|
|
|
|
// header.ut's crumb renderer (shared with menu-aurora.js), on a fake document.
|
|
const loadHeaderCrumb = (elements) => {
|
|
const fn = header.match(/function auroraCrumb\(\) \{[\s\S]*?\n\t\t\t\}/)?.[0];
|
|
|
|
assert.ok(fn, "header.ut must define auroraCrumb()");
|
|
|
|
return new Function("document", `${fn}; return auroraCrumb;`)({
|
|
getElementById: (id) => elements[id] ?? null,
|
|
createElement: (tagName) => new FakeElement(tagName),
|
|
});
|
|
};
|
|
|
|
const textContent = (element) =>
|
|
element.children
|
|
.map((child) =>
|
|
child instanceof FakeElement ? textContent(child) : String(child),
|
|
)
|
|
.join("");
|
|
|
|
const directItem = (overrides = {}) => ({
|
|
name: "status",
|
|
title: "Status",
|
|
href: "/admin/status",
|
|
hasChildren: false,
|
|
isLogout: false,
|
|
isActiveGroup: false,
|
|
isActivePage: false,
|
|
activePage: null,
|
|
pages: [],
|
|
...overrides,
|
|
});
|
|
|
|
const groupItem = (overrides = {}) => ({
|
|
name: "network",
|
|
title: "Network",
|
|
href: "/admin/network",
|
|
hasChildren: true,
|
|
isLogout: false,
|
|
isActiveGroup: false,
|
|
isActivePage: false,
|
|
activePage: null,
|
|
pages: [],
|
|
...overrides,
|
|
});
|
|
|
|
test("defines the shared navigation renderer and emits its common contract", () => {
|
|
const renderer = getMethodSource("renderNavigationItem");
|
|
|
|
assert.match(renderer, /renderNavigationItem\(item, mode\)/);
|
|
[
|
|
"navigation-group",
|
|
"navigation-group-toggle",
|
|
"navigation-group-region",
|
|
"navigation-submenu-list",
|
|
"navigation-sublink",
|
|
"navigation-direct",
|
|
"nav-row",
|
|
"is-active-group",
|
|
"is-expanded",
|
|
"is-active-page",
|
|
].forEach((className) => assert.match(renderer, new RegExp(className)));
|
|
assert.match(renderer, /aria-current/);
|
|
});
|
|
|
|
test("renders both navigation surfaces through the shared renderer and accordion", () => {
|
|
const mobile = getMethodSource("renderMobileMenu");
|
|
const sidebar = getMethodSource("renderSidebar");
|
|
|
|
assert.match(mobile, /renderNavigationItem\(item,\s*"mobile"\)/);
|
|
assert.match(mobile, /bindNavigationAccordion\(list\)/);
|
|
assert.match(sidebar, /renderNavigationItem\(item,\s*"sidebar"\)/);
|
|
assert.match(sidebar, /bindNavigationAccordion\(list\)/);
|
|
});
|
|
|
|
test("removes legacy navigation state helpers and class tokens", () => {
|
|
[
|
|
"setMobileSubmenuExpanded",
|
|
"setSidebarSectionExpanded",
|
|
"submenu-expanded",
|
|
"sidebar-group-open",
|
|
"has-active",
|
|
].forEach((token) => assert.doesNotMatch(source, new RegExp(token)));
|
|
});
|
|
|
|
test("builds the navigation model once and passes the same items to both surfaces", () => {
|
|
const renderModeMenu = getMethodSource("renderModeMenu");
|
|
|
|
assert.equal(source.match(/\bbuildNavigationModel\(/g)?.length, 2);
|
|
assert.match(
|
|
renderModeMenu,
|
|
/const navigationItems = this\.buildNavigationModel\(\s*ui\.menu\.getChildren\(activeChild\),\s*activeChild\.name,\s*\)/,
|
|
);
|
|
assert.match(
|
|
renderModeMenu,
|
|
/this\.renderMainMenu\(activeChild,\s*activeChild\.name,\s*0,\s*navigationItems\)/,
|
|
);
|
|
assert.match(renderModeMenu, /this\.renderMobileMenu\(navigationItems\)/);
|
|
});
|
|
|
|
test("renders an active direct item with shared and mode-specific state", () => {
|
|
const menu = loadMenuModule();
|
|
const item = menu.renderNavigationItem(
|
|
{
|
|
name: "status",
|
|
title: "Status",
|
|
href: "/admin/status",
|
|
hasChildren: false,
|
|
isActivePage: true,
|
|
},
|
|
"mobile",
|
|
);
|
|
const anchor = item.children[0];
|
|
|
|
assert.equal(item.getAttribute("class"), "mobile-nav-item");
|
|
assert.equal(
|
|
anchor.getAttribute("class"),
|
|
"navigation-direct nav-row is-active-page",
|
|
);
|
|
assert.equal(anchor.getAttribute("href"), "/admin/status");
|
|
assert.equal(anchor.getAttribute("aria-current"), "page");
|
|
});
|
|
|
|
test("renders an active group expanded with active page semantics", () => {
|
|
const menu = loadMenuModule();
|
|
const item = menu.renderNavigationItem(
|
|
{
|
|
name: "network",
|
|
title: "Network",
|
|
hasChildren: true,
|
|
isActiveGroup: true,
|
|
pages: [
|
|
{
|
|
title: "Wireless",
|
|
href: "/admin/network/wireless",
|
|
isActivePage: true,
|
|
},
|
|
],
|
|
},
|
|
"sidebar",
|
|
);
|
|
const [toggle, region] = item.children;
|
|
const list = region.children[0];
|
|
const activeLink = list.children[0].children[0];
|
|
|
|
assert.equal(
|
|
item.getAttribute("class"),
|
|
"navigation-group sidebar-group is-active-group is-expanded",
|
|
);
|
|
assert.equal(toggle.getAttribute("aria-controls"), "sidebar-submenu-network");
|
|
assert.equal(toggle.getAttribute("aria-current"), "location");
|
|
assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
|
assert.equal(region.getAttribute("aria-hidden"), "false");
|
|
assert.equal(region.hasAttribute("inert"), false);
|
|
assert.equal(
|
|
activeLink.getAttribute("class"),
|
|
"navigation-sublink is-active-page",
|
|
);
|
|
assert.equal(activeLink.getAttribute("aria-current"), "page");
|
|
});
|
|
|
|
test("renders the active desktop submenu link with current-page semantics", () => {
|
|
const menu = loadMenuModule({
|
|
dispatchpath: ["admin", "network", "wireless"],
|
|
});
|
|
const list = menu.renderMainMenu(
|
|
{
|
|
name: "network",
|
|
children: {
|
|
interfaces: { title: "Interfaces" },
|
|
wireless: { title: "Wireless" },
|
|
},
|
|
},
|
|
"admin/network",
|
|
1,
|
|
);
|
|
const [inactiveItem, activeItem] = list.children;
|
|
const inactiveLink = inactiveItem.children[0];
|
|
const activeLink = activeItem.children[0];
|
|
|
|
assert.equal(inactiveLink.hasAttribute("aria-current"), false);
|
|
assert.equal(activeLink.getAttribute("class"), "is-active-page");
|
|
assert.equal(activeLink.getAttribute("aria-current"), "page");
|
|
});
|
|
|
|
test("renders an inactive group collapsed and inert", () => {
|
|
const menu = loadMenuModule();
|
|
const item = menu.renderNavigationItem(
|
|
{
|
|
name: "system/tools",
|
|
title: "System",
|
|
hasChildren: true,
|
|
isActiveGroup: false,
|
|
pages: [],
|
|
},
|
|
"mobile",
|
|
);
|
|
const [toggle, region] = item.children;
|
|
|
|
assert.equal(item.getAttribute("class"), "navigation-group mobile-nav-item");
|
|
assert.equal(
|
|
toggle.getAttribute("aria-controls"),
|
|
"mobile-submenu-system%2Ftools",
|
|
);
|
|
assert.equal(toggle.getAttribute("aria-expanded"), "false");
|
|
assert.equal(toggle.hasAttribute("aria-current"), false);
|
|
assert.equal(region.getAttribute("aria-hidden"), "true");
|
|
assert.equal(region.getAttribute("inert"), "");
|
|
});
|
|
|
|
test("renders mode and mobile navigation without desktop containers", () => {
|
|
const mobileList = new FakeElement("ul");
|
|
const mobileFooter = new FakeElement("div");
|
|
const document = createFakeDocument({
|
|
elements: {
|
|
"#mobile-nav-footer-action": mobileFooter,
|
|
"#mobile-nav-list": mobileList,
|
|
},
|
|
});
|
|
const menu = loadMenuModule({
|
|
dispatchpath: ["admin", "status"],
|
|
document,
|
|
requestpath: ["admin"],
|
|
translate: (value) => `translated:${value}`,
|
|
});
|
|
const tree = {
|
|
children: {
|
|
admin: {
|
|
title: "Administration",
|
|
children: {
|
|
status: { title: "Status" },
|
|
},
|
|
},
|
|
services: {
|
|
title: "Services",
|
|
children: {
|
|
dns: { title: "DNS" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const originalBuildNavigationModel = menu.buildNavigationModel.bind(menu);
|
|
const originalRenderMainMenu = menu.renderMainMenu.bind(menu);
|
|
const originalRenderMobileMenu = menu.renderMobileMenu.bind(menu);
|
|
const desktopCalls = [];
|
|
const mobileCalls = [];
|
|
let buildCount = 0;
|
|
|
|
menu.buildNavigationModel = (...args) => {
|
|
buildCount += 1;
|
|
return originalBuildNavigationModel(...args);
|
|
};
|
|
menu.renderMainMenu = (...args) => {
|
|
desktopCalls.push(args);
|
|
return originalRenderMainMenu(...args);
|
|
};
|
|
menu.renderMobileMenu = (...args) => {
|
|
mobileCalls.push(args);
|
|
return originalRenderMobileMenu(...args);
|
|
};
|
|
|
|
assert.doesNotThrow(() => menu.renderModeMenu(tree));
|
|
assert.equal(buildCount, 1);
|
|
assert.equal(desktopCalls.length, 1);
|
|
assert.equal(desktopCalls[0][0].name, "admin");
|
|
assert.equal(mobileCalls.length, 1);
|
|
assert.strictEqual(desktopCalls[0][3], mobileCalls[0][0]);
|
|
assert.deepEqual(mobileCalls[0][0], [
|
|
{
|
|
name: "status",
|
|
title: "translated:Status",
|
|
href: "/admin/status",
|
|
hasChildren: false,
|
|
isLogout: false,
|
|
isActiveGroup: false,
|
|
isActivePage: true,
|
|
activePage: null,
|
|
pages: [],
|
|
},
|
|
]);
|
|
assert.equal(mobileList.children.length, 1);
|
|
});
|
|
|
|
test("skips mega-menu initialization when the top menu is missing", () => {
|
|
const menu = loadMenuModule({
|
|
document: createFakeDocument({ navType: "mega-menu" }),
|
|
});
|
|
const tree = {
|
|
children: {
|
|
status: { title: "Status" },
|
|
},
|
|
};
|
|
let calls = 0;
|
|
let result;
|
|
|
|
menu.initMegaMenu = () => {
|
|
calls += 1;
|
|
};
|
|
|
|
assert.doesNotThrow(() => {
|
|
result = menu.renderMainMenu(tree, "admin");
|
|
});
|
|
assert.equal(calls, 0);
|
|
assert.ok(result instanceof FakeElement);
|
|
});
|
|
|
|
test("measures mega-menu canvas from the viewport-bounded panel height", () => {
|
|
const initMegaMenu = getMethodSource("initMegaMenu");
|
|
|
|
assert.match(initMegaMenu, /nav\.offsetHeight/);
|
|
assert.doesNotMatch(initMegaMenu, /nav\.scrollHeight/);
|
|
});
|
|
|
|
test("skips dropdown initialization when the top menu is missing", () => {
|
|
const menu = loadMenuModule({
|
|
document: createFakeDocument({ navType: "dropdown" }),
|
|
});
|
|
const tree = {
|
|
children: {
|
|
status: { title: "Status" },
|
|
},
|
|
};
|
|
let calls = 0;
|
|
let result;
|
|
|
|
menu.initDropdown = () => {
|
|
calls += 1;
|
|
};
|
|
|
|
assert.doesNotThrow(() => {
|
|
result = menu.renderMainMenu(tree, "admin");
|
|
});
|
|
assert.equal(calls, 0);
|
|
assert.ok(result instanceof FakeElement);
|
|
});
|
|
|
|
test("renders sidebar items when the top menu is missing", () => {
|
|
const menu = loadMenuModule({
|
|
document: createFakeDocument({ navType: "sidebar" }),
|
|
});
|
|
const tree = {
|
|
children: {
|
|
status: { title: "Status" },
|
|
},
|
|
};
|
|
const items = [directItem()];
|
|
let renderedItems = null;
|
|
let result;
|
|
|
|
menu.renderSidebar = (navigationItems) => {
|
|
renderedItems = navigationItems;
|
|
};
|
|
|
|
assert.doesNotThrow(() => {
|
|
result = menu.renderMainMenu(tree, "admin", 0, items);
|
|
});
|
|
assert.strictEqual(renderedItems, items);
|
|
assert.ok(result instanceof FakeElement);
|
|
});
|
|
|
|
test("generates stable collision-safe submenu IDs", () => {
|
|
const menu = loadMenuModule();
|
|
|
|
assert.equal(
|
|
menu.navigationSubmenuId("mobile", "system/tools"),
|
|
"mobile-submenu-system%2Ftools",
|
|
);
|
|
assert.equal(
|
|
menu.navigationSubmenuId("mobile", "system-tools"),
|
|
"mobile-submenu-system-tools",
|
|
);
|
|
assert.equal(
|
|
menu.navigationSubmenuId("mobile", "system.tools"),
|
|
"mobile-submenu-system.tools",
|
|
);
|
|
});
|
|
|
|
test("renders unique submenu controls for punctuation-distinct group names", () => {
|
|
const menu = loadMenuModule();
|
|
const names = ["system/tools", "system-tools", "system.tools"];
|
|
const rendered = names.map((name) =>
|
|
menu.renderNavigationItem(groupItem({ name }), "mobile"),
|
|
);
|
|
const controlIds = rendered.map((item) =>
|
|
item.children[0].getAttribute("aria-controls"),
|
|
);
|
|
const regionIds = rendered.map((item) => item.children[1].getAttribute("id"));
|
|
|
|
assert.deepEqual(controlIds, [
|
|
"mobile-submenu-system%2Ftools",
|
|
"mobile-submenu-system-tools",
|
|
"mobile-submenu-system.tools",
|
|
]);
|
|
assert.deepEqual(regionIds, controlIds);
|
|
assert.equal(new Set(controlIds).size, names.length);
|
|
});
|
|
|
|
test("renders mobile items and logout repeatedly without duplicate listeners", () => {
|
|
const list = new FakeElement("ul", {}, [new FakeElement("li")]);
|
|
const footer = new FakeElement("div", {}, [new FakeElement("a")]);
|
|
const document = createFakeDocument({
|
|
elements: {
|
|
"#mobile-nav-footer-action": footer,
|
|
"#mobile-nav-list": list,
|
|
},
|
|
});
|
|
const menu = loadMenuModule({ document });
|
|
const items = [
|
|
directItem(),
|
|
groupItem(),
|
|
directItem({
|
|
name: "logout",
|
|
title: "Logout",
|
|
href: "/admin/logout",
|
|
isLogout: true,
|
|
}),
|
|
];
|
|
|
|
menu.renderMobileMenu(items);
|
|
menu.renderMobileMenu(items);
|
|
|
|
assert.equal(list.children.length, 2);
|
|
assert.equal(
|
|
list.children[0].children[0].getAttribute("class"),
|
|
"navigation-direct nav-row",
|
|
);
|
|
assert.equal(
|
|
list.children[1].getAttribute("class"),
|
|
"navigation-group mobile-nav-item",
|
|
);
|
|
assert.equal(footer.children.length, 1);
|
|
assert.equal(footer.children[0].getAttribute("class"), "mobile-nav-logout");
|
|
assert.equal(footer.children[0].getAttribute("href"), "/admin/logout");
|
|
assert.equal(textContent(footer.children[0]), "Logout");
|
|
assert.equal(list.dataset.accordionBound, "true");
|
|
assert.equal(list.listenerCount("click"), 1);
|
|
});
|
|
|
|
test("renders sidebar items and logout without duplication, then the crumb", () => {
|
|
const list = new FakeElement("ul", {}, [new FakeElement("li")]);
|
|
const footer = new FakeElement("div", {}, [new FakeElement("a")]);
|
|
const document = createFakeDocument({
|
|
elements: {
|
|
"#sidebar-footer": footer,
|
|
"#sidebar-list": list,
|
|
},
|
|
navType: "sidebar",
|
|
});
|
|
let crumbRenders = 0;
|
|
const menu = loadMenuModule({
|
|
dispatchpath: ["admin", "network", "wireless"],
|
|
document,
|
|
translate: (value) => `translated:${value}`,
|
|
window: { auroraCrumb: () => (crumbRenders += 1) },
|
|
});
|
|
const tree = {
|
|
children: {
|
|
network: {
|
|
title: "Network",
|
|
children: {
|
|
wireless: { title: "Wireless" },
|
|
},
|
|
},
|
|
status: { title: "Status" },
|
|
logout: { title: "Logout" },
|
|
},
|
|
};
|
|
const items = menu.buildNavigationModel(getMenuChildren(tree), "admin");
|
|
|
|
menu.renderSidebar(items);
|
|
menu.renderSidebar(items);
|
|
|
|
assert.equal(list.children.length, 2);
|
|
assert.equal(
|
|
list.children[0].getAttribute("class"),
|
|
"navigation-group sidebar-group is-active-group is-expanded",
|
|
);
|
|
assert.equal(
|
|
list.children[1].children[0].getAttribute("class"),
|
|
"navigation-direct nav-row",
|
|
);
|
|
assert.equal(footer.children.length, 1);
|
|
assert.equal(footer.children[0].getAttribute("class"), "nav-link");
|
|
assert.equal(footer.children[0].getAttribute("href"), "/admin/logout");
|
|
assert.equal(textContent(footer.children[0]), "translated:Logout");
|
|
assert.equal(crumbRenders, 2);
|
|
assert.equal(list.dataset.accordionBound, "true");
|
|
assert.equal(list.listenerCount("click"), 1);
|
|
|
|
const crumb = new FakeElement("ol", {}, [new FakeElement("li")]);
|
|
loadHeaderCrumb({ "header-crumb": crumb, "sidebar-list": list })();
|
|
|
|
assert.deepEqual(
|
|
crumb.children.map((child) => textContent(child)),
|
|
["translated:Network", "/", "translated:Wireless"],
|
|
);
|
|
assert.deepEqual(
|
|
crumb.children.map((child) => child.getAttribute("class")),
|
|
["", "crumb-sep", "current"],
|
|
);
|
|
});
|
|
|
|
test("collapses same-named group/page crumbs to a single level", () => {
|
|
const list = new FakeElement("ul");
|
|
const crumb = new FakeElement("ol", {}, [new FakeElement("li")]);
|
|
const menu = loadMenuModule({
|
|
dispatchpath: ["admin", "system", "system"],
|
|
document: createFakeDocument({
|
|
elements: { "#sidebar-list": list },
|
|
navType: "sidebar",
|
|
}),
|
|
translate: (value) => `translated:${value}`,
|
|
});
|
|
const tree = {
|
|
children: {
|
|
system: {
|
|
title: "System",
|
|
children: {
|
|
system: { title: "System" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
menu.renderSidebar(menu.buildNavigationModel(getMenuChildren(tree), "admin"));
|
|
loadHeaderCrumb({ "header-crumb": crumb, "sidebar-list": list })();
|
|
|
|
assert.deepEqual(
|
|
crumb.children.map((child) => textContent(child)),
|
|
["translated:System"],
|
|
);
|
|
assert.equal(crumb.children[0].getAttribute("class"), "current");
|
|
});
|
|
|
|
test("caches the rendered nav under the stamp header.ut replays against", () => {
|
|
const stored = new Map();
|
|
const document = {
|
|
...createFakeDocument(),
|
|
body: { dataset: { navStamp: "sidebar:zh_Hans:root:1.3.0" } },
|
|
querySelectorAll: () => [
|
|
{ id: "sidebar-list", firstChild: {}, innerHTML: "<li>list</li>" },
|
|
{ id: "sidebar-footer", firstChild: null, innerHTML: "" },
|
|
],
|
|
};
|
|
const menu = loadMenuModule({
|
|
document,
|
|
sessionStorage: { setItem: (key, value) => stored.set(key, value) },
|
|
});
|
|
|
|
menu.cacheNav();
|
|
|
|
assert.deepEqual(JSON.parse(stored.get("aurora.nav")), [
|
|
"sidebar:zh_Hans:root:1.3.0",
|
|
{ "sidebar-list": "<li>list</li>" },
|
|
]);
|
|
// Both sides read the one server-rendered stamp (user included).
|
|
assert.match(header, /<body [^>]*data-nav-stamp="[^"]*ctx\?\.authuser/);
|
|
assert.match(header, /cache\?\.\[0\] !== body\.dataset\.navStamp\) return;/);
|
|
|
|
// Storage that throws (privacy modes, quota) leaves rendering untouched.
|
|
const sealed = loadMenuModule({
|
|
document,
|
|
sessionStorage: {
|
|
setItem() {
|
|
throw new Error("sealed");
|
|
},
|
|
},
|
|
});
|
|
assert.doesNotThrow(() => sealed.cacheNav());
|
|
});
|
|
|
|
test("releases the inert replay however the menu load settles", () => {
|
|
const init = getMethodSource("__init__");
|
|
|
|
// In finally: a failed load or a throwing render must not leave a dead copy.
|
|
assert.match(
|
|
init,
|
|
/\.finally\(\(\) => \{[\s\S]*querySelectorAll\("\[data-restored\]"\)[\s\S]*inert = false/,
|
|
);
|
|
});
|
|
|
|
test("drops the replayed top menu before building the live one", () => {
|
|
const ul = E("ul", { id: "topmenu" }, [E("li")]);
|
|
const menu = loadMenuModule({
|
|
document: createFakeDocument({ elements: { "#topmenu": ul } }),
|
|
});
|
|
let childrenAtInit = null;
|
|
|
|
menu.initMegaMenu = (children, url, list) => {
|
|
childrenAtInit = list.children.length;
|
|
};
|
|
menu.renderMainMenu(
|
|
{ children: { status: { title: "Status", children: { a: {} } } } },
|
|
"admin",
|
|
);
|
|
|
|
assert.equal(childrenAtInit, 0);
|
|
});
|
|
|
|
test("renders an active group expanded when an open mobile list was initially empty", () => {
|
|
const list = new FakeElement("ul");
|
|
const overlay = new FakeElement("div", { class: "mobile-menu-open" }, [list]);
|
|
const document = createFakeDocument({
|
|
elements: {
|
|
"#mobile-menu-overlay": overlay,
|
|
"#mobile-nav-list": list,
|
|
},
|
|
});
|
|
const menu = loadMenuModule({ document });
|
|
|
|
menu.renderMobileMenu([
|
|
groupItem({
|
|
isActiveGroup: true,
|
|
pages: [
|
|
{
|
|
title: "Wireless",
|
|
href: "/admin/network/wireless",
|
|
isActivePage: true,
|
|
},
|
|
],
|
|
}),
|
|
]);
|
|
|
|
const item = list.children[0];
|
|
const [toggle, region] = item.children;
|
|
|
|
assert.equal(overlay.classList.contains("mobile-menu-open"), true);
|
|
assert.equal(item.classList.contains("is-expanded"), true);
|
|
assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
|
assert.equal(region.getAttribute("aria-hidden"), "false");
|
|
assert.equal(region.hasAttribute("inert"), false);
|
|
});
|
|
|
|
test("starts the mega-menu overlay and surface exit together", () => {
|
|
const sheet = new FakeElement("div", { class: "desktop-menu-sheet" });
|
|
const container = new FakeElement(
|
|
"div",
|
|
{ class: "desktop-menu-container active" },
|
|
[sheet],
|
|
);
|
|
const overlay = new FakeElement("div", {
|
|
class: "desktop-menu-overlay active",
|
|
});
|
|
const header = new FakeElement("header");
|
|
const document = createFakeDocument({
|
|
elements: {
|
|
header,
|
|
".desktop-menu-container": container,
|
|
".desktop-menu-overlay": overlay,
|
|
},
|
|
});
|
|
const menu = loadMenuModule({ document });
|
|
|
|
container.style.setProperty("--mega-menu-height", "320px");
|
|
header.style.setProperty("--mega-menu-duration", "1ms");
|
|
|
|
menu.hideDesktopNav();
|
|
|
|
// Removing .active starts the scrim/blur fade immediately. The container
|
|
// remains in .closing so its own opacity can fade while the sheet retracts.
|
|
assert.equal(overlay.classList.contains("active"), false);
|
|
assert.equal(container.classList.contains("active"), false);
|
|
assert.equal(container.classList.contains("closing"), true);
|
|
// The canvas height is the sheet's translate reference, not the animated
|
|
// property — it must survive the close for the retract to have a distance.
|
|
assert.equal(container.style.properties.get("--mega-menu-height"), "320px");
|
|
|
|
sheet.dispatchEvent({
|
|
type: "transitionend",
|
|
target: sheet,
|
|
propertyName: "translate",
|
|
});
|
|
|
|
assert.equal(container.classList.contains("closing"), false);
|
|
});
|
|
|
|
test("ignores stale mega-menu close timers after reopen and second close", async () => {
|
|
const sheet = new FakeElement("div", { class: "desktop-menu-sheet" });
|
|
const container = new FakeElement(
|
|
"div",
|
|
{ class: "desktop-menu-container active" },
|
|
[sheet],
|
|
);
|
|
const overlay = new FakeElement("div", {
|
|
class: "desktop-menu-overlay active",
|
|
});
|
|
const header = new FakeElement("header");
|
|
const document = createFakeDocument({
|
|
elements: {
|
|
header,
|
|
".desktop-menu-container": container,
|
|
".desktop-menu-overlay": overlay,
|
|
},
|
|
});
|
|
const menu = loadMenuModule({ document });
|
|
|
|
header.style.setProperty("--mega-menu-duration", "300ms");
|
|
|
|
menu.hideDesktopNav();
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
|
|
container.classList.add("active");
|
|
container.classList.remove("closing");
|
|
overlay.classList.add("active");
|
|
|
|
menu.hideDesktopNav();
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 260));
|
|
|
|
assert.equal(container.classList.contains("closing"), true);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
|
|
assert.equal(container.classList.contains("closing"), false);
|
|
});
|
|
|
|
test("the hover path opens, switches, closes and reopens the mega-menu", async () => {
|
|
// The first-open dwell is 100ms; category switches on an open menu fire
|
|
// with no dwell but still through a timer.
|
|
const openDwell = () => new Promise((resolve) => setTimeout(resolve, 120));
|
|
|
|
const canvas = new FakeElement("div", { class: "desktop-menu-canvas" });
|
|
const sheet = new FakeElement("div", { class: "desktop-menu-sheet" }, [
|
|
canvas,
|
|
]);
|
|
const container = new FakeElement(
|
|
"div",
|
|
{ class: "desktop-menu-container" },
|
|
[sheet],
|
|
);
|
|
const overlay = new FakeElement("div", { class: "desktop-menu-overlay" });
|
|
const header = new FakeElement("header", {}, [
|
|
new FakeElement("div", { class: "header-content" }),
|
|
container,
|
|
]);
|
|
const document = createFakeDocument({
|
|
elements: {
|
|
header,
|
|
".desktop-menu-container": container,
|
|
".desktop-menu-overlay": overlay,
|
|
},
|
|
});
|
|
const ul = E("ul", { id: "topmenu" });
|
|
// deactivateDesktopNavExcept sweeps document-wide; give the fake document
|
|
// a real tree to sweep or stale `.active` panels trip the wasActive guard.
|
|
const root = new FakeElement("html", {}, [header, ul]);
|
|
document.querySelectorAll = (selector) => root.querySelectorAll(selector);
|
|
const menu = loadMenuModule({
|
|
document,
|
|
window: { addEventListener() {} },
|
|
});
|
|
|
|
menu.initMegaMenu(
|
|
[
|
|
{ name: "network", title: "Network", children: { wifi: {} } },
|
|
{ name: "system", title: "System", children: { admin: {} } },
|
|
],
|
|
"admin",
|
|
ul,
|
|
);
|
|
|
|
const [firstItem, secondItem] = ul.children;
|
|
const navFor = (item) => item.querySelector(".desktop-nav");
|
|
|
|
// First open: hover dwell activates the container, the overlay and the
|
|
// hovered category's panel together.
|
|
firstItem.dispatchEvent({ type: "mouseenter" });
|
|
await openDwell();
|
|
assert.equal(container.classList.contains("active"), true);
|
|
assert.equal(overlay.classList.contains("active"), true);
|
|
assert.equal(navFor(firstItem).classList.contains("active"), true);
|
|
|
|
// Switching categories on the open menu swaps the active panel without
|
|
// dropping the container out of its open state.
|
|
secondItem.dispatchEvent({ type: "mouseenter" });
|
|
await openDwell();
|
|
assert.equal(container.classList.contains("active"), true);
|
|
assert.equal(navFor(firstItem).classList.contains("active"), false);
|
|
assert.equal(navFor(secondItem).classList.contains("active"), true);
|
|
|
|
// Close, then reopen BEFORE the retract finishes: the interrupted close
|
|
// must not leave a stale `.active` panel that trips the wasActive guard
|
|
// and blocks the container from re-activating.
|
|
menu.hideDesktopNav();
|
|
assert.equal(container.classList.contains("active"), false);
|
|
assert.equal(container.classList.contains("closing"), true);
|
|
firstItem.dispatchEvent({ type: "mouseenter" });
|
|
await openDwell();
|
|
assert.equal(container.classList.contains("active"), true);
|
|
assert.equal(container.classList.contains("closing"), false);
|
|
assert.equal(navFor(firstItem).classList.contains("active"), true);
|
|
});
|