Files
Azornes-Comfyui-Model-Resolver/tests/test_loaded_model_selection.mjs
Dariusz L 1cdee997d1 Add multi-selection and copying for model chips in Loaded Models and Metadata
- Enable multi-selection on model chips with click, Ctrl+click (toggle/add), Shift+click (range from anchor), and Ctrl+Shift+click.
- Add keyboard shortcuts: Ctrl+C to copy selected model tokens to clipboard with feedback, Ctrl+A to select all visible chips, and Escape to clear selection.
- Add a subtle green outline and background highlight for selected chips (.is-selected).
- Add a 'Selected' mode button to the copy toolbar that dynamically appears when chips are selected and syncs token count and preview text.
- Stop click event propagation on chips and clean up prior selection instances during tab switching and re-rendering to prevent ghost listeners and bubbling conflicts between tabs.
- Add comprehensive Node test suite covering selection operations, keyboard shortcuts, DOM rendering, and tab transitions.
2026-10-16 15:54:46 +02:00

535 lines
23 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Window } from 'happy-dom';
import {
initLoadedModelSelection,
updateLoadedModelSelectionState,
getVisibleModelChips,
getSelectedModelChips,
getSelectedModelTokens,
clearLoadedModelSelection,
} from '../web/resolver/utils/loaded_model_selection.js';
import {
groupLoadedModelsByCategory,
buildLoadedModelTokenStrings,
} from '../web/resolver/utils/loaded_model_utils.js';
const getSvgIcon = () => '<svg></svg>';
void groupLoadedModelsByCategory;
void buildLoadedModelTokenStrings;
void getSvgIcon;
function setupDomEnvironment() {
const window = new Window();
const document = window.document;
globalThis.window = window;
globalThis.document = document;
try {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
writable: true,
value: window.navigator,
});
} catch {
// Fallback for environments where navigator cannot be redefined
}
window.MLSetCopyMode = function(sectionId, mode) {
const section = document.getElementById(sectionId);
if (!section) return;
const normalizedMode = ['all', 'active', 'inactive', 'selected'].includes(mode) ? mode : 'all';
if (normalizedMode !== 'selected') {
section.dataset.mlPreviousMode = normalizedMode;
}
const codeEl = section.querySelector('.mr-copy-code');
const labelEl = section.querySelector('.mr-copy-label');
const metaEl = section.querySelector('.mr-copy-meta');
const dataKey = normalizedMode === 'all'
? 'mlAll'
: normalizedMode === 'active'
? 'mlActive'
: normalizedMode === 'inactive'
? 'mlInactive'
: 'mlSelected';
const countKey = normalizedMode === 'all'
? 'mlAllCount'
: normalizedMode === 'active'
? 'mlActiveCount'
: normalizedMode === 'inactive'
? 'mlInactiveCount'
: 'mlSelectedCount';
const labelMap = {
all: 'Copy all',
active: 'Copy active',
inactive: 'Copy inactive',
selected: 'Copy selected'
};
if (codeEl) codeEl.textContent = section.dataset[dataKey] || '';
if (labelEl) labelEl.textContent = labelMap[normalizedMode];
if (metaEl) {
const count = Number(section.dataset[countKey] || 0);
metaEl.textContent = `${count} token${count === 1 ? '' : 's'}`;
}
section.querySelectorAll('.mr-copy-mode').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mlCopyMode === normalizedMode);
});
};
window.MLSetCopyButtonFeedback = function(btn, text) {
if (!btn) return;
btn.textContent = text;
};
return { window, document };
}
function createLoadedModelsMarkup(copySectionId = 'mr-copy-test') {
return `
<div class="mr-loaded-models-header">
<h3 class="mr-loaded-models-title">Loaded Models</h3>
</div>
<div class="mr-models-list">
<div class="mr-model-section" data-ml-category="checkpoints">
<div class="mr-model-group mr-model-group-active">
<div class="mr-model-chip-list">
<span class="mr-model-chip" data-ml-token="<checkpoint:base_model:1.00>" data-ml-loaded-model-key="key-ckpt-1">base_model</span>
</div>
</div>
</div>
<div class="mr-model-section" data-ml-category="loras">
<div class="mr-model-group mr-model-group-active">
<div class="mr-model-chip-list">
<span class="mr-model-chip" data-ml-token="<lora:lora_a:0.80>" data-ml-loaded-model-key="key-lora-1">lora_a<span class="mr-model-chip-strength">0.80</span></span>
<span class="mr-model-chip" data-ml-token="<lora:lora_b:1.00>" data-ml-loaded-model-key="key-lora-2">lora_b<span class="mr-model-chip-strength">1.00</span></span>
<span class="mr-model-chip" data-ml-token="<lora:lora_c:0.50>" data-ml-loaded-model-key="key-lora-3">lora_c<span class="mr-model-chip-strength">0.50</span></span>
</div>
</div>
<div class="mr-model-group mr-model-group-inactive">
<div class="mr-model-chip-list mr-model-chip-list-inactive">
<span class="mr-model-chip" data-ml-token="<lora:lora_d:1.00>" data-ml-loaded-model-key="key-lora-4">lora_d<span class="mr-model-chip-strength">1.00</span></span>
</div>
</div>
</div>
</div>
<div id="${copySectionId}" class="mr-copy-section" data-ml-active="<checkpoint:base_model:1.00> <lora:lora_a:0.80> <lora:lora_b:1.00> <lora:lora_c:0.50>" data-ml-inactive="<lora:lora_d:1.00>" data-ml-all="<checkpoint:base_model:1.00> <lora:lora_a:0.80> <lora:lora_b:1.00> <lora:lora_c:0.50> <lora:lora_d:1.00>" data-ml-selected="" data-ml-active-count="4" data-ml-inactive-count="1" data-ml-all-count="5" data-ml-selected-count="0" data-ml-previous-mode="all">
<div class="mr-copy-toolbar">
<div class="mr-copy-heading">
<div class="mr-copy-label" id="${copySectionId}-label">Copy all</div>
<div class="mr-copy-meta" id="${copySectionId}-meta">5 tokens</div>
</div>
<div class="mr-copy-actions">
<button type="button" class="mr-btn-filter mr-copy-mode active" data-ml-copy-mode="all" onclick="window.MLSetCopyMode('${copySectionId}', 'all', this)">All</button>
<button type="button" class="mr-btn-filter mr-copy-mode" data-ml-copy-mode="active" onclick="window.MLSetCopyMode('${copySectionId}', 'active', this)">Active</button>
<button type="button" class="mr-btn-filter mr-copy-mode" data-ml-copy-mode="inactive" onclick="window.MLSetCopyMode('${copySectionId}', 'inactive', this)">Inactive</button>
<button type="button" class="mr-btn-filter mr-copy-mode mr-copy-mode-selected" data-ml-copy-mode="selected" style="display: none;" onclick="window.MLSetCopyMode('${copySectionId}', 'selected', this)">Selected (<span class="mr-copy-selected-count">0</span>)</button>
<button class="mr-btn-filter mr-copy-main-btn" onclick="window.MLCopyCode('${copySectionId}', this)">Copy</button>
</div>
</div>
<code class="mr-copy-code" id="${copySectionId}-code"><checkpoint:base_model:1.00> <lora:lora_a:0.80> <lora:lora_b:1.00> <lora:lora_c:0.50> <lora:lora_d:1.00></code>
</div>
`;
}
test('single click selects only clicked chip and switches copy toolbar to selected mode', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
assert.equal(chips[1].classList.contains('is-selected'), true);
assert.equal(chips[0].classList.contains('is-selected'), false);
assert.equal(chips[2].classList.contains('is-selected'), false);
const copySection = container.querySelector('.mr-copy-section');
assert.equal(copySection.dataset.mlSelected, '<lora:lora_a:0.80>');
assert.equal(copySection.dataset.mlSelectedCount, '1');
const selectedBtn = copySection.querySelector('.mr-copy-mode-selected');
assert.equal(selectedBtn.style.display, '');
assert.equal(selectedBtn.classList.contains('active'), true);
const codeEl = copySection.querySelector('.mr-copy-code');
assert.equal(codeEl.textContent, '<lora:lora_a:0.80>');
cleanup();
});
test('ctrl+click adds and toggles chips in multi-selection', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Select first chip with regular click
chips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.deepEqual(getSelectedModelTokens(container), ['<checkpoint:base_model:1.00>']);
// Ctrl+click second chip
chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<checkpoint:base_model:1.00>',
'<lora:lora_b:1.00>',
]);
const copySection = container.querySelector('.mr-copy-section');
assert.equal(copySection.dataset.mlSelected, '<checkpoint:base_model:1.00> <lora:lora_b:1.00>');
assert.equal(copySection.dataset.mlSelectedCount, '2');
// Ctrl+click to toggle off the first chip
chips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.deepEqual(getSelectedModelTokens(container), ['<lora:lora_b:1.00>']);
assert.equal(copySection.dataset.mlSelected, '<lora:lora_b:1.00>');
cleanup();
});
test('shift+click performs range selection from previous anchor', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Click on index 1 (lora_a) as anchor
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.deepEqual(getSelectedModelTokens(container), ['<lora:lora_a:0.80>']);
// Shift+click on index 3 (lora_c) -> range 1..3 selected
chips[3].dispatchEvent(new window.MouseEvent('click', { bubbles: true, shiftKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<lora:lora_a:0.80>',
'<lora:lora_b:1.00>',
'<lora:lora_c:0.50>',
]);
// Shift+click again on index 2 (lora_b) from same anchor -> range 1..2 selected
chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, shiftKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<lora:lora_a:0.80>',
'<lora:lora_b:1.00>',
]);
cleanup();
});
test('ctrl+shift+click expands existing selection with range', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Select index 0 (checkpoint)
chips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
// Click index 2 as anchor with Ctrl
chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<checkpoint:base_model:1.00>',
'<lora:lora_b:1.00>',
]);
// Ctrl+Shift+click index 4 (inactive lora_d) -> adds 2..4 to selection without removing index 0
chips[4].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true, shiftKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<checkpoint:base_model:1.00>',
'<lora:lora_b:1.00>',
'<lora:lora_c:0.50>',
'<lora:lora_d:1.00>',
]);
cleanup();
});
test('clicking blank area deselects all chips and reverts copy mode', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Select a chip
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.equal(getSelectedModelChips(container).length, 1);
// Click on empty list space
const list = container.querySelector('.mr-models-list');
list.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.equal(getSelectedModelChips(container).length, 0);
const copySection = container.querySelector('.mr-copy-section');
assert.equal(copySection.dataset.mlSelectedCount, '0');
assert.equal(copySection.querySelector('.mr-copy-mode-selected').style.display, 'none');
// Reverted back to 'all'
const activeModeBtn = copySection.querySelector('.mr-copy-mode.active');
assert.equal(activeModeBtn.dataset.mlCopyMode, 'all');
cleanup();
});
test('escape key clears selection', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.equal(getSelectedModelChips(container).length, 1);
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
assert.equal(getSelectedModelChips(container).length, 0);
cleanup();
});
test('ctrl+a selects all visible chips', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Make inactive section hidden like MLFilterSwitch('active') does
const inactiveGroup = container.querySelector('.mr-model-group-inactive');
inactiveGroup.style.display = 'none';
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a', ctrlKey: true, bubbles: true }));
const selected = getSelectedModelChips(container);
assert.equal(selected.length, 4); // Only the 4 visible active chips
assert.equal(chips[4].classList.contains('is-selected'), false);
cleanup();
});
test('ctrl+c writes selected tokens to clipboard with feedback', async () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
let writtenText = '';
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
writable: true,
value: {
clipboard: {
writeText: async (text) => {
writtenText = text;
}
}
}
});
const notifications = [];
const dialog = {
activeTab: 'loaded',
showNotification: (msg, type) => notifications.push({ msg, type })
};
const cleanup = initLoadedModelSelection(container, dialog);
const chips = container.querySelectorAll('.mr-model-chip');
// Select two chips
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
// Press Ctrl+C
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true }));
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(writtenText, '<lora:lora_a:0.80> <lora:lora_b:1.00>');
assert.equal(notifications.length, 1);
assert.match(notifications[0].msg, /Copied 2 model tokens to clipboard/);
cleanup();
});
test('displayLoadedModels renders chips with data-ml-token and includes Selected mode button', () => {
const { document } = setupDomEnvironment();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const source = fs.readFileSync(path.join(__dirname, '../web/resolver/views/tabs_loaded_methods.js'), 'utf-8');
function extractMethod(sourceCode, methodName) {
const regex = new RegExp(`\\n\\s+${methodName}\\s*\\(([^)]*\\{[^}]*\\}|[^)]*)\\)\\s*\\{`);
const match = regex.exec(sourceCode);
assert.ok(match, `Could not find ${methodName}`);
const params = match[1];
const braceStart = match.index + match[0].length - 1;
let depth = 0;
for (let i = braceStart; i < sourceCode.length; i += 1) {
if (sourceCode[i] === '{') depth += 1;
if (sourceCode[i] === '}') depth -= 1;
if (depth === 0) {
return `function ${methodName}(${params}) ${sourceCode.slice(braceStart, i + 1)}`;
}
}
throw new Error(`Unmatched braces for ${methodName}`);
}
const displayLoadedModels = eval(`(${extractMethod(source, 'displayLoadedModels')})`);
const container = document.createElement('div');
document.body.appendChild(container);
const dialog = {
getModelNameAndStrength: model => ({
name: model.name,
strength: model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null
}),
getFilenameFromPath: pathVal => pathVal,
stripModelExtension: name => name,
getCategoryTokenName: cat => cat.replace(/s$/, ''),
getModelToken: (model, cat) => {
const str = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : '1.00';
return `<${cat.replace(/s$/, '')}:${model.name}:${str}>`;
},
getLoadedModelDomKey: model => model.name,
getLoadedModelContext: model => model,
getContextMenuAttrs: () => '',
getModelPreviewTooltipAttrs: () => '',
getCategoryDisplayName: cat => cat,
escapeHtml: str => String(str),
escapeJsString: str => JSON.stringify(str),
bindLoadedModelSelection: () => {}
};
displayLoadedModels.call(dialog, container, {
loaded_models: [
{ category: 'checkpoints', name: 'model_1', active: true, connected: true },
{ category: 'loras', name: 'lora_1', strength: 0.8, active: true, connected: true },
{ category: 'loras', name: 'lora_2', strength: 1.0, active: false, connected: true },
],
total: 3
});
const chips = container.querySelectorAll('.mr-model-chip');
assert.equal(chips.length, 3);
assert.equal(chips[0].getAttribute('data-ml-token'), '<checkpoint:model_1:1.00>');
assert.equal(chips[1].getAttribute('data-ml-token'), '<lora:lora_1:0.80>');
assert.equal(chips[2].getAttribute('data-ml-token'), '<lora:lora_2:1.00>');
const selectedBtn = container.querySelector('.mr-copy-mode-selected');
assert.ok(selectedBtn, 'Selected mode button should be present in DOM');
assert.equal(selectedBtn.getAttribute('data-ml-copy-mode'), 'selected');
});
test('ctrl+click works reliably in metadata tab even when loaded tab was previously bound', () => {
const { document } = setupDomEnvironment();
const contentElement = document.createElement('div');
contentElement.id = 'mr-content-element';
document.body.appendChild(contentElement);
const dialog = {
activeTab: 'loaded',
contentElement,
_mlSelectionCleanup: null,
bindLoadedModelSelection(container) {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
const cleanup = initLoadedModelSelection(container, this);
this._mlSelectionCleanup = cleanup;
return cleanup;
}
};
// 1. User was first on "Loaded Models" tab
contentElement.innerHTML = createLoadedModelsMarkup('mr-copy-loaded');
dialog.bindLoadedModelSelection(contentElement);
// 2. User switches to "Metadata" tab
dialog.activeTab = 'metadata';
const metadataShell = document.createElement('div');
metadataShell.className = 'mr-image-inspector';
const metadataResult = document.createElement('div');
metadataResult.setAttribute('data-image-inspector-result', '');
const modelContainer = document.createElement('div');
modelContainer.id = 'mr-image-inspector-models-12345';
modelContainer.innerHTML = createLoadedModelsMarkup('mr-copy-metadata');
metadataResult.appendChild(modelContainer);
metadataShell.appendChild(metadataResult);
contentElement.appendChild(metadataShell);
dialog.bindLoadedModelSelection(modelContainer);
// 3. User Ctrl+clicks chips in Metadata tab
const metaChips = modelContainer.querySelectorAll('.mr-model-chip');
assert.equal(metaChips.length, 5);
// Ctrl+click first chip
metaChips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.equal(metaChips[0].classList.contains('is-selected'), true, 'First chip must remain selected after Ctrl+click in metadata tab');
assert.deepEqual(getSelectedModelTokens(modelContainer), ['<checkpoint:base_model:1.00>']);
// Ctrl+click second chip
metaChips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.equal(metaChips[1].classList.contains('is-selected'), true, 'Second chip must remain selected after Ctrl+click');
assert.deepEqual(getSelectedModelTokens(modelContainer), [
'<checkpoint:base_model:1.00>',
'<lora:lora_a:0.80>'
]);
// Ctrl+click first chip again to toggle it off
metaChips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.equal(metaChips[0].classList.contains('is-selected'), false, 'First chip must be toggled off');
assert.deepEqual(getSelectedModelTokens(modelContainer), ['<lora:lora_a:0.80>']);
const copySection = modelContainer.querySelector('.mr-copy-section');
assert.equal(copySection.dataset.mlSelected, '<lora:lora_a:0.80>');
assert.equal(copySection.dataset.mlSelectedCount, '1');
dialog._mlSelectionCleanup?.();
});
test('chip click event propagation is stopped to prevent parent container interference', () => {
const { document } = setupDomEnvironment();
const parentContainer = document.createElement('div');
const childContainer = document.createElement('div');
childContainer.innerHTML = createLoadedModelsMarkup('mr-copy-nested');
parentContainer.appendChild(childContainer);
document.body.appendChild(parentContainer);
let parentClickFired = false;
parentContainer.addEventListener('click', () => {
parentClickFired = true;
});
const cleanup = initLoadedModelSelection(childContainer);
const chip = childContainer.querySelector('.mr-model-chip');
chip.dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.equal(chip.classList.contains('is-selected'), true);
assert.equal(parentClickFired, false, 'Parent container should not receive click event due to stopPropagation');
cleanup();
});