Customizable nav lanes: pin moods / topics / discovery tags
Readers can now choose which quick-access lanes sit above the feed; "Today" stays pinned. The pool (goodnews/lanes.py, served at /api/lanes) is one source of truth over three lane kinds the feed already renders: moods, primary topics, and high-volume Discovery tags. Selection lives in the existing prefs blob (localStorage + /api/prefs sync); the filter parser ignores the new `lanes` field, so it rides along harmlessly. Default = today's moods, unchanged. Food/Space stay grouping tags rather than primary topics (per review): `space` already existed; added `food` to the Mind & Craft family so the classifier assigns it, and seeded the Food lane by re-tagging the two food sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
data/*.sqlite3
|
data/*.sqlite3
|
||||||
data/*.sqlite3-*
|
data/*.sqlite3-*
|
||||||
|
|
||||||
|
logs/
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script>
|
||||||
|
// pool: { pinned, default, groups:[{name, lanes:[{key,label,description,count?}]}] }
|
||||||
|
// selected: array of currently-pinned lane keys (excludes the always-on 'today')
|
||||||
|
let { pool, selected, onsave, onclose } = $props();
|
||||||
|
|
||||||
|
// Work on a local copy; only commit on Done.
|
||||||
|
let chosen = $state(new Set(selected ?? []));
|
||||||
|
|
||||||
|
function toggle(key) {
|
||||||
|
if (chosen.has(key)) chosen.delete(key);
|
||||||
|
else chosen.add(key);
|
||||||
|
chosen = new Set(chosen); // reassign so Svelte re-renders
|
||||||
|
}
|
||||||
|
function resetDefault() {
|
||||||
|
chosen = new Set(pool?.default ?? []);
|
||||||
|
}
|
||||||
|
function done() {
|
||||||
|
// Preserve each group's natural order so the rail reads predictably.
|
||||||
|
const order = [];
|
||||||
|
for (const g of pool?.groups ?? []) for (const l of g.lanes) order.push(l.key);
|
||||||
|
onsave?.(order.filter((k) => chosen.has(k)));
|
||||||
|
onclose?.();
|
||||||
|
}
|
||||||
|
function onkey(e) {
|
||||||
|
if (e.key === 'Escape') onclose?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onkey} />
|
||||||
|
|
||||||
|
<div class="overlay" onclick={onclose} role="presentation">
|
||||||
|
<div class="sheet rise" role="dialog" aria-modal="true" aria-label="Customize lanes" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<button class="x" onclick={onclose} aria-label="Close">×</button>
|
||||||
|
|
||||||
|
<h2>Your lanes</h2>
|
||||||
|
<p class="sub">Pick the quick-access lanes above the feed. <strong>Today</strong> always stays — choose the rest.</p>
|
||||||
|
|
||||||
|
<div class="pinned-row">
|
||||||
|
<span class="chip pinned" title="Always shown">Today <span class="lock">📌</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each pool?.groups ?? [] as g (g.name)}
|
||||||
|
{#if g.lanes.length}
|
||||||
|
<div class="group">
|
||||||
|
<span class="label">{g.name}</span>
|
||||||
|
<div class="chips">
|
||||||
|
{#each g.lanes as l (l.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
class:on={chosen.has(l.key)}
|
||||||
|
title={l.description ?? ''}
|
||||||
|
onclick={() => toggle(l.key)}
|
||||||
|
>
|
||||||
|
{l.label}{#if l.count > 0}<small>{l.count}</small>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="reset" onclick={resetDefault}>Reset to default</button>
|
||||||
|
<button class="primary" onclick={done}>Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(10, 22, 38, 0.32);
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 60;
|
||||||
|
}
|
||||||
|
.sheet {
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow); width: 100%; max-width: 480px; padding: 26px 24px 20px;
|
||||||
|
position: relative; max-height: 86vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.x { position: absolute; top: 12px; right: 14px; background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--muted); cursor: pointer; }
|
||||||
|
h2 { font-size: 1.4rem; margin: 0 0 6px; }
|
||||||
|
.sub { margin: 0 0 16px; color: var(--muted); font-size: 0.92rem; }
|
||||||
|
.pinned-row { margin-bottom: 14px; }
|
||||||
|
.group { margin-bottom: 16px; }
|
||||||
|
.label {
|
||||||
|
display: block; font-size: 0.78rem; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em; color: var(--muted); margin-bottom: 9px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
border: 1px solid var(--line); background: var(--bg); color: var(--ink);
|
||||||
|
border-radius: 999px; padding: 6px 14px; font-size: 0.88rem; cursor: pointer;
|
||||||
|
transition: all 0.14s ease;
|
||||||
|
}
|
||||||
|
.chip:hover { border-color: var(--accent); }
|
||||||
|
.chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.chip small { font-size: 0.72rem; opacity: 0.7; }
|
||||||
|
.chip.pinned { background: var(--accent-soft); color: var(--accent-deep); cursor: default; border-color: transparent; }
|
||||||
|
.chip .lock { font-size: 0.8rem; }
|
||||||
|
.actions { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; }
|
||||||
|
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; cursor: pointer; }
|
||||||
|
.primary {
|
||||||
|
font: inherit; font-weight: 600; background: var(--accent); color: #fff; border: none;
|
||||||
|
border-radius: 999px; padding: 10px 22px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.primary:hover { background: var(--accent-deep); }
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
<script>
|
<script>
|
||||||
let { moods, selected, onselect } = $props();
|
// `lanes` is the resolved display list ({key, label, description}), with
|
||||||
|
// Today already pinned first. `oncustomize` opens the lane picker.
|
||||||
|
let { lanes, selected, onselect, oncustomize } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="moods">
|
<nav class="moods">
|
||||||
{#each moods as m}
|
{#each lanes as m (m.key)}
|
||||||
<button class:active={selected === m.key} title={m.description} onclick={() => onselect(m.key)}>
|
<button class:active={selected === m.key} title={m.description} onclick={() => onselect(m.key)}>
|
||||||
{m.label}
|
{m.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<button class="customize" title="Choose your lanes" aria-label="Customize lanes" onclick={oncustomize}>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -16,6 +21,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
padding: 4px 0 6px;
|
padding: 4px 0 6px;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
@@ -34,4 +40,11 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 4px 14px rgba(47, 125, 91, 0.25);
|
box-shadow: 0 4px 14px rgba(47, 125, 91, 0.25);
|
||||||
}
|
}
|
||||||
|
.customize {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.customize:hover { opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export function blank() {
|
|||||||
include_topics: [], include_flavors: [],
|
include_topics: [], include_flavors: [],
|
||||||
mute_topics: [], mute_flavors: [],
|
mute_topics: [], mute_flavors: [],
|
||||||
avoid_terms: [], pauses: [], max_cortisol: null,
|
avoid_terms: [], pauses: [], max_cortisol: null,
|
||||||
|
// UI-only: which nav lanes the reader has pinned (keys from /api/lanes).
|
||||||
|
// [] means "not customized" — the feed falls back to the default lane set.
|
||||||
|
// The backend filter parser ignores unknown keys, so this rides along safely.
|
||||||
|
lanes: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,20 @@
|
|||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
import BottomNav from '$lib/components/BottomNav.svelte';
|
import BottomNav from '$lib/components/BottomNav.svelte';
|
||||||
import MoodNav from '$lib/components/MoodNav.svelte';
|
import MoodNav from '$lib/components/MoodNav.svelte';
|
||||||
|
import LanePicker from '$lib/components/LanePicker.svelte';
|
||||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import SavedFlyout from '$lib/components/SavedFlyout.svelte';
|
import SavedFlyout from '$lib/components/SavedFlyout.svelte';
|
||||||
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||||
import { prefs, initPrefs, active as prefsActive, applyPrefAction, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
|
||||||
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
||||||
import { trackVisit, track } from '$lib/analytics.js';
|
import { trackVisit, track } from '$lib/analytics.js';
|
||||||
|
|
||||||
let moods = $state([]);
|
let moods = $state([]);
|
||||||
let topics = $state([]);
|
let topics = $state([]);
|
||||||
let families = $state([]);
|
let families = $state([]);
|
||||||
|
let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
|
||||||
|
let showLanes = $state(false);
|
||||||
let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:<slug>'
|
let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:<slug>'
|
||||||
let brief = $state(null);
|
let brief = $state(null);
|
||||||
let heroIdx = $state(0);
|
let heroIdx = $state(0);
|
||||||
@@ -92,6 +95,33 @@
|
|||||||
);
|
);
|
||||||
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
|
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
|
||||||
|
|
||||||
|
// Customizable nav rail: Today is always first, then the reader's pinned
|
||||||
|
// lanes (or the default set if they've never customized). Resolve each key
|
||||||
|
// to its {label, description} from the pool.
|
||||||
|
let laneMap = $derived(
|
||||||
|
new Map(
|
||||||
|
lanePool
|
||||||
|
? [
|
||||||
|
[lanePool.pinned.key, lanePool.pinned],
|
||||||
|
...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])),
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let pinnedLaneKeys = $derived(
|
||||||
|
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
|
||||||
|
);
|
||||||
|
let navLanes = $derived(
|
||||||
|
lanePool ? [lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
|
||||||
|
);
|
||||||
|
|
||||||
|
function saveLanes(keys) {
|
||||||
|
prefs.data.lanes = keys;
|
||||||
|
persistPrefs();
|
||||||
|
// If the reader unpinned the lane they're currently viewing, fall back home.
|
||||||
|
if (selected !== 'today' && !keys.includes(selected)) select('today');
|
||||||
|
}
|
||||||
|
|
||||||
// Hero is the only image slot; if its image won't load, promote the next one.
|
// Hero is the only image slot; if its image won't load, promote the next one.
|
||||||
let heroArticle = $derived(brief?.items?.[heroIdx] ?? null);
|
let heroArticle = $derived(brief?.items?.[heroIdx] ?? null);
|
||||||
let restArticles = $derived((brief?.items ?? []).filter((_, i) => i !== heroIdx));
|
let restArticles = $derived((brief?.items ?? []).filter((_, i) => i !== heroIdx));
|
||||||
@@ -226,6 +256,7 @@
|
|||||||
try {
|
try {
|
||||||
moods = await getJSON('/api/moods');
|
moods = await getJSON('/api/moods');
|
||||||
topics = (await getJSON('/api/categories')).topics;
|
topics = (await getJSON('/api/categories')).topics;
|
||||||
|
try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; }
|
||||||
try { families = await getJSON('/api/families'); } catch { families = []; }
|
try { families = await getJSON('/api/families'); } catch { families = []; }
|
||||||
await select('today');
|
await select('today');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -239,10 +270,13 @@
|
|||||||
|
|
||||||
{#if showSignIn}<SignIn onclose={() => (showSignIn = false)} />{/if}
|
{#if showSignIn}<SignIn onclose={() => (showSignIn = false)} />{/if}
|
||||||
{#if showSaved && auth.user}<SavedFlyout onclose={() => (showSaved = false)} />{/if}
|
{#if showSaved && auth.user}<SavedFlyout onclose={() => (showSaved = false)} />{/if}
|
||||||
|
{#if showLanes && lanePool}
|
||||||
|
<LanePicker pool={lanePool} selected={pinnedLaneKeys} onsave={saveLanes} onclose={() => (showLanes = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{#if moods.length}
|
{#if navLanes.length}
|
||||||
<MoodNav {moods} {selected} onselect={select} />
|
<MoodNav lanes={navLanes} {selected} onselect={select} oncustomize={() => (showLanes = true)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
{#if notice}<p class="notice rise">{notice}</p>{/if}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from .filters import filter_articles, prefs_from_json
|
|||||||
from .hero import safe_to_lead
|
from .hero import safe_to_lead
|
||||||
from .llm import LocalModelClient
|
from .llm import LocalModelClient
|
||||||
from .moods import MOODS, mood_filter
|
from .moods import MOODS, mood_filter
|
||||||
|
from .lanes import build_lane_pool
|
||||||
from .paywall import is_paywalled
|
from .paywall import is_paywalled
|
||||||
from .taxonomy import FAMILIES, FLAVORS, TOPICS
|
from .taxonomy import FAMILIES, FLAVORS, TOPICS
|
||||||
|
|
||||||
@@ -866,6 +867,18 @@ def create_app() -> FastAPI:
|
|||||||
# client merges with the user's own Calm Filters.
|
# client merges with the user's own Calm Filters.
|
||||||
return MOODS
|
return MOODS
|
||||||
|
|
||||||
|
@app.get("/api/lanes")
|
||||||
|
def lanes() -> dict:
|
||||||
|
# The customizable quick-access rail: 'today' is always pinned, and the
|
||||||
|
# reader pins any subset of these moods / topics / Discovery tags. Live
|
||||||
|
# counts let the client gate empty lanes and show volume.
|
||||||
|
with get_conn() as conn:
|
||||||
|
tagc = queries.tag_counts(conn)
|
||||||
|
topicc: dict[str, int] = {}
|
||||||
|
for row in queries.category_counts(conn):
|
||||||
|
topicc[row["topic"]] = topicc.get(row["topic"], 0) + int(row["count"])
|
||||||
|
return build_lane_pool(topicc, tagc)
|
||||||
|
|
||||||
@app.get("/api/families")
|
@app.get("/api/families")
|
||||||
def families() -> list[dict]:
|
def families() -> list[dict]:
|
||||||
# Grouping vocabulary organised into calm families for the Explore UI.
|
# Grouping vocabulary organised into calm families for the Explore UI.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Nav lanes — the customizable quick-access rail above the feed.
|
||||||
|
|
||||||
|
"Today" is always pinned (the daily brief). Past that, a reader pins whichever
|
||||||
|
lanes they like from a pool of three kinds, all of which the feed already knows
|
||||||
|
how to render as a view:
|
||||||
|
|
||||||
|
* moods — curated filter presets (see moods.py); key is the bare mood key.
|
||||||
|
* topics — the eight primary categories; key is the bare topic key.
|
||||||
|
* tags — high-volume grouping tags (Discovery); key is "tag:<slug>".
|
||||||
|
|
||||||
|
Single source of truth so the website and any future companion app agree on the
|
||||||
|
pool and the default selection. The pool's tag membership is volume-gated so we
|
||||||
|
never offer a lane that would land the reader on an empty feed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .moods import MOODS
|
||||||
|
from .taxonomy import ALLOWED_TAGS, TOPICS
|
||||||
|
|
||||||
|
# The lane pinned first, always — never user-removable.
|
||||||
|
PINNED = {"key": "today", "label": "Today", "description": "The day's good things."}
|
||||||
|
|
||||||
|
# What a reader who has never customized sees: today's curated moods, unchanged.
|
||||||
|
DEFAULT_LANES: list[str] = [m["key"] for m in MOODS if m["key"] != "today"]
|
||||||
|
|
||||||
|
# A grouping tag joins the Discovery pool once it clears this many accepted
|
||||||
|
# articles — except the curated few we always want offered (they fill forward).
|
||||||
|
_TAG_MIN_COUNT = 60
|
||||||
|
_ALWAYS_OFFER_TAGS = ("space", "food")
|
||||||
|
# Tags that duplicate a primary topic are dropped from Discovery to avoid two
|
||||||
|
# pills that mean the same thing.
|
||||||
|
_TAG_EXCLUDE = set(TOPICS)
|
||||||
|
|
||||||
|
|
||||||
|
def _humanize(slug: str) -> str:
|
||||||
|
return slug.replace("-", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def build_lane_pool(topic_counts: dict[str, int], tag_counts: dict[str, int]) -> dict:
|
||||||
|
"""Assemble the selectable pool, annotated with live counts.
|
||||||
|
|
||||||
|
topic_counts / tag_counts map key -> number of accepted articles, so the
|
||||||
|
client can show volume and we can volume-gate the Discovery tags.
|
||||||
|
"""
|
||||||
|
moods = [
|
||||||
|
{"key": m["key"], "label": m["label"], "description": m["description"]}
|
||||||
|
for m in MOODS
|
||||||
|
if m["key"] != "today"
|
||||||
|
]
|
||||||
|
topics = [
|
||||||
|
{"key": k, "label": _humanize(k), "description": v, "count": int(topic_counts.get(k, 0))}
|
||||||
|
for k, v in TOPICS.items()
|
||||||
|
]
|
||||||
|
discovery = [
|
||||||
|
{"key": f"tag:{t}", "label": _humanize(t), "count": int(tag_counts.get(t, 0))}
|
||||||
|
for t in ALLOWED_TAGS
|
||||||
|
if t not in _TAG_EXCLUDE
|
||||||
|
and (tag_counts.get(t, 0) >= _TAG_MIN_COUNT or t in _ALWAYS_OFFER_TAGS)
|
||||||
|
]
|
||||||
|
discovery.sort(key=lambda d: (-d["count"], d["label"]))
|
||||||
|
return {
|
||||||
|
"pinned": PINNED,
|
||||||
|
"default": DEFAULT_LANES,
|
||||||
|
"groups": [
|
||||||
|
{"name": "Moods", "lanes": moods},
|
||||||
|
{"name": "Topics", "lanes": topics},
|
||||||
|
{"name": "Discovery", "lanes": discovery},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def known_lane_keys(pool: dict) -> set[str]:
|
||||||
|
"""Every selectable key in a pool (excluding the always-pinned 'today')."""
|
||||||
|
return {lane["key"] for group in pool["groups"] for lane in group["lanes"]}
|
||||||
@@ -40,7 +40,7 @@ FAMILIES: dict[str, dict] = {
|
|||||||
},
|
},
|
||||||
"Mind & Craft": {
|
"Mind & Craft": {
|
||||||
"description": "Ideas, learning, and making.",
|
"description": "Ideas, learning, and making.",
|
||||||
"tags": ["learning", "ideas", "arts", "books", "creativity", "perspective", "work-life"],
|
"tags": ["learning", "ideas", "arts", "books", "creativity", "perspective", "work-life", "food"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from goodnews.lanes import build_lane_pool, known_lane_keys, DEFAULT_LANES
|
||||||
|
|
||||||
|
|
||||||
|
def test_pool_shape_and_pinned():
|
||||||
|
pool = build_lane_pool({"science": 100}, {"space": 200, "food": 0, "innovation": 99})
|
||||||
|
assert pool["pinned"]["key"] == "today"
|
||||||
|
assert pool["default"] == DEFAULT_LANES
|
||||||
|
names = [g["name"] for g in pool["groups"]]
|
||||||
|
assert names == ["Moods", "Topics", "Discovery"]
|
||||||
|
# 'today' is pinned, never part of the selectable pool.
|
||||||
|
assert "today" not in known_lane_keys(pool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_topics_are_bare_keys_tags_are_prefixed():
|
||||||
|
pool = build_lane_pool({"science": 5}, {})
|
||||||
|
topics = next(g for g in pool["groups"] if g["name"] == "Topics")["lanes"]
|
||||||
|
assert any(l["key"] == "science" for l in topics) # bare topic key
|
||||||
|
disc = next(g for g in pool["groups"] if g["name"] == "Discovery")["lanes"]
|
||||||
|
assert all(l["key"].startswith("tag:") for l in disc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_volume_gate_and_always_offer():
|
||||||
|
# space/food are always offered; a low-volume non-curated tag is dropped.
|
||||||
|
pool = build_lane_pool({}, {"space": 1, "food": 0, "resilience": 3, "innovation": 999})
|
||||||
|
disc_keys = {l["key"] for l in next(g for g in pool["groups"] if g["name"] == "Discovery")["lanes"]}
|
||||||
|
assert "tag:space" in disc_keys and "tag:food" in disc_keys
|
||||||
|
assert "tag:innovation" in disc_keys # over threshold
|
||||||
|
assert "tag:resilience" not in disc_keys # under threshold, not curated
|
||||||
|
|
||||||
|
|
||||||
|
def test_topic_named_tags_excluded_from_discovery():
|
||||||
|
# a tag that duplicates a primary topic must not appear as a Discovery lane
|
||||||
|
pool = build_lane_pool({}, {"science": 999, "technology": 999})
|
||||||
|
disc_keys = {l["key"] for l in next(g for g in pool["groups"] if g["name"] == "Discovery")["lanes"]}
|
||||||
|
assert "tag:science" not in disc_keys and "tag:technology" not in disc_keys
|
||||||
Reference in New Issue
Block a user