"""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_picker_survives_empty_live_pool(conn): """An overzealous admin could tombstone every answer in a variant. The live pool then reads empty, but the daily picker must not divide-by-zero — it falls back to the curated baseline rather than crashing.""" for w in list(games._POOL["5"]): games.remove_pool_word(conn, w) assert games.answer_pool(conn, "5") == [] # live pool truly empty ans = games._pick_answer(conn, "2026-06-11", "5") # must not raise assert ans in set(games._POOL["5"]) # fell back to curated 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")