Files
2026-10-09 12:11:19 +08:00

793 lines
22 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 E = (tagName, attributes, children) => ({
tagName,
attributes,
children,
});
const loadMenuModule = (
localStorage = {},
{
document = {},
navigator = { platform: "" },
ui = { menu: { getChildren: () => [] } },
} = {},
) => {
const baseclass = {
extend(module) {
return module;
},
};
const L = {
env: { dispatchpath: [], requestpath: [] },
url: (...segments) => `/${segments.join("/")}`,
};
return new Function(
"baseclass",
"ui",
"E",
"L",
"_",
"document",
"window",
"localStorage",
"navigator",
source,
)(baseclass, ui, E, L, (value) => value, document, {}, localStorage, navigator);
};
const menu = loadMenuModule();
const score = (query, text) => menu.fuzzyMatch(query, text)?.score ?? null;
const sliced = (text, ranges) =>
ranges.map(([from, to]) => text.slice(from, to));
test("matches a scattered subsequence and reports its ranges", () => {
const match = menu.fuzzyMatch("dns", "DHCP and DNS");
assert.ok(match);
assert.deepEqual(sliced("DHCP and DNS", match.ranges), ["DNS"]);
});
test("returns null when the query is not a subsequence", () => {
assert.equal(menu.fuzzyMatch("zzz", "Firewall"), null);
});
test("retries from every first-character occurrence instead of staying greedy", () => {
// A single greedy pass anchors "dns" to the leading D of "DHCP and DNS" and
// scatters, scoring the real hit below shorter near-misses.
assert.ok(score("dns", "DHCP and DNS") > score("dns", "Diagnostics"));
});
test("scores an adjacency run above the same characters scattered", () => {
assert.ok(score("fir", "Firewall") > score("fir", "Failover in Routing"));
});
test("rewards word starts", () => {
assert.ok(score("s", "Status") > score("s", "Wireless"));
});
test("penalises longer targets so the tighter title wins", () => {
assert.ok(score("log", "Logout") > score("log", "Logging and reporting"));
});
test("matches CJK titles", () => {
const match = menu.fuzzyMatch("防火墙", "防火墙设置");
assert.ok(match);
assert.deepEqual(sliced("防火墙设置", match.ranges), ["防火墙"]);
});
test("keeps astral characters whole in the reported ranges", () => {
// `at` is a UTF-16 offset while for...of yields code points: advancing by 1
// would end the range inside the surrogate pair and slice it into mojibake.
const title = "🚀 Passwall";
const match = menu.fuzzyMatch("🚀", title);
assert.ok(match);
assert.deepEqual(match.ranges, [[0, 2]]);
assert.deepEqual(sliced(title, match.ranges), ["🚀"]);
});
test("merges adjacent astral matches into one range", () => {
const title = "𠀀𠀁 Extension";
const match = menu.fuzzyMatch("𠀀𠀁", title);
assert.ok(match);
assert.deepEqual(match.ranges, [[0, 4]]);
assert.deepEqual(sliced(title, match.ranges), ["𠀀𠀁"]);
});
test("an empty query matches every entry unranked and unhighlighted", () => {
assert.deepEqual(menu.matchPaletteEntry("", { title: "Status" }), {
score: 0,
ranges: null,
});
});
test("title hits outrank name hits and are the only highlighted ones", () => {
const title = menu.matchPaletteEntry("dns", {
title: "DNS",
name: "dns",
group: "Network",
});
const viaName = menu.matchPaletteEntry("dhcp", {
title: "DNS",
name: "dhcp",
group: "Network",
});
assert.ok(title.ranges);
assert.equal(viaName.ranges, null);
assert.ok(title.score > viaName.score);
});
test("falls back to the group when neither title nor name matches", () => {
const match = menu.matchPaletteEntry("netw", {
title: "DNS",
name: "dns",
group: "Network",
});
assert.ok(match);
assert.equal(match.ranges, null);
});
test("returns falsy when nothing on the entry matches", () => {
assert.ok(
!menu.matchPaletteEntry("zzz", {
title: "DNS",
name: "dns",
group: "Network",
}),
);
});
test("wraps matched ranges in <mark> and keeps the surrounding text", () => {
const parts = menu.highlightPaletteMatch("DHCP and DNS", [[9, 12]]);
assert.deepEqual(
parts.map((part) => (typeof part === "string" ? part : part.children[0])),
["DHCP and ", "DNS", ""],
);
assert.equal(parts[1].tagName, "mark");
});
test("highlights astral characters without splitting the surrogate pair", () => {
const parts = menu.highlightPaletteMatch("🚀 Passwall", [[0, 2]]);
assert.equal(parts[1].children[0], "🚀");
assert.ok(!parts.some((part) => String(part).includes("�")));
});
test("skips highlighting when case folding would skew the offsets", () => {
// "İ".toLowerCase() is two code units, so ranges measured on the lowercased
// copy no longer index the original.
assert.deepEqual(menu.highlightPaletteMatch("İnterface", [[0, 1]]), [
"İnterface",
]);
});
test("renders the plain title when there are no ranges", () => {
assert.deepEqual(menu.highlightPaletteMatch("Status", null), ["Status"]);
});
// ---- Recents: recording, storage hygiene, and browse-order floating ----
const RECENTS_KEY = "aurora.paletteRecents";
const fakeStorage = (initial = {}) => {
const map = new Map(Object.entries(initial));
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
map,
};
};
const navPage = (name, title, group = "Group") => ({
title,
name,
group,
href: `/${name}`,
});
const paletteWith = (storage, index) => {
const module = loadMenuModule(storage);
module.paletteIndex = index;
return module;
};
const browseIndex = () => [
navPage("status/overview", "概览", "状态"),
navPage("status/syslog", "系统日志", "状态"),
navPage("network/iface", "接口", "网络"),
navPage("network/firewall", "防火墙", "网络"),
{ title: "浅色模式", name: "theme light", group: "主题", mode: "light" },
];
const stored = (recents) => ({ [RECENTS_KEY]: JSON.stringify(recents) });
const names = (matches) => matches.map((match) => match.page.name);
test("a recorded selection lands at the head of the stored recents", () => {
const storage = fakeStorage();
const palette = paletteWith(storage, browseIndex());
palette.recordPaletteRecent("status/syslog");
palette.recordPaletteRecent("network/iface");
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
"network/iface",
"status/syslog",
]);
});
test("re-recording an entry moves it up instead of duplicating it", () => {
const storage = fakeStorage(stored(["network/iface", "status/syslog"]));
const palette = paletteWith(storage, browseIndex());
palette.recordPaletteRecent("status/syslog");
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
"status/syslog",
"network/iface",
]);
});
test("the store has no artificial cap — dedupe is the only bound", () => {
const storage = fakeStorage(stored(["h", "g", "f", "e", "d", "c", "b", "a"]));
const palette = paletteWith(storage, browseIndex());
palette.recordPaletteRecent("i");
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
"i",
"h",
"g",
"f",
"e",
"d",
"c",
"b",
"a",
]);
});
test("corrupt or foreign stored values read as no history", () => {
for (const value of ["not json", '"just a string"', "[1,2,3]", "{}"]) {
const palette = paletteWith(
fakeStorage({ [RECENTS_KEY]: value }),
browseIndex(),
);
assert.deepEqual(palette.readPaletteRecents(), [], value);
}
});
test("a throwing storage neither breaks reads nor records", () => {
const hostile = {
getItem: () => {
throw new Error("privacy mode");
},
setItem: () => {
throw new Error("quota");
},
};
const palette = paletteWith(hostile, browseIndex());
assert.deepEqual(palette.readPaletteRecents(), []);
assert.doesNotThrow(() => palette.recordPaletteRecent("status/overview"));
});
test("browsing floats stored recents in recency order, menu order behind", () => {
const palette = paletteWith(
fakeStorage(stored(["network/iface", "status/syslog"])),
browseIndex(),
);
assert.deepEqual(names(palette.collectPaletteMatches("")), [
"network/iface",
"status/syslog",
"status/overview",
"network/firewall",
"theme light",
]);
});
test("every visited page floats in recency order; vanished names drop", () => {
const wide = [
navPage("p1", "One"),
navPage("p2", "Two"),
navPage("p3", "Three"),
navPage("p4", "Four"),
navPage("p5", "Five"),
navPage("p6", "Six"),
navPage("p7", "Seven"),
];
const palette = paletteWith(
fakeStorage(stored(["removed/page", "p7", "p6", "p5", "p4", "p3", "p2"])),
wide,
);
// Pure LRU: all six visited pages float, only p1 keeps its menu slot.
assert.deepEqual(names(palette.collectPaletteMatches("")), [
"p7",
"p6",
"p5",
"p4",
"p3",
"p2",
"p1",
]);
});
test("a stored name never floats a command row", () => {
const palette = paletteWith(
fakeStorage(stored(["theme light"])),
browseIndex(),
);
assert.deepEqual(names(palette.collectPaletteMatches("")), [
"status/overview",
"status/syslog",
"network/iface",
"network/firewall",
"theme light",
]);
});
test("typed queries rank by fuzzy score alone", () => {
const palette = paletteWith(
fakeStorage(stored(["diag"])),
[navPage("dns", "DNS Settings"), navPage("diag", "Diagnostics")],
);
// "Diagnostics" is the recent entry, but the tight title hit must win.
assert.deepEqual(names(palette.collectPaletteMatches("dns")), [
"dns",
"diag",
]);
});
test("'>' browsing lists command rows only, unaffected by recents", () => {
const palette = paletteWith(
fakeStorage(stored(["network/iface"])),
browseIndex(),
);
assert.deepEqual(names(palette.collectPaletteMatches(">")), ["theme light"]);
});
// ---- Logout as a ">"-only command ----
const logoutIndex = () => [
navPage("p1", "Alpha"),
navPage("p2", "Beta"),
navPage("p3", "Gamma"),
navPage("p4", "Delta"),
navPage("p5", "Epsilon"),
navPage("p6", "Zeta"),
{ title: "浅色模式", name: "theme light", group: "主题", mode: "light" },
{ title: "Logout", name: "logout", group: null, href: "/logout", isLogout: true },
];
test("'>' browsing includes the logout command", () => {
const palette = paletteWith(fakeStorage(), logoutIndex());
assert.deepEqual(names(palette.collectPaletteMatches(">")), [
"theme light",
"logout",
]);
});
test("plain browsing hides the logout command", () => {
const palette = paletteWith(fakeStorage(), logoutIndex());
assert.deepEqual(names(palette.collectPaletteMatches("")), [
"p1",
"p2",
"p3",
"p4",
"p5",
"p6",
"theme light",
]);
});
test("typed queries still reach logout", () => {
const palette = paletteWith(fakeStorage(), logoutIndex());
assert.deepEqual(names(palette.collectPaletteMatches("logout")), ["logout"]);
});
test("a stored logout name never floats", () => {
const palette = paletteWith(
fakeStorage(stored(["logout", "p6", "p5", "p4", "p3", "p2"])),
logoutIndex(),
);
assert.deepEqual(names(palette.collectPaletteMatches("")), [
"p6",
"p5",
"p4",
"p3",
"p2",
"p1",
"theme light",
]);
});
test("initPalette appends logout as the last command row", () => {
const toggle = {
setAttribute() {},
addEventListener() {},
};
const doc = {
querySelector: (sel) => (sel === "#cmdk-trigger" ? toggle : null),
addEventListener() {},
};
const palette = loadMenuModule(fakeStorage(), { document: doc });
palette.initPalette([
{
name: "status",
title: "Status",
hasChildren: true,
pages: [{ name: "overview", title: "Overview", href: "/overview" }],
},
{
name: "logout",
title: "Logout",
href: "/logout",
hasChildren: false,
isLogout: true,
},
]);
const last = palette.paletteIndex.at(-1);
assert.equal(last.isLogout, true);
assert.equal(last.href, "/logout");
assert.equal(last.title, "Logout");
// After the theme commands, so ">" lists modes first, logout last.
assert.ok(palette.paletteIndex.findIndex((page) => page.mode) >= 0);
assert.ok(
palette.paletteIndex.findIndex((page) => page.mode) <
palette.paletteIndex.indexOf(last),
);
});
// ---- Tabs: third-level menu nodes ----
// ui.menu.getChildren() as luci-base ships it, down to handing out alias
// nodes with their target's children (none, for a tab target).
const menuUi = {
menu: {
getChildren: (node) =>
Object.entries(node?.children ?? {})
.filter(([, child]) => child.satisfied && "title" in child)
.map(([name, child]) =>
child.action?.type === "alias"
? { ...child, name, children: undefined }
: Object.assign(child, { name }),
)
.sort((a, b) => (a.order ?? 1000) - (b.order ?? 1000)),
},
};
const node = (title, order, action, children) => ({
satisfied: true,
title,
order,
action,
children,
});
const view = { type: "view" };
const menuRoot = () => ({
name: "admin",
children: {
status: node("Status", 1, view, {
overview: node("Overview", 1, view),
logs: node(
"System Log",
2,
{ type: "alias", path: "admin/status/logs/syslog" },
{
dmesg: node("Kernel Log", 2, view),
syslog: node("System Log", 1, view),
},
),
}),
system: node("System", 2, view, {
admin: node(
"Administration",
1,
{ type: "firstchild" },
{
dropbear: node("SSH Access", 2, view),
hidden: { satisfied: true, order: 0, action: view },
password: node("Router Password", 1, view),
},
),
bmx: node("BMX", 2, view, { nodes: node("Nodes", 1, view) }),
}),
},
});
const menuItems = () => [
{
name: "status",
title: "Status",
hasChildren: true,
pages: [
{ name: "overview", title: "Overview", href: "/admin/status/overview" },
{ name: "logs", title: "System Log", href: "/admin/status/logs" },
],
},
{
name: "system",
title: "System",
hasChildren: true,
pages: [
{ name: "admin", title: "Administration", href: "/admin/system/admin" },
{ name: "bmx", title: "BMX", href: "/admin/system/bmx" },
],
},
];
const initTabbedPalette = (storage = fakeStorage()) => {
const doc = {
querySelector: (sel) =>
sel === "#cmdk-trigger"
? { setAttribute() {}, addEventListener() {} }
: null,
addEventListener() {},
};
const palette = loadMenuModule(storage, { document: doc, ui: menuUi });
palette.initPalette(menuItems(), menuRoot());
return palette;
};
const navRows = (palette) =>
palette.paletteIndex.filter((page) => !page.mode && !page.isLogout);
test("tabs join the index; a redirecting parent gives way to them", () => {
const rows = navRows(initTabbedPalette());
assert.deepEqual(
rows.map((page) => [page.name, page.parent ?? null]),
[
["status/overview", null],
["status/logs/syslog", "System Log"],
["status/logs/dmesg", "System Log"],
["system/admin/password", "Administration"],
["system/admin/dropbear", "Administration"],
["system/bmx", null],
["system/bmx/nodes", "BMX"],
],
);
assert.equal(rows[2].href, "/admin/status/logs/dmesg");
assert.equal(rows[2].group, "Status");
});
test("redirecting parents map to the tab they open", () => {
assert.deepEqual(initTabbedPalette().paletteAliases, {
"status/logs": "status/logs/syslog",
"system/admin": "system/admin/password",
});
});
test("a stored parent name reads as its tab, deduplicated", () => {
const palette = initTabbedPalette(
fakeStorage(
stored(["system/admin", "system/admin/password", "status/logs"]),
),
);
assert.deepEqual(palette.readPaletteRecents(), [
"system/admin/password",
"status/logs/syslog",
]);
assert.deepEqual(names(palette.collectPaletteMatches("")).slice(0, 3), [
"system/admin/password",
"status/logs/syslog",
"status/overview",
]);
});
const tab = (name, title, parent = "Firewall") => ({
title,
parent,
name: `network/firewall/${name}`,
group: "Network",
href: `/${name}`,
});
const firewallIndex = () => [
navPage("status/nftables", "Firewall", "Status"),
tab("zones", "General Settings"),
tab("forwards", "Port Forwards"),
tab("rules", "Traffic Rules"),
tab("ipsets", "IP Sets"),
];
test("a parent-only query keeps its tabs in menu order", () => {
const palette = paletteWith(fakeStorage(), firewallIndex());
assert.deepEqual(names(palette.collectPaletteMatches("firewall")), [
"status/nftables",
"network/firewall/zones",
"network/firewall/forwards",
"network/firewall/rules",
"network/firewall/ipsets",
]);
});
test("a parent hit highlights the parent, not the title", () => {
const match = menu.matchPaletteEntry(
"fire",
tab("forwards", "Port Forwards"),
);
assert.equal(match.ranges, null);
assert.deepEqual(sliced("Firewall", match.parentRanges), ["Fire"]);
});
test("a spaced query pairs parent words with title words", () => {
const palette = paletteWith(fakeStorage(), firewallIndex());
const [hit, ...rest] = palette.collectPaletteMatches("firewall port");
assert.equal(hit.page.name, "network/firewall/forwards");
assert.deepEqual(rest, []);
assert.deepEqual(sliced("Port Forwards", hit.ranges), ["Port"]);
assert.deepEqual(sliced("Firewall", hit.parentRanges), ["Firewall"]);
});
test("a query no longer scatters across path segments", () => {
const other = {
title: "Other Settings",
parent: "PassWall 2",
name: "services/passwall2/other",
group: "Services",
};
assert.ok(!menu.matchPaletteEntry("ssh", other));
assert.ok(
menu.matchPaletteEntry("sshkeys", {
title: "SSH 密钥",
parent: "管理权",
name: "system/admin/sshkeys",
group: "系统",
}),
);
});
test("path words land in segments in order, never scattered", () => {
const wireless = { title: "无线", name: "network/wireless", group: "网络" };
const forwards = tab("forwards", "端口转发", "防火墙");
assert.ok(menu.matchPaletteEntry("network wireless", wireless));
assert.ok(
menu.matchPaletteEntry(
"status/overview",
navPage("status/overview", "概览"),
),
);
assert.ok(menu.matchPaletteEntry("network firewall", forwards));
assert.ok(menu.matchPaletteEntry("netw forw", forwards));
assert.ok(!menu.matchPaletteEntry("wireless network", wireless));
assert.ok(!menu.matchPaletteEntry("netwire", wireless));
assert.ok(!menu.matchPaletteEntry("/", wireless));
});
test("a parent keeps its row unless it redirects to one of its own tabs", () => {
const doc = {
querySelector: (sel) =>
sel === "#cmdk-trigger"
? { setAttribute() {}, addEventListener() {} }
: null,
addEventListener() {},
};
const palette = loadMenuModule(fakeStorage(), { document: doc, ui: menuUi });
const root = {
name: "admin",
children: {
services: node("Services", 1, view, {
// Redirects to an untitled page, so no tab stands in for it.
foo: node(
"Foo",
1,
{ type: "alias", path: "admin/services/foo/main" },
{
main: { satisfied: true, order: 1, action: view },
log: node("Log", 2, view),
},
),
bar: node(
"Bar",
2,
{ type: "firstchild" },
{ only: { ...node("Only", 1, view), firstchild_ineligible: true } },
),
}),
},
};
palette.initPalette(
[
{
name: "services",
title: "Services",
hasChildren: true,
pages: [
{ name: "foo", title: "Foo", href: "/admin/services/foo" },
{ name: "bar", title: "Bar", href: "/admin/services/bar" },
],
},
],
root,
);
assert.deepEqual(
navRows(palette).map((page) => page.name),
["services/foo", "services/foo/log", "services/bar", "services/bar/only"],
);
assert.deepEqual(palette.paletteAliases, {});
});
test("recording after a legacy read writes the mapped names back", () => {
const storage = fakeStorage(stored(["status/logs", "status/overview"]));
const palette = initTabbedPalette(storage);
palette.recordPaletteRecent("system/admin/dropbear");
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
"system/admin/dropbear",
"status/logs/syslog",
"status/overview",
]);
});
test("a tab row renders its parent before the title unless they share a name", () => {
const rows = [];
const palette = paletteWith(fakeStorage(), [
tab("forwards", "Port Forwards"),
{ ...tab("firewall", "Firewall"), name: "network/firewall/firewall" },
]);
palette.paletteInput = { removeAttribute() {}, setAttribute() {} };
palette.paletteList = {
replaceChildren() {},
appendChild: (row) => rows.push(row),
};
palette.setPaletteSelection = () => {};
palette.renderPaletteResults("fire");
const title = (name) =>
rows.find((row) => row.attributes["data-name"] === name).children[0]
.children;
const forwards = title("network/firewall/forwards");
const same = title("network/firewall/firewall");
assert.deepEqual(
forwards.map((part) => part.attributes.class),
["cmdk-parent", "cmdk-label"],
);
const marked = (parts) => parts.some((part) => part.tagName === "mark");
// The parent hit is highlighted inside the parent, the title stays plain.
assert.ok(marked(forwards[0].children));
assert.deepEqual(forwards[1].children, ["Port Forwards"]);
// Same name: a plain highlighted title, no prefix.
assert.ok(marked(same));
assert.ok(!same.some((part) => part.attributes?.class === "cmdk-parent"));
});