mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): add reconnect remediation paths for missing recipe LoRAs
- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched entries can be undone via the existing restore flow - Bulk missing-LoRA downloads mark unresolvable failures hash-invalid, flipping those entries from download to reconnect candidacy - Recipe modal always offers a reconnect action next to download for missing LoRA entries - Rematch runs collect an opt-in relaxed-matching choice (also reconnect missing models by file name) via a pre-run options dialog on the global, bulk and single-recipe entries - L4 (filename-level) matches are listed in a results dialog with per-entry undo
This commit is contained in:
@@ -309,6 +309,21 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes relaxed in the bulk rematch body only when opted in', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, total: 1, rematched: 1, skipped: 0, errors: 0, recipes: [] }),
|
||||
});
|
||||
|
||||
await api.rematchBulkModels(['/recipes/a.webp'], { relaxed: true });
|
||||
|
||||
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
|
||||
recipe_ids: ['a'],
|
||||
relaxed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws the backend error when bulk rematch fails', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
global.fetch.mockResolvedValue({
|
||||
|
||||
@@ -143,6 +143,16 @@ async function flushAsyncTasks() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
// The real RematchModalManager runs against the mocked modalManager; the
|
||||
// global rematch menu action now opens the options dialog first and only
|
||||
// starts once confirmOptions() is invoked (the user clicking Rematch).
|
||||
async function getRematchModalManager() {
|
||||
const { rematchModalManager } = await import(
|
||||
'../../../static/js/managers/RematchModalManager.js'
|
||||
);
|
||||
return rematchModalManager;
|
||||
}
|
||||
|
||||
function createDeferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
@@ -2266,15 +2276,23 @@ describe('Interaction-level regression coverage', () => {
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
// The click only opens the options dialog — nothing starts yet.
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(rematchItem.classList.contains('disabled')).toBe(false);
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const runPromise = rematchModalManager.confirmOptions();
|
||||
expect(rematchItem.classList.contains('disabled')).toBe(true);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relaxed: false }),
|
||||
});
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
@@ -2331,10 +2349,15 @@ describe('Interaction-level regression coverage', () => {
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const runPromise = rematchModalManager.confirmOptions();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
@@ -2384,10 +2407,15 @@ describe('Interaction-level regression coverage', () => {
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const runPromise = rematchModalManager.confirmOptions();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
@@ -2437,10 +2465,15 @@ describe('Interaction-level regression coverage', () => {
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const runPromise = rematchModalManager.confirmOptions();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
@@ -2489,10 +2522,15 @@ describe('Interaction-level regression coverage', () => {
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const runPromise = rematchModalManager.confirmOptions();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
await runPromise;
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -44,6 +44,29 @@ const flushAsyncTasks = async (rounds = 5) => {
|
||||
}
|
||||
};
|
||||
|
||||
// The single-recipe rematch now opens the options dialog first and only
|
||||
// starts once confirmOptions() is invoked (the user clicking Rematch).
|
||||
async function confirmRematchOptions() {
|
||||
const { rematchModalManager } = await import(
|
||||
'../../../static/js/managers/RematchModalManager.js'
|
||||
);
|
||||
return rematchModalManager.confirmOptions();
|
||||
}
|
||||
|
||||
async function cancelRematchOptions() {
|
||||
const { rematchModalManager } = await import(
|
||||
'../../../static/js/managers/RematchModalManager.js'
|
||||
);
|
||||
rematchModalManager.cancelOptions();
|
||||
}
|
||||
|
||||
async function getRematchModalManager() {
|
||||
const { rematchModalManager } = await import(
|
||||
'../../../static/js/managers/RematchModalManager.js'
|
||||
);
|
||||
return rematchModalManager;
|
||||
}
|
||||
|
||||
describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -91,8 +114,16 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
// The click only opened the options dialog — nothing started yet.
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relaxed: false }),
|
||||
});
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchComplete',
|
||||
@@ -126,6 +157,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
@@ -155,6 +188,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
@@ -186,6 +221,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
@@ -207,6 +244,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
@@ -214,4 +253,88 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
'error'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
|
||||
const menu = await createMenu();
|
||||
const card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
|
||||
document.body.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<input type="checkbox" id="rematchOptionsRelaxed">'
|
||||
);
|
||||
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, rematched: 0, skipped: 1 }),
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-action="rematch"]')
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
// The dialog resets the checkbox to unchecked on open; the user opts in.
|
||||
document.getElementById('rematchOptionsRelaxed').checked = true;
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/rematch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relaxed: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('starts nothing when the options dialog is cancelled', async () => {
|
||||
const menu = await createMenu();
|
||||
const card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
|
||||
document
|
||||
.querySelector('[data-action="rematch"]')
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await cancelRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the results modal when the result carries l4_matches', async () => {
|
||||
const menu = await createMenu();
|
||||
const card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
|
||||
const l4Matches = [
|
||||
{ recipe_id: 'recipe-1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
|
||||
];
|
||||
global.fetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, rematched: 1, matched_entries: 1, l4_matches: l4Matches }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }),
|
||||
});
|
||||
|
||||
const rematchModalManager = await getRematchModalManager();
|
||||
const showResultsSpy = vi
|
||||
.spyOn(rematchModalManager, 'showResultsModal')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
document
|
||||
.querySelector('[data-action="rematch"]')
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
await confirmRematchOptions();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showResultsSpy).toHaveBeenCalledWith(l4Matches);
|
||||
showResultsSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,10 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
stripLoraTags: vi.fn((text) => text),
|
||||
sendPromptToWorkflow: vi.fn(),
|
||||
sendGenParamsToWorkflow: vi.fn(),
|
||||
// Keep the real predicate: the download-failure tests assert on its
|
||||
// unresolvable-error classification.
|
||||
isUnresolvableDownloadError: (message) =>
|
||||
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
@@ -292,7 +296,7 @@ describe('RecipeModal resource item interactions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a download action (not reconnect) for a version-only LoRA', async () => {
|
||||
it('renders a download action alongside reconnect for a version-only LoRA', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
recipeModal.showRecipeDetails(recipeWithResources);
|
||||
await flushWiring();
|
||||
@@ -301,10 +305,12 @@ describe('RecipeModal resource item interactions', () => {
|
||||
expect(item).not.toBeNull();
|
||||
expect(item.classList.contains('missing-locally')).toBe(true);
|
||||
// Missing from the local library (badge) but still downloadable by its
|
||||
// exact CivitAI version id, so the row offers Download, not Reconnect.
|
||||
// exact CivitAI version id, so the row offers Download as the primary
|
||||
// action; Reconnect stays available for entries the user already has
|
||||
// locally under a different hash.
|
||||
expect(item.querySelector('.missing-badge')).not.toBeNull();
|
||||
expect(item.querySelector('.lora-download')).not.toBeNull();
|
||||
expect(item.querySelector('.lora-reconnect')).toBeNull();
|
||||
expect(item.querySelector('.lora-reconnect')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('downloads a version-only LoRA by resolving the model id from the version endpoint', async () => {
|
||||
@@ -588,10 +594,12 @@ describe('RecipeModal resource item interactions', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
|
||||
|
||||
// The entry keeps the download action and never flips to reconnect
|
||||
// The entry keeps the download action and never flips to hash-invalid
|
||||
// (reconnect is always present for missing entries now; the signal here
|
||||
// is that the download action survives and no invalid badge appears)
|
||||
const item = document.querySelector('[data-lora-index="1"]');
|
||||
expect(item.querySelector('.lora-download')).not.toBeNull();
|
||||
expect(item.querySelector('.lora-reconnect')).toBeNull();
|
||||
expect(item.querySelector('.invalid-hash-badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
|
||||
|
||||
@@ -73,6 +73,22 @@ vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
|
||||
getNsfwLevelSelector: vi.fn(),
|
||||
}));
|
||||
|
||||
// The real RematchModalManager runs against the mocked modalManager; confirm
|
||||
// is invoked explicitly, mirroring the user clicking Rematch in the dialog.
|
||||
async function confirmRematchOptions() {
|
||||
const { rematchModalManager } = await import(
|
||||
'../../../static/js/managers/RematchModalManager.js'
|
||||
);
|
||||
return rematchModalManager.confirmOptions();
|
||||
}
|
||||
|
||||
async function cancelRematchOptions() {
|
||||
const { rematchModalManager } = await import(
|
||||
'../../../static/js/managers/RematchModalManager.js'
|
||||
);
|
||||
rematchModalManager.cancelOptions();
|
||||
}
|
||||
|
||||
describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -114,12 +130,16 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(rematchBulkModelsMock).toHaveBeenCalledWith([
|
||||
'/recipes/a.webp',
|
||||
'/recipes/b.webp',
|
||||
'/recipes/c.webp',
|
||||
]);
|
||||
expect(rematchBulkModelsMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'/recipes/a.webp',
|
||||
'/recipes/b.webp',
|
||||
'/recipes/c.webp',
|
||||
],
|
||||
{ relaxed: false }
|
||||
);
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchComplete',
|
||||
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
|
||||
@@ -155,6 +175,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchCompleteErrors',
|
||||
@@ -182,6 +203,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchAllFailed',
|
||||
@@ -215,6 +237,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
@@ -243,6 +266,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
@@ -268,6 +292,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
@@ -284,6 +309,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
@@ -319,4 +345,100 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
);
|
||||
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start the rematch until the options dialog is confirmed', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
||||
|
||||
rematchBulkModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
total: 1,
|
||||
rematched: 1,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
matched_recipes: 1,
|
||||
matched_entries: 1,
|
||||
recipes: [],
|
||||
});
|
||||
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: false });
|
||||
});
|
||||
|
||||
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
document.body.innerHTML = '<input type="checkbox" id="rematchOptionsRelaxed">';
|
||||
|
||||
rematchBulkModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
total: 1,
|
||||
rematched: 0,
|
||||
skipped: 1,
|
||||
errors: 0,
|
||||
recipes: [],
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
// The dialog resets the checkbox to unchecked on open; the user opts in.
|
||||
document.getElementById('rematchOptionsRelaxed').checked = true;
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: true });
|
||||
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('starts nothing when the options dialog is cancelled', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await cancelRematchOptions();
|
||||
|
||||
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchComplete',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the L4 results modal when the bulk result carries l4_matches', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
|
||||
const l4Matches = [
|
||||
{ recipe_id: 'a', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
|
||||
];
|
||||
rematchBulkModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
total: 1,
|
||||
rematched: 1,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
matched_recipes: 1,
|
||||
matched_entries: 1,
|
||||
recipes: [],
|
||||
l4_matches: l4Matches,
|
||||
});
|
||||
|
||||
const { rematchModalManager } = await import(
|
||||
'../../../static/js/managers/RematchModalManager.js'
|
||||
);
|
||||
const showResultsSpy = vi
|
||||
.spyOn(rematchModalManager, 'showResultsModal')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
await confirmRematchOptions();
|
||||
|
||||
expect(showResultsSpy).toHaveBeenCalledWith(l4Matches);
|
||||
showResultsSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const MODULE = '../../../static/js/managers/BulkMissingLoraDownloadManager.js';
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const updateProgressMock = vi.fn();
|
||||
const updateSingleItemMock = vi.fn();
|
||||
|
||||
const mockApiClient = {
|
||||
downloadModel: vi.fn(),
|
||||
cancelDownload: vi.fn(),
|
||||
fetchModelRoots: vi.fn(() => Promise.resolve({ roots: ['/models/loras'] })),
|
||||
};
|
||||
|
||||
const loadingManagerStub = {
|
||||
showDownloadProgress: vi.fn(() => updateProgressMock),
|
||||
setStatus: vi.fn(),
|
||||
showCancelButton: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
// Keep the real predicate: these tests assert on its classification.
|
||||
isUnresolvableDownloadError: (message) =>
|
||||
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: vi.fn(() => mockApiClient),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
extractRecipeId: (filePath) => {
|
||||
if (!filePath) return null;
|
||||
const basename = filePath.split('/').pop().split('\\').pop();
|
||||
const dotIndex = basename.lastIndexOf('.');
|
||||
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: {
|
||||
loadingManager: loadingManagerStub,
|
||||
virtualScroller: { updateSingleItem: updateSingleItemMock },
|
||||
global: { settings: {} },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||
}));
|
||||
|
||||
/** Mirrors the FakeWebSocket pattern from downloadManager.batchSummary.test.js. */
|
||||
class FakeWebSocket {
|
||||
static instances = [];
|
||||
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.onopen = null;
|
||||
this.onmessage = null;
|
||||
this.onerror = null;
|
||||
this.close = vi.fn();
|
||||
FakeWebSocket.instances.push(this);
|
||||
queueMicrotask(() => {
|
||||
if (this.onopen) this.onopen();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const makeRecipe = (filePath, loras) => ({ file_path: filePath, loras });
|
||||
|
||||
describe('BulkMissingLoraDownloadManager unresolvable-failure write-back', () => {
|
||||
let manager;
|
||||
let fetchMock;
|
||||
let requests;
|
||||
|
||||
beforeEach(async () => {
|
||||
FakeWebSocket.instances = [];
|
||||
vi.clearAllMocks();
|
||||
loadingManagerStub.showDownloadProgress.mockReturnValue(updateProgressMock);
|
||||
|
||||
requests = [];
|
||||
fetchMock = vi.fn((url, options) => {
|
||||
requests.push({ url, options });
|
||||
if (url === '/api/lm/recipe/lora/mark-hash-invalid') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
}
|
||||
// Recipe detail refresh after the download loop
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'refreshed' }) });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||
|
||||
vi.resetModules();
|
||||
({ bulkMissingLoraDownloadManager: manager } = await import(MODULE));
|
||||
manager.pendingLoras = [];
|
||||
manager.pendingRecipes = [];
|
||||
manager.pendingMissingByRecipe = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const primePending = (recipes) => {
|
||||
const stats = manager.collectMissingLoras(recipes);
|
||||
manager.pendingRecipes = recipes;
|
||||
manager.pendingMissingByRecipe = stats.missingLorasByRecipe;
|
||||
return stats.uniqueLoras;
|
||||
};
|
||||
|
||||
it('marks every recipe occurrence hash-invalid when the failure is unresolvable', async () => {
|
||||
const entryA = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||
const entryB = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||
const recipe1 = makeRecipe('/recipes/r1.json', [entryA]);
|
||||
const recipe2 = makeRecipe('/recipes/r2.json', [{ hash: 'x', file_name: 'keep.safetensors', inLibrary: true }, entryB]);
|
||||
const uniqueLoras = primePending([recipe1, recipe2]);
|
||||
|
||||
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Model not found' });
|
||||
|
||||
await manager.executeDownload(uniqueLoras);
|
||||
|
||||
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
|
||||
expect(markCalls).toHaveLength(2);
|
||||
const payloads = markCalls.map(r => JSON.parse(r.options.body));
|
||||
expect(payloads).toContainEqual({ recipe_id: 'r1', lora_index: 0 });
|
||||
expect(payloads).toContainEqual({ recipe_id: 'r2', lora_index: 1 });
|
||||
expect(entryA.hashInvalid).toBe(true);
|
||||
expect(entryB.hashInvalid).toBe(true);
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.unresolvableMarkedForReconnect',
|
||||
{ count: 2 },
|
||||
'info',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves entries untouched when the failure is transient', async () => {
|
||||
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||
const recipe = makeRecipe('/recipes/r1.json', [entry]);
|
||||
const uniqueLoras = primePending([recipe]);
|
||||
|
||||
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Connection timed out' });
|
||||
|
||||
await manager.executeDownload(uniqueLoras);
|
||||
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
|
||||
expect(entry.hashInvalid).toBeUndefined();
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.unresolvableMarkedForReconnect',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks hash-invalid when the download request itself throws an unresolvable error', async () => {
|
||||
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||
const recipe = makeRecipe('/recipes/r1.json', [entry]);
|
||||
const uniqueLoras = primePending([recipe]);
|
||||
|
||||
mockApiClient.downloadModel.mockRejectedValue(new Error('410 Gone'));
|
||||
|
||||
await manager.executeDownload(uniqueLoras);
|
||||
|
||||
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
|
||||
expect(markCalls).toHaveLength(1);
|
||||
expect(entry.hashInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark entries whose download succeeds', async () => {
|
||||
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||
const recipe = makeRecipe('/recipes/r1.json', [entry]);
|
||||
const uniqueLoras = primePending([recipe]);
|
||||
|
||||
mockApiClient.downloadModel.mockResolvedValue({ success: true });
|
||||
|
||||
await manager.executeDownload(uniqueLoras);
|
||||
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||
const modalManagerMock = {
|
||||
showModal: vi.fn(),
|
||||
closeModal: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: modalManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: translateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
}));
|
||||
|
||||
async function getManager() {
|
||||
const { rematchModalManager } = await import(
|
||||
'../../../static/js/managers/RematchModalManager.js'
|
||||
);
|
||||
return rematchModalManager;
|
||||
}
|
||||
|
||||
describe('RematchModalManager options dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = `
|
||||
<p id="rematchOptionsMessage"></p>
|
||||
<input type="checkbox" id="rematchOptionsRelaxed">
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('does not invoke the callback until confirmOptions is called', async () => {
|
||||
const manager = await getManager();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
manager.showOptionsModal({ recipeCount: 3, onConfirm });
|
||||
|
||||
expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchOptionsModal');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
// Bulk message mentions the selection size.
|
||||
expect(document.getElementById('rematchOptionsMessage').textContent).toContain('3');
|
||||
// The checkbox always starts unchecked.
|
||||
expect(document.getElementById('rematchOptionsRelaxed').checked).toBe(false);
|
||||
|
||||
manager.confirmOptions();
|
||||
expect(onConfirm).toHaveBeenCalledWith({ relaxed: false });
|
||||
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
|
||||
});
|
||||
|
||||
it('uses the generic message when no recipe count is given', async () => {
|
||||
const manager = await getManager();
|
||||
|
||||
manager.showOptionsModal({ onConfirm: vi.fn() });
|
||||
|
||||
expect(translateMock).toHaveBeenCalledWith(
|
||||
'modals.rematchOptions.messageGlobal',
|
||||
{},
|
||||
'All recipes will be scanned against your local model library.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the single-recipe message for scope: single', async () => {
|
||||
const manager = await getManager();
|
||||
|
||||
manager.showOptionsModal({ scope: 'single', onConfirm: vi.fn() });
|
||||
|
||||
expect(translateMock).toHaveBeenCalledWith(
|
||||
'modals.rematchOptions.messageSingle',
|
||||
{},
|
||||
'This recipe will be scanned against your local model library.'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes relaxed: true when the checkbox is checked', async () => {
|
||||
const manager = await getManager();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
manager.showOptionsModal({ onConfirm });
|
||||
document.getElementById('rematchOptionsRelaxed').checked = true;
|
||||
manager.confirmOptions();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({ relaxed: true });
|
||||
});
|
||||
|
||||
it('resets the checkbox to unchecked each time the dialog opens', async () => {
|
||||
const manager = await getManager();
|
||||
const checkbox = document.getElementById('rematchOptionsRelaxed');
|
||||
checkbox.checked = true;
|
||||
|
||||
manager.showOptionsModal({ onConfirm: vi.fn() });
|
||||
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('cancelOptions runs nothing and clears the callback', async () => {
|
||||
const manager = await getManager();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
manager.showOptionsModal({ onConfirm });
|
||||
manager.cancelOptions();
|
||||
|
||||
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
|
||||
// A later confirm must not fire the cancelled callback.
|
||||
manager.confirmOptions();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RematchModalManager results modal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = '<ul id="rematchResultsList"></ul>';
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it('does nothing for an empty match list', async () => {
|
||||
const manager = await getManager();
|
||||
|
||||
manager.showResultsModal([]);
|
||||
|
||||
expect(modalManagerMock.showModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders one row per L4 match with entry, file name and recipe', async () => {
|
||||
const manager = await getManager();
|
||||
|
||||
manager.showResultsModal([
|
||||
{ recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 },
|
||||
{ recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' },
|
||||
]);
|
||||
|
||||
const rows = document.querySelectorAll('#rematchResultsList .rematch-results-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain('old.safetensors');
|
||||
expect(rows[0].textContent).toContain('new.safetensors');
|
||||
expect(rows[0].textContent).toContain('r1');
|
||||
expect(rows[1].textContent).toContain('cp-new.safetensors');
|
||||
expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchResultsModal');
|
||||
});
|
||||
|
||||
it('undo posts to the lora restore endpoint and disables the row', async () => {
|
||||
const manager = await getManager();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
manager.showResultsModal([
|
||||
{ recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 },
|
||||
]);
|
||||
|
||||
const row = document.querySelector('.rematch-results-row');
|
||||
const button = row.querySelector('.rematch-results-undo');
|
||||
button.click();
|
||||
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }),
|
||||
});
|
||||
expect(row.classList.contains('undone')).toBe(true);
|
||||
});
|
||||
|
||||
it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => {
|
||||
const manager = await getManager();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
manager.showResultsModal([
|
||||
{ recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' },
|
||||
]);
|
||||
|
||||
const button = document.querySelector('.rematch-results-undo');
|
||||
button.click();
|
||||
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipe_id: 'r2' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the row actionable and toasts when undo fails', async () => {
|
||||
const manager = await getManager();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: false, error: 'no snapshot' }),
|
||||
});
|
||||
|
||||
manager.showResultsModal([
|
||||
{ recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
|
||||
]);
|
||||
|
||||
const row = document.querySelector('.rematch-results-row');
|
||||
const button = row.querySelector('.rematch-results-undo');
|
||||
button.click();
|
||||
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
|
||||
|
||||
expect(button.disabled).toBe(false);
|
||||
expect(row.classList.contains('undone')).toBe(false);
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'modals.rematchResults.undoFailed',
|
||||
{ message: 'no snapshot' },
|
||||
'error'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,9 @@ class StubRecipeScanner:
|
||||
self.rematch_all_calls: List[Any] = []
|
||||
self.rematch_by_id_calls: List[str] = []
|
||||
self.rematch_bulk_calls: List[List[str]] = []
|
||||
self.rematch_all_relaxed: List[bool] = []
|
||||
self.rematch_by_id_relaxed: List[bool] = []
|
||||
self.rematch_bulk_relaxed: List[bool] = []
|
||||
self.rematch_results: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
|
||||
@@ -131,7 +134,7 @@ class StubRecipeScanner:
|
||||
def reset_cancellation(self) -> None:
|
||||
self.reset_calls += 1
|
||||
|
||||
async def rematch_all_recipes(self, progress_callback=None):
|
||||
async def rematch_all_recipes(self, progress_callback=None, *, relaxed: bool = False):
|
||||
"""Run a canned rematch-all run, mirroring the real progress events."""
|
||||
if progress_callback:
|
||||
await progress_callback({"status": "started"})
|
||||
@@ -142,6 +145,7 @@ class StubRecipeScanner:
|
||||
{"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1}
|
||||
)
|
||||
self.rematch_all_calls.append(progress_callback)
|
||||
self.rematch_all_relaxed.append(relaxed)
|
||||
return {
|
||||
"success": True,
|
||||
"status": "completed",
|
||||
@@ -151,14 +155,20 @@ class StubRecipeScanner:
|
||||
"total": 1,
|
||||
}
|
||||
|
||||
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
async def rematch_recipe_by_id(
|
||||
self, recipe_id: str, *, relaxed: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
self.rematch_by_id_calls.append(recipe_id)
|
||||
self.rematch_by_id_relaxed.append(relaxed)
|
||||
if recipe_id not in self.rematch_results:
|
||||
raise RecipeNotFoundError(f"Recipe not found: {recipe_id}")
|
||||
return self.rematch_results[recipe_id]
|
||||
|
||||
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
|
||||
async def rematch_recipes_bulk(
|
||||
self, recipe_ids: List[str], *, relaxed: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
self.rematch_bulk_calls.append(list(recipe_ids))
|
||||
self.rematch_bulk_relaxed.append(relaxed)
|
||||
total = len(recipe_ids)
|
||||
rematched = 0
|
||||
skipped = 0
|
||||
@@ -1992,6 +2002,82 @@ async def test_rematch_recipe_maps_not_found_to_404(monkeypatch, tmp_path: Path)
|
||||
assert harness.scanner.rematch_by_id_calls == ["ghost"]
|
||||
|
||||
|
||||
async def test_rematch_recipes_passes_relaxed_flag_from_body(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipes/rematch", json={"relaxed": True}
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200, payload
|
||||
await asyncio.sleep(0.1)
|
||||
assert harness.scanner.rematch_all_relaxed == [True]
|
||||
|
||||
|
||||
async def test_rematch_recipes_relaxed_defaults_to_false(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post("/api/lm/recipes/rematch")
|
||||
payload = await response.json()
|
||||
assert response.status == 200, payload
|
||||
await asyncio.sleep(0.1)
|
||||
assert harness.scanner.rematch_all_relaxed == [False]
|
||||
|
||||
|
||||
async def test_rematch_recipes_relaxed_query_param_fallback(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post("/api/lm/recipes/rematch?relaxed=true")
|
||||
payload = await response.json()
|
||||
assert response.status == 200, payload
|
||||
await asyncio.sleep(0.1)
|
||||
assert harness.scanner.rematch_all_relaxed == [True]
|
||||
|
||||
|
||||
async def test_rematch_recipes_bulk_passes_relaxed_flag_from_body(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipes/rematch-bulk",
|
||||
json={"recipe_ids": ["r1"], "relaxed": True},
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200, payload
|
||||
assert harness.scanner.rematch_bulk_relaxed == [True]
|
||||
|
||||
|
||||
async def test_rematch_recipes_bulk_relaxed_query_param_fallback(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipes/rematch-bulk?relaxed=true",
|
||||
json={"recipe_ids": ["r1"]},
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200, payload
|
||||
assert harness.scanner.rematch_bulk_relaxed == [True]
|
||||
|
||||
|
||||
async def test_rematch_recipe_passes_relaxed_flag_from_query(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.scanner.rematch_results = {
|
||||
"abc123": {"success": True, "rematched": 1},
|
||||
}
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/abc123/rematch?relaxed=true"
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200, payload
|
||||
assert harness.scanner.rematch_by_id_relaxed == [True]
|
||||
|
||||
|
||||
async def test_get_rematch_progress_404_when_no_progress(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -4012,6 +4012,7 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
|
||||
"entry": "old.safetensors",
|
||||
"file_name": "m.safetensors",
|
||||
"match_level": "L1",
|
||||
"lora_index": 0,
|
||||
}
|
||||
]
|
||||
assert result["recipe"] is enriched
|
||||
@@ -4032,6 +4033,79 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
|
||||
assert resort_calls == [] # Metis F1 — hoisted to public entry points
|
||||
|
||||
|
||||
# Rematch write-back must snapshot the pre-match state (undo affordance)
|
||||
|
||||
|
||||
async def test_write_rematch_lora_entry_snapshots_pre_match_state(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
original_entry = {
|
||||
"isDeleted": True,
|
||||
"hashInvalid": False,
|
||||
"hash": "oldhash",
|
||||
"file_name": "old.safetensors",
|
||||
"modelVersionId": 0,
|
||||
"modelName": "Old Name",
|
||||
}
|
||||
entry = dict(original_entry)
|
||||
item = _civitai_lora_item(
|
||||
sha256="b" * 64,
|
||||
version_id=222,
|
||||
name="v2.0",
|
||||
model_name="New Model",
|
||||
file_name="new.safetensors",
|
||||
)
|
||||
|
||||
scanner._write_rematch_lora_entry(entry, item)
|
||||
|
||||
assert entry["hash"] == "b" * 64
|
||||
assert entry["file_name"] == "new.safetensors"
|
||||
assert entry["reconnectSnapshot"] == original_entry
|
||||
|
||||
|
||||
async def test_write_rematch_lora_entry_snapshot_never_nests(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
entry = {
|
||||
"isDeleted": True,
|
||||
"hash": "oldhash",
|
||||
"file_name": "old.safetensors",
|
||||
"reconnectSnapshot": {"file_name": "even-older.safetensors"},
|
||||
}
|
||||
item = _civitai_lora_item(sha256="c" * 64, file_name="new.safetensors")
|
||||
|
||||
scanner._write_rematch_lora_entry(entry, item)
|
||||
|
||||
snapshot = entry["reconnectSnapshot"]
|
||||
assert snapshot["file_name"] == "old.safetensors"
|
||||
assert "reconnectSnapshot" not in snapshot
|
||||
|
||||
|
||||
async def test_write_rematch_checkpoint_entry_snapshots_pre_match_state(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
original_entry = {
|
||||
"isDeleted": True,
|
||||
"hashInvalid": True,
|
||||
"hash": "oldhash",
|
||||
"file_name": "old.safetensors",
|
||||
"name": "Old CP",
|
||||
"modelVersionId": 0,
|
||||
}
|
||||
entry = dict(original_entry)
|
||||
item = _civitai_checkpoint_item(
|
||||
sha256="d" * 64,
|
||||
version_id=333,
|
||||
name="cp-v1",
|
||||
model_name="New CP",
|
||||
file_name="new-cp.safetensors",
|
||||
)
|
||||
|
||||
scanner._write_rematch_checkpoint_entry(entry, item)
|
||||
|
||||
assert entry["hash"] == "d" * 64
|
||||
assert entry["file_name"] == "new-cp.safetensors"
|
||||
assert entry["reconnectSnapshot"] == original_entry
|
||||
assert "reconnectSnapshot" not in entry["reconnectSnapshot"]
|
||||
|
||||
|
||||
# Acceptance criterion (2): checkpoint entry rematched via L2 — parser style
|
||||
|
||||
|
||||
@@ -4659,6 +4733,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop(
|
||||
local_cache: dict[str, Any],
|
||||
autov3_cache: dict[str, Any],
|
||||
filename_cache=None,
|
||||
**_kwargs: Any,
|
||||
) -> tuple[int, int, dict[str, Any]]:
|
||||
if recipe.get("id") == "boom":
|
||||
raise RuntimeError("kaboom")
|
||||
@@ -4717,12 +4792,15 @@ async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypat
|
||||
local_cache: dict[str, Any],
|
||||
autov3_cache: dict[str, Any],
|
||||
filename_cache=None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[int, int, dict[str, Any]]:
|
||||
nonlocal entered
|
||||
if recipe.get("id") == "r0":
|
||||
entered = True
|
||||
await release.wait()
|
||||
return await original(recipe, local_cache, autov3_cache, filename_cache)
|
||||
return await original(
|
||||
recipe, local_cache, autov3_cache, filename_cache, **kwargs
|
||||
)
|
||||
|
||||
monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single)
|
||||
|
||||
@@ -4899,6 +4977,236 @@ async def test_rematch_all_autov3_cache_reuse_across_calls(
|
||||
assert len(called) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relaxed rematch candidacy (Feature 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_is_rematch_candidate_relaxed_accepts_healthy_entry(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
healthy = {"hash": "abc", "file_name": "m.safetensors"}
|
||||
assert scanner._is_rematch_candidate(healthy, relaxed=True)
|
||||
# Default strict behavior is unchanged.
|
||||
assert not scanner._is_rematch_candidate(healthy)
|
||||
assert not scanner._is_rematch_candidate(healthy, relaxed=False)
|
||||
|
||||
|
||||
async def test_is_rematch_candidate_relaxed_still_requires_identifier(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
assert not scanner._is_rematch_candidate({}, relaxed=True)
|
||||
assert not scanner._is_rematch_candidate({"isDeleted": True}, relaxed=True)
|
||||
assert not scanner._is_rematch_candidate("garbage", relaxed=True)
|
||||
|
||||
|
||||
async def test_rematch_relaxed_skips_healthy_entry_with_local_hash(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
# Anti-churn: a relaxed-only candidate whose hash already resolves in the
|
||||
# L1 local cache is already correctly linked — no write-back, no
|
||||
# snapshot, and it counts as neither matched nor unresolved.
|
||||
sha256 = ("A1" * 32).lower()
|
||||
item = _civitai_lora_item(sha256=sha256, file_name="m.safetensors")
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"loras": [{"hash": sha256, "file_name": "m.safetensors"}],
|
||||
}
|
||||
_set_recipe_cache(scanner, [recipe])
|
||||
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
|
||||
result = await scanner.rematch_recipe_by_id("r1", relaxed=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["matched_entries"] == 0
|
||||
assert result["unresolved_entries"] == 0
|
||||
assert result["details"] == {"matched": [], "unresolved": []}
|
||||
assert saved == []
|
||||
assert "reconnectSnapshot" not in recipe["loras"][0]
|
||||
|
||||
|
||||
async def test_rematch_relaxed_matches_healthy_missing_entry_via_l4(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
# A healthy entry whose hash is NOT in the local library becomes an L4
|
||||
# filename match under relaxed mode when the base models agree.
|
||||
sha256 = ("B2" * 32).lower()
|
||||
item = _rematch_item(
|
||||
sha256=sha256,
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"base_model": "SD 1.5",
|
||||
"loras": [
|
||||
{
|
||||
"hash": "f" * 64, # not present locally
|
||||
"file_name": "detail.safetensors",
|
||||
}
|
||||
],
|
||||
}
|
||||
_set_recipe_cache(scanner, [recipe])
|
||||
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
|
||||
# Strict mode never touches the healthy entry.
|
||||
strict = await scanner.rematch_recipe_by_id("r1")
|
||||
assert strict["matched_entries"] == 0
|
||||
assert saved == []
|
||||
|
||||
result = await scanner.rematch_recipe_by_id("r1", relaxed=True)
|
||||
|
||||
assert result["matched_entries"] == 1
|
||||
assert result["details"]["matched"] == [
|
||||
{
|
||||
"type": "lora",
|
||||
"entry": "detail.safetensors",
|
||||
"file_name": "detail.safetensors",
|
||||
"match_level": "L4",
|
||||
"lora_index": 0,
|
||||
}
|
||||
]
|
||||
entry = recipe["loras"][0]
|
||||
assert entry["hash"] == sha256
|
||||
assert entry["reconnectSnapshot"]["hash"] == "f" * 64
|
||||
assert saved == [recipe]
|
||||
|
||||
|
||||
async def test_rematch_matched_details_carry_lora_index_and_bulk_flattens_l4(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
sha256_l1 = ("C3" * 32).lower()
|
||||
l1_item = _civitai_lora_item(sha256=sha256_l1, file_name="l1.safetensors")
|
||||
l4_item = _rematch_item(
|
||||
sha256=("D4" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([l1_item, l4_item], [], tmp_path)
|
||||
recipes: list[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "r0",
|
||||
"base_model": "SD 1.5",
|
||||
"loras": [
|
||||
# index 0: not a candidate at all (healthy, strict run)
|
||||
{"hash": "zzz", "file_name": "other.safetensors"},
|
||||
# index 1: L4 filename match
|
||||
{"isDeleted": True, "file_name": "detail.safetensors"},
|
||||
# index 2: L1 hash match
|
||||
{
|
||||
"isDeleted": True,
|
||||
"hash": sha256_l1,
|
||||
"file_name": "old.safetensors",
|
||||
},
|
||||
],
|
||||
},
|
||||
{"id": "r1", "loras": []},
|
||||
]
|
||||
_set_recipe_cache(scanner, recipes)
|
||||
await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
await _spy_resort(scanner, monkeypatch)
|
||||
|
||||
result = await scanner.rematch_recipes_bulk(["r0", "r1"])
|
||||
|
||||
assert result["matched_entries"] == 2
|
||||
matched = result["details"][0]["matched"]
|
||||
assert matched[0]["lora_index"] == 1
|
||||
assert matched[0]["match_level"] == "L4"
|
||||
assert matched[1]["lora_index"] == 2
|
||||
assert matched[1]["match_level"] == "L1"
|
||||
# Only the L4 match is flattened for review; L1 matches need none.
|
||||
assert result["l4_matches"] == [
|
||||
{
|
||||
"recipe_id": "r0",
|
||||
"type": "lora",
|
||||
"entry": "detail.safetensors",
|
||||
"file_name": "detail.safetensors",
|
||||
"lora_index": 1,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_rematch_recipe_by_id_returns_flattened_l4_matches(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
# The single-recipe return carries the same flattened l4_matches shape
|
||||
# as the bulk/global paths so the frontend results modal works for all
|
||||
# three entry points.
|
||||
l4_item = _rematch_item(
|
||||
sha256=("F6" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([l4_item], [], tmp_path)
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"base_model": "SD 1.5",
|
||||
"loras": [{"isDeleted": True, "file_name": "detail.safetensors"}],
|
||||
}
|
||||
_set_recipe_cache(scanner, [recipe])
|
||||
await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
|
||||
result = await scanner.rematch_recipe_by_id("r1")
|
||||
|
||||
assert result["l4_matches"] == [
|
||||
{
|
||||
"recipe_id": "r1",
|
||||
"type": "lora",
|
||||
"entry": "detail.safetensors",
|
||||
"file_name": "detail.safetensors",
|
||||
"lora_index": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_rematch_all_recipes_reports_l4_matches_in_completed_payload(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
l4_item = _rematch_item(
|
||||
sha256=("E5" * 32).lower(),
|
||||
sub_type="checkpoint",
|
||||
base_model="SDXL",
|
||||
file_name="realistic.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([], [l4_item], tmp_path)
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"loras": [],
|
||||
"checkpoint": {
|
||||
"isDeleted": True,
|
||||
"file_name": "realistic.safetensors",
|
||||
"baseModel": "SDXL",
|
||||
},
|
||||
}
|
||||
_set_recipe_cache(scanner, [recipe])
|
||||
await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
await _spy_resort(scanner, monkeypatch)
|
||||
|
||||
events: list[Dict[str, Any]] = []
|
||||
|
||||
async def cb(ev: Dict[str, Any]) -> None:
|
||||
events.append(ev)
|
||||
|
||||
result = await scanner.rematch_all_recipes(progress_callback=cb)
|
||||
|
||||
expected_l4 = [
|
||||
{
|
||||
"recipe_id": "r1",
|
||||
"type": "checkpoint",
|
||||
"entry": "realistic.safetensors",
|
||||
"file_name": "realistic.safetensors",
|
||||
}
|
||||
]
|
||||
# Checkpoint matches carry no lora_index (the checkpoint restore
|
||||
# endpoint only needs recipe_id).
|
||||
assert result["l4_matches"] == expected_l4
|
||||
completed = [e for e in events if e["status"] == "completed"]
|
||||
assert completed and completed[0]["l4_matches"] == expected_l4
|
||||
|
||||
|
||||
|
||||
async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
Reference in New Issue
Block a user