From ddcfab3a1195983fe0457066863bffa63127ff91 Mon Sep 17 00:00:00 2001 From: jay Date: Fri, 12 Jun 2026 21:37:51 -0400 Subject: [PATCH] Admin: source Articles inspector (verify metrics against real evidence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New per-row "Articles" button on the Sources table expands a read-only inline panel of the source's ACTUAL ingested articles — so the automated metrics (paywall/image/acceptance/duplicate) can be verified against evidence instead of trusted blind. Distinct from "Check" (which re-samples the LIVE feed for would-pass quality); this shows what's already in the DB, which is what the table metrics are computed from. - Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin, read-only). queries.source_articles + source_articles_summary — per article: title, url, date, accepted, reason (the "why"), topic/flavor, paywalled (domain rule), has_image, duplicate. Summary = counts + source-level paywall rule. - Frontend: expandable panel with a summary header ("27 ingested · 18 accepted · … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No image/Duplicates), compact rows with title→link + badges + reason, Load more. So "100% paywall" or "0% images" becomes clickable evidence: open two articles to tell a real paywall from a mis-flagged domain, or a true image gap from an enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/components/WordGame.svelte | 45 +++++-- .../src/lib/components/WordSearchGame.svelte | 53 ++++++-- frontend/src/routes/admin/+page.svelte | 87 ++++++++++++++ goodnews/api.py | 18 +++ goodnews/games.py | 113 ++++++++++++------ goodnews/queries.py | 69 +++++++++++ tests/test_admin.py | 15 +++ tests/test_game_sync.py | 28 +++-- tests/test_wordsearch_grid.py | 78 ++++++++++++ 9 files changed, 445 insertions(+), 61 deletions(-) create mode 100644 tests/test_wordsearch_grid.py diff --git a/frontend/src/lib/components/WordGame.svelte b/frontend/src/lib/components/WordGame.svelte index 47c9780..cad0dce 100644 --- a/frontend/src/lib/components/WordGame.svelte +++ b/frontend/src/lib/components/WordGame.svelte @@ -193,7 +193,7 @@ {#each Array(length) as _, c (c)} {@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
{(ch || '').toUpperCase()}
+ style={cs ? `--d:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()} {/each} {/each} @@ -253,22 +253,53 @@ aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border: 2px solid var(--line); border-radius: 8px; font-family: var(--label); font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase; - background: var(--surface); + background: var(--surface); position: relative; overflow: hidden; } .tile.filled { border-color: #b7c0cb; } - .tile.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; } - .tile.present { background: #d8b24a; border-color: #d8b24a; color: #fff; } - .tile.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; } + /* Judged tiles set like glazed enamel: a soft top-light gradient over the + colour, an inner bevel, and a little lift off the board. Pending tiles stay + flat on purpose — depth marks the moment a letter is settled. */ + .tile.correct, .tile.present, .tile.absent { color: #fff; text-shadow: 0 1px 2px rgba(20, 30, 25, 0.22); } + .tile.correct { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%), + linear-gradient(165deg, #56ac7c, #4a9d6e 55%, #3e8a5e); + border-color: #3e8a5e; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32), inset 0 -3px 5px rgba(22, 64, 42, 0.2), + 0 2px 5px rgba(58, 125, 86, 0.28); + } + .tile.present { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%), + linear-gradient(165deg, #e2c163, #d8b24a 55%, #c29c38); + border-color: #c29c38; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), inset 0 -3px 5px rgba(122, 92, 22, 0.2), + 0 2px 5px rgba(184, 148, 58, 0.28); + } + .tile.absent { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0) 45%), + linear-gradient(165deg, #a7b2bd, #9aa6b2 55%, #87939f); + border-color: #87939f; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.26), inset 0 -3px 5px rgba(50, 60, 70, 0.16), + 0 2px 4px rgba(110, 122, 134, 0.24); + } + /* One quiet glint sweeps each tile just after its flip lands — once, not a loop. */ + .tile.correct::after, .tile.present::after, .tile.absent::after { + content: ''; position: absolute; inset: 0; pointer-events: none; + background: linear-gradient(115deg, transparent 38%, rgba(255, 255, 255, 0.38) 50%, transparent 62%); + transform: translateX(-130%); + animation: sheen 0.65s ease-out both; + animation-delay: calc(var(--d, 0s) + 0.32s); + } /* Juice: a tile pops as you type; the row reveals with a staggered bounce when you submit; the row shakes on an invalid word. */ .tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; } - .tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; } + .tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; animation-delay: var(--d, 0s); } .row.shake { animation: shake 0.4s ease; } @keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } } @keyframes reveal { 0% { transform: scale(0.5); opacity: 0.3; } 55% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } } + @keyframes sheen { to { transform: translateX(130%); } } @keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } } - @media (prefers-reduced-motion: reduce) { .tile, .row { animation: none !important; } } + @media (prefers-reduced-motion: reduce) { .tile, .tile::after, .row { animation: none !important; } } .flash { text-align: center; background: var(--ink); color: #fff; border-radius: 8px; padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem; diff --git a/frontend/src/lib/components/WordSearchGame.svelte b/frontend/src/lib/components/WordSearchGame.svelte index c00120f..42942fc 100644 --- a/frontend/src/lib/components/WordSearchGame.svelte +++ b/frontend/src/lib/components/WordSearchGame.svelte @@ -16,7 +16,8 @@ let foundWords = $state([]); // {word, cells:[[r,c]], ci} let sel = $state([]); // current selection cells let selecting = false; - let startTime = 0; + let playedMs = 0; // accumulated ACTIVE play time (closed segments) + let segStart = 0; // wall-clock start of the open segment (0 = paused) let resultMs = $state(0); let best = $state(0); let loading = $state(true); @@ -63,16 +64,44 @@ return m; }); + // --- the clock counts ACTIVE play only ----------------------------------- + // Wall-clock timing made "finish in one sitting" feel mandatory — the + // opposite of calm. The clock runs only while the puzzle is on screen + // (tab visible, window focused, game unfinished); stepping away pauses it, + // coming back resumes it, and several sittings simply add up. + function playedNow() { return playedMs + (segStart ? Date.now() - segStart : 0); } + function pauseClock(save = true) { + if (!segStart) return; + playedMs += Date.now() - segStart; segStart = 0; + if (save) persist(); // don't lose the segment if the tab dies + } + function resumeClock() { + if (!segStart && !loading && status === 'playing' && !document.hidden) segStart = Date.now(); + } + $effect(() => { + const onVis = () => (document.hidden ? pauseClock() : resumeClock()); + const onAway = () => pauseClock(); + document.addEventListener('visibilitychange', onVis); + window.addEventListener('pagehide', onAway); + window.addEventListener('blur', onAway); + window.addEventListener('focus', onVis); + return () => { + document.removeEventListener('visibilitychange', onVis); + window.removeEventListener('pagehide', onAway); + window.removeEventListener('blur', onAway); + window.removeEventListener('focus', onVis); + }; + }); + async function load() { const seq = ++loadSeq; // stale-load guard for rapid size switches loading = true; ready = false; - foundWords = []; sel = []; resultMs = 0; startTime = 0; + foundWords = []; sel = []; resultMs = 0; playedMs = 0; segStart = 0; try { const p = await getJSON('/api/puzzle/wordsearch?variant=' + size); if (seq !== loadSeq) return; // a newer size was selected — abandon theme = p.theme; words = p.words; grid = p.grid; date = p.date; restore(); - if (!startTime) startTime = Date.now(); try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; } } catch { if (seq !== loadSeq) return; @@ -80,6 +109,7 @@ } if (seq !== loadSeq) return; loading = false; + resumeClock(); requestAnimationFrame(() => (ready = true)); // Reconcile with the server in the background (signed-in only): pull any // progress from another device, and pull the cross-device best time. @@ -103,14 +133,14 @@ const s = JSON.parse(localStorage.getItem(stateKey) || 'null'); if (s && Array.isArray(s.foundWords)) { foundWords = validFinds(s.foundWords); - startTime = s.startTime || 0; + playedMs = s.played || 0; // pre-"active clock" saves restart at 0:00 — the kind direction resultMs = foundWords.length === words.length ? (s.ms || 0) : 0; } } catch { /* ignore */ } onstatus?.(summary()); } function persist() { - try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); } + try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, played: playedNow(), ms: resultMs, status })); } catch { /* ignore */ } onstatus?.(summary()); } @@ -125,13 +155,17 @@ } // renumber colours by find order so overlap blends stay consistent foundWords = foundWords.map((fw, i) => ({ ...fw, ci: i % PALETTE.length })); - if (merged.startTime && (!startTime || merged.startTime < startTime)) startTime = merged.startTime; + // another device may have accumulated more active time — credit the larger + if ((merged.played || 0) > playedNow()) { + playedMs = merged.played; + if (segStart) segStart = Date.now(); + } if (foundWords.length === words.length && merged.ms) resultMs = Math.min(resultMs || merged.ms, merged.ms); persist(); } async function syncNow() { const d = date, sz = size; // pin against a size switch mid-flight - const merged = await pushGameState('wordsearch', sz, d, { foundWords, startTime, ms: resultMs }); + const merged = await pushGameState('wordsearch', sz, d, { foundWords, played: playedNow(), ms: resultMs }); if (d === date && sz === size) adopt(merged); // ignore if the user switched away } function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1200); } @@ -144,7 +178,7 @@ function down(e) { if (status === 'done') return; selecting = true; - if (!startTime) startTime = Date.now(); + resumeClock(); // safety net if a focus event was missed sel = [cellAt(e)]; gridEl.setPointerCapture?.(e.pointerId); e.preventDefault(); @@ -171,7 +205,8 @@ } function finish() { - resultMs = startTime ? Date.now() - startTime : 0; + pauseClock(false); // close the open segment; persist follows in evaluate() + resultMs = playedMs; if (resultMs && (!best || resultMs < best)) { best = resultMs; try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ } diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 61f45c5..2429d8a 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -263,6 +263,27 @@ } function dismissCheck(s) { s._check = null; s._checkErr = ''; } + // --- Source article inspector: the real articles behind the metrics --- + async function toggleArticles(s) { + if (s._showArts) { s._showArts = false; return; } + s._showArts = true; + if (!s._arts) await loadArticles(s, 'all', true); + } + async function loadArticles(s, filter, reset) { + s._artBusy = true; s._artErr = ''; + if (reset) { s._artFilter = filter; s._artOffset = 0; s._arts = []; } + try { + const q = `filter=${s._artFilter}&limit=25&offset=${s._artOffset}`; + const r = await getJSON(`/api/admin/sources/${s.id}/articles?${q}`); + s._arts = reset ? r.articles : [...(s._arts || []), ...r.articles]; + if (r.summary) s._artSummary = r.summary; + s._artMore = r.has_more; + s._artOffset += r.articles.length; + } catch (e) { s._artErr = e?.message || 'Could not load articles.'; } + finally { s._artBusy = false; } + } + const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['no_image', 'No image'], ['duplicates', 'Duplicates']]; + // --- Source candidates: supervised "add a source" pipeline --- let candidates = $state([]); let newFeedUrl = $state(''); @@ -677,6 +698,7 @@ + {#if s._checking || s._check || s._checkErr} @@ -698,6 +720,49 @@ {/if} + {#if s._showArts} + + + {#if s._artSummary} +
+ {s._artSummary.total} ingested · {s._artSummary.accepted} accepted · + {s._artSummary.rejected} rejected · {s._artSummary.no_image} no image · + {s._artSummary.duplicates} dup · + paywall rule: {s._artSummary.paywalled ? 'ON (domain)' : 'off'} +
+ {/if} +
+ {#each ART_FILTERS as [key, label] (key)} + + {/each} + +
+ {#if s._artErr}

{s._artErr}

{/if} + {#if s._arts?.length} + + {#if s._artMore}{/if} + {:else if !s._artBusy} +

No articles{s._artFilter && s._artFilter !== 'all' ? ' match this filter' : ' yet'}.

+ {/if} + {#if s._artBusy && !s._arts?.length}

Loading articles…

{/if} + + + {/if} {:else} {srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'} {/each} @@ -1188,6 +1253,28 @@ .chkex { margin-top: 5px; color: var(--ink); } .chkex .chklbl { color: var(--muted); } .chkex.chkrej { color: var(--muted); } + + /* Source article inspector */ + .srctable tr.artrow td { background: var(--bg); font-size: 0.84rem; padding: 10px 12px; } + .artsum { color: var(--ink); margin-bottom: 8px; } + .artsum .pwrule { color: var(--muted); font-weight: 600; } + .artsum .pwrule.on { color: #9a3b3b; } + .artfilters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; } + .chip.sm { font-size: 0.74rem; padding: 3px 10px; } + .artlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; max-height: 360px; overflow-y: auto; } + .artlist li { border-bottom: 1px solid var(--line); padding-bottom: 6px; } + .art-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .art-title { color: var(--accent-deep); font-weight: 600; text-decoration: none; } + .art-title:hover { text-decoration: underline; } + .art-row .badge { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 7px; border-radius: 999px; } + .badge.ok { background: #e3efe4; color: #3f7048; } + .badge.no { background: #f3e0e0; color: #9a3b3b; } + .art-row .pw { font-size: 0.78rem; } + .art-flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; } + .art-cat { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; } + .art-when { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; } + .art-reason { font-size: 0.76rem; color: var(--muted); font-style: italic; margin-top: 2px; } + .act.more { margin-top: 8px; } .srctable .rowactions { white-space: nowrap; } .srctable .rowactions .act { background: none; border: 1px solid var(--line); color: var(--accent-deep); diff --git a/goodnews/api.py b/goodnews/api.py index fe6a900..dcc459b 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -1146,6 +1146,24 @@ def create_app() -> FastAPI: url = src["feed_url"] return _preview_or_502(url) # safe fetch, no DB connection held + @app.get("/api/admin/sources/{sid}/articles") + def admin_source_articles(sid: int, request: Request, filter: str = "all", + limit: int = 25, offset: int = 0) -> dict: + # Read-only inspector: the REAL ingested articles behind a source's metrics, + # so paywall/image/acceptance/duplicate signals can be verified against evidence. + limit = max(1, min(int(limit), 100)) + offset = max(0, int(offset)) + with get_conn() as conn: + _require_admin(conn, request) + if not conn.execute("SELECT 1 FROM sources WHERE id = ?", (sid,)).fetchone(): + raise HTTPException(status_code=404, detail="source not found") + arts = queries.source_articles(conn, sid, filter, limit, offset) + return { + "articles": arts, + "summary": queries.source_articles_summary(conn, sid) if offset == 0 else None, + "has_more": len(arts) == limit, + } + # --- Source candidates (supervised add-a-source pipeline) ---------------- def _candidate_dict(row) -> dict: diff --git a/goodnews/games.py b/goodnews/games.py index bd99cde..3bc7683 100644 --- a/goodnews/games.py +++ b/goodnews/games.py @@ -518,42 +518,79 @@ def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None) return json.loads(row["payload_json"]) +_WS_CROSS_TARGET = 0.5 # aim: about half the placements cross an existing word + + +def _zone(r: int, c: int, size: int) -> tuple[int, int]: + """Which quadrant a cell falls in — coarse occupancy used to spread words.""" + return (r * 2 // size, c * 2 // size) + + +def _place_words(words: list[str], size: int, seed: int) -> tuple[list[list[str | None]], list[tuple[str, list[tuple[int, int]]]]]: + """Core placement (date-seeded, deterministic). Returns the letter grid (None + where unfilled) and [(word, cells)] for every word genuinely placed. + + Interlock is a TARGET, not a side effect: each word either (a) must cross an + already-placed word — when crossings are running below ~half of placements — + or (b) anchors in open ground. Both modes steer toward the least crowded / + least developed quadrant, so crossings attach to lonely words at the edges of + structure rather than thickening one knot, and anchors spread across the + board. All valid spots are enumerated (the grid is tiny) — earlier random + sampling kept missing the rare crossing spots, which is why grids came out + as disconnected "clean" words.""" + rng = random.Random(seed) + grid: list[list[str | None]] = [[None] * size for _ in range(size)] + zone_fill = {(zr, zc): 0 for zr in (0, 1) for zc in (0, 1)} + placements: list[tuple[str, list[tuple[int, int]]]] = [] + crossed = 0 + for word in sorted(words, key=len, reverse=True): + n = len(word) + if n > size: + continue + cands = [] # (overlap, cells) over every legal placement + for dr, dc in _DIRS: + for r0 in range(size): + for c0 in range(size): + if not (0 <= r0 + dr * (n - 1) < size and 0 <= c0 + dc * (n - 1) < size): + continue + cells = [(r0 + dr * i, c0 + dc * i) for i in range(n)] + if not all(grid[r][c] in (None, word[i]) for i, (r, c) in enumerate(cells)): + continue + cands.append((sum(1 for i, (r, c) in enumerate(cells) if grid[r][c] == word[i]), cells)) + if not cands: + continue + crossing = [t for t in cands if t[0] > 0] + want_cross = bool(crossing) and crossed < _WS_CROSS_TARGET * len(placements) + scored = [] # (score, overlap, cells) + for overlap, cells in crossing if want_cross else cands: + crowd = _neighbour_fill(grid, cells, size) + zload = sum(zone_fill[_zone(r, c, size)] for r, c in cells) // n + # Crossing mode rewards extra overlaps; anchor mode is overlap-neutral + # (crowding already steers it to open ground). + scored.append(((overlap * 4 if want_cross else 0) - 2 * crowd - zload, overlap, cells)) + scored.sort(key=lambda t: t[0], reverse=True) + top = [t for t in scored if t[0] >= scored[0][0] - 1] # near-best: variety without losing intent + _, overlap, cells = rng.choice(top) + for i, (r, c) in enumerate(cells): + if grid[r][c] is None: + grid[r][c] = word[i] + zone_fill[_zone(r, c, size)] += 1 + placements.append((word, cells)) + if overlap: + crossed += 1 + return grid, placements + + def _build_grid(words: list[str], size: int, seed: int) -> tuple[list[str], list[str]]: """Place words in a size×size grid (date-seeded, deterministic) and fill the rest. Returns (rows, placed_words). Every returned word is genuinely placed.""" - rng = random.Random(seed) - grid: list[list[str | None]] = [[None] * size for _ in range(size)] - placed = [] - for word in sorted(words, key=len, reverse=True): - if len(word) > size: - continue - # Gather valid placements and SCORE them: reward crossing an existing word - # (so the grid interlocks like a real puzzle) but penalise crowding, so - # words spread across the board instead of all clustering around the ones - # placed first. Pick at random among the best ~20% to keep organic variety. - scored = [] # (score, cells) - for _ in range(400): - dr, dc = rng.choice(_DIRS) - r0, c0 = rng.randrange(size), rng.randrange(size) - cells = [(r0 + dr * i, c0 + dc * i) for i in range(len(word))] - if any(not (0 <= r < size and 0 <= c < size) for r, c in cells): - continue - if not all(grid[r][c] in (None, word[i]) for i, (r, c) in enumerate(cells)): - continue - overlap = sum(1 for i, (r, c) in enumerate(cells) if grid[r][c] == word[i]) - scored.append((overlap * 4 - _neighbour_fill(grid, cells, size), cells)) - if not scored: - continue - scored.sort(key=lambda t: t[0], reverse=True) - _, cells = rng.choice(scored[: max(1, len(scored) // 5)]) - for i, (r, c) in enumerate(cells): - grid[r][c] = word[i] - placed.append(word) + grid, placements = _place_words(words, size, seed) + rng = random.Random(_seed(str(seed), "fill")) for r in range(size): for c in range(size): if grid[r][c] is None: grid[r][c] = chr(65 + rng.randrange(26)) - return ["".join(row) for row in grid], placed + return ["".join(row) for row in grid], [w for w, _ in placements] # --- Cross-device game state sync ------------------------------------------- @@ -562,17 +599,18 @@ def _build_grid(words: list[str], size: int, seed: int) -> tuple[list[str], list def _merge_wordsearch(a: dict, b: dict) -> dict: """Union the found words (a find is monotonic — you can't un-find one, so the - union is always correct), keep the earliest start and the best (min) time.""" + union is always correct), credit the most ACTIVE play time either device has + banked (max — the clock only runs while the puzzle is on screen, so wall-clock + gaps between sittings never count), and keep the best (min) finish time.""" by_word = {} for fw in list(a.get("foundWords") or []) + list(b.get("foundWords") or []): w = fw.get("word") if isinstance(fw, dict) else None if w and w not in by_word: by_word[w] = fw - starts = [s for s in (a.get("startTime"), b.get("startTime")) if s] times = [m for m in (a.get("ms"), b.get("ms")) if m] return { "foundWords": list(by_word.values()), - "startTime": min(starts) if starts else 0, + "played": max(_int(a.get("played")), _int(b.get("played"))), "ms": min(times) if times else 0, } @@ -615,6 +653,13 @@ def _int(x) -> int: return 0 +_WS_MS_CAP = 86_400_000 # clamp client-sent timings to one day — beyond that is junk + + +def _ms(x) -> int: + return max(0, min(_int(x), _WS_MS_CAP)) + + def _sanitize_wordsearch(conn: sqlite3.Connection, variant: str, date: str, state: dict) -> dict: """Trust only finds that are real for THIS puzzle: word in the day's list and cells that actually spell it in the grid (validated when the puzzle exists, @@ -656,8 +701,8 @@ def _sanitize_wordsearch(conn: sqlite3.Connection, variant: str, date: str, stat seen.add(w) clean.append({"word": w, "cells": cells, "ci": len(clean) % 10}) done = bool(words) and len(clean) == len(words) - return {"foundWords": clean, "startTime": _int(state.get("startTime")), - "ms": _int(state.get("ms")) if done else 0} + return {"foundWords": clean, "played": _ms(state.get("played")), + "ms": _ms(state.get("ms")) if done else 0} _WORD_COLOURS = {"absent", "present", "correct"} diff --git a/goodnews/queries.py b/goodnews/queries.py index b38e46c..2ba739d 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -454,6 +454,75 @@ def _attention(content: dict, sources: list[dict], feedback_unread: int, now: da return items +# --- Source article inspector: the real articles behind the source metrics ----- + +_SRC_ART_FILTERS = { + "accepted": "AND s.accepted = 1", + "rejected": "AND s.accepted = 0", + "no_image": "AND (a.image_url IS NULL OR a.image_url = '')", + "duplicates": "AND a.duplicate_of IS NOT NULL", +} + + +def source_articles(conn: sqlite3.Connection, source_id: int, filter: str = "all", + limit: int = 25, offset: int = 0) -> list[dict]: + """The actual ingested articles for a source, newest first — so admins can + verify the metric (paywall/image/acceptance) against real evidence.""" + where = _SRC_ART_FILTERS.get(filter, "") + rows = conn.execute( + f""" + SELECT a.id, a.title, a.canonical_url, a.published_at, a.discovered_at, + a.image_url, a.duplicate_of, + s.accepted, s.reason_code, s.reason_text, s.topic, s.flavor + FROM articles a + LEFT JOIN article_scores s ON s.article_id = a.id + WHERE a.source_id = ? {where} + ORDER BY COALESCE(a.published_at, a.discovered_at) DESC + LIMIT ? OFFSET ? + """, + (source_id, limit, offset), + ).fetchall() + return [ + { + "id": r["id"], + "title": r["title"], + "url": r["canonical_url"], + "published_at": r["published_at"] or r["discovered_at"], + "accepted": r["accepted"], + "reason": r["reason_text"] or r["reason_code"], # the "why" behind accept/reject + "topic": r["topic"], + "flavor": r["flavor"], + "paywalled": is_paywalled(r["canonical_url"]), # domain rule — same for the source + "has_image": bool(r["image_url"]), + "duplicate": r["duplicate_of"] is not None, + } + for r in rows + ] + + +def source_articles_summary(conn: sqlite3.Connection, source_id: int) -> dict: + """Counts behind the table metrics + the source-level paywall rule, so the + panel header reads e.g. '120 · 96 accepted · 24 rejected · 3 no image · paywall: ON'.""" + agg = conn.execute( + """ + SELECT COUNT(*) total, + COALESCE(SUM(s.accepted = 1), 0) accepted, + COALESCE(SUM(s.accepted = 0), 0) rejected, + COALESCE(SUM(a.image_url IS NULL OR a.image_url = ''), 0) no_image, + COALESCE(SUM(a.duplicate_of IS NOT NULL), 0) duplicates + FROM articles a LEFT JOIN article_scores s ON s.article_id = a.id + WHERE a.source_id = ? + """, + (source_id,), + ).fetchone() + one = conn.execute("SELECT canonical_url FROM articles WHERE source_id = ? LIMIT 1", (source_id,)).fetchone() + return { + "total": agg["total"], "accepted": agg["accepted"], "rejected": agg["rejected"], + "no_image": agg["no_image"], "duplicates": agg["duplicates"], + "paywalled": is_paywalled(one["canonical_url"]) if one else False, + } + + def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict: """Aggregate, non-personal usage stats for the admin dashboard.""" since = f"-{days} days" diff --git a/tests/test_admin.py b/tests/test_admin.py index 66e3264..095c610 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -503,3 +503,18 @@ def test_wordsearch_theme_admin(tmp_path, monkeypatch): # remove left = tc.delete(f"/api/admin/wordsearch/themes/{tid}").json() assert not any(t["id"] == tid for t in left) + + +def test_source_articles_inspector(tmp_path, monkeypatch): + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + assert TestClient(app).get("/api/admin/sources/1/articles").status_code == 401 # gated + tc = _signin(app, api, "boss@x.com") + r = tc.get("/api/admin/sources/1/articles").json() + assert r["summary"]["total"] == 1 and r["summary"]["accepted"] == 1 and r["summary"]["no_image"] == 1 + assert len(r["articles"]) == 1 + a = r["articles"][0] + assert a["title"] == "t1" and a["accepted"] == 1 and a["has_image"] is False and a["paywalled"] is False + # filters resolve in SQL; rejected → none (the seeded article is accepted) + assert tc.get("/api/admin/sources/1/articles?filter=rejected").json()["articles"] == [] + assert len(tc.get("/api/admin/sources/1/articles?filter=no_image").json()["articles"]) == 1 + assert tc.get("/api/admin/sources/999/articles").status_code == 404 # unknown source diff --git a/tests/test_game_sync.py b/tests/test_game_sync.py index 8a674ec..c10f7f9 100644 --- a/tests/test_game_sync.py +++ b/tests/test_game_sync.py @@ -17,11 +17,13 @@ def conn(tmp_path): # --- merge logic (the audited core) --- def test_merge_wordsearch_unions_finds(): - a = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "startTime": 100, "ms": 0} - b = {"foundWords": [{"word": "DOG", "cells": [[1, 1]], "ci": 1}], "startTime": 50, "ms": 0} + a = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "played": 9000, "ms": 0} + b = {"foundWords": [{"word": "DOG", "cells": [[1, 1]], "ci": 1}], "played": 4000, "ms": 0} m = games.merge_game_state("wordsearch", a, b) assert {f["word"] for f in m["foundWords"]} == {"CAT", "DOG"} # union of finds - assert m["startTime"] == 50 # earliest start + # active-time clock: the device that banked the most play time is the truth — + # wall-clock gaps between sittings must never inflate the timer + assert m["played"] == 9000 def test_merge_wordsearch_dedupes_and_keeps_best_time(): @@ -55,12 +57,12 @@ def _find(word, row): # a shape-valid find: cells spelling the word along a row def test_save_converges_across_devices(conn): # No stored puzzle for this date → shape-only sanitize (words 4-12, cells match). games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12", - {"foundWords": [_find("BEACH", 0)], "startTime": 100}) + {"foundWords": [_find("BEACH", 0)], "played": 100}) merged = games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12", - {"foundWords": [_find("OCEAN", 1)], "startTime": 50}) + {"foundWords": [_find("OCEAN", 1)], "played": 50}) assert {f["word"] for f in merged["foundWords"]} == {"BEACH", "OCEAN"} - # stored state reflects the merge (order-independent) - assert games.load_game_state(conn, 1, "wordsearch", "small", "2026-06-12")["startTime"] == 50 + # stored state reflects the merge (order-independent): most banked time wins + assert games.load_game_state(conn, 1, "wordsearch", "small", "2026-06-12")["played"] == 100 # --- derived stats --- @@ -96,15 +98,15 @@ def test_game_state_api_roundtrip(tmp_path, monkeypatch): # signed out → no sync, echoes the posted state and GET sees nothing stored anon = TestClient(app) body = {"game": "wordsearch", "variant": "small", "date": "2026-06-12", - "state": {"foundWords": [_find("BEACH", 0)], "startTime": 9}} + "state": {"foundWords": [_find("BEACH", 0)], "played": 9}} assert anon.put("/api/games/state", json=body).json()["state"]["foundWords"][0]["word"] == "BEACH" assert anon.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"] is None # signed in: push from "device A", then "device B" → server returns the union tc = _signin(app, api, "p@x.com") tc.put("/api/games/state", json=body) - bodyB = {**body, "state": {"foundWords": [_find("OCEAN", 1)], "startTime": 4}} + bodyB = {**body, "state": {"foundWords": [_find("OCEAN", 1)], "played": 4}} merged = tc.put("/api/games/state", json=bodyB).json()["state"] - assert {f["word"] for f in merged["foundWords"]} == {"BEACH", "OCEAN"} and merged["startTime"] == 4 + assert {f["word"] for f in merged["foundWords"]} == {"BEACH", "OCEAN"} and merged["played"] == 9 # GET returns the stored merge; unknown game → 404; bad date → 400 got = tc.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"] assert {f["word"] for f in got["foundWords"]} == {"BEACH", "OCEAN"} @@ -123,5 +125,9 @@ def test_sanitizers_reject_junk(conn): "foundWords": [_find("BEACH", 0), # ok {"word": "CAT", "cells": [[0, 0], [0, 1], [0, 2]]}, # too short (<4) {"word": "OCEAN", "cells": [[1, 0], [1, 1]]}], # cells != len - "ms": 12345}) + "ms": 12345, "played": -5}) assert [f["word"] for f in ws["foundWords"]] == ["BEACH"] and ws["ms"] == 0 + assert ws["played"] == 0 # negative junk clamped + # absurd active-time claims are capped at a day + capped = games._sanitize_wordsearch(conn, "small", "2026-06-12", {"foundWords": [], "played": 10**12}) + assert capped["played"] == 86_400_000 diff --git a/tests/test_wordsearch_grid.py b/tests/test_wordsearch_grid.py new file mode 100644 index 0000000..d52175f --- /dev/null +++ b/tests/test_wordsearch_grid.py @@ -0,0 +1,78 @@ +"""Locks the word-search placement qualities players actually feel: + +1. Every word gets placed (exhaustive candidate search — nothing silently dropped). +2. Grids INTERLOCK like a real puzzle (the "clean isolated words" regression). +3. Words SPREAD across the board (the "all clumped in one corner" regression). +4. Same date/seed → same grid (cross-device players must see identical puzzles). + +Thresholds were calibrated against all curated themes × 12 seeds × 3 tiers +(288 grids/tier): crossing fraction averaged ~0.7 (old algorithm: ~0.3, with a +third of small grids having ZERO crossings), worst quadrant share 0.42, and all +four quadrants always held word cells. Deterministic, so no flake margin needed. +""" + +import random +import statistics + +from goodnews.games import _WS_FALLBACKS, WS_TIERS, _WS_ORDER, _build_grid, _place_words, _zone + + +def _tier_grids(tier): + """Yield (placements, size) for every curated theme × 12 seeds in a tier.""" + t = WS_TIERS[tier] + for _, words in _WS_FALLBACKS: + for seed in range(12): + rng = random.Random(seed * 1000 + 7) + ws = list(words) + rng.shuffle(ws) + _, placements = _place_words(ws[: t["count"]], t["grid"], seed) + yield placements, t["grid"] + + +def _cross_fraction(placements): + """Fraction of placed words sharing at least one cell with another word.""" + owners: dict[tuple[int, int], list[str]] = {} + for w, cells in placements: + for cell in cells: + owners.setdefault(cell, []).append(w) + crossing = set() + for ws in owners.values(): + if len(ws) > 1: + crossing.update(ws) + return len(crossing) / len(placements) + + +def test_all_words_placed(): + for tier in _WS_ORDER: + for placements, _ in _tier_grids(tier): + assert len(placements) == WS_TIERS[tier]["count"] + + +def test_grids_interlock_without_clumping(): + for tier in _WS_ORDER: + fracs = [] + for placements, size in _tier_grids(tier): + fracs.append(_cross_fraction(placements)) + # Spread: word cells must reach all four quadrants, and no quadrant + # may hoard more than half of them (perfectly even would be 0.25). + quad: dict[tuple[int, int], int] = {} + cells = {c for _, cs in placements for c in cs} + for r, c in cells: + quad[_zone(r, c, size)] = quad.get(_zone(r, c, size), 0) + 1 + assert len(quad) == 4, f"{tier}: words confined to {len(quad)} quadrant(s)" + assert max(quad.values()) / len(cells) <= 0.5, f"{tier}: clumped in one quadrant" + # Interlock: every grid has some crossings; on average most words connect. + assert min(fracs) >= 0.3, f"{tier}: a grid came out as disconnected clean words" + assert 0.55 <= statistics.mean(fracs) <= 0.9, f"{tier}: avg crossing {statistics.mean(fracs):.2f}" + + +def test_grid_deterministic_and_honest(): + """Same inputs → byte-identical grid, and every reported word is really in it + (forward or reversed along some line — spot-checked via placements).""" + words = _WS_FALLBACKS[0][1][:9] + rows1, placed1 = _build_grid(words, 11, 42) + rows2, placed2 = _build_grid(words, 11, 42) + assert rows1 == rows2 and placed1 == placed2 + _, placements = _place_words(words, 11, 42) + for word, cells in placements: + assert "".join(rows1[r][c] for r, c in cells) == word