use std::collections::HashMap;
use super::{
apply_deterministic_synthesis, apply_fallback_narrative, apply_synthesis_json,
assign_trend_tags, build_synthesizer_user_message, derive_trajectory, jaccard_similarity,
synthesis_output_schema, synthesizer_system_prompt,
};
use crate::profile::types::{
AuthorPeriodSummary, ContributorProfile, Effort, Finding, LongitudinalFinding, PeriodBatch,
Trajectory, TrendTag,
};
fn make_finding(period: &str, description: &str) -> LongitudinalFinding {
make_finding_with_confidence(period, description, 0.8)
}
fn make_finding_with_confidence(
period: &str,
description: &str,
confidence: f32,
) -> LongitudinalFinding {
LongitudinalFinding {
period_label: period.to_string(),
finding: Finding::new(
"src/lib.rs",
"error_handling",
description,
"fix it",
confidence,
Effort::Medium,
),
trend_tag: None,
}
}
fn make_profile() -> ContributorProfile {
ContributorProfile::new("alice@example.com", "Alice", "2026-01-01", "2026-12-31")
}
fn make_period(label: &str, score: f64) -> PeriodBatch {
PeriodBatch::from_stats(AuthorPeriodSummary {
period_label: label.to_string(),
since: "2026-01-01".to_string(),
until: "2026-03-31".to_string(),
commit_count: 3,
categories: HashMap::new(),
effort_histogram: HashMap::new(),
quality_score: score,
ticketed_pct: 0.5,
pr_metrics: crate::report::drilldown::PrMetrics {
total: 1,
merged: 1,
avg_cycle_time_hours: None,
median_cycle_time_hours: None,
p95_cycle_time_hours: None,
},
repositories: vec!["acme/api".to_string()],
})
}
#[test]
fn jaccard_similarity_basic() {
assert!(
(jaccard_similarity("error handling in async", "error handling in async") - 1.0).abs()
< 1e-10
);
assert!(jaccard_similarity("error handling", "completely different concept") < 0.5);
assert!((jaccard_similarity("", "") - 1.0).abs() < 1e-10);
assert!((jaccard_similarity("foo", "") - 0.0).abs() < 1e-10);
}
#[test]
fn jaccard_similarity_similar_descriptions() {
let a = "missing error propagation in async function";
let b = "missing error propagation async function handler";
let sim = jaccard_similarity(a, b);
assert!(sim >= 0.6, "similar descriptions: sim={sim:.3}");
}
#[test]
fn synthesizer_dedup_assigns_recurring() {
let findings = vec![
make_finding("2026-Q1", "missing error propagation in async handler"),
make_finding("2026-Q2", "missing error propagation in async handler"),
];
let tagged = assign_trend_tags(findings);
assert_eq!(tagged.len(), 2);
for f in &tagged {
assert_eq!(
f.trend_tag,
Some(TrendTag::Recurring),
"both should be Recurring: {:?}",
f.trend_tag
);
}
}
#[test]
fn synthesizer_dedup_assigns_new() {
let findings = vec![make_finding(
"2026-Q2",
"newly introduced SQL injection risk",
)];
let tagged = assign_trend_tags(findings);
assert_eq!(tagged[0].trend_tag, Some(TrendTag::New));
}
#[test]
fn synthesizer_dedup_assigns_resolved() {
let findings = vec![
make_finding("2026-Q1", "unreachable panic in fallback path"),
make_finding("2026-Q2", "completely unrelated memory allocation issue"),
];
let tagged = assign_trend_tags(findings);
let q1 = tagged
.iter()
.find(|f| f.period_label == "2026-Q1")
.expect("Q1 finding present");
assert_eq!(
q1.trend_tag,
Some(TrendTag::Resolved),
"Q1-only finding must be Resolved"
);
}
#[test]
fn synthesizer_dedup_assigns_worsening() {
let findings = vec![
make_finding_with_confidence("2026-Q1", "unchecked index into the buffer", 0.6),
make_finding_with_confidence("2026-Q2", "unchecked index into the buffer", 0.9),
];
let tagged = assign_trend_tags(findings);
for f in &tagged {
assert_eq!(
f.trend_tag,
Some(TrendTag::Worsening),
"rising confidence must tag Worsening, got {:?}",
f.trend_tag
);
}
}
#[test]
fn synthesizer_dedup_empty_findings() {
assert!(assign_trend_tags(Vec::new()).is_empty());
}
#[test]
fn synthesizer_trajectory_from_slope() {
let up = vec![
("Q1".to_string(), 2.0),
("Q2".to_string(), 3.0),
("Q3".to_string(), 4.0),
];
assert_eq!(derive_trajectory(&up), Trajectory::Improving);
let down = vec![
("Q1".to_string(), 4.0),
("Q2".to_string(), 3.0),
("Q3".to_string(), 2.0),
];
assert_eq!(derive_trajectory(&down), Trajectory::Declining);
let flat = vec![
("Q1".to_string(), 3.0),
("Q2".to_string(), 3.1),
("Q3".to_string(), 2.9),
];
assert_eq!(derive_trajectory(&flat), Trajectory::Stable);
assert_eq!(
derive_trajectory(&[("Q1".to_string(), 3.0)]),
Trajectory::Stable
);
assert_eq!(derive_trajectory(&[]), Trajectory::Stable);
}
#[test]
fn synthesizer_quality_trend_populated() {
let mut profile = make_profile();
let periods = vec![make_period("2026-Q1", 3.0), make_period("2026-Q2", 3.5)];
apply_deterministic_synthesis(&mut profile, vec![], &periods);
assert_eq!(profile.quality_trend.len(), 2);
assert_eq!(profile.quality_trend[0].0, "2026-Q1");
assert!((profile.quality_trend[0].1 - 3.0).abs() < f64::EPSILON);
assert_eq!(profile.quality_trend[1].0, "2026-Q2");
}
#[test]
fn synthesizer_deterministic_synthesis_tags_findings() {
let mut profile = make_profile();
let periods = vec![make_period("2026-Q1", 2.0), make_period("2026-Q2", 4.0)];
let per_period = vec![
vec![make_finding(
"2026-Q1",
"missing error propagation in async",
)],
vec![make_finding(
"2026-Q2",
"missing error propagation in async",
)],
];
apply_deterministic_synthesis(&mut profile, per_period, &periods);
assert_eq!(profile.all_findings.len(), 2, "findings must be flattened");
assert!(
profile.all_findings.iter().all(|f| f.trend_tag.is_some()),
"every finding must carry a trend tag"
);
assert_eq!(
profile.improvement_trajectory,
Trajectory::Improving,
"2.0 → 4.0 must derive Improving"
);
}
#[test]
fn synthesizer_applies_llm_result() {
let response = r#"Assessment follows.
```json
{
"strengths": ["Consistent ticket coverage", "Fast cycle times"],
"recurring_weaknesses": ["Missing error handling"],
"improvement_trajectory": "improving",
"narrative": "Alice shows strong improvement over the profile window."
}
```"#;
let mut profile = make_profile();
apply_synthesis_json(&mut profile, response);
assert_eq!(profile.strengths.len(), 2);
assert_eq!(profile.recurring_weaknesses.len(), 1);
assert_eq!(profile.improvement_trajectory, Trajectory::Improving);
assert!(profile.narrative.contains("Alice"));
}
#[test]
fn synthesizer_applies_direct_json_result() {
let direct_json = r#"{"strengths":["Good test coverage"],"recurring_weaknesses":["Error handling gaps"],"improvement_trajectory":"improving","narrative":"Bob demonstrates steady improvement."}"#;
let mut profile = make_profile();
apply_synthesis_json(&mut profile, direct_json);
assert_eq!(profile.strengths.len(), 1);
assert_eq!(profile.strengths[0], "Good test coverage");
assert_eq!(profile.improvement_trajectory, Trajectory::Improving);
assert!(profile.narrative.contains("Bob"));
}
#[test]
fn synthesizer_unparseable_response_falls_back() {
let mut profile = make_profile();
apply_synthesis_json(&mut profile, "I could not complete that request.");
assert!(
profile
.narrative
.contains("Narrative generation unavailable"),
"unparseable response must apply the fallback: {}",
profile.narrative
);
}
#[test]
fn synthesizer_ignores_unknown_trajectory() {
let mut profile = make_profile();
profile.improvement_trajectory = Trajectory::Declining;
apply_synthesis_json(
&mut profile,
r#"{"strengths":[],"recurring_weaknesses":[],"improvement_trajectory":"sideways","narrative":"n"}"#,
);
assert_eq!(
profile.improvement_trajectory,
Trajectory::Declining,
"an unknown trajectory spelling must leave the derived value alone"
);
}
#[test]
fn synthesizer_fail_safe_narrative() {
let mut profile = make_profile();
profile.all_findings = assign_trend_tags(vec![
make_finding("2026-Q1", "missing error propagation in async"),
make_finding("2026-Q2", "missing error propagation in async"),
]);
apply_fallback_narrative(&mut profile);
assert!(
!profile.narrative.is_empty(),
"fail-safe must produce a non-empty narrative"
);
assert!(
profile.narrative.contains("Alice"),
"fail-safe narrative must mention the contributor name"
);
assert!(
profile.narrative.contains("LLM call failed"),
"fail-safe narrative must indicate the failure: {}",
profile.narrative
);
assert!(
profile.narrative.contains("2 recurring issue(s)"),
"fail-safe narrative must count the recurring findings: {}",
profile.narrative
);
}
#[test]
fn synthesis_output_schema_has_expected_properties() {
let schema = synthesis_output_schema();
assert!(schema.is_object(), "schema must be a JSON object");
let props = &schema["properties"];
assert!(
props["strengths"].is_object(),
"schema must have strengths property"
);
assert!(
props["narrative"].is_object(),
"schema must have narrative property"
);
}
#[test]
fn synthesizer_system_prompt_names_output_fields() {
let prompt = synthesizer_system_prompt();
for field in [
"strengths",
"recurring_weaknesses",
"improvement_trajectory",
"narrative",
] {
assert!(prompt.contains(field), "system prompt must name {field}");
}
}
#[test]
fn synthesizer_user_message_includes_trend_and_findings() {
let mut profile = make_profile();
let periods = vec![make_period("2026-Q1", 2.0), make_period("2026-Q2", 4.0)];
apply_deterministic_synthesis(
&mut profile,
vec![
vec![make_finding(
"2026-Q1",
"missing error propagation in async",
)],
vec![make_finding(
"2026-Q2",
"missing error propagation in async",
)],
],
&periods,
);
let msg = build_synthesizer_user_message(&profile);
assert!(msg.contains("Alice"), "identity header");
assert!(msg.contains("2026-Q1"), "quality trend row");
assert!(msg.contains("error_handling: 2×"), "frequency by kind");
assert!(msg.contains("[Recurring]"), "trend tag on a sample finding");
assert!(
msg.contains("Deterministic trajectory: improving"),
"the derived trajectory must reach the prompt: {msg}"
);
}
#[test]
fn synthesizer_user_message_handles_no_findings() {
let mut profile = make_profile();
apply_deterministic_synthesis(&mut profile, vec![], &[make_period("2026-Q1", 3.0)]);
let msg = build_synthesizer_user_message(&profile);
assert!(
msg.contains("no findings extracted"),
"empty findings must be stated explicitly: {msg}"
);
}
use std::sync::Arc;
use trusty_common::inference::test_support::ScriptedAdapter;
use trusty_common::inference::{
capabilities, AssistantMessage, ChatChoice, ChatResponse, ProviderId, UsageBlock,
};
use super::{
build_synthesis_request, Synthesizer, SYNTHESIZER_MAX_TOKENS, SYNTHESIZER_TEMPERATURE,
};
fn scripted_response(body: &str) -> ChatResponse {
ChatResponse {
id: "synth-1".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: 800,
completion_tokens: 200,
total_tokens: 1000,
..Default::default()
},
}
}
fn profile_with_trend() -> ContributorProfile {
let mut p = ContributorProfile::new("a@example.com", "Alice", "2026-01-01", "2026-06-30");
p.quality_trend = vec![("2026Q1".to_string(), 3.0), ("2026Q2".to_string(), 3.9)];
p.improvement_trajectory = Trajectory::Improving;
p
}
#[tokio::test]
async fn synthesizer_transport_applies_the_models_narrative() {
let body = r#"{"strengths":["tests first"],"recurring_weaknesses":["broad error types"],
"improvement_trajectory":"improving","narrative":"Steady quarter over quarter."}"#;
let adapter = ScriptedAdapter::new("scripted", capabilities(ProviderId::OpenRouter))
.with_response(scripted_response(body));
let synth = Synthesizer::with_adapter(Arc::new(adapter), "openai/gpt-5.4-mini");
let mut profile = profile_with_trend();
let outcome = synth.synthesize(&mut profile).await;
assert!(outcome.is_none(), "the adapter answered: {outcome:?}");
assert_eq!(profile.strengths, vec!["tests first".to_string()]);
assert_eq!(
profile.recurring_weaknesses,
vec!["broad error types".to_string()]
);
assert_eq!(profile.narrative, "Steady quarter over quarter.");
assert_eq!(profile.token_cost.input_tokens, 800);
assert_eq!(profile.token_cost.output_tokens, 200);
}
#[tokio::test]
async fn synthesizer_transport_falls_back_and_reports_the_failure() {
let synth = Synthesizer::with_adapter(
Arc::new(ScriptedAdapter::new(
"scripted",
capabilities(ProviderId::OpenRouter),
)),
"openai/gpt-5.4-mini",
);
let mut profile = profile_with_trend();
let outcome = synth.synthesize(&mut profile).await;
assert!(
outcome.is_some(),
"a provider failure must be reported, not swallowed into a plausible narrative"
);
assert!(
profile
.narrative
.contains("Narrative generation unavailable"),
"the fallback must say it is one: {}",
profile.narrative
);
assert_eq!(
profile.improvement_trajectory,
Trajectory::Improving,
"the deterministic trajectory must survive a narrative failure"
);
}
#[test]
fn synthesis_request_preserves_routing_prefix_and_sampling() {
let req = build_synthesis_request(&profile_with_trend(), "bedrock/us.anthropic.claude");
assert_eq!(req.model, "bedrock/us.anthropic.claude");
assert_eq!(req.temperature, Some(SYNTHESIZER_TEMPERATURE));
assert_eq!(req.max_tokens, Some(SYNTHESIZER_MAX_TOKENS));
assert_eq!(req.messages.len(), 2, "one system turn and one user turn");
}