ddcfab3a11
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.
- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
read-only). queries.source_articles + source_articles_summary — per article:
title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
(domain rule), has_image, duplicate. Summary = counts + source-level paywall
rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
· … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
image/Duplicates), compact rows with title→link + badges + reason, Load more.
So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
134 lines
6.9 KiB
Python
134 lines
6.9 KiB
Python
"""Cross-device game-state sync: server-side merge so two devices converge."""
|
|
import pytest
|
|
|
|
from goodnews import games
|
|
from goodnews.db import connect, init_db
|
|
|
|
|
|
@pytest.fixture
|
|
def conn(tmp_path):
|
|
c = connect(str(tmp_path / "t.db"))
|
|
init_db(c)
|
|
c.execute("INSERT INTO users (email) VALUES ('a@b.c')")
|
|
c.commit()
|
|
return c
|
|
|
|
|
|
# --- merge logic (the audited core) ---
|
|
|
|
def test_merge_wordsearch_unions_finds():
|
|
a = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "played": 9000, "ms": 0}
|
|
b = {"foundWords": [{"word": "DOG", "cells": [[1, 1]], "ci": 1}], "played": 4000, "ms": 0}
|
|
m = games.merge_game_state("wordsearch", a, b)
|
|
assert {f["word"] for f in m["foundWords"]} == {"CAT", "DOG"} # union of finds
|
|
# active-time clock: the device that banked the most play time is the truth —
|
|
# wall-clock gaps between sittings must never inflate the timer
|
|
assert m["played"] == 9000
|
|
|
|
|
|
def test_merge_wordsearch_dedupes_and_keeps_best_time():
|
|
a = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "ms": 5000}
|
|
b = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "ms": 3000}
|
|
m = games.merge_game_state("wordsearch", a, b)
|
|
assert len(m["foundWords"]) == 1 and m["ms"] == 3000 # same word once, best time
|
|
|
|
|
|
def test_merge_word_furthest_progress_wins():
|
|
playing = {"status": "playing", "guesses": ["AAAAA", "BBBBB"]}
|
|
won = {"status": "won", "guesses": ["AAAAA", "CCCCC", "DDDDD"]}
|
|
assert games.merge_game_state("word", playing, won) == won # terminal beats in-progress
|
|
less = {"status": "playing", "guesses": ["A"]}
|
|
more = {"status": "playing", "guesses": ["A", "B", "C"]}
|
|
assert games.merge_game_state("word", less, more) == more # more guesses beats fewer
|
|
|
|
|
|
def test_merge_handles_missing_sides():
|
|
a = {"status": "won", "guesses": ["x"]}
|
|
assert games.merge_game_state("word", None, a) == a
|
|
assert games.merge_game_state("word", a, None) == a
|
|
|
|
|
|
# --- persistence convergence ---
|
|
|
|
def _find(word, row): # a shape-valid find: cells spelling the word along a row
|
|
return {"word": word, "cells": [[row, i] for i in range(len(word))], "ci": 0}
|
|
|
|
|
|
def test_save_converges_across_devices(conn):
|
|
# No stored puzzle for this date → shape-only sanitize (words 4-12, cells match).
|
|
games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12",
|
|
{"foundWords": [_find("BEACH", 0)], "played": 100})
|
|
merged = games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12",
|
|
{"foundWords": [_find("OCEAN", 1)], "played": 50})
|
|
assert {f["word"] for f in merged["foundWords"]} == {"BEACH", "OCEAN"}
|
|
# stored state reflects the merge (order-independent): most banked time wins
|
|
assert games.load_game_state(conn, 1, "wordsearch", "small", "2026-06-12")["played"] == 100
|
|
|
|
|
|
# --- derived stats ---
|
|
|
|
def test_word_stats_streak_and_distribution(conn):
|
|
games.save_game_state(conn, 1, "word", "5", "2026-06-12", {"status": "won", "guesses": ["aaaaa", "bbbbb", "ccccc"]})
|
|
games.save_game_state(conn, 1, "word", "5", "2026-06-11", {"status": "won", "guesses": ["aaaaa", "bbbbb", "ccccc", "ddddd"]})
|
|
games.save_game_state(conn, 1, "word", "5", "2026-06-10", {"status": "lost", "guesses": ["aaaaa"] * 6})
|
|
st = games.game_stats(conn, 1, "word", "5")
|
|
assert st["played"] == 3 and st["won"] == 2
|
|
assert st["streak"] == 2 # two most-recent wins, then the loss stops it
|
|
assert st["dist"] == {3: 1, 4: 1}
|
|
|
|
|
|
def test_wordsearch_stats_best_time(conn):
|
|
import json
|
|
# Store completed states directly — game_stats just reads what's persisted
|
|
# (the sanitizer that gates `ms` on real completion is covered separately).
|
|
for d, ms in (("2026-06-12", 4000), ("2026-06-11", 6000)):
|
|
conn.execute("INSERT INTO game_state (user_id, game, variant, puzzle_date, state_json) "
|
|
"VALUES (1,'wordsearch','med',?,?)", (d, json.dumps({"foundWords": [], "ms": ms})))
|
|
conn.commit()
|
|
st = games.game_stats(conn, 1, "wordsearch", "med")
|
|
assert st["completed"] == 2 and st["best"] == 4000
|
|
|
|
|
|
# --- API round-trip (signed-in only; needs the test helpers) ---
|
|
|
|
def test_game_state_api_roundtrip(tmp_path, monkeypatch):
|
|
from test_admin import _make, _signin
|
|
from fastapi.testclient import TestClient
|
|
app, api = _make(tmp_path, monkeypatch)
|
|
# signed out → no sync, echoes the posted state and GET sees nothing stored
|
|
anon = TestClient(app)
|
|
body = {"game": "wordsearch", "variant": "small", "date": "2026-06-12",
|
|
"state": {"foundWords": [_find("BEACH", 0)], "played": 9}}
|
|
assert anon.put("/api/games/state", json=body).json()["state"]["foundWords"][0]["word"] == "BEACH"
|
|
assert anon.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"] is None
|
|
# signed in: push from "device A", then "device B" → server returns the union
|
|
tc = _signin(app, api, "p@x.com")
|
|
tc.put("/api/games/state", json=body)
|
|
bodyB = {**body, "state": {"foundWords": [_find("OCEAN", 1)], "played": 4}}
|
|
merged = tc.put("/api/games/state", json=bodyB).json()["state"]
|
|
assert {f["word"] for f in merged["foundWords"]} == {"BEACH", "OCEAN"} and merged["played"] == 9
|
|
# GET returns the stored merge; unknown game → 404; bad date → 400
|
|
got = tc.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"]
|
|
assert {f["word"] for f in got["foundWords"]} == {"BEACH", "OCEAN"}
|
|
assert tc.get("/api/games/state?game=nope&variant=x&date=2026-06-12").status_code == 404
|
|
assert tc.get("/api/games/state?game=wordsearch&variant=small&date=notadate").status_code == 400
|
|
|
|
|
|
def test_sanitizers_reject_junk(conn):
|
|
# Word: bad status → playing; wrong-length/non-alpha guesses dropped; cols capped to kept guesses
|
|
w = games._sanitize_word("5", {"status": "hacked", "guesses": ["abcde", "no", "12345", "fghij"],
|
|
"cols": [["correct", "absent", "bogus", "x", "y"], ["absent"] * 5, ["x"], ["y"]]})
|
|
assert w["status"] == "playing" and w["guesses"] == ["abcde", "fghij"]
|
|
assert len(w["cols"]) == 2 and w["cols"][0] == ["correct", "absent"]
|
|
# Word Search (no stored puzzle → shape-only): bad shapes dropped, no completion without word count
|
|
ws = games._sanitize_wordsearch(conn, "small", "2026-06-12", {
|
|
"foundWords": [_find("BEACH", 0), # ok
|
|
{"word": "CAT", "cells": [[0, 0], [0, 1], [0, 2]]}, # too short (<4)
|
|
{"word": "OCEAN", "cells": [[1, 0], [1, 1]]}], # cells != len
|
|
"ms": 12345, "played": -5})
|
|
assert [f["word"] for f in ws["foundWords"]] == ["BEACH"] and ws["ms"] == 0
|
|
assert ws["played"] == 0 # negative junk clamped
|
|
# absurd active-time claims are capped at a day
|
|
capped = games._sanitize_wordsearch(conn, "small", "2026-06-12", {"foundWords": [], "played": 10**12})
|
|
assert capped["played"] == 86_400_000
|