use super::*;
use trusty_common::intent_source::{
Method, MethodKind, Precedence, ResolvedIntent, TicketData, TicketRef,
};
struct MockFetcher {
body: String,
fail: bool,
}
#[async_trait]
impl TicketFetcher for MockFetcher {
async fn fetch(
&self,
_owner: &str,
_repo: &str,
ticket_id: &str,
) -> Result<TicketData, IsrError> {
if self.fail {
return Err(IsrError::TicketFetch("mock fetch failure".to_string()));
}
Ok(TicketData {
id: ticket_id.to_string(),
title: "Mock ticket".to_string(),
body: self.body.clone(),
url: Some("https://example/issues/1325".to_string()),
backend: "github".to_string(),
})
}
}
struct NoSpecLookup;
impl SpecLookup for NoSpecLookup {
fn load(&self, _spec_file: &str) -> Option<String> {
None
}
}
const TEST_PR_NUMBER: u64 = 1359;
fn subject_with_body(body: &str) -> ReviewSubject {
ReviewSubject {
owner: "bobmatnyc".to_string(),
repo: "trusty-tools".to_string(),
title: "Add pagination".to_string(),
body: body.to_string(),
changed_files: vec!["src/page.rs".to_string()],
identifiers: vec![],
pr_number: TEST_PR_NUMBER,
}
}
fn source_with_fetcher(fetcher: MockFetcher) -> ConformanceSource {
ConformanceSource::new(
true,
RetrievalMode::Live,
Box::new(fetcher),
Box::new(NoSpecLookup),
)
}
#[tokio::test]
async fn gather_renders_ticket_method() {
let fetcher = MockFetcher {
body: "Implement listing. Method: use cursor-based pagination, not offset.".to_string(),
fail: false,
};
let src = source_with_fetcher(fetcher);
let subject = subject_with_body("Closes #1325 — add the listing endpoint.");
let section = src.gather(&subject).await.expect("gather must not error");
assert_eq!(section.heading, "Intended method (ticket/spec)");
assert!(
!section.snippets.is_empty(),
"a ticket with a prescribed method must render a non-empty section"
);
let rendered = section
.snippets
.iter()
.filter_map(|s| s.body.clone())
.collect::<Vec<_>>()
.join(" ");
assert!(
rendered.to_lowercase().contains("cursor"),
"the prescribed method text must be surfaced: {rendered}"
);
}
#[tokio::test]
async fn gather_fail_open_on_unresolved() {
let src = source_with_fetcher(MockFetcher {
body: String::new(),
fail: true,
});
let subject = subject_with_body("Closes #1325");
let section = src.gather(&subject).await.expect("fail-open: Ok, not Err");
assert!(
section.snippets.is_empty(),
"an unresolved ISR must render an EMPTY section (AC-11 fail-open)"
);
}
#[tokio::test]
async fn gather_no_linkage_renders_empty() {
let src = source_with_fetcher(MockFetcher {
body: "use cursor pagination".to_string(),
fail: false,
});
let subject = subject_with_body("A PR with no ticket reference at all.");
let section = src.gather(&subject).await.expect("Ok");
assert!(
section.snippets.is_empty(),
"no ticket linkage → empty section (no intent to conform to)"
);
}
#[tokio::test]
async fn gather_gap_renders_empty() {
let src = source_with_fetcher(MockFetcher {
body: "Please add a feature flag. Thanks!".to_string(),
fail: false,
});
let subject = subject_with_body("Closes #1325");
let section = src.gather(&subject).await.expect("Ok");
assert!(
section.snippets.is_empty(),
"a ticket with no prescribed method is a gap (M3) → empty section (AC-9)"
);
}
#[test]
fn render_stale_spec_advisory() {
let intent = ResolvedIntent {
ticket: Some(TicketRef {
id: "#1325".to_string(),
title: "t".to_string(),
url: None,
backend: "github".to_string(),
}),
ticket_method: Some(Method {
text: "add dependency X".to_string(),
kind: MethodKind::Approach,
source_excerpt: "add dependency X".to_string(),
}),
spec_section: None,
spec_method: Some(Method {
text: "no new dependencies".to_string(),
kind: MethodKind::Constraint,
source_excerpt: "no new dependencies".to_string(),
}),
precedence_winner: Precedence::Ticket,
conflict: true,
stale_spec: true,
unresolved: None,
};
let section = ConformanceSource::render_section(&intent);
assert_eq!(
section.snippets.len(),
2,
"ticket method + stale-spec advisory"
);
let advisory = section
.snippets
.iter()
.any(|s| s.title.to_lowercase().contains("stale"));
assert!(
advisory,
"the conflicting spec must be rendered as a stale advisory (M4)"
);
}
#[test]
fn render_unresolved_is_empty() {
let intent = ResolvedIntent::unresolved("ticket fetch failed");
let section = ConformanceSource::render_section(&intent);
assert!(section.snippets.is_empty());
}
fn ticket_intent(m: &str) -> ResolvedIntent {
ResolvedIntent {
ticket: Some(TicketRef {
id: "#1362".to_string(),
title: "t".to_string(),
url: None,
backend: "github".to_string(),
}),
ticket_method: Some(Method {
text: m.to_string(),
kind: MethodKind::Approach,
source_excerpt: m.to_string(),
}),
spec_section: None,
spec_method: None,
precedence_winner: Precedence::Ticket,
conflict: false,
stale_spec: false,
unresolved: None,
}
}
#[test]
fn would_flag_true_for_prescribed_method() {
let intent = ticket_intent("use cursor-based pagination");
assert!(
ConformanceSource::would_flag(&intent),
"a prescribed method must be surfaced (M5 would-flag)"
);
}
#[test]
fn would_flag_false_for_gap() {
assert!(
!ConformanceSource::would_flag(&ResolvedIntent::none()),
"a gap (M3) surfaces no method → no finding possible"
);
}
#[test]
fn would_flag_false_for_unresolved() {
assert!(
!ConformanceSource::would_flag(&ResolvedIntent::unresolved("fetch failed")),
"an unresolved intent is fail-open → no finding (AC-11)"
);
}
#[tokio::test]
async fn semantic_mode_errors() {
let src = ConformanceSource::new(
true,
RetrievalMode::Semantic,
Box::new(MockFetcher {
body: String::new(),
fail: false,
}),
Box::new(NoSpecLookup),
);
let subject = subject_with_body("Closes #1325");
let err = src.gather(&subject).await.unwrap_err();
assert!(matches!(
err,
ContextSourceError::SemanticNotImplemented { .. }
));
}
#[tokio::test]
async fn gather_local_diff_renders_empty() {
let src = source_with_fetcher(MockFetcher {
body: "use cursor pagination".to_string(),
fail: false,
});
let subject = ReviewSubject::default(); let section = src.gather(&subject).await.expect("Ok");
assert!(section.snippets.is_empty());
}
#[test]
fn from_config_respects_explicit_disable() {
let cfg_off = crate::integrations::context::SourceConfig {
enabled: Some(false),
mode: RetrievalMode::Live,
};
let src = ConformanceSource::from_config(&cfg_off, RunMode::Cli, ReviewConfig::load(None));
assert!(
!src.is_enabled(),
"explicit disable must keep the source off"
);
let cfg_default = crate::integrations::context::SourceConfig::default();
let src2 = ConformanceSource::from_config(&cfg_default, RunMode::Cli, ReviewConfig::load(None));
assert!(
!src2.is_enabled(),
"default conformance source is DISABLED (no auto-enable)"
);
}
#[test]
fn from_config_respects_explicit_enable() {
let cfg_on = crate::integrations::context::SourceConfig {
enabled: Some(true),
mode: RetrievalMode::Live,
};
let src = ConformanceSource::from_config(&cfg_on, RunMode::Cli, ReviewConfig::load(None));
assert!(src.is_enabled(), "explicit enable must turn the source on");
assert_eq!(src.name(), "conformance");
}
#[test]
fn query_carries_pr_number() {
let subject = subject_with_body("Closes #1325");
let query = ConformanceSource::build_query(&subject).expect("owner/repo present → Some");
match query {
IntentQuery::Pr { pr_number, .. } => {
assert_eq!(
pr_number, TEST_PR_NUMBER,
"the real PR number must be threaded, not hard-coded 0"
);
}
other => panic!("build_query must produce IntentQuery::Pr, got {other:?}"),
}
}
#[test]
fn query_none_without_owner_repo() {
let subject = ReviewSubject {
owner: String::new(),
repo: String::new(),
..subject_with_body("Closes #1325")
};
assert!(
ConformanceSource::build_query(&subject).is_none(),
"no owner/repo (local-diff) must yield None"
);
}