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:
Will Miao
2026-08-15 11:37:46 +08:00
parent 34c87d4934
commit c85b6b64a1
21 changed files with 502 additions and 22 deletions
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "Älteste", "dateAsc": "Älteste",
"lorasCount": "LoRA-Anzahl", "lorasCount": "LoRA-Anzahl",
"lorasCountDesc": "Meiste", "lorasCountDesc": "Meiste",
"lorasCountAsc": "Wenigste" "lorasCountAsc": "Wenigste",
"opened": "Zuletzt geöffnet",
"openedDesc": "Zuletzt geöffnet"
}, },
"refresh": { "refresh": {
"title": "Rezeptliste aktualisieren", "title": "Rezeptliste aktualisieren",
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "Oldest", "dateAsc": "Oldest",
"lorasCount": "LoRA Count", "lorasCount": "LoRA Count",
"lorasCountDesc": "Most", "lorasCountDesc": "Most",
"lorasCountAsc": "Least" "lorasCountAsc": "Least",
"opened": "Recently Opened",
"openedDesc": "Recently opened"
}, },
"refresh": { "refresh": {
"title": "Refresh recipe list", "title": "Refresh recipe list",
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "Más antiguo", "dateAsc": "Más antiguo",
"lorasCount": "Cant. de LoRAs", "lorasCount": "Cant. de LoRAs",
"lorasCountDesc": "Más", "lorasCountDesc": "Más",
"lorasCountAsc": "Menos" "lorasCountAsc": "Menos",
"opened": "Abiertos recientemente",
"openedDesc": "Abiertos recientemente"
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de recetas", "title": "Actualizar lista de recetas",
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "Plus ancien", "dateAsc": "Plus ancien",
"lorasCount": "Nombre de LoRAs", "lorasCount": "Nombre de LoRAs",
"lorasCountDesc": "Plus", "lorasCountDesc": "Plus",
"lorasCountAsc": "Moins" "lorasCountAsc": "Moins",
"opened": "Récemment ouverts",
"openedDesc": "Récemment ouverts"
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes", "title": "Actualiser la liste des recipes",
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "הכי ישן", "dateAsc": "הכי ישן",
"lorasCount": "מספר LoRAs", "lorasCount": "מספר LoRAs",
"lorasCountDesc": "הכי הרבה", "lorasCountDesc": "הכי הרבה",
"lorasCountAsc": "הכי פחות" "lorasCountAsc": "הכי פחות",
"opened": "נפתחו לאחרונה",
"openedDesc": "נפתחו לאחרונה"
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים", "title": "רענן רשימת מתכונים",
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "古い順", "dateAsc": "古い順",
"lorasCount": "LoRA数", "lorasCount": "LoRA数",
"lorasCountDesc": "多い順", "lorasCountDesc": "多い順",
"lorasCountAsc": "少ない順" "lorasCountAsc": "少ない順",
"opened": "最近開いた",
"openedDesc": "最近開いた"
}, },
"refresh": { "refresh": {
"title": "レシピリストを更新", "title": "レシピリストを更新",
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "오래된순", "dateAsc": "오래된순",
"lorasCount": "LoRA 수", "lorasCount": "LoRA 수",
"lorasCountDesc": "많은순", "lorasCountDesc": "많은순",
"lorasCountAsc": "적은순" "lorasCountAsc": "적은순",
"opened": "최근에 연",
"openedDesc": "최근에 연"
}, },
"refresh": { "refresh": {
"title": "레시피 목록 새로고침", "title": "레시피 목록 새로고침",
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "Сначала старые", "dateAsc": "Сначала старые",
"lorasCount": "Кол-во LoRA", "lorasCount": "Кол-во LoRA",
"lorasCountDesc": "Больше всего", "lorasCountDesc": "Больше всего",
"lorasCountAsc": "Меньше всего" "lorasCountAsc": "Меньше всего",
"opened": "Недавно открытые",
"openedDesc": "Недавно открытые"
}, },
"refresh": { "refresh": {
"title": "Обновить список рецептов", "title": "Обновить список рецептов",
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "最早", "dateAsc": "最早",
"lorasCount": "LoRA 数量", "lorasCount": "LoRA 数量",
"lorasCountDesc": "最多", "lorasCountDesc": "最多",
"lorasCountAsc": "最少" "lorasCountAsc": "最少",
"opened": "最近打开",
"openedDesc": "最近打开"
}, },
"refresh": { "refresh": {
"title": "刷新配方列表", "title": "刷新配方列表",
+3 -1
View File
@@ -920,7 +920,9 @@
"dateAsc": "最舊", "dateAsc": "最舊",
"lorasCount": "LoRA 數量", "lorasCount": "LoRA 數量",
"lorasCountDesc": "最多", "lorasCountDesc": "最多",
"lorasCountAsc": "最少" "lorasCountAsc": "最少",
"opened": "最近開啟",
"openedDesc": "最近開啟"
}, },
"refresh": { "refresh": {
"title": "重新整理配方列表", "title": "重新整理配方列表",
+29
View File
@@ -34,6 +34,7 @@ from ...utils.civitai_utils import (
) )
from ...utils.constants import NSFW_LEVELS from ...utils.constants import NSFW_LEVELS
from ...utils.exif_utils import ExifUtils from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager from ...services.websocket_manager import ws_manager as default_ws_manager
@@ -98,6 +99,7 @@ class RecipeHandlerSet:
"download_shared_recipe": self.sharing.download_shared_recipe, "download_shared_recipe": self.sharing.download_shared_recipe,
"get_recipe_syntax": self.query.get_recipe_syntax, "get_recipe_syntax": self.query.get_recipe_syntax,
"update_recipe": self.management.update_recipe, "update_recipe": self.management.update_recipe,
"record_recipe_open": self.management.record_recipe_open,
"reconnect_lora": self.management.reconnect_lora, "reconnect_lora": self.management.reconnect_lora,
"find_duplicates": self.query.find_duplicates, "find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk, "move_recipes_bulk": self.management.move_recipes_bulk,
@@ -1458,6 +1460,33 @@ class RecipeManagementHandler:
self._logger.error("Error updating recipe: %s", exc, exc_info=True) self._logger.error("Error updating recipe: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def record_recipe_open(self, request: web.Request) -> web.Response:
"""Record that a recipe's detail modal was opened.
Lightweight fire-and-forget endpoint backing the "Recently Opened"
sort. It only writes the timestamp into the separate open-stats file
— recipe JSON and EXIF are never touched.
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
recipe_id = request.match_info["recipe_id"]
# Skip recording opens for recipes the scanner no longer knows.
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_json_path:
return web.json_response(
{"success": False, "error": "Recipe not found"}, status=404
)
RecipeOpenStats().record_open(recipe_id)
return web.json_response({"success": True})
except Exception as exc:
self._logger.error("Error recording recipe open: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_recipe(self, request: web.Request) -> web.Response: async def move_recipe(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
+3
View File
@@ -43,6 +43,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
), ),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/opened", "record_recipe_open"
),
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
+21 -4
View File
@@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Un
from ..config import config from ..config import config
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
from ..utils.file_utils import calculate_autov3 from ..utils.file_utils import calculate_autov3
from ..utils.recipe_open_stats import RecipeOpenStats
from .recipe_cache import RecipeCache from .recipe_cache import RecipeCache
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
from natsort import natsorted from natsort import natsorted
@@ -2782,9 +2783,11 @@ class RecipeScanner:
Args: Args:
page: Current page number (1-based) page: Current page number (1-based)
page_size: Number of items per page page_size: Number of items per page
sort_by: Sort method ('name', 'date', 'loras_count', or 'random' sort_by: Sort method ('name', 'date', 'loras_count', 'opened',
with an optional seed like 'random:abc123'; the part after or 'random' with an optional seed like 'random:abc123'; the
'random:' is the shuffle seed, not a direction) part after 'random:' is the shuffle seed, not a direction).
'opened' hides recipes that were never opened it is a
"recently opened" view, not a plain reorder
search: Search term search: Search term
filters: Dictionary of filters to apply filters: Dictionary of filters to apply
search_options: Dictionary of search options to apply search_options: Dictionary of search options to apply
@@ -2965,7 +2968,7 @@ class RecipeScanner:
] ]
# Apply sorting if not already handled by pre-sorted cache # Apply sorting if not already handled by pre-sorted cache
if ":" in sort_by or sort_field in ("loras_count", "random"): if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
field, order = (sort_by.split(":") + ["desc"])[:2] field, order = (sort_by.split(":") + ["desc"])[:2]
reverse = order.lower() == "desc" reverse = order.lower() == "desc"
@@ -2984,6 +2987,20 @@ class RecipeScanner:
), ),
reverse=reverse, reverse=reverse,
) )
elif field == "opened":
# "Recently Opened" view: recipes never opened are hidden.
# The open stats live outside recipe metadata; see
# RecipeOpenStats.
opened_map = RecipeOpenStats().get_opened_map()
filtered_data = [
item
for item in filtered_data
if opened_map.get(str(item.get("id", ""))) is not None
]
filtered_data.sort(
key=lambda x: opened_map.get(str(x.get("id", "")), 0),
reverse=reverse,
)
elif field == "loras_count": elif field == "loras_count":
filtered_data.sort( filtered_data.sort(
key=lambda x: len(x.get("loras", [])), reverse=reverse key=lambda x: len(x.get("loras", [])), reverse=reverse
+161
View File
@@ -0,0 +1,161 @@
"""Track recipe modal open timestamps for the "Recently Opened" sort.
The data is deliberately kept OUTSIDE the recipe metadata files: recording an
open must be cheap and must never rewrite recipe JSON or EXIF (which the
generic metadata update path does). A tiny JSON map of
``recipe_id -> unix timestamp`` lives under
``{settings_dir}/stats/recipe_last_opened.json`` and is written atomically on
a short debounce.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
from ..utils.settings_paths import get_settings_dir
logger = logging.getLogger(__name__)
class RecipeOpenStats:
"""Persist the last time each recipe was opened in the recipe modal."""
STATS_FILENAME: str = "recipe_last_opened.json"
SAVE_DELAY: float = 1.0 # seconds of debounce between consecutive writes
_instance: "RecipeOpenStats | None" = None
_opened: dict[str, float]
_file_mtime: float | None
_dirty: bool
_lock: asyncio.Lock
_save_task: "asyncio.Task[None] | None"
_stats_file_path: str
_initialized: bool
def __new__(cls) -> "RecipeOpenStats":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self) -> None:
if getattr(self, "_initialized", False):
return
self._opened = {}
self._file_mtime = None
self._dirty = False
self._lock = asyncio.Lock()
self._save_task = None
self._stats_file_path = self._get_stats_file_path()
self._load_stats()
self._initialized = True
def _get_stats_file_path(self) -> str:
settings_dir = get_settings_dir(create=True)
return os.path.join(settings_dir, "stats", self.STATS_FILENAME)
def _load_stats(self) -> None:
"""Load the opened map from disk, tolerating corrupt/absent files.
The mtime is recorded even when parsing fails so a corrupt file is
not re-read (and re-logged) on every lookup.
"""
if not os.path.exists(self._stats_file_path):
return
try:
mtime = os.path.getmtime(self._stats_file_path)
except OSError:
return
try:
with open(self._stats_file_path, "r", encoding="utf-8") as file_obj:
raw = json.load(file_obj)
if isinstance(raw, dict):
self._opened = {
str(key): float(value)
for key, value in raw.items()
if isinstance(value, (int, float))
}
except Exception as exc: # pragma: no cover - defensive logging path
logger.error("Error loading recipe open stats: %s", exc)
self._opened = {}
self._file_mtime = mtime
def get_opened_map(self) -> dict[str, float]:
"""Return a copy of ``recipe_id -> last opened timestamp``.
Refreshes from disk when the file changed since the last load so a
second server process (or manual edit) is picked up without restart.
"""
try:
if os.path.exists(self._stats_file_path):
mtime = os.path.getmtime(self._stats_file_path)
if self._file_mtime is None or mtime != self._file_mtime:
self._load_stats()
except OSError:
pass
return dict(self._opened)
def record_open(self, recipe_id: str) -> None:
"""Mark a recipe as opened now; persists shortly in the background."""
if not recipe_id:
return
self._opened[str(recipe_id)] = time.time()
self._dirty = True
if self._save_task is None or self._save_task.done():
self._save_task = asyncio.create_task(self._delayed_save())
async def _delayed_save(self) -> None:
"""Debounced writer: batches rapid consecutive opens into one write."""
await asyncio.sleep(self.SAVE_DELAY)
_ = await self.save_stats()
async def save_stats(self, force: bool = False) -> bool:
"""Persist the opened map atomically if dirty (or when forced).
The on-disk map is merged in first so a second process sharing the
settings dir does not lose its entries; the larger timestamp wins
per recipe.
"""
if not force and not self._dirty:
return False
async with self._lock:
if not force and not self._dirty:
return False
try:
merged = self._merge_with_disk()
os.makedirs(os.path.dirname(self._stats_file_path), exist_ok=True)
temp_path = f"{self._stats_file_path}.tmp"
with open(temp_path, "w", encoding="utf-8") as file_obj:
json.dump(merged, file_obj, indent=2)
os.replace(temp_path, self._stats_file_path)
self._opened = merged
self._file_mtime = os.path.getmtime(self._stats_file_path)
self._dirty = False
return True
except Exception as exc: # pragma: no cover - defensive logging path
logger.error("Error saving recipe open stats: %s", exc, exc_info=True)
return False
def _merge_with_disk(self) -> dict[str, float]:
"""Merge the in-memory map with the current on-disk map."""
disk: dict[str, float] = {}
try:
if os.path.exists(self._stats_file_path):
with open(self._stats_file_path, "r", encoding="utf-8") as file_obj:
raw = json.load(file_obj)
if isinstance(raw, dict):
disk = {
str(key): float(value)
for key, value in raw.items()
if isinstance(value, (int, float))
}
except Exception as exc: # pragma: no cover - defensive logging path
logger.error("Error reading recipe open stats for merge: %s", exc)
merged = dict(disk)
for key, value in self._opened.items():
merged[key] = max(value, disk.get(key, 0.0))
return merged
+8
View File
@@ -306,6 +306,14 @@ class RecipeModal {
modalManager.showModal('recipeModal'); modalManager.showModal('recipeModal');
if (this.recipeId) { if (this.recipeId) {
// Fire-and-forget: record this open for the "Recently Opened"
// sort. Tracking must never disturb the modal, so failures are
// swallowed.
fetch(`/api/lm/recipe/${encodeURIComponent(this.recipeId)}/opened`, {
method: 'POST',
keepalive: true,
}).catch(() => {});
const hydrationRequestId = ++this.recipeHydrationRequestId; const hydrationRequestId = ++this.recipeHydrationRequestId;
const requestEditVersions = this.captureLocalEditVersions(); const requestEditVersions = this.captureLocalEditVersions();
this.hydrateRecipeDetails( this.hydrateRecipeDetails(
+11 -4
View File
@@ -646,10 +646,17 @@ export class MasonryScroller {
const pageType = state.currentPageType; const pageType = state.currentPageType;
if (pageType === 'recipes') { if (pageType === 'recipes') {
placeholderText = ` if (String(getCurrentPageState().sortBy).startsWith('opened')) {
<p>No recipes found</p> placeholderText = `
<p>Add recipe images to your recipes folder to see them here.</p> <p>No recently opened recipes</p>
`; <p>Recipes you open will appear here.</p>
`;
} else {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
}
} else if (pageType === 'loras') { } else if (pageType === 'loras') {
placeholderText = ` placeholderText = `
<p>No LoRAs found</p> <p>No LoRAs found</p>
+11 -4
View File
@@ -699,10 +699,17 @@ export class VirtualScroller {
const pageType = state.currentPageType; const pageType = state.currentPageType;
if (pageType === 'recipes') { if (pageType === 'recipes') {
placeholderText = ` if (String(getCurrentPageState().sortBy).startsWith('opened')) {
<p>No recipes found</p> placeholderText = `
<p>Add recipe images to your recipes folder to see them here.</p> <p>No recently opened recipes</p>
`; <p>Recipes you open will appear here.</p>
`;
} else {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
}
} else if (pageType === 'loras') { } else if (pageType === 'loras') {
placeholderText = ` placeholderText = `
<p>No LoRAs found</p> <p>No LoRAs found</p>
+5
View File
@@ -54,6 +54,11 @@
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option> <option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup> </optgroup>
{% endif %} {% endif %}
{% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.opened', default='Recently Opened') }}">
<option value="opened:desc">{{ t('recipes.controls.sort.openedDesc', default='Recently opened') }}</option>
</optgroup>
{% endif %}
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}"> <optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option> <option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
</optgroup> </optgroup>
@@ -324,6 +324,18 @@ describe('MasonryScroller', () => {
expect(placeholder.textContent).toContain('No recipes found'); 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', () => { it('dispose removes classes, spacer and event listeners', () => {
const { scroller, grid } = track(createScroller()); const { scroller, grid } = track(createScroller());
+52
View File
@@ -1095,6 +1095,58 @@ async def test_get_paginated_data_random_sort(recipe_scanner):
assert len(set(combined)) == 3 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): async def test_build_image_id_map_filters_correctly(recipe_scanner):
"""Only recipes with valid CivitAI source_path appear in image_id_map. """Only recipes with valid CivitAI source_path appear in image_id_map.
+159
View File
@@ -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() == {}