diff --git a/tests/test_dom_patchers.mjs b/tests/test_dom_patchers.mjs
index ddfb8e3..d5e2de7 100644
--- a/tests/test_dom_patchers.mjs
+++ b/tests/test_dom_patchers.mjs
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import assert from 'node:assert/strict';
+import { performance } from 'node:perf_hooks';
import { Window } from 'happy-dom';
import {
bindEventOnce,
@@ -480,3 +481,99 @@ test('search UI refreshes coalesce within one animation frame', () => {
else globalThis.cancelAnimationFrame = previousCancelAnimationFrame;
}
});
+
+test('DOM patchers stay within the local benchmark budget', () => {
+ const window = new Window();
+ const dialog = createDialog(window);
+ const itemCount = 80;
+ const iterations = 30;
+ const elementFromHtml = html => {
+ const wrapper = window.document.createElement('div');
+ wrapper.innerHTML = html;
+ return wrapper.firstElementChild;
+ };
+ const benchmark = (label, operation) => {
+ const start = performance.now();
+ for (let iteration = 0; iteration < iterations; iteration += 1) {
+ operation(iteration);
+ }
+ const elapsedMs = performance.now() - start;
+ assert.ok(elapsedMs < 5000, `${label} exceeded 5000ms: ${elapsedMs.toFixed(1)}ms`);
+ return elapsedMs;
+ };
+
+ const searchHtml = iteration => `
+
+ ${Array.from({ length: itemCount }, (_, index) => `
+
+
+ Source ${index}
+ ${iteration}%
+
+
+
+ `).join('')}
+
`;
+ const searchCurrent = elementFromHtml(searchHtml(0));
+ const searchNext = elementFromHtml(searchHtml(0));
+ const searchFirstItem = searchCurrent.firstElementChild;
+ const searchMs = benchmark('search progress patcher', iteration => {
+ searchNext.querySelectorAll('.mr-search-progress-status').forEach(status => {
+ status.textContent = `${iteration}%`;
+ });
+ dialog.patchSearchProgressElement(searchCurrent, searchNext);
+ });
+ assert.strictEqual(searchCurrent.firstElementChild, searchFirstItem);
+
+ const localContainer = window.document.createElement('div');
+ const localHtml = iteration => `
+ ${Array.from({ length: itemCount }, (_, index) => `
+ ${iteration}-${index}
+ `).join('')}`;
+ dialog.patchLocalMatchesContainer(localContainer, localHtml(0));
+ const localFirstRow = localContainer.firstElementChild;
+ const localMs = benchmark('local match patcher', iteration => {
+ dialog.patchLocalMatchesContainer(localContainer, localHtml(iteration));
+ });
+ assert.strictEqual(localContainer.firstElementChild, localFirstRow);
+
+ const progressContainer = window.document.createElement('div');
+ const progressHtml = iteration => `
+
+
Loading ${iteration}
+
+
${iteration} / 100${iteration}%
+
`;
+ dialog.patchLoadedModelsProgress(progressContainer, progressHtml(0));
+ const progressRoot = progressContainer.firstElementChild;
+ const progressMs = benchmark('loaded progress patcher', iteration => {
+ dialog.patchLoadedModelsProgress(progressContainer, progressHtml(iteration));
+ });
+ assert.strictEqual(progressContainer.firstElementChild, progressRoot);
+
+ const downloadsCurrent = elementFromHtml(`
+
+ ${Array.from({ length: itemCount }, (_, index) => `
+
0%
+ `).join('')}
+
`);
+ const downloadsFirstCard = downloadsCurrent.firstElementChild;
+ const downloadsMs = benchmark('downloads panel patcher', iteration => {
+ const downloadsNext = elementFromHtml(`
+
+ ${Array.from({ length: itemCount }, (_, index) => `
+
${iteration}%
+ `).join('')}
+
`);
+ dialog.patchDownloadsPanelElement(downloadsCurrent, downloadsNext);
+ });
+ assert.strictEqual(downloadsCurrent.firstElementChild, downloadsFirstCard);
+
+ console.info(
+ `[dom-benchmark] search=${searchMs.toFixed(1)}ms `
+ + `local=${localMs.toFixed(1)}ms `
+ + `progress=${progressMs.toFixed(1)}ms `
+ + `downloads=${downloadsMs.toFixed(1)}ms `
+ + `(${itemCount} items x ${iterations} updates)`
+ );
+});