From 2ef86fed3ba54ba6adf8b67b04e14d3117120df9 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 6 Aug 2026 20:01:20 +0200 Subject: [PATCH] refactor: unify local match classification --- tests/test_dom_patchers.mjs | 1 + tests/test_downloads_tab_workflow_route.mjs | 125 ++++++++++++++++++ web/resolver/search/search_panel.js | 26 ++-- web/resolver/utils/local_match_utils.js | 21 +++ web/resolver/views/missing_browser_methods.js | 21 ++- 5 files changed, 169 insertions(+), 25 deletions(-) create mode 100644 web/resolver/utils/local_match_utils.js diff --git a/tests/test_dom_patchers.mjs b/tests/test_dom_patchers.mjs index c065ec5..444858c 100644 --- a/tests/test_dom_patchers.mjs +++ b/tests/test_dom_patchers.mjs @@ -10,6 +10,7 @@ import { syncElementAttributes, } from '../web/resolver/utils/dom_patch_utils.js'; import * as domPatchUtils from '../web/resolver/utils/dom_patch_utils.js'; +import { classifyLocalMatches } from '../web/resolver/utils/local_match_utils.js'; const projectRoot = path.resolve(import.meta.dirname, '..'); const resolveDownloadMethodsSource = fs.readFileSync( diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 84fa240..2ef1940 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -9,6 +9,7 @@ import { safeStorage, } from '../web/resolver/utils/html_utils.js'; import { getModelCardUrl } from '../web/resolver/utils/url_utils.js'; +import { classifyLocalMatches } from '../web/resolver/utils/local_match_utils.js'; import { extractComfyWorkflow } from '../web/resolver/utils/workflow_metadata.js'; import { buildModelResolverNodeMenu, @@ -6020,6 +6021,130 @@ test('local match rows preserve best-match and alternative rendering contracts', assert.equal((html.match(/class="status-group"/g) || []).length, 2); }); +test('local match classification preserves the confidence threshold and alternative partition', () => { + globalThis.getSvgIcon = () => ''; + const renderLocalMatchRow = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchRow')})`); + const renderLocalMatchesContent = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchesContent')})`); + const dialog = { + renderLocalMatchRow, + getHashMatchLabelMap() { + return null; + }, + buildContextMenuModelData() { + return { model: 'context' }; + }, + getLocalMatchContextData() { + return { source: 'local' }; + }, + getLocalMatchIdentity(match) { + return match.identity; + }, + escapeHtml(value) { + return String(value).replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char])); + }, + getContextMenuAttrs() { + return ''; + }, + getConfidenceBadge(confidence) { + return `${confidence}`; + }, + getModelPreviewTooltipAttrs() { + return ''; + }, + renderLocalMatchStatusGroup() { + return ''; + }, + areLocalMatchAlternativesCollapsed() { + return false; + }, + }; + const html = renderLocalMatchesContent.call(dialog, { + node_id: 1, + widget_index: 2, + matches: [ + { confidence: 69, identity: 'below-threshold', model: { relative_path: 'below.safetensors' } }, + { confidence: 70, identity: 'minimum-threshold', model: { relative_path: 'minimum.safetensors' } }, + { confidence: 80, identity: 'alternative', model: { relative_path: 'alternative.safetensors' } }, + { confidence: 100, identity: 'perfect', model: { relative_path: 'perfect.safetensors' } }, + ], + }, 0); + + assert.match(html, /perfect\.safetensors/); + assert.match(html, /minimum\.safetensors/); + assert.match(html, /alternative\.safetensors/); + assert.doesNotMatch(html, /below\.safetensors/); + assert.match(html, /Alternatives \(2\)/); +}); + +test('local match classification limits visible non-perfect matches to the five highest confidences', () => { + globalThis.getSvgIcon = () => ''; + const renderLocalMatchRow = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchRow')})`); + const renderLocalMatchesContent = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchesContent')})`); + const dialog = { + renderLocalMatchRow, + getHashMatchLabelMap() { + return null; + }, + buildContextMenuModelData() { + return { model: 'context' }; + }, + getLocalMatchContextData() { + return { source: 'local' }; + }, + getLocalMatchIdentity(match) { + return match.identity; + }, + escapeHtml(value) { + return String(value).replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char])); + }, + getContextMenuAttrs() { + return ''; + }, + getConfidenceBadge(confidence) { + return `${confidence}`; + }, + getModelPreviewTooltipAttrs() { + return ''; + }, + renderLocalMatchStatusGroup() { + return ''; + }, + areLocalMatchAlternativesCollapsed() { + return false; + }, + }; + const html = renderLocalMatchesContent.call(dialog, { + node_id: 1, + widget_index: 2, + matches: [ + { confidence: 69, identity: 'below-threshold', model: { relative_path: 'below.safetensors' } }, + ...[70, 75, 80, 85, 90, 95].map(confidence => ({ + confidence, + identity: `match-${confidence}`, + model: { relative_path: `match-${confidence}.safetensors` }, + })), + ], + }, 0); + + assert.match(html, /match-95\.safetensors/); + assert.match(html, /match-75\.safetensors/); + assert.doesNotMatch(html, /match-70\.safetensors/); + assert.doesNotMatch(html, /below\.safetensors/); + assert.equal((html.match(/class="mr-match-row/g) || []).length, 5); +}); + test('URN resolution preserves the download panel container', () => { const resolveUrnAsync = extractMethod(resolveDownloadMethodsSource, 'resolveUrnAsync'); diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index f6b4631..3d42d21 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -5,6 +5,7 @@ import { getCivitaiModelUrl } from "../globals.js"; import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js"; import { getSha256Field } from "../utils/hash_utils.js"; import { bindEventOnce } from "../utils/dom_patch_utils.js"; +import { classifyLocalMatches } from "../utils/local_match_utils.js"; const localStorage = safeStorage; export const searchPanelMethods = { isAutoFillBaseModelEnabled() { @@ -2399,18 +2400,19 @@ export const searchPanelMethods = { renderLocalMatchesContent(missing, missingIndex = 0) { const allMatches = missing.matches || []; - const filteredMatches = allMatches.filter(m => m.confidence >= 70); - const hasMatches = filteredMatches.length > 0; - const perfectMatches = filteredMatches.filter(m => m.confidence === 100); - const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70); + const { + filteredMatches, + hasMatches, + perfectMatches, + otherMatches, + visibleMatches, + } = classifyLocalMatches(allMatches); const hashLabelMap = this.getHashMatchLabelMap?.(missing) || null; let html = ''; if (hasMatches) { - const matchesToShow = perfectMatches.length > 0 - ? perfectMatches - : otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5); + const matchesToShow = perfectMatches.length > 0 ? perfectMatches : visibleMatches; const sortedMatches = matchesToShow.sort((a, b) => { if (a.confidence === 100 && b.confidence !== 100) return -1; @@ -2479,12 +2481,10 @@ export const searchPanelMethods = { const matchIndex = Number(row?.dataset.localMatchIndex); if (!row || !Number.isInteger(matchIndex) || matchIndex < 0) return; - const filteredMatches = (currentMissing.matches || []).filter(match => match.confidence >= 70); - const perfectMatches = filteredMatches.filter(match => match.confidence === 100); - const otherMatches = filteredMatches.filter(match => match.confidence < 100 && match.confidence >= 70); - const visibleMatches = perfectMatches.length > 0 - ? perfectMatches - : otherMatches.sort((left, right) => right.confidence - left.confidence).slice(0, 5); + const { + otherMatches, + visibleMatches, + } = classifyLocalMatches(currentMissing.matches || []); const match = row.dataset.localMatchAlternative === 'true' ? otherMatches[matchIndex] : visibleMatches[matchIndex]; diff --git a/web/resolver/utils/local_match_utils.js b/web/resolver/utils/local_match_utils.js new file mode 100644 index 0000000..cdd6084 --- /dev/null +++ b/web/resolver/utils/local_match_utils.js @@ -0,0 +1,21 @@ +export function classifyLocalMatches( + matches = [], + { minConfidence = 70, perfectConfidence = 100, visibleLimit = 5 } = {}, +) { + const filteredMatches = matches.filter(match => match.confidence >= minConfidence); + const perfectMatches = filteredMatches.filter(match => match.confidence === perfectConfidence); + const otherMatches = filteredMatches.filter( + match => match.confidence < perfectConfidence && match.confidence >= minConfidence, + ); + const visibleMatches = perfectMatches.length > 0 + ? perfectMatches + : [...otherMatches].sort((left, right) => right.confidence - left.confidence).slice(0, visibleLimit); + + return { + filteredMatches, + perfectMatches, + otherMatches, + visibleMatches, + hasMatches: filteredMatches.length > 0, + }; +} diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index a9d2344..7f04a26 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -4,6 +4,7 @@ import { startSplitterDrag } from "../utils/splitter_drag.js"; import { getCivitaiModelUrl } from "../globals.js"; import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js"; import { matchesWorkflowModelReference } from "../node_context_menu.js"; +import { classifyLocalMatches } from "../utils/local_match_utils.js"; const MISSING_ROW_FALLBACK_HEIGHT = 70; const MISSING_VIRTUAL_INITIAL_ROWS = 24; @@ -2409,7 +2410,7 @@ export const missingBrowserMethods = { // Check if there are active downloads const activeCount = Object.keys(this.activeDownloads).length; const hasAny100Match = visibleMissingModels.some(missing => - (missing.matches || []).some(match => match.confidence === 100) + classifyLocalMatches(missing.matches || []).perfectMatches.length > 0 ); const hasAnyModelsToDisplay = rawMissingCount > 0 || resolvedModels.length > 0; @@ -2481,13 +2482,13 @@ export const missingBrowserMethods = { const aMatches = a.matches || []; const bMatches = b.matches || []; - // Filter to 70%+ confidence - const aFiltered = aMatches.filter(m => m.confidence >= 70); - const bFiltered = bMatches.filter(m => m.confidence >= 70); + const aClassification = classifyLocalMatches(aMatches); + const bClassification = classifyLocalMatches(bMatches); + const aFiltered = aClassification.filteredMatches; + const bFiltered = bClassification.filteredMatches; - // Check if they have 100% matches - const aHas100 = aFiltered.some(m => m.confidence === 100); - const bHas100 = bFiltered.some(m => m.confidence === 100); + const aHas100 = aClassification.perfectMatches.length > 0; + const bHas100 = bClassification.perfectMatches.length > 0; // If one has 100% and the other doesn't, prioritize the one with 100% if (aHas100 && !bHas100) return -1; @@ -2612,11 +2613,7 @@ export const missingBrowserMethods = { renderMissingModel(missing, missingIndex = 0) { const allMatches = missing.matches || []; - // Filter out matches below 70% confidence threshold - const filteredMatches = allMatches.filter(m => m.confidence >= 70); - - // Calculate 100% matches upfront (needed for download section) - const perfectMatches = filteredMatches.filter(m => m.confidence === 100); + const { perfectMatches } = classifyLocalMatches(allMatches); const missingFilename = this.getMissingFilename(missing);