diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index aa7d7a4..3fa57f3 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -37,6 +37,10 @@ import { getSha256Field, normalizeSha256 } from '../web/resolver/utils/hash_util import { normalizeSearchToken } from '../web/resolver/utils/search_utils.js'; import { parseFiniteNumber } from '../web/resolver/utils/size_utils.js'; import { getSourceDisplayLabel, normalizeSourceKey } from '../web/resolver/utils/source_labels.js'; +import { + buildLoadedModelTokenStrings, + groupLoadedModelsByCategory, +} from '../web/resolver/utils/loaded_model_utils.js'; import { baseModelAliasMethods } from '../web/resolver/search/base_model_alias_methods.js'; import { searchHashMethods } from '../web/resolver/search/search_hash_methods.js'; import { missingModelStateMethods } from '../web/resolver/views/missing_model_state_methods.js'; @@ -57,8 +61,11 @@ void CATEGORY_ALIASES; void normalizeCategoryToken; void normalizeSha256; void pollBackgroundTask; +void buildLoadedModelTokenStrings; +void groupLoadedModelsByCategory; const projectRoot = path.resolve(import.meta.dirname, '..'); +const getSvgIcon = () => ''; const queueMethodsSource = fs.readFileSync( path.join(projectRoot, 'web/resolver/actions/queue_methods.js'), 'utf8' @@ -2068,6 +2075,66 @@ test('Loaded Models progress patches the stable progress container', () => { assert.doesNotMatch(pollAnalysisProgress, /contentElement\.innerHTML\s*=/); }); +test('loaded model grouping and copy strings stay consistent across render paths', () => { + const updateLoadedModelCopyValues = eval( + `(${extractMethod(tabsLoadedMethodsSource, 'updateLoadedModelCopyValues')})` + ); + const displayLoadedModels = eval( + `(${extractMethod(tabsLoadedMethodsSource, 'displayLoadedModels')})` + ); + const models = [ + { category: 'loras', name: 'active-lora', active: true, connected: true }, + { category: 'loras', name: 'inactive-lora', active: false, connected: true }, + { category: 'checkpoints', name: 'default-checkpoint' }, + { name: 'unknown-inactive', connected: false }, + ]; + const getModelToken = (model, category) => `<${category}:${model.name}>`; + const dialog = { + getModelToken, + escapeJsString: value => JSON.stringify(value), + getCategoryDisplayName: category => category, + escapeHtml: value => String(value), + getModelNameAndStrength: model => ({ name: model.name, strength: null }), + getLoadedModelDomKey: model => model.name, + getLoadedModelContext: model => model, + getContextMenuAttrs: () => '', + getModelPreviewTooltipAttrs: () => '', + }; + const expectedUpdate = { + active: ' ', + inactive: ' ', + all: ' ' + + ' ', + }; + const expectedDisplay = { + active: ' ', + inactive: ' ', + all: expectedUpdate.all, + }; + + const updateContainer = { dataset: {} }; + updateLoadedModelCopyValues.call(dialog, updateContainer, models); + + const displayContainer = { dataset: {}, innerHTML: '' }; + displayLoadedModels.call(dialog, displayContainer, { + loaded_models: models, + total: models.length, + }); + + assert.deepEqual({ + active: updateContainer.dataset.mlActiveString, + inactive: updateContainer.dataset.mlInactiveString, + all: updateContainer.dataset.mlAllString, + }, expectedUpdate); + assert.deepEqual({ + active: displayContainer.dataset.mlActiveString, + inactive: displayContainer.dataset.mlInactiveString, + all: displayContainer.dataset.mlAllString, + }, expectedDisplay); + assert.notEqual(displayContainer.innerHTML, ''); + void getSvgIcon; +}); + test('workflow progress polling preserves endpoint, rendering, and token cleanup', async () => { const pollWorkflowProgress = eval(`(${extractMethod(renderFormatMethodsSource, 'pollWorkflowProgress')})`); const pollAnalysisProgress = eval(`(${extractMethod(renderFormatMethodsSource, 'pollAnalysisProgress')})`); diff --git a/web/resolver/utils/loaded_model_utils.js b/web/resolver/utils/loaded_model_utils.js new file mode 100644 index 0000000..687bc62 --- /dev/null +++ b/web/resolver/utils/loaded_model_utils.js @@ -0,0 +1,41 @@ +export function groupLoadedModelsByCategory(loadedModels = []) { + const byCategory = {}; + + for (const model of loadedModels) { + const category = model.category || 'unknown'; + if (!byCategory[category]) { + byCategory[category] = { active: [], inactive: [] }; + } + + const isActive = model.active !== false && model.connected !== false; + byCategory[category][isActive ? 'active' : 'inactive'].push(model); + } + + return byCategory; +} + +export function buildLoadedModelTokenStrings( + byCategory, + getToken, + { preserveEmptyCategories = false } = {} +) { + const build = filter => { + const categoryStrings = Object.entries(byCategory).map(([category, models]) => { + const selected = filter === 'all' + ? [...models.active, ...models.inactive] + : models[filter]; + return selected.map(model => getToken(model, category)).join(' '); + }); + + return (preserveEmptyCategories + ? categoryStrings + : categoryStrings.filter(Boolean) + ).join(' '); + }; + + return { + active: build('active'), + inactive: build('inactive'), + all: build('all'), + }; +} diff --git a/web/resolver/views/tabs_loaded_methods.js b/web/resolver/views/tabs_loaded_methods.js index 1d44daf..73dbc45 100644 --- a/web/resolver/views/tabs_loaded_methods.js +++ b/web/resolver/views/tabs_loaded_methods.js @@ -4,6 +4,10 @@ import { getCustomNodeOriginalIdentity, } from "../custom_nodes/registry.js"; import { normalizePathIdentity } from "../utils/html_utils.js"; +import { + buildLoadedModelTokenStrings, + groupLoadedModelsByCategory, +} from "../utils/loaded_model_utils.js"; export const tabsLoadedMethods = { getTabButton(tab) { return { @@ -207,31 +211,11 @@ export const tabsLoadedMethods = { updateLoadedModelCopyValues(container, loadedModels = []) { if (!container) return; - const byCategory = {}; - for (const model of loadedModels) { - const category = model.category || 'unknown'; - if (!byCategory[category]) { - byCategory[category] = { active: [], inactive: [] }; - } - const filter = model.active !== false && model.connected !== false - ? 'active' - : 'inactive'; - byCategory[category][filter].push(model); - } - - const buildString = (filter) => Object.entries(byCategory) - .flatMap(([category, models]) => { - const selected = filter === 'all' - ? [...models.active, ...models.inactive] - : models[filter]; - return selected.map(model => this.getModelToken(model, category)); - }) - .join(' '); - const strings = { - active: buildString('active'), - inactive: buildString('inactive'), - all: buildString('all'), - }; + const byCategory = groupLoadedModelsByCategory(loadedModels); + const strings = buildLoadedModelTokenStrings( + byCategory, + (model, category) => this.getModelToken(model, category), + ); container.dataset.mlActiveString = strings.active; container.dataset.mlInactiveString = strings.inactive; @@ -353,40 +337,20 @@ export const tabsLoadedMethods = { return; } - const byCategory = {}; - - for (const model of loadedModels) { - const cat = model.category || 'unknown'; - if (!byCategory[cat]) { - byCategory[cat] = { active: [], inactive: [] }; - } - - // Some loaders expose per-model enablement separately from links. - const isActive = model.active !== false && model.connected !== false; - - if (isActive) { - byCategory[cat].active.push(model); - } else { - byCategory[cat].inactive.push(model); - } - } + const byCategory = groupLoadedModelsByCategory(loadedModels); const activeCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.active.length, 0); const inactiveCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.inactive.length, 0); - const buildCategoryStrings = (filter) => { - const result = {}; - for (const [category, modelsObj] of Object.entries(byCategory)) { - const models = filter === 'active' ? modelsObj.active : filter === 'inactive' ? modelsObj.inactive : [...modelsObj.active, ...modelsObj.inactive]; - const parts = models.map(model => this.getModelToken(model, category)); - result[category] = parts.join(' '); - } - return Object.values(result).join(' '); - }; - - const activeString = buildCategoryStrings('active'); - const inactiveString = buildCategoryStrings('inactive'); - const allString = buildCategoryStrings('all'); + const { + active: activeString, + inactive: inactiveString, + all: allString, + } = buildLoadedModelTokenStrings( + byCategory, + (model, category) => this.getModelToken(model, category), + { preserveEmptyCategories: true }, + ); const copyIcon = getSvgIcon('copy', 'currentColor', 'mr-copy-btn-icon'); const copyButtonHtml = (text, extraClass = '') => `