"""The score is implemented twice — SQL for the live grid, Python for export and tests. Two implementations of one formula is a real risk; this is the mitigation.""" import random import time import pytest from mediashelf import scoring def make_rows(n=500, seed=17): rnd = random.Random(seed) now = int(time.time()) rows = [] for i in range(n): kind = rnd.choice(["movie", "season"]) rows.append({ "provider_item_id": str(i), "kind": kind, "title": "T%d" % i, "size_bytes": rnd.choice([0, 1, 10**6, 2 * 10**9, 60 * 10**9, 213 * 10**9]), "added_at": rnd.choice([None, now - rnd.randint(1, 5000) * 86400]), "last_watched_at": rnd.choice([None, None, now - rnd.randint(1, 4000) * 86400]), "watch_count": rnd.choice([0, 0, 0, 1, 2, 5, 24]), "abandoned_count": rnd.randint(0, 5), "distinct_watcher_count": rnd.randint(0, 8), "episode_count": rnd.randint(1, 30) if kind == "season" else 0, "pre_history": rnd.choice([0, 1]), }) return now, rows WEIGHT_PROFILES = [ None, {"size": 1, "staleness": 0, "unpopularity": 0, "solitude": 0, "age": 0, "rejection": 0}, {"size": 0, "staleness": 0, "unpopularity": 0, "solitude": 0, "age": 0, "rejection": 1}, {"size": .1, "staleness": .5, "unpopularity": .1, "solitude": .1, "age": .1, "rejection": .1}, {"size": -5, "staleness": "x", "unpopularity": .2}, # junk must be tolerated ] @pytest.mark.parametrize("has_completion", [True, False]) @pytest.mark.parametrize("weights", WEIGHT_PROFILES) def test_sql_and_python_agree(db, has_completion, weights): now, rows = make_rows() db.execute("INSERT INTO provider (kind,name,base_url,created_at) " "VALUES ('plex','L','http://x',1)") db.execute("INSERT INTO library (provider_id,provider_key,title,kind) " "VALUES (1,'1','Movies','movie')") for r in rows: db.execute( "INSERT INTO media_item (provider_id,library_id,provider_item_id,kind,title," "size_bytes,added_at,last_watched_at,watch_count,abandoned_count," "distinct_watcher_count,episode_count,pre_history) " "VALUES (1,1,:provider_item_id,:kind,:title,:size_bytes,:added_at," ":last_watched_at,:watch_count,:abandoned_count,:distinct_watcher_count," ":episode_count,:pre_history)", r) maxsize = db.scalar("SELECT MAX(size_bytes) FROM media_item") or 1 ctx = scoring.ScoreContext(now=now, max_size_bytes=maxsize, has_completion_data=has_completion) # Compare the raw formula, so a real divergence cannot hide behind rounding. expr, params = scoring.sql_expression(ctx, weights, rounded=False) params["now"] = now raw = {r["pid"]: r["score"] for r in db.query( "WITH s(maxsize) AS (SELECT MAX(size_bytes) FROM media_item) " f"SELECT i.provider_item_id AS pid, {expr} AS score FROM media_item i, s", params)} rexpr, rparams = scoring.sql_expression(ctx, weights, rounded=True) rparams["now"] = now shown = {r["pid"]: r["score"] for r in db.query( "WITH s(maxsize) AS (SELECT MAX(size_bytes) FROM media_item) " f"SELECT i.provider_item_id AS pid, {rexpr} AS score FROM media_item i, s", rparams)} for r in rows: out = scoring.score_row(r, ctx, weights) pid = r["provider_item_id"] assert abs(out["score_raw"] - raw[pid]) <= 1e-9, ( f"formula diverges on {pid}: SQL {raw[pid]} vs Python {out['score_raw']}") # Displayed values must agree to the last displayed digit. Exact equality # is NOT assertable: SQLite and Python evaluate the same formula in a # different order, so a raw score sitting exactly on a .xx5 boundary can # land on either side of it while still agreeing to 1e-9 above. # Compared in integer hundredths: subtracting two 2dp floats does not # give exactly 0.01, so a float tolerance here fails on its own rounding. assert abs(round(out["score"] * 100) - round(shown[pid] * 100)) <= 1, ( f"rounding diverges on {pid}: SQL {shown[pid]} vs Python {out['score']}") def test_new_arrival_grace_clamps_to_zero(): now = int(time.time()) ctx = scoring.ScoreContext(now=now, max_size_bytes=10**12) row = {"kind": "movie", "size_bytes": 10**12, "added_at": now - 5 * 86400, "last_watched_at": None, "watch_count": 0, "abandoned_count": 0, "distinct_watcher_count": 0, "episode_count": 0, "pre_history": 0} out = scoring.score_row(row, ctx) assert out["score"] == 0.0 and out["grace"] == "new" def test_recent_watch_grace_caps_the_score(): now = int(time.time()) ctx = scoring.ScoreContext(now=now, max_size_bytes=10**12) row = {"kind": "movie", "size_bytes": 10**12, "added_at": now - 3000 * 86400, "last_watched_at": now - 5 * 86400, "watch_count": 1, "abandoned_count": 0, "distinct_watcher_count": 1, "episode_count": 0, "pre_history": 0} out = scoring.score_row(row, ctx) assert out["score"] <= 25.0 and out["grace"] == "recent" def test_rejection_is_unavailable_not_zero_without_completion_data(): """A missing component must renormalize, not drag every score down (§6.2).""" now = int(time.time()) row = {"kind": "movie", "size_bytes": 5 * 10**9, "added_at": now - 2000 * 86400, "last_watched_at": None, "watch_count": 0, "abandoned_count": 0, "distinct_watcher_count": 0, "episode_count": 0, "pre_history": 0} with_cd = scoring.score_row( row, scoring.ScoreContext(now=now, max_size_bytes=10**11, has_completion_data=True)) without = scoring.score_row( row, scoring.ScoreContext(now=now, max_size_bytes=10**11, has_completion_data=False)) assert without["components"]["rejection"] is None # rejection would have scored 0 here, so dropping it must RAISE the score assert without["score"] > with_cd["score"] def test_rejection_zeroes_once_something_is_finished(): now = int(time.time()) ctx = scoring.ScoreContext(now=now, max_size_bytes=10**11) base = {"kind": "movie", "size_bytes": 5 * 10**9, "added_at": now - 2000 * 86400, "last_watched_at": None, "abandoned_count": 4, "distinct_watcher_count": 2, "episode_count": 0, "pre_history": 0} assert scoring.components({**base, "watch_count": 0}, ctx)["rejection"] == 1.0 assert scoring.components({**base, "watch_count": 1}, ctx)["rejection"] == 0.0 def test_tv_watches_are_normalized_per_episode(): """A 24-episode season watched once must not look 24x more popular.""" now = int(time.time()) ctx = scoring.ScoreContext(now=now, max_size_bytes=10**11) season = {"kind": "season", "size_bytes": 10**10, "added_at": now - 1000 * 86400, "last_watched_at": now - 500 * 86400, "watch_count": 24, "abandoned_count": 0, "distinct_watcher_count": 1, "episode_count": 24, "pre_history": 0} movie = {**season, "kind": "movie", "watch_count": 1, "episode_count": 0} assert scoring.components(season, ctx)["unpopularity"] == \ pytest.approx(scoring.components(movie, ctx)["unpopularity"]) def test_pre_history_never_watched_is_capped_below_truly_never_watched(): now = int(time.time()) ctx = scoring.ScoreContext(now=now, max_size_bytes=10**11) base = {"kind": "movie", "size_bytes": 10**10, "added_at": now - 3000 * 86400, "last_watched_at": None, "watch_count": 0, "abandoned_count": 0, "distinct_watcher_count": 0, "episode_count": 0} known = scoring.components({**base, "pre_history": 0}, ctx)["staleness"] unknown = scoring.components({**base, "pre_history": 1}, ctx)["staleness"] assert known == 1.0 assert unknown < known