use super::args::{KotlinArgsContext, build_args_and_setup};
use super::assertions::render_assertion;
use super::project::render_build_gradle;
use super::test_file::{is_enum_typed, render_test_file_inner};
use crate::core::config::ResolvedCrateConfig;
use crate::e2e::config::ArgMapping;
use crate::e2e::config::E2eConfig;
use crate::e2e::field_access::FieldResolver;
use crate::e2e::fixture::{Assertion, Fixture};
use std::collections::{BTreeMap, HashMap, HashSet};
fn make_resolver_for_finish_reason() -> FieldResolver {
let mut optional = HashSet::new();
optional.insert("choices.finish_reason".to_string());
let mut arrays = HashSet::new();
arrays.insert("choices".to_string());
FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
}
#[test]
fn kotlin_ir_reachable_field_absent_from_result_fields_is_not_skipped() {
let reachable: HashSet<String> = ["data".to_string()].into_iter().collect();
let resolver = FieldResolver::new(
&HashMap::new(),
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
)
.with_ir_fields(reachable, HashSet::new(), HashSet::new());
let assertion = Assertion {
skip: None,
assertion_type: "equals".to_string(),
field: Some("data".to_string()),
value: Some(serde_json::Value::String("hello".to_string())),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
&HashSet::new(),
&HashSet::new(),
&HashMap::new(),
false,
false,
);
assert!(!out.contains("skipped"), "got: {out}");
}
#[test]
fn kotlin_ir_excluded_field_present_in_result_fields_is_still_skipped() {
let result_fields: HashSet<String> = ["internal_diagnostics".to_string()].into_iter().collect();
let excluded: HashSet<String> = ["internal_diagnostics".to_string()].into_iter().collect();
let resolver = FieldResolver::new(
&HashMap::new(),
&HashSet::new(),
&result_fields,
&HashSet::new(),
&HashSet::new(),
)
.with_ir_fields(HashSet::new(), excluded, HashSet::new());
let assertion = Assertion {
skip: None,
assertion_type: "equals".to_string(),
field: Some("internal_diagnostics".to_string()),
value: Some(serde_json::Value::String("hello".to_string())),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
&HashSet::new(),
&HashSet::new(),
&HashMap::new(),
false,
false,
);
assert!(out.contains("skipped"), "got: {out}");
}
#[test]
fn assertion_enum_optional_uses_safe_get_value_then_or_empty() {
let resolver = make_resolver_for_finish_reason();
let mut enum_fields = HashSet::new();
enum_fields.insert("choices.finish_reason".to_string());
let assertion = Assertion {
skip: None,
assertion_type: "equals".to_string(),
field: Some("choices.finish_reason".to_string()),
value: Some(serde_json::Value::String("stop".to_string())),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
&enum_fields,
&HashSet::new(),
&HashMap::new(),
false,
false,
);
assert!(
out.contains("result.choices().first().finishReason()?.getValue().orEmpty()"),
"expected enum-optional safe-call pattern, got: {out}"
);
assert!(
!out.contains(".finishReason().orEmpty().getValue()"),
"must not emit .orEmpty().getValue() on a nullable enum: {out}"
);
}
#[test]
fn handle_config_deserialization_uses_resolved_options_type() {
let args = vec![ArgMapping {
name: "session".to_string(),
field: "input.config".to_string(),
arg_type: "handle".to_string(),
optional: false,
owned: false,
element_type: None,
go_type: None,
vec_inner_is_ref: false,
trait_name: None,
}];
let fixture = Fixture {
docs: None,
requirements: Vec::new(),
id: "session_fixture".to_string(),
category: None,
description: "test fixture".to_string(),
tags: vec![],
skip: None,
env: None,
setup: Vec::new(),
call: None,
input: serde_json::json!({ "config": { "limit": 3 } }),
mock_response: None,
visitor: None,
args: vec![],
assertion_recipes: vec![],
assertions: vec![],
source: String::new(),
http: None,
asyncapi: None,
websocket: None,
preserve_input_urls: false,
};
let (setup, args_str) = build_args_and_setup(
&fixture.input,
&args,
KotlinArgsContext {
fixture: &fixture,
class_name: "Sample",
options_type: Some("SessionConfig"),
fixture_id: &fixture.id,
kotlin_android_style: false,
config: &ResolvedCrateConfig::default(),
type_defs: &[],
owner_handle_is_receiver: false,
},
)
.expect("args build succeeds");
let rendered = setup.join("\n");
assert_eq!(args_str, "session");
assert!(rendered.contains("MAPPER.readValue(\"{\\\"limit\\\":3}\", SessionConfig::class.java)"));
assert!(rendered.contains("Sample.createSession(sessionConfig)"));
assert!(!rendered.contains("CrawlConfig"));
}
#[test]
fn json_object_arg_with_mock_url_placeholder_binds_once_at_runtime() {
let args = vec![ArgMapping {
name: "request".to_string(),
field: "input.request".to_string(),
arg_type: "json_object".to_string(),
optional: false,
owned: false,
element_type: None,
go_type: None,
vec_inner_is_ref: false,
trait_name: None,
}];
let fixture = Fixture {
id: "request_fixture".to_string(),
description: "test fixture".to_string(),
input: serde_json::json!({ "request": { "url": "$mock_url/upload" } }),
..Fixture::default()
};
let (setup, args_str) = build_args_and_setup(
&fixture.input,
&args,
KotlinArgsContext {
fixture: &fixture,
class_name: "Sample",
options_type: Some("UploadRequest"),
fixture_id: &fixture.id,
kotlin_android_style: false,
config: &ResolvedCrateConfig::default(),
type_defs: &[],
owner_handle_is_receiver: false,
},
)
.expect("args build succeeds");
let rendered = setup.join("\n");
assert_eq!(args_str, "request");
assert!(
rendered.contains("val request = MAPPER.readValue(requestJson, UploadRequest::class.java)"),
"expected a runtime-bound readValue call, got:\n{rendered}"
);
let binding_count = rendered.matches("val request =").count();
assert_eq!(
binding_count, 1,
"expected exactly one `val request =` binding, got {binding_count}:\n{rendered}"
);
assert!(
rendered.contains(".replace(\"\\$mock_url\", requestMockBaseUrl)"),
"expected the mock URL placeholder to be swapped in at runtime, got:\n{rendered}"
);
}
fn make_resolver_for_optional_field(field: &str) -> FieldResolver {
let mut optional = HashSet::new();
optional.insert(field.to_string());
FieldResolver::new(
&HashMap::new(),
&optional,
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
)
}
#[test]
fn not_empty_is_type_aware_for_nullable_values() {
let cases = [
("quality_score", false, "result.qualityScore() != null"),
("keywords", true, "result.keywords()?.isNotEmpty() == true"),
];
for (field, is_collection, expected) in cases {
let mut optional = HashSet::new();
optional.insert(field.to_string());
let mut arrays = HashSet::new();
if is_collection {
arrays.insert(field.to_string());
}
let resolver = FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new());
let assertion = Assertion {
skip: None,
assertion_type: "not_empty".to_string(),
field: Some(field.to_string()),
value: None,
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
&HashSet::new(),
&HashSet::new(),
&HashMap::new(),
false,
false,
);
assert!(out.contains(expected), "field {field}: {out}");
assert!(!out.contains("toString"), "field {field}: {out}");
}
}
#[test]
fn assertion_json_scalar_optional_field_stringifies_before_or_empty() {
let resolver = make_resolver_for_optional_field("data");
let mut json_scalar_fields = HashSet::new();
json_scalar_fields.insert("data".to_string());
let assertion = Assertion {
skip: None,
assertion_type: "contains".to_string(),
field: Some("data".to_string()),
value: Some(serde_json::Value::String("JS Test Page".to_string())),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
&HashSet::new(),
&json_scalar_fields,
&HashMap::new(),
false,
true,
);
assert!(
out.contains("result.data?.toString().orEmpty().contains(\"JS Test Page\")"),
"expected null-safe stringify before orEmpty() for an Any? field, got: {out}"
);
assert!(
!out.contains("result.data.orEmpty()"),
"must not emit a bare .orEmpty() on Any?, got: {out}"
);
}
#[test]
fn assertion_json_scalar_matches_configured_array_wildcard() {
let resolver = FieldResolver::new(
&HashMap::new(),
&HashSet::new(),
&HashSet::from(["action_results".to_string()]),
&HashSet::from(["action_results".to_string()]),
&HashSet::new(),
);
let assertion = Assertion {
skip: None,
assertion_type: "contains".to_string(),
field: Some("action_results[0].data".to_string()),
value: Some(serde_json::json!("JS Test Page")),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut output = String::new();
render_assertion(
&mut output,
&assertion,
"result",
"",
&resolver,
false,
false,
&HashSet::new(),
&HashSet::from(["action_results[].data".to_string()]),
&HashMap::new(),
false,
true,
);
assert!(output.contains("data?.toString().orEmpty().contains"), "got: {output}");
assert!(!output.contains("data.orEmpty()"), "got: {output}");
}
#[test]
fn assertion_json_scalar_and_nullable_root_are_stringified_for_contains() {
let resolver = FieldResolver::new(
&HashMap::new(),
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
);
let assertion = Assertion {
skip: None,
assertion_type: "contains".to_string(),
field: Some("data".to_string()),
value: Some(serde_json::json!("needle")),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut field_output = String::new();
render_assertion(
&mut field_output,
&assertion,
"result",
"",
&resolver,
true,
false,
&HashSet::new(),
&HashSet::from(["data".to_string()]),
&HashMap::new(),
false,
true,
);
assert!(
field_output.contains("result?.toString().orEmpty().contains(\"needle\")"),
"got: {field_output}"
);
let mut root_output = String::new();
let mut root_assertion = assertion;
root_assertion.field = None;
render_assertion(
&mut root_output,
&root_assertion,
"result",
"",
&resolver,
true,
true,
&HashSet::new(),
&HashSet::new(),
&HashMap::new(),
false,
true,
);
assert!(
root_output.contains("result?.toString().orEmpty().contains(\"needle\")"),
"got: {root_output}"
);
}
#[test]
fn assertion_string_optional_field_still_uses_plain_or_empty() {
let resolver = make_resolver_for_optional_field("title");
let assertion = Assertion {
skip: None,
assertion_type: "contains".to_string(),
field: Some("title".to_string()),
value: Some(serde_json::Value::String("Example".to_string())),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
&HashSet::new(),
&HashSet::new(),
&HashMap::new(),
false,
true,
);
assert!(
out.contains("result.title.orEmpty().contains(\"Example\")"),
"expected plain .orEmpty() for a String? field, got: {out}"
);
assert!(
!out.contains("?.toString().orEmpty()"),
"must not stringify a genuine String? field, got: {out}"
);
}
#[test]
fn assertion_enum_non_optional_uses_plain_get_value() {
let mut arrays = HashSet::new();
arrays.insert("choices".to_string());
let resolver = FieldResolver::new(
&HashMap::new(),
&HashSet::new(),
&HashSet::new(),
&arrays,
&HashSet::new(),
);
let mut enum_fields = HashSet::new();
enum_fields.insert("choices.finish_reason".to_string());
let assertion = Assertion {
skip: None,
assertion_type: "equals".to_string(),
field: Some("choices.finish_reason".to_string()),
value: Some(serde_json::Value::String("stop".to_string())),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
&enum_fields,
&HashSet::new(),
&HashMap::new(),
false,
false,
);
assert!(
out.contains("result.choices().first().finishReason().getValue()"),
"expected plain .getValue() for non-optional enum, got: {out}"
);
}
#[test]
fn per_call_enum_field_override_routes_through_get_value() {
let resolver = FieldResolver::new(
&HashMap::new(),
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
);
let global_enum_fields: HashSet<String> = HashSet::new();
let mut per_call_enum_fields: HashSet<String> = global_enum_fields.clone();
per_call_enum_fields.insert("status".to_string());
let assertion = Assertion {
skip: None,
assertion_type: "equals".to_string(),
field: Some("status".to_string()),
value: Some(serde_json::Value::String("validating".to_string())),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out_no_merge = String::new();
render_assertion(
&mut out_no_merge,
&assertion,
"result",
"",
&resolver,
false,
false,
&global_enum_fields,
&HashSet::new(),
&HashMap::new(),
false,
false,
);
assert!(
!out_no_merge.contains(".getValue()"),
"global-only set must not emit .getValue() for unregistered status: {out_no_merge}"
);
let mut out_merged = String::new();
render_assertion(
&mut out_merged,
&assertion,
"result",
"",
&resolver,
false,
false,
&per_call_enum_fields,
&HashSet::new(),
&HashMap::new(),
false,
false,
);
assert!(
out_merged.contains(".getValue()"),
"merged per-call set must emit .getValue() for status: {out_merged}"
);
}
#[test]
fn auto_detected_enum_fields_from_type_defs_route_through_get_value() {
use crate::core::ir::{CoreWrapper, FieldDef, TypeDef, TypeRef};
let batch_object_def = TypeDef {
name: "BatchObject".to_string(),
rust_path: "demo_client::BatchObject".to_string(),
original_rust_path: String::new(),
fields: vec![
FieldDef {
version: Default::default(),
name: "id".to_string(),
ty: TypeRef::String,
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: CoreWrapper::None,
vec_inner_core_wrapper: CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
serde_with: None,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
},
FieldDef {
version: Default::default(),
name: "status".to_string(),
ty: TypeRef::Named("BatchStatus".to_string()),
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: CoreWrapper::None,
vec_inner_core_wrapper: CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
serde_with: None,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
},
],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
doc: String::new(),
cfg: None,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: true,
serde_rename_all: None,
has_serde: true,
serde_container_default: false,
super_traits: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
is_variant_wrapper: false,
has_lifetime_params: false,
has_private_fields: false,
version: Default::default(),
};
let type_defs = [batch_object_def];
let struct_names: HashSet<&str> = type_defs.iter().map(|td| td.name.as_str()).collect();
let status_ty = TypeRef::Named("BatchStatus".to_string());
assert!(
is_enum_typed(&status_ty, &struct_names),
"BatchStatus (not a known struct) should be detected as enum-typed"
);
let id_ty = TypeRef::String;
assert!(
!is_enum_typed(&id_ty, &struct_names),
"String field should NOT be detected as enum-typed"
);
let type_enum_fields: std::collections::HashMap<String, HashSet<String>> = type_defs
.iter()
.filter_map(|td| {
let enum_field_names: HashSet<String> = td
.fields
.iter()
.filter(|field| is_enum_typed(&field.ty, &struct_names))
.map(|field| field.name.clone())
.collect();
if enum_field_names.is_empty() {
None
} else {
Some((td.name.clone(), enum_field_names))
}
})
.collect();
let batch_enum_fields = type_enum_fields
.get("BatchObject")
.expect("BatchObject should have enum fields");
assert!(
batch_enum_fields.contains("status"),
"BatchObject.status should be auto-detected as enum-typed, got: {batch_enum_fields:?}"
);
assert!(
!batch_enum_fields.contains("id"),
"BatchObject.id (String) must not be in enum fields"
);
let resolver = FieldResolver::new(
&HashMap::new(),
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
);
let assertion = Assertion {
skip: None,
assertion_type: "equals".to_string(),
field: Some("status".to_string()),
value: Some(serde_json::Value::String("validating".to_string())),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
batch_enum_fields,
&HashSet::new(),
&HashMap::new(),
false,
false,
);
assert!(
out.contains(".getValue()"),
"auto-detected enum field must route through .getValue(), got: {out}"
);
}
#[test]
fn kotlin_android_streaming_fixture_emits_flow_to_list_import() {
use crate::core::config::e2e::CallConfig;
use crate::core::config::extras::{AdapterConfig, AdapterParam, AdapterPattern};
use crate::e2e::fixture::MockResponse;
let streaming_fixture = Fixture {
docs: None,
requirements: Vec::new(),
id: "smoke_stream".to_string(),
category: None,
description: "streaming test".to_string(),
tags: vec![],
skip: None,
env: None,
setup: Vec::new(),
call: None,
input: serde_json::json!({}),
mock_response: Some(MockResponse {
status: 200,
body: None,
stream_chunks: Some(vec![serde_json::json!({"delta": "hi"})]),
headers: BTreeMap::new(),
}),
visitor: None,
args: vec![],
assertion_recipes: vec![],
assertions: vec![],
source: String::new(),
http: None,
asyncapi: None,
websocket: None,
preserve_input_urls: false,
};
let e2e_config = E2eConfig {
call: CallConfig {
function: "stream_items".to_string(),
..CallConfig::default()
},
..E2eConfig::default()
};
let config = crate::core::config::ResolvedCrateConfig {
adapters: vec![AdapterConfig {
name: "stream_items".to_string(),
pattern: AdapterPattern::Streaming,
core_path: "sample::Engine::stream_items".to_string(),
params: vec![AdapterParam {
name: "request".to_string(),
ty: "sample::StreamRequest".to_string(),
optional: false,
}],
returns: None,
error_type: None,
owner_type: Some("Engine".to_string()),
item_type: Some("StreamItem".to_string()),
gil_release: false,
trait_name: None,
trait_method: None,
detect_async: false,
request_type: None,
skip_languages: Vec::new(),
}],
..crate::core::config::ResolvedCrateConfig::default()
};
let type_defs: Vec<crate::core::ir::TypeDef> = Vec::new();
let out_android = render_test_file_inner(
"streaming",
&[&streaming_fixture],
"LlmClient",
"chatStream",
"dev.sample_crate.sampleapp.android",
"result",
&[],
None,
false,
&e2e_config,
&HashMap::new(),
true,
&config,
&type_defs,
&[],
&[],
)
.expect("android streaming test file renders");
assert!(
out_android.contains("import kotlinx.coroutines.flow.toList"),
"kotlin_android streaming file must import flow.toList, got:\n{out_android}"
);
assert!(
out_android.contains("import dev.sample_crate.sampleapp.android.StreamRequest"),
"streaming request DTO must be imported, got:\n{out_android}"
);
let out_jvm = render_test_file_inner(
"streaming",
&[&streaming_fixture],
"LlmClient",
"chatStream",
"dev.sample_crate.sampleapp.android",
"result",
&[],
None,
false,
&e2e_config,
&HashMap::new(),
false,
&config,
&type_defs,
&[],
&[],
)
.expect("jvm streaming test file renders");
assert!(
!out_jvm.contains("import kotlinx.coroutines.flow.toList"),
"non-android streaming file must NOT import flow.toList, got:\n{out_jvm}"
);
}
#[test]
fn kotlin_android_object_mapper_emits_register_kotlin_module() {
use crate::core::config::e2e::CallConfig;
use crate::e2e::fixture::{HttpExpectedResponse, HttpFixture, HttpHandler, HttpRequest};
let http_fixture = Fixture {
docs: None,
requirements: Vec::new(),
id: "http_test".to_string(),
category: None,
description: "http test".to_string(),
tags: vec![],
skip: None,
env: None,
setup: Vec::new(),
call: None,
input: serde_json::json!({}),
mock_response: None,
visitor: None,
args: vec![],
assertion_recipes: vec![],
assertions: vec![],
source: String::new(),
http: Some(HttpFixture {
handler: HttpHandler {
route: "/v1/test".to_string(),
method: "POST".to_string(),
body_schema: None,
parameters: BTreeMap::new(),
middleware: None,
},
request: HttpRequest {
method: "POST".to_string(),
path: "/v1/test".to_string(),
headers: BTreeMap::new(),
query_params: BTreeMap::new(),
cookies: BTreeMap::new(),
body: None,
form_data: None,
content_type: None,
},
expected_response: HttpExpectedResponse {
status_code: 200,
body: None,
body_partial: None,
headers: BTreeMap::new(),
validation_errors: None,
},
}),
asyncapi: None,
websocket: None,
preserve_input_urls: false,
};
let e2e_config = E2eConfig {
call: CallConfig::default(),
..E2eConfig::default()
};
let config = crate::core::config::ResolvedCrateConfig::default();
let type_defs: Vec<crate::core::ir::TypeDef> = Vec::new();
let out_android = render_test_file_inner(
"configuration",
&[&http_fixture],
"",
"",
"dev.sample_crate.sampleapp.android",
"result",
&[],
None,
false,
&e2e_config,
&HashMap::new(),
true,
&config,
&type_defs,
&[],
&[],
)
.expect("android configuration test file renders");
assert!(
out_android.contains("import com.fasterxml.jackson.module.kotlin.registerKotlinModule"),
"kotlin_android with ObjectMapper must import registerKotlinModule, got:\n{out_android}"
);
assert!(
out_android.contains(".registerKotlinModule()"),
"kotlin_android MAPPER must call .registerKotlinModule(), got:\n{out_android}"
);
let out_jvm = render_test_file_inner(
"configuration",
&[&http_fixture],
"",
"",
"dev.sample_crate.sampleapp.android",
"result",
&[],
None,
false,
&e2e_config,
&HashMap::new(),
false,
&config,
&type_defs,
&[],
&[],
)
.expect("jvm configuration test file renders");
assert!(
!out_jvm.contains("registerKotlinModule"),
"non-android MAPPER must NOT reference registerKotlinModule, got:\n{out_jvm}"
);
}
#[test]
fn registry_dep_uses_group_artifact_version_coordinate() {
let out = render_build_gradle(
"sample_router-kotlin",
"dev.sample_router",
"0.15.6-rc.3",
crate::e2e::config::DependencyMode::Registry,
false,
);
assert!(
out.contains(r#"testImplementation("dev.sample_router:sample_router-kotlin:0.15.6-rc.3")"#),
"expected single-group maven coordinate, got:\n{out}"
);
}
#[test]
fn registry_dep_does_not_double_the_group_prefix() {
let out = render_build_gradle(
"dev.sample_router:sample_router-kotlin",
"dev.sample_router",
"0.15.6-rc.3",
crate::e2e::config::DependencyMode::Registry,
false,
);
assert!(
out.contains(r#"testImplementation("dev.sample_router:sample_router-kotlin:0.15.6-rc.3")"#),
"group must not be doubled, got:\n{out}"
);
assert!(
!out.contains("dev.sample_router:dev.sample_router"),
"doubled group must never appear, got:\n{out}"
);
}
#[test]
fn local_dep_references_built_jar_by_base_name() {
let out = render_build_gradle(
"sample_router",
"dev.sample_router",
"0.15.6-rc.3",
crate::e2e::config::DependencyMode::Local,
false,
);
assert!(
out.contains("packages/kotlin/build/libs/sample_router-0.15.6-rc.3.jar"),
"expected local jar reference, got:\n{out}"
);
}
#[test]
fn kotlin_android_bytes_arg_emits_files_read_all_bytes() {
let args = vec![ArgMapping {
name: "content".to_string(),
field: "input.path".to_string(),
arg_type: "bytes".to_string(),
optional: false,
owned: false,
element_type: None,
go_type: None,
vec_inner_is_ref: false,
trait_name: None,
}];
let fixture = Fixture {
docs: None,
requirements: Vec::new(),
id: "extract_bytes_fixture".to_string(),
category: None,
description: "test bytes extraction".to_string(),
tags: vec![],
skip: None,
env: None,
setup: Vec::new(),
call: None,
input: serde_json::json!({ "path": "pdf/test.pdf" }),
mock_response: None,
visitor: None,
args: vec![],
assertion_recipes: vec![],
assertions: vec![],
source: String::new(),
http: None,
asyncapi: None,
websocket: None,
preserve_input_urls: false,
};
let (_, args_jvm) = build_args_and_setup(
&fixture.input,
&args,
KotlinArgsContext {
fixture: &fixture,
class_name: "SampleBinding",
options_type: None,
fixture_id: "extract_bytes_fixture",
kotlin_android_style: false,
config: &ResolvedCrateConfig::default(),
type_defs: &[],
owner_handle_is_receiver: false,
},
)
.expect("args build succeeds");
assert!(
args_jvm.contains("\"pdf/test.pdf\""),
"JVM style must emit string literal, got: {args_jvm}"
);
let (_, args_android) = build_args_and_setup(
&fixture.input,
&args,
KotlinArgsContext {
fixture: &fixture,
class_name: "SampleBinding",
options_type: None,
fixture_id: "extract_bytes_fixture",
kotlin_android_style: true,
config: &ResolvedCrateConfig::default(),
type_defs: &[],
owner_handle_is_receiver: false,
},
)
.expect("args build succeeds");
assert!(
args_android.contains("java.nio.file.Files.readAllBytes"),
"kotlin_android bytes arg must use Files.readAllBytes, got: {args_android}"
);
assert!(
args_android.contains("Paths.get("),
"kotlin_android bytes arg must use Paths.get, got: {args_android}"
);
}
#[test]
fn kotlin_android_batch_bytes_item_wraps_paths() {
let args = vec![ArgMapping {
name: "items".to_string(),
field: "input.paths".to_string(),
arg_type: "json_object".to_string(),
optional: false,
owned: false,
element_type: Some("FileBytesItem".to_string()),
go_type: None,
vec_inner_is_ref: false,
trait_name: None,
}];
let fixture = Fixture {
docs: None,
requirements: Vec::new(),
id: "batch_extract_fixture".to_string(),
category: None,
description: "test batch extraction".to_string(),
tags: vec![],
skip: None,
env: None,
setup: Vec::new(),
call: None,
input: serde_json::json!({ "paths": ["pdf/test1.pdf", "pdf/test2.pdf"] }),
mock_response: None,
visitor: None,
args: vec![],
assertion_recipes: vec![],
assertions: vec![],
source: String::new(),
http: None,
asyncapi: None,
websocket: None,
preserve_input_urls: false,
};
let (_, args_android) = build_args_and_setup(
&fixture.input,
&args,
KotlinArgsContext {
fixture: &fixture,
class_name: "SampleBinding",
options_type: None,
fixture_id: "batch_extract_fixture",
kotlin_android_style: true,
config: &ResolvedCrateConfig::default(),
type_defs: &[],
owner_handle_is_receiver: false,
},
)
.expect("args build succeeds");
assert!(
args_android.contains("FileBytesItem"),
"kotlin_android batch must wrap items in the configured item type, got: {args_android}"
);
assert!(
args_android.contains("java.nio.file.Files.readAllBytes"),
"kotlin_android batch items must read file bytes, got: {args_android}"
);
assert!(
args_android.contains("listOf("),
"kotlin_android batch must emit listOf(...), got: {args_android}"
);
}
#[test]
fn kotlin_android_test_file_loads_resolved_jni_lib_name_not_crate_name() {
use crate::core::config::e2e::CallConfig;
use crate::core::config::{FfiConfig, ResolvedCrateConfig};
use crate::e2e::fixture::MockResponse;
let fixture = Fixture {
docs: None,
requirements: Vec::new(),
id: "smoke_one".to_string(),
category: None,
description: "smoke".to_string(),
tags: vec![],
skip: None,
env: None,
setup: Vec::new(),
call: None,
input: serde_json::json!({}),
mock_response: Some(MockResponse {
status: 200,
body: None,
stream_chunks: None,
headers: BTreeMap::new(),
}),
visitor: None,
args: vec![],
assertion_recipes: vec![],
assertions: vec![],
source: String::new(),
http: None,
asyncapi: None,
websocket: None,
preserve_input_urls: false,
};
let e2e_config = E2eConfig {
call: CallConfig::default(),
..E2eConfig::default()
};
let mut config = ResolvedCrateConfig {
name: "custom-runtime-crate".to_string(),
..ResolvedCrateConfig::default()
};
config.ffi = Some(FfiConfig {
prefix: Some("custom_runtime".to_string()),
error_style: "last_error".to_string(),
header_name: None,
lib_name: None,
visitor_callbacks: false,
features: None,
extra_features: Vec::new(),
serde_rename_all: None,
exclude_functions: Vec::new(),
exclude_types: Vec::new(),
capsule_types: HashMap::new(),
rename_fields: HashMap::new(),
plugin_error_constructor: None,
target_dep_overrides: Vec::new(),
});
let type_defs: Vec<crate::core::ir::TypeDef> = Vec::new();
let out = render_test_file_inner(
"smoke",
&[&fixture],
"Bridge",
"doThing",
"dev.sample_crate.sampleapp.android",
"result",
&[],
None,
false,
&e2e_config,
&HashMap::new(),
true,
&config,
&type_defs,
&[],
&[],
)
.expect("kotlin_android test file renders");
assert!(
out.contains("System.loadLibrary(\"custom_runtime_jni\")"),
"kotlin_android test must loadLibrary the resolved jni_lib_name (`custom_runtime_jni`), got:\n{out}"
);
assert!(
!out.contains("custom-runtime-crate_jni"),
"kotlin_android test must NOT loadLibrary the raw crate name, got:\n{out}"
);
}
#[test]
fn build_gradle_kotlin_declares_junit_platform_launcher_when_http_fixtures_present() {
let out = render_build_gradle(
"sample_project-kotlin",
"dev.sample_project",
"0.1.0",
crate::e2e::config::DependencyMode::Local,
true,
);
assert!(
out.contains(r#"testImplementation("org.junit.platform:junit-platform-launcher:"#),
"build.gradle.kts must declare junit-platform-launcher when HTTP fixtures are present, got:\n{out}"
);
}
#[test]
fn build_gradle_kotlin_omits_junit_platform_launcher_without_http_fixtures() {
let out = render_build_gradle(
"sample_project-kotlin",
"dev.sample_project",
"0.1.0",
crate::e2e::config::DependencyMode::Local,
false,
);
assert!(
!out.contains("junit-platform-launcher"),
"build.gradle.kts must not declare junit-platform-launcher without HTTP fixtures, got:\n{out}"
);
}
#[test]
fn assertion_json_scalar_matches_namespace_stripped_path() {
let resolver = FieldResolver::new(
&HashMap::new(),
&HashSet::from(["action_results[].data".to_string()]),
&HashSet::from(["action_results".to_string()]),
&HashSet::from(["action_results".to_string()]),
&HashSet::new(),
);
let assertion = Assertion {
skip: None,
assertion_type: "contains".to_string(),
field: Some("interaction.action_results[0].data".to_string()),
value: Some(serde_json::json!("JS Test Page")),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
&HashSet::new(),
&HashSet::from(["action_results[].data".to_string()]),
&HashMap::new(),
false,
true,
);
assert!(
!out.contains(".data.orEmpty()"),
"must not emit a bare `.orEmpty()` on the Any? field behind a namespace-prefixed path, got:\n{out}"
);
assert!(
out.contains(".data?.toString().orEmpty().contains(\"JS Test Page\")"),
"expected the null-safe stringify path for the namespace-prefixed json-scalar field, got:\n{out}"
);
}
#[test]
fn assertion_namespace_stripped_string_field_still_uses_plain_or_empty() {
let resolver = FieldResolver::new(
&HashMap::new(),
&HashSet::from(["action_results[].action_type".to_string()]),
&HashSet::from(["action_results".to_string()]),
&HashSet::from(["action_results".to_string()]),
&HashSet::new(),
);
let assertion = Assertion {
skip: None,
assertion_type: "contains".to_string(),
field: Some("interaction.action_results[0].action_type".to_string()),
value: Some(serde_json::json!("executeJs")),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&resolver,
false,
false,
&HashSet::new(),
&HashSet::from(["action_results[].data".to_string()]),
&HashMap::new(),
false,
true,
);
assert!(
out.contains(".actionType.orEmpty().contains(\"executeJs\")"),
"expected the plain .orEmpty() fallback for a genuinely nullable String field, got:\n{out}"
);
assert!(
!out.contains("?.toString().orEmpty()"),
"a non-json-scalar field must not be routed through the Any? stringify path, got:\n{out}"
);
}
#[test]
fn assertion_json_scalar_accepts_both_bracket_and_dotted_spellings() {
let make_resolver = || {
FieldResolver::new(
&HashMap::new(),
&HashSet::from(["action_results[].data".to_string()]),
&HashSet::from(["action_results".to_string()]),
&HashSet::from(["action_results".to_string()]),
&HashSet::new(),
)
};
let assertion = Assertion {
skip: None,
assertion_type: "contains".to_string(),
field: Some("action_results[0].data".to_string()),
value: Some(serde_json::json!("JS Test Page")),
values: None,
method: None,
check: None,
args: None,
return_type: None,
};
for spelling in ["action_results[].data", "action_results.data"] {
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&make_resolver(),
false,
false,
&HashSet::new(),
&HashSet::from([spelling.to_string()]),
&HashMap::new(),
false,
true,
);
assert!(
out.contains(".data?.toString().orEmpty().contains"),
"spelling `{spelling}` must resolve as a json-scalar field, got:\n{out}"
);
}
}
fn wildcard_resolver() -> FieldResolver {
FieldResolver::new(
&HashMap::new(),
&HashSet::new(),
&HashSet::from(["links".to_string()]),
&HashSet::from(["links".to_string()]),
&HashSet::new(),
)
}
fn render_wildcard(field: &str, kotlin_android_style: bool) -> String {
let assertion = Assertion {
assertion_type: "contains".to_string(),
field: Some(field.to_string()),
value: Some(serde_json::json!("internal")),
..Default::default()
};
let mut out = String::new();
render_assertion(
&mut out,
&assertion,
"result",
"",
&wildcard_resolver(),
false,
false,
&HashSet::new(),
&HashSet::new(),
&HashMap::new(),
false,
kotlin_android_style,
);
out
}
#[test]
fn kotlin_wildcard_contains_emits_any_over_all_elements() {
let out = render_wildcard("links[].link_type", false);
assert!(
out.contains("result.links().any { e -> e.linkType().toString().contains(\"internal\") }"),
"expected an any-element quantifier, got:\n{out}"
);
}
#[test]
fn kotlin_wildcard_does_not_collapse_to_element_zero() {
let out = render_wildcard("links[].link_type", false);
assert!(
!out.contains(".first()") && !out.contains("[0]") && !out.contains(".get(0)"),
"wildcard must not lower to a single-element access, got:\n{out}"
);
}
#[test]
fn kotlin_explicit_index_still_resolves_to_element_zero() {
let out = render_wildcard("links[0].link_type", false);
assert!(
out.contains("result.links().first().linkType()"),
"explicit index 0 must keep its index-preserving accessor, got:\n{out}"
);
assert!(
!out.contains(".any {"),
"explicit index must not become a quantifier, got:\n{out}"
);
}
#[test]
fn kotlin_android_wildcard_uses_property_accessors_on_both_sides() {
let out = render_wildcard("links[].link_type", true);
assert!(
out.contains("result.links.any { e -> e.linkType.toString().contains(\"internal\") }"),
"expected kotlin_android property accessors on both the array and the element, got:\n{out}"
);
}