use agentstatedeveloper_core::{
AsgFeedbackStore, Engine, FeedbackEntry, FeedbackStore, FeedbackVerdict, SearchFtsDb,
};
use chrono::{Duration, Utc};
struct Fixture {
_dir: tempfile::TempDir,
db: std::path::PathBuf,
engine: Engine,
}
impl Fixture {
fn new(entries: Vec<FeedbackEntry>) -> Self {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join(".asd-state.db");
let engine = Engine::open_sqlite(&db).expect("file-backed engine");
let store = AsgFeedbackStore::from_engine(&engine);
for e in &entries {
store.record(&engine.ref_name, e, "test").expect("record");
}
let f = Self {
_dir: dir,
db,
engine,
};
f.assert_cache_warm();
f
}
fn store(&self) -> AsgFeedbackStore<'_> {
AsgFeedbackStore::from_engine(&self.engine)
}
fn assert_cache_warm(&self) {
let fts = SearchFtsDb::open(&self.db).expect("open fts");
assert!(
fts.feedback_count() > 0,
"SQLite cache is cold — list_all would bypass it and these tests \
would be checking the git tree twice"
);
}
fn via_cache(&self) -> Vec<FeedbackEntry> {
SearchFtsDb::open(&self.db)
.expect("open fts")
.list_all_feedback()
.expect("cache read")
}
fn via_tree(&self, symbol_id: &str) -> Vec<FeedbackEntry> {
self.store()
.list_for_symbol(&self.engine.ref_name, symbol_id)
.expect("tree read")
}
}
fn entry(id: &str, qname: &str, verdict: FeedbackVerdict) -> FeedbackEntry {
FeedbackEntry {
entry_id: id.to_string(),
symbol_id: format!("id::{qname}"),
symbol_qname: qname.to_string(),
query: "charge a card".to_string(),
verdict,
author: "alice".to_string(),
created_at: Utc::now(),
note: None,
file_scope: None,
expires_at: None,
withdrawn_at: None,
withdrawn_by: None,
withdrawn_reason: None,
}
}
fn assert_paths_agree(f: &Fixture, entry_id: &str, symbol_id: &str, what: &str) {
let cached = f.via_cache();
let tree = f.via_tree(symbol_id);
let c = cached
.iter()
.find(|e| e.entry_id == entry_id)
.unwrap_or_else(|| panic!("{what}: {entry_id} missing from the SQLite cache"));
let t = tree
.iter()
.find(|e| e.entry_id == entry_id)
.unwrap_or_else(|| panic!("{what}: {entry_id} missing from the ASG tree"));
assert_eq!(c.expires_at, t.expires_at, "{what}: expires_at diverged");
assert_eq!(
c.withdrawn_at, t.withdrawn_at,
"{what}: withdrawn_at diverged"
);
assert_eq!(
c.withdrawn_by, t.withdrawn_by,
"{what}: withdrawn_by diverged"
);
assert_eq!(
c.withdrawn_reason, t.withdrawn_reason,
"{what}: withdrawn_reason diverged"
);
assert_eq!(
c.is_inert(),
t.is_inert(),
"{what}: the two paths disagree on whether ranking should see this"
);
}
#[test]
fn expiry_reaches_both_read_paths() {
let f = Fixture::new(vec![entry("fb-1", "util.log", FeedbackVerdict::Noisy)]);
let store = f.store();
let mut lapsed = store.list_all(&f.engine.ref_name).unwrap()[0].clone();
lapsed.expires_at = Some(Utc::now());
store
.record(&f.engine.ref_name, &lapsed, "alice")
.expect("re-record with expiry");
assert_paths_agree(&f, "fb-1", "id::util.log", "expire");
assert!(f.via_cache()[0].is_expired(), "cache still shows it live");
assert!(
store.flat_verdicts(&f.engine.ref_name).unwrap().is_empty(),
"expired verdict still reaching ranking"
);
}
#[test]
fn withdrawal_reaches_both_read_paths() {
let f = Fixture::new(vec![entry("fb-1", "util.log", FeedbackVerdict::Noisy)]);
let store = f.store();
store
.withdraw(&f.engine.ref_name, "fb-1", "craig", Some("wrong symbol"))
.expect("withdraw");
assert_paths_agree(&f, "fb-1", "id::util.log", "withdraw");
assert_eq!(f.via_cache()[0].withdrawn_by.as_deref(), Some("craig"));
assert!(
store.flat_verdicts(&f.engine.ref_name).unwrap().is_empty(),
"withdrawn verdict still reaching ranking"
);
}
#[test]
fn retired_verdicts_leave_ranking_but_stay_listed_on_both_paths() {
let f = Fixture::new(vec![
entry("fb-expired", "util.log", FeedbackVerdict::Noisy),
entry("fb-withdrawn", "util.log", FeedbackVerdict::Noisy),
entry("fb-live", "pay.charge", FeedbackVerdict::Useful),
]);
let store = f.store();
let mut lapsed = entry("fb-expired", "util.log", FeedbackVerdict::Noisy);
lapsed.expires_at = Some(Utc::now() - Duration::hours(1));
store.record(&f.engine.ref_name, &lapsed, "alice").unwrap();
store
.withdraw(&f.engine.ref_name, "fb-withdrawn", "craig", None)
.unwrap();
let ranked = store.flat_verdicts(&f.engine.ref_name).unwrap();
assert_eq!(ranked.len(), 1, "ranking view: {ranked:?}");
assert_eq!(ranked[0].2, FeedbackVerdict::Useful);
assert_eq!(store.list_all(&f.engine.ref_name).unwrap().len(), 3);
assert_eq!(f.via_cache().len(), 3, "cache dropped a retired entry");
assert_eq!(f.via_tree("id::util.log").len(), 2, "tree dropped one");
}
#[test]
fn a_cold_cache_and_a_warm_one_report_the_same_thing() {
let f = Fixture::new(vec![entry("fb-1", "util.log", FeedbackVerdict::Noisy)]);
let store = f.store();
store
.withdraw(&f.engine.ref_name, "fb-1", "craig", Some("wrong"))
.unwrap();
let warm = store.list_all(&f.engine.ref_name).unwrap();
rusqlite::Connection::open(&f.db)
.expect("open db")
.execute("DELETE FROM asd_feedback", [])
.expect("clear cache");
let cold = store.list_all(&f.engine.ref_name).unwrap();
assert_eq!(warm.len(), cold.len(), "warm and cold disagree on count");
assert_eq!(warm[0].entry_id, cold[0].entry_id);
assert_eq!(warm[0].withdrawn_at, cold[0].withdrawn_at);
assert_eq!(warm[0].withdrawn_by, cold[0].withdrawn_by);
assert_eq!(warm[0].is_inert(), cold[0].is_inert());
}
#[test]
fn file_scope_verdicts_are_coherent_too() {
let mut e = entry("fb-glob", "", FeedbackVerdict::Noisy);
e.file_scope = Some("src/legacy/**".to_string());
e.symbol_id = "__file_scope__glob".to_string();
let f = Fixture::new(vec![e]);
let store = f.store();
assert_eq!(
store
.flat_file_scope_verdicts(&f.engine.ref_name)
.unwrap()
.len(),
1
);
store
.withdraw(&f.engine.ref_name, "fb-glob", "craig", None)
.unwrap();
assert_paths_agree(&f, "fb-glob", "__file_scope__glob", "file-scope withdraw");
assert!(
store
.flat_file_scope_verdicts(&f.engine.ref_name)
.unwrap()
.is_empty()
);
}
#[test]
fn purge_clears_both_stores() {
let f = Fixture::new(vec![
entry("fb-gone", "util.log", FeedbackVerdict::Noisy),
entry("fb-stays", "pay.charge", FeedbackVerdict::Useful),
]);
let store = f.store();
let purged = store
.purge(&f.engine.ref_name, "fb-gone")
.expect("purge")
.expect("entry existed");
assert_eq!(purged.entry_id, "fb-gone");
assert!(
!f.via_cache().iter().any(|e| e.entry_id == "fb-gone"),
"purged entry still in the SQLite cache — list_all would serve it"
);
assert!(
!f.via_tree("id::util.log")
.iter()
.any(|e| e.entry_id == "fb-gone"),
"purged entry still in the ASG tree"
);
assert!(f.via_cache().iter().any(|e| e.entry_id == "fb-stays"));
}
#[test]
fn purging_the_last_entry_for_a_symbol_leaves_nothing_behind() {
let f = Fixture::new(vec![entry("fb-only", "util.log", FeedbackVerdict::Noisy)]);
let store = f.store();
store.purge(&f.engine.ref_name, "fb-only").unwrap();
assert!(
f.via_tree("id::util.log").is_empty(),
"symbol still reports feedback after its last entry was purged"
);
assert!(store.list_all(&f.engine.ref_name).unwrap().is_empty());
assert!(store.flat_verdicts(&f.engine.ref_name).unwrap().is_empty());
rusqlite::Connection::open(&f.db)
.expect("open db")
.execute("DELETE FROM asd_feedback", [])
.expect("clear cache");
assert!(
store.list_all(&f.engine.ref_name).unwrap().is_empty(),
"cold-cache read resurrected a purged entry"
);
}
#[test]
fn purging_an_unknown_entry_reports_rather_than_half_succeeding() {
let f = Fixture::new(vec![entry("fb-real", "util.log", FeedbackVerdict::Noisy)]);
let store = f.store();
assert!(
store
.purge(&f.engine.ref_name, "fb-nope")
.unwrap()
.is_none(),
"purge invented a deletion"
);
assert_eq!(store.list_all(&f.engine.ref_name).unwrap().len(), 1);
assert_eq!(f.via_cache().len(), 1);
}
#[test]
fn purge_removes_a_withdrawn_entry_too() {
let f = Fixture::new(vec![entry("fb-1", "util.log", FeedbackVerdict::Noisy)]);
let store = f.store();
store
.withdraw(&f.engine.ref_name, "fb-1", "craig", Some("oops"))
.unwrap();
store.purge(&f.engine.ref_name, "fb-1").unwrap();
assert!(f.via_cache().is_empty(), "cache retained a purged entry");
assert!(f.via_tree("id::util.log").is_empty(), "tree retained it");
}