feat(recipes): show a summary modal after rematch runs

Replace the post-run toast cascade and the standalone L4 results modal
with a summary modal modeled on the batch download summary: 3-state
header, stat cards (matched / needs review / unresolved / errors),
an L4 review table with per-entry undo, and a copyable report. Wired
into the global, bulk and single-recipe rematch entries; complete
no-op runs keep the lightweight toast. Obsolete results-modal code,
styles and i18n keys are removed.
This commit is contained in:
Will Miao
2026-09-09 10:38:10 +08:00
parent 51cad6f852
commit 4963bf2b2e
23 changed files with 997 additions and 583 deletions
@@ -60,13 +60,6 @@ async function cancelRematchOptions() {
rematchModalManager.cancelOptions();
}
async function getRematchModalManager() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager;
}
describe('RecipeContextMenu.rematchRecipe', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -92,8 +85,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
}
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
// mirror would fire the skipped toast here.
it('posts to the per-recipe rematch endpoint and toasts the rematched count', async () => {
// mirror would render 0 matched entries in the summary modal here.
it('posts to the per-recipe rematch endpoint and opens the summary modal', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
@@ -125,16 +118,22 @@ describe('RecipeContextMenu.rematchRecipe', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: false }),
});
expect(showToastMock).toHaveBeenCalledWith(
// Non-noop runs open the summary modal instead of toasting.
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
{ rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 },
'success'
expect.anything(),
expect.anything()
);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
expect.anything(),
expect.anything()
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('success')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2');
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1');
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
id: 'recipe-1',
@@ -142,7 +141,7 @@ describe('RecipeContextMenu.rematchRecipe', () => {
});
});
it('toasts an info message when the entries had no local match', async () => {
it('opens the summary modal when the entries had no local match', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
@@ -160,11 +159,11 @@ describe('RecipeContextMenu.rematchRecipe', () => {
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchUnmatched',
{ entries: 2, recipes: 1, total: 1 },
'info'
);
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0');
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
expect.anything(),
@@ -303,7 +302,7 @@ describe('RecipeContextMenu.rematchRecipe', () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it('shows the results modal when the result carries l4_matches', async () => {
it('lists L4 filename matches in the summary modal with undo buttons', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
@@ -321,11 +320,6 @@ describe('RecipeContextMenu.rematchRecipe', () => {
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 }));
@@ -334,7 +328,15 @@ describe('RecipeContextMenu.rematchRecipe', () => {
await confirmRematchOptions();
await flushAsyncTasks();
expect(showResultsSpy).toHaveBeenCalledWith(l4Matches);
showResultsSpy.mockRestore();
const summaryModal = document.getElementById('rematchSummaryModal');
expect(summaryModal).not.toBeNull();
// L4 matches to review force the warning header
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1');
const rows = summaryModal.querySelectorAll('tr[data-l4-index]');
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toContain('old.safetensors');
expect(rows[0].textContent).toContain('new.safetensors');
expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull();
});
});