use crate::core::config::ResolvedCrateConfig;
use crate::e2e::config::E2eConfig;
use crate::e2e::escape::{escape_php_single, php_pcre_literal};
use crate::e2e::field_access::FieldResolver;
use crate::e2e::fixture::Fixture;
use heck::ToLowerCamelCase;
use std::collections::{HashMap, HashSet};
use std::fmt::Write as FmtWrite;
use super::{args, assertions, stubs, types, visitor};
use crate::e2e::codegen::inert_example::{self, InertCause, InertExample};
fn has_executable_assertion(body: &str) -> bool {
body.lines().any(|line| {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with("//")
})
}
fn apply_vacuous_assertion_fallback(
assertions_body: &mut String,
is_streaming: bool,
expects_error: bool,
result_var: &str,
streaming_drive_is_the_check: bool,
) {
if expects_error || has_executable_assertion(assertions_body) {
return;
}
if is_streaming {
if streaming_drive_is_the_check {
assertions_body.push_str(" $this->assertTrue(is_array($chunks), 'expected drained chunks list');\n");
}
} else {
let _ = writeln!(assertions_body, " $this->assertNotNull(${result_var});");
}
}
fn render_php_refusal(markers: &str, refusal: &InertExample) -> String {
let reason = escape_php_single(&refusal.reason());
let statement = match refusal.cause {
InertCause::UnresolvedFieldPath => format!(" $this->fail('{reason}');\n"),
InertCause::AwaitedOrLimited | InertCause::RenderedNothing => {
format!(" $this->markTestSkipped('{reason}');\n")
}
};
inert_example::refusal_body(markers, &statement)
}
fn render_error_test_body(setup_lines: &[String], call_expr: &str, declared_error: Option<&str>) -> String {
let mut out = String::new();
match declared_error {
Some(value) => {
let pattern = php_pcre_literal(value);
let message_value = escape_php_single(value);
out.push_str(" try {\n");
for line in setup_lines {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " {call_expr};");
out.push_str(" $this->fail('Expected an exception to be thrown');\n");
out.push_str(" } catch (\\Exception $e) {\n");
let _ = writeln!(
out,
" $this->assertTrue(preg_match({pattern}, $e->getMessage()) === 1 || preg_match({pattern}, get_class($e)) === 1, 'expected exception message or class name to match {message_value}');"
);
out.push_str(" }");
}
None => {
out.push_str(" $this->expectException(\\Exception::class);\n");
for line in setup_lines {
let _ = writeln!(out, " {line}");
}
let _ = write!(out, " {call_expr};");
}
}
out
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_test_method(
out: &mut String,
fixture: &Fixture,
e2e_config: &E2eConfig,
lang: &str,
namespace: &str,
class_name: &str,
type_defs: &[crate::core::ir::TypeDef],
php_enum_names: &HashSet<String>,
enum_fields: &HashMap<String, String>,
result_is_simple: bool,
php_client_factory: Option<&str>,
options_via: &str,
adapters: &[crate::core::config::extras::AdapterConfig],
php_lang_rename_all: &str,
config: &ResolvedCrateConfig,
trait_bridge_imports: &mut Vec<String>,
) {
let mut call_config = e2e_config.resolve_call_for_fixture(
fixture.call.as_deref(),
&fixture.id,
&fixture.resolved_category(),
&fixture.tags,
&fixture.input,
);
call_config = crate::e2e::codegen::select_best_matching_call(call_config, e2e_config, fixture);
let per_call_getter_map = types::build_php_getter_map(
type_defs,
php_enum_names,
call_config,
e2e_config.effective_result_fields(call_config),
);
let (ir_reachable_fields, ir_known_excluded_fields) = FieldResolver::ir_field_sets(type_defs);
let call_field_resolver = FieldResolver::new_with_php_getters(
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),
&HashSet::new(),
&HashMap::new(),
per_call_getter_map,
)
.with_display_as_text_fields(e2e_config.effective_fields_display_as_text(call_config).clone())
.with_ir_fields(ir_reachable_fields, ir_known_excluded_fields);
let field_resolver = &call_field_resolver;
let call_overrides = call_config.overrides.get(lang);
let has_override = call_overrides.is_some_and(|o| o.function.is_some());
let result_is_simple =
call_config.result_is_simple || call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
let mut function_name = call_overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call_config.function.clone());
if !has_override {
function_name = function_name.to_lower_camel_case();
}
let result_var = call_config.effective_result_var();
let recipe = crate::e2e::codegen::recipe::ResolvedE2eCallRecipe::resolve(lang, fixture, call_config, type_defs);
let args = recipe.args;
let method_name = crate::e2e::escape::sanitize_filename(&fixture.id);
let description = &fixture.description;
let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
let call_options_type = recipe.options_type.or_else(|| {
e2e_config
.call
.overrides
.get(lang)
.and_then(|o| o.options_type.as_deref())
});
let adapter_lookup_name = call_config.core_lookup_name(lang);
let call_adapter = adapter_lookup_name
.as_deref()
.and_then(|name| adapters.iter().find(|a| a.name == name));
let adapter_request_type: Option<String> = call_adapter
.and_then(|a| a.request_type.as_deref())
.map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
let streaming_owner_handle: Option<String> = if call_adapter.is_some_and(|a| {
matches!(a.pattern, crate::core::config::extras::AdapterPattern::Streaming) && a.owner_type.is_some()
}) {
args.iter().find(|a| a.arg_type == "handle").map(|a| a.name.clone())
} else {
None
};
let (mut setup_lines, args_str, teardown_block) = args::build_args_and_setup(
&fixture.input,
args,
class_name,
enum_fields,
fixture,
options_via,
call_options_type,
adapter_request_type.as_deref(),
namespace,
streaming_owner_handle.is_some(),
type_defs,
php_lang_rename_all,
config,
trait_bridge_imports,
false,
);
let skip_test = call_config.skip_languages.iter().any(|l| l == "php");
if skip_test {
let rendered = crate::e2e::template_env::render(
"php/test_method.jinja",
minijinja::context! {
method_name => method_name,
description => description,
client_factory => String::new(),
setup_lines => Vec::<String>::new(),
expects_error => false,
skip_test => true,
call_expr => String::new(),
result_var => result_var,
assertions_body => String::new(),
},
);
out.push_str(&rendered);
return;
}
let mut options_already_created = !args_str.is_empty() && args_str == "$options";
if let Some(visitor_spec) = &fixture.visitor {
visitor::build_php_visitor(&mut setup_lines, visitor_spec);
if !options_already_created {
let Some(options_type) = call_options_type.or_else(|| stubs::trait_bridge_options_type(config)) else {
panic!(
"PHP e2e generator: fixture `{}` declares a `visitor`, but neither its `[e2e.call]` config nor any `[[crates.trait_bridges]]` entry provides an `options_type` to attach it to; cannot generate a PHP visitor test without a resolvable trait bridge options type",
fixture.id
);
};
if options_via == "from_json" {
setup_lines.push(format!("$options = \\{namespace}\\{options_type}::from_json('{{}}');"));
setup_lines.push(format!(
"$visitorHandle = \\{namespace}\\VisitorHandle::from_php_object($visitor);"
));
setup_lines.push("$options = $options->withVisitor($visitorHandle);".to_string());
} else {
setup_lines.push(format!("$builder = \\{namespace}\\{options_type}::builder();"));
setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
}
options_already_created = true;
}
}
let final_args = if options_already_created {
if args_str.is_empty() || args_str == "$options" {
"$options".to_string()
} else {
format!("{args_str}, $options")
}
} else {
args_str
};
let call_expr = if php_client_factory.is_some() {
format!("$client->{function_name}({final_args})")
} else if let Some(ref handle_var) = streaming_owner_handle {
format!("${handle_var}->{function_name}({final_args})")
} else {
format!("{class_name}::{function_name}({final_args})")
};
let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
let client_factory = if let Some(factory) = php_client_factory {
let fixture_id = &fixture.id;
if let Some(var) = api_key_var.filter(|_| has_mock) {
format!(
"$apiKey = getenv('{var}');\n $baseUrl = ($apiKey !== false && $apiKey !== '') ? null : getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';\n fwrite(STDERR, \"{fixture_id}: \" . ($baseUrl === null ? 'using real API ({var} is set)' : 'using mock server ({var} not set)') . \"\\n\");\n $client = \\{namespace}\\{class_name}::{factory}($baseUrl === null ? $apiKey : 'test-key', $baseUrl);"
)
} else if has_mock {
let base_url_expr = if fixture.has_host_root_route() {
let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
format!("(getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}')")
} else {
format!("getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}'")
};
format!("$client = \\{namespace}\\{class_name}::{factory}('test-key', {base_url_expr});")
} else if let Some(var) = api_key_var {
format!(
"$apiKey = getenv('{var}');\n if (!$apiKey) {{ $this->markTestSkipped('{var} not set'); return; }}\n $client = \\{namespace}\\{class_name}::{factory}($apiKey);"
)
} else {
format!("$client = \\{namespace}\\{class_name}::{factory}('test-key');")
}
} else {
String::new()
};
let is_streaming =
crate::e2e::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming_enabled());
let collect_snippet = if is_streaming {
crate::e2e::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("php", result_var, "chunks")
.unwrap_or_default()
} else {
String::new()
};
let mut fields_array_bindings: std::collections::BTreeMap<String, (String, String)> =
std::collections::BTreeMap::new();
for assertion in &fixture.assertions {
if let Some(f) = &assertion.field {
let is_enum_variant_accessor = f.contains("metadata.format.") && f.matches('.').count() >= 2;
if !f.is_empty()
&& !is_enum_variant_accessor
&& field_resolver.is_array(f)
&& field_resolver.is_valid_for_result(f)
{
if !fields_array_bindings.contains_key(f.as_str()) {
let accessor = field_resolver.accessor(f, "php", &format!("${result_var}"));
let var_name = f.to_lower_camel_case();
fields_array_bindings.insert(f.clone(), (var_name, accessor));
}
}
}
}
let mut field_bindings = String::new();
for (var_name, accessor) in fields_array_bindings.values() {
field_bindings.push_str(&format!(" ${} = {};\n", var_name, accessor));
}
let mut assertions_body = String::new();
for assertion in &fixture.assertions {
assertions::render_assertion(
&mut assertions_body,
assertion,
result_var,
field_resolver,
result_is_simple,
call_config.result_is_array,
&fields_array_bindings,
is_streaming,
);
}
crate::e2e::codegen::fail_on_unavailable_field_markers(&assertions_body, "php", &fixture.id, &fixture.assertions);
crate::e2e::codegen::fail_on_unsupported_assertion_type_markers(&assertions_body, "php", &fixture.id);
let verdict = if expects_error {
None
} else {
inert_example::inert_verdict(&assertions_body, "php", &fixture.id, &fixture.assertions)
};
let declares_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
let refuse = verdict.as_ref().is_some_and(|refusal| {
refusal.cause == InertCause::UnresolvedFieldPath || (is_streaming && !declares_not_error)
});
if let Some(refusal) = verdict.filter(|_| refuse) {
inert_example::record_refusal(&refusal);
assertions_body = render_php_refusal(&assertions_body, &refusal);
} else {
apply_vacuous_assertion_fallback(
&mut assertions_body,
is_streaming,
expects_error,
result_var,
declares_not_error,
);
}
let error_test_body = if expects_error {
let mut body = render_error_test_body(
&setup_lines,
&call_expr,
crate::e2e::codegen::declared_error_value(fixture),
);
let markers = crate::e2e::codegen::error_path_assertions::render(fixture, " // ", "php");
if !markers.is_empty() {
body.push('\n');
body.push_str(markers.trim_end());
}
body
} else {
String::new()
};
let rendered = crate::e2e::template_env::render(
"php/test_method.jinja",
minijinja::context! {
method_name => method_name,
description => description,
client_factory => client_factory,
setup_lines => setup_lines,
expects_error => expects_error,
error_test_body => error_test_body,
skip_test => fixture.assertions.is_empty(),
call_expr => call_expr,
result_var => result_var,
collect_snippet => collect_snippet,
field_bindings => field_bindings,
assertions_body => assertions_body,
teardown_block => teardown_block,
},
);
out.push_str(&rendered);
}
#[cfg(test)]
mod vacuous_assertion_fallback_tests {
use super::{apply_vacuous_assertion_fallback, has_executable_assertion};
#[test]
fn has_executable_assertion_is_false_for_comment_only_body() {
let body = " // skipped: field 'foo' not available on result type\n";
assert!(
!has_executable_assertion(body),
"comment-only body must not count as asserting"
);
}
#[test]
fn has_executable_assertion_is_false_for_empty_body() {
assert!(!has_executable_assertion(""));
assert!(!has_executable_assertion(" \n \n"));
}
#[test]
fn has_executable_assertion_is_true_when_a_real_statement_is_present() {
let body =
" // skipped: field 'foo' not available on result type\n $this->assertTrue($result->ok);\n";
assert!(
has_executable_assertion(body),
"a real assertTrue line must count as asserting"
);
}
#[test]
fn not_error_only_body_gets_a_real_assertion_not_a_framework_suppression() {
let mut body = String::new();
apply_vacuous_assertion_fallback(&mut body, false, false, "result", false);
assert_eq!(body, " $this->assertNotNull($result);\n");
assert!(!body.contains("expectNotToPerformAssertions"));
}
#[test]
fn comment_only_body_also_gets_a_real_assertion() {
let mut body = " // skipped: field 'chunks' not available on result type\n".to_string();
apply_vacuous_assertion_fallback(&mut body, false, false, "result", false);
assert!(
body.contains("$this->assertNotNull($result);"),
"a comment-only body must still get a real fallback assertion, got: {body}"
);
assert!(!body.contains("expectNotToPerformAssertions"));
}
#[test]
fn streaming_not_error_body_keeps_the_line_that_marks_the_test_non_risky() {
let mut body = String::new();
apply_vacuous_assertion_fallback(&mut body, true, false, "result", true);
assert_eq!(
body,
" $this->assertTrue(is_array($chunks), 'expected drained chunks list');\n"
);
}
#[test]
fn streaming_body_without_not_error_gets_no_guard_that_cannot_fail() {
let mut body = " // skipped: field 'x' not available on result type\n".to_string();
let original = body.clone();
apply_vacuous_assertion_fallback(&mut body, true, false, "result", false);
assert_eq!(
body, original,
"a check that cannot fail must not be injected in place of the dropped assertions"
);
}
#[test]
fn error_expecting_fixture_is_left_untouched() {
let mut body = String::new();
apply_vacuous_assertion_fallback(&mut body, false, true, "result", false);
assert!(
body.is_empty(),
"error-expecting fixtures render their own error_test_body, not this fallback"
);
}
#[test]
fn body_with_a_real_assertion_is_left_untouched() {
let mut body = " $this->assertEquals(1, $result->count);\n".to_string();
let original = body.clone();
apply_vacuous_assertion_fallback(&mut body, false, false, "result", false);
assert_eq!(
body, original,
"a fixture with a real assertion must not get an extra fallback line"
);
}
#[test]
fn skip_comment_carries_the_marker_the_strict_mode_matches_on() {
let mut body = " // skipped: field 'nonexistent_field' not available on result type\n".to_string();
assert!(!has_executable_assertion(&body));
apply_vacuous_assertion_fallback(&mut body, false, false, "result", false);
assert!(
body.contains("field 'nonexistent_field' not available on result type"),
"got: {body}"
);
}
}
#[cfg(test)]
mod error_test_body_tests {
use super::render_error_test_body;
#[test]
fn no_declared_value_is_byte_identical_to_expect_exception() {
let body = render_error_test_body(
&["$options = new Options();".to_string()],
"Client::create($options)",
None,
);
assert_eq!(
body,
" $this->expectException(\\Exception::class);\n $options = new Options();\n Client::create($options);"
);
}
#[test]
fn declared_value_adds_message_or_class_name_check() {
let body = render_error_test_body(&[], "Client::create()", Some("BadRequest"));
assert_eq!(
body,
" try {\n Client::create();\n $this->fail('Expected an exception to be thrown');\n } catch (\\Exception $e) {\n $this->assertTrue(preg_match('/BadRequest/', $e->getMessage()) === 1 || preg_match('/BadRequest/', get_class($e)) === 1, 'expected exception message or class name to match BadRequest');\n }"
);
}
#[test]
fn declared_value_with_regex_metacharacters_is_escaped() {
let body = render_error_test_body(&[], "Client::create()", Some("field.name[0]"));
assert!(
body.contains("'/field\\\\.name\\\\[0\\\\]/'"),
"expected escaped PCRE literal, got: {body}"
);
}
#[test]
fn declared_value_message_argument_is_a_well_formed_php_single_quoted_literal() {
let body = render_error_test_body(&[], "Client::create()", Some("max_depth"));
let message_start = body
.find("'expected exception message or class name to match")
.expect("message argument must be present");
let message = &body[message_start..];
let message_end = message
.find("');")
.expect("message argument must be closed and followed by ');'");
let literal = &message[..=message_end];
let quote_count = literal.replace("\\'", "").matches('\'').count();
assert_eq!(
quote_count, 2,
"message argument must be a single balanced single-quoted PHP string, got: {literal}"
);
assert!(
literal.contains("max_depth"),
"failure message should still name the expected pattern: {literal}"
);
assert!(
!literal.contains("'/max_depth/'"),
"must not embed the pre-quoted PCRE literal inside the message string: {literal}"
);
}
#[test]
fn declared_value_with_single_quote_is_escaped_in_message() {
let body = render_error_test_body(&[], "Client::create()", Some("it's bad"));
assert!(
body.contains("match it\\'s bad')"),
"expected escaped single quote in message, got: {body}"
);
}
}
#[cfg(test)]
mod visitor_options_type_tests {
use super::render_test_method;
use crate::core::config::ResolvedCrateConfig;
use crate::e2e::config::E2eConfig;
use crate::e2e::fixture::{CallbackAction, Fixture, VisitorSpec};
use std::collections::{HashMap, HashSet};
#[test]
#[should_panic(expected = "PHP e2e generator: fixture `visitor_smoke` declares a `visitor`")]
fn visitor_fixture_without_trait_bridge_options_type_fails_loudly_instead_of_emitting_a_skip() {
let fixture = Fixture {
id: "visitor_smoke".into(),
description: "Visitor smoke".into(),
visitor: Some(VisitorSpec {
callbacks: [("visit_element".to_string(), CallbackAction::Skip)]
.into_iter()
.collect(),
}),
..Fixture::default()
};
let mut e2e_config = E2eConfig::default();
e2e_config.call.function = "convert".into();
e2e_config.call.result_var = "result".into();
let config = ResolvedCrateConfig {
name: "sample".into(),
..ResolvedCrateConfig::default()
};
let mut out = String::new();
let mut trait_bridge_imports: Vec<String> = Vec::new();
render_test_method(
&mut out,
&fixture,
&e2e_config,
"php",
"Sample",
"SampleClient",
&[],
&HashSet::new(),
&HashMap::new(),
false,
None,
"",
&[],
"",
&config,
&mut trait_bridge_imports,
);
}
fn render_php_error_method(extra: Vec<crate::e2e::fixture::Assertion>, declared: Option<&str>) -> String {
let mut assertions = vec![crate::e2e::fixture::Assertion {
assertion_type: "error".into(),
value: declared.map(|v| serde_json::Value::String(v.to_string())),
..Default::default()
}];
assertions.extend(extra);
let fixture = Fixture {
id: "rate_limited".into(),
description: "Rejects the request".into(),
assertions,
..Fixture::default()
};
let mut e2e_config = E2eConfig::default();
e2e_config.call.function = "parse".into();
e2e_config.call.result_var = "result".into();
let config = ResolvedCrateConfig {
name: "sample".into(),
..ResolvedCrateConfig::default()
};
let mut out = String::new();
let mut trait_bridge_imports: Vec<String> = Vec::new();
let _ = crate::e2e::codegen::take_skip_records();
render_test_method(
&mut out,
&fixture,
&e2e_config,
"php",
"Sample",
"SampleClient",
&[],
&HashSet::new(),
&HashMap::new(),
false,
None,
"",
&[],
"",
&config,
&mut trait_bridge_imports,
);
out
}
#[test]
fn php_equals_on_an_error_field_is_named_instead_of_dropped() {
let out = render_php_error_method(
vec![crate::e2e::fixture::Assertion {
assertion_type: "equals".into(),
field: Some("error.status_code".into()),
..Default::default()
}],
Some("BadRequest"),
);
assert!(
out.contains("catch (\\Exception $e)"),
"the error block must render: {out}"
);
assert!(
out.contains(
"// skipped: assertion type 'equals' has no accessor for error field error.status_code in this \
backend"
),
"{out}"
);
let records = crate::e2e::codegen::take_skip_records();
assert_eq!(records.len(), 1, "got: {records:?}");
assert_eq!(records[0].language, "php");
assert_eq!(records[0].field, "equals");
}
#[test]
fn php_a_lone_error_assertion_renders_no_marker() {
let out = render_php_error_method(Vec::new(), None);
assert!(
out.contains("$this->expectException(\\Exception::class);"),
"the error block must render: {out}"
);
assert!(!out.contains("has no accessor for error field"), "{out}");
}
}
#[cfg(test)]
mod inert_example_refusal_tests {
use super::render_test_method;
use crate::core::config::ResolvedCrateConfig;
use crate::e2e::codegen::inert_example::take_inert_examples;
use crate::e2e::config::E2eConfig;
use crate::e2e::fixture::{Assertion, Fixture};
use std::collections::{HashMap, HashSet};
fn assertion(field: &str) -> Assertion {
Assertion {
assertion_type: "equals".to_string(),
field: Some(field.to_string()),
value: Some(serde_json::json!("x")),
..Default::default()
}
}
fn render(fixture_id: &str, assertions: Vec<Assertion>) -> String {
let fixture = Fixture {
id: fixture_id.to_string(),
description: "php refusal fixture".to_string(),
assertions,
..Fixture::default()
};
let mut e2e_config = E2eConfig::default();
e2e_config.call.function = "process".into();
e2e_config.call.result_var = "result".into();
e2e_config.call.result_fields = HashSet::from(["content".to_string()]);
let config = ResolvedCrateConfig {
name: "sample".into(),
..ResolvedCrateConfig::default()
};
let mut out = String::new();
let mut trait_bridge_imports: Vec<String> = Vec::new();
render_test_method(
&mut out,
&fixture,
&e2e_config,
"php",
"Sample",
"SampleClient",
&[],
&HashSet::new(),
&HashMap::new(),
false,
None,
"",
&[],
"",
&config,
&mut trait_bridge_imports,
);
out
}
#[test]
fn a_resolvable_assertion_is_published_unchanged() {
let _ = take_inert_examples();
let out = render("php_control", vec![assertion("content")]);
assert!(
out.contains("$this->assertEquals(\"x\","),
"the renderable assertion must still be emitted, got:\n{out}"
);
assert!(
!out.contains("markTestSkipped('alef") && !out.contains("$this->fail('alef"),
"a live example must not be refused, got:\n{out}"
);
assert!(
take_inert_examples().is_empty(),
"nothing may be recorded for a live example"
);
}
#[test]
fn an_unresolved_field_path_is_refused_with_a_failing_check() {
let _ = take_inert_examples();
let out = render(
"php_unresolved",
vec![assertion("nonexistent_field"), assertion("another_missing_field")],
);
assert!(
out.contains("$this->fail('alef resolved no assertion for fixture `php_unresolved`"),
"a consumer-fixable gap must be refused with a FAILING check, got:\n{out}"
);
assert!(
out.contains("// skipped:") && out.contains("nonexistent_field") && out.contains("another_missing_field"),
"the skip markers must be carried into the refusal, not replaced by silence, got:\n{out}"
);
assert!(
!out.contains("$this->assertNotNull($result);"),
"the vacuous fallback must not stand in for the refused assertions, got:\n{out}"
);
let refusals = take_inert_examples();
assert_eq!(refusals.len(), 1, "the refusal must be recorded once for the summary");
assert_eq!(refusals[0].fixture_id, "php_unresolved");
}
#[test]
fn acknowledged_debt_on_a_non_streaming_call_keeps_its_failable_fallback() {
let _ = take_inert_examples();
let out = render(
"php_generator_debt",
vec![Assertion {
assertion_type: "equals".to_string(),
field: Some("nonexistent_field".to_string()),
value: Some(serde_json::json!("x")),
skip: Some(crate::e2e::fixture::AssertionSkip::All(true)),
..Default::default()
}],
);
assert!(
out.contains("$this->assertNotNull($result);"),
"the failable fallback must survive, got:\n{out}"
);
assert!(
out.contains("// skipped:"),
"the marker must survive beside the fallback, got:\n{out}"
);
assert!(
take_inert_examples().is_empty(),
"an example that still asserts something is not a refusal"
);
}
#[test]
fn a_fixture_with_no_declared_assertions_keeps_its_smoke_test_shape() {
let _ = take_inert_examples();
let out = render("php_smoke_only", Vec::new());
assert!(
out.contains("$this->assertNotNull($result);"),
"the pre-existing smoke fallback must be untouched, got:\n{out}"
);
assert!(
!out.contains("markTestSkipped('alef") && !out.contains("$this->fail('alef"),
"a fixture with no assertions must never be refused, got:\n{out}"
);
assert!(take_inert_examples().is_empty());
}
}