| 1 | """Tests for scripts/wisdom_cap.py.""" |
| 2 | |
| 3 | import sys |
| 4 | from pathlib import Path |
| 5 | |
| 6 | sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
| 7 | |
| 8 | from scripts.wisdom_cap import ( |
| 9 | main, |
| 10 | parse_heuristics, |
| 11 | retire_heuristics, |
| 12 | select_for_retirement, |
| 13 | ) |
| 14 | |
| 15 | SAMPLE_WISDOM = """\ |
| 16 | # Topic Wisdom |
| 17 | |
| 18 | ## Signal Patterns |
| 19 | - Heuristic one about signals |
| 20 | - Heuristic two about patterns |
| 21 | - Heuristic three about growth |
| 22 | |
| 23 | ## Noise Patterns |
| 24 | - Noise heuristic one |
| 25 | - Noise heuristic two |
| 26 | |
| 27 | ## Scoring Adjustments |
| 28 | - Scoring rule one |
| 29 | - Scoring rule two |
| 30 | - Scoring rule three |
| 31 | """ |
| 32 | |
| 33 | |
| 34 | class TestParseHeuristics: |
| 35 | def test_parses_all_bullets(self): |
| 36 | heuristics = parse_heuristics(SAMPLE_WISDOM) |
| 37 | assert len(heuristics) == 8 |
| 38 | |
| 39 | def test_captures_sections(self): |
| 40 | heuristics = parse_heuristics(SAMPLE_WISDOM) |
| 41 | sections = {h["section"] for h in heuristics} |
| 42 | assert "Signal Patterns" in sections |
| 43 | assert "Noise Patterns" in sections |
| 44 | |
| 45 | def test_empty_content(self): |
| 46 | assert parse_heuristics("") == [] |
| 47 | assert parse_heuristics("# Just a header\n") == [] |
| 48 | |
| 49 | |
| 50 | class TestSelectForRetirement: |
| 51 | def test_selects_from_end(self): |
| 52 | heuristics = parse_heuristics(SAMPLE_WISDOM) |
| 53 | retired = select_for_retirement(heuristics, bytes_to_free=50) |
| 54 | # Should select from the end |
| 55 | assert retired[0]["text"] == "- Scoring rule three" |
| 56 | |
| 57 | def test_respects_bytes_needed(self): |
| 58 | heuristics = parse_heuristics(SAMPLE_WISDOM) |
| 59 | retired = select_for_retirement(heuristics, bytes_to_free=1) |
| 60 | assert len(retired) >= 1 |
| 61 | |
| 62 | |
| 63 | class TestRetireHeuristics: |
| 64 | def test_under_limit_no_action(self, tmp_path): |
| 65 | wisdom = tmp_path / "wisdom.md" |
| 66 | archive = tmp_path / "archive.md" |
| 67 | wisdom.write_text(SAMPLE_WISDOM) |
| 68 | |
| 69 | result = retire_heuristics(wisdom, archive, limit=10000) |
| 70 | assert result["status"] == "ok" |
| 71 | assert not archive.exists() |
| 72 | |
| 73 | def test_over_limit_retires(self, tmp_path): |
| 74 | wisdom = tmp_path / "wisdom.md" |
| 75 | archive = tmp_path / "archive.md" |
| 76 | wisdom.write_text(SAMPLE_WISDOM) |
| 77 | # Set limit below current size to trigger retirement |
| 78 | current_size = len(SAMPLE_WISDOM.encode("utf-8")) |
| 79 | limit = current_size - 50 |
| 80 | |
| 81 | result = retire_heuristics(wisdom, archive, limit=limit) |
| 82 | assert result["status"] == "retired" |
| 83 | assert result["retired_count"] >= 1 |
| 84 | assert archive.exists() |
| 85 | # Wisdom should be smaller now |
| 86 | new_size = len(wisdom.read_text().encode("utf-8")) |
| 87 | assert new_size < current_size |
| 88 | |
| 89 | def test_dry_run_no_changes(self, tmp_path): |
| 90 | wisdom = tmp_path / "wisdom.md" |
| 91 | archive = tmp_path / "archive.md" |
| 92 | wisdom.write_text(SAMPLE_WISDOM) |
| 93 | limit = 50 # Way under to force retirement |
| 94 | |
| 95 | result = retire_heuristics(wisdom, archive, limit=limit, dry_run=True) |
| 96 | assert result["status"] == "dry_run" |
| 97 | assert result["would_retire"] > 0 |
| 98 | # File should be unchanged |
| 99 | assert wisdom.read_text() == SAMPLE_WISDOM |
| 100 | assert not archive.exists() |
| 101 | |
| 102 | def test_missing_wisdom(self, tmp_path): |
| 103 | wisdom = tmp_path / "nope.md" |
| 104 | archive = tmp_path / "archive.md" |
| 105 | result = retire_heuristics(wisdom, archive, limit=5120) |
| 106 | assert result["status"] == "skip" |
| 107 | |
| 108 | def test_idempotent(self, tmp_path): |
| 109 | """Running twice produces same result if already under limit.""" |
| 110 | wisdom = tmp_path / "wisdom.md" |
| 111 | archive = tmp_path / "archive.md" |
| 112 | wisdom.write_text(SAMPLE_WISDOM) |
| 113 | limit = len(SAMPLE_WISDOM.encode("utf-8")) - 50 |
| 114 | |
| 115 | retire_heuristics(wisdom, archive, limit=limit) |
| 116 | content_after_first = wisdom.read_text() |
| 117 | |
| 118 | # Running again should be ok (under limit now) |
| 119 | result = retire_heuristics(wisdom, archive, limit=limit) |
| 120 | assert result["status"] == "ok" |
| 121 | assert wisdom.read_text() == content_after_first |
| 122 | |
| 123 | def test_archive_appends(self, tmp_path): |
| 124 | """Multiple retirements append to archive, never overwrite.""" |
| 125 | wisdom = tmp_path / "wisdom.md" |
| 126 | archive = tmp_path / "archive.md" |
| 127 | wisdom.write_text(SAMPLE_WISDOM) |
| 128 | |
| 129 | # First retirement |
| 130 | retire_heuristics(wisdom, archive, limit=50) |
| 131 | first_archive = archive.read_text() |
| 132 | |
| 133 | # Add more content and retire again |
| 134 | current = wisdom.read_text() |
| 135 | wisdom.write_text(current + "- New heuristic added\n" * 20) |
| 136 | retire_heuristics(wisdom, archive, limit=50) |
| 137 | second_archive = archive.read_text() |
| 138 | |
| 139 | assert len(second_archive) > len(first_archive) |
| 140 | |
| 141 | |
| 142 | class TestMainCLI: |
| 143 | def test_runs_with_missing_topic(self, tmp_path, monkeypatch): |
| 144 | monkeypatch.setattr("scripts.wisdom_cap.SQUAD_DIR", tmp_path / ".squad" / "topics") |
| 145 | rc = main(["--topic", "nonexistent"]) |
| 146 | assert rc == 0 # graceful skip |