mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-12 08:50:15 -03:00
feat(recipes): add prompt-aware duplicate detection toggle
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,15 @@ class StubRecipeScanner:
|
||||
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||
return self.recipes.get(recipe_id)
|
||||
|
||||
async def find_all_duplicate_recipes(
|
||||
self, include_prompt: bool = False
|
||||
) -> Dict[str, List[Any]]:
|
||||
self.last_duplicate_include_prompt = include_prompt
|
||||
return dict(getattr(self, "duplicate_groups_override", {}))
|
||||
|
||||
async def find_duplicate_recipes_by_source(self) -> Dict[str, List[Any]]:
|
||||
return dict(getattr(self, "duplicate_source_groups_override", {}))
|
||||
|
||||
async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
|
||||
return list(self.lora_lookup.get(lora_hash.lower(), []))
|
||||
|
||||
@@ -1951,3 +1960,56 @@ async def test_get_rematch_progress_returns_stored_progress(
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["progress"]["status"] == "processing"
|
||||
|
||||
|
||||
async def test_find_duplicates_defaults_to_fingerprint_only(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.scanner.recipes = {
|
||||
"r1": {"id": "r1", "title": "One", "modified": 100},
|
||||
"r2": {"id": "r2", "title": "Two", "modified": 200},
|
||||
}
|
||||
harness.scanner.duplicate_groups_override = {"abc:0.8": ["r1", "r2"]}
|
||||
harness.scanner.duplicate_source_groups_override = {}
|
||||
|
||||
response = await harness.client.get("/api/lm/recipes/find-duplicates")
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert harness.scanner.last_duplicate_include_prompt is False
|
||||
assert len(payload["duplicate_groups"]) == 1
|
||||
group = payload["duplicate_groups"][0]
|
||||
assert group["type"] == "fingerprint"
|
||||
assert group["key"] == "g-1"
|
||||
assert group["fingerprint"] == "abc:0.8"
|
||||
assert group["count"] == 2
|
||||
|
||||
|
||||
async def test_find_duplicates_forwards_include_prompt_and_assigns_unique_keys(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.scanner.recipes = {
|
||||
"r1": {"id": "r1", "title": "One", "modified": 100},
|
||||
"r2": {"id": "r2", "title": "Two", "modified": 200},
|
||||
"r3": {"id": "r3", "title": "Three", "modified": 300},
|
||||
"r4": {"id": "r4", "title": "Four", "modified": 400},
|
||||
}
|
||||
harness.scanner.duplicate_groups_override = {"abc:0.8\x1fa girl": ["r1", "r2"]}
|
||||
harness.scanner.duplicate_source_groups_override = {
|
||||
"civitai.com/images/9": ["r3", "r4"]
|
||||
}
|
||||
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipes/find-duplicates?include_prompt=1"
|
||||
)
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
assert harness.scanner.last_duplicate_include_prompt is True
|
||||
groups = payload["duplicate_groups"]
|
||||
assert len(groups) == 2
|
||||
assert {g["type"] for g in groups} == {"fingerprint", "source_path"}
|
||||
assert len({g["key"] for g in groups}) == 2
|
||||
|
||||
@@ -3209,3 +3209,71 @@ async def test_rematch_all_autov3_cache_reuse_across_calls(
|
||||
# are read once across both calls (Oracle R2-F4).
|
||||
assert len(called) == 1
|
||||
|
||||
|
||||
|
||||
async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch):
|
||||
scanner, _ = recipe_scanner
|
||||
cache = SimpleNamespace(
|
||||
raw_data=[
|
||||
{"id": "r1", "fingerprint": "abc:0.8", "gen_params": {"prompt": "A Girl, blue hair"}},
|
||||
{"id": "r2", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
|
||||
{"id": "r3", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
|
||||
{"id": "r4", "fingerprint": "def:1.0", "gen_params": {"prompt": "A Girl, blue hair"}},
|
||||
{"id": "r5", "fingerprint": "", "gen_params": {"prompt": "landscape"}},
|
||||
{"id": "r6", "fingerprint": "", "gen_params": {}},
|
||||
]
|
||||
)
|
||||
async def fake_get_cached_data():
|
||||
return cache
|
||||
monkeypatch.setattr(scanner, "get_cached_data", fake_get_cached_data)
|
||||
|
||||
groups = await scanner.find_all_duplicate_recipes()
|
||||
assert groups == {"abc:0.8": ["r1", "r2", "r3"]}
|
||||
|
||||
|
||||
async def test_find_all_duplicate_recipes_include_prompt_composite_key(recipe_scanner, monkeypatch):
|
||||
scanner, _ = recipe_scanner
|
||||
cache = SimpleNamespace(
|
||||
raw_data=[
|
||||
{"id": "r1", "fingerprint": "abc:0.8", "gen_params": {"prompt": "A Girl, blue hair"}},
|
||||
{"id": "r2", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a girl, blue hair"}},
|
||||
{"id": "r3", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
|
||||
{"id": "r4", "fingerprint": "", "gen_params": {"prompt": " landscape "}},
|
||||
{"id": "r5", "fingerprint": "", "gen_params": {"prompt": "landscape"}},
|
||||
{"id": "r6", "fingerprint": "", "gen_params": {}},
|
||||
{"id": "r7", "fingerprint": "def:1.0", "gen_params": {"prompt": "a girl, blue hair"}},
|
||||
]
|
||||
)
|
||||
async def fake_get_cached_data():
|
||||
return cache
|
||||
monkeypatch.setattr(scanner, "get_cached_data", fake_get_cached_data)
|
||||
|
||||
groups = await scanner.find_all_duplicate_recipes(include_prompt=True)
|
||||
assert groups == {
|
||||
"abc:0.8\x1fa girl, blue hair": ["r1", "r2"],
|
||||
"\x1flandscape": ["r4", "r5"],
|
||||
}
|
||||
# Same-lora recipes with different prompts are no longer duplicates
|
||||
assert "abc:0.8\x1fa boy" not in groups
|
||||
# Different-lora recipes with the same prompt are not grouped either
|
||||
assert "def:1.0\x1fa girl, blue hair" not in groups
|
||||
# Recipes with neither fingerprint nor prompt are skipped
|
||||
assert "r6" not in [rid for ids in groups.values() for rid in ids]
|
||||
|
||||
|
||||
async def test_find_all_duplicate_recipes_include_prompt_missing_gen_params(recipe_scanner, monkeypatch):
|
||||
scanner, _ = recipe_scanner
|
||||
cache = SimpleNamespace(
|
||||
raw_data=[
|
||||
{"id": "r1", "fingerprint": "abc:0.8"},
|
||||
{"id": "r2", "fingerprint": "abc:0.8"},
|
||||
{"id": "r3", "fingerprint": "abc:0.8", "gen_params": {"prompt": "a boy"}},
|
||||
]
|
||||
)
|
||||
async def fake_get_cached_data():
|
||||
return cache
|
||||
monkeypatch.setattr(scanner, "get_cached_data", fake_get_cached_data)
|
||||
|
||||
groups = await scanner.find_all_duplicate_recipes(include_prompt=True)
|
||||
# Recipes without gen_params/prompt normalize to empty prompt and match
|
||||
assert groups == {"abc:0.8\x1f": ["r1", "r2"]}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test for modelVersionId fallback in fingerprint calculation."""
|
||||
import pytest
|
||||
from py.utils.utils import calculate_recipe_fingerprint
|
||||
from py.utils.utils import calculate_recipe_fingerprint, normalize_prompt_for_dedup
|
||||
|
||||
|
||||
def test_calculate_fingerprint_with_model_version_id_fallback():
|
||||
@@ -98,3 +98,15 @@ def test_calculate_fingerprint_without_hash_or_version_id():
|
||||
]
|
||||
fingerprint = calculate_recipe_fingerprint(loras)
|
||||
assert fingerprint == ""
|
||||
|
||||
|
||||
def test_normalize_prompt_casefolds_and_collapses_whitespace():
|
||||
assert normalize_prompt_for_dedup("A Girl, blue hair") == "a girl, blue hair"
|
||||
assert normalize_prompt_for_dedup(" landscape \n\t with details ") == "landscape with details"
|
||||
assert normalize_prompt_for_dedup("MASTERPIECE, best quality") == "masterpiece, best quality"
|
||||
|
||||
|
||||
def test_normalize_prompt_handles_missing_or_non_string():
|
||||
assert normalize_prompt_for_dedup(None) == ""
|
||||
assert normalize_prompt_for_dedup("") == ""
|
||||
assert normalize_prompt_for_dedup(12345) == ""
|
||||
|
||||
Reference in New Issue
Block a user