/// Forage engine smoke tests. /// /// These tests verify the full engine lifecycle: /// seed corpus load → signal write → feed generation → discovered item injection. /// They run against an ephemeral (in-memory) engine so they leave no disk state. use forage_engine::{ForageEngine, ForageItemInput, SignalKind}; // ── P2: Builder pattern tests ───────────────────────────────────────────────── /// `ForageEngine::builder().ephemeral().open()` is equivalent to `ForageEngine::ephemeral()`. #[test] fn builder_ephemeral_equivalent() { let engine = ForageEngine::builder() .ephemeral() .open() .expect("builder ephemeral"); engine.seed_default_corpus().expect("seed"); let feed = engine.feed(1, 7).expect("feed"); assert_eq!(feed.len(), 7); } /// Builder with `with_embedder` pointing at a non-existent URL falls back gracefully. /// `add_item` must not error out — it logs a warning and uses a neutral vector. #[test] fn builder_with_embedder_fallback_on_unavailable_sidecar() { // Point at a URL that will never respond. let engine = ForageEngine::builder() .ephemeral() .with_embedder("http://127.0.0.1:19999") // nothing listening here .open() .expect("builder with embedder"); engine.seed_default_corpus().expect("seed with fallback"); let item_id = engine .add_item(ForageItemInput { url: "https://example.com/p2-fallback".to_owned(), title: "P2 Fallback Article".to_owned(), source: "example.com".to_owned(), category: "technology".to_owned(), reading_time_min: 4, description: "Tests neutral vector fallback when embedder is down.".to_owned(), tags: vec![], entities: vec![], content_type: String::new(), summary: String::new(), }) .expect("add_item must succeed even when embedder is unreachable"); // Item should still appear in feed via discovered_ids injection. let feed = engine.feed(99, 7).expect("feed"); assert!( feed.iter().any(|i| i.id == item_id), "discovered item should appear in feed even with fallback embedding" ); } /// Seeded corpus loads 100 items across ≥3 categories; feed returns requested limit. #[test] fn seed_corpus_and_cold_feed() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); let feed = engine.feed(1, 7).expect("feed"); assert_eq!(feed.len(), 7, "feed should return exactly 7 items"); let categories: std::collections::HashSet<_> = feed.iter().map(|item| item.category.as_str()).collect(); assert!( categories.len() >= 3, "cold feed should span ≥3 categories, got: {categories:?}" ); } /// User 3 (convergent, heavy tech+jazz signals) gets different feed than user 1 (cold). #[test] fn warm_user_differs_from_cold_user() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); let cold_feed: Vec = engine .feed(1, 7) .expect("cold feed") .iter() .map(|i| i.id) .collect(); let warm_feed: Vec = engine .feed(3, 7) .expect("warm feed") .iter() .map(|i| i.id) .collect(); // At least one item in the warm feed must differ from the cold feed. let cold_set: std::collections::HashSet<_> = cold_feed.iter().copied().collect(); let differs = warm_feed.iter().any(|id| !cold_set.contains(id)); assert!( differs, "warm user (user 3) feed should differ from cold user (user 1) feed" ); } /// Signals shift the feed toward the signaled category over time. #[test] fn signals_shift_feed_toward_signaled_category() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // Fresh user — no preference vector. let before: Vec = engine .feed(99, 7) .expect("before feed") .iter() .map(|i| i.id) .collect(); // Write 10 strong signals on jazz items (ids 25–34). for item_id in 25u64..=34 { engine .signal(99, item_id, SignalKind::View) .expect("view signal"); engine .signal(99, item_id, SignalKind::Save) .expect("save signal"); } let after = engine.feed(99, 7).expect("after feed"); // After signaling, at least one jazz item must appear in the feed. let has_jazz = after.iter().any(|item| item.category == "jazz"); assert!( has_jazz, "feed should shift toward jazz after 10 jazz signals; got: {:?}", after .iter() .map(|i| (&i.title, &i.category)) .collect::>() ); // Before and after feeds differ (ranking shifted). let before_set: std::collections::HashSet<_> = before.iter().copied().collect(); let after_ids: Vec = after.iter().map(|i| i.id).collect(); let changed = after_ids.iter().any(|id| !before_set.contains(id)); assert!(changed, "feed should change after signals are written"); } /// `exploring` label appears: MAB always allocates 1 explore slot for limit=7. #[test] fn feed_contains_exploring_label() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); let feed = engine.feed(3, 7).expect("feed"); let has_exploring = feed .iter() .any(|item| matches!(item.label, forage_engine::ItemLabel::Exploring)); assert!( has_exploring, "feed of 7 items should always contain at least 1 Exploring item" ); } /// `add_item` is idempotent: same URL returns same ID without a second DB write. #[test] fn add_item_is_idempotent() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); let url = "https://example.com/test-article"; let input = || ForageItemInput { url: url.to_owned(), title: "Test Article".to_owned(), source: "example.com".to_owned(), category: "technology".to_owned(), reading_time_min: 5, description: "A test article for idempotency verification.".to_owned(), tags: vec![], entities: vec![], content_type: String::new(), summary: String::new(), }; let id1 = engine.add_item(input()).expect("first add_item"); let id2 = engine.add_item(input()).expect("second add_item"); assert_eq!( id1, id2, "add_item must return the same ID for the same URL" ); assert!(id1 > 100_000, "discovered item ID must be above seed range"); } /// Discovered items appear in the feed after `add_item`. #[test] fn discovered_item_surfaces_in_feed() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); let url = "https://example.com/discovered-page"; let item_id = engine .add_item(ForageItemInput { url: url.to_owned(), title: "Discovered Page".to_owned(), source: "example.com".to_owned(), category: "design".to_owned(), reading_time_min: 3, description: "A page discovered via capture.".to_owned(), tags: vec![], entities: vec![], content_type: String::new(), summary: String::new(), }) .expect("add_item"); // Use a different user (99) so the item is unseen and eligible. let feed = engine.feed(99, 7).expect("feed"); let in_feed = feed.iter().any(|item| item.id == item_id); assert!( in_feed, "newly added item {item_id} should appear in the next feed" ); } /// `semantic_search` returns results even without an embedder (neutral-vector fallback). /// When no sidecar is configured the query vector is uniform, so the assertion is /// only that the method succeeds and returns ≤ requested limit — not semantic quality. #[test] fn semantic_search_returns_results_without_embedder() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // Without an embedder, embed_text returns a neutral unit vector. The ANN // query succeeds but results have no semantic ordering. let results = engine .semantic_search("jazz theory", 5) .expect("semantic_search should succeed even without embedder"); assert!( results.len() <= 5, "should respect requested limit; got {}", results.len() ); // Must return at least some results from the 100-item corpus. assert!( !results.is_empty(), "semantic_search on a seeded corpus should return at least one item" ); } /// `similar_to` returns items for a valid seed item ID. #[test] fn similar_to_returns_items_for_seed_item() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // Item 1 is a seed item with a category-axis embedding; similar_to should // find items with close vectors (same or nearby category). let results = engine .similar_to(1, 5) .expect("similar_to should succeed for a seeded item"); assert!( results.len() <= 5, "should respect requested limit; got {}", results.len() ); assert!( !results.is_empty(), "similar_to on a seeded corpus should return at least one item" ); // The source item (id=1) should be excluded from its own similarity results. assert!( !results.iter().any(|i| i.id == 1), "source item should be excluded from similar_to results" ); } /// After saving items, `feed` augments the pool with semantically similar candidates. #[test] fn similar_to_saved_augments_feed_pool() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // User 99 has no history. Record a "save" signal on a jazz item (id 25). engine .signal(99, 25, SignalKind::Save) .expect("save signal"); // Feed should succeed; the similar_to_saved path is exercised. let feed = engine.feed(99, 7).expect("feed after save"); assert_eq!(feed.len(), 7, "feed should return 7 items"); } /// `top_categories` returns empty for cold users and non-empty for warm users. #[test] fn top_categories_reflects_user_state() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // User 1 is cold — no preference vector. let cold = engine.top_categories(1); assert!( cold.is_empty(), "cold user should have no top categories, got: {cold:?}" ); // User 3 is convergent on tech+jazz — should have preferences. let warm = engine.top_categories(3); assert!( !warm.is_empty(), "warm user (user 3) should have top categories" ); } // ── P4: Bridge item (surprise moment) tests ─────────────────────────────────── /// Warm user with signals in 2+ categories gets a Bridge-labelled item in feed. #[test] fn bridge_item_appears_for_warm_user() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // User 3 is convergent on tech+jazz — has ≥2 active preference dimensions. let feed = engine.feed(3, 7).expect("feed"); let has_bridge = feed .iter() .any(|item| matches!(&item.label, forage_engine::ItemLabel::Bridge { .. })); assert!( has_bridge, "warm user (user 3) with tech+jazz signals should get a Bridge item; \ labels: {:?}", feed.iter() .map(|i| (&i.category, format!("{:?}", i.label))) .collect::>() ); } /// Cold-start user (no preference vector) does not receive a Bridge item. #[test] fn bridge_item_absent_for_cold_user() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // User 1 is cold — no signals, no preference vector. let feed = engine.feed(1, 7).expect("cold feed"); let has_bridge = feed .iter() .any(|item| matches!(&item.label, forage_engine::ItemLabel::Bridge { .. })); assert!( !has_bridge, "cold user (user 1) should not receive a Bridge item; \ labels: {:?}", feed.iter() .map(|i| (&i.category, format!("{:?}", i.label))) .collect::>() ); } /// Bridge label carries distinct, non-empty category names. #[test] fn bridge_label_carries_category_names() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); let feed = engine.feed(3, 7).expect("feed"); let bridge = feed .iter() .find(|item| matches!(&item.label, forage_engine::ItemLabel::Bridge { .. })) .expect("user 3 should have a Bridge item"); let forage_engine::ItemLabel::Bridge { cat_a, cat_b } = &bridge.label else { panic!("expected Bridge label"); }; assert!(!cat_a.is_empty(), "cat_a must not be empty"); assert!(!cat_b.is_empty(), "cat_b must not be empty"); assert_ne!( cat_a, cat_b, "bridge categories must be distinct; got both = {cat_a}" ); } // ── P3: Adaptive MAB tests ───────────────────────────────────────────────────── /// Cold-start user has default exploration ratio and zero stats. #[test] fn adaptive_ratio_defaults_for_cold_user() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); let stats = engine.exploration_stats(99); assert_eq!( stats.exploration_total, 0, "cold user has no exploration history" ); assert_eq!( stats.adaptive_ratio(), 0.14, "cold user uses default exploration ratio" ); } /// Exploration ratio rises to 0.25 after enough positive exploration hits. #[test] fn adaptive_ratio_rises_for_adventurous_user() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // Simulate the user engaging with 6 out of 10 exploring items (hit_rate = 0.6 > 0.5). // We write a feed to populate last_explore_items, then signal the exploring item. // Repeat until we have enough exploration history. for round in 0..10u64 { let feed = engine.feed(42, 7).expect("feed"); for item in &feed { if matches!(item.label, forage_engine::ItemLabel::Exploring) { // Signal positively to record a hit (6 of 10 rounds are positive). if round < 6 { engine .signal(42, item.id, SignalKind::View) .expect("view signal"); } else { engine .signal(42, item.id, SignalKind::Skip) .expect("skip signal"); } } } } let stats = engine.exploration_stats(42); assert!( stats.exploration_total >= 6, "should have at least 6 exploration outcomes, got {}", stats.exploration_total ); assert!( stats.hit_rate() > 0.5, "hit_rate should exceed 0.5, got {}", stats.hit_rate() ); assert_eq!( stats.adaptive_ratio(), 0.25, "adventurous user should get 0.25 exploration ratio" ); } /// Exploration ratio drops to 0.10 for a convergent user who ignores exploration items. #[test] fn adaptive_ratio_drops_for_convergent_user() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // Signal a lot on jazz items first so the user has a warm preference vector. for item_id in 25u64..=34 { engine .signal(55, item_id, SignalKind::View) .expect("view signal"); } // Now simulate 10 rounds where the user always skips exploration items. for _ in 0..10 { let feed = engine.feed(55, 7).expect("feed"); for item in &feed { if matches!(item.label, forage_engine::ItemLabel::Exploring) { engine .signal(55, item.id, SignalKind::Skip) .expect("skip signal"); } } } let stats = engine.exploration_stats(55); // 10 rounds × at least 1 explore slot each → must have ≥5 outcomes. assert!( stats.exploration_total >= 5, "should have ≥5 exploration outcomes after 10 rounds; got {}", stats.exploration_total ); assert!( stats.hit_rate() < 0.2, "convergent user hit_rate should be < 0.2, got {}", stats.hit_rate() ); assert_eq!( stats.adaptive_ratio(), 0.10, "convergent user should get 0.10 exploration ratio" ); } /// UCB1 bonus computation increases for categories with no signals. #[test] fn ucb1_bonus_higher_for_unseen_categories() { use forage_engine::ExplorationStats; let mut stats = ExplorationStats::default(); // Record 10 signals in "technology" and 0 in "jazz". for _ in 0..10 { stats.record_category_signal("technology"); } let tech_bonus = stats.ucb1_bonus("technology"); let jazz_bonus = stats.ucb1_bonus("jazz"); assert!( jazz_bonus > tech_bonus, "unseen category (jazz) should get higher UCB1 bonus than explored category (technology); \ jazz={jazz_bonus:.3}, tech={tech_bonus:.3}" ); // Zero total signals → zero bonus. let empty = ExplorationStats::default(); assert_eq!( empty.ucb1_bonus("anything"), 0.0, "cold user gets zero UCB1 bonus" ); } /// Category signals are tracked when `signal()` is called. #[test] fn category_signals_tracked_on_signal_write() { let engine = ForageEngine::ephemeral().expect("ephemeral engine"); engine.seed_default_corpus().expect("seed corpus"); // Item IDs 25–34 are jazz items (per seed corpus). engine .signal(77, 25, SignalKind::View) .expect("view signal"); engine .signal(77, 26, SignalKind::Save) .expect("save signal"); let stats = engine.exploration_stats(77); let jazz_count = stats.category_signals.get("jazz").copied().unwrap_or(0); assert_eq!( jazz_count, 2, "two jazz signals should be tracked; category_signals={:?}", stats.category_signals ); } // ── Browse tasks tests ────────────────────────────────────────────────────── /// Cold user (no signals) gets all 8 source categories at equal priority. #[test] fn browse_tasks_cold_start_equal_weights() { let engine = ForageEngine::ephemeral().unwrap(); engine.seed_default_corpus().unwrap(); // Cold user (no signals) — all 8 categories should have equal priority ~0.125. let plan = engine.browse_tasks(99, 5); // user 99 has no signals assert!(plan.should_run); assert_eq!(plan.limit_per_topic, 5); assert_eq!(plan.interval_minutes, 30); assert_eq!( plan.topics.len(), 8, "all 8 source categories should be present" ); // All priorities should be equal (within floating point tolerance). let first_priority = plan.topics[0].priority; for topic in &plan.topics { assert!( (topic.priority - first_priority).abs() < 1e-5, "cold-start topics should have equal priority, got {} and {}", first_priority, topic.priority ); } // Every topic must have at least 1 source URL. for topic in &plan.topics { assert!( !topic.sources.is_empty(), "topic '{}' has no sources", topic.name ); } // Cold start: no tag hints. assert!( plan.tag_hints.is_empty(), "cold user should have no tag hints" ); } /// Warm user with jazz saves gets jazz as the highest-priority browse topic. #[test] fn browse_tasks_warm_user_top_category_ranks_first() { let engine = ForageEngine::ephemeral().unwrap(); engine.seed_default_corpus().unwrap(); // Give user 1 several saves on jazz items to build a preference. // Find jazz seed items and save them. let jazz_items: Vec = engine .all_items() .iter() .filter(|s| s.category == "jazz") .take(5) .map(|s| s.id) .collect(); assert!(!jazz_items.is_empty(), "seed corpus should have jazz items"); for id in &jazz_items { engine.signal(1, *id, SignalKind::Save).unwrap(); } let plan = engine.browse_tasks(1, 5); // Jazz should be the highest-priority topic. assert!(!plan.topics.is_empty()); assert_eq!( plan.topics[0].name, "jazz", "jazz should rank first after jazz saves, got: {:?}", plan.topics .iter() .map(|t| (&t.name, t.priority)) .collect::>() ); // Jazz's priority should be higher than all other topics. let jazz_priority = plan.topics[0].priority; for other in plan.topics.iter().skip(1) { assert!( jazz_priority > other.priority, "jazz ({}) should outrank {} ({})", jazz_priority, other.name, other.priority ); } } /// Tag hints are populated from saved items' tags. #[test] fn browse_tasks_tag_hints_populated_from_saves() { let engine = ForageEngine::ephemeral().unwrap(); engine.seed_default_corpus().unwrap(); // Capture an item with tags and save it. let input = ForageItemInput { url: "https://example.com/modal-jazz-article".to_string(), title: "A Guide to Modal Jazz".to_string(), source: "example.com".to_string(), category: "jazz".to_string(), reading_time_min: 8, description: "Deep dive into modal jazz techniques.".to_string(), tags: vec![ "modal jazz".to_string(), "music theory".to_string(), "coltrane".to_string(), ], entities: vec!["John Coltrane".to_string()], content_type: "tutorial".to_string(), summary: "Explores the harmonic language of modal jazz. Coltrane is the central focus." .to_string(), }; let item_id = engine.add_item(input).unwrap(); // Save the item for user 1. engine.signal(1, item_id, SignalKind::Save).unwrap(); let plan = engine.browse_tasks(1, 5); // Tag hints should contain the tags from the saved item. assert!( plan.tag_hints.contains(&"modal jazz".to_string()), "tag_hints should contain 'modal jazz', got: {:?}", plan.tag_hints ); assert!( plan.tag_hints.contains(&"music theory".to_string()), "tag_hints should contain 'music theory', got: {:?}", plan.tag_hints ); } // ── Top tags tests ────────────────────────────────────────────────────────── /// Cold user (no saves) gets empty top_tags. #[test] fn top_tags_empty_for_cold_user() { let engine = ForageEngine::ephemeral().unwrap(); engine.seed_default_corpus().unwrap(); // User 99 has no saves — top_tags should return empty. let tags = engine.top_tags(99, 5); assert!( tags.is_empty(), "cold user should have no tags, got: {:?}", tags ); } /// Top tags are ordered by frequency of occurrence across saved items. #[test] fn top_tags_frequency_ranked() { let engine = ForageEngine::ephemeral().unwrap(); engine.seed_default_corpus().unwrap(); // Capture 3 items with overlapping tags and save them. // "rust" appears 3 times, "async" appears 2 times, "wasm" appears 1 time. let items = vec![ ( "https://example.com/rust-async", vec!["rust", "async", "tokio"], ), ("https://example.com/rust-wasm", vec!["rust", "wasm"]), ("https://example.com/rust-futures", vec!["rust", "async"]), ]; for (url, tags) in items { let input = ForageItemInput { url: url.to_string(), title: format!("Article: {url}"), source: "example.com".to_string(), category: "technology".to_string(), reading_time_min: 5, description: String::new(), tags: tags.iter().map(|s| s.to_string()).collect(), entities: vec![], content_type: "tutorial".to_string(), summary: String::new(), }; let id = engine.add_item(input).unwrap(); engine.signal(1, id, SignalKind::Save).unwrap(); } let tags = engine.top_tags(1, 5); // "rust" appears 3x — must be first. assert!(!tags.is_empty(), "should have tags after saves"); assert_eq!( tags[0], "rust", "most frequent tag should be first, got: {:?}", tags ); // "async" appears 2x — must rank above "wasm" (1x). let async_pos = tags .iter() .position(|t| t == "async") .expect("async should be present"); let wasm_pos = tags .iter() .position(|t| t == "wasm") .expect("wasm should be present"); assert!( async_pos < wasm_pos, "async (2x) should rank before wasm (1x)" ); } /// Enrichment fields (tags, entities, content_type, summary) stored via `add_item` /// are hydrated on feed items returned by `feed()`. /// Regression guard for the feed enrichment hydration path added in fix-all. #[test] fn discovered_item_enrichment_preserved_in_feed() { let engine = ForageEngine::ephemeral().unwrap(); engine.seed_default_corpus().unwrap(); let item_id = engine .add_item(ForageItemInput { url: "https://example.com/enriched-article".to_string(), title: "Enriched Article".to_string(), source: "example.com".to_string(), category: "technology".to_string(), reading_time_min: 6, description: "An article with full enrichment metadata.".to_string(), tags: vec!["rust".to_string(), "async".to_string()], entities: vec!["Tokio".to_string()], content_type: "tutorial".to_string(), summary: "Teaches async Rust. Tokio is the runtime used throughout.".to_string(), }) .unwrap(); // Retrieve feed for a fresh user so the discovered item is injected. let feed = engine.feed(99, 7).unwrap(); let item = feed .iter() .find(|i| i.id == item_id) .expect("discovered item should appear in feed"); assert_eq!( item.tags, vec!["rust", "async"], "feed item should carry its stored tags, got: {:?}", item.tags ); assert_eq!( item.entities, vec!["Tokio"], "feed item should carry its stored entities, got: {:?}", item.entities ); assert_eq!( item.content_type, "tutorial", "feed item should carry its stored content_type" ); assert!( !item.summary.is_empty(), "feed item should carry its stored summary, got empty string" ); } /// Items the user dwelled on for ≥15 seconds contribute to `top_tags`, /// even if they were never explicitly saved. #[test] fn top_tags_includes_dwell_items() { let engine = ForageEngine::ephemeral().unwrap(); engine.seed_default_corpus().unwrap(); // Add an item with distinctive tags. let item_id = engine .add_item(ForageItemInput { url: "https://example.com/dwell-tagged-article".to_string(), title: "Deep Read Article".to_string(), source: "example.com".to_string(), category: "science".to_string(), reading_time_min: 10, description: "An article worth reading slowly.".to_string(), tags: vec!["quantum computing".to_string(), "research".to_string()], entities: vec![], content_type: "research".to_string(), summary: "Explores quantum error correction. Practical applications are assessed." .to_string(), }) .unwrap(); // Dwell ≥15 000 ms (completion threshold) without saving. engine.signal_dwell(88, item_id, 20_000).unwrap(); // top_tags should include tags from the dwelled item. let tags = engine.top_tags(88, 5); assert!( tags.contains(&"quantum computing".to_string()), "top_tags should include tags from completion-dwell items; got: {:?}", tags ); // Short dwell (< 15 s) should NOT contribute tags. let item_id2 = engine .add_item(ForageItemInput { url: "https://example.com/short-dwell-article".to_string(), title: "Brief Glance Article".to_string(), source: "example.com".to_string(), category: "science".to_string(), reading_time_min: 5, description: "Skimmed article.".to_string(), tags: vec!["astronomy".to_string()], entities: vec![], content_type: "news".to_string(), summary: "Brief overview of recent astronomy findings.".to_string(), }) .unwrap(); engine.signal_dwell(88, item_id2, 5_000).unwrap(); // only 5 seconds let tags_after = engine.top_tags(88, 10); assert!( !tags_after.contains(&"astronomy".to_string()), "short dwell (<15s) should not contribute tags; got: {:?}", tags_after ); } /// `signal_dwell` is re-exported properly for test use. /// Quick sanity check that the method exists and accepts valid parameters. #[test] fn signal_dwell_method_exists() { let engine = ForageEngine::ephemeral().unwrap(); engine.seed_default_corpus().unwrap(); // 30 seconds of dwell on a seed item — should succeed without error. engine.signal_dwell(1, 1, 30_000).unwrap(); }