mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-15 18:23:21 -03:00
feat(recipes): add recently opened sort with modal open tracking
Track recipe modal opens in a separate stats file (never touching recipe JSON/EXIF), expose a fire-and-forget POST endpoint, and add an 'opened' sort that hides never-opened recipes as a true recently-opened view. Includes i18n for all locales and backend/frontend tests.
This commit is contained in:
@@ -324,6 +324,18 @@ describe('MasonryScroller', () => {
|
||||
expect(placeholder.textContent).toContain('No recipes found');
|
||||
});
|
||||
|
||||
it('shows the recently-opened empty placeholder under the opened sort', async () => {
|
||||
getCurrentPageState().sortBy = 'opened:desc';
|
||||
const { scroller, grid } = track(createScroller({ items: [] }));
|
||||
|
||||
await scroller.initialize();
|
||||
|
||||
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
|
||||
expect(placeholder).not.toBeNull();
|
||||
expect(placeholder.textContent).toContain('No recently opened recipes');
|
||||
getCurrentPageState().sortBy = '';
|
||||
});
|
||||
|
||||
it('dispose removes classes, spacer and event listeners', () => {
|
||||
const { scroller, grid } = track(createScroller());
|
||||
|
||||
|
||||
@@ -1095,6 +1095,58 @@ async def test_get_paginated_data_random_sort(recipe_scanner):
|
||||
assert len(set(combined)) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_opened_sort(recipe_scanner, monkeypatch):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]:
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": rid,
|
||||
"title": title,
|
||||
"created_date": 10.0,
|
||||
"loras": [{}],
|
||||
"file_path": f"{rid.lower()}.png",
|
||||
}
|
||||
)
|
||||
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_resort(scanner)
|
||||
|
||||
class _FakeStats:
|
||||
def get_opened_map(self):
|
||||
return {"B": 300.0, "C": 200.0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.recipe_scanner.RecipeOpenStats", lambda: _FakeStats()
|
||||
)
|
||||
|
||||
# Never-opened A is hidden from the view; B (300) > C (200)
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc")
|
||||
assert [i["id"] for i in res["items"]] == ["B", "C"]
|
||||
assert res["total"] == 2
|
||||
|
||||
# ASC: C (200) < B (300)
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:asc")
|
||||
assert [i["id"] for i in res["items"]] == ["C", "B"]
|
||||
|
||||
# Plain "opened" (no direction) behaves like desc by default
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened")
|
||||
assert [i["id"] for i in res["items"]] == ["B", "C"]
|
||||
|
||||
# When nothing was opened the view is empty (not a fallback reorder)
|
||||
class _EmptyStats:
|
||||
def get_opened_map(self):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.recipe_scanner.RecipeOpenStats", lambda: _EmptyStats()
|
||||
)
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc")
|
||||
assert res["items"] == []
|
||||
assert res["total"] == 0
|
||||
|
||||
|
||||
async def test_build_image_id_map_filters_correctly(recipe_scanner):
|
||||
"""Only recipes with valid CivitAI source_path appear in image_id_map.
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils import recipe_open_stats as stats_module
|
||||
from py.utils.recipe_open_stats import RecipeOpenStats
|
||||
|
||||
|
||||
async def _finalize(tasks) -> None:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
RecipeOpenStats._instance = None
|
||||
|
||||
|
||||
def _prepare(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
RecipeOpenStats._instance = None
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(
|
||||
stats_module, "get_settings_dir", lambda create=True: str(settings_dir)
|
||||
)
|
||||
created_tasks = []
|
||||
real_create_task = stats_module.asyncio.create_task
|
||||
|
||||
def _track_task(coro):
|
||||
task = real_create_task(coro)
|
||||
created_tasks.append(task)
|
||||
return task
|
||||
|
||||
monkeypatch.setattr(stats_module.asyncio, "create_task", _track_task)
|
||||
return RecipeOpenStats(), created_tasks, settings_dir
|
||||
|
||||
|
||||
async def _wait_for_save(stats_file: Path) -> None:
|
||||
for _ in range(100):
|
||||
if stats_file.exists():
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
raise AssertionError("Recipe open stats file was never written")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_open_persists_timestamp(tmp_path, monkeypatch):
|
||||
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
|
||||
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
|
||||
|
||||
stats.record_open("abc-123")
|
||||
await _wait_for_save(stats_file)
|
||||
|
||||
data = json.loads(stats_file.read_text(encoding="utf-8"))
|
||||
assert isinstance(data["abc-123"], float)
|
||||
await _finalize(tasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_open_updates_existing_entry(tmp_path, monkeypatch):
|
||||
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
|
||||
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
|
||||
|
||||
stats.record_open("r1")
|
||||
await _wait_for_save(stats_file)
|
||||
first = json.loads(stats_file.read_text(encoding="utf-8"))["r1"]
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
stats.record_open("r1")
|
||||
await stats.save_stats(force=True)
|
||||
|
||||
second = json.loads(stats_file.read_text(encoding="utf-8"))["r1"]
|
||||
assert second > first
|
||||
await _finalize(tasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_opened_map_reloads_on_file_change(tmp_path, monkeypatch):
|
||||
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
|
||||
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
|
||||
|
||||
stats.record_open("r1")
|
||||
await _wait_for_save(stats_file)
|
||||
|
||||
stats_file.write_text(json.dumps({"r2": 500.0}), encoding="utf-8")
|
||||
opened_map = stats.get_opened_map()
|
||||
assert opened_map == {"r2": 500.0}
|
||||
await _finalize(tasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_merges_entries_written_by_another_process(tmp_path, monkeypatch):
|
||||
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
|
||||
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
|
||||
|
||||
stats.record_open("r1")
|
||||
await _wait_for_save(stats_file)
|
||||
first_ts = json.loads(stats_file.read_text(encoding="utf-8"))["r1"]
|
||||
|
||||
# Another process writes its own entry plus a newer timestamp for r1
|
||||
stats_file.write_text(
|
||||
json.dumps({"r1": first_ts + 100000.0, "r2": 500.0}), encoding="utf-8"
|
||||
)
|
||||
|
||||
stats.record_open("r3")
|
||||
await stats.save_stats(force=True)
|
||||
|
||||
data = json.loads(stats_file.read_text(encoding="utf-8"))
|
||||
# r2 from the other process survives; r1 keeps the newer disk timestamp;
|
||||
# r3 from this process is added
|
||||
assert data["r1"] == first_ts + 100000.0
|
||||
assert data["r2"] == 500.0
|
||||
assert isinstance(data["r3"], float)
|
||||
await _finalize(tasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_opened_map_returns_copy(tmp_path, monkeypatch):
|
||||
stats, tasks, _ = _prepare(tmp_path, monkeypatch)
|
||||
stats.record_open("r1")
|
||||
|
||||
opened_map = stats.get_opened_map()
|
||||
opened_map["injected"] = 1.0
|
||||
assert "injected" not in stats.get_opened_map()
|
||||
await _finalize(tasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_stats_file_returns_empty_map(tmp_path, monkeypatch):
|
||||
stats, tasks, _ = _prepare(tmp_path, monkeypatch)
|
||||
assert stats.get_opened_map() == {}
|
||||
await _finalize(tasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_stats_skips_when_not_dirty(tmp_path, monkeypatch):
|
||||
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
|
||||
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
|
||||
|
||||
assert await stats.save_stats() is False
|
||||
assert not stats_file.exists()
|
||||
await _finalize(tasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_ignores_corrupt_file(tmp_path, monkeypatch):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
|
||||
stats_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
stats_file.write_text("{not valid json", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
stats_module, "get_settings_dir", lambda create=True: str(settings_dir)
|
||||
)
|
||||
RecipeOpenStats._instance = None
|
||||
stats = RecipeOpenStats()
|
||||
assert stats.get_opened_map() == {}
|
||||
Reference in New Issue
Block a user