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:
@@ -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")
|
||||
Reference in New Issue
Block a user