diff --git a/tests/test_dom_patchers.mjs b/tests/test_dom_patchers.mjs index 3f67e7b..b329c85 100644 --- a/tests/test_dom_patchers.mjs +++ b/tests/test_dom_patchers.mjs @@ -60,6 +60,7 @@ const patchLocalMatchesContainer = eval(`(${extractMethod(resolveDownloadMethods const resolveUrnAsync = eval(`(${extractMethod(resolveDownloadMethodsSource, 'resolveUrnAsync')})`); const fetchUrnLocalMatches = eval(`(${extractMethod(searchPanelMethodsSource, 'fetchUrnLocalMatches')})`); const scheduleSearchUiRefresh = eval(`(${extractMethod(searchPanelMethodsSource, 'scheduleSearchUiRefresh')})`); +const wireLocalMatchButtons = eval(`(${extractMethod(searchPanelMethodsSource, 'wireLocalMatchButtons')})`); const patchQueuedSelections = eval(`(${extractMethod(queueMethodsSource, 'patchQueuedSelections')})`); const patchDownloadsPanelElement = eval(`(${extractMethod(queueMethodsSource, 'patchDownloadsPanelElement')})`); const patchLoadedModelsProgress = eval(`(${extractMethod(renderFormatMethodsSource, 'patchLoadedModelsProgress')})`); @@ -253,15 +254,54 @@ test('search download button wiring remains idempotent on the same node', () => const window = new Window(); const dialog = createDialog(window); const container = window.document.createElement('div'); - container.innerHTML = ''; + container.innerHTML = ` + + + `; let calls = 0; + let details = null; dialog.downloadFromSearch = () => { calls += 1; }; + dialog.showSourceModelDetails = model => { details = model; }; dialog.wireSearchDownloadButtons(container, {}); dialog.wireSearchDownloadButtons(container, {}); container.querySelector('.search-download-btn').click(); + container.querySelector('.search-show-details-btn').click(); assert.equal(calls, 1); + assert.deepEqual(details, { name: 'Example' }); +}); + +test('local match action delegation remains idempotent across keyed rows', () => { + const window = new Window(); + const container = window.document.createElement('div'); + container.innerHTML = ` +
+ +
+
+ +
+ `; + const missing = { + matches: [ + { confidence: 100, model: { id: 'best' } }, + { confidence: 80, model: { id: 'alternative' } }, + ], + }; + const queued = []; + const dialog = { + queueResolution(_missing, model) { + queued.push(model.id); + }, + }; + + wireLocalMatchButtons.call(dialog, container, missing); + wireLocalMatchButtons.call(dialog, container, missing); + container.querySelector('.mr-match-row button').click(); + container.querySelector('[data-local-match-alternative="true"] button').click(); + + assert.deepEqual(queued, ['best', 'alternative']); }); test('stale URN responses cannot overwrite the latest UI request', async () => { diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index bd35359..8e99c66 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -5232,8 +5232,9 @@ test('local match refreshes preserve keyed rows and button handlers', () => { assert.match(patchLocalMatchesContainer, /mr-local-alternatives-toggle/); assert.match(refreshLocalMatchesUiForMissing, /patchLocalMatchesContainer/); assert.doesNotMatch(refreshLocalMatchesUiForMissing, /body\.innerHTML\s*=/); - assert.match(wireLocalMatchButtons, /resolveButton\.onclick\s*=/); - assert.match(wireLocalMatchButtons, /altBtn\.onclick\s*=/); + assert.match(wireLocalMatchButtons, /bindEventOnce/); + assert.match(wireLocalMatchButtons, /local-match-actions/); + assert.doesNotMatch(wireLocalMatchButtons, /\.onclick\s*=/); assert.doesNotMatch(wireLocalMatchButtons, /addEventListener\('click'/); }); @@ -5843,9 +5844,11 @@ test('search result refreshes preserve keyed progress and result DOM', () => { assert.doesNotMatch(displaySearchResults, /container\.innerHTML\s*=/); assert.match(patchSearchProgressElement, /data-search-progress-source|searchProgressSource/); assert.match(patchSearchResultsTable, /data-search-result-key|searchResultKey/); - assert.match(wireSearchDownloadButtons, /mlSearchDownloadBound/); - assert.match(wireSearchDownloadButtons, /mlSearchOpenPageBound/); - assert.match(wireSearchDownloadButtons, /mlSearchDetailsBound/); + assert.match(wireSearchDownloadButtons, /bindEventOnce/); + assert.match(wireSearchDownloadButtons, /search-result-actions/); + assert.doesNotMatch(wireSearchDownloadButtons, /mlSearchDownloadBound/); + assert.doesNotMatch(wireSearchDownloadButtons, /mlSearchOpenPageBound/); + assert.doesNotMatch(wireSearchDownloadButtons, /mlSearchDetailsBound/); assert.match(resolveDownloadMethodsSource, /rows\.push\(\{ \.\.\.row, __searchResultKey: rowKey \}\)/); assert.match(searchPanelMethodsSource, /data-search-progress-source=/); assert.match(searchPanelMethodsSource, /data-search-result-key=/); diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 1734f09..d93b474 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -4,7 +4,7 @@ import { getModelCardUrl, parseHuggingFaceFileUrl } from "../utils/url_utils.js" import { getCivitaiModelUrl } from "../globals.js"; import { normalizeSha256 } from "../utils/hash_utils.js"; import { normalizePathIdentity } from "../utils/html_utils.js"; -import { syncElementAttributes } from "../utils/dom_patch_utils.js"; +import { bindEventOnce, syncElementAttributes } from "../utils/dom_patch_utils.js"; const log = createModuleLogger('resolve_download_methods'); export const resolveDownloadMethods = { @@ -2996,12 +2996,13 @@ export const resolveDownloadMethods = { if (!container) return; this.wireSearchHashMatchHighlights?.(container); + bindEventOnce(container, 'click', (event) => { + const btn = event.target?.closest?.( + '.search-download-btn, .search-open-page-btn, .search-show-details-btn' + ); + if (!btn || !container.contains(btn)) return; - const downloadBtns = container.querySelectorAll('.search-download-btn'); - downloadBtns.forEach(btn => { - if (btn.dataset.mlSearchDownloadBound === 'true') return; - btn.dataset.mlSearchDownloadBound = 'true'; - btn.addEventListener('click', () => { + if (btn.classList.contains('search-download-btn')) { const url = btn.dataset.url; const filename = btn.dataset.filename; const category = btn.dataset.category; @@ -3022,14 +3023,10 @@ export const resolveDownloadMethods = { downloadMetadata = null; } this.downloadFromSearch(missing, url, filename, category, btn, pathMetadata, downloadMetadata); - }); - }); + return; + } - const openPageBtns = container.querySelectorAll('.search-open-page-btn'); - openPageBtns.forEach(btn => { - if (btn.dataset.mlSearchOpenPageBound === 'true') return; - btn.dataset.mlSearchOpenPageBound = 'true'; - btn.addEventListener('click', () => { + if (btn.classList.contains('search-open-page-btn')) { const url = btn.dataset.url; if (!url) return; @@ -3037,23 +3034,17 @@ export const resolveDownloadMethods = { if (opened) { opened.opener = null; } - }); - }); + return; + } - const detailsBtns = container.querySelectorAll('.search-show-details-btn'); - detailsBtns.forEach(btn => { - if (btn.dataset.mlSearchDetailsBound === 'true') return; - btn.dataset.mlSearchDetailsBound = 'true'; - btn.addEventListener('click', () => { - try { - const model = JSON.parse(decodeURIComponent(btn.dataset.model || '')); - this.showSourceModelDetails(model); - } catch (error) { - console.error('Model Resolver: failed to open model details:', error); - this.showNotification?.('Failed to open model details.', 'error'); - } - }); - }); + try { + const model = JSON.parse(decodeURIComponent(btn.dataset.model || '')); + this.showSourceModelDetails(model); + } catch (error) { + console.error('Model Resolver: failed to open model details:', error); + this.showNotification?.('Failed to open model details.', 'error'); + } + }, 'search-result-actions'); }, wireDownloadSearchPanel(container, missing) { diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index 76224b5..d77f966 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -2,6 +2,7 @@ import { getSvgIcon } from "../../utils/icon_utils.js"; import { getModelCardUrl, parseHuggingFaceFileUrl } from "../utils/url_utils.js"; import { getCivitaiModelUrl } from "../globals.js"; import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js"; +import { bindEventOnce } from "../utils/dom_patch_utils.js"; const localStorage = safeStorage; export const searchPanelMethods = { isAutoFillBaseModelEnabled() { @@ -2360,7 +2361,7 @@ export const searchPanelMethods = { const localMatchKeyAttr = ` data-local-match-key="${this.escapeHtml(encodeURIComponent(String(localMatchKey)))}"`; const rowClass = `mr-match-row${isBestMatch ? ' mr-best-match' : ''}`; - html += `
`; + html += `
`; html += this.getConfidenceBadge(match.confidence); html += `${this.escapeHtml(matchPath)}`; html += this.renderLocalMatchStatusGroup(missing, match, hashLabelMap); @@ -2398,7 +2399,7 @@ export const searchPanelMethods = { || match.filename || `alternative-${mIdx}`; const localMatchKeyAttr = ` data-local-match-key="${this.escapeHtml(encodeURIComponent(String(localMatchKey)))}"`; - html += `
`; + html += `
`; html += this.getConfidenceBadge(match.confidence); html += `${this.escapeHtml(matchPath)}`; html += this.renderLocalMatchStatusGroup(missing, match, hashLabelMap); @@ -2418,43 +2419,28 @@ export const searchPanelMethods = { return html; }, - wireLocalMatchButtons(container, missing, missingIndex = 0) { - const allMatches = missing.matches || []; - const filteredMatches = allMatches.filter(m => m.confidence >= 70); - const perfectMatches = filteredMatches.filter(m => m.confidence === 100); - const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70); - const matchesToShow = perfectMatches.length > 0 - ? perfectMatches - : otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5); + wireLocalMatchButtons(container, missing) { + bindEventOnce(container, 'click', (event) => { + const button = event.target?.closest?.('.mr-local-link-btn'); + if (!button || !container.contains(button)) return; - const sortedMatches = matchesToShow.sort((a, b) => { - if (a.confidence === 100 && b.confidence !== 100) return -1; - if (a.confidence !== 100 && b.confidence === 100) return 1; - return b.confidence - a.confidence; - }); + const row = button.closest('.mr-match-row'); + const matchIndex = Number(row?.dataset.localMatchIndex); + if (!row || !Number.isInteger(matchIndex) || matchIndex < 0) return; - sortedMatches.forEach((match, matchIndex) => { - const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`; - const resolveButton = container.querySelector(`#${buttonId}`); - if (resolveButton) { - resolveButton.onclick = () => { - this.queueResolution(missing, match.model); - }; + const filteredMatches = (missing.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 match = row.dataset.localMatchAlternative === 'true' + ? otherMatches[matchIndex] + : visibleMatches[matchIndex]; + if (match?.model) { + this.queueResolution(missing, match.model); } - }); - - if (otherMatches && otherMatches.length > 0) { - for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) { - const match = otherMatches[mIdx]; - const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`; - const altBtn = container.querySelector(`#${altBtnId}`); - if (altBtn) { - altBtn.onclick = () => { - this.queueResolution(missing, match.model); - }; - } - } - } + }, 'local-match-actions'); this.wireLocalHashMatchResultHighlights?.(container); this.bindTooltips?.(container); @@ -2723,7 +2709,7 @@ export const searchPanelMethods = { container, this.renderLocalMatchesContent(missing, missing.__displayIndex || 0) ); - this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0); + this.wireLocalMatchButtons(container, missing); } this.refreshMissingListRow(missing, { refreshBaseModels: true }); } catch (error) {