89c0fbe1f6
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.
SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
(a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
rep (URL stability) -> quality score, so an accepted page never retires to a
rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).
Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
picker (bundled data, no CDN) for the blurb editor.
Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.
Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
256 lines
11 KiB
Python
256 lines
11 KiB
Python
"""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)
|