Files
ComfyUI-Lora-Manager/tests/utils/test_recipe_open_stats.py
T
Will Miao 8d46d26abe fix(tests): deflake recipe open stats tests by shrinking debounce in tests
The four tests that wait on the background debounced write race against
SAVE_DELAY (1.0s): _wait_for_save polls 100 x 0.01s = 1.0s, exactly equal to
the debounce, leaving zero slack. On a loaded CI runner the write lands after
the poll gives up, failing intermittently with 'Recipe open stats file was
never written' (5 of 62 backend runs since the tests landed).

Shrink SAVE_DELAY to 0.05s in _prepare so the write lands ~20x inside the
poll window. The debounce duration is not what these tests verify; production
default stays 1.0s.
2026-08-30 22:14:04 +08:00

165 lines
5.4 KiB
Python

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)
)
# Shrink the debounce well below the 100 x 0.01s poll window in
# _wait_for_save so the background write always lands with a wide margin.
# The debounce duration is not what these tests verify; SAVE_DELAY stays
# at its production default (1.0s) outside this test module.
monkeypatch.setattr(RecipeOpenStats, "SAVE_DELAY", 0.05)
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() == {}