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:
@@ -66,8 +66,52 @@
|
||||
wpPool = res.pool; wpWord = ''; wpResult = null; wpMsg = `Added “${res.word}” to the ${res.variant}-letter pool.`;
|
||||
} catch (e) { wpMsg = e.message || 'Could not add that word.'; }
|
||||
}
|
||||
// Refresh the live lookup tag after a pool mutation, so the button flips Remove↔Restore↔Add.
|
||||
async function refreshLookup() {
|
||||
const w = wpWord.trim();
|
||||
if (!w) { wpResult = null; return; }
|
||||
try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
|
||||
catch { wpResult = null; }
|
||||
}
|
||||
async function removeWord(w) {
|
||||
try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ }
|
||||
try {
|
||||
wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w));
|
||||
wpMsg = `Removed “${w}”. It won’t appear as an answer — restore it any time below.`;
|
||||
await refreshLookup();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
async function restoreWord(w) {
|
||||
try {
|
||||
wpPool = await postJSON('/api/admin/word/pool/restore', { word: w });
|
||||
wpMsg = `Restored “${w}” to the pool.`;
|
||||
await refreshLookup();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Games: bulk import (paste or .txt/.csv upload) ---
|
||||
let wpImportText = $state('');
|
||||
let wpImportResult = $state(null); // { counts, added[], duplicates[], rejected[] }
|
||||
let wpImporting = $state(false);
|
||||
function onImportFile(e) {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result || '');
|
||||
wpImportText = wpImportText.trim() ? wpImportText.trim() + '\n' + text : text;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = ''; // allow re-picking the same file
|
||||
}
|
||||
async function importWords() {
|
||||
if (!wpImportText.trim() || wpImporting) return;
|
||||
wpImporting = true; wpImportResult = null;
|
||||
try {
|
||||
const res = await postJSON('/api/admin/word/pool/import', { text: wpImportText });
|
||||
wpPool = res.pool; wpImportResult = res; wpImportText = '';
|
||||
await refreshLookup();
|
||||
} catch (e) { wpImportResult = { error: e.message || 'Import failed.' }; }
|
||||
finally { wpImporting = false; }
|
||||
}
|
||||
|
||||
// --- Games: Word Search themes ---
|
||||
@@ -783,7 +827,11 @@
|
||||
{#if !wpResult.variant}
|
||||
<span class="wp-tag bad">Must be 5 or 6 letters</span>
|
||||
{:else if wpResult.in_pool}
|
||||
<span class="wp-tag ok">Already in the pool</span>
|
||||
<span class="wp-tag ok">In the pool</span>
|
||||
<button class="act del" onclick={() => removeWord(wpResult.word)}>Remove</button>
|
||||
{:else if wpResult.removed}
|
||||
<span class="wp-tag bad">Removed</span>
|
||||
<button class="wp-add" onclick={() => restoreWord(wpResult.word)}>Restore</button>
|
||||
{:else if wpResult.in_dictionary}
|
||||
<span class="wp-tag ok">Valid {wpResult.variant}-letter word</span>
|
||||
<button class="wp-add" onclick={addWord}>Add to pool</button>
|
||||
@@ -807,11 +855,52 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
|
||||
{#if wpPool[v].removed && wpPool[v].removed.length}
|
||||
<p class="muted small" style="margin-top:10px">Removed ({wpPool[v].removed.length}) — tap to restore:</p>
|
||||
<ul class="wp-added removed">
|
||||
{#each wpPool[v].removed as w (w)}
|
||||
<li>{w}<button class="x" onclick={() => restoreWord(w)} aria-label={'Restore ' + w}>↺</button></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3 style="margin-top:24px">Import a vetted list</h3>
|
||||
<p class="muted small">Paste words (any separators) or upload a .txt/.csv. Each is checked —
|
||||
real word · 5–6 letters · in the guess dictionary — and duplicates are ignored.</p>
|
||||
<div class="wp-import">
|
||||
<textarea bind:value={wpImportText} rows="4"
|
||||
placeholder="clear, bloom, kindle, ripple… (or upload a file below)"></textarea>
|
||||
<div class="wp-import-row">
|
||||
<label class="act file">Choose file…
|
||||
<input type="file" accept=".txt,.csv,text/plain,text/csv" onchange={onImportFile} hidden />
|
||||
</label>
|
||||
<button class="wp-add" onclick={importWords} disabled={!wpImportText.trim() || wpImporting}>
|
||||
{wpImporting ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
{#if wpImportResult}
|
||||
{#if wpImportResult.error}
|
||||
<p class="wp-msg">{wpImportResult.error}</p>
|
||||
{:else}
|
||||
<p class="wp-msg">Added {wpImportResult.counts.added} · ignored {wpImportResult.counts.duplicates} duplicate(s) · rejected {wpImportResult.counts.rejected}.</p>
|
||||
{#if wpImportResult.rejected.length}
|
||||
<details class="wp-rejects">
|
||||
<summary>{wpImportResult.rejected.length} rejected — see why</summary>
|
||||
<ul class="muted small">
|
||||
{#each wpImportResult.rejected as r (r.word)}
|
||||
<li>{r.word} — {r.reason}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:38px">Word Search themes</h2>
|
||||
<p class="muted">Add a theme and its words — the system splits them across the three sizes and places
|
||||
them. You need {WS_NEEDED}+ valid words (4–8 letters) to fill all three puzzles. Stuck? Let the AI
|
||||
@@ -1139,6 +1228,24 @@
|
||||
.wp-added .x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.15rem;
|
||||
line-height: 1; padding: 0 5px; border-radius: 50%; }
|
||||
.wp-added .x:hover { color: #9a3b3b; }
|
||||
.wp-added.removed li { opacity: 0.7; border-style: dashed; }
|
||||
.wp-added.removed .x { font-size: 0.95rem; }
|
||||
.wp-added.removed .x:hover { color: var(--accent-deep); }
|
||||
.wp-lookup .act { font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 7px 16px; cursor: pointer; color: #9a3b3b; }
|
||||
.wp-lookup .act:hover { border-color: #9a3b3b; }
|
||||
/* Bulk import */
|
||||
.wp-import { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 10px 0 6px; }
|
||||
.wp-import textarea { font: inherit; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
|
||||
background: var(--surface); color: var(--ink); resize: vertical; line-height: 1.6;
|
||||
text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.wp-import-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.act.file { font: inherit; font-size: 0.84rem; background: var(--accent-soft); color: var(--accent-deep);
|
||||
border: none; border-radius: 999px; padding: 8px 16px; cursor: pointer; }
|
||||
.act.file:hover { filter: brightness(0.97); }
|
||||
.wp-rejects { margin: 4px 0 0; }
|
||||
.wp-rejects summary { cursor: pointer; color: var(--accent-deep); font-size: 0.84rem; }
|
||||
.wp-rejects ul { margin: 8px 0 0; padding-left: 18px; }
|
||||
|
||||
/* Word Search theme authoring */
|
||||
.ws-form { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 14px 0 6px; }
|
||||
|
||||
@@ -344,6 +344,11 @@ class WordPoolBody(BaseModel):
|
||||
word: str
|
||||
|
||||
|
||||
class WordPoolImportBody(BaseModel):
|
||||
text: str = ""
|
||||
words: list[str] = []
|
||||
|
||||
|
||||
class ClientErrorBody(BaseModel):
|
||||
reason: str = ""
|
||||
path: str = ""
|
||||
@@ -1499,6 +1504,7 @@ def create_app() -> FastAPI:
|
||||
_require_admin(conn, request)
|
||||
res = games.lookup_word(word)
|
||||
res["in_pool"] = bool(res["variant"]) and res["word"] in games.answer_pool(conn, res["variant"])
|
||||
res["removed"] = bool(res["variant"]) and res["word"] in games._db_removed(conn, res["variant"])
|
||||
return res
|
||||
|
||||
@app.get("/api/admin/word/pool")
|
||||
@@ -1523,6 +1529,23 @@ def create_app() -> FastAPI:
|
||||
games.remove_pool_word(conn, word)
|
||||
return games.pool_summary(conn)
|
||||
|
||||
@app.post("/api/admin/word/pool/restore")
|
||||
def admin_word_pool_restore(body: WordPoolBody, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
games.restore_pool_word(conn, body.word)
|
||||
return games.pool_summary(conn)
|
||||
|
||||
@app.post("/api/admin/word/pool/import")
|
||||
def admin_word_pool_import(body: WordPoolImportBody, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
words = list(body.words)
|
||||
if body.text:
|
||||
words += re.findall(r"[A-Za-z]+", body.text)
|
||||
res = games.import_pool_words(conn, words)
|
||||
return {**res, "pool": games.pool_summary(conn)}
|
||||
|
||||
# --- Admin: Word Search theme authoring ---
|
||||
@app.get("/api/admin/wordsearch/themes")
|
||||
def admin_ws_themes(request: Request) -> list[dict]:
|
||||
|
||||
@@ -283,6 +283,13 @@ CREATE TABLE IF NOT EXISTS word_pool (
|
||||
PRIMARY KEY (variant, word)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS word_pool_removed (
|
||||
word TEXT NOT NULL,
|
||||
variant TEXT NOT NULL, -- '5' | '6'
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (variant, word)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_puzzles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
puzzle_date TEXT NOT NULL,
|
||||
|
||||
+102
-16
@@ -36,9 +36,21 @@ def _db_pool(conn: sqlite3.Connection, variant: str) -> list[str]:
|
||||
return [r["word"] for r in rows]
|
||||
|
||||
|
||||
def _db_removed(conn: sqlite3.Connection, variant: str) -> list[str]:
|
||||
rows = conn.execute(
|
||||
"SELECT word FROM word_pool_removed WHERE variant=? ORDER BY word", (variant,)
|
||||
).fetchall()
|
||||
return [r["word"] for r in rows]
|
||||
|
||||
|
||||
def answer_pool(conn: sqlite3.Connection, variant: str) -> list[str]:
|
||||
"""The day's answer pool = curated static list ∪ admin-added (DB) words."""
|
||||
return sorted(set(_POOL.get(variant, [])) | set(_db_pool(conn, variant)))
|
||||
"""The day's answer pool = (curated static ∪ admin-added) − admin-removed.
|
||||
|
||||
Removals are tombstones, so even a baked-in curated word can be pulled
|
||||
(e.g. on negative feedback) without editing the JSON or redeploying — and
|
||||
a removal is reversible by lifting the tombstone (restore)."""
|
||||
pool = (set(_POOL.get(variant, [])) | set(_db_pool(conn, variant))) - set(_db_removed(conn, variant))
|
||||
return sorted(pool)
|
||||
|
||||
|
||||
# --- Admin: Daily Word pool curation -------------------------------------------
|
||||
@@ -56,37 +68,111 @@ def lookup_word(word: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _validate_word(conn: sqlite3.Connection, w: str) -> tuple[str | None, str | None]:
|
||||
"""Return (variant, error). variant is set only when w is a valid candidate."""
|
||||
if not w.isalpha() or str(len(w)) not in WORD_VARIANTS:
|
||||
return None, "not a 5- or 6-letter word"
|
||||
variant = str(len(w))
|
||||
if w not in _DICT[variant]:
|
||||
return None, "not in the dictionary — it wouldn't be guessable"
|
||||
return variant, None
|
||||
|
||||
|
||||
def _put_word(conn: sqlite3.Connection, w: str, variant: str) -> None:
|
||||
"""Make w a live answer: lift any tombstone, and record it as an addition
|
||||
only if it isn't already part of the curated static list."""
|
||||
if w not in set(_POOL.get(variant, [])):
|
||||
conn.execute("INSERT OR IGNORE INTO word_pool (word, variant) VALUES (?, ?)", (w, variant))
|
||||
conn.execute("DELETE FROM word_pool_removed WHERE variant=? AND word=?", (variant, w))
|
||||
|
||||
|
||||
def add_pool_word(conn: sqlite3.Connection, word: str) -> dict:
|
||||
"""Add a word to the answer pool. Enforces the invariant (alpha, 5/6 letters,
|
||||
present in the guess dictionary) so the daily answer is always guessable."""
|
||||
w = (word or "").strip().lower()
|
||||
variant = str(len(w))
|
||||
if not w.isalpha() or variant not in WORD_VARIANTS:
|
||||
return {"error": "must be a 5- or 6-letter word"}
|
||||
if w not in _DICT[variant]:
|
||||
return {"error": "not in the guess dictionary — it wouldn't be guessable"}
|
||||
if w in set(_POOL.get(variant, [])) or w in set(_db_pool(conn, variant)):
|
||||
variant, err = _validate_word(conn, w)
|
||||
if err:
|
||||
return {"error": err}
|
||||
live = (set(_POOL.get(variant, [])) | set(_db_pool(conn, variant))) - set(_db_removed(conn, variant))
|
||||
if w in live:
|
||||
return {"error": "already in the pool"}
|
||||
conn.execute("INSERT OR IGNORE INTO word_pool (word, variant) VALUES (?, ?)", (w, variant))
|
||||
_put_word(conn, w, variant)
|
||||
conn.commit()
|
||||
return {"word": w, "variant": variant, "added": True}
|
||||
|
||||
|
||||
def remove_pool_word(conn: sqlite3.Connection, word: str) -> dict:
|
||||
"""Remove an admin-added word. Curated static words can't be removed here."""
|
||||
"""Remove ANY pool word — curated or admin-added — by laying down a tombstone.
|
||||
Reversible via restore_pool_word. The daily picker subtracts tombstones."""
|
||||
w = (word or "").strip().lower()
|
||||
cur = conn.execute("DELETE FROM word_pool WHERE variant=? AND word=?", (str(len(w)), w))
|
||||
variant = str(len(w))
|
||||
if variant not in WORD_VARIANTS:
|
||||
return {"word": w, "removed": False}
|
||||
conn.execute("INSERT OR IGNORE INTO word_pool_removed (word, variant) VALUES (?, ?)", (w, variant))
|
||||
conn.commit()
|
||||
return {"word": w, "removed": cur.rowcount > 0}
|
||||
return {"word": w, "variant": variant, "removed": True}
|
||||
|
||||
|
||||
def restore_pool_word(conn: sqlite3.Connection, word: str) -> dict:
|
||||
"""Undo a removal: lift the tombstone so the word rejoins the pool."""
|
||||
w = (word or "").strip().lower()
|
||||
variant = str(len(w))
|
||||
cur = conn.execute("DELETE FROM word_pool_removed WHERE variant=? AND word=?", (variant, w))
|
||||
conn.commit()
|
||||
return {"word": w, "variant": variant, "restored": cur.rowcount > 0}
|
||||
|
||||
|
||||
def import_pool_words(conn: sqlite3.Connection, words: list[str]) -> dict:
|
||||
"""Bulk-add a vetted list. Validates each word (alpha · 5–6 · in dictionary),
|
||||
ignores duplicates (already-live words), and reports rejects with reasons.
|
||||
A word currently tombstoned is treated as importable (re-adding lifts it)."""
|
||||
static = {v: set(_POOL.get(v, [])) for v in WORD_VARIANTS}
|
||||
added = {v: set(_db_pool(conn, v)) for v in WORD_VARIANTS}
|
||||
removed = {v: set(_db_removed(conn, v)) for v in WORD_VARIANTS}
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
rejected: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for raw in words:
|
||||
w = (raw or "").strip().lower()
|
||||
if not w or w in seen:
|
||||
continue
|
||||
seen.add(w)
|
||||
variant, err = _validate_word(conn, w)
|
||||
if err:
|
||||
rejected.append({"word": w, "reason": err})
|
||||
continue
|
||||
live = (static[variant] | added[variant]) - removed[variant]
|
||||
if w in live:
|
||||
duplicates.append(w)
|
||||
continue
|
||||
_put_word(conn, w, variant)
|
||||
added[variant].add(w)
|
||||
removed[variant].discard(w)
|
||||
accepted.append(w)
|
||||
conn.commit()
|
||||
return {
|
||||
"added": accepted,
|
||||
"duplicates": duplicates,
|
||||
"rejected": rejected,
|
||||
"counts": {"added": len(accepted), "duplicates": len(duplicates), "rejected": len(rejected)},
|
||||
}
|
||||
|
||||
|
||||
def pool_summary(conn: sqlite3.Connection) -> dict:
|
||||
"""Pool counts + the removable (admin-added) words, per variant."""
|
||||
"""Pool counts + the admin-added words and the removed (tombstoned) words, per variant."""
|
||||
out = {}
|
||||
for v in WORD_VARIANTS:
|
||||
added = _db_pool(conn, v)
|
||||
out[v] = {"curated": len(_POOL.get(v, [])), "added": added,
|
||||
"total": len(set(_POOL.get(v, [])) | set(added))}
|
||||
static = set(_POOL.get(v, []))
|
||||
removed = set(_db_removed(conn, v))
|
||||
added = [w for w in _db_pool(conn, v) if w not in removed]
|
||||
total = len((static | set(added)) - removed)
|
||||
out[v] = {
|
||||
"curated": len(static),
|
||||
"added": sorted(added),
|
||||
"removed": sorted(removed),
|
||||
"total": total,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
|
||||
+18
-1
@@ -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):
|
||||
|
||||
@@ -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