Compare commits

...
3 changed files with 159 additions and 14 deletions

No files matched your search

@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type { GroupNode } from "../types";
test("HostTreeView React updates invalidate order when configs or sort mode change", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="scroll"><div id="root"></div></div></body></html>', {
url: "http://localhost", pretendToBeVisual: true,
});
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
for (const key of ["window", "document", "navigator", "HTMLElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent", "localStorage"] as const) {
install(key, key === "window" ? dom.window : dom.window[key]);
}
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
install("ResizeObserver", ResizeObserverStub);
install("IS_REACT_ACT_ENVIRONMENT", true);
const scroller = dom.window.document.getElementById("scroll")!;
Object.defineProperties(scroller, { offsetHeight: { value: 800 }, offsetWidth: { value: 1280 } });
const { act, createElement } = await import("react");
const { createRoot } = await import("react-dom/client");
const { HostTreeView } = await import("./HostTreeView.tsx");
const noop = () => undefined;
const groupTree: GroupNode[] = ["alpha", "beta"].map((path) => ({ name: path, path, hosts: [], children: {} }));
const common = {
groupTree, hosts: [], expandedPaths: new Set<string>(), scrollRef: { current: scroller },
onConnect: noop, onEditHost: noop, onDuplicateHost: noop, onDeleteHost: noop,
onCopyCredentials: noop, onNewGroup: noop, onRenameGroup: noop, onEditGroup: noop,
onDeleteGroup: noop, moveHostToGroup: noop, moveGroup: noop,
};
const root = createRoot(dom.window.document.getElementById("root")!);
const paths = () => [...dom.window.document.querySelectorAll("[data-group-path]")].map((node) => node.getAttribute("data-group-path"));
try {
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "beta", order: -1 }] }));
});
assert.deepEqual(paths(), ["beta", "alpha"]);
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "alpha", order: -1 }] }));
});
assert.deepEqual(paths(), ["alpha", "beta"]);
const groupConfigs = [{ path: "beta", order: -1 }];
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "az", groupConfigs }));
});
assert.deepEqual(paths(), ["alpha", "beta"]);
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs }));
});
assert.deepEqual(paths(), ["beta", "alpha"]);
} finally {
await act(async () => { root.unmount(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
+72
View File
@@ -498,3 +498,75 @@ test("HostTreeView IP sorting stays inside each group and leaves manual order in
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
});
// Deterministic work-count assertions, not a wall-clock benchmark.
test("HostTreeView scans manual group order only once across expanded leaf groups", () => {
const groupTree: GroupNode[] = ["c", "b", "a"].map((path) => ({
name: path, path, children: {}, hosts: [],
}));
const groupConfigs: GroupConfig[] = groupTree.map((node, order) => ({ path: node.path, order }));
let configVisits = 0;
Object.defineProperty(groupConfigs, "filter", {
value: (predicate: (value: GroupConfig, index: number, array: GroupConfig[]) => unknown) =>
Array.prototype.filter.call(groupConfigs, (value: GroupConfig, index: number, array: GroupConfig[]) => {
configVisits += 1;
return predicate(value, index, array);
}),
});
for (const expandedPaths of [new Set<string>(), new Set(["a", "b", "c"])]) {
configVisits = 0;
const manual = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode: "manual" });
assert.equal(configVisits, 3);
assert.deepEqual(manual.map((item) => item.key), ["group:c", "group:b", "group:a"]);
for (const sortMode of ["az", "za", "newest", "oldest", "group", "ip"] as const) {
configVisits = 0;
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode });
assert.equal(configVisits, 0, `${sortMode} must not construct a manual order index`);
assert.deepEqual(items.map((item) => item.key), sortMode === "za"
? ["group:c", "group:b", "group:a"]
: ["group:a", "group:b", "group:c"]);
}
}
});
test("HostTreeView shared order index preserves valid duplicates, ties and nested sibling metadata", () => {
const leaf = (path: string): GroupNode => ({ name: path.split("/").at(-1)!, path, children: {}, hosts: [] });
const parent: GroupNode = {
...leaf("parent"),
children: { z: leaf("parent/z"), a: leaf("parent/a"), b: leaf("parent/b") },
hosts: [{ ...baseHost, id: "child-host", group: "parent" }],
};
const groupTree = [leaf("unordered-z"), leaf("tie-b"), parent, leaf("tie-a"), leaf("unordered-a")];
const groupConfigs: GroupConfig[] = [
{ path: "parent", order: 10 }, { path: "parent", order: -1 },
{ path: "parent", order: Number.NaN }, { path: "parent", order: Infinity },
{ path: "tie-b", order: 0 }, { path: "tie-a", order: 0 },
{ path: "unordered-a", order: Number.NaN },
{ path: "parent/a", order: 0 }, { path: "parent/b", order: 0 },
{ path: "parent/z", order: -2 },
];
const ungroupedHosts = [{ ...baseHost, id: "root-host", group: "" }];
const expandedPaths = new Set(["parent", "parent/z", "parent/a", "parent/b"]);
const inputBefore = structuredClone({ groupTree, groupConfigs, ungroupedHosts, expandedPaths });
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, ungroupedHosts, expandedPaths, sortMode: "manual" });
assert.deepEqual(items.map(({ key, depth, posInSet, setSize }) => ({ key, depth, posInSet, setSize })), [
{ key: "group:parent", depth: 0, posInSet: 1, setSize: 6 },
{ key: "group:parent/z", depth: 1, posInSet: 1, setSize: 4 },
{ key: "group:parent/b", depth: 1, posInSet: 2, setSize: 4 },
{ key: "group:parent/a", depth: 1, posInSet: 3, setSize: 4 },
{ key: "host:child-host", depth: 1, posInSet: 4, setSize: 4 },
{ key: "group:tie-a", depth: 0, posInSet: 2, setSize: 6 },
{ key: "group:tie-b", depth: 0, posInSet: 3, setSize: 6 },
{ key: "group:unordered-z", depth: 0, posInSet: 4, setSize: 6 },
{ key: "group:unordered-a", depth: 0, posInSet: 5, setSize: 6 },
{ key: "host:root-host", depth: 0, posInSet: 6, setSize: 6 },
]);
assert.equal(items[0].kind === "group" && items[0].node, parent);
assert.equal(items[4].kind === "host" && items[4].host, parent.hosts[0]);
assert.deepEqual({ groupTree, groupConfigs, ungroupedHosts, expandedPaths }, inputBefore);
const changed = buildVisibleHostTreeItems({
groupTree, groupConfigs: [...groupConfigs, { path: "tie-a", order: -10 }],
ungroupedHosts, expandedPaths, sortMode: "manual",
});
assert.equal(changed[0].key, "group:tie-a");
});
+18 -14
View File
@@ -64,22 +64,25 @@ export type VisibleHostTreeItem =
setSize: number;
};
const sortHostTreeGroups = (
nodes: GroupNode[],
sortMode: HostTreeSortMode,
groupConfigs: GroupConfig[],
): GroupNode[] => {
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
const orderByPath = new Map(
const buildHostTreeGroupOrder = (groupConfigs: GroupConfig[]): Map<string, number> => (
new Map(
groupConfigs
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
.map((config) => [config.path, config.order as number]),
);
)
);
const sortHostTreeGroups = (
nodes: GroupNode[],
sortMode: HostTreeSortMode,
orderByPath?: ReadonlyMap<string, number>,
): GroupNode[] => {
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
return [...nodes].sort((a, b) => {
if (sortMode === 'za') return b.name.localeCompare(a.name);
if (sortMode === 'manual') {
const orderA = orderByPath.get(a.path);
const orderB = orderByPath.get(b.path);
const orderA = orderByPath?.get(a.path);
const orderB = orderByPath?.get(b.path);
if (orderA !== undefined && orderB !== undefined && orderA !== orderB) return orderA - orderB;
if (orderA !== undefined) return -1;
if (orderB !== undefined) return 1;
@@ -115,8 +118,9 @@ export function buildVisibleHostTreeItems({
groupConfigs: GroupConfig[];
}): VisibleHostTreeItem[] {
const items: VisibleHostTreeItem[] = [];
const orderByPath = sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined;
const visitGroups = (nodes: GroupNode[], depth: number) => {
const sortedGroups = sortHostTreeGroups(nodes, sortMode, groupConfigs);
const sortedGroups = sortHostTreeGroups(nodes, sortMode, orderByPath);
// Root-level ungrouped hosts are siblings of top-level groups.
const rootUngrouped = depth === 0 ? sortHostTreeHosts(ungroupedHosts, sortMode) : [];
const siblingSetSize = sortedGroups.length + rootUngrouped.length;
@@ -133,7 +137,7 @@ export function buildVisibleHostTreeItems({
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
orderByPath,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
@@ -182,7 +186,7 @@ export function buildVisibleHostTreeItems({
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
orderByPath,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
@@ -439,7 +443,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const childNodes = useMemo(() => sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined,
), [groupConfigs, node.children, sortMode]);
const sortedHosts = useMemo(
() => sortHostTreeHosts(node.hosts, sortMode),