use super::project;
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::{FieldDef, FunctionDef, PrimitiveType, TypeDef, TypeRef};
use crate::e2e::config::{CallConfig, E2eConfig};
use crate::e2e::fixture::{Fixture, FixtureGroup};
use std::collections::HashSet;
const ASSERTION_TOKENS: &[&str] = &["assert", "fail("];
const EXPLICIT_SKIP_TOKENS: &[&str] = &["@Disabled", "assumeTrue("];
const COMMENT_OPENERS: &[&str] = &["//", "/*", "*"];
struct EmittedTest {
name: String,
head: String,
body: String,
}
fn line_is_comment(line: &str) -> bool {
let trimmed = line.trim();
COMMENT_OPENERS.iter().any(|opener| trimmed.starts_with(opener))
}
fn executable_lines(body: &str) -> Vec<&str> {
body.lines()
.filter(|line| !line.trim().is_empty() && !line_is_comment(line))
.collect()
}
fn emitted_tests(source: &str) -> Vec<EmittedTest> {
let mut tests = Vec::new();
for chunk in source.split("@Test").skip(1) {
let Some(fun_at) = chunk.find("fun ") else {
continue;
};
let Some(brace_at) = chunk[fun_at..].find('{').map(|offset| fun_at + offset) else {
continue;
};
let name = chunk[fun_at + "fun ".len()..]
.split(|c: char| c == '(' || c.is_whitespace())
.next()
.unwrap_or_default()
.to_string();
tests.push(EmittedTest {
name,
head: chunk[..brace_at].to_string(),
body: chunk[brace_at..].to_string(),
});
}
tests
}
fn assertion_free_tests(files: &[crate::core::backend::GeneratedFile]) -> (usize, Vec<String>) {
let mut total = 0;
let mut offenders = Vec::new();
for file in files.iter().filter(|f| f.path.extension().is_some_and(|e| e == "kt")) {
for test in emitted_tests(&file.content) {
total += 1;
let declares_skip = EXPLICIT_SKIP_TOKENS
.iter()
.any(|token| test.head.contains(token) || test.body.contains(token));
if declares_skip {
continue;
}
let asserts = executable_lines(&test.body)
.iter()
.any(|line| ASSERTION_TOKENS.iter().any(|token| line.contains(token)));
if !asserts {
offenders.push(format!("{}::{}", file.path.display(), test.name));
}
}
}
(total, offenders)
}
fn sample_ir() -> (Vec<TypeDef>, Vec<FunctionDef>) {
let type_defs = vec![TypeDef {
name: "SampleReport".into(),
fields: vec![
FieldDef {
name: "label".into(),
ty: TypeRef::String,
..FieldDef::default()
},
FieldDef {
name: "item_count".into(),
ty: TypeRef::Primitive(PrimitiveType::I64),
..FieldDef::default()
},
],
..TypeDef::default()
}];
let functions = vec![FunctionDef {
name: "build_report".into(),
return_type: TypeRef::Named("SampleReport".into()),
..FunctionDef::default()
}];
(type_defs, functions)
}
fn fixture(id: &str, assertions: serde_json::Value) -> Fixture {
serde_json::from_value(serde_json::json!({
"id": id,
"description": format!("sample fixture {id}"),
"input": {"source": "alpha"},
"assertions": assertions,
"docs": {"topic": "smoke", "stem": id},
}))
.expect("fixture must parse")
}
fn sample_groups() -> Vec<FixtureGroup> {
vec![
FixtureGroup {
category: "smoke".into(),
fixtures: vec![
fixture(
"smoke_label_matches",
serde_json::json!([{"type": "equals", "field": "label", "value": "alpha"}]),
),
fixture(
"smoke_item_count_positive",
serde_json::json!([{"type": "greater_than", "field": "item_count", "value": 0}]),
),
],
},
FixtureGroup {
category: "reporting".into(),
fixtures: vec![fixture(
"reporting_succeeds",
serde_json::json!([{"type": "not_error"}]),
)],
},
]
}
fn sample_e2e_config() -> E2eConfig {
E2eConfig {
call: CallConfig {
function: "build_report".into(),
result_var: "report".into(),
..CallConfig::default()
},
result_fields: HashSet::from(["label".to_string(), "item_count".to_string()]),
..E2eConfig::default()
}
}
#[test]
fn every_emitted_kotlin_android_test_asserts_or_reports_itself_skipped() {
let (type_defs, functions) = sample_ir();
let groups = sample_groups();
let declared_fixtures: usize = groups.iter().map(|group| group.fixtures.len()).sum();
let config = ResolvedCrateConfig {
name: "sample_kit".into(),
..ResolvedCrateConfig::default()
};
let files = project::generate(&groups, &sample_e2e_config(), &config, &type_defs, &[], &functions)
.expect("kotlin_android e2e generation succeeds");
let (total, offenders) = assertion_free_tests(&files);
assert!(
total >= declared_fixtures,
"expected at least one emitted @Test per declared fixture ({declared_fixtures}); found \
{total} across {} generated file(s). A guard that scans zero tests proves nothing.",
files.len()
);
assert!(
offenders.is_empty(),
"{} of {total} emitted kotlin_android test(s) run the call and check nothing — such a test \
passes whatever the binding does. Emit a real assertion, or an explicit skip that reports \
as skipped:\n {}",
offenders.len(),
offenders.join("\n ")
);
}
#[test]
fn the_detector_catches_the_shape_this_guard_exists_for() {
let historical = crate::core::backend::GeneratedFile {
path: std::path::PathBuf::from("src/androidTest/kotlin/dev/sample/e2e/SmokeTest.kt"),
content: "package dev.sample.e2e\n\n\
import androidx.test.ext.junit.runners.AndroidJUnit4\n\
import org.junit.Test\n\
import org.junit.runner.RunWith\n\n\
@RunWith(AndroidJUnit4::class)\n\
class SmokeTest {\n\n\
\x20 @Test\n\
\x20 fun test_smoke_label_matches() {\n\
\x20 val client = SampleFacade()\n\
\x20 val report = client.buildReport(/* fixture: smoke_label_matches */)\n\
\x20 }\n\n\
}\n"
.to_string(),
generated_header: true,
};
let (total, offenders) = assertion_free_tests(std::slice::from_ref(&historical));
assert_eq!(total, 1, "the detector must see the one @Test in the sample");
assert_eq!(
offenders.len(),
1,
"the detector must flag a body that calls and asserts nothing"
);
assert!(
offenders[0].ends_with("::test_smoke_label_matches"),
"the offender must be named so a failure is actionable, got: {offenders:?}"
);
}
#[test]
fn an_explicitly_disabled_test_is_not_reported_as_assertion_free() {
let excluded = crate::core::backend::GeneratedFile {
path: std::path::PathBuf::from("src/test/kotlin/dev/sample/e2e/ExcludedBindingsTest.kt"),
content: crate::e2e::template_env::render(
"kotlin_android/excluded_fixtures.kt.jinja",
minijinja::context! {
package_name => "dev.sample",
entries => vec![minijinja::context! {
name => "visitor_round_trip",
reason => "visitor is excluded by crates.kotlin_android.exclude_functions",
}],
},
),
generated_header: true,
};
let (total, offenders) = assertion_free_tests(std::slice::from_ref(&excluded));
assert_eq!(total, 1, "the detector must see the one @Disabled @Test");
assert!(
offenders.is_empty(),
"@Disabled reports as skipped, not as a pass, and must be accepted: {offenders:?}"
);
}