"""Bloom — the daily word wheel. Locks the design/acceptance split: • DESIGN (deterministic, stored): wheel + tiers + pangram + Full-Bloom target, from the COMMON list. The PERMANENT guardrail — Flourishing reachable with common words — still holds. • ACCEPTANCE (broad + dynamic): every valid word buildable from the wheel, computed live as broad dict ∪ {allow} − {block}; runtime admin overrides + player reports drive curation with no deploy. """ import os from pathlib import Path import pytest from fastapi.testclient import TestClient from goodnews import bloom, games from goodnews.db import connect, init_db DATES = [f"2026-06-{d:02d}" for d in range(10, 25)] # 15 sample days @pytest.fixture(scope="module") def designs(): return {d: bloom.build_puzzle(d) for d in DATES} @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 def _letters(p): return frozenset(p["center"]) | frozenset(p["outer"]) def _commons_for(p): """COMMON words for a center-mode wheel (the designed puzzle).""" L = _letters(p) return [w for w in bloom._COMMON if p["center"] in w and frozenset(w) <= L] def _assert_no_answer_leak(resp): assert "words" not in resp assert resp["accepted"] and all( isinstance(h, str) and len(h) == 64 and set(h) <= set("0123456789abcdef") for h in resp["accepted"]) # --- DESIGN (deterministic, common-based) -------------------------------------- def test_build_is_deterministic(): assert bloom.build_puzzle("2026-06-15") == bloom.build_puzzle("2026-06-15") @pytest.mark.parametrize("date", DATES) def test_design_shape(designs, date): p = designs[date] L = _letters(p) assert len(L) == 7 and "s" not in L assert p["center"] in L and len(p["outer"]) == 6 assert bloom.MIN_COMMON_WORDS <= len(_commons_for(p)) <= bloom.MAX_COMMON_WORDS assert frozenset(p["pangram"]) == L # display pangram uses all 7 assert p["pangram"] in bloom._COMMON and p["pangram"] not in bloom._AVOID @pytest.mark.parametrize("date", DATES) def test_PERMANENT_top_tier_reachable_with_common_words(designs, date): """Flourishing reachable from COMMON words alone — never obscure-word hunting.""" p = designs[date] flourishing = next(t["score"] for t in p["tiers"] if t["name"] == "Flourishing") assert bloom.score_words(p, _commons_for(p)) >= flourishing def test_tiers_are_8_30_70_of_common_and_max_is_common_total(): p = bloom.build_puzzle("2026-06-15") assert [t["name"] for t in p["tiers"]] == ["Sprouting", "Budding", "Blooming", "Flourishing"] common_total = bloom.score_words(p, _commons_for(p)) assert p["max_score"] == common_total # Full Bloom = the designed puzzle flour = next(t["score"] for t in p["tiers"] if t["name"] == "Flourishing") assert flour == int(0.70 * common_total) and flour <= p["max_score"] # --- ACCEPTANCE (broad + dynamic) ---------------------------------------------- def test_accept_is_broad_and_obeys_center_rule(conn): p = bloom.build_puzzle("2026-06-15") acc = bloom.accepted_words(conn, p["center"], p["outer"], require_center=True) L = _letters(p) for w in acc: assert len(w) >= 4 and "s" not in w and frozenset(w) <= L and p["center"] in w # broad accept is a SUPERSET of the common puzzle (bonus words beyond design) assert set(_commons_for(p)) <= set(acc) assert len(acc) > len(_commons_for(p)) def test_arraign_class_words_auto_accepted(): # broad dict includes real-but-rare words without any include-list for w in ("arraign", "feign", "crwth"): assert w in set(bloom.ACCEPT) def test_overrides_block_and_allow(conn): p = bloom.build_puzzle("2026-06-15") acc0 = set(bloom.accepted_words(conn, p["center"], p["outer"], True)) victim = sorted(acc0)[0] bloom.set_override(conn, victim, "block", by="t") assert victim not in set(bloom.accepted_words(conn, p["center"], p["outer"], True)) # allow a made-up letter-combo that fits the wheel + center fake = (p["center"] + "".join(p["outer"][:3]))[:5] if "s" not in fake and len(fake) >= 4: bloom.set_override(conn, fake, "allow", by="t") assert fake in set(bloom.accepted_words(conn, p["center"], p["outer"], True)) bloom.clear_override(conn, victim) assert victim in set(bloom.accepted_words(conn, p["center"], p["outer"], True)) def test_allow_override_rejects_inert_hard_rule_words(conn): # an allow that could never count (too short / has 's') is rejected, not stored assert bloom.set_override(conn, "cat", "allow") is False # < 4 letters assert bloom.set_override(conn, "roses", "allow") is False # contains 's' assert bloom.set_override(conn, "bloom", "allow") is True # valid → stored allow, _ = bloom.overrides(conn) assert allow == {"bloom"} # block stays permissive (can block anything) assert bloom.set_override(conn, "roses", "block") is True def test_wild_accepts_words_without_center(conn): p = bloom.build_free("seed-w", "wild") acc = bloom.accepted_words(conn, p["center"], p["outer"], require_center=False) assert any(p["center"] not in w for w in acc) # Wild's defining trait assert all(frozenset(w) <= _letters(p) for w in acc) # --- responses + storage ------------------------------------------------------- def test_generate_is_idempotent_and_stored(conn): a = bloom.generate_bloom_puzzle(conn, "2026-06-15") assert a == bloom.generate_bloom_puzzle(conn, "2026-06-15") == bloom.stored_payload(conn, "2026-06-15") assert "words" not in a # design payload holds no answers def test_response_no_leak_and_hash_roundtrip(conn): r = bloom.bloom_response(conn, "2026-06-15") _assert_no_answer_leak(r) p = bloom.stored_payload(conn, "2026-06-15") real = bloom.accepted_words(conn, p["center"], p["outer"], True)[0] assert bloom.word_hash("2026-06-15", real) in set(r["accepted"]) assert bloom.word_hash("2026-06-15", "zzzzq") not in set(r["accepted"]) assert r["max_score"] == p["max_score"] def test_free_endpoint_resumes_and_leaks_nothing(api_app): tc = TestClient(api_app) r1 = tc.get("/api/puzzle/bloom/free?format=wild").json() seed = r1["seed"] assert r1["mode"] == "free" and r1["format"] == "wild" and seed r2 = tc.get(f"/api/puzzle/bloom/free?format=wild&seed={seed}").json() assert r2["center"] == r1["center"] and r2["outer"] == r1["outer"] _assert_no_answer_leak(r1) # --- server-side state --------------------------------------------------------- def test_sanitize_drops_junk_recomputes_score_and_full(conn): p = bloom.generate_bloom_puzzle(conn, "2026-06-15") acc = bloom.accepted_words(conn, p["center"], p["outer"], True) good = acc[:3] clean = games.sanitize_game_state(conn, "bloom", "", "2026-06-15", {"found": good + ["zzzz", "ab", good[0], 9], "score": 9999}) assert sorted(clean["found"]) == sorted(set(good)) assert clean["score"] == bloom.score_words(p, good) assert "full" not in clean # finding the whole common puzzle ⇒ Full Bloom (score ≥ max_score) full = games.sanitize_game_state(conn, "bloom", "", "2026-06-15", {"found": _commons_for(p)}) assert full.get("full") is True def test_merge_unions_found(): m = games.merge_game_state("bloom", {"found": ["able", "bake"]}, {"found": ["bake", "tale"]}) assert sorted(m["found"]) == ["able", "bake", "tale"] def test_block_override_takes_effect_without_regen(conn): # the live response reflects an override with no puzzle regeneration p = bloom.generate_bloom_puzzle(conn, "2026-06-15") victim = bloom.accepted_words(conn, p["center"], p["outer"], True)[0] before = set(bloom.bloom_response(conn, "2026-06-15")["accepted"]) bloom.set_override(conn, victim, "block", by="t") after = set(bloom.bloom_response(conn, "2026-06-15")["accepted"]) assert bloom.word_hash("2026-06-15", victim) in before assert bloom.word_hash("2026-06-15", victim) not in after # --- reports → admin queue → overrides ----------------------------------------- def test_report_then_approve_creates_allow_override(conn): assert bloom.add_report(conn, "arraign", "2026-06-15", "daily", "center", "aceglnr", "not in the word list") assert bloom.add_report(conn, "arraign", "2026-06-15", "daily", "center", "aceglnr", "x") # dedup pending pending = bloom.list_reports(conn, "pending") assert len(pending) == 1 and pending[0]["word"] == "arraign" assert bloom.resolve_report(conn, pending[0]["id"], "approve", by="admin") allow, _ = bloom.overrides(conn) assert "arraign" in allow assert not bloom.list_reports(conn, "pending") assert bloom.list_reports(conn, "approved") def test_report_block_creates_block_override(conn): bloom.add_report(conn, "uglyword", None, "free", "wild", "abcdefg", "x") rid = bloom.list_reports(conn, "pending")[0]["id"] bloom.resolve_report(conn, rid, "block", by="admin") _, block = bloom.overrides(conn) assert "uglyword" in block # --- API: public report + admin endpoints -------------------------------------- @pytest.fixture def api_app(tmp_path, monkeypatch): db = tmp_path / "t.sqlite3" monkeypatch.setenv("GOODNEWS_DB", str(db)) monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver") monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", "admin@b.com") import importlib import goodnews.api as api importlib.reload(api) c = connect(str(db)); init_db(c); c.commit(); c.close() return api.create_app() def _admin(app): tc = TestClient(app) sent = {} import goodnews.email_send as es orig = es.send_magic_link es.send_magic_link = lambda to, link: sent.update(link=link) try: tc.post("/api/auth/email/start", json={"email": "admin@b.com"}) tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]}) finally: es.send_magic_link = orig return tc def test_public_report_then_admin_queue_flow(api_app): pub = TestClient(api_app) assert pub.post("/api/bloom/report", json={"word": "arraign", "date": "2026-06-15", "mode": "daily", "format": "center", "letters": "aceglnr", "reason": "not in the word list"}).json()["ok"] # admin-only queue assert TestClient(api_app).get("/api/admin/bloom/reports").status_code == 401 tc = _admin(api_app) q = tc.get("/api/admin/bloom/reports").json() assert len(q["reports"]) == 1 rid = q["reports"][0]["id"] assert tc.post(f"/api/admin/bloom/reports/{rid}", json={"action": "approve"}).json()["ok"] ovr = tc.get("/api/admin/bloom/reports").json()["overrides"] assert any(o["word"] == "arraign" and o["action"] == "allow" for o in ovr)