use agentstatedeveloper_core::{
AsgFeedbackStore, Engine, FeedbackEntry, FeedbackStore, FeedbackVerdict,
};
use chrono::{Duration, Utc};
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 engine_with(entries: Vec<FeedbackEntry>) -> Engine {
let engine = Engine::open_in_memory().expect("in-memory engine");
let store = AsgFeedbackStore::from_engine(&engine);
for e in &entries {
store.record(&engine.ref_name, e, "test").expect("record");
}
engine
}
#[test]
fn a_withdrawn_verdict_leaves_the_ranking_view() {
let engine = engine_with(vec![
entry("fb-bad", "util.log", FeedbackVerdict::Noisy),
entry("fb-good", "pay.charge", FeedbackVerdict::Useful),
]);
let store = AsgFeedbackStore::from_engine(&engine);
assert_eq!(store.flat_verdicts(&engine.ref_name).unwrap().len(), 2);
let w = store
.withdraw(&engine.ref_name, "fb-bad", "craig", Some("mistyped"))
.expect("withdraw")
.expect("entry existed");
assert!(w.is_withdrawn());
assert_eq!(w.withdrawn_by.as_deref(), Some("craig"));
assert_eq!(w.withdrawn_reason.as_deref(), Some("mistyped"));
let ranked = store.flat_verdicts(&engine.ref_name).unwrap();
assert_eq!(
ranked.len(),
1,
"withdrawn verdict still ranking: {ranked:?}"
);
assert_eq!(ranked[0].2, FeedbackVerdict::Useful);
}
#[test]
fn withdrawn_entries_stay_listed() {
let engine = engine_with(vec![entry("fb-bad", "util.log", FeedbackVerdict::Noisy)]);
let store = AsgFeedbackStore::from_engine(&engine);
store
.withdraw(&engine.ref_name, "fb-bad", "craig", None)
.unwrap();
let listed = store.list_all(&engine.ref_name).unwrap();
assert_eq!(listed.len(), 1, "withdrawn entries must stay listed");
assert!(listed[0].is_withdrawn());
assert!(store.flat_verdicts(&engine.ref_name).unwrap().is_empty());
}
#[test]
fn withdrawal_reaches_both_read_paths() {
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);
store
.record(
&engine.ref_name,
&entry("fb-bad", "util.log", FeedbackVerdict::Noisy),
"test",
)
.expect("record");
let fts = agentstatedeveloper_core::SearchFtsDb::open(&db).expect("fts");
assert!(
fts.feedback_count() > 0,
"cache cold — list_all would bypass it and this test would be vacuous"
);
store
.withdraw(&engine.ref_name, "fb-bad", "craig", Some("wrong"))
.unwrap();
let cached = fts.list_all_feedback().expect("cache read");
assert_eq!(cached.len(), 1);
assert!(
cached[0].is_withdrawn(),
"SQLite cache still serving it live"
);
assert_eq!(cached[0].withdrawn_by.as_deref(), Some("craig"));
assert_eq!(cached[0].withdrawn_reason.as_deref(), Some("wrong"));
let via_tree = store
.list_for_symbol(&engine.ref_name, "id::util.log")
.unwrap();
assert_eq!(via_tree.len(), 1);
assert!(via_tree[0].is_withdrawn(), "git tree stale");
assert!(store.flat_verdicts(&engine.ref_name).unwrap().is_empty());
}
#[test]
fn withdrawing_twice_keeps_the_first_timestamp() {
let engine = engine_with(vec![entry("fb-bad", "util.log", FeedbackVerdict::Noisy)]);
let store = AsgFeedbackStore::from_engine(&engine);
let first = store
.withdraw(&engine.ref_name, "fb-bad", "craig", Some("wrong"))
.unwrap()
.unwrap();
let again = store
.withdraw(&engine.ref_name, "fb-bad", "someone-else", Some("other"))
.unwrap()
.unwrap();
assert_eq!(
first.withdrawn_at, again.withdrawn_at,
"a second withdrawal must not move the timestamp"
);
assert_eq!(again.withdrawn_by.as_deref(), Some("craig"));
}
#[test]
fn withdrawing_an_unknown_entry_reports_rather_than_inventing_one() {
let engine = engine_with(vec![entry("fb-real", "util.log", FeedbackVerdict::Noisy)]);
let store = AsgFeedbackStore::from_engine(&engine);
assert!(
store
.withdraw(&engine.ref_name, "fb-nope", "craig", None)
.unwrap()
.is_none()
);
assert_eq!(store.list_all(&engine.ref_name).unwrap().len(), 1);
}
#[test]
fn withdrawal_and_expiry_are_independent_states() {
let mut e = entry("fb-bad", "util.log", FeedbackVerdict::Noisy);
e.expires_at = Some(Utc::now() + Duration::days(30)); let engine = engine_with(vec![e]);
let store = AsgFeedbackStore::from_engine(&engine);
store
.withdraw(&engine.ref_name, "fb-bad", "craig", None)
.unwrap();
let listed = &store.list_all(&engine.ref_name).unwrap()[0];
assert!(!listed.is_expired(), "future expiry means not expired");
assert!(listed.is_withdrawn());
assert!(listed.is_inert(), "inert via withdrawal alone");
assert!(
store.flat_verdicts(&engine.ref_name).unwrap().is_empty(),
"a live expiry must not resurrect a withdrawn verdict"
);
}
#[test]
fn file_scope_verdicts_withdraw_too() {
let mut e = entry("fb-glob", "", FeedbackVerdict::Noisy);
e.file_scope = Some("src/legacy/**".to_string());
let engine = engine_with(vec![e]);
let store = AsgFeedbackStore::from_engine(&engine);
assert_eq!(
store
.flat_file_scope_verdicts(&engine.ref_name)
.unwrap()
.len(),
1
);
store
.withdraw(&engine.ref_name, "fb-glob", "craig", None)
.unwrap();
assert!(
store
.flat_file_scope_verdicts(&engine.ref_name)
.unwrap()
.is_empty(),
"file-scope verdicts withdraw on the same terms"
);
}