use super::*;
use crate::core::ir::{CoreWrapper, EnumDef, EnumVariant, FieldDef, TypeRef};
fn make_field(name: &str, ty: TypeRef) -> FieldDef {
FieldDef {
name: name.to_string(),
ty,
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,
}
}
fn make_variant(name: &str, serde_rename: Option<&str>, fields: Vec<FieldDef>) -> EnumVariant {
EnumVariant {
name: name.to_string(),
fields,
doc: String::new(),
is_default: false,
serde_rename: serde_rename.map(str::to_string),
binding_excluded: false,
binding_exclusion_reason: None,
is_tuple: false,
originally_had_data_fields: false,
cfg: None,
version: Default::default(),
}
}
fn make_enum(
name: &str,
serde_tag: Option<&str>,
serde_untagged: bool,
serde_rename_all: Option<&str>,
variants: Vec<EnumVariant>,
) -> EnumDef {
EnumDef {
name: name.to_string(),
rust_path: format!("crate::{name}"),
original_rust_path: format!("crate::{name}"),
variants,
methods: vec![],
doc: String::new(),
cfg: None,
is_copy: false,
has_serde: true,
serde_tag: serde_tag.map(str::to_string),
serde_untagged,
serde_rename_all: serde_rename_all.map(str::to_string),
binding_excluded: false,
binding_exclusion_reason: None,
excluded_variants: vec![],
version: Default::default(),
}
}
#[test]
fn emit_enum_tagged_sealed_class_emits_json_deserialize_annotation() {
let en = make_enum(
"Message",
Some("role"),
false,
None,
vec![
make_variant(
"System",
Some("system"),
vec![make_field("_0", TypeRef::Named("SystemMessage".to_string()))],
),
make_variant(
"User",
Some("user"),
vec![make_field("_0", TypeRef::Named("UserMessage".to_string()))],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = MessageDeserializer::class)"),
"missing @JsonDeserialize annotation on tagged sealed class; got:\n{out}",
);
assert!(
out.contains("private class MessageDeserializer"),
"missing MessageDeserializer class; got:\n{out}",
);
assert!(
out.contains("node.get(\"role\")"),
"deserializer must read the 'role' tag field; got:\n{out}",
);
assert!(
out.contains("\"system\" ->"),
"deserializer must dispatch on variant 'system'; got:\n{out}",
);
assert!(
out.contains("val tag = node.get(\"role\")?.asText()"),
"tagged deserializer must extract tag into separate variable; got:\n{out}",
);
assert!(
out.contains("val payload = (node.deepCopy() as com.fasterxml.jackson.databind.node.ObjectNode).apply { remove(\"role\") }"),
"tagged deserializer must strip tag field from payload via cast-safe deepCopy; got:\n{out}",
);
assert!(
out.contains("Message.System(ctx.readTreeAsValue<SystemMessage>(payload, SystemMessage::class.java))"),
"tagged deserializer must wrap readTreeAsValue<InnerType>(payload) in variant constructor for newtype; got:\n{out}",
);
}
#[test]
fn emit_enum_untagged_sealed_class_emits_json_deserialize_annotation() {
let en = make_enum(
"EmbeddingInput",
None,
true,
None,
vec![
make_variant("Single", None, vec![make_field("_0", TypeRef::String)]),
make_variant(
"Multiple",
None,
vec![make_field("_0", TypeRef::Vec(Box::new(TypeRef::String)))],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains(
"@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = EmbeddingInputDeserializer::class)"
),
"missing @JsonDeserialize annotation on untagged sealed class; got:\n{out}",
);
assert!(
out.contains("private class EmbeddingInputDeserializer"),
"missing EmbeddingInputDeserializer class; got:\n{out}",
);
assert!(
out.contains("node.isTextual"),
"untagged deserializer must check isTextual for String variant; got:\n{out}",
);
assert!(
out.contains("node.isArray"),
"untagged deserializer must check isArray for List variant; got:\n{out}",
);
assert!(
out.contains("ctx.typeFactory.constructCollectionType(List::class.java, String::class.java)"),
"untagged deserializer must use constructCollectionType for List<String> variant; got:\n{out}",
);
assert!(
!out.contains("ctx.readTreeAsValue(node, List::class.java)"),
"untagged deserializer must NOT use raw List::class.java; got:\n{out}",
);
}
#[test]
fn tagged_deserializer_named_field_variant_no_double_wrap() {
let en = make_enum(
"InputDocument",
Some("type"),
false,
Some("snake_case"),
vec![
make_variant("Url", Some("url"), vec![make_field("url", TypeRef::String)]),
make_variant(
"Base64",
Some("base64"),
vec![
make_field("data", TypeRef::String),
make_field("media_type", TypeRef::Named("MediaType".to_string())),
],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains(
"\"base64\" -> ctx.readTreeAsValue<InputDocument.Base64>(payload, InputDocument.Base64::class.java)"
),
"tagged deserializer must return readTreeAsValue<T>(payload) directly for named-field variant; got:\n{out}",
);
assert!(
!out.contains("InputDocument.Base64(ctx.readTreeAsValue"),
"tagged deserializer must NOT wrap readTreeAsValue result in variant constructor; got:\n{out}",
);
}
#[test]
fn tagged_deserializer_newtype_variant_no_double_wrap() {
let en = make_enum(
"ContentPart",
Some("type"),
false,
Some("snake_case"),
vec![make_variant(
"Text",
Some("text"),
vec![make_field("_0", TypeRef::Named("TextContent".to_string()))],
)],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("ContentPart.Text(ctx.readTreeAsValue<TextContent>(payload, TextContent::class.java))"),
"tagged deserializer must wrap readTreeAsValue<InnerType>(payload) in variant constructor for newtype variant; got:\n{out}",
);
}
#[test]
fn untagged_deserializer_list_of_named_type_uses_java_type() {
let en = make_enum(
"UserContent",
None,
true,
None,
vec![
make_variant("Text", None, vec![make_field("_0", TypeRef::String)]),
make_variant(
"Parts",
None,
vec![make_field(
"_0",
TypeRef::Vec(Box::new(TypeRef::Named("ContentPart".to_string()))),
)],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("ctx.typeFactory.constructCollectionType(List::class.java, ContentPart::class.java)"),
"untagged deserializer must use constructCollectionType for List<ContentPart>; got:\n{out}",
);
assert!(
!out.contains("ctx.readTreeAsValue(node, List::class.java)"),
"untagged deserializer must NOT use raw List::class.java for List<ContentPart>; got:\n{out}",
);
}
#[test]
fn emit_enum_unit_only_does_not_emit_json_deserialize() {
let en = make_enum(
"FinishReason",
None,
false,
None,
vec![make_variant("Stop", None, vec![]), make_variant("Length", None, vec![])],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
!out.contains("@JsonDeserialize") && !out.contains("Deserializer"),
"unit-only enum must not emit a deserializer; got:\n{out}",
);
assert!(
out.contains("enum class FinishReason"),
"must emit enum class; got:\n{out}"
);
}
#[test]
fn tagged_deserializer_strips_tag_field_from_payload() {
let en = make_enum(
"Message",
Some("role"),
false,
None,
vec![make_variant(
"System",
Some("system"),
vec![make_field("_0", TypeRef::Named("SystemMessage".to_string()))],
)],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("val tag = node.get(\"role\")?.asText()"),
"deserializer must extract tag into a local variable; got:\n{out}",
);
assert!(
out.contains(
"val payload = (node.deepCopy() as com.fasterxml.jackson.databind.node.ObjectNode).apply { remove(\"role\") }"
),
"deserializer must create tag-stripped payload via cast-safe deepCopy; got:\n{out}",
);
assert!(
out.contains("return when (tag)"),
"deserializer must dispatch on extracted tag variable; got:\n{out}",
);
assert!(
out.contains("readTreeAsValue<SystemMessage>(payload, SystemMessage::class.java)"),
"deserializer must pass tag-stripped payload to readTreeAsValue; got:\n{out}",
);
assert!(
!out.contains("readTreeAsValue<SystemMessage>(node, SystemMessage::class.java)"),
"deserializer must NOT pass un-stripped node to readTreeAsValue; got:\n{out}",
);
}
#[test]
fn sealed_class_variant_field_type_qualified_when_name_clashes_with_sibling_variant() {
let en = make_enum(
"ContentPart",
Some("type"),
false,
None,
vec![
make_variant("Text", Some("text"), vec![make_field("text", TypeRef::String)]),
make_variant(
"ImageUrl",
Some("image_url"),
vec![make_field("image_url", TypeRef::Named("ImageUrl".to_string()))],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "dev.sample_crate.samplellm.android");
assert!(
out.contains("val imageUrl: dev.sample_crate.samplellm.android.ImageUrl"),
"variant field type must be package-qualified when it clashes with a sibling variant name; got:\n{out}",
);
assert!(
out.contains("data class ImageUrl("),
"variant class declaration must still use simple name; got:\n{out}",
);
}
#[test]
fn sealed_class_variant_field_type_unqualified_when_no_clash() {
let en = make_enum(
"ContentPart",
Some("type"),
false,
None,
vec![make_variant(
"Document",
Some("document"),
vec![make_field("document", TypeRef::Named("DocumentContent".to_string()))],
)],
);
let mut out = String::new();
emit_enum(&en, &mut out, "dev.sample_crate.samplellm.android");
assert!(
out.contains("val document: DocumentContent"),
"non-clashing field type must remain unqualified; got:\n{out}",
);
assert!(
!out.contains("dev.sample_crate.samplellm.android.DocumentContent"),
"non-clashing field type must not be package-qualified; got:\n{out}",
);
}
#[test]
fn sealed_class_variant_data_classes_get_json_deserialize_reset_annotation() {
let en = make_enum(
"InputDocument",
Some("type"),
false,
Some("snake_case"),
vec![
make_variant("Url", Some("document_url"), vec![make_field("url", TypeRef::String)]),
make_variant(
"Base64",
Some("base64"),
vec![
make_field("data", TypeRef::String),
make_field("mediaType", TypeRef::String),
],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains(" @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = com.fasterxml.jackson.databind.JsonDeserializer.None::class)\n @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.JsonSerializer.None::class)\n data class Url("),
"Url variant must have @JsonDeserialize(using=None) and @JsonSerialize(using=None) reset annotations; got:\n{out}",
);
assert!(
out.contains(" @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = com.fasterxml.jackson.databind.JsonDeserializer.None::class)\n @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.JsonSerializer.None::class)\n data class Base64("),
"Base64 variant must have @JsonDeserialize(using=None) and @JsonSerialize(using=None) reset annotations; got:\n{out}",
);
}
#[test]
fn untagged_sealed_class_vec_variant_serializer_uses_declared_type_serializer() {
let en = make_enum(
"UserContent",
None,
true,
None,
vec![
make_variant("Text", None, vec![make_field("_0", TypeRef::String)]),
make_variant(
"Parts",
None,
vec![make_field(
"_0",
TypeRef::Vec(Box::new(TypeRef::Named("ContentPart".to_string()))),
)],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
!out.contains(" @com.fasterxml.jackson.databind.annotation.JsonDeserialize\n @com.fasterxml.jackson.databind.annotation.JsonSerialize\n data class Text("),
"Text newtype variant must NOT have reset annotations; got:\n{out}",
);
assert!(
out.contains("provider.findValueSerializer(ContentPart::class.java)"),
"Parts serializer must use provider.findValueSerializer(ContentPart::class.java); got:\n{out}",
);
assert!(
out.contains("is UserContent.Text -> mapper.writeValue(gen, value.value)"),
"Text serializer must use mapper.writeValue; got:\n{out}",
);
}
#[test]
fn untagged_serializer_tuple_variant_uses_payload_derived_field_name() {
let en = make_enum(
"EmbeddingInput",
None,
true,
None,
vec![
make_variant("Single", None, vec![make_field("_0", TypeRef::String)]),
make_variant(
"Multiple",
None,
vec![make_field("_0", TypeRef::Vec(Box::new(TypeRef::String)))],
),
],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("-> mapper.writeValue(gen, value.value)"),
"untagged serializer must use payload-derived field name `value`; got:\n{out}",
);
assert!(
!out.contains("value.field0"),
"untagged serializer must NOT use hardcoded `field0`; got:\n{out}",
);
}
#[test]
fn tagged_serializer_named_field_variant_casts_to_concrete_type() {
let en = make_enum(
"InputDocument",
Some("type"),
false,
Some("snake_case"),
vec![make_variant(
"Url",
Some("document_url"),
vec![make_field("url", TypeRef::String)],
)],
);
let mut out = String::new();
emit_enum(&en, &mut out, "");
assert!(
out.contains("mapper.valueToTree<com.fasterxml.jackson.databind.node.ObjectNode>(value as InputDocument.Url) as com.fasterxml.jackson.databind.node.ObjectNode"),
"tagged serializer must cast value to concrete variant type; got:\n{out}",
);
assert!(
!out.contains("mapper.valueToTree<com.fasterxml.jackson.databind.node.ObjectNode>(value) as"),
"tagged serializer must NOT call valueToTree on un-cast parent-type value; got:\n{out}",
);
}
#[test]
fn file_level_suppress_includes_unused_parameter() {
let imports = std::collections::BTreeSet::new();
let body = "data class Foo(val x: Int)";
let result = crate::backends::kotlin::gen_bindings::shared::assemble_kt_file("com.example", &imports, body);
assert!(
result.contains("\"UnusedParameter\""),
"file-level @file:Suppress must include 'UnusedParameter' to suppress detekt for unused stub params; got:\n{result}",
);
assert!(
result.contains("@file:Suppress("),
"generated file must have @file:Suppress annotation; got:\n{result}",
);
}
#[test]
fn instance_method_params_camel_case_conversion() {
use heck::ToLowerCamelCase;
let param_names = vec!["max_size", "enabled", "chunk_config", "api_key"];
for name in ¶m_names {
let camel = name.to_lower_camel_case();
assert!(
!camel.contains("_"),
"camelCase param name must not contain underscores; '{}' -> '{}'",
name,
camel
);
assert!(
camel.chars().next().unwrap().is_lowercase(),
"camelCase param name must start with lowercase; '{}' -> '{}'",
name,
camel
);
}
}