use crate::core::config::ResolvedCrateConfig;
use crate::e2e::codegen::declared_error_variant::{DeclaredErrorAssertion, classify, skip_line};
use crate::e2e::config::E2eConfig;
use crate::e2e::escape::sanitize_ident;
use crate::e2e::field_access::FieldResolver;
use crate::e2e::fixture::Fixture;
use std::fmt::Write as FmtWrite;
use super::{args, assertions, visitor};
fn render_r_error_check(
function_name: &str,
final_args: &str,
fixture: &crate::e2e::fixture::Fixture,
errors: &[crate::core::ir::ErrorDef],
) -> String {
match classify("r", fixture, errors) {
DeclaredErrorAssertion::Undeclared => format!(" expect_error({function_name}({final_args}))"),
DeclaredErrorAssertion::Assert(value) => {
let pattern = crate::e2e::escape::r_regex_literal(value);
format!(
" tryCatch({{\n {function_name}({final_args})\n fail(\"expected an error to be thrown\")\n }}, error = function(e) {{\n expect_true(grepl({pattern}, conditionMessage(e)) || grepl({pattern}, paste(class(e), collapse = \" \")))\n }})"
)
}
DeclaredErrorAssertion::Unsubstantiable(variant) => {
let skip = skip_line(" ", "#", variant, &fixture.id, "r");
format!(" expect_error({function_name}({final_args}))\n{skip}")
}
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_test_case(
out: &mut String,
fixture: &Fixture,
e2e_config: &E2eConfig,
default_result_is_simple: bool,
default_result_is_r_list: bool,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
errors: &[crate::core::ir::ErrorDef],
) {
let call_config = e2e_config.resolve_call_for_fixture(
fixture.call.as_deref(),
&fixture.id,
&fixture.resolved_category(),
&fixture.tags,
&fixture.input,
);
let (ir_reachable_fields, ir_known_excluded_fields, ir_optional_fields) = FieldResolver::ir_field_sets(type_defs);
let call_field_resolver = FieldResolver::new(
e2e_config.effective_fields(call_config),
e2e_config.effective_fields_optional(call_config),
e2e_config.effective_result_fields(call_config),
e2e_config.effective_fields_array(call_config),
&std::collections::HashSet::new(),
)
.with_ir_fields(ir_reachable_fields, ir_known_excluded_fields, ir_optional_fields);
let field_resolver = &call_field_resolver;
let function_name = call_config
.overrides
.get("r")
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call_config.function.clone());
let result_var = call_config.effective_result_var();
let r_override = call_config.overrides.get("r");
let result_is_simple = if fixture.call.is_some() {
call_config.result_is_simple || r_override.is_some_and(|o| o.result_is_simple)
} else {
default_result_is_simple
};
let result_is_r_list = if fixture.call.is_some() {
r_override.is_some_and(|o| o.result_is_r_list)
} else {
default_result_is_r_list
};
let test_name = sanitize_ident(&fixture.id);
let description = fixture.description.replace('"', "\\\"");
let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
let arg_name_map = r_override.map(|o| &o.arg_name_map);
let recipe = crate::e2e::codegen::recipe::ResolvedE2eCallRecipe::resolve("r", fixture, call_config, type_defs);
let options_type = recipe.compatible_options_type(&["r", "csharp", "java", "go", "php", "python"]);
let mut setup_lines = Vec::new();
let mut teardown_block = String::new();
let args_str = args::build_args_string(
&fixture.input,
fixture.resolved_args(call_config),
args::RArgsContext {
arg_name_map,
options_type,
fixture,
config,
type_defs,
setup_lines: &mut setup_lines,
teardown_block: &mut teardown_block,
},
);
let r_extra_args: Vec<String> = r_override.map(|o| o.extra_args.clone()).unwrap_or_default();
let args_with_extra = if r_extra_args.is_empty() {
args_str
} else {
let extra = r_extra_args.join(", ");
if args_str.is_empty() {
extra
} else {
format!("{args_str}, {extra}")
}
};
let final_args = if let Some(visitor_spec) = &fixture.visitor {
visitor::build_r_visitor(&mut setup_lines, visitor_spec);
let base = args::strip_options_arg(&args_with_extra);
let visitor_opts = "options = list(visitor = visitor)";
let trimmed = base.trim_matches([' ', ',']);
if trimmed.is_empty() {
visitor_opts.to_string()
} else {
format!("{trimmed}, {visitor_opts}")
}
} else {
args_with_extra
};
if expects_error {
let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
for line in &setup_lines {
let _ = writeln!(out, " {line}");
}
let error_check = render_r_error_check(&function_name, &final_args, fixture, errors);
let _ = writeln!(out, "{error_check}");
let _ = writeln!(out, "}})");
return;
}
let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
for line in &setup_lines {
let _ = writeln!(out, " {line}");
}
if call_config.returns_void {
let _ = writeln!(out, " invisible({function_name}({final_args}))");
} else if result_is_simple || result_is_r_list {
let _ = writeln!(out, " {result_var} <- {function_name}({final_args})");
} else {
let _ = writeln!(
out,
" {result_var} <- jsonlite::fromJSON({function_name}({final_args}), simplifyVector = FALSE)"
);
}
let result_is_bytes = call_config.result_is_bytes || r_override.is_some_and(|o| o.result_is_bytes);
static EMPTY_ASSERT_ENUM_FIELDS: std::sync::LazyLock<std::collections::HashMap<String, String>> =
std::sync::LazyLock::new(std::collections::HashMap::new);
let assert_enum_fields = r_override
.map(|o| &o.assert_enum_fields)
.unwrap_or(&EMPTY_ASSERT_ENUM_FIELDS);
let assertions_start = out.len();
for assertion in &fixture.assertions {
let context = assertions::RAssertionContext {
field_resolver,
result_is_simple,
result_is_bytes,
assert_enum_fields,
};
assertions::render_assertion(out, assertion, result_var, &context);
}
apply_vacuous_assertion_fallback(
out,
assertions_start,
!fixture.assertions.is_empty(),
call_config.returns_void,
result_var,
);
crate::e2e::codegen::fail_on_unavailable_field_markers(
&out[assertions_start..],
"r",
&fixture.id,
&fixture.assertions,
);
crate::e2e::codegen::fail_on_unsupported_assertion_type_markers(&out[assertions_start..], "r", &fixture.id);
for line in teardown_block.lines() {
let _ = writeln!(out, "{line}");
}
let _ = writeln!(out, "}})");
}
fn apply_vacuous_assertion_fallback(
out: &mut String,
assertions_start: usize,
has_declared_assertions: bool,
returns_void: bool,
result_var: &str,
) {
if returns_void || !has_declared_assertions {
return;
}
let has_real_assertion = out[assertions_start..]
.lines()
.any(|line| !line.trim().is_empty() && !line.trim().starts_with('#'));
if has_real_assertion {
return;
}
let _ = writeln!(out, " expect_true(!is.null({result_var}))");
}
#[cfg(test)]
mod render_r_error_check_tests {
use super::render_r_error_check;
use crate::core::ir::{ErrorDef, ErrorVariant};
use crate::e2e::fixture::{Assertion, Fixture};
fn fixture_with_declared_error(value: &str) -> Fixture {
Fixture {
id: "declares_error".to_string(),
assertions: vec![Assertion {
assertion_type: "error".to_string(),
value: Some(serde_json::Value::String(value.to_string())),
..Assertion::default()
}],
..Fixture::default()
}
}
fn coded_variant(name: &str) -> Vec<ErrorDef> {
vec![ErrorDef {
name: "ApiError".to_string(),
rust_path: "lib::ApiError".to_string(),
original_rust_path: String::new(),
variants: vec![ErrorVariant {
name: name.to_string(),
error_code: Some(100),
is_unit: true,
..ErrorVariant::default()
}],
doc: String::new(),
methods: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
version: Default::default(),
}]
}
#[test]
fn no_declared_value_is_byte_identical_to_bare_expect_error() {
let fixture = Fixture {
id: "no_error".to_string(),
..Fixture::default()
};
assert_eq!(
render_r_error_check("extract_bytes", "data", &fixture, &[]),
" expect_error(extract_bytes(data))"
);
}
#[test]
fn declared_value_adds_message_or_class_name_check() {
let fixture = fixture_with_declared_error("BadRequest");
let check = render_r_error_check("extract_bytes", "data", &fixture, &[]);
assert_eq!(
check,
" tryCatch({\n extract_bytes(data)\n fail(\"expected an error to be thrown\")\n }, error = function(e) {\n expect_true(grepl(\"BadRequest\", conditionMessage(e)) || grepl(\"BadRequest\", paste(class(e), collapse = \" \")))\n })"
);
}
#[test]
fn declared_value_with_regex_metacharacters_is_escaped() {
let fixture = fixture_with_declared_error("field.name[0]");
let check = render_r_error_check("extract_bytes", "data", &fixture, &[]);
assert!(
check.contains("\"field\\\\.name\\\\[0\\\\]\""),
"expected escaped regex literal, got: {check}"
);
}
#[test]
fn known_variant_r_cannot_substantiate_is_skipped() {
let fixture = fixture_with_declared_error("Authentication");
let errors = coded_variant("Authentication");
let check = render_r_error_check("extract_bytes", "data", &fixture, &errors);
assert_eq!(
check,
" expect_error(extract_bytes(data))\n # skipped: declared error variant 'Authentication' not yet \
preserved as a distinct identity by this backend's generator"
);
}
}
#[cfg(test)]
mod vacuous_assertion_fallback_tests {
use super::render_test_case;
use crate::core::config::ResolvedCrateConfig;
use crate::e2e::config::E2eConfig;
use crate::e2e::fixture::{Assertion, Fixture};
#[test]
fn dropped_field_assertion_still_gets_a_real_fallback_assertion() {
let mut e2e_config = E2eConfig::default();
e2e_config.call.function = "process".to_string();
e2e_config.call.result_var = "result".to_string();
e2e_config.call.result_fields = std::collections::HashSet::from(["content".to_string()]);
let fixture = Fixture {
id: "process_smoke".to_string(),
description: "test".to_string(),
assertions: vec![Assertion {
assertion_type: "equals".to_string(),
field: Some("nonexistent_field".to_string()),
value: Some(serde_json::json!("x")),
..Assertion::default()
}],
..Fixture::default()
};
let config = ResolvedCrateConfig {
name: "sample".into(),
..ResolvedCrateConfig::default()
};
let mut out = String::new();
render_test_case(&mut out, &fixture, &e2e_config, false, false, &config, &[], &[]);
assert!(
out.contains("# skipped: field 'nonexistent_field' not available on result type"),
"expected the dropped assertion's skip comment, got:\n{out}"
);
assert!(
out.contains("expect_true(!is.null(result))"),
"expected a real fallback assertion on the discarded result, got:\n{out}"
);
}
#[test]
fn zero_declared_assertions_are_left_untouched() {
let mut e2e_config = E2eConfig::default();
e2e_config.call.function = "process".to_string();
e2e_config.call.result_var = "result".to_string();
let fixture = Fixture {
id: "process_smoke".to_string(),
description: "test".to_string(),
assertions: Vec::new(),
..Fixture::default()
};
let config = ResolvedCrateConfig {
name: "sample".into(),
..ResolvedCrateConfig::default()
};
let mut out = String::new();
render_test_case(&mut out, &fixture, &e2e_config, false, false, &config, &[], &[]);
assert!(
!out.contains("expect_true(!is.null(result))"),
"a fixture with zero declared assertions must stay vacuous, got:\n{out}"
);
}
}