Pool admin: delete any word (tombstones + restore) + bulk import

Daily Word pool curation, full add/delete/import — no redeploys to fix tone:
- Remove ANY pool word, curated or admin-added, via a word_pool_removed
  tombstone table. Runtime pool = (static ∪ added) − removed, so even a
  baked-in word can be pulled on negative feedback. Reversible: a "Removed"
  list with one-tap Restore lifts the tombstone. Lookup now surfaces a Remove
  button when in-pool, Restore when removed.
- Import a vetted list (paste or .txt/.csv upload, read client-side): validates
  each word (alpha · 5–6 · in guess dictionary), ignores duplicates, and reports
  rejects with reasons. Re-adding/importing a removed word lifts its tombstone.
- Word Search theme delete already existed (Edit/Remove per theme) — verified.

Pool stays the clean 251/224; today's noisy LLM enrichment is discarded.
Tests: +tests/test_pool_admin.py, extended test_word_pool_admin. 222 pytest +
11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-11 17:17:16 -04:00
parent fb781f48b8
commit 2461584052
6 changed files with 352 additions and 19 deletions
+18 -1
View File
@@ -371,12 +371,14 @@ def test_wordsearch_thin_llm_falls_back(tmp_path, monkeypatch):
def test_word_pool_admin(tmp_path, monkeypatch):
from goodnews import games
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
assert TestClient(app).get("/api/admin/word/pool").status_code == 401 # gated
tc = _signin(app, api, "boss@x.com")
# lookup: valid dict word vs non-word vs wrong length
assert tc.get("/api/admin/word/lookup?word=thrive").json() == {
"word": "thrive", "length": 6, "alpha": True, "variant": "6", "in_dictionary": True, "in_pool": True}
"word": "thrive", "length": 6, "alpha": True, "variant": "6",
"in_dictionary": True, "in_pool": True, "removed": False}
assert tc.get("/api/admin/word/lookup?word=zzzzz").json()["in_dictionary"] is False
assert tc.get("/api/admin/word/lookup?word=cat").json()["variant"] is None
# add a valid word, then it shows in the pool + lookup
@@ -390,6 +392,21 @@ def test_word_pool_admin(tmp_path, monkeypatch):
# remove the admin-added word
tc.delete("/api/admin/word/pool/plumb")
assert "plumb" not in tc.get("/api/admin/word/pool").json()["5"]["added"]
# remove a CURATED word (only a tombstone can pull it) → restore brings it back
tc.delete("/api/admin/word/pool/thrive")
look = tc.get("/api/admin/word/lookup?word=thrive").json()
assert look["in_pool"] is False and look["removed"] is True
assert "thrive" in tc.get("/api/admin/word/pool").json()["6"]["removed"]
tc.post("/api/admin/word/pool/restore", json={"word": "thrive"})
assert tc.get("/api/admin/word/lookup?word=thrive").json()["in_pool"] is True
# bulk import: validates, dedupes, reports rejects
fresh5 = next(w for w in sorted(games._DICT["5"]) if w not in set(games._POOL["5"]))
imp = tc.post("/api/admin/word/pool/import",
json={"text": f"{fresh5.upper()}, {fresh5}, thrive, qwxzv, hi"}).json()
assert fresh5 in imp["added"] and imp["counts"]["added"] == 1
assert imp["counts"]["duplicates"] >= 1 # thrive already present
assert {r["word"] for r in imp["rejected"]} == {"qwxzv", "hi"}
assert fresh5 in tc.get("/api/admin/word/pool").json()["5"]["added"]
def test_client_error_telemetry(tmp_path, monkeypatch):
+93
View File
@@ -0,0 +1,93 @@
"""Admin curation of the Daily Word answer pool: add, remove (tombstone any
word — curated or added), restore, and bulk import with dedupe + validation."""
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)
return c
def _a_curated(variant):
return games._POOL[variant][0] # a known baked-in word
def _fresh(variant, n=1):
"""n dictionary words of the given length that are NOT already in the pool."""
pool = set(games._POOL[variant])
out = [w for w in sorted(games._DICT[variant]) if w not in pool and w.isalpha()]
return out[:n]
def test_add_and_remove_added_word(conn):
w = _fresh("5")[0]
res = games.add_pool_word(conn, w)
assert res.get("added") and res["variant"] == "5"
assert w in games.answer_pool(conn, "5")
# Removing an added word tombstones it → drops out of the live pool.
games.remove_pool_word(conn, w)
assert w not in games.answer_pool(conn, "5")
# ...and is reversible.
games.restore_pool_word(conn, w)
assert w in games.answer_pool(conn, "5")
def test_remove_curated_word_via_tombstone(conn):
w = _a_curated("5")
assert w in games.answer_pool(conn, "5")
games.remove_pool_word(conn, w) # curated word — only a tombstone can pull it
assert w not in games.answer_pool(conn, "5")
summary = games.pool_summary(conn)
assert w in summary["5"]["removed"]
games.restore_pool_word(conn, w)
assert w in games.answer_pool(conn, "5")
def test_add_rejects_non_dictionary_and_bad_length(conn):
assert "error" in games.add_pool_word(conn, "zzzzz") # not in dict
assert "error" in games.add_pool_word(conn, "hi") # wrong length
assert "error" in games.add_pool_word(conn, _a_curated("5")) # already in pool
def test_add_relifts_tombstone(conn):
w = _a_curated("6")
games.remove_pool_word(conn, w)
assert w not in games.answer_pool(conn, "6")
# Re-adding a removed word lifts the tombstone rather than erroring.
res = games.add_pool_word(conn, w)
assert res.get("added")
assert w in games.answer_pool(conn, "6")
def test_import_dedupes_and_validates(conn):
new5 = _fresh("5", 2) # two fresh 5-letter words
added_word = new5[0]
games.add_pool_word(conn, added_word)
existing_curated = _a_curated("5")
res = games.import_pool_words(conn, [
new5[1], # valid new
new5[1].upper(), # same word, different case → counted once
added_word, # dup (already added)
existing_curated, # dup (curated)
"zzzzz", # rejected: not in dictionary
"hi", # rejected: wrong length
" ", # blank → ignored
])
assert set(res["added"]) == {new5[1]}
assert res["counts"]["added"] == 1
assert res["counts"]["duplicates"] >= 2
assert {r["word"] for r in res["rejected"]} == {"zzzzz", "hi"}
assert new5[1] in games.answer_pool(conn, "5")
def test_import_relifts_removed_word(conn):
w = _a_curated("5")
games.remove_pool_word(conn, w)
res = games.import_pool_words(conn, [w])
assert w in res["added"]
assert w in games.answer_pool(conn, "5")