use std::fmt::{self, Write as _};
use async_trait::async_trait;
use wiremock::MockServer;
pub use turnframe_provider::conformance::{
Check, CheckResult, CheckStatus, ConformanceReport, ProviderFactory, RowSupport, Scenario,
StatusRow, StatusSupport, WireFixtures, payloads, run_all,
};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConformanceRunError {
#[error("the conformance suite needs its own runtime, and one could not be started: {reason}")]
RuntimeUnavailable {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConformanceGap {
#[error("check {check} failed: {detail}")]
Failed {
check: Check,
detail: String,
},
#[error(
"check {check} was skipped and is therefore unproven ({reason}); \
list it as allowed-to-skip if that is deliberate"
)]
UnexpectedSkip {
check: Check,
reason: String,
},
}
pub async fn run<F: ProviderFactory, W: WireFixtures>(
factory: &F,
fixtures: &W,
) -> ConformanceReport {
run_all(factory, fixtures).await
}
pub fn run_blocking<F: ProviderFactory, W: WireFixtures>(
factory: &F,
fixtures: &W,
) -> Result<ConformanceReport, ConformanceRunError> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| ConformanceRunError::RuntimeUnavailable {
reason: error.to_string(),
})?;
Ok(runtime.block_on(run_all(factory, fixtures)))
}
pub fn accept(
report: &ConformanceReport,
allow_skipped: &[Check],
) -> Result<(), Vec<ConformanceGap>> {
let mut gaps = Vec::new();
for result in &report.results {
match result.status {
CheckStatus::Passed => {}
CheckStatus::Failed => gaps.push(ConformanceGap::Failed {
check: result.check,
detail: result.detail.clone().unwrap_or_default(),
}),
CheckStatus::Skipped => {
if !allow_skipped.contains(&result.check) {
gaps.push(ConformanceGap::UnexpectedSkip {
check: result.check,
reason: result.detail.clone().unwrap_or_default(),
});
}
}
}
}
if gaps.is_empty() { Ok(()) } else { Err(gaps) }
}
#[must_use]
pub fn describe(report: &ConformanceReport, gaps: &[ConformanceGap]) -> String {
let mut out = format!("{report}");
for gap in gaps {
let _ = write!(out, "\n{gap}");
}
out
}
pub struct DeclaredRows<W> {
fixtures: W,
declarations: Vec<(Check, String)>,
}
impl<W> DeclaredRows<W> {
#[must_use]
pub const fn new(fixtures: W) -> Self {
Self {
fixtures,
declarations: Vec::new(),
}
}
#[must_use]
pub fn not_producible(mut self, check: Check, reason: impl Into<String>) -> Self {
if !self.declarations.iter().any(|(row, _)| *row == check) {
self.declarations.push((check, reason.into()));
}
self
}
#[must_use]
pub fn declared(&self) -> Vec<Check> {
self.declarations.iter().map(|(check, _)| *check).collect()
}
#[must_use]
pub const fn fixtures(&self) -> &W {
&self.fixtures
}
fn support(&self, check: Check) -> Option<RowSupport> {
self.declarations
.iter()
.find(|(row, _)| *row == check)
.map(|(_, reason)| RowSupport::not_producible(reason.clone()))
}
}
impl<W> fmt::Debug for DeclaredRows<W> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeclaredRows")
.field(
"declared",
&self
.declarations
.iter()
.map(|(check, _)| check.as_str())
.collect::<Vec<_>>(),
)
.finish_non_exhaustive()
}
}
#[async_trait]
impl<W: WireFixtures> WireFixtures for DeclaredRows<W> {
async fn mount(&self, server: &MockServer, scenario: Scenario) {
self.fixtures.mount(server, scenario).await;
}
fn status_support(&self, row: StatusRow) -> RowSupport {
self.support(Check::StatusMapping(row))
.unwrap_or_else(|| self.fixtures.status_support(row))
}
fn feature_support(&self, check: Check) -> RowSupport {
self.support(check)
.unwrap_or_else(|| self.fixtures.feature_support(check))
}
}
#[macro_export]
macro_rules! provider_conformance_suite {
(
name: $name:ident,
factory: $factory:expr,
fixtures: $fixtures:expr $(,)?
) => {
$crate::provider_conformance_suite! {
name: $name,
factory: $factory,
fixtures: $fixtures,
allow_skipped: [],
not_producible: [],
}
};
(
name: $name:ident,
factory: $factory:expr,
fixtures: $fixtures:expr,
allow_skipped: [$($check:ident $(($row:ident))?),* $(,)?] $(,)?
) => {
$crate::provider_conformance_suite! {
name: $name,
factory: $factory,
fixtures: $fixtures,
allow_skipped: [$($check $(($row))?),*],
not_producible: [],
}
};
(
name: $name:ident,
factory: $factory:expr,
fixtures: $fixtures:expr,
not_producible: [
$($declared:ident $(($declared_row:ident))? => $reason:expr),* $(,)?
] $(,)?
) => {
$crate::provider_conformance_suite! {
name: $name,
factory: $factory,
fixtures: $fixtures,
allow_skipped: [],
not_producible: [$($declared $(($declared_row))? => $reason),*],
}
};
(
name: $name:ident,
factory: $factory:expr,
fixtures: $fixtures:expr,
allow_skipped: [$($check:ident $(($row:ident))?),* $(,)?],
not_producible: [
$($declared:ident $(($declared_row:ident))? => $reason:expr),* $(,)?
] $(,)?
) => {
#[test]
fn $name() {
let fixtures = $crate::providers::conformance::DeclaredRows::new($fixtures)
$(
.not_producible(
$crate::providers::conformance::Check::$declared
$(($crate::providers::conformance::StatusRow::$declared_row))?,
$reason,
)
)*;
let report = match $crate::providers::conformance::run_blocking(&$factory, &fixtures) {
Ok(report) => report,
Err(error) => panic!("{error}"),
};
let allowed = {
let mut rows = fixtures.declared();
rows.extend_from_slice(&[
$(
$crate::providers::conformance::Check::$check
$(($crate::providers::conformance::StatusRow::$row))?
),*
]);
rows
};
if let Err(gaps) = $crate::providers::conformance::accept(&report, &allowed) {
panic!(
"{}",
$crate::providers::conformance::describe(&report, &gaps)
);
}
}
};
}
#[macro_export]
macro_rules! provider_conformance_report {
(factory: $factory:expr, fixtures: $fixtures:expr $(,)?) => {
match $crate::providers::conformance::run_blocking(&$factory, &$fixtures) {
Ok(report) => report,
Err(error) => panic!("{error}"),
}
};
}
#[cfg(test)]
mod tests {
use super::*;
use turnframe_provider::ids::{ModelKey, ProviderKey};
fn report(results: Vec<CheckResult>) -> ConformanceReport {
let mut report = ConformanceReport::new(ProviderKey::from("p"), ModelKey::from("m"));
for result in results {
report.push(result);
}
report
}
#[test]
fn a_clean_report_is_accepted() {
let report = report(vec![
CheckResult::passed(Check::MalformedJson),
CheckResult::passed(Check::Timeout),
]);
assert_eq!(accept(&report, &[]), Ok(()));
}
#[test]
fn a_failure_is_reported_with_its_detail() {
let report = report(vec![CheckResult::failed(Check::Timeout, "answered late")]);
let gaps = accept(&report, &[]).unwrap_err();
assert_eq!(
gaps,
vec![ConformanceGap::Failed {
check: Check::Timeout,
detail: "answered late".to_owned(),
}]
);
assert!(describe(&report, &gaps).contains("check timeout failed"));
}
#[test]
fn a_skip_is_refused_unless_it_was_declared() {
let report = report(vec![CheckResult::skipped(
Check::StreamingReconstruction,
"the profile declares no streaming",
)]);
assert!(report.passed(), "the suite itself tolerates a skip");
let gaps = accept(&report, &[]).unwrap_err();
assert!(matches!(gaps[0], ConformanceGap::UnexpectedSkip { .. }));
assert_eq!(accept(&report, &[Check::StreamingReconstruction]), Ok(()));
}
struct InnerFixtures;
#[async_trait]
impl WireFixtures for InnerFixtures {
async fn mount(&self, _server: &MockServer, _scenario: Scenario) {}
fn feature_support(&self, check: Check) -> RowSupport {
match check {
Check::Refusal => RowSupport::not_producible("this gateway has no refusal channel"),
_ => RowSupport::Mounted,
}
}
fn status_support(&self, row: StatusRow) -> RowSupport {
match row {
StatusRow::ContentFilter => {
RowSupport::not_producible("nothing here inspects a prompt")
}
_ => RowSupport::Mounted,
}
}
}
#[test]
fn a_declaration_answers_both_hooks_and_reaches_the_gate() {
let fixtures = DeclaredRows::new(InnerFixtures)
.not_producible(Check::StreamingIncremental, "no streaming route")
.not_producible(
Check::StatusMapping(StatusRow::RequestTimeout),
"the gateway answers 504",
);
assert_eq!(
fixtures
.feature_support(Check::StreamingIncremental)
.reason(),
Some("no streaming route")
);
assert_eq!(
fixtures.status_support(StatusRow::RequestTimeout).reason(),
Some("the gateway answers 504")
);
assert_eq!(
fixtures.declared(),
vec![
Check::StreamingIncremental,
Check::StatusMapping(StatusRow::RequestTimeout),
]
);
}
#[test]
fn a_row_the_wrapper_says_nothing_about_falls_through_to_the_fixtures() {
let fixtures = DeclaredRows::new(InnerFixtures)
.not_producible(Check::StreamingIncremental, "no streaming route");
assert_eq!(
fixtures.feature_support(Check::Refusal).reason(),
Some("this gateway has no refusal channel")
);
assert_eq!(
fixtures.status_support(StatusRow::ContentFilter).reason(),
Some("nothing here inspects a prompt")
);
assert_eq!(
fixtures.feature_support(Check::RateLimit),
RowSupport::Mounted
);
assert_eq!(
fixtures.status_support(StatusRow::Forbidden),
RowSupport::Mounted
);
assert_eq!(fixtures.declared(), vec![Check::StreamingIncremental]);
assert!(format!("{fixtures:?}").contains("streaming_incremental"));
}
#[test]
fn the_first_reason_given_for_a_row_is_the_one_it_keeps() {
let fixtures = DeclaredRows::new(InnerFixtures)
.not_producible(Check::RateLimit, "nothing meters this deployment")
.not_producible(Check::RateLimit, "on second thoughts, something might");
assert_eq!(
fixtures.feature_support(Check::RateLimit).reason(),
Some("nothing meters this deployment")
);
assert_eq!(fixtures.declared(), vec![Check::RateLimit]);
}
#[test]
fn wrapping_fixtures_with_nothing_to_declare_changes_nothing() {
let fixtures = DeclaredRows::new(InnerFixtures);
assert!(fixtures.declared().is_empty());
assert_eq!(
fixtures.fixtures().feature_support(Check::Refusal).reason(),
Some("this gateway has no refusal channel")
);
assert_eq!(
fixtures.feature_support(Check::Refusal),
InnerFixtures.feature_support(Check::Refusal)
);
assert_eq!(
fixtures.status_support(StatusRow::ContentFilter),
InnerFixtures.status_support(StatusRow::ContentFilter)
);
}
#[test]
fn failures_and_skips_are_reported_together_in_check_order() {
let report = report(vec![
CheckResult::skipped(Check::StreamingReconstruction, "no streaming"),
CheckResult::failed(Check::Timeout, "answered late"),
]);
let gaps = accept(&report, &[]).unwrap_err();
assert_eq!(gaps.len(), 2);
assert!(matches!(gaps[0], ConformanceGap::UnexpectedSkip { .. }));
assert!(matches!(gaps[1], ConformanceGap::Failed { .. }));
}
}