Spaces:
Build error
test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths
Browse filesAdds 17 targeted tests to close the coverage gap on the new traffic_learner
paths (codecov flagged ~51%). Exercises:
- `flush_to_file` end-to-end with a fake learn plugin + writer: verifies
anchored patterns are bucketed per project, recommendations are passed
to the writer, writer exceptions are swallowed, and each early-return
branch (no plugin, no patterns, discover_projects failure, un-anchored
patterns) is hit without raising.
- `_resolve_backend_db_path` on None backend, backend without
`_config`, and backend with empty `db_path`.
- `_collect_all_patterns` merging persisted + accumulator patterns by
content_hash with summed evidence_count, plus the missing-DB branch.
- `_hydrate_persisted_state` with backend=None and with a backend
pointing at a non-existent DB file (both no-ops).
- `_bump_persisted_evidence` with no backend, missing DB, and
unknown memory id (all silent no-ops so the proxy hot path never
blows up on malformed state).
- `stop()` cancelling the flush task cleanly.
All new tests use the existing `_FakeBackend` + `_init_db` helpers so
they exercise real SQLite paths, not mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@@ -753,3 +753,326 @@ class TestEvidencePersistence:
|
|
| 753 |
assert len(rows) == 1
|
| 754 |
assert rows[0][0] == "seed-id"
|
| 755 |
assert rows[0][2]["evidence_count"] == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
assert len(rows) == 1
|
| 754 |
assert rows[0][0] == "seed-id"
|
| 755 |
assert rows[0][2]["evidence_count"] == 4
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
# =============================================================================
|
| 759 |
+
# flush_to_file end-to-end + early-return paths
|
| 760 |
+
# =============================================================================
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
class _FakeWriteResult:
|
| 764 |
+
def __init__(self, files_written):
|
| 765 |
+
self.files_written = files_written
|
| 766 |
+
|
| 767 |
+
|
| 768 |
+
class _FakeWriter:
|
| 769 |
+
def __init__(self):
|
| 770 |
+
self.calls: list[tuple] = []
|
| 771 |
+
self.files_to_return: list = []
|
| 772 |
+
self.raise_on_write = False
|
| 773 |
+
|
| 774 |
+
def write(self, recommendations, project, *, dry_run):
|
| 775 |
+
self.calls.append((list(recommendations), project, dry_run))
|
| 776 |
+
if self.raise_on_write:
|
| 777 |
+
raise RuntimeError("boom")
|
| 778 |
+
return _FakeWriteResult(list(self.files_to_return))
|
| 779 |
+
|
| 780 |
+
|
| 781 |
+
class _FakePlugin:
|
| 782 |
+
def __init__(self, roots, writer, discover_raises=False):
|
| 783 |
+
self._roots = roots
|
| 784 |
+
self._writer = writer
|
| 785 |
+
self._discover_raises = discover_raises
|
| 786 |
+
|
| 787 |
+
def discover_projects(self):
|
| 788 |
+
if self._discover_raises:
|
| 789 |
+
raise RuntimeError("discover blew up")
|
| 790 |
+
return list(self._roots)
|
| 791 |
+
|
| 792 |
+
def create_writer(self):
|
| 793 |
+
return self._writer
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
def _install_plugin_registry(monkeypatch, plugin):
|
| 797 |
+
"""Stub out headroom.learn.registry so flush_to_file uses our fake."""
|
| 798 |
+
import sys
|
| 799 |
+
import types as _types
|
| 800 |
+
|
| 801 |
+
fake = _types.ModuleType("headroom.learn.registry")
|
| 802 |
+
fake.auto_detect_plugins = lambda: [plugin] if plugin is not None else [] # type: ignore[attr-defined]
|
| 803 |
+
fake.get_plugin = lambda agent_type: plugin # type: ignore[attr-defined]
|
| 804 |
+
monkeypatch.setitem(sys.modules, "headroom.learn.registry", fake)
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def _make_project(path):
|
| 808 |
+
from pathlib import Path as _P
|
| 809 |
+
|
| 810 |
+
from headroom.learn.models import ProjectInfo
|
| 811 |
+
|
| 812 |
+
p = _P(path)
|
| 813 |
+
return ProjectInfo(name=p.name, project_path=p, data_path=p)
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
class TestFlushToFile:
|
| 817 |
+
@pytest.mark.asyncio
|
| 818 |
+
async def test_end_to_end_writes_per_project(self, tmp_path, monkeypatch):
|
| 819 |
+
"""Happy path: anchored patterns → bucketed per project → writer called."""
|
| 820 |
+
db = tmp_path / "memory.db"
|
| 821 |
+
_init_db(db)
|
| 822 |
+
backend = _FakeBackend(db)
|
| 823 |
+
|
| 824 |
+
learner = TrafficLearner(backend=backend, agent_type="claude", min_evidence=2)
|
| 825 |
+
writer = _FakeWriter()
|
| 826 |
+
writer.files_to_return = [tmp_path / "CLAUDE.md"]
|
| 827 |
+
proj = _make_project(str(tmp_path))
|
| 828 |
+
plugin = _FakePlugin(roots=[proj], writer=writer)
|
| 829 |
+
_install_plugin_registry(monkeypatch, plugin)
|
| 830 |
+
|
| 831 |
+
# Need the save worker running so accumulated patterns actually land in
|
| 832 |
+
# the DB where flush_to_file reads them.
|
| 833 |
+
await learner.start()
|
| 834 |
+
try:
|
| 835 |
+
|
| 836 |
+
def mk() -> ExtractedPattern:
|
| 837 |
+
return ExtractedPattern(
|
| 838 |
+
category=PatternCategory.ENVIRONMENT,
|
| 839 |
+
content=f"Use /usr/bin/python3 at {tmp_path}/main.py",
|
| 840 |
+
importance=0.6,
|
| 841 |
+
)
|
| 842 |
+
|
| 843 |
+
# Two sightings → save at evidence_count=2 (crosses live-flush gate).
|
| 844 |
+
await learner._accumulate(mk())
|
| 845 |
+
await learner._accumulate(mk())
|
| 846 |
+
await _wait_for_saved(learner, 1, db)
|
| 847 |
+
|
| 848 |
+
await learner.flush_to_file()
|
| 849 |
+
finally:
|
| 850 |
+
await learner.stop()
|
| 851 |
+
|
| 852 |
+
assert len(writer.calls) >= 1
|
| 853 |
+
recs, written_proj, dry_run = writer.calls[0]
|
| 854 |
+
assert dry_run is False
|
| 855 |
+
assert written_proj is proj
|
| 856 |
+
assert len(recs) == 1
|
| 857 |
+
assert "python3" in recs[0].content
|
| 858 |
+
|
| 859 |
+
@pytest.mark.asyncio
|
| 860 |
+
async def test_early_returns_no_plugin(self, monkeypatch):
|
| 861 |
+
"""No plugin detected → flush is a no-op."""
|
| 862 |
+
learner = TrafficLearner(backend=None, agent_type="unknown", min_evidence=1)
|
| 863 |
+
_install_plugin_registry(monkeypatch, None)
|
| 864 |
+
# Seed an accumulator entry so the check isn't vacuously "no patterns".
|
| 865 |
+
learner._pattern_counts["h"] = (
|
| 866 |
+
ExtractedPattern(
|
| 867 |
+
category=PatternCategory.ENVIRONMENT,
|
| 868 |
+
content="x",
|
| 869 |
+
importance=0.5,
|
| 870 |
+
evidence_count=2,
|
| 871 |
+
),
|
| 872 |
+
2,
|
| 873 |
+
)
|
| 874 |
+
await learner.flush_to_file() # returns without raising
|
| 875 |
+
|
| 876 |
+
@pytest.mark.asyncio
|
| 877 |
+
async def test_early_return_no_patterns(self, monkeypatch):
|
| 878 |
+
"""Empty accumulator and empty DB → flush returns without calling writer."""
|
| 879 |
+
writer = _FakeWriter()
|
| 880 |
+
plugin = _FakePlugin(roots=[_make_project("/x/a")], writer=writer)
|
| 881 |
+
_install_plugin_registry(monkeypatch, plugin)
|
| 882 |
+
|
| 883 |
+
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
|
| 884 |
+
await learner.flush_to_file()
|
| 885 |
+
assert writer.calls == []
|
| 886 |
+
|
| 887 |
+
@pytest.mark.asyncio
|
| 888 |
+
async def test_discover_projects_failure_is_swallowed(self, monkeypatch):
|
| 889 |
+
"""If plugin.discover_projects raises, flush logs and returns."""
|
| 890 |
+
writer = _FakeWriter()
|
| 891 |
+
plugin = _FakePlugin(roots=[], writer=writer, discover_raises=True)
|
| 892 |
+
_install_plugin_registry(monkeypatch, plugin)
|
| 893 |
+
|
| 894 |
+
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
|
| 895 |
+
learner._pattern_counts["h"] = (
|
| 896 |
+
ExtractedPattern(
|
| 897 |
+
category=PatternCategory.ENVIRONMENT,
|
| 898 |
+
content="whatever",
|
| 899 |
+
importance=0.5,
|
| 900 |
+
evidence_count=2,
|
| 901 |
+
),
|
| 902 |
+
2,
|
| 903 |
+
)
|
| 904 |
+
await learner.flush_to_file()
|
| 905 |
+
assert writer.calls == [] # no roots → short-circuits before writer
|
| 906 |
+
|
| 907 |
+
@pytest.mark.asyncio
|
| 908 |
+
async def test_unanchored_patterns_dropped(self, tmp_path, monkeypatch):
|
| 909 |
+
"""Patterns with no path anchoring are dropped before writer is called."""
|
| 910 |
+
writer = _FakeWriter()
|
| 911 |
+
plugin = _FakePlugin(roots=[_make_project(str(tmp_path))], writer=writer)
|
| 912 |
+
_install_plugin_registry(monkeypatch, plugin)
|
| 913 |
+
|
| 914 |
+
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
|
| 915 |
+
# Content has no absolute path — should be dropped as un-anchored.
|
| 916 |
+
learner._pattern_counts["h"] = (
|
| 917 |
+
ExtractedPattern(
|
| 918 |
+
category=PatternCategory.PREFERENCE,
|
| 919 |
+
content="User preference: use terse output",
|
| 920 |
+
importance=0.7,
|
| 921 |
+
evidence_count=2,
|
| 922 |
+
),
|
| 923 |
+
2,
|
| 924 |
+
)
|
| 925 |
+
await learner.flush_to_file()
|
| 926 |
+
assert writer.calls == []
|
| 927 |
+
|
| 928 |
+
@pytest.mark.asyncio
|
| 929 |
+
async def test_writer_exception_does_not_propagate(self, tmp_path, monkeypatch):
|
| 930 |
+
"""A writer raising should be logged; flush must not bubble the error."""
|
| 931 |
+
writer = _FakeWriter()
|
| 932 |
+
writer.raise_on_write = True
|
| 933 |
+
plugin = _FakePlugin(roots=[_make_project(str(tmp_path))], writer=writer)
|
| 934 |
+
_install_plugin_registry(monkeypatch, plugin)
|
| 935 |
+
|
| 936 |
+
learner = TrafficLearner(backend=None, agent_type="claude", min_evidence=1)
|
| 937 |
+
learner._pattern_counts["h"] = (
|
| 938 |
+
ExtractedPattern(
|
| 939 |
+
category=PatternCategory.ENVIRONMENT,
|
| 940 |
+
content=f"Use {tmp_path}/tool.py",
|
| 941 |
+
importance=0.6,
|
| 942 |
+
evidence_count=2,
|
| 943 |
+
),
|
| 944 |
+
2,
|
| 945 |
+
)
|
| 946 |
+
await learner.flush_to_file() # must not raise
|
| 947 |
+
assert len(writer.calls) == 1
|
| 948 |
+
|
| 949 |
+
|
| 950 |
+
# =============================================================================
|
| 951 |
+
# Internal helper edge cases — _resolve_backend_db_path / _collect_all_patterns
|
| 952 |
+
# / _hydrate_persisted_state / _bump_persisted_evidence
|
| 953 |
+
# =============================================================================
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
class TestBackendResolution:
|
| 957 |
+
def test_resolve_none_backend(self):
|
| 958 |
+
from headroom.memory.traffic_learner import _resolve_backend_db_path
|
| 959 |
+
|
| 960 |
+
assert _resolve_backend_db_path(None) is None
|
| 961 |
+
|
| 962 |
+
def test_resolve_backend_without_config(self):
|
| 963 |
+
from headroom.memory.traffic_learner import _resolve_backend_db_path
|
| 964 |
+
|
| 965 |
+
class _Bare:
|
| 966 |
+
pass
|
| 967 |
+
|
| 968 |
+
assert _resolve_backend_db_path(_Bare()) is None
|
| 969 |
+
|
| 970 |
+
def test_resolve_backend_with_empty_db_path(self):
|
| 971 |
+
import types as _types
|
| 972 |
+
|
| 973 |
+
from headroom.memory.traffic_learner import _resolve_backend_db_path
|
| 974 |
+
|
| 975 |
+
backend = _types.SimpleNamespace(_config=_types.SimpleNamespace(db_path=""))
|
| 976 |
+
assert _resolve_backend_db_path(backend) is None
|
| 977 |
+
|
| 978 |
+
|
| 979 |
+
class TestCollectAllPatterns:
|
| 980 |
+
@pytest.mark.asyncio
|
| 981 |
+
async def test_merges_db_and_accumulator(self, tmp_path):
|
| 982 |
+
"""Patterns in both DB and accumulator get evidence_count summed by hash."""
|
| 983 |
+
db = tmp_path / "memory.db"
|
| 984 |
+
_init_db(db)
|
| 985 |
+
backend = _FakeBackend(db)
|
| 986 |
+
|
| 987 |
+
# Seed DB with a traffic_learner row at evidence_count=3.
|
| 988 |
+
await backend.save_memory(
|
| 989 |
+
content="shared pattern",
|
| 990 |
+
user_id="t",
|
| 991 |
+
importance=0.5,
|
| 992 |
+
metadata={
|
| 993 |
+
"source": "traffic_learner",
|
| 994 |
+
"category": "environment",
|
| 995 |
+
"evidence_count": 3,
|
| 996 |
+
},
|
| 997 |
+
)
|
| 998 |
+
|
| 999 |
+
learner = TrafficLearner(backend=backend, min_evidence=1)
|
| 1000 |
+
# Same content in accumulator with count=2; hash matches.
|
| 1001 |
+
p = ExtractedPattern(
|
| 1002 |
+
category=PatternCategory.ENVIRONMENT,
|
| 1003 |
+
content="shared pattern",
|
| 1004 |
+
importance=0.5,
|
| 1005 |
+
)
|
| 1006 |
+
learner._pattern_counts[p.content_hash] = (p, 2)
|
| 1007 |
+
|
| 1008 |
+
merged = learner._collect_all_patterns()
|
| 1009 |
+
assert len(merged) == 1
|
| 1010 |
+
assert merged[0].evidence_count == 3 + 2
|
| 1011 |
+
|
| 1012 |
+
def test_handles_missing_db_gracefully(self, tmp_path):
|
| 1013 |
+
"""A backend pointing to a nonexistent DB is skipped, not raised."""
|
| 1014 |
+
backend = _FakeBackend(tmp_path / "absent.db") # file not created
|
| 1015 |
+
learner = TrafficLearner(backend=backend, min_evidence=1)
|
| 1016 |
+
merged = learner._collect_all_patterns()
|
| 1017 |
+
assert merged == []
|
| 1018 |
+
|
| 1019 |
+
|
| 1020 |
+
class TestHydrateEdgeCases:
|
| 1021 |
+
@pytest.mark.asyncio
|
| 1022 |
+
async def test_no_backend(self):
|
| 1023 |
+
"""start() with backend=None hydrates to empty state and still runs."""
|
| 1024 |
+
learner = TrafficLearner(backend=None, min_evidence=1)
|
| 1025 |
+
await learner.start()
|
| 1026 |
+
try:
|
| 1027 |
+
assert learner._saved_hashes == set()
|
| 1028 |
+
assert learner._persisted_ids == {}
|
| 1029 |
+
finally:
|
| 1030 |
+
await learner.stop()
|
| 1031 |
+
|
| 1032 |
+
@pytest.mark.asyncio
|
| 1033 |
+
async def test_missing_db_file(self, tmp_path):
|
| 1034 |
+
"""Backend with a db_path that doesn't exist → hydrate is a no-op."""
|
| 1035 |
+
backend = _FakeBackend(tmp_path / "not-there.db")
|
| 1036 |
+
learner = TrafficLearner(backend=backend, min_evidence=1)
|
| 1037 |
+
await learner._hydrate_persisted_state()
|
| 1038 |
+
assert learner._saved_hashes == set()
|
| 1039 |
+
assert learner._persisted_ids == {}
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
class TestBumpEdgeCases:
|
| 1043 |
+
@pytest.mark.asyncio
|
| 1044 |
+
async def test_bump_with_no_backend_is_noop(self):
|
| 1045 |
+
learner = TrafficLearner(backend=None, min_evidence=1)
|
| 1046 |
+
# Should not raise even with no backend.
|
| 1047 |
+
await learner._bump_persisted_evidence("some-id")
|
| 1048 |
+
|
| 1049 |
+
@pytest.mark.asyncio
|
| 1050 |
+
async def test_bump_with_missing_db_is_noop(self, tmp_path):
|
| 1051 |
+
backend = _FakeBackend(tmp_path / "absent.db")
|
| 1052 |
+
learner = TrafficLearner(backend=backend, min_evidence=1)
|
| 1053 |
+
await learner._bump_persisted_evidence("some-id") # no exception
|
| 1054 |
+
|
| 1055 |
+
@pytest.mark.asyncio
|
| 1056 |
+
async def test_bump_unknown_id_is_noop(self, tmp_path):
|
| 1057 |
+
"""Updating a non-existent memory id silently affects zero rows."""
|
| 1058 |
+
db = tmp_path / "memory.db"
|
| 1059 |
+
_init_db(db)
|
| 1060 |
+
backend = _FakeBackend(db)
|
| 1061 |
+
learner = TrafficLearner(backend=backend, min_evidence=1)
|
| 1062 |
+
await learner._bump_persisted_evidence("no-such-id")
|
| 1063 |
+
assert _read_traffic_rows(db) == []
|
| 1064 |
+
|
| 1065 |
+
|
| 1066 |
+
# =============================================================================
|
| 1067 |
+
# stop() cancels the flush task
|
| 1068 |
+
# =============================================================================
|
| 1069 |
+
|
| 1070 |
+
|
| 1071 |
+
class TestStopCancels:
|
| 1072 |
+
@pytest.mark.asyncio
|
| 1073 |
+
async def test_stop_cancels_flush_task(self):
|
| 1074 |
+
learner = TrafficLearner(backend=None, min_evidence=1)
|
| 1075 |
+
await learner.start()
|
| 1076 |
+
assert learner._flush_task is not None and not learner._flush_task.done()
|
| 1077 |
+
await learner.stop()
|
| 1078 |
+
assert learner._flush_task is None or learner._flush_task.done()
|