mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-02 03:42:34 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea87a8d755 | ||
|
|
3be46169dc |
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];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user