Garm Claude Opus 4.7 (1M context) commited on
Commit
6239096
·
1 Parent(s): e5edc40

test(learn): cover flush_to_file, backend edge cases, and hydrate/bump error paths

Browse files

Adds 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>

tests/test_memory/test_traffic_learner.py CHANGED
@@ -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()