Add feed sidebar navigation #8

Merged
schaefera merged 14 commits from worktree-feed-sidebar-nav into main 2026-09-15 07:20:41 +00:00
Showing only changes of commit 8edffe8973 - Show all commits

View file

@ -152,3 +152,118 @@ impl Db {
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A `Db` backed by a throwaway SQLite file under the OS temp dir,
/// migrated fresh per test and deleted (including its `-wal`/`-shm`
/// siblings) when the test finishes, so tests can't see each other's
/// data or leak files across runs.
struct TestDb {
db: Db,
path: std::path::PathBuf,
}
impl std::ops::Deref for TestDb {
type Target = Db;
fn deref(&self) -> &Db {
&self.db
}
}
impl Drop for TestDb {
fn drop(&mut self) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", self.path.display()));
}
}
}
async fn test_db() -> TestDb {
let path = std::env::temp_dir().join(format!("feedsignal-test-{}.db", Uuid::new_v4()));
let db = Db::connect(path.to_str().unwrap())
.await
.expect("connect test db");
TestDb { db, path }
}
fn article(feed_id: Uuid, final_score: f32) -> Article {
Article {
id: Uuid::new_v4(),
feed_id,
url: format!("https://example.com/{}", Uuid::new_v4()),
title: "Title".to_string(),
summary: "Summary".to_string(),
published_at: None,
topics: vec![],
embedding_score: None,
llm_score: None,
final_score: Some(final_score),
estimated_read_seconds: None,
}
}
/// `feed_id: None` joins every subscribed feed's articles into one
/// ranked list — what the sidebar's "All" view depends on.
#[tokio::test]
async fn list_ranked_articles_with_no_feed_filter_returns_every_feed() {
let db = test_db().await;
let feed_a = db
.upsert_feed("https://a.example.com/feed", "Feed A")
.await
.unwrap();
let feed_b = db
.upsert_feed("https://b.example.com/feed", "Feed B")
.await
.unwrap();
db.insert_article(&article(feed_a, 0.9)).await.unwrap();
db.insert_article(&article(feed_b, 0.5)).await.unwrap();
let rows = db.list_ranked_articles(None, 10).await.unwrap();
assert_eq!(rows.len(), 2);
}
/// `feed_id: Some(id)` scopes the list to that one feed and excludes
/// every other subscribed feed's articles — what the sidebar's
/// per-feed nav depends on.
#[tokio::test]
async fn list_ranked_articles_with_feed_filter_excludes_other_feeds() {
let db = test_db().await;
let feed_a = db
.upsert_feed("https://a.example.com/feed", "Feed A")
.await
.unwrap();
let feed_b = db
.upsert_feed("https://b.example.com/feed", "Feed B")
.await
.unwrap();
db.insert_article(&article(feed_a, 0.9)).await.unwrap();
db.insert_article(&article(feed_b, 0.5)).await.unwrap();
let rows = db.list_ranked_articles(Some(feed_a), 10).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].1, feed_a.to_string());
}
/// Results stay ordered by `final_score` descending regardless of
/// insertion order, matching how the UI ranks the article list.
#[tokio::test]
async fn list_ranked_articles_orders_by_final_score_descending() {
let db = test_db().await;
let feed = db
.upsert_feed("https://a.example.com/feed", "Feed A")
.await
.unwrap();
db.insert_article(&article(feed, 0.2)).await.unwrap();
db.insert_article(&article(feed, 0.8)).await.unwrap();
let rows = db.list_ranked_articles(None, 10).await.unwrap();
assert_eq!(rows[0].6, Some(0.8));
assert_eq!(rows[1].6, Some(0.2));
}
}