|
| 1 | +""" |
| 2 | +Unit tests for the consolidated exclusion-rule helpers introduced in issue #23: |
| 3 | +
|
| 4 | +- ``session_text_for_exclusion`` — moved from a duplicate-defined private helper |
| 5 | + in ``scripts/export.py`` and ``api/export_api.py`` into ``utils/exclusion_rules``. |
| 6 | +- ``is_session_excluded`` — wraps the previously-inlined "extract text → |
| 7 | + build_searchable_text → is_excluded_by_rules" pattern that was repeated |
| 8 | + across six call sites. |
| 9 | +
|
| 10 | +Both functions are pure and dependency-free, so they're tested directly without |
| 11 | +booting Flask or any of the API blueprints. |
| 12 | +
|
| 13 | +Run: |
| 14 | + pytest tests/test_exclusion_helpers.py -v |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import sys |
| 20 | +from pathlib import Path |
| 21 | + |
| 22 | +import pytest |
| 23 | + |
| 24 | +REPO_ROOT = Path(__file__).resolve().parent.parent |
| 25 | +sys.path.insert(0, str(REPO_ROOT)) |
| 26 | + |
| 27 | +from utils.exclusion_rules import ( |
| 28 | + is_session_excluded, |
| 29 | + load_rules, |
| 30 | + session_text_for_exclusion, |
| 31 | +) |
| 32 | + |
| 33 | + |
| 34 | +# --------------------------------------------------------------------------- |
| 35 | +# Helpers |
| 36 | +# --------------------------------------------------------------------------- |
| 37 | + |
| 38 | +def _write_rules(tmp_path, *lines: str) -> str: |
| 39 | + """Write rules file and return its path. Tokenized by load_rules.""" |
| 40 | + p = tmp_path / "exclusion-rules.txt" |
| 41 | + p.write_text("\n".join(lines), encoding="utf-8") |
| 42 | + return str(p) |
| 43 | + |
| 44 | + |
| 45 | +def _session(*, title: str = "session", models: list[str] | None = None, |
| 46 | + messages: list[dict] | None = None) -> dict: |
| 47 | + return { |
| 48 | + "title": title, |
| 49 | + "metadata": {"models_used": models or []}, |
| 50 | + "messages": messages or [], |
| 51 | + } |
| 52 | + |
| 53 | + |
| 54 | +# --------------------------------------------------------------------------- |
| 55 | +# session_text_for_exclusion |
| 56 | +# --------------------------------------------------------------------------- |
| 57 | + |
| 58 | +class TestSessionTextForExclusion: |
| 59 | + |
| 60 | + def test_empty_session(self): |
| 61 | + assert session_text_for_exclusion({}) == "" |
| 62 | + |
| 63 | + def test_session_with_no_messages(self): |
| 64 | + assert session_text_for_exclusion({"messages": []}) == "" |
| 65 | + |
| 66 | + def test_joins_message_text_with_blank_lines(self): |
| 67 | + s = _session(messages=[{"text": "alpha"}, {"text": "beta"}]) |
| 68 | + assert session_text_for_exclusion(s) == "alpha\n\nbeta" |
| 69 | + |
| 70 | + def test_skips_messages_without_text(self): |
| 71 | + s = _session(messages=[{"text": "alpha"}, {"role": "tool"}, {"text": "gamma"}]) |
| 72 | + assert session_text_for_exclusion(s) == "alpha\n\ngamma" |
| 73 | + |
| 74 | + def test_skips_whitespace_only_text(self): |
| 75 | + # Regression: this is the inconsistency the consolidation fixed — |
| 76 | + # the helper rejects whitespace-only strings, the previous inline |
| 77 | + # variants didn't. The helper version is now canonical. |
| 78 | + s = _session(messages=[ |
| 79 | + {"text": "alpha"}, |
| 80 | + {"text": " "}, # whitespace-only — should be skipped |
| 81 | + {"text": "\n\t\n"}, # whitespace-only — should be skipped |
| 82 | + {"text": "beta"}, |
| 83 | + ]) |
| 84 | + assert session_text_for_exclusion(s) == "alpha\n\nbeta" |
| 85 | + |
| 86 | + def test_skips_non_string_text(self): |
| 87 | + s = _session(messages=[{"text": "alpha"}, {"text": 42}, {"text": None}, {"text": "beta"}]) |
| 88 | + assert session_text_for_exclusion(s) == "alpha\n\nbeta" |
| 89 | + |
| 90 | + |
| 91 | +# --------------------------------------------------------------------------- |
| 92 | +# is_session_excluded |
| 93 | +# --------------------------------------------------------------------------- |
| 94 | + |
| 95 | +class TestIsSessionExcluded: |
| 96 | + |
| 97 | + def test_returns_false_when_rules_empty(self, tmp_path): |
| 98 | + s = _session(title="anything", messages=[{"text": "anything"}]) |
| 99 | + assert is_session_excluded([], s, "any project") is False |
| 100 | + assert is_session_excluded(None, s, "any project") is False # type: ignore[arg-type] |
| 101 | + |
| 102 | + def test_matches_on_project_name(self, tmp_path): |
| 103 | + rules = load_rules(_write_rules(tmp_path, "secret-project")) |
| 104 | + s = _session() |
| 105 | + assert is_session_excluded(rules, s, "my secret-project work") is True |
| 106 | + assert is_session_excluded(rules, s, "unrelated work") is False |
| 107 | + |
| 108 | + def test_matches_on_session_title(self, tmp_path): |
| 109 | + rules = load_rules(_write_rules(tmp_path, "confidential")) |
| 110 | + assert is_session_excluded(rules, _session(title="Confidential debrief"), "proj") is True |
| 111 | + assert is_session_excluded(rules, _session(title="Public roadmap"), "proj") is False |
| 112 | + |
| 113 | + def test_matches_on_model_name(self, tmp_path): |
| 114 | + rules = load_rules(_write_rules(tmp_path, "claude-opus-4-7")) |
| 115 | + s = _session(models=["claude-opus-4-7", "claude-haiku-4-5"]) |
| 116 | + assert is_session_excluded(rules, s, "proj") is True |
| 117 | + |
| 118 | + def test_matches_on_message_content(self, tmp_path): |
| 119 | + rules = load_rules(_write_rules(tmp_path, "password")) |
| 120 | + s = _session(messages=[{"text": "do not commit the password"}]) |
| 121 | + assert is_session_excluded(rules, s, "proj") is True |
| 122 | + |
| 123 | + def test_AND_rule_requires_both_terms(self, tmp_path): |
| 124 | + # AND has higher precedence than OR (per the rule grammar). |
| 125 | + rules = load_rules(_write_rules(tmp_path, "alpha AND beta")) |
| 126 | + s_both = _session(messages=[{"text": "alpha and beta together"}]) |
| 127 | + s_one = _session(messages=[{"text": "only alpha here"}]) |
| 128 | + assert is_session_excluded(rules, s_both, "proj") is True |
| 129 | + assert is_session_excluded(rules, s_one, "proj") is False |
| 130 | + |
| 131 | + def test_OR_rule_matches_either(self, tmp_path): |
| 132 | + rules = load_rules(_write_rules(tmp_path, "alpha OR beta")) |
| 133 | + s_alpha = _session(messages=[{"text": "alpha here"}]) |
| 134 | + s_beta = _session(messages=[{"text": "beta here"}]) |
| 135 | + s_neither = _session(messages=[{"text": "gamma here"}]) |
| 136 | + assert is_session_excluded(rules, s_alpha, "proj") is True |
| 137 | + assert is_session_excluded(rules, s_beta, "proj") is True |
| 138 | + assert is_session_excluded(rules, s_neither, "proj") is False |
| 139 | + |
| 140 | + def test_quoted_phrase_match(self, tmp_path): |
| 141 | + rules = load_rules(_write_rules(tmp_path, '"project alpha"')) |
| 142 | + s_match = _session(title="Project alpha kickoff") |
| 143 | + s_partial = _session(title="alpha project") # token order matters |
| 144 | + assert is_session_excluded(rules, s_match, "proj") is True |
| 145 | + assert is_session_excluded(rules, s_partial, "proj") is False |
| 146 | + |
| 147 | + def test_handles_session_without_metadata(self, tmp_path): |
| 148 | + # Defensive: session dicts coming from older code paths might be |
| 149 | + # missing a metadata key. Should not raise. |
| 150 | + rules = load_rules(_write_rules(tmp_path, "anything")) |
| 151 | + bare = {"title": "x", "messages": []} # no metadata key at all |
| 152 | + assert is_session_excluded(rules, bare, "proj") is False |
| 153 | + |
| 154 | + def test_project_name_None_does_not_break(self, tmp_path): |
| 155 | + rules = load_rules(_write_rules(tmp_path, "confidential")) |
| 156 | + s = _session(title="Confidential") |
| 157 | + # project_name=None should still let title-based rules match. |
| 158 | + assert is_session_excluded(rules, s, None) is True |
0 commit comments