mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
load_config re-read and re-parsed config.json, the secrets file, AND the template (running the recursive migration diff) on every call — with ~30 call sites in web request handlers, some hit 2-3x per request. Fast path: stat all three files (mtime_ns + size); when unchanged since the last successful load, return the already-parsed self.config (same aliasing semantics as before). The signature is taken AFTER load + migration so a migration write-back doesn't retrigger, and both save paths refresh it. Cross-process freshness is preserved by construction: a save from the other process bumps the file mtime, so the next load here re-reads — verified by a dedicated test. Same-second edits are caught by mtime_ns plus a size check. Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
"""Tests for load_config's mtime fast path (src/config_manager.py).
|
|
|
|
load_config used to re-read + re-parse config.json, the template (with a
|
|
recursive migration diff) and secrets on EVERY call — ~30 web request
|
|
handlers call it, some 2-3x per request. The fast path skips all of it
|
|
when the three files' (mtime_ns, size) signatures are unchanged.
|
|
|
|
The invariant that matters most: cross-process freshness — a save from
|
|
the web process must be picked up by the display process's next load.
|
|
That's guaranteed because the signature is re-stat'd on every call.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from src.config_manager import ConfigManager # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def mgr(tmp_path):
|
|
config = tmp_path / "config.json"
|
|
secrets = tmp_path / "secrets.json"
|
|
template = tmp_path / "template.json"
|
|
config.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
|
|
secrets.write_text(json.dumps({"weather": {"api_key": "sek"}}))
|
|
template.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
|
|
m = ConfigManager(config_path=str(config), secrets_path=str(secrets))
|
|
m.template_path = str(template)
|
|
return m, config, secrets, template
|
|
|
|
|
|
def _count_opens(monkeypatch, mgr_paths):
|
|
"""Count open() calls hitting the config files."""
|
|
counts = {"n": 0}
|
|
real_open = open
|
|
|
|
def counting_open(file, *args, **kwargs):
|
|
if str(file) in mgr_paths:
|
|
counts["n"] += 1
|
|
return real_open(file, *args, **kwargs)
|
|
|
|
import builtins
|
|
monkeypatch.setattr(builtins, "open", counting_open)
|
|
return counts
|
|
|
|
|
|
class TestFastPath:
|
|
def test_unchanged_files_are_not_reread(self, mgr, monkeypatch):
|
|
m, config, secrets, template = mgr
|
|
first = m.load_config()
|
|
assert first["weather"]["api_key"] == "sek" # secrets merged
|
|
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
|
|
for _ in range(10):
|
|
again = m.load_config()
|
|
assert counts["n"] == 0, "fast path must not re-open any config file"
|
|
assert again is first # same aliasing semantics as the full path
|
|
|
|
def test_config_change_triggers_reload(self, mgr):
|
|
m, config, secrets, template = mgr
|
|
m.load_config()
|
|
data = json.loads(config.read_text())
|
|
data["display"]["brightness"] = 55
|
|
config.write_text(json.dumps(data))
|
|
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
|
|
assert m.load_config()["display"]["brightness"] == 55
|
|
|
|
def test_secrets_change_triggers_reload(self, mgr):
|
|
m, config, secrets, template = mgr
|
|
m.load_config()
|
|
secrets.write_text(json.dumps({"weather": {"api_key": "NEW"}}))
|
|
os.utime(secrets, (os.stat(secrets).st_atime, os.stat(secrets).st_mtime + 2))
|
|
assert m.load_config()["weather"]["api_key"] == "NEW"
|
|
|
|
def test_template_change_triggers_reload_and_migration(self, mgr):
|
|
m, config, secrets, template = mgr
|
|
m.load_config()
|
|
template.write_text(json.dumps({
|
|
"display": {"brightness": 90}, "timezone": "UTC",
|
|
"brand_new_key": {"added": True}}))
|
|
os.utime(template, (os.stat(template).st_atime, os.stat(template).st_mtime + 2))
|
|
reloaded = m.load_config()
|
|
assert reloaded.get("brand_new_key") == {"added": True}
|
|
|
|
def test_same_second_edit_detected_via_mtime_ns_or_size(self, mgr):
|
|
"""Coarse-mtime same-second edits: size difference still busts it."""
|
|
m, config, secrets, template = mgr
|
|
m.load_config()
|
|
st = os.stat(config)
|
|
data = json.loads(config.read_text())
|
|
data["timezone"] = "America/New_York" # different byte length
|
|
config.write_text(json.dumps(data))
|
|
os.utime(config, (st.st_atime, st.st_mtime)) # force same mtime
|
|
assert m.load_config()["timezone"] == "America/New_York"
|
|
|
|
|
|
class TestSaveCoherence:
|
|
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
|
|
m, config, secrets, template = mgr
|
|
m.load_config()
|
|
new = {"display": {"brightness": 42}, "timezone": "UTC",
|
|
"weather": {"api_key": "sek"}}
|
|
m.save_config(new)
|
|
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
|
|
loaded = m.load_config()
|
|
assert loaded["display"]["brightness"] == 42
|
|
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
|
|
assert counts["n"] == 0 # signature refreshed by save; no re-read
|
|
|
|
def test_cross_process_save_is_picked_up(self, mgr):
|
|
"""Another process writing config.json (different mtime) must bust
|
|
this process's fast path — the core cross-process guarantee."""
|
|
m, config, secrets, template = mgr
|
|
m.load_config()
|
|
other = ConfigManager(config_path=str(config), secrets_path=str(secrets))
|
|
other.template_path = str(template)
|
|
other.load_config()
|
|
other.save_config({"display": {"brightness": 11}, "timezone": "UTC",
|
|
"weather": {"api_key": "sek"}})
|
|
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
|
|
assert m.load_config()["display"]["brightness"] == 11
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main([__file__, "-v"]))
|