124 lines
7.4 KiB
JavaScript
124 lines
7.4 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { fixture, all } from './ui_fixture.mjs';
|
||
|
||
const f = fixture();
|
||
f.app.canvas.setDirty = () => {};
|
||
const hub = await f.module('ui_model_sources.js');
|
||
const node = (id, type, name, value, values) => ({ id, type, widgets: [{ name, value, options: { values } }] });
|
||
f.app.graph._nodes = [
|
||
node(1, 'DualCLIPLoader', 'clip_name1', 'encoders/t5.gguf', ['encoders\\t5.gguf']),
|
||
node(2, 'CLIPLoader', 'clip_name', 'clip.pth', () => ['clip.pth']),
|
||
node(3, 'VAELoader', 'vae_name', 'taesd', ['taesd']),
|
||
node(4, 'VAELoader', 'vae_name', 'missing.safetensors', ['available.safetensors']),
|
||
node(5, 'CLIPVisionLoader', 'clip_name', 'vision.safetensors', ['vision.safetensors']),
|
||
node(6, 'DualCLIPLoader', 'device', 'cpu', ['cpu', 'cuda']),
|
||
node(7, 'VAELoader', 'vae_name', 'None', ['None']),
|
||
node(8, 'VAELoader', 'vae_name', 'uninstalled.safetensors', []),
|
||
];
|
||
f.app.graph.extra = {};
|
||
let entries = hub.collectWorkflowModels();
|
||
assert.equal(entries.length, 6, 'Include components without requiring a source URL or hash; skip non-model controls');
|
||
assert.equal(entries[0].isMissing, false, 'Native slash variants are not missing files');
|
||
assert.equal(entries[1].isMissing, false, 'Dynamic choices are not proof of a missing file');
|
||
assert.equal(entries[3].isMissing, true);
|
||
assert.equal(entries[5].isMissing, true, 'An empty native file list still identifies an unavailable selected model');
|
||
assert.deepEqual([...entries[4].folderTypes], ['clip_vision']);
|
||
|
||
f.fetch = async (url, options) => {
|
||
assert.equal(url, '/anomalous/resolve_paths_to_previews', 'Opening only reads local metadata');
|
||
const body = JSON.parse(options.body);
|
||
const context_models = {};
|
||
for (const request of body.context_requests || []) {
|
||
assert.equal(request.exact_only, true);
|
||
assert.ok(request.folder_types.length);
|
||
if (request.path === 'vision.safetensors') context_models[request.key] = {
|
||
type: 'clip_vision', subfolder: '', path_idx: 1, file_path: 'E:/fixtures/vision.safetensors',
|
||
metadata: { source_url: 'https://huggingface.co/example/vision' },
|
||
};
|
||
}
|
||
return { ok: true, json: async () => ({ context_models, models: {
|
||
// A same-named checkpoint must not be used for a VAE.
|
||
'missing.safetensors': { type: 'checkpoints', metadata: { source_url: 'https://example.com/wrong' } },
|
||
} }) };
|
||
};
|
||
await hub.resolveWorkflowModelsMetadata(entries);
|
||
assert.equal(entries[3].url, '');
|
||
assert.equal(entries[4].url, 'https://huggingface.co/example/vision');
|
||
assert.equal(entries[4].path_idx, 1);
|
||
assert.equal(await hub.autoDetectModelSource(entries[3]), '', 'Unknown component stays unresolved; no cloud search URL is treated as a source');
|
||
await assert.rejects(() => hub.saveSingleModelToLocalSidecar(entries[2], 'https://example.com/taesd'));
|
||
entries[2].url = 'https://example.com/taesd';
|
||
hub.syncWorkflowSources(entries);
|
||
assert.equal(f.app.graph.extra.anomalous_model_sources.taesd.url, entries[2].url, 'Unresolved local options can still carry workflow links');
|
||
|
||
const many = Array.from({ length: 19 }, (_, i) => ({ ...entries[4], key: `component-${i}` }));
|
||
const start = f.requests.length;
|
||
await hub.resolveWorkflowModelsMetadata(many);
|
||
const batches = f.requests.slice(start).map(([, o]) => JSON.parse(o.body)).filter(b => b.context_requests);
|
||
assert.deepEqual(batches.map(b => b.context_requests.length), [16, 3]);
|
||
await hub.copySourcesSummary([{ ...entries[2], url: '' }]);
|
||
assert.ok(f.clipboard.at(-1).includes('Not provided (optional)'));
|
||
assert.ok(!f.clipboard.at(-1).includes('⚠️'));
|
||
|
||
// Exercise the actual UI renderer, including manual entry and both status badges.
|
||
f.app.graph.extra = {};
|
||
const close = hub.openModelSourcesModal('workflow');
|
||
await f.flush();
|
||
assert.equal(f.document.body.querySelectorAll('.anomalous-source-row-item').length, 0, 'Collapsed components do not build row DOM');
|
||
let disclosure = f.document.body.querySelector('.anomalous-sources-components-toggle');
|
||
assert.ok(disclosure.textContent.includes('Foundation components (advanced) · 6'));
|
||
assert.ok(disclosure.textContent.includes('Missing local files × 2'));
|
||
assert.equal(disclosure.getAttribute('aria-expanded'), 'false');
|
||
disclosure.click();
|
||
let rows = f.document.body.querySelectorAll('.anomalous-source-row-item');
|
||
assert.equal(rows.length, 6);
|
||
assert.ok(rows[0].textContent.includes('Text encoder'));
|
||
assert.ok(rows[4].textContent.includes('Vision encoder'));
|
||
const missing = rows[3];
|
||
assert.ok(missing.querySelector('.anomalous-source-badge-danger'));
|
||
const pending = all(missing).find(el => el.textContent === 'Not provided (optional)' && el.className === 'anomalous-source-badge-neutral');
|
||
assert.equal(pending.hidden, false);
|
||
const input = missing.querySelector('input');
|
||
assert.equal(input.readOnly, false);
|
||
input.value = 'https://example.com/vae';
|
||
input.oninput();
|
||
assert.equal(pending.hidden, true, 'Typing a source removes the pending-source hint without hiding missing-file warning');
|
||
assert.ok(missing.querySelector('.anomalous-source-badge-danger'));
|
||
assert.equal(rows[4].querySelector('input').readOnly, true, 'Resolved source is preserved');
|
||
|
||
// Source filters affect main models only; search affects component rows and disclosure count.
|
||
f.button(f.document.body, 'Source provided').click();
|
||
assert.equal(f.document.body.querySelectorAll('.anomalous-source-row-item').length, 6);
|
||
let search = f.document.body.querySelector('.anomalous-sources-search-input');
|
||
search.value = 'vision';
|
||
search.oninput();
|
||
rows = f.document.body.querySelectorAll('.anomalous-source-row-item');
|
||
assert.equal(rows.length, 1);
|
||
disclosure = f.document.body.querySelector('.anomalous-sources-components-toggle');
|
||
assert.ok(disclosure.textContent.includes('Foundation components (advanced) · 1'));
|
||
assert.ok(disclosure.textContent.includes('Missing local files × 2'), 'Missing warning ignores search and source filters');
|
||
await f.button(f.document.body, 'Copy Visible Range').click();
|
||
assert.ok(f.clipboard.at(-1).includes('vision.safetensors'));
|
||
assert.ok(!f.clipboard.at(-1).includes('t5.gguf'), 'Summary exports only the currently visible range');
|
||
f.button(f.document.body, 'Save to Workflow').click();
|
||
assert.equal(f.app.graph.extra.anomalous_model_sources['missing.safetensors'].url, 'https://example.com/vae');
|
||
assert.equal(f.app.graph.extra.anomalous_model_sources['vision.safetensors'].url, 'https://huggingface.co/example/vision', 'Filtered save retains full workflow semantics');
|
||
assert.equal(f.document.body.querySelectorAll('.anomalous-source-row-item').length, 1, 'Save refresh preserves expansion and search');
|
||
close();
|
||
assert.equal(f.document.body.querySelector('.anomalous-model-sources-overlay'), null);
|
||
const closeReopened = hub.openModelSourcesModal('workflow');
|
||
assert.equal(f.document.body.querySelectorAll('.anomalous-source-row-item').length, 0, 'A new modal session starts collapsed');
|
||
closeReopened();
|
||
let saved = null;
|
||
f.fetch = async (url, options) => {
|
||
assert.equal(url, '/anomalous/update_metadata');
|
||
saved = JSON.parse(options.body);
|
||
return { ok: true, json: async () => ({ status: 'success' }) };
|
||
};
|
||
await hub.saveSingleModelToLocalSidecar(entries[4], 'https://example.com/vision');
|
||
assert.equal(saved.type, 'clip_vision');
|
||
assert.equal(saved.path_idx, 1);
|
||
assert.equal(saved.filename, 'vision.safetensors');
|
||
assert.equal(saved.custom_source_url, 'https://example.com/vision');
|
||
console.log('PASS component collection, category-scoped lookup, manual workflow links, batching and independent UI status.');
|