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
66 changes: 66 additions & 0 deletions client/src/components/Panels/utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getValidPanelItems,
getValidToolsInEachSection,
searchObjectsByKeys,
searchTools,
type SearchCommonKeys,
} from "./utilities";
import type { Tool, ToolPanelItem, ToolSection, ToolSectionLabel } from "@/stores/toolStore";
Expand Down Expand Up @@ -493,3 +494,68 @@ describe("getValidPanelItems", () => {
expect(result["testlabel1"]).toBeDefined();
});
});

describe("searchTools tool-lineage de-duplication", () => {
// Regression for issue #23151: an instance can install and place more than one
// version of the same tool (sometimes across different sections). Searching the
// tool's name previously returned every placed version as a separate result,
// rendering as a duplicate. Search results should collapse to a single entry
// per tool lineage.
const pgt38 = "toolshed.g2.bx.psu.edu/repos/iuc/pygenometracks/pygenomeTracks/3.8+galaxy2";
const pgt39 = "toolshed.g2.bx.psu.edu/repos/iuc/pygenometracks/pygenomeTracks/3.9+galaxy0";

const twoVersionTools = [
{ id: pgt38, name: "pyGenomeTracks", description: "Plot genomic tracks", version: "3.8+galaxy2" },
{ id: pgt39, name: "pyGenomeTracks", description: "Plot genomic tracks", version: "3.9+galaxy0" },
] as unknown as Tool[];

const twoSectionPanel = {
graph_display_data: {
model_class: "ToolSection",
id: "graph_display_data",
name: "Graph/Display Data",
tools: [pgt38],
},
plots: {
model_class: "ToolSection",
id: "plots",
name: "Plots",
tools: [pgt39],
},
} as unknown as Record<string, ToolPanelItem>;

it("collapses a tool placed at multiple versions across sections to a single result", () => {
const { results, resultPanel } = searchTools(twoVersionTools, "pyGenomeTracks", twoSectionPanel);

// Only one lineage entry survives (the first / highest-ranked occurrence)
expect(results).toEqual([pgt38]);

// The sectioned result panel no longer renders the dropped version;
// the section left empty by the dropped version is removed.
const remainingToolIds = Object.values(resultPanel).flatMap((item) => (item as ToolSection).tools ?? []);
expect(remainingToolIds).toEqual([pgt38]);
});

it("keeps distinct tools that merely share a search term", () => {
const distinctTools = [
{ id: "pygenometracks/1.0", name: "pyGenomeTracks", description: "Plot tracks", version: "1.0" },
{
id: "pygenometracks_utils/1.0",
name: "pyGenomeTracks utils",
description: "Track helpers",
version: "1.0",
},
] as unknown as Tool[];
const panel = {
plots: {
model_class: "ToolSection",
id: "plots",
name: "Plots",
tools: ["pygenometracks/1.0", "pygenometracks_utils/1.0"],
},
} as unknown as Record<string, ToolPanelItem>;

const { results } = searchTools(distinctTools, "pyGenomeTracks", panel);
expect(results).toHaveLength(2);
});
});
60 changes: 59 additions & 1 deletion client/src/components/Panels/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,65 @@ export function searchTools(
"description",
]);
const { idResults, resultPanel } = createSortedResultPanel(matchedResults, currentPanel);
return { results: idResults, resultPanel: resultPanel, closestTerm: closestTerm };
// An instance may install and place more than one version of the same tool,
// sometimes across different sections. Collapse those to a single result so a
// tool doesn't surface as a duplicate in the search results (issue #23151).
const { results, resultPanel: dedupedPanel } = dedupeResultsByToolLineage(idResults, resultPanel, tools);
return { results, resultPanel: dedupedPanel, closestTerm: closestTerm };
}

/**
* Collapses search results that belong to the same tool lineage (the same tool
* installed at multiple versions) down to a single result, keeping the first
* (highest-ranked) occurrence. Also prunes the sectioned `resultPanel` so the
* dropped ids no longer render, removing any section left empty as a result.
*
* @param idResults ordered result tool ids from `createSortedResultPanel`
* @param resultPanel sectioned result panel from `createSortedResultPanel`
* @param tools the searched tools, used to resolve each id's version
* @returns the de-duplicated `results` and matching `resultPanel`
*/
export function dedupeResultsByToolLineage(
idResults: string[],
resultPanel: Record<string, Tool | ToolSection>,
tools: Tool[],
): { results: string[]; resultPanel: Record<string, Tool | ToolSection> } {
const toolById = new Map(tools.map((tool) => [tool.id, tool]));
const keptLineages = new Set<string>();
const results: string[] = [];
for (const id of idResults) {
const lineage = getVersionlessToolId(id, toolById.get(id));
if (keptLineages.has(lineage)) {
continue;
}
keptLineages.add(lineage);
results.push(id);
}

// Nothing collapsed: keep the original panel untouched.
if (results.length === idResults.length) {
return { results: idResults, resultPanel };
}

const keptIds = new Set(results);
const prunedPanel: Record<string, Tool | ToolSection> = {};
for (const [key, item] of Object.entries(resultPanel)) {
if (isToolSection(item)) {
const sectionTools = (item.tools ?? []).filter(
(toolId) => typeof toolId !== "string" || keptIds.has(toolId),
);
if (sectionTools.length > 0) {
prunedPanel[key] = { ...item, tools: sectionTools };
}
} else if (isTool(item)) {
if (keptIds.has(item.id)) {
prunedPanel[key] = item;
}
} else {
prunedPanel[key] = item;
}
}
return { results, resultPanel: prunedPanel };
}

export function searchSections(sections: ToolSection[], query: string) {
Expand Down
Loading