use std::collections::HashMap;
use std::sync::Arc;
use serial_test::serial;
use trusty_common::credentials::{KeyStore, MemoryKeyStore};
use trusty_common::inference::test_support::ScriptedAdapter;
use trusty_common::inference::{
capabilities, AssistantMessage, ChatChoice, ChatResponse, InferenceError, ProviderId,
UsageBlock,
};
use crate::profile::test_support::RecordingAdapter;
use super::{
build_period_request, build_period_user_message, parse_period_findings, period_findings_schema,
period_reviewer_system_prompt, severity_to_effort, PeriodReview, PeriodReviewer,
PeriodRunSummary, PERIOD_FINDINGS_SCHEMA_NAME, PERIOD_REVIEWER_MAX_TOKENS,
PERIOD_REVIEWER_TEMPERATURE,
};
use crate::profile::types::{
AuthorPeriodSummary, Effort, PeriodBatch, SampledDiff, TokenCostSummary,
};
use crate::profile::ProfileError;
fn make_batch() -> PeriodBatch {
PeriodBatch::from_stats(AuthorPeriodSummary {
period_label: "2026-Q1".to_string(),
since: "2026-01-01".to_string(),
until: "2026-03-31".to_string(),
commit_count: 5,
categories: HashMap::from([("feature".to_string(), 3u64)]),
effort_histogram: HashMap::from([("M".to_string(), 5u32)]),
quality_score: 3.5,
ticketed_pct: 0.6,
pr_metrics: crate::report::drilldown::PrMetrics {
total: 2,
merged: 2,
avg_cycle_time_hours: Some(24.0),
median_cycle_time_hours: None,
p95_cycle_time_hours: None,
},
repositories: vec!["acme/api".to_string()],
})
}
const JSON_RESPONSE: &str = r#"
The commits show some error handling gaps.
```json
{
"findings": [
{
"kind": "error_handling",
"description": "Missing error propagation in async function.",
"suggestion": "Use ? operator or handle the error explicitly.",
"confidence": 0.85,
"file": "src/handler.rs",
"severity": "medium"
},
{
"kind": "security",
"description": "SQL query uses string concatenation.",
"suggestion": "Use parameterised queries.",
"confidence": 0.92,
"file": "src/db.rs",
"severity": "high"
}
]
}
```
"#;
#[test]
fn batch_reviewer_parses_findings_from_json() {
let findings = parse_period_findings(JSON_RESPONSE, "2026-Q1");
assert_eq!(findings.len(), 2, "should parse 2 findings");
assert_eq!(findings[0].period_label, "2026-Q1");
assert_eq!(findings[0].finding.kind, "error_handling");
assert_eq!(findings[0].finding.effort, Effort::Medium);
assert_eq!(findings[1].finding.kind, "security");
assert_eq!(findings[1].finding.effort, Effort::High);
assert!(
findings[0].trend_tag.is_none(),
"trend_tag must be None until the synthesizer has seen every period"
);
}
#[test]
fn batch_reviewer_parses_direct_json() {
const DIRECT_JSON: &str = r#"{"findings":[{"kind":"error_handling","description":"Missing error propagation.","suggestion":"Use ? operator.","confidence":0.85,"file":"src/lib.rs","severity":"medium"}]}"#;
let findings = parse_period_findings(DIRECT_JSON, "2026-Q1");
assert_eq!(findings.len(), 1, "direct JSON must parse 1 finding");
assert_eq!(findings[0].finding.kind, "error_handling");
assert_eq!(findings[0].period_label, "2026-Q1");
}
#[test]
fn batch_reviewer_parses_null_optionals_as_absent() {
const NULL_OPTIONALS: &str = r#"{"findings":[{"kind":"logic","description":"Off-by-one in the window bound.","suggestion":null,"confidence":null,"file":null,"severity":null}]}"#;
let findings = parse_period_findings(NULL_OPTIONALS, "2026-Q1");
assert_eq!(
findings.len(),
1,
"an explicit null must not cost the finding"
);
let f = &findings[0].finding;
assert_eq!(f.kind, "logic");
assert_eq!(
f.file, "unknown",
"a null file takes the absent-key sentinel"
);
assert_eq!(f.suggestion, "");
assert_eq!(f.confidence, 0.0);
assert_eq!(
f.effort,
Effort::Low,
"a null severity must not inflate the finding's weight"
);
}
#[test]
fn batch_reviewer_fail_safe_on_empty_response() {
assert!(
parse_period_findings("", "2026-Q1").is_empty(),
"empty response must yield empty findings"
);
}
#[test]
fn batch_reviewer_fail_safe_on_malformed_json() {
let findings = parse_period_findings("```json\n{\"findings\": [broken\n```", "2026-Q1");
assert!(
findings.is_empty(),
"malformed JSON must yield empty findings"
);
}
#[test]
fn batch_reviewer_fail_safe_on_prose_response() {
let findings = parse_period_findings("The code looks fine to me.", "2026-Q1");
assert!(findings.is_empty(), "prose must yield empty findings");
}
#[test]
fn batch_reviewer_prompt_contains_period_label() {
let content = build_period_user_message(&make_batch());
assert!(
content.contains("2026-Q1"),
"user message must contain the period label"
);
assert!(
content.contains("Commits: 5"),
"user message must include commit count"
);
}
#[test]
fn batch_reviewer_prompt_handles_empty_diffs() {
let empty = build_period_user_message(&make_batch());
assert!(
empty.contains("no diffs available"),
"a diff-less period must say so: {empty}"
);
let mut batch = make_batch();
batch.sampled_diffs.push(SampledDiff {
sha: "abcdef1234567890".to_string(),
repository: "acme/api".to_string(),
message: "feat: add endpoint".to_string(),
diff_text: "+fn handler() {}".to_string(),
category: Some("feature".to_string()),
effort: Some("M".to_string()),
});
let with_diff = build_period_user_message(&batch);
assert!(!with_diff.contains("no diffs available"));
assert!(
with_diff.contains("+fn handler() {}"),
"diff text must reach the prompt"
);
assert!(
with_diff.contains("abcdef12"),
"the short SHA must label the diff"
);
}
#[test]
fn batch_reviewer_system_prompt_contains_schema() {
let prompt = period_reviewer_system_prompt();
assert!(
prompt.contains("findings"),
"system prompt must reference the findings field"
);
assert!(
prompt.contains("confidence"),
"system prompt must include confidence field"
);
assert!(
prompt.contains("severity"),
"system prompt must include severity field"
);
}
#[test]
fn period_findings_schema_has_findings_property() {
let schema = period_findings_schema();
assert!(schema.is_object(), "schema must be a JSON object");
assert!(
schema["properties"]["findings"].is_object(),
"schema must have a findings property"
);
}
#[test]
fn severity_to_effort_mapping() {
assert_eq!(severity_to_effort("high"), Effort::High);
assert_eq!(severity_to_effort("critical"), Effort::High);
assert_eq!(severity_to_effort("medium"), Effort::Medium);
assert_eq!(severity_to_effort("low"), Effort::Low);
assert_eq!(severity_to_effort("unknown"), Effort::Low);
}
fn scripted_response(body: &str, prompt_tokens: u32, completion_tokens: u32) -> ChatResponse {
ChatResponse {
id: "test".to_string(),
model: "test-model".to_string(),
choices: vec![ChatChoice {
message: AssistantMessage {
content: Some(body.to_string()),
tool_calls: Vec::new(),
},
finish_reason: Some("stop".to_string()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
..Default::default()
},
}
}
#[test]
fn period_request_preserves_routing_prefix() {
let req = build_period_request(
&make_batch(),
"bedrock/us.anthropic.claude-sonnet-4-5-v1:0",
false,
);
assert_eq!(
req.model, "bedrock/us.anthropic.claude-sonnet-4-5-v1:0",
"the routing prefix is trusty_common's to consume, not tga's to strip"
);
let req = build_period_request(&make_batch(), "openrouter/openai/gpt-5.4-mini", true);
assert_eq!(req.model, "openrouter/openai/gpt-5.4-mini");
}
#[test]
fn period_request_sends_no_temperature_to_bedrock() {
for model in [
"bedrock/us.anthropic.claude-sonnet-5",
"bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
"Bedrock/us.anthropic.claude-sonnet-5",
] {
let req = build_period_request(&make_batch(), model, false);
let json = serde_json::to_value(&req).expect("serialize");
assert!(json.get("temperature").is_none(), "{model}: {json}");
}
for model in ["openrouter/openai/gpt-5.4-mini", "gpt-4o-mini"] {
let req = build_period_request(&make_batch(), model, true);
assert_eq!(
req.temperature,
Some(PERIOD_REVIEWER_TEMPERATURE),
"{model}"
);
}
}
#[test]
fn period_request_sends_the_schema_through_response_schema() {
let req = build_period_request(&make_batch(), "openai/gpt-5.4-mini", true);
let directive = req
.response_schema
.as_ref()
.expect("a structured-output provider gets the real field, not prose");
assert_eq!(directive.name, PERIOD_FINDINGS_SCHEMA_NAME);
assert_eq!(directive.schema, period_findings_schema());
assert_eq!(req.messages.len(), 2, "one system turn, one user turn");
assert_eq!(req.messages[0].role, "system");
assert_eq!(req.messages[1].role, "user");
let system = req.messages[0].content.clone().unwrap_or_default();
assert!(
!system.contains("## Response schema"),
"the schema must not also be pasted into the prompt: {system}"
);
let user = req.messages[1].content.clone().unwrap_or_default();
assert!(user.contains("2026-Q1"), "the user turn carries the period");
assert_eq!(req.temperature, Some(PERIOD_REVIEWER_TEMPERATURE));
assert_eq!(req.max_tokens, Some(PERIOD_REVIEWER_MAX_TOKENS));
}
#[test]
fn period_request_falls_back_to_prose_without_the_capability() {
let req = build_period_request(&make_batch(), "bedrock/us.anthropic.claude", false);
assert!(
req.response_schema.is_none(),
"a provider without the capability must not be sent the field"
);
let system = req.messages[0].content.clone().unwrap_or_default();
assert!(
system.contains("\"findings\""),
"the schema must still reach the model: {system}"
);
}
fn reviewer_answering(body: &str, prompt_tokens: u32, completion_tokens: u32) -> PeriodReviewer {
let adapter = ScriptedAdapter::new("scripted", capabilities(ProviderId::OpenRouter))
.with_response(scripted_response(body, prompt_tokens, completion_tokens));
PeriodReviewer::with_adapter(Arc::new(adapter), "openai/gpt-5.4-mini")
}
#[tokio::test]
async fn period_reviewer_routes_through_shared_inference() {
let reviewer = reviewer_answering(JSON_RESPONSE, 1200, 340);
let mut cost = TokenCostSummary::default();
let review = reviewer.review_period(&make_batch(), &mut cost).await;
assert!(!review.was_skipped(), "the adapter answered");
assert_eq!(
review.findings.len(),
2,
"both findings must survive the round trip"
);
assert_eq!(review.findings[0].period_label, "2026-Q1");
assert_eq!(review.findings[1].finding.kind, "security");
assert_eq!(cost.input_tokens, 1200, "adapter usage must be accumulated");
assert_eq!(cost.output_tokens, 340);
}
#[tokio::test]
async fn period_reviewer_picks_the_delivery_the_adapter_supports() {
let structured = Arc::new(RecordingAdapter::new(
capabilities(ProviderId::OpenRouter),
scripted_response(JSON_RESPONSE, 10, 5),
));
let mut cost = TokenCostSummary::default();
PeriodReviewer::with_adapter(structured.clone(), "openrouter/openai/gpt-5.4-mini")
.review_period(&make_batch(), &mut cost)
.await;
let directive = structured
.only_request()
.response_schema
.expect("openrouter honours the field, so the transport must send it");
assert_eq!(directive.schema, period_findings_schema());
assert!(
!structured.only_system_turn().contains("## Response schema"),
"no prose copy alongside the field"
);
let prose = Arc::new(RecordingAdapter::new(
capabilities(ProviderId::Bedrock),
scripted_response(JSON_RESPONSE, 10, 5),
));
let mut cost = TokenCostSummary::default();
PeriodReviewer::with_adapter(prose.clone(), "bedrock/us.anthropic.claude")
.review_period(&make_batch(), &mut cost)
.await;
assert!(
prose.only_request().response_schema.is_none(),
"bedrock cannot honour the field, and sending it fails the call outright"
);
assert!(
prose.only_system_turn().contains("\"findings\""),
"the schema must still reach the model"
);
}
#[tokio::test]
async fn period_review_distinguishes_provider_failure_from_a_clean_period() {
let mut clean_cost = TokenCostSummary::default();
let clean = reviewer_answering(r#"{"findings":[]}"#, 900, 12)
.review_period(&make_batch(), &mut clean_cost)
.await;
let failed_reviewer = PeriodReviewer::with_adapter(
Arc::new(ScriptedAdapter::new(
"scripted",
capabilities(ProviderId::OpenRouter),
)),
"openai/gpt-5.4-mini",
);
let mut failed_cost = TokenCostSummary::default();
let failed = failed_reviewer
.review_period(&make_batch(), &mut failed_cost)
.await;
assert!(clean.findings.is_empty());
assert!(failed.findings.is_empty());
assert!(
!clean.was_skipped(),
"a model that answered 'no findings' reviewed this period"
);
assert!(
failed.was_skipped(),
"a provider failure must not read as a clean period"
);
assert!(
failed.skipped.is_some(),
"the provider error must reach the caller, not just the log"
);
assert_eq!(clean_cost.input_tokens, 900, "an answered call bills");
assert_eq!(failed_cost.input_tokens, 0, "a failed call bills nothing");
assert_eq!(failed_cost.output_tokens, 0);
}
#[test]
fn from_slug_with_store_builds_an_adapter_for_a_stored_credential() {
let store = MemoryKeyStore::new();
store.set("openrouter", "test-key").expect("seed key");
let reviewer = PeriodReviewer::from_slug_with_store("openrouter/openai/gpt-5.4-mini", &store);
assert!(
reviewer.is_ok(),
"a resolvable credential must yield an adapter: {:?}",
reviewer.err()
);
}
#[test]
#[serial]
fn from_slug_with_store_errors_when_no_credential_resolves() {
let saved = std::env::var("OPENROUTER_API_KEY").ok();
unsafe {
std::env::remove_var("OPENROUTER_API_KEY");
}
let result = PeriodReviewer::from_slug_with_store(
"openrouter/openai/gpt-5.4-mini",
&MemoryKeyStore::new(),
);
if let Some(value) = saved {
unsafe {
std::env::set_var("OPENROUTER_API_KEY", value);
}
}
match result {
Err(ProfileError::Inference(_)) => {}
Err(other) => panic!("expected ProfileError::Inference, got {other:?}"),
Ok(_) => panic!("an empty store with no env key must not resolve a credential"),
}
}
fn skipped_review() -> PeriodReview {
PeriodReview {
findings: Vec::new(),
skipped: Some(InferenceError::Transport("connection reset".to_string())),
}
}
#[test]
fn period_run_summary_separates_a_skipped_period_from_a_clean_one() {
let mut summary = PeriodRunSummary::default();
let clean_findings = summary.record(
"2026Q1",
PeriodReview {
findings: Vec::new(),
skipped: None,
},
);
let skipped_findings = summary.record("2026Q2", skipped_review());
assert!(clean_findings.is_empty());
assert!(skipped_findings.is_empty());
assert_eq!(
summary.reviewed, 1,
"only the period the model answered for counts as reviewed"
);
assert_eq!(
summary.skipped.len(),
1,
"the failed period must be recorded as skipped, not flattened into 'no findings'"
);
assert_eq!(summary.skipped[0].period_label, "2026Q2");
assert!(
summary.skipped[0].reason.contains("connection reset"),
"the provider's own reason must survive: {}",
summary.skipped[0].reason
);
assert_eq!(summary.attempted(), 2);
assert!(!summary.is_complete());
let line = summary.coverage_line();
assert!(
line.contains("1/2") && line.contains("2026Q2"),
"the coverage line must name both the shortfall and the period: {line}"
);
}
#[test]
fn period_run_summary_coverage_note_absent_when_complete() {
let mut complete = PeriodRunSummary::default();
complete.record(
"2026Q1",
PeriodReview {
findings: Vec::new(),
skipped: None,
},
);
assert!(
complete.coverage_note().is_none(),
"a complete run must not carry a coverage caveat"
);
let mut partial = PeriodRunSummary::default();
partial.record("2026Q2", skipped_review());
let note = partial
.coverage_note()
.expect("a skipped period needs a note");
assert!(
note.contains("2026Q2"),
"the note must name the period: {note}"
);
assert!(
note.contains("skipped"),
"the note must say the period was skipped: {note}"
);
}