2026-06-14 21:16:36 +02:00
|
|
|
import importlib.util
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
_SPEC = importlib.util.spec_from_file_location(
|
|
|
|
|
"friction_scan",
|
|
|
|
|
os.path.join(os.path.dirname(__file__), "..", "scripts", "friction-scan.py"),
|
|
|
|
|
)
|
|
|
|
|
fs = importlib.util.module_from_spec(_SPEC)
|
|
|
|
|
_SPEC.loader.exec_module(fs)
|
|
|
|
|
|
|
|
|
|
SAMPLE = """# FRICTION.md
|
|
|
|
|
|
|
|
|
|
## Open signals
|
|
|
|
|
|
|
|
|
|
_(append new raw signals here)_
|
|
|
|
|
|
|
|
|
|
- `[gotcha]` **First thing** (2026-06-01): body line one.
|
|
|
|
|
continuation line two.
|
|
|
|
|
- `[friction]` **Second thing** (2026-06-10): only one line.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Kaizen reviews — decisions ledger
|
|
|
|
|
|
|
|
|
|
- `[gotcha]` **Should not be parsed** (2026-01-01): in the ledger.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_extract_open_section_stops_at_next_heading():
|
|
|
|
|
section = fs.extract_open_section(SAMPLE)
|
|
|
|
|
assert "First thing" in section
|
|
|
|
|
assert "Second thing" in section
|
|
|
|
|
assert "Should not be parsed" not in section
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_split_signals_finds_two_items_and_joins_continuations():
|
|
|
|
|
signals = fs.split_signals(fs.extract_open_section(SAMPLE))
|
|
|
|
|
assert len(signals) == 2
|
|
|
|
|
assert "continuation line two" in signals[0]
|
|
|
|
|
assert signals[1].startswith("`[friction]`")
|
2026-06-14 21:17:03 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
import datetime
|
|
|
|
|
|
|
|
|
|
TODAY = datetime.date(2026, 6, 15)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_signal_extracts_tag_and_date_and_age():
|
|
|
|
|
raw = fs.split_signals(fs.extract_open_section(SAMPLE))[0]
|
|
|
|
|
sig = fs.parse_signal(raw, TODAY)
|
|
|
|
|
assert sig["tag"] == "gotcha"
|
|
|
|
|
assert sig["first_seen"] == "2026-06-01"
|
|
|
|
|
assert sig["age_days"] == 14
|
|
|
|
|
assert "First thing" in sig["text"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_signal_handles_missing_date():
|
|
|
|
|
sig = fs.parse_signal("`[unused]` **No date here** something", TODAY)
|
|
|
|
|
assert sig["tag"] == "unused"
|
|
|
|
|
assert sig["first_seen"] is None
|
|
|
|
|
assert sig["age_days"] is None
|
2026-06-14 21:17:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recurrence_from_ordinal():
|
|
|
|
|
assert fs.parse_recurrence("blah 5th occurrence (06-05/06/06) blah") == 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recurrence_from_datelist_when_no_ordinal():
|
|
|
|
|
# three slash-separated date fragments -> recurrence 3
|
|
|
|
|
assert fs.parse_recurrence("recurred (06-05/06-09/06-10) again") == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recurrence_defaults_to_one():
|
|
|
|
|
assert fs.parse_recurrence("a one-off gotcha") == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_paths_picks_repo_paths_only():
|
|
|
|
|
paths = fs.parse_paths("see `scripts/repo-scan.py` and `latest` and `foo.yml`")
|
|
|
|
|
assert "scripts/repo-scan.py" in paths
|
|
|
|
|
assert "foo.yml" in paths
|
|
|
|
|
assert "latest" not in paths
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_still_exists_false_for_missing_path():
|
|
|
|
|
sig = fs.parse_signal("`[unused]` **x** (2026-06-01): `scripts/nope-not-real.py`", TODAY)
|
|
|
|
|
assert sig["still_exists"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_still_exists_true_for_real_path():
|
|
|
|
|
sig = fs.parse_signal("`[gotcha]` **x** (2026-06-01): `scripts/repo-scan.py`", TODAY)
|
|
|
|
|
assert sig["still_exists"] is True
|
2026-06-14 21:18:16 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_nudge_line_overdue_on_recurrence():
|
|
|
|
|
sigs = [{"age_days": 2, "recurrence_count": 5}]
|
|
|
|
|
line = fs.nudge_line(sigs)
|
|
|
|
|
assert "OVERDUE" in line
|
|
|
|
|
assert "max recurrence 5x" in line
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_nudge_line_ok_when_quiet():
|
|
|
|
|
sigs = [{"age_days": 3, "recurrence_count": 1}, {"age_days": 1, "recurrence_count": 1}]
|
|
|
|
|
line = fs.nudge_line(sigs)
|
|
|
|
|
assert "ok" in line
|
|
|
|
|
assert "OVERDUE" not in line
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_nudge_line_overdue_on_count():
|
|
|
|
|
sigs = [{"age_days": 1, "recurrence_count": 1} for _ in range(8)]
|
|
|
|
|
assert "OVERDUE" in fs.nudge_line(sigs)
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 21:25:03 +02:00
|
|
|
def test_still_exists_ignores_non_repo_tokens():
|
|
|
|
|
sig = fs.parse_signal("`[gotcha]` **x** (2026-06-01): `caddy-dns/gandi` and `make tf-init/plan`", TODAY)
|
|
|
|
|
assert sig["still_exists"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_nudge_line_overdue_on_age():
|
|
|
|
|
sigs = [{"age_days": 21, "recurrence_count": 1}]
|
|
|
|
|
assert "OVERDUE" in fs.nudge_line(sigs)
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 21:18:16 +02:00
|
|
|
def test_load_signals_reads_real_friction_file():
|
|
|
|
|
path = os.path.join(os.path.dirname(__file__), "..", "docs", "FRICTION.md")
|
|
|
|
|
sigs = fs.load_signals(path, TODAY)
|
2026-06-17 17:50:17 +02:00
|
|
|
# May legitimately be empty right after a /kaizen pass consumes every open signal —
|
|
|
|
|
# an empty Open-signals section is the goal state, not a failure. Assert the function
|
|
|
|
|
# parses the real file into well-formed signals (validity holds vacuously when empty).
|
|
|
|
|
assert isinstance(sigs, list)
|
2026-06-14 21:18:16 +02:00
|
|
|
assert all(s["tag"] in {"friction", "gotcha", "recurring", "unused"} for s in sigs)
|