+ {#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