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 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 {
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,
&HashMap::new(),
false,
false,
);
assert!(
out.contains("result.choices().first().finishReason()?.getValue().orEmpty().trim()"),
"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 {
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,
};
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: &[],
},
);
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 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 {
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,
&HashMap::new(),
false,
false,
);
assert!(
out.contains("result.choices().first().finishReason().getValue().trim()"),
"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 {
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,
&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,
&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 {
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,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
},
FieldDef {
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,
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,
super_traits: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
is_variant_wrapper: false,
has_lifetime_params: 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 {
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,
&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::e2e::fixture::MockResponse;
let streaming_fixture = Fixture {
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,
};
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(
"streaming",
&[&streaming_fixture],
"LlmClient",
"chatStream",
"dev.sample_crate.sampleapp.android",
"result",
&[],
None,
false,
&e2e_config,
&HashMap::new(),
true,
&config,
&type_defs,
);
assert!(
out_android.contains("import kotlinx.coroutines.flow.toList"),
"kotlin_android streaming file must import flow.toList, 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,
);
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 {
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,
},
}),
};
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,
);
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,
);
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 {
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,
};
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: &[],
},
);
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: &[],
},
);
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 {
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,
};
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: &[],
},
);
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 {
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,
};
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,
serde_rename_all: None,
exclude_functions: Vec::new(),
exclude_types: Vec::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,
);
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}"
);
}