Files
LEDMatrix/test/test_cache_manager_memory_tier.py
ChuckandClaude Opus 5.5 2601cb4cbb chore(deprecation): remove the 35 APIs deprecated for 3.8.0 (#708)
* chore(deprecation): remove the 35 APIs deprecated for 3.8.0

The usage scan (docs/DEPRECATIONS_3.8.md, regenerated 2026-10-01 and
committed here) finds no call or override of any of them in the 46
monorepo plugins or the 8 third-party plugins plugins.json lists; the
only core callers were other deprecated methods removed alongside.

- CacheManager: 13 methods, plus the private helpers only
  has_data_changed used (_has_*_changed, _is_market_open).
- DisplayManager: 7 methods, plus WEATHER_COLORS and the private
  _draw_sun/_cloud/_rain/_snow/_storm helpers only the icon methods used.
- FontManager: 14 methods, plus size_tokens, _save_overrides and
  _clear_plugin_font_cache. font_overrides and _load_overrides stay:
  resolve_font() still applies config/font_overrides.json.
  performance_stats stays: get_font() keeps it and tests read it.
- PluginManager.get_enabled_plugins.

test_deprecation.py pins only the two 3.9.0 markers now; the scanner
tests run against a stand-in core instead of the real markers. The
memory-tier tests read stats through log_memory_cache_stats() and the
component, and the test of the removed _clear_plugin_font_cache goes.
Docs drop the removed methods' reference entries; the Deprecated APIs
table becomes "Removed in 3.8.0". CHANGELOG gains a Removed section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(deprecation): drop the test harness's copies of the removed icon methods

VisualTestDisplayManager still drew weather icons that DisplayManager no
longer has, so a plugin's visual tests could pass on calls that raise
AttributeError on the real display. Its draw_sun/draw_cloud/draw_rain/
draw_snow/draw_weather_icon/draw_text_with_icons, WEATHER_COLORS and the
private helpers go, with the tests that exercised them. The CHANGELOG's
Deprecations entries no longer say nothing is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:45:28 -04:00

117 lines
4.1 KiB
Python

"""CacheManager's memory tier is MemoryCache's, not a copy of it.
CacheManager used to re-implement MemoryCache.cleanup() line for line and
read the component's private dicts and lock through aliases bound at
construction. Those aliases went stale the moment the component was replaced
(tests do exactly that), and the listing of the cache *directory* held the
memory tier's lock for the whole scan.
"""
import os
import time
from unittest.mock import patch
import pytest
from src.cache.memory_cache import MemoryCache
from src.cache_manager import CacheManager
@pytest.fixture
def cm(tmp_path):
with patch('src.cache_manager.CacheManager._get_writable_cache_dir',
return_value=str(tmp_path)):
manager = CacheManager()
# The disk sweep thread stats this directory too; keep it out of the
# os.stat spies below.
manager.stop_cleanup_thread()
yield manager
def test_cleanup_and_stats_follow_a_replaced_component(cm):
"""Replace the component the way test_cache_ttl_honoured does; cleanup and
stats must act on the new one, not on dicts captured at construction."""
cm._memory_cache_component = MemoryCache(max_size=7, cleanup_interval=11.0)
cm._memory_cache_component.set("fresh", {"v": 1})
cm._memory_cache_component.set("stale", {"v": 2})
cm._memory_cache_component._timestamps["stale"] = time.time() - 4000
assert cm._cleanup_memory_cache(force=True) == 1
assert cm._memory_cache_component.get("stale") is None
assert cm._memory_cache_component.get("fresh") == {"v": 1}
with patch.object(cm.logger, "info") as info:
cm.log_memory_cache_stats()
assert "Size: 1/7 (14.3%)" in info.call_args[0][0]
def test_periodic_cleanup_is_throttled_and_records_its_run(cm):
mem = cm._memory_cache_component
mem.set("stale", {"v": 1})
mem._timestamps["stale"] = time.time() - 4000
# Within the interval: nothing runs, even through the get path.
assert cm._cleanup_memory_cache() == 0
assert mem.size() == 1
mem._last_cleanup = time.time() - mem._cleanup_interval - 1
before = time.time()
cm.get_cached_data("missing") # triggers the periodic sweep
assert mem.size() == 0
assert mem.get_stats()["last_cleanup"] >= before
def test_memory_stats_log_reads_the_live_tier(cm):
cm.set("k", {"v": 1})
with patch.object(cm.logger, "info") as info:
cm.log_memory_cache_stats()
message = info.call_args[0][0]
assert f"Size: 1/{cm._memory_cache_component.max_size()}" in message
assert "Last cleanup:" in message
def test_listing_the_cache_dir_does_not_hold_the_memory_lock(cm, tmp_path):
"""8,864 files on a real rig: every get/set used to wait out the scan."""
for name in ("a", "b"):
(tmp_path / f"{name}.json").write_text("{}")
(tmp_path / "notes.txt").write_text("x")
lock = cm._memory_cache_component._lock
held_during_stat = []
real_stat = os.stat
def spying_stat(path, *args, **kwargs):
held_during_stat.append(lock.locked())
return real_stat(path, *args, **kwargs)
with patch('src.cache_manager.os.stat', side_effect=spying_stat):
files = cm.list_cache_files()
assert held_during_stat and not any(held_during_stat)
assert sorted(f["key"] for f in files) == ["a", "b"]
def test_listing_skips_a_file_deleted_mid_scan(cm, tmp_path):
for name in ("a", "b"):
(tmp_path / f"{name}.json").write_text("{}")
real_stat = os.stat
def vanishing_stat(path, *args, **kwargs):
if str(path).endswith("a.json"):
raise FileNotFoundError(path)
return real_stat(path, *args, **kwargs)
with patch('src.cache_manager.os.stat', side_effect=vanishing_stat):
files = cm.list_cache_files()
assert [f["key"] for f in files] == ["b"]
def test_listing_is_newest_first(cm, tmp_path):
now = time.time()
for i, name in enumerate(("old", "mid", "new")):
p = tmp_path / f"{name}.json"
p.write_text("{}")
os.utime(p, (now - 300 + i * 100, now - 300 + i * 100))
assert [f["key"] for f in cm.list_cache_files()] == ["new", "mid", "old"]