import { describe, it, beforeEach, expect, vi } from 'vitest';
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
const loadingManagerStub = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
restoreProgressBar: vi.fn(),
};
const virtualScrollerStub = {
updateSingleItem: vi.fn(),
getNavigationState: vi.fn(() => ({
index: 0,
hasPrev: false,
hasNext: false,
loadedItems: 1,
totalItems: 1,
})),
getAdjacentItemByFilePath: vi.fn(async () => null),
};
const stateStub = {
global: { settings: {}, loadingManager: loadingManagerStub },
loadingManager: loadingManagerStub,
virtualScroller: virtualScrollerStub,
};
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
sendModelPathToWorkflow: vi.fn(),
openCivitaiByMetadata: vi.fn(),
stripLoraTags: vi.fn((text) => text),
sendPromptToWorkflow: vi.fn(),
sendGenParamsToWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
getStorageItem: vi.fn(() => null),
setStorageItem: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
fetchRecipeDetails: vi.fn(),
updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })),
sendRecipeWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings',
},
}));
function recipeModalFixture() {
return `
`;
}
describe('RecipeModal no-LoRA reason panel', () => {
let recipeModal;
beforeEach(async () => {
vi.clearAllMocks();
document.body.innerHTML = recipeModalFixture();
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
recipeModal = new RecipeModal();
});
function sync(recipe) {
recipeModal.syncResourcesSection(recipe);
return document.getElementById('recipeLorasList');
}
it('shows the base message only when generation genuinely used no LoRAs', () => {
const list = sync({
id: 'r1',
loras: [],
import_info: { channel: 'url', reason: 'no_loras_used' },
});
expect(list.querySelector('.no-loras')).not.toBeNull();
expect(list.textContent).toContain('No LoRAs associated with this recipe');
expect(list.querySelector('details.no-loras-reason')).toBeNull();
});
it('renders a collapsed reason panel from recorded import_info', () => {
const list = sync({
id: 'r2',
loras: [],
import_info: {
channel: 'batch_import_url',
reason: 'api_meta_no_lora_resources',
details: {
api_meta_keys: ['prompt'],
api_model_version_ids: 0,
exif_present: false,
},
},
});
const details = list.querySelector('details.no-loras-reason');
expect(details).not.toBeNull();
// Collapsed by default (no `open` attribute).
expect(details.hasAttribute('open')).toBe(false);
expect(details.querySelector('summary').textContent).toContain('Why no LoRAs?');
const body = details.querySelector('.no-loras-reason-body');
expect(body.textContent).toContain('Batch import (image URL)');
expect(body.textContent).toContain('The source API returned no LoRA resource data');
expect(body.textContent).toContain('API metadata fields');
expect(body.textContent).toContain('prompt');
expect(body.textContent).toContain('Model version IDs reported');
expect(body.textContent).toContain('Embedded metadata');
// Recorded diagnostics are not labeled as inferred.
expect(body.querySelector('.no-loras-inferred-note')).toBeNull();
});
it('infers a possible reason for legacy URL recipes without import_info', () => {
const list = sync({
id: 'r3',
loras: [],
source_path: 'https://civitai.red/images/139995974',
gen_params: { prompt: 'a castle' },
});
const details = list.querySelector('details.no-loras-reason');
expect(details).not.toBeNull();
const body = details.querySelector('.no-loras-reason-body');
expect(body.textContent).toContain('The source API returned no LoRA resource data');
// Heuristic results must be labeled as inferred.
expect(body.querySelector('.no-loras-inferred-note')).not.toBeNull();
});
it('reports missing embedded metadata for legacy local recipes with no params', () => {
const list = sync({
id: 'r4',
loras: [],
source_path: '/data/images/photo.png',
gen_params: {},
});
const details = list.querySelector('details.no-loras-reason');
expect(details).not.toBeNull();
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
'The image has no embedded generation metadata'
);
});
it('does not show the panel for legacy local recipes with complete params', () => {
const list = sync({
id: 'r5',
loras: [],
source_path: '/data/images/photo.png',
gen_params: { prompt: 'a castle', steps: 20, seed: 42 },
});
expect(list.querySelector('details.no-loras-reason')).toBeNull();
});
it('flags ComfyUI workflow sources via has_workflow', () => {
const list = sync({
id: 'r6',
loras: [],
has_workflow: true,
});
const details = list.querySelector('details.no-loras-reason');
expect(details).not.toBeNull();
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
'ComfyUI workflow'
);
});
it('escapes HTML in recorded diagnostic values', () => {
const list = sync({
id: 'r7',
loras: [],
import_info: {
channel: 'url',
reason: 'api_meta_no_lora_resources',
details: { api_meta_keys: ['
'] },
},
});
const details = list.querySelector('details.no-loras-reason');
expect(details).not.toBeNull();
expect(details.innerHTML).not.toContain('
');
});
});