feat(recipes): add prompt-aware duplicate detection toggle

This commit is contained in:
Will Miao
2026-08-10 00:07:14 +08:00
parent 8237e5f9ea
commit 95fb3c7fc9
20 changed files with 582 additions and 39 deletions

View File

@@ -2,13 +2,22 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const recreateVirtualScrollMock = vi.fn();
const translateMock = vi.fn((key) => key);
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: class {},
RecipeCard: class {
constructor() {
this.element = document.createElement('div');
}
},
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
@@ -85,3 +94,120 @@ describe('DuplicatesManager exitDuplicateMode', () => {
expect(document.getElementById('duplicatesBanner').style.display).toBe('none');
});
});
describe('DuplicatesManager prompt matching toggle', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
setCurrentPageType('recipes');
setupDom();
state.pendingLayoutRecreate = false;
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
});
afterEach(() => {
state.pendingLayoutRecreate = false;
state.virtualScroller = null;
});
it('sends include_prompt=1 when the preference is enabled', async () => {
localStorage.setItem('recipes_duplicates_include_prompt', '1');
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
duplicate_groups: [
{ type: 'fingerprint', key: 'g-1', fingerprint: 'abc:0.8', count: 2, recipes: [{ id: 'r1', modified: 1 }, { id: 'r2', modified: 2 }] },
],
}),
});
const manager = new DuplicatesManager({});
await manager.findDuplicates();
expect(globalThis.fetch).toHaveBeenCalledWith('/api/lm/recipes/find-duplicates?include_prompt=1');
});
it('calls the endpoint without the param when disabled', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, duplicate_groups: [] }),
});
const manager = new DuplicatesManager({});
await manager.findDuplicates();
expect(globalThis.fetch).toHaveBeenCalledWith('/api/lm/recipes/find-duplicates');
});
it('stays in duplicate mode with an empty view when a re-run finds no groups', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, duplicate_groups: [] }),
});
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.findDuplicates();
// The view stays open (with the empty state) so the matching-basis
// toggle remains reachable — the deadlock fix
expect(manager.inDuplicateMode).toBe(true);
expect(manager.duplicateGroups).toEqual([]);
expect(document.getElementById('duplicatesBanner').style.display).toBe('block');
expect(document.querySelector('.duplicates-empty-state')).not.toBeNull();
});
it('enters the empty duplicates view when the toggle is on but no groups match', async () => {
localStorage.setItem('recipes_duplicates_include_prompt', '1');
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, duplicate_groups: [] }),
});
const manager = new DuplicatesManager({});
await manager.findDuplicates();
expect(manager.inDuplicateMode).toBe(true);
expect(document.getElementById('duplicatesBanner').style.display).toBe('block');
});
it('toasts and stays on the library grid when the toggle is off and no groups match', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, duplicate_groups: [] }),
});
const manager = new DuplicatesManager({});
await manager.findDuplicates();
expect(manager.inDuplicateMode).toBe(false);
expect(showToastMock).toHaveBeenCalledWith('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info');
});
it('renders the matching basis and checkbox from the stored preference', () => {
document.body.innerHTML = `
<span id="duplicatesBasis"></span>
<span id="duplicatesHelpText"></span>
<input type="checkbox" id="promptMatchInput">
`;
localStorage.setItem('recipes_duplicates_include_prompt', '1');
const manager = new DuplicatesManager({});
manager.updateBasisDisplay();
expect(translateMock).toHaveBeenCalledWith('recipes.duplicates.basis.loraComboAndPrompt');
expect(translateMock).toHaveBeenCalledWith('recipes.duplicates.basis.hintPromptIncluded');
expect(document.getElementById('promptMatchInput').checked).toBe(true);
});
it('shows the lora-combo basis when the preference is disabled', () => {
document.body.innerHTML = `<span id="duplicatesBasis"></span>`;
const manager = new DuplicatesManager({});
manager.updateBasisDisplay();
expect(translateMock).toHaveBeenCalledWith('recipes.duplicates.basis.loraCombo');
expect(document.getElementById('duplicatesBasis').textContent).toBe('recipes.duplicates.basis.loraCombo');
});
});