Initial commit
This commit is contained in:
commit
5f2db08b43
100 files changed
+16985
No files matched your search
@@ -0,0 +1,216 @@
|
||||
"""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"
|
||||
Reference in new issue
Block a user