745 lines
25 KiB
JavaScript
745 lines
25 KiB
JavaScript
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { imageMetadataMethods } from '../web/resolver/views/tabs_image_metadata_methods.js';
|
|
|
|
const modelInfoMethodsSource = readFileSync(
|
|
new URL('../web/resolver/views/model_info_methods.js', import.meta.url),
|
|
'utf8'
|
|
);
|
|
const imageMetadataMethodsSource = readFileSync(
|
|
new URL('../web/resolver/views/tabs_image_metadata_methods.js', import.meta.url),
|
|
'utf8'
|
|
);
|
|
|
|
function extractMethod(source, methodName, paramsPattern = '[^)]*') {
|
|
const signatureRegex = new RegExp(`\\n\\s+(async\\s+)?${methodName}\\s*\\(${paramsPattern}\\)\\s*\\{`);
|
|
const match = signatureRegex.exec(source);
|
|
assert.ok(match, `Could not find ${methodName}`);
|
|
const isAsync = Boolean(match[1]);
|
|
const parenStart = source.indexOf('(', match.index);
|
|
const parenEnd = source.indexOf(')', parenStart);
|
|
const params = source.slice(parenStart + 1, parenEnd);
|
|
const braceStart = source.indexOf('{', parenEnd);
|
|
let depth = 0;
|
|
for (let index = braceStart; index < source.length; index += 1) {
|
|
const char = source[index];
|
|
if (char === '{') depth += 1;
|
|
if (char === '}') depth -= 1;
|
|
if (depth === 0) {
|
|
return `${isAsync ? 'async ' : ''}function ${methodName}(${params}) ${source.slice(braceStart, index + 1)}`;
|
|
}
|
|
}
|
|
throw new Error(`Could not parse ${methodName}`);
|
|
}
|
|
|
|
function createTransferContext(catalogValues = ['clip.safetensors']) {
|
|
return {
|
|
...imageMetadataMethods,
|
|
getMissingAcceptedModelFileTypes: ({ node_type: nodeType, widget_name: widgetName }) => (
|
|
nodeType === 'CLIPLoader' && widgetName === 'clip_name'
|
|
? catalogValues
|
|
.map(value => {
|
|
const match = String(value || '').match(/\.([a-z0-9]+)$/i);
|
|
return match
|
|
? { extension: match[1].toLowerCase(), display: `.${match[1].toLowerCase()}` }
|
|
: null;
|
|
})
|
|
.filter(Boolean)
|
|
: []
|
|
),
|
|
getModelFileTypeInfo: value => {
|
|
const match = String(value || '').match(/\.([a-z0-9]+)$/i);
|
|
return match ? { extension: match[1].toLowerCase() } : null;
|
|
},
|
|
getComfyWidgetNameByIndex: () => 'clip_name',
|
|
getCurrentComfyCatalogValues: (nodeType, widgetName) => (
|
|
nodeType === 'CLIPLoader' && widgetName === 'clip_name'
|
|
? catalogValues
|
|
: null
|
|
),
|
|
escapeHtml: value => String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, '''),
|
|
getFilenameFromPath: value => String(value).split(/[\\/]/).pop(),
|
|
stripModelExtension: value => String(value).replace(/\.[^.]+$/, ''),
|
|
getContextMenuAttrs: model => model?.context_scope
|
|
? ` data-context-scope="${model.context_scope}"`
|
|
: '',
|
|
getModelPreviewTooltipAttrs: model => model?.exists === true && model?.resolved_path
|
|
? ` data-tooltip-image="${model.resolved_path}"`
|
|
: '',
|
|
};
|
|
}
|
|
|
|
function createTransferState(sourceModels) {
|
|
const targetGroups = [{
|
|
category: 'text_encoders',
|
|
nodes: [{
|
|
nodeKey: 'top:1',
|
|
nodeId: 1,
|
|
nodeType: 'CLIPLoader',
|
|
refs: [{
|
|
category: 'text_encoders',
|
|
node_id: 1,
|
|
node_type: 'CLIPLoader',
|
|
widget_index: 0,
|
|
widget_name: 'clip_name',
|
|
original_path: 'current.safetensors',
|
|
transferKey: 'text_encoders:top:1:0',
|
|
}],
|
|
}],
|
|
}];
|
|
|
|
return {
|
|
loadedModels: { loaded_models: sourceModels },
|
|
transfer: {
|
|
mode: 'replace',
|
|
targetGroups,
|
|
selectedNodeKeys: new Set(['top:1']),
|
|
sourceSelections: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createLoraTransferState(sourceModels, activityScope = 'all') {
|
|
const targetGroups = [{
|
|
category: 'loras',
|
|
nodes: [{
|
|
nodeKey: 'top:2',
|
|
nodeId: 2,
|
|
nodeType: 'LoraLoaderV2',
|
|
refs: [
|
|
{
|
|
category: 'loras',
|
|
node_id: 2,
|
|
node_type: 'LoraLoaderV2',
|
|
widget_index: 2,
|
|
original_path: 'active-current.safetensors',
|
|
active: true,
|
|
transferKey: 'loras:top:2:0',
|
|
},
|
|
{
|
|
category: 'loras',
|
|
node_id: 2,
|
|
node_type: 'LoraLoaderV2',
|
|
widget_index: 2,
|
|
original_path: 'inactive-current.safetensors',
|
|
active: false,
|
|
transferKey: 'loras:top:2:1',
|
|
},
|
|
],
|
|
}],
|
|
}];
|
|
|
|
return {
|
|
loadedModels: { loaded_models: sourceModels },
|
|
transfer: {
|
|
mode: 'replace-add',
|
|
activityScope,
|
|
targetGroups,
|
|
selectedNodeKeys: new Set(['top:2']),
|
|
sourceSelections: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
test('metadata transfer persists only mode and activity scope preferences', () => {
|
|
const context = createTransferContext();
|
|
|
|
assert.deepEqual(context.getMetadataTransferPreferences(), {
|
|
mode: 'replace',
|
|
activityScope: 'all',
|
|
});
|
|
|
|
assert.equal(context.setMetadataTransferMode('merge'), 'merge');
|
|
assert.equal(context.setMetadataTransferActivityScope('inactive'), 'inactive');
|
|
assert.deepEqual(context.getMetadataTransferPreferences(), {
|
|
mode: 'merge',
|
|
activityScope: 'inactive',
|
|
});
|
|
});
|
|
|
|
test('metadata transfer refresh updates only the transfer panel when it is mounted', () => {
|
|
const state = createTransferState([
|
|
{ category: 'text_encoders', original_path: 'imported.safetensors' },
|
|
]);
|
|
const currentPanel = { outerHTML: '<section class="mr-image-transfer">old</section>' };
|
|
const result = {
|
|
querySelector(selector) {
|
|
assert.equal(selector, '.mr-image-transfer');
|
|
return currentPanel;
|
|
},
|
|
};
|
|
let fullRenderCount = 0;
|
|
let tooltipBindCount = 0;
|
|
const context = {
|
|
...imageMetadataMethods,
|
|
imageInspectorState: state,
|
|
contentElement: {
|
|
querySelector(selector) {
|
|
assert.equal(selector, '[data-image-inspector-result]');
|
|
return result;
|
|
},
|
|
},
|
|
renderMetadataTransferPanel: () => '<section class="mr-image-transfer">updated</section>',
|
|
renderImageMetadataResult() {
|
|
fullRenderCount += 1;
|
|
},
|
|
bindTooltips() {
|
|
tooltipBindCount += 1;
|
|
},
|
|
};
|
|
|
|
assert.equal(context.renderMetadataTransferPanelInPlace(), true);
|
|
assert.equal(currentPanel.outerHTML, '<section class="mr-image-transfer">updated</section>');
|
|
assert.equal(fullRenderCount, 0);
|
|
assert.equal(tooltipBindCount, 1);
|
|
});
|
|
|
|
test('metadata transfer shows a spinner while applying', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([
|
|
{ category: 'text_encoders', original_path: 'imported.safetensors' },
|
|
]);
|
|
state.transfer.applying = true;
|
|
|
|
const html = context.renderMetadataTransferPanel(state);
|
|
|
|
assert.match(html, /aria-busy="true"/);
|
|
assert.match(html, /class="mr-image-transfer-action-spinner" aria-hidden="true"/);
|
|
assert.match(html, /<span>Applying\.\.\.<\/span>/);
|
|
});
|
|
|
|
test('metadata transfer guidance is available through a question tooltip', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([]);
|
|
state.loadedModels = null;
|
|
|
|
const html = context.renderMetadataTransferPanel(state);
|
|
|
|
assert.match(html, /class="mr-tooltip-badge"[^>]*data-tooltip="[\s\S]*Select a node row to include it in the transfer/);
|
|
assert.match(html, /Import a workflow with model metadata to enable Apply transfer/);
|
|
assert.doesNotMatch(html, /class="mr-image-transfer-note"/);
|
|
});
|
|
|
|
test('metadata transfer keeps target node checkboxes selectable without an imported workflow', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([]);
|
|
state.loadedModels = null;
|
|
state.transfer.selectedNodeKeys = new Set();
|
|
let renderCount = 0;
|
|
context.imageInspectorState = state;
|
|
context.renderMetadataTransferPanelInPlace = () => {
|
|
renderCount += 1;
|
|
return true;
|
|
};
|
|
|
|
const html = context.renderMetadataTransferPanel(state);
|
|
|
|
assert.match(html, /data-image-transfer-node="top:1"/);
|
|
const targetButton = html.match(/<button[^>]*data-image-transfer-node="top:1"[^>]*>/)?.[0] || '';
|
|
assert.doesNotMatch(targetButton, /\sdisabled(?:\s|=|>)/);
|
|
context.toggleMetadataTransferNode('top:1');
|
|
assert.deepEqual(Array.from(state.transfer.selectedNodeKeys), ['top:1']);
|
|
assert.equal(renderCount, 1);
|
|
});
|
|
|
|
test('metadata transfer does not disable a target category missing from the imported workflow', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([
|
|
{ category: 'checkpoints', original_path: 'imported-checkpoint.safetensors' },
|
|
]);
|
|
|
|
const html = context.renderMetadataTransferPanel(state);
|
|
|
|
assert.match(html, /No source model/);
|
|
const targetButton = html.match(/<button[^>]*data-image-transfer-node="top:1"[^>]*>/)?.[0] || '';
|
|
assert.doesNotMatch(targetButton, /\sdisabled(?:\s|=|>)/);
|
|
});
|
|
|
|
test('metadata transfer exposes Undo transfer while the post-transfer workflow is unchanged', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([
|
|
{ category: 'text_encoders', original_path: 'imported.safetensors' },
|
|
]);
|
|
state.lastTransfer = {
|
|
workflow: { signature: 'before' },
|
|
afterSignature: 'after',
|
|
undoing: false,
|
|
};
|
|
context.imageInspectorState = state;
|
|
context.getCurrentWorkflow = () => ({ signature: 'after' });
|
|
context.getWorkflowSignature = workflow => workflow.signature;
|
|
|
|
const html = context.renderMetadataTransferPanel(state);
|
|
|
|
assert.match(html, /data-image-inspector-action="undo-transfer"/);
|
|
assert.match(html, />\s*Undo transfer\s*<\/button>/);
|
|
|
|
context.getCurrentWorkflow = () => ({ signature: 'changed' });
|
|
const changedHtml = context.renderMetadataTransferPanel(state);
|
|
assert.match(changedHtml, /data-image-inspector-action="undo-transfer" disabled/);
|
|
assert.match(changedHtml, /title="The current workflow changed after the last transfer\."/);
|
|
assert.match(changedHtml, />\s*Undo transfer\s*<\/button>/);
|
|
});
|
|
|
|
test('metadata transfer undo restores the previous workflow and consumes its history', async () => {
|
|
const previousWorkflow = {
|
|
signature: 'before',
|
|
nodes: [{ id: 1, widgets_values: ['old-model.safetensors'] }],
|
|
};
|
|
let currentWorkflow = {
|
|
signature: 'after',
|
|
nodes: [{ id: 1, widgets_values: ['new-model.safetensors'] }],
|
|
};
|
|
let restoredWorkflow = null;
|
|
const notifications = [];
|
|
const state = {
|
|
transfer: { undoing: false },
|
|
lastTransfer: {
|
|
workflow: previousWorkflow,
|
|
beforeSignature: 'before',
|
|
afterSignature: 'after',
|
|
undoing: false,
|
|
},
|
|
};
|
|
const context = {
|
|
...imageMetadataMethods,
|
|
imageInspectorState: state,
|
|
getCurrentWorkflow: () => currentWorkflow,
|
|
getWorkflowSignature: workflow => workflow.signature,
|
|
updateWorkflowInComfyUI: async workflow => {
|
|
restoredWorkflow = workflow;
|
|
currentWorkflow = workflow;
|
|
return true;
|
|
},
|
|
renderImageMetadataResult() {},
|
|
openMetadataTransfer() {
|
|
state.transfer = { undoing: false };
|
|
},
|
|
showNotification(message, type) {
|
|
notifications.push({ message, type });
|
|
},
|
|
cachedLoadedModelsSignature: 'after',
|
|
cachedLoadedModelsData: { loaded_models: [] },
|
|
activeWorkflowSignature: 'after',
|
|
};
|
|
const previousWindow = globalThis.window;
|
|
globalThis.window = { dispatchEvent() {} };
|
|
|
|
try {
|
|
await context.undoMetadataTransfer();
|
|
} finally {
|
|
if (previousWindow === undefined) delete globalThis.window;
|
|
else globalThis.window = previousWindow;
|
|
}
|
|
|
|
assert.deepEqual(restoredWorkflow, previousWorkflow);
|
|
assert.notEqual(restoredWorkflow, previousWorkflow);
|
|
assert.equal(context.imageInspectorState.lastTransfer, null);
|
|
assert.equal(context.imageInspectorState.transfer.undoing, false);
|
|
assert.equal(context.activeWorkflowSignature, 'before');
|
|
assert.equal(context.cachedLoadedModelsSignature, null);
|
|
assert.equal(context.cachedLoadedModelsData, null);
|
|
assert.deepEqual(notifications, [{
|
|
message: 'Undid the last model transfer.',
|
|
type: 'success',
|
|
}]);
|
|
});
|
|
|
|
test('metadata transfer undo refuses to restore a changed workflow', async () => {
|
|
const state = {
|
|
lastTransfer: {
|
|
workflow: { signature: 'before' },
|
|
afterSignature: 'after',
|
|
undoing: false,
|
|
},
|
|
};
|
|
let updateCalled = false;
|
|
const notifications = [];
|
|
const context = {
|
|
...imageMetadataMethods,
|
|
imageInspectorState: state,
|
|
getCurrentWorkflow: () => ({ signature: 'changed' }),
|
|
getWorkflowSignature: workflow => workflow.signature,
|
|
updateWorkflowInComfyUI: async () => {
|
|
updateCalled = true;
|
|
return true;
|
|
},
|
|
renderImageMetadataResult() {},
|
|
showNotification(message, type) {
|
|
notifications.push({ message, type });
|
|
},
|
|
};
|
|
|
|
const result = await context.undoMetadataTransfer();
|
|
|
|
assert.equal(result, null);
|
|
assert.equal(updateCalled, false);
|
|
assert.equal(state.lastTransfer.undoing, false);
|
|
assert.deepEqual(notifications, [{
|
|
message: 'The current workflow changed after the last transfer.',
|
|
type: 'warning',
|
|
}]);
|
|
});
|
|
|
|
test('metadata tab reuses transfer state for the unchanged workflow', () => {
|
|
const context = createTransferContext();
|
|
context.getWorkflowSignature = workflow => workflow.signature;
|
|
const state = { transfer: { workflowSignature: 'workflow-a' } };
|
|
|
|
assert.equal(
|
|
context.isMetadataTransferCurrentWorkflow(state, { signature: 'workflow-a' }),
|
|
true
|
|
);
|
|
assert.equal(
|
|
context.isMetadataTransferCurrentWorkflow(state, { signature: 'workflow-b' }),
|
|
false
|
|
);
|
|
assert.equal(
|
|
context.isMetadataTransferCurrentWorkflow({ transfer: { workflowSignature: '' } }, null),
|
|
true
|
|
);
|
|
assert.equal(context.isMetadataTransferCurrentWorkflow(null, null), false);
|
|
});
|
|
|
|
test('metadata transfer reuses the shared Loaded Models cache for the same workflow', async () => {
|
|
const cachedData = {
|
|
loaded_models: [{
|
|
category: 'text_encoders',
|
|
node_id: 1,
|
|
node_type: 'CLIPLoader',
|
|
widget_index: 0,
|
|
original_path: 'current.safetensors',
|
|
active: true,
|
|
connected: true,
|
|
is_top_level: true,
|
|
}],
|
|
total: 1,
|
|
};
|
|
let fetchCount = 0;
|
|
const context = {
|
|
...imageMetadataMethods,
|
|
getCurrentWorkflow: () => ({ signature: 'workflow-a' }),
|
|
getWorkflowSignature: workflow => workflow.signature,
|
|
cachedLoadedModelsSignature: 'workflow-a',
|
|
cachedLoadedModelsData: cachedData,
|
|
getCachedLoadedModelsForSignature(signature, { force = false } = {}) {
|
|
return !force && signature === this.cachedLoadedModelsSignature
|
|
? this.cachedLoadedModelsData
|
|
: null;
|
|
},
|
|
fetchJson: async () => {
|
|
fetchCount += 1;
|
|
throw new Error('The shared cache was not used');
|
|
},
|
|
renderImageMetadataResult() {},
|
|
};
|
|
|
|
const result = await context.openMetadataTransfer();
|
|
|
|
assert.equal(fetchCount, 0);
|
|
assert.equal(result, cachedData);
|
|
assert.equal(context.imageInspectorState.transfer.targetModels, cachedData);
|
|
});
|
|
|
|
test('metadata transfer stores a fresh target scan in the shared Loaded Models cache', async () => {
|
|
const freshData = { loaded_models: [], total: 0 };
|
|
let saveCount = 0;
|
|
const context = {
|
|
...imageMetadataMethods,
|
|
getCurrentWorkflow: () => ({ signature: 'workflow-b' }),
|
|
getWorkflowSignature: workflow => workflow.signature,
|
|
cachedLoadedModelsSignature: null,
|
|
cachedLoadedModelsData: null,
|
|
getCachedLoadedModelsForSignature() {
|
|
return null;
|
|
},
|
|
fetchJson: async () => freshData,
|
|
saveLoadedModelsCacheForActiveWorkflow() {
|
|
saveCount += 1;
|
|
},
|
|
renderImageMetadataResult() {},
|
|
};
|
|
|
|
const result = await context.openMetadataTransfer();
|
|
|
|
assert.equal(result, freshData);
|
|
assert.equal(context.cachedLoadedModelsSignature, 'workflow-b');
|
|
assert.equal(context.cachedLoadedModelsData, freshData);
|
|
assert.equal(saveCount, 1);
|
|
});
|
|
|
|
test('metadata transfer refresh keeps the current panel visible while scanning a changed workflow', async () => {
|
|
const oldTargetGroup = {
|
|
category: 'text_encoders',
|
|
nodes: [{
|
|
nodeKey: 'top:1',
|
|
nodeId: 1,
|
|
nodeType: 'CLIPLoader',
|
|
refs: [],
|
|
}],
|
|
};
|
|
const sourceModel = {
|
|
category: 'text_encoders',
|
|
original_path: 'imported.safetensors',
|
|
};
|
|
let resolveFetch;
|
|
let renderCount = 0;
|
|
const context = {
|
|
...imageMetadataMethods,
|
|
imageInspectorState: {
|
|
requestToken: 'source-request',
|
|
loadedModels: { loaded_models: [sourceModel] },
|
|
transfer: {
|
|
open: true,
|
|
workflowSignature: 'old-workflow',
|
|
targetModels: { loaded_models: [] },
|
|
targetGroups: [oldTargetGroup],
|
|
selectedNodeKeys: new Set(['top:1']),
|
|
collapsedCategories: new Set(['text_encoders']),
|
|
sourceSelections: {},
|
|
},
|
|
},
|
|
getCurrentWorkflow: () => ({ signature: 'new-workflow' }),
|
|
getWorkflowSignature: workflow => workflow.signature,
|
|
syncWorkflowScopedQueue() {},
|
|
getCachedLoadedModelsForSignature() {
|
|
return null;
|
|
},
|
|
getMetadataTransferAcceptedFileTypes() {
|
|
return [];
|
|
},
|
|
fetchJson: () => new Promise(resolve => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
saveLoadedModelsCacheForActiveWorkflow() {},
|
|
renderImageMetadataResult() {
|
|
renderCount += 1;
|
|
},
|
|
};
|
|
|
|
const refreshPromise = context.openMetadataTransfer({ preserveContent: true });
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
assert.equal(renderCount, 0);
|
|
assert.equal(context.imageInspectorState.transfer.refreshing, true);
|
|
assert.deepEqual(context.imageInspectorState.transfer.targetGroups, [oldTargetGroup]);
|
|
assert.deepEqual(
|
|
Array.from(context.imageInspectorState.transfer.selectedNodeKeys),
|
|
['top:1']
|
|
);
|
|
|
|
resolveFetch({
|
|
loaded_models: [{
|
|
category: 'text_encoders',
|
|
node_id: 1,
|
|
node_type: 'CLIPLoader',
|
|
widget_index: 0,
|
|
original_path: 'current.safetensors',
|
|
active: true,
|
|
connected: true,
|
|
is_top_level: true,
|
|
}],
|
|
total: 1,
|
|
});
|
|
await refreshPromise;
|
|
|
|
assert.equal(renderCount, 1);
|
|
assert.equal(context.imageInspectorState.transfer.refreshing, false);
|
|
assert.equal(context.imageInspectorState.transfer.targetGroups[0].nodes[0].nodeKey, 'top:1');
|
|
assert.deepEqual(
|
|
Array.from(context.imageInspectorState.transfer.selectedNodeKeys),
|
|
['top:1']
|
|
);
|
|
});
|
|
|
|
test('metadata transfer uses the shared canonical category aliases', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([
|
|
{ category: 'clip_gguf', original_path: 'clip.safetensors' },
|
|
]);
|
|
state.transfer.targetGroups[0].category = 'clips';
|
|
state.transfer.targetGroups[0].nodes[0].refs[0].category = 'clips';
|
|
|
|
const [row] = context.getMetadataTransferPreviewRows(state);
|
|
|
|
assert.equal(row.sourceUnavailable, false);
|
|
assert.equal(row.nextValue, 'clip.safetensors');
|
|
});
|
|
|
|
test('metadata transfer hides imported models with an unsupported file extension', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([
|
|
{ category: 'text_encoders', original_path: 'clip.gguf' },
|
|
{ category: 'text_encoders', original_path: 'clip.safetensors' },
|
|
]);
|
|
|
|
const [row] = context.getMetadataTransferPreviewRows(state);
|
|
|
|
assert.deepEqual(row.sourceOptions.map(option => option.value), ['clip.safetensors']);
|
|
assert.equal(row.nextValue, 'clip.safetensors');
|
|
assert.equal(row.sourceUnavailable, false);
|
|
});
|
|
|
|
test('metadata transfer blocks a selected node when no imported model matches its format', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([
|
|
{ category: 'text_encoders', original_path: 'clip.gguf' },
|
|
]);
|
|
|
|
const [row] = context.getMetadataTransferPreviewRows(state);
|
|
|
|
assert.equal(row.sourceOptions.length, 0);
|
|
assert.equal(row.nextLabel, 'No compatible model');
|
|
assert.equal(row.sourceUnavailable, true);
|
|
});
|
|
|
|
test('replace/add preview replaces existing LoRAs and shows remaining models as additions', () => {
|
|
const context = createTransferContext();
|
|
const state = createLoraTransferState([
|
|
{ category: 'loras', original_path: 'new-a.safetensors' },
|
|
{ category: 'loras', original_path: 'new-b.safetensors' },
|
|
{ category: 'loras', original_path: 'new-c.safetensors' },
|
|
]);
|
|
|
|
const rows = context.getMetadataTransferPreviewRows(state);
|
|
|
|
assert.equal(rows.length, 3);
|
|
assert.deepEqual(rows.slice(0, 2).map(row => row.operation), ['replace', 'replace']);
|
|
assert.equal(rows[0].nextValue, 'new-a.safetensors');
|
|
assert.equal(rows[1].nextValue, 'new-b.safetensors');
|
|
assert.equal(rows[2].operation, 'add');
|
|
assert.equal(rows[2].nextValue, 'new-c.safetensors');
|
|
});
|
|
|
|
test('metadata transfer preview adds model actions and previews only for local files', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([
|
|
{
|
|
category: 'text_encoders',
|
|
original_path: 'imported.safetensors',
|
|
exists: true,
|
|
resolved_path: 'C:\\models\\imported.safetensors',
|
|
},
|
|
]);
|
|
state.transfer.targetGroups[0].nodes[0].refs[0] = {
|
|
...state.transfer.targetGroups[0].nodes[0].refs[0],
|
|
exists: true,
|
|
resolved_path: 'C:\\models\\current.safetensors',
|
|
};
|
|
|
|
const [row] = context.getMetadataTransferPreviewRows(state);
|
|
const html = context.renderMetadataTransferPreview([row]);
|
|
|
|
assert.equal(row.nextModel.resolved_path, 'C:\\models\\imported.safetensors');
|
|
assert.match(html, /data-context-scope="loaded_model"/);
|
|
assert.match(html, /data-context-scope="local_model"/);
|
|
assert.match(html, /data-tooltip-image="C:\\models\\current\.safetensors"/);
|
|
assert.match(html, /data-tooltip-image="C:\\models\\imported\.safetensors"/);
|
|
|
|
state.loadedModels.loaded_models[0].exists = false;
|
|
const [missingRow] = context.getMetadataTransferPreviewRows(state);
|
|
const missingHtml = context.renderMetadataTransferPreview([missingRow]);
|
|
|
|
assert.doesNotMatch(missingHtml, /data-context-scope="local_model"/);
|
|
assert.doesNotMatch(missingHtml, /data-tooltip-image="C:\\models\\imported\.safetensors"/);
|
|
});
|
|
|
|
test('unselected target nodes keep current model actions available', () => {
|
|
const context = createTransferContext();
|
|
const state = createTransferState([
|
|
{ category: 'text_encoders', original_path: 'imported.safetensors' },
|
|
]);
|
|
state.transfer.selectedNodeKeys = new Set();
|
|
state.transfer.targetGroups[0].nodes[0].refs[0] = {
|
|
...state.transfer.targetGroups[0].nodes[0].refs[0],
|
|
exists: true,
|
|
resolved_path: 'C:\\models\\current.safetensors',
|
|
};
|
|
|
|
const html = context.renderMetadataTransferPanel(state);
|
|
|
|
assert.match(html, /class="mr-image-transfer-change is-unselected"/);
|
|
assert.match(html, /data-context-scope="loaded_model"/);
|
|
assert.match(html, /data-tooltip-image="C:\\models\\current\.safetensors"/);
|
|
});
|
|
|
|
test('LoRA activity scope filters imported models but keeps all target slots eligible', () => {
|
|
const context = createTransferContext();
|
|
const state = createLoraTransferState([
|
|
{ category: 'loras', original_path: 'new-active.safetensors', active: true },
|
|
{ category: 'loras', original_path: 'new-inactive-a.safetensors', active: false },
|
|
{ category: 'loras', original_path: 'new-inactive-b.safetensors', active: false },
|
|
{ category: 'loras', original_path: 'new-inactive-c.safetensors', active: false },
|
|
], 'inactive');
|
|
|
|
const rows = context.getMetadataTransferPreviewRows(state);
|
|
|
|
assert.equal(rows.length, 3);
|
|
assert.equal(rows[0].currentValue, 'active-current.safetensors');
|
|
assert.equal(rows[0].nextValue, 'new-inactive-a.safetensors');
|
|
assert.equal(rows[1].currentValue, 'inactive-current.safetensors');
|
|
assert.equal(rows[1].nextValue, 'new-inactive-b.safetensors');
|
|
assert.equal(rows[0].operation, 'replace');
|
|
assert.equal(rows[1].operation, 'replace');
|
|
assert.equal(rows[2].operation, 'add');
|
|
assert.equal(rows[2].nextValue, 'new-inactive-c.safetensors');
|
|
});
|
|
|
|
test('workflow node context keeps Locate Node but hides model info', () => {
|
|
const contextMenu = {
|
|
style: { display: 'none', left: '', top: '' },
|
|
querySelector() {
|
|
return null;
|
|
},
|
|
getBoundingClientRect() {
|
|
return { right: 0, bottom: 0 };
|
|
},
|
|
};
|
|
const visibility = {};
|
|
const showContextMenu = eval(`(${extractMethod(modelInfoMethodsSource, 'showContextMenu')})`);
|
|
const dialog = {
|
|
contextMenu,
|
|
hideTooltip() {},
|
|
canShowSourceDetails() {
|
|
return false;
|
|
},
|
|
getContextMenuSourceLink() {
|
|
return null;
|
|
},
|
|
getContextMenuSourceLookupModel(value) {
|
|
return value;
|
|
},
|
|
canSuggestDownloadSubfolderFromContextMenu() {
|
|
return false;
|
|
},
|
|
setContextMenuItemVisible(action, visible) {
|
|
visibility[action] = visible;
|
|
},
|
|
setContextMenuDividerVisible() {},
|
|
updateContextMenuSourceItem() {},
|
|
refreshContextMenuSourceLink() {},
|
|
};
|
|
|
|
const previousWindow = globalThis.window;
|
|
globalThis.window = { innerWidth: 1000, innerHeight: 1000 };
|
|
try {
|
|
showContextMenu.call(dialog, 10, 20, {
|
|
context_scope: 'workflow_node',
|
|
node_id: 42,
|
|
});
|
|
} finally {
|
|
if (previousWindow === undefined) delete globalThis.window;
|
|
else globalThis.window = previousWindow;
|
|
}
|
|
|
|
assert.match(imageMetadataMethodsSource, /context_scope: 'workflow_node'/);
|
|
assert.equal(visibility.showInfo, false);
|
|
assert.equal(visibility.locateNode, true);
|
|
});
|