Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/celemod-ui/src/manageDependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
collectDuplicateFixTargets,
profileDisabledFilesAfterSwitch,
collectSwitchNames,
getDependencyHealth,
normalizeManageDependencies,
selectVisibleRootNames,
type ManageNode,
Expand Down Expand Up @@ -359,3 +360,84 @@ test("disabling a remaining file does not discard its exclusion", () => {
["Remaining.zip"],
);
});

/**
* A shared helper layer makes the same Mod reachable through many dependency
* paths. Walking all of them grows with the number of paths, so the counts
* below are read through a proxy: a Mod list that walks each Mod once stays
* linear, one that walks every path does not finish.
*/
const pathHeavyMods = (libs: number, fanOut: number) => {
const nodes: Record<string, ManageNode> = {};
for (let index = 0; index < libs; index += 1) {
const dependencies: ManageNode["dependencies"] = [];
for (let step = 1; step <= fanOut; step += 1) {
if (index + step >= libs) break;
dependencies.push({
name: `Lib.${index + step}`,
version: "1.0.0",
optional: false,
});
}
nodes[`Lib.${index}`] = node(`Lib.${index}`, { dependencies });
}
for (let index = 0; index < 20; index += 1) {
nodes[`Mod.${index}`] = node(`Mod.${index}`, {
dependencies: [
{ name: "Lib.0", version: "1.0.0", optional: false },
{ name: `Lib.${index % libs}`, version: "1.0.0", optional: false },
],
});
}
return nodes;
};

const countingNodes = (nodes: Record<string, ManageNode>) => {
const state = { reads: 0 };
const counted = new Proxy(nodes, {
get(target, property, receiver) {
if (typeof property === "string") state.reads += 1;
return Reflect.get(target, property, receiver);
},
});
return { counted, state };
};

test("inspects each dependency once when many paths reach it", () => {
const { counted, state } = countingNodes(pathHeavyMods(40, 3));

assert.deepEqual(getDependencyHealth("Lib.0", counted, false), {
status: "healthy",
messages: [],
});
assert.ok(
state.reads < 500,
`expected a linear walk, read Mods ${state.reads} times`,
);
});

test("does not walk dependencies when the health filter is off", () => {
const { counted, state } = countingNodes(pathHeavyMods(40, 3));

const visible = selectVisibleRootNames({
nodes: counted,
filters: {
query: "",
enabled: "all",
health: "all",
types: [],
updateOnly: false,
duplicateOnly: false,
showHiddenTypes: false,
},
rootOnly: false,
includeOptional: false,
hiddenTypes: [],
updateNames: new Set(),
});
assert.equal(visible.length, 60);
assert.ok(
state.reads < 2000,
`expected one read per Mod, read Mods ${state.reads} times`,
);
});
31 changes: 20 additions & 11 deletions src/celemod-ui/src/stores/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,12 @@ export const getDependencyHealth = (
if (!nodes[name]?.enabled) return { status: "healthy", messages: [] };
const messages: string[] = [];
let status: ManageDependencyHealth["status"] = "healthy";
const visiting = new Set<string>();
// A Mod that several Mods depend on is reachable through every one of those
// paths, and walking them all grows with the number of paths, so a shared
// helper layer makes the Mod list hang. A Mod reports the same problems no
// matter which path reached it, so each one is inspected once; the same set
// still stops dependency cycles.
const visited = new Set<string>();
const ranks = { healthy: 0, version: 1, disabled: 2, missing: 3 };

const merge = (next: ManageDependencyHealth["status"], message: string) => {
Expand All @@ -427,10 +432,10 @@ export const getDependencyHealth = (
};

const visit = (nodeName: string) => {
if (visiting.has(nodeName)) return;
if (visited.has(nodeName)) return;
const node = nodes[nodeName];
if (!node) return;
visiting.add(nodeName);
visited.add(nodeName);
for (const dependency of node.dependencies) {
if (
EXCLUDED_DEPENDENCIES.has(dependency.name) ||
Expand All @@ -455,7 +460,6 @@ export const getDependencyHealth = (
}
visit(installed.name);
}
visiting.delete(nodeName);
};
visit(name);
return { status, messages };
Expand Down Expand Up @@ -595,6 +599,9 @@ export const selectVisibleRootNames = ({
displayNames?: Record<string, ManageDisplayNames>;
}) => {
const query = filters.query.trim().toLocaleLowerCase();
// "All" is the default state of the filter, and the dependency walk it does
// not need is the expensive part of every Mod-list render.
const needsHealth = filters.health !== "all";
const matches = (node: ManageNode) => {
if (
!filters.showHiddenTypes &&
Expand All @@ -611,13 +618,15 @@ export const selectVisibleRootNames = ({
if (filters.enabled === "disabled" && node.enabled) return false;
if (filters.updateOnly && !updateNames.has(node.name)) return false;
if (filters.duplicateOnly && node.duplicateFiles.length < 2) return false;
const health = getDependencyHealth(node.name, nodes, includeOptional);
if (filters.health === "healthy" && health.status !== "healthy")
return false;
if (filters.health === "issues" && health.status === "healthy")
return false;
if (filters.health === "missing" && health.status !== "missing")
return false;
if (needsHealth) {
const health = getDependencyHealth(node.name, nodes, includeOptional);
if (filters.health === "healthy" && health.status !== "healthy")
return false;
if (filters.health === "issues" && health.status === "healthy")
return false;
if (filters.health === "missing" && health.status !== "missing")
return false;
}
if (query) {
const text = [
node.name,
Expand Down
Loading