217 lines
8.7 KiB
Python
217 lines
8.7 KiB
Python
"""Tests for the dashboard ranking / float-to-top logic (spec §5, DESIGN §4).
|
|
|
|
Pure-logic coverage of the heart of §5, no DB:
|
|
|
|
* a WATCHED-LEGISLATOR match raises a bill's score so it floats to the top;
|
|
* a TAG match (opted-in topic) raises a bill's score so it floats to the top;
|
|
* ANON view has NO relevance and NO personalized sort (personal=off excludes it);
|
|
* hearing_sort orders "Upon Call of the Chair" last, times asc;
|
|
* a T-1 day is excluded from an upcoming view;
|
|
* "Why is this here?" (matched[]) transparency carries the trigger.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from scraper.dashboard import ranking
|
|
from scraper.dashboard.ranking import (
|
|
hearing_sort, score_bill, normalise_bill, float_to_top,
|
|
personal_band, full_queue, is_floated, Weights,
|
|
)
|
|
from scraper.dashboard.context import RelevanceContext, from_prefs
|
|
|
|
|
|
def _bill(bill_id, author, committee=None, tags=None, chamber="ASSEMBLY",
|
|
hearing_time="9:30am", date="2026-09-07",
|
|
short_title="t", description="d"):
|
|
b = {
|
|
"bill_id": bill_id,
|
|
"bill_number": bill_id,
|
|
"chamber_origin": chamber,
|
|
"author": author,
|
|
"short_title": short_title,
|
|
"description": description,
|
|
"committee": committee,
|
|
"hearing_time": hearing_time,
|
|
"location": "Room 1",
|
|
"date": date,
|
|
"session": "2025-2026",
|
|
"item_number": 1,
|
|
"supplement": 1,
|
|
"source": "ASSEMBLY_DAILY_FILE",
|
|
"tags": tags,
|
|
}
|
|
b["hearing_sort"] = hearing_sort(hearing_time)
|
|
return b
|
|
|
|
|
|
def _bills():
|
|
# Three bills, all on the same day. B2 is authored by a watched legislator;
|
|
# B3 is in a topic (education) the user opted into; B1 matches nothing.
|
|
return [
|
|
_bill("A1", "Unrelated McPerson", committee="General",
|
|
tags=["budget"], date="2026-09-07"),
|
|
_bill("A2", "Schiavo et al.", committee="Resources and Water",
|
|
date="2026-09-07"), # author watched path
|
|
_bill("A3", "Macedo", committee=None, tags=["education", "health"],
|
|
date="2026-09-07"), # tag match path
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# hearing_sort
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_hearing_sort_walltime_ascending():
|
|
assert hearing_sort("9:30am") == 9 * 3600 + 30 * 60
|
|
assert hearing_sort("10:00am") > hearing_sort("9:30am")
|
|
assert hearing_sort("1:00pm") > hearing_sort("12:00pm") # 1pm = 13:00
|
|
assert hearing_sort("12:00am") == 0 # midnight
|
|
|
|
|
|
def test_hearing_sort_call_of_chair_and_null_sort_last():
|
|
last = hearing_sort("Upon Call of the Chair")
|
|
assert last == hearing_sort("Call of the Chair")
|
|
assert last == hearing_sort(None) # missing time sinks (time TBD)
|
|
assert last == hearing_sort("")
|
|
# a real wall time sorts before call-of-the-chair / nulls.
|
|
assert hearing_sort("9:30am") < last
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# score_bill — the OR rule (author OR tag suffices to float)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_watched_author_floats():
|
|
b = _bills()[1] # authored by "Schiavo et al."
|
|
rel = score_bill(b, watched_authors={"schiavo"},
|
|
watched_committees=set(), opted_in_tags=set())
|
|
assert rel["score"] >= 3, rel
|
|
assert is_floated({**b, "relevance": rel})
|
|
# transparency: matched[] names the rule (a "Why is this here?" trigger)
|
|
types = [m["type"] for m in rel["matched"]]
|
|
assert "watched-legislator" in types
|
|
|
|
|
|
def test_opted_in_tag_floats():
|
|
# A bill that is NOT authored by a watched leg but carries an opted-in
|
|
# topic floats on the TAG path alone.
|
|
b = _bills()[2] # tags ["education","health"]
|
|
rel = score_bill(b, watched_authors=set(),
|
|
watched_committees=set(),
|
|
opted_in_tags={"education"})
|
|
assert rel["score"] > 0, rel
|
|
assert is_floated({**b, "relevance": rel})
|
|
types = [m["type"] for m in rel["matched"]]
|
|
assert "tag" in types
|
|
# matched carries the actual tag that triggered it
|
|
assert any(m["value"].lower().startswith("topic")
|
|
for m in rel["matched"])
|
|
|
|
|
|
def test_no_match_does_not_float():
|
|
# A bill matching nothing scores 0 and does NOT float.
|
|
b = _bills()[0]
|
|
rel = score_bill(b, watched_authors=set(),
|
|
watched_committees=set(), opted_in_tags=set())
|
|
assert rel["score"] == 0
|
|
assert not is_floated({**b, "relevance": rel})
|
|
|
|
|
|
def test_both_paths_stack_score():
|
|
# Author watched + an opted-in tag both present -> score > either alone.
|
|
b = _bill("A2", "Schiavo et al.", tags=["education"])
|
|
rel = score_bill(b, watched_authors={"schiavo"},
|
|
watched_committees=set(), opted_in_tags={"education"})
|
|
alone = score_bill(b, watched_authors={"schiavo"},
|
|
watched_committees=set(), opted_in_tags=set())
|
|
assert rel["score"] > alone["score"]
|
|
assert {"watched-legislator", "tag"}.issubset(
|
|
{m["type"] for m in rel["matched"]})
|
|
|
|
|
|
def test_committee_of_watched_legislator_path():
|
|
# A bill in a committee a watched legislator chairs/serves on floats even
|
|
# with no author/tag match (committee->members join supplied).
|
|
b = _bill("A4", "Otherperson", committee="Emergency Management",
|
|
tags=[], )
|
|
rel = score_bill(b, watched_authors=set(),
|
|
watched_committees={"emergency-management"},
|
|
opted_in_tags=set())
|
|
assert rel["score"] > 0, rel
|
|
assert "committee-of-watched-legislator" in [
|
|
m["type"] for m in rel["matched"]]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# float-to-top order + anon exclusion of personalized sort
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _personal_bills():
|
|
bills = _bills()
|
|
ctx = from_prefs({"watched_legislators": ["schiavo"],
|
|
"opted_in_tags": ["education"]})
|
|
for b in bills:
|
|
b["relevance"] = ctx.score(b)
|
|
return bills, ctx
|
|
|
|
|
|
def test_float_to_top_puts_relevant_first():
|
|
bills, ctx = _personal_bills()
|
|
assert ctx.is_personalised
|
|
ordered = float_to_top(bills)
|
|
# A2 (author) and A3 (tag) float ahead of A1 (no match).
|
|
ids = [b["bill_id"] for b in ordered]
|
|
assert ids.index("A2") < ids.index("A1")
|
|
assert ids.index("A3") < ids.index("A1")
|
|
# floated band = top N of the floated bills
|
|
band = personal_band(bills)
|
|
assert {b["bill_id"] for b in band} == {"A2", "A3"}
|
|
assert all(is_floated(b) for b in band)
|
|
|
|
|
|
def test_personal_band_bounded_to_n():
|
|
bills, ctx = _personal_bills()
|
|
band = personal_band(bills, n=1)
|
|
assert len(band) == 1
|
|
# higher score first (A2 = author+nothing vs A3 = tag+nothing both score
|
|
# 3 and 2 respectively; A2 should lead when scores differ)
|
|
a2 = next(b for b in bills if b["bill_id"] == "A2")
|
|
a3 = next(b for b in bills if b["bill_id"] == "A3")
|
|
assert a2["relevance"]["score"] >= a3["relevance"]["score"]
|
|
assert band[0]["bill_id"] == "A2"
|
|
|
|
|
|
def test_full_queue_promotes_but_hides_nothing():
|
|
# Every bill remains present; only order changes (promote, not hide).
|
|
bills, ctx = _personal_bills()
|
|
ordered = full_queue(bills)
|
|
assert {b["bill_id"] for b in ordered} == {"A1", "A2", "A3"}
|
|
assert len(ordered) == 3
|
|
|
|
|
|
def test_anon_view_has_no_relevance_and_no_personal_sort():
|
|
# The anon envelope carries NO relevance block and is NOT float-sorted:
|
|
# the personal=off path must not reorder anything.
|
|
bills = _bills()
|
|
anon = [dict(b, **{"relevance": None}) for b in bills]
|
|
# strip relevance to mirror the anon projection
|
|
anon = [{k: v for k, v in b.items() if k != "relevance"} for b in _bills()]
|
|
assert all("relevance" not in b for b in anon)
|
|
# anon ordering == stable input order (no float applied)
|
|
ordered = float_to_top([
|
|
{k: v for k, v in b.items() if k != "relevance"} for b in _bills()])
|
|
assert [b["bill_id"] for b in ordered] == ["A1", "A2", "A3"]
|
|
|
|
|
|
def test_weights_are_tunable():
|
|
# D3: weights are knobs. Bumping the tag weight changes band ordering.
|
|
bills, ctx = _personal_bills()
|
|
hi = Weights(tag_watched=10)
|
|
for b in bills:
|
|
b["relevance"] = score_bill(b,
|
|
watched_authors=ctx.watched_authors,
|
|
watched_committees=ctx.watched_committees,
|
|
opted_in_tags=ctx.opted_in_tags, weights=hi)
|
|
ordered = float_to_top(bills)
|
|
# A3 (tag, weight 10) now outranks A2 (author, weight 3)
|
|
assert ordered[0]["bill_id"] == "A3"
|