use crate::metrics_api::Exercised;
use crate::model::TestResultRow;
use std::collections::BTreeMap;
pub const W_METRIC_NOT_EXERCISED: &str = "W_METRIC_NOT_EXERCISED";
pub const W_ASSERTION_NOT_EXERCISED: &str = "W_ASSERTION_NOT_EXERCISED";
pub const ASSERTIONS_NOT_EXERCISED: &str = "assertions_not_exercised";
const MAX_NAMED_TESTS: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Surface {
Metric,
Assertion,
}
impl Surface {
fn code(self) -> &'static str {
match self {
Self::Metric => W_METRIC_NOT_EXERCISED,
Self::Assertion => W_ASSERTION_NOT_EXERCISED,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotExercised {
pub surface: Surface,
pub check: String,
pub reason: String,
pub test_ids: Vec<String>,
}
impl NotExercised {
pub fn render(&self) -> String {
let named = self
.test_ids
.iter()
.take(MAX_NAMED_TESTS)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let rest = self.test_ids.len().saturating_sub(MAX_NAMED_TESTS);
let tail = if rest > 0 {
format!("{named} and {rest} more")
} else {
named
};
format!(
"{}: {} was requested by {} test(s) and evaluated nothing ({}) — {}",
self.surface.code(),
self.check,
self.test_ids.len(),
self.reason,
tail
)
}
}
const UNRECORDED_REASON: &str = "no reason recorded";
pub fn collect(results: &[TestResultRow]) -> Vec<NotExercised> {
let mut folded: BTreeMap<(Surface, String, String), Vec<String>> = BTreeMap::new();
for row in results {
if let Some(metrics) = row.details.get("metrics").and_then(|m| m.as_object()) {
for (metric_name, metric) in metrics {
let label = metric.get("exercised").and_then(|e| e.as_str());
if label != Some(Exercised::NotExercised.label()) {
continue;
}
let reason = metric
.get("details")
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str())
.unwrap_or(UNRECORDED_REASON);
folded
.entry((Surface::Metric, metric_name.clone(), reason.to_string()))
.or_default()
.push(row.test_id.clone());
}
}
let covers = row
.details
.get(ASSERTIONS_NOT_EXERCISED)
.and_then(|c| c.as_array());
for cover in covers.into_iter().flatten() {
let Some(assertion) = cover.get("assertion").and_then(|a| a.as_str()) else {
continue;
};
let reason = cover
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or(UNRECORDED_REASON);
folded
.entry((
Surface::Assertion,
assertion.to_string(),
reason.to_string(),
))
.or_default()
.push(row.test_id.clone());
}
}
folded
.into_iter()
.map(|((surface, check, reason), mut test_ids)| {
test_ids.sort();
NotExercised {
surface,
check,
reason,
test_ids,
}
})
.collect()
}
pub fn warnings(results: &[TestResultRow]) -> Vec<String> {
collect(results).iter().map(NotExercised::render).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::TestStatus;
fn row(test_id: &str, metrics: serde_json::Value) -> TestResultRow {
TestResultRow {
test_id: test_id.to_string(),
status: TestStatus::Pass,
score: Some(1.0),
cached: false,
message: "ok".into(),
details: serde_json::json!({ "metrics": metrics }),
duration_ms: Some(1),
fingerprint: None,
skip_reason: None,
attempts: None,
error_policy_applied: None,
}
}
fn metric(exercised: Exercised, reason: Option<&str>) -> serde_json::Value {
let details = match reason {
Some(r) => serde_json::json!({ "reason": r }),
None => serde_json::json!({}),
};
serde_json::json!({
"score": 1.0,
"passed": true,
"unstable": false,
"exercised": exercised.label(),
"details": details
})
}
#[test]
fn a_requested_metric_that_evaluated_nothing_is_reported() {
let rows = vec![row(
"t1",
serde_json::json!({
"sequence_valid": metric(Exercised::NotExercised, Some("no tool calls in the trace"))
}),
)];
let found = collect(&rows);
assert_eq!(found.len(), 1);
assert_eq!(found[0].surface, Surface::Metric);
assert_eq!(found[0].check, "sequence_valid");
assert_eq!(found[0].reason, "no tool calls in the trace");
assert_eq!(found[0].test_ids, vec!["t1"]);
}
#[test]
fn a_not_applicable_metric_is_not_a_finding() {
let rows = vec![row(
"t1",
serde_json::json!({
"must_contain": metric(Exercised::NotApplicable, None),
"regex_match": metric(Exercised::NotApplicable, None),
"semantic": metric(Exercised::Exercised, None)
}),
)];
assert!(collect(&rows).is_empty());
}
#[test]
fn the_same_metric_across_tests_folds_into_one_finding() {
let m = || {
serde_json::json!({
"tool_output_valid": metric(Exercised::NotExercised, Some("no output schemas configured"))
})
};
let rows = vec![row("t2", m()), row("t1", m()), row("t3", m())];
let found = collect(&rows);
assert_eq!(found.len(), 1);
assert_eq!(found[0].test_ids, vec!["t1", "t2", "t3"], "sorted");
}
#[test]
fn one_metric_with_two_reasons_is_two_findings() {
let rows = vec![
row(
"t1",
serde_json::json!({ "seq": metric(Exercised::NotExercised, Some("no tool calls")) }),
),
row(
"t2",
serde_json::json!({ "seq": metric(Exercised::NotExercised, Some("no policy")) }),
),
];
assert_eq!(collect(&rows).len(), 2);
}
#[test]
fn a_missing_reason_does_not_drop_the_finding() {
let rows = vec![row(
"t1",
serde_json::json!({ "seq": metric(Exercised::NotExercised, None) }),
)];
let found = collect(&rows);
assert_eq!(found.len(), 1);
assert_eq!(found[0].reason, UNRECORDED_REASON);
}
#[test]
fn a_row_without_metrics_is_skipped() {
let mut r = row("t1", serde_json::json!({}));
r.details = serde_json::json!({ "prompt": "hello" });
assert!(collect(&[r]).is_empty());
}
#[test]
fn the_rendered_warning_names_the_code_metric_count_and_reason() {
let f = NotExercised {
surface: Surface::Metric,
check: "sequence_valid".into(),
reason: "no tool calls in the trace".into(),
test_ids: vec!["t1".into(), "t2".into()],
};
let line = f.render();
assert!(line.starts_with("W_METRIC_NOT_EXERCISED: "), "{line}");
assert!(line.contains("sequence_valid"), "{line}");
assert!(line.contains("2 test(s)"), "{line}");
assert!(line.contains("no tool calls in the trace"), "{line}");
assert!(line.contains("t1, t2"), "{line}");
}
#[test]
fn a_long_test_list_is_bounded_and_counts_the_remainder() {
let f = NotExercised {
surface: Surface::Metric,
check: "seq".into(),
reason: "no tool calls".into(),
test_ids: (1..=10).map(|i| format!("t{i:02}")).collect(),
};
let line = f.render();
assert!(line.contains("t01, t02, t03 and 7 more"), "{line}");
assert_eq!(line.lines().count(), 1, "one hole is one line");
}
#[test]
fn an_assertion_cover_is_collected_under_the_assertion_code() {
let mut r = row("t1", serde_json::json!({}));
r.details[ASSERTIONS_NOT_EXERCISED] = serde_json::json!([{
"assertion": "trace_must_not_call_tool",
"reason": "the agent was never offered `delete_repository`, so no trace could have called it"
}]);
let found = collect(&[r]);
assert_eq!(found.len(), 1);
assert_eq!(found[0].surface, Surface::Assertion);
assert_eq!(found[0].check, "trace_must_not_call_tool");
assert!(found[0].render().starts_with("W_ASSERTION_NOT_EXERCISED: "));
}
#[test]
fn a_name_shared_by_both_surfaces_does_not_fold_together() {
let mut r = row(
"t1",
serde_json::json!({
"sequence_valid": metric(Exercised::NotExercised, Some("no sequence configured"))
}),
);
r.details[ASSERTIONS_NOT_EXERCISED] = serde_json::json!([{
"assertion": "sequence_valid",
"reason": "no sequence configured"
}]);
let found = collect(&[r]);
assert_eq!(found.len(), 2, "{found:?}");
assert_eq!(found[0].surface, Surface::Metric);
assert_eq!(found[1].surface, Surface::Assertion);
assert_ne!(found[0].render(), found[1].render());
}
#[test]
fn a_nameless_cover_is_skipped() {
let mut r = row("t1", serde_json::json!({}));
r.details[ASSERTIONS_NOT_EXERCISED] = serde_json::json!([{ "reason": "something" }]);
assert!(collect(&[r]).is_empty());
}
#[test]
fn a_row_without_assertion_covers_reports_nothing() {
let r = row("t1", serde_json::json!({}));
assert!(collect(&[r]).is_empty());
}
#[test]
fn the_label_compared_against_is_the_one_the_runner_writes() {
assert_eq!(Exercised::NotExercised.label(), "not_exercised");
let rows = vec![row(
"t1",
serde_json::json!({ "seq": {
"exercised": Exercised::NotExercised.label(),
"details": { "reason": "no tool calls" }
}}),
)];
assert_eq!(collect(&rows).len(), 1);
}
}