cc-os/plugins/os-doc-hygiene/tests/test_scanner_file_length.py

128 lines
5.0 KiB
Python
Raw Normal View History

"""
Tests for the scanner's file_length signal (Forgejo issue #25, Part 1).
Objective fact only: line count + estimated token count vs configurable
thresholds. No classification, no report-owned fields (token_estimate is
owned by report_builder.py, not the scanner).
"""
import sys
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from scanner import Scanner, SignalComputer # noqa: E402
from token_estimator import TokenEstimator # noqa: E402
from test_scanner_signals import ( # noqa: E402
FORBIDDEN_SIGNAL_FIELDS,
_assert_no_classifier_fields,
_assert_signal_present,
_make_tree,
)
class _StubEstimator(TokenEstimator):
"""Deterministic stub: 1 token per line, ignoring content."""
name = "stub-per-line"
def estimate(self, text: str) -> int:
if not text:
return 0
return len(text.splitlines())
def _run(root: Path, **kwargs) -> dict:
return Scanner(
root=root,
git_log_fn=lambda p: [],
now_fn=lambda: 0.0,
token_estimator=kwargs.pop("token_estimator", _StubEstimator()),
**kwargs,
).run()
class TestFileLengthSignal:
def test_short_file_no_signal(self, tmp_path):
_make_tree(tmp_path, {"doc.md": "\n".join(f"line {i}" for i in range(10))})
result = _run(tmp_path)
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "file_length" not in names
def test_over_line_threshold_emits_signal(self, tmp_path):
content = "\n".join(f"line {i}" for i in range(500))
_make_tree(tmp_path, {"doc.md": content})
result = _run(tmp_path, max_lines=400, max_tokens=4000)
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "file_length")
_assert_no_classifier_fields(sig)
assert "500" in sig["detail"]
assert "400" in sig["detail"]
def test_over_token_threshold_emits_signal(self, tmp_path):
"""Line count under threshold but stub estimator pushes tokens over."""
content = "\n".join(f"line {i}" for i in range(50))
_make_tree(tmp_path, {"doc.md": content})
# Stub estimates 1 token/line -> 50 tokens; set max_tokens low so it fires
# even though line count (50) stays well under max_lines (400).
result = _run(tmp_path, max_lines=400, max_tokens=10)
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "file_length")
_assert_no_classifier_fields(sig)
assert "threshold 400 lines / 10 tokens" in sig["detail"]
def test_configurable_threshold_respected(self, tmp_path):
content = "\n".join(f"line {i}" for i in range(50))
_make_tree(tmp_path, {"doc.md": content})
# Below default threshold -> no signal
result_default = _run(tmp_path)
assert "file_length" not in [
s["name"] for s in result_default["signals"].get("doc.md", [])
]
# Lower the threshold below the actual line count -> signal fires
result_custom = _run(tmp_path, max_lines=10, max_tokens=4000)
sigs = result_custom["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "file_length")
assert "threshold 10 lines" in sig["detail"]
def test_silent_below_threshold_with_default_estimator(self, tmp_path):
"""Uses the real default_estimator() (no injected stub) below threshold."""
_make_tree(tmp_path, {"doc.md": "short content"})
result = Scanner(
root=tmp_path,
git_log_fn=lambda p: [],
now_fn=lambda: 0.0,
).run()
sigs = result["signals"].get("doc.md", [])
names = [s["name"] for s in sigs]
assert "file_length" not in names
def test_signal_omits_forbidden_report_fields(self, tmp_path):
"""file_length must not leak report-owned fields (e.g. token_estimate)."""
content = "\n".join(f"line {i}" for i in range(500))
_make_tree(tmp_path, {"doc.md": content})
result = _run(tmp_path, max_lines=400, max_tokens=4000)
sigs = result["signals"].get("doc.md", [])
sig = _assert_signal_present(sigs, "file_length")
for field in FORBIDDEN_SIGNAL_FIELDS:
assert field not in sig, f"forbidden field '{field}' found in file_length signal"
def test_signal_computer_direct_default_thresholds(self, tmp_path):
"""SignalComputer alone: constructor defaults are 400 lines / 4000 tokens."""
content = "\n".join(f"line {i}" for i in range(500))
_make_tree(tmp_path, {"doc.md": content})
path = tmp_path / "doc.md"
computer = SignalComputer(
root=tmp_path,
git_log_fn=None,
now_fn=lambda: 0.0,
token_estimator=_StubEstimator(),
)
sigs = computer._file_length(path)
sig = _assert_signal_present(sigs, "file_length")
assert "threshold 400 lines / 4000 tokens" in sig["detail"]