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.`;
|
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.'; }
|
} 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) {
|
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 ---
|
// --- Games: Word Search themes ---
|
||||||
@@ -783,7 +827,11 @@
|
|||||||
{#if !wpResult.variant}
|
{#if !wpResult.variant}
|
||||||
<span class="wp-tag bad">Must be 5 or 6 letters</span>
|
<span class="wp-tag bad">Must be 5 or 6 letters</span>
|
||||||
{:else if wpResult.in_pool}
|
{: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}
|
{:else if wpResult.in_dictionary}
|
||||||
<span class="wp-tag ok">Valid {wpResult.variant}-letter word</span>
|
<span class="wp-tag ok">Valid {wpResult.variant}-letter word</span>
|
||||||
<button class="wp-add" onclick={addWord}>Add to pool</button>
|
<button class="wp-add" onclick={addWord}>Add to pool</button>
|
||||||
@@ -807,11 +855,52 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
|
{: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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
<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
|
<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
|
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;
|
.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%; }
|
line-height: 1; padding: 0 5px; border-radius: 50%; }
|
||||||
.wp-added .x:hover { color: #9a3b3b; }
|
.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 */
|
/* Word Search theme authoring */
|
||||||
.ws-form { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 14px 0 6px; }
|
.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
|
word: str
|
||||||
|
|
||||||
|
|
||||||
|
class WordPoolImportBody(BaseModel):
|
||||||
|
text: str = ""
|
||||||
|
words: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class ClientErrorBody(BaseModel):
|
class ClientErrorBody(BaseModel):
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
path: str = ""
|
path: str = ""
|
||||||
@@ -1499,6 +1504,7 @@ def create_app() -> FastAPI:
|
|||||||
_require_admin(conn, request)
|
_require_admin(conn, request)
|
||||||
res = games.lookup_word(word)
|
res = games.lookup_word(word)
|
||||||
res["in_pool"] = bool(res["variant"]) and res["word"] in games.answer_pool(conn, res["variant"])
|
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
|
return res
|
||||||
|
|
||||||
@app.get("/api/admin/word/pool")
|
@app.get("/api/admin/word/pool")
|
||||||
@@ -1523,6 +1529,23 @@ def create_app() -> FastAPI:
|
|||||||
games.remove_pool_word(conn, word)
|
games.remove_pool_word(conn, word)
|
||||||
return games.pool_summary(conn)
|
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 ---
|
# --- Admin: Word Search theme authoring ---
|
||||||
@app.get("/api/admin/wordsearch/themes")
|
@app.get("/api/admin/wordsearch/themes")
|
||||||
def admin_ws_themes(request: Request) -> list[dict]:
|
def admin_ws_themes(request: Request) -> list[dict]:
|
||||||
|
|||||||
@@ -283,6 +283,13 @@ CREATE TABLE IF NOT EXISTS word_pool (
|
|||||||
PRIMARY KEY (variant, word)
|
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 (
|
CREATE TABLE IF NOT EXISTS daily_puzzles (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
puzzle_date TEXT NOT NULL,
|
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]
|
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]:
|
def answer_pool(conn: sqlite3.Connection, variant: str) -> list[str]:
|
||||||
"""The day's answer pool = curated static list ∪ admin-added (DB) words."""
|
"""The day's answer pool = (curated static ∪ admin-added) − admin-removed.
|
||||||
return sorted(set(_POOL.get(variant, [])) | set(_db_pool(conn, variant)))
|
|
||||||
|
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 -------------------------------------------
|
# --- 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:
|
def add_pool_word(conn: sqlite3.Connection, word: str) -> dict:
|
||||||
"""Add a word to the answer pool. Enforces the invariant (alpha, 5/6 letters,
|
"""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."""
|
present in the guess dictionary) so the daily answer is always guessable."""
|
||||||
w = (word or "").strip().lower()
|
w = (word or "").strip().lower()
|
||||||
variant = str(len(w))
|
variant, err = _validate_word(conn, w)
|
||||||
if not w.isalpha() or variant not in WORD_VARIANTS:
|
if err:
|
||||||
return {"error": "must be a 5- or 6-letter word"}
|
return {"error": err}
|
||||||
if w not in _DICT[variant]:
|
live = (set(_POOL.get(variant, [])) | set(_db_pool(conn, variant))) - set(_db_removed(conn, variant))
|
||||||
return {"error": "not in the guess dictionary — it wouldn't be guessable"}
|
if w in live:
|
||||||
if w in set(_POOL.get(variant, [])) or w in set(_db_pool(conn, variant)):
|
|
||||||
return {"error": "already in the pool"}
|
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()
|
conn.commit()
|
||||||
return {"word": w, "variant": variant, "added": True}
|
return {"word": w, "variant": variant, "added": True}
|
||||||
|
|
||||||
|
|
||||||
def remove_pool_word(conn: sqlite3.Connection, word: str) -> dict:
|
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()
|
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()
|
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:
|
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 = {}
|
out = {}
|
||||||
for v in WORD_VARIANTS:
|
for v in WORD_VARIANTS:
|
||||||
added = _db_pool(conn, v)
|
static = set(_POOL.get(v, []))
|
||||||
out[v] = {"curated": len(_POOL.get(v, [])), "added": added,
|
removed = set(_db_removed(conn, v))
|
||||||
"total": len(set(_POOL.get(v, [])) | set(added))}
|
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
|
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):
|
def test_word_pool_admin(tmp_path, monkeypatch):
|
||||||
|
from goodnews import games
|
||||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||||
assert TestClient(app).get("/api/admin/word/pool").status_code == 401 # gated
|
assert TestClient(app).get("/api/admin/word/pool").status_code == 401 # gated
|
||||||
tc = _signin(app, api, "boss@x.com")
|
tc = _signin(app, api, "boss@x.com")
|
||||||
# lookup: valid dict word vs non-word vs wrong length
|
# lookup: valid dict word vs non-word vs wrong length
|
||||||
assert tc.get("/api/admin/word/lookup?word=thrive").json() == {
|
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=zzzzz").json()["in_dictionary"] is False
|
||||||
assert tc.get("/api/admin/word/lookup?word=cat").json()["variant"] is None
|
assert tc.get("/api/admin/word/lookup?word=cat").json()["variant"] is None
|
||||||
# add a valid word, then it shows in the pool + lookup
|
# 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
|
# remove the admin-added word
|
||||||
tc.delete("/api/admin/word/pool/plumb")
|
tc.delete("/api/admin/word/pool/plumb")
|
||||||
assert "plumb" not in tc.get("/api/admin/word/pool").json()["5"]["added"]
|
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):
|
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