1use crate::config::E2eConfig;
8use crate::escape::sanitize_filename;
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions::pub_dev;
15use anyhow::Result;
16use heck::{ToLowerCamelCase, ToSnakeCase};
17use std::collections::HashSet;
18use std::fmt::Write as FmtWrite;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22
23pub struct DartE2eCodegen;
25
26impl E2eCodegen for DartE2eCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 alef_config: &AlefConfig,
32 ) -> Result<Vec<GeneratedFile>> {
33 let lang = self.language_name();
34 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36 let mut files = Vec::new();
37
38 let call = &e2e_config.call;
40 let overrides = call.overrides.get(lang);
41 let function_name = overrides
42 .and_then(|o| o.function.as_ref())
43 .cloned()
44 .unwrap_or_else(|| call.function.clone());
45 let result_var = &call.result_var;
46 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
47
48 let dart_pkg = e2e_config.resolve_package("dart");
50 let pkg_name = dart_pkg
54 .as_ref()
55 .and_then(|p| p.name.as_ref())
56 .cloned()
57 .unwrap_or_else(|| alef_config.dart_pubspec_name());
58 let pkg_path = dart_pkg
59 .as_ref()
60 .and_then(|p| p.path.as_ref())
61 .cloned()
62 .unwrap_or_else(|| "../../packages/dart".to_string());
63 let pkg_version = dart_pkg
64 .as_ref()
65 .and_then(|p| p.version.as_ref())
66 .cloned()
67 .unwrap_or_else(|| "0.1.0".to_string());
68
69 files.push(GeneratedFile {
71 path: output_base.join("pubspec.yaml"),
72 content: render_pubspec(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
73 generated_header: false,
74 });
75
76 let test_base = output_base.join("test");
77
78 let field_resolver = FieldResolver::new(
79 &e2e_config.fields,
80 &e2e_config.fields_optional,
81 &e2e_config.result_fields,
82 &e2e_config.fields_array,
83 );
84
85 for group in groups {
87 let active: Vec<&Fixture> = group
88 .fixtures
89 .iter()
90 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
91 .collect();
92
93 if active.is_empty() {
94 continue;
95 }
96
97 let filename = format!("{}_test.dart", sanitize_filename(&group.category));
98 let content = render_test_file(
99 &group.category,
100 &active,
101 e2e_config,
102 &pkg_name,
103 &function_name,
104 result_var,
105 &e2e_config.call.args,
106 &field_resolver,
107 result_is_simple,
108 &e2e_config.fields_enum,
109 );
110 files.push(GeneratedFile {
111 path: test_base.join(filename),
112 content,
113 generated_header: true,
114 });
115 }
116
117 Ok(files)
118 }
119
120 fn language_name(&self) -> &'static str {
121 "dart"
122 }
123}
124
125fn render_pubspec(
130 pkg_name: &str,
131 pkg_path: &str,
132 pkg_version: &str,
133 dep_mode: crate::config::DependencyMode,
134) -> String {
135 let test_ver = pub_dev::TEST_PACKAGE;
136
137 let dep_block = match dep_mode {
138 crate::config::DependencyMode::Registry => {
139 format!(" {pkg_name}: ^{pkg_version}")
140 }
141 crate::config::DependencyMode::Local => {
142 format!(" {pkg_name}:\n path: {pkg_path}")
143 }
144 };
145
146 format!(
147 r#"name: e2e_dart
148version: 0.1.0
149publish_to: none
150
151environment:
152 sdk: ">=3.0.0 <4.0.0"
153
154dependencies:
155{dep_block}
156
157dev_dependencies:
158 test: {test_ver}
159"#
160 )
161}
162
163#[allow(clippy::too_many_arguments)]
164fn render_test_file(
165 category: &str,
166 fixtures: &[&Fixture],
167 e2e_config: &E2eConfig,
168 pkg_name: &str,
169 function_name: &str,
170 result_var: &str,
171 args: &[crate::config::ArgMapping],
172 field_resolver: &FieldResolver,
173 result_is_simple: bool,
174 enum_fields: &HashSet<String>,
175) -> String {
176 let mut out = String::new();
177 out.push_str(&hash::header(CommentStyle::DoubleSlash));
178 let module_name = pkg_name.to_snake_case();
179 let needs_dart_io = args.iter().any(|a| a.arg_type == "mock_url");
181 let _ = writeln!(out, "import 'package:test/test.dart';");
182 if needs_dart_io {
183 let _ = writeln!(out, "import 'dart:io';");
184 }
185 let _ = writeln!(out, "import 'package:{module_name}/{module_name}.dart';");
186 let _ = writeln!(out);
187
188 let _ = writeln!(out, "// E2e tests for category: {category}");
189 let _ = writeln!(out, "void main() {{");
190
191 for fixture in fixtures {
192 render_test_case(
193 &mut out,
194 fixture,
195 e2e_config,
196 function_name,
197 result_var,
198 args,
199 field_resolver,
200 result_is_simple,
201 enum_fields,
202 );
203 }
204
205 let _ = writeln!(out, "}}");
206 out
207}
208
209#[allow(clippy::too_many_arguments)]
210fn render_test_case(
211 out: &mut String,
212 fixture: &Fixture,
213 e2e_config: &E2eConfig,
214 _function_name: &str,
215 _result_var: &str,
216 _args: &[crate::config::ArgMapping],
217 field_resolver: &FieldResolver,
218 result_is_simple: bool,
219 enum_fields: &HashSet<String>,
220) {
221 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
223 let lang = "dart";
224 let call_overrides = call_config.overrides.get(lang);
225 let function_name = call_overrides
226 .and_then(|o| o.function.as_ref())
227 .cloned()
228 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
229 let result_var = &call_config.result_var;
230 let args = &call_config.args;
231
232 let description = &fixture.description;
233 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
234 let is_async = call_config.r#async;
235
236 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, &fixture.id);
237
238 if is_async {
242 let _ = writeln!(out, " test('{description}', () async {{");
243 } else {
244 let _ = writeln!(out, " test('{description}', () {{");
245 }
246
247 for line in &setup_lines {
248 let _ = writeln!(out, " {line}");
249 }
250
251 if expects_error {
252 if is_async {
253 let _ = writeln!(
254 out,
255 " await expectLater({function_name}({args_str}), throwsA(isA<Exception>()));"
256 );
257 } else {
258 let _ = writeln!(
259 out,
260 " expect(() => {function_name}({args_str}), throwsA(isA<Exception>()));"
261 );
262 }
263 let _ = writeln!(out, " }});");
264 let _ = writeln!(out);
265 return;
266 }
267
268 if is_async {
269 let _ = writeln!(out, " final {result_var} = await {function_name}({args_str});");
270 } else {
271 let _ = writeln!(out, " final {result_var} = {function_name}({args_str});");
272 }
273
274 for assertion in &fixture.assertions {
275 render_assertion(
276 out,
277 assertion,
278 result_var,
279 field_resolver,
280 result_is_simple,
281 enum_fields,
282 );
283 }
284
285 let _ = writeln!(out, " }});");
286 let _ = writeln!(out);
287}
288
289fn build_args_and_setup(
291 input: &serde_json::Value,
292 args: &[crate::config::ArgMapping],
293 fixture_id: &str,
294) -> (Vec<String>, String) {
295 if args.is_empty() {
296 return (Vec::new(), String::new());
297 }
298
299 let mut setup_lines: Vec<String> = Vec::new();
300 let mut parts: Vec<String> = Vec::new();
301
302 for arg in args {
303 if arg.arg_type == "mock_url" {
304 setup_lines.push(format!(
305 "final {} = Platform.environment['MOCK_SERVER_URL']! + '/fixtures/{fixture_id}';",
306 arg.name,
307 ));
308 parts.push(arg.name.clone());
309 continue;
310 }
311
312 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
313 let val = input.get(field);
314 match val {
315 None | Some(serde_json::Value::Null) if arg.optional => {
316 continue;
317 }
318 None | Some(serde_json::Value::Null) => {
319 let default_val = match arg.arg_type.as_str() {
320 "string" => "''".to_string(),
321 "int" | "integer" => "0".to_string(),
322 "float" | "number" => "0.0".to_string(),
323 "bool" | "boolean" => "false".to_string(),
324 _ => "null".to_string(),
325 };
326 parts.push(default_val);
327 }
328 Some(v) => {
329 parts.push(json_to_dart(v));
330 }
331 }
332 }
333
334 (setup_lines, parts.join(", "))
335}
336
337fn render_assertion(
338 out: &mut String,
339 assertion: &Assertion,
340 result_var: &str,
341 field_resolver: &FieldResolver,
342 result_is_simple: bool,
343 enum_fields: &HashSet<String>,
344) {
345 if let Some(f) = &assertion.field {
347 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
348 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
349 return;
350 }
351 }
352
353 let field_is_enum = assertion
355 .field
356 .as_deref()
357 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
358
359 let field_expr = if result_is_simple {
360 result_var.to_string()
361 } else {
362 match &assertion.field {
363 Some(f) if !f.is_empty() => field_resolver.accessor(f, "dart", result_var),
364 _ => result_var.to_string(),
365 }
366 };
367
368 let string_expr = if field_is_enum {
370 format!("{field_expr}.name")
371 } else {
372 field_expr.clone()
373 };
374
375 match assertion.assertion_type.as_str() {
376 "equals" => {
377 if let Some(expected) = &assertion.value {
378 let dart_val = json_to_dart(expected);
379 if expected.is_string() {
380 let _ = writeln!(out, " expect({string_expr}.trim(), equals({dart_val}));");
381 } else {
382 let _ = writeln!(out, " expect({field_expr}, equals({dart_val}));");
383 }
384 }
385 }
386 "contains" => {
387 if let Some(expected) = &assertion.value {
388 let dart_val = json_to_dart(expected);
389 let _ = writeln!(out, " expect({string_expr}, contains({dart_val}));");
390 }
391 }
392 "contains_all" => {
393 if let Some(values) = &assertion.values {
394 for val in values {
395 let dart_val = json_to_dart(val);
396 let _ = writeln!(out, " expect({string_expr}, contains({dart_val}));");
397 }
398 }
399 }
400 "not_contains" => {
401 if let Some(expected) = &assertion.value {
402 let dart_val = json_to_dart(expected);
403 let _ = writeln!(out, " expect({string_expr}, isNot(contains({dart_val})));");
404 }
405 }
406 "not_empty" => {
407 let _ = writeln!(out, " expect({field_expr}, isNotEmpty);");
408 }
409 "is_empty" => {
410 let _ = writeln!(out, " expect({field_expr}, isEmpty);");
411 }
412 "contains_any" => {
413 if let Some(values) = &assertion.values {
414 let checks: Vec<String> = values
415 .iter()
416 .map(|v| {
417 let dart_val = json_to_dart(v);
418 format!("{string_expr}.contains({dart_val})")
419 })
420 .collect();
421 let joined = checks.join(" || ");
422 let _ = writeln!(
423 out,
424 " expect({joined}, isTrue, reason: 'expected to contain at least one of the specified values');"
425 );
426 }
427 }
428 "greater_than" => {
429 if let Some(val) = &assertion.value {
430 let dart_val = json_to_dart(val);
431 let _ = writeln!(out, " expect({field_expr}, greaterThan({dart_val}));");
432 }
433 }
434 "less_than" => {
435 if let Some(val) = &assertion.value {
436 let dart_val = json_to_dart(val);
437 let _ = writeln!(out, " expect({field_expr}, lessThan({dart_val}));");
438 }
439 }
440 "greater_than_or_equal" => {
441 if let Some(val) = &assertion.value {
442 let dart_val = json_to_dart(val);
443 let _ = writeln!(out, " expect({field_expr}, greaterThanOrEqualTo({dart_val}));");
444 }
445 }
446 "less_than_or_equal" => {
447 if let Some(val) = &assertion.value {
448 let dart_val = json_to_dart(val);
449 let _ = writeln!(out, " expect({field_expr}, lessThanOrEqualTo({dart_val}));");
450 }
451 }
452 "starts_with" => {
453 if let Some(expected) = &assertion.value {
454 let dart_val = json_to_dart(expected);
455 let _ = writeln!(out, " expect({string_expr}, startsWith({dart_val}));");
456 }
457 }
458 "ends_with" => {
459 if let Some(expected) = &assertion.value {
460 let dart_val = json_to_dart(expected);
461 let _ = writeln!(out, " expect({string_expr}, endsWith({dart_val}));");
462 }
463 }
464 "min_length" => {
465 if let Some(val) = &assertion.value {
466 if let Some(n) = val.as_u64() {
467 let _ = writeln!(out, " expect({field_expr}.length, greaterThanOrEqualTo({n}));");
468 }
469 }
470 }
471 "max_length" => {
472 if let Some(val) = &assertion.value {
473 if let Some(n) = val.as_u64() {
474 let _ = writeln!(out, " expect({field_expr}.length, lessThanOrEqualTo({n}));");
475 }
476 }
477 }
478 "count_min" => {
479 if let Some(val) = &assertion.value {
480 if let Some(n) = val.as_u64() {
481 let _ = writeln!(out, " expect({field_expr}.length, greaterThanOrEqualTo({n}));");
482 }
483 }
484 }
485 "count_equals" => {
486 if let Some(val) = &assertion.value {
487 if let Some(n) = val.as_u64() {
488 let _ = writeln!(out, " expect({field_expr}.length, equals({n}));");
489 }
490 }
491 }
492 "is_true" => {
493 let _ = writeln!(out, " expect({field_expr}, isTrue);");
494 }
495 "is_false" => {
496 let _ = writeln!(out, " expect({field_expr}, isFalse);");
497 }
498 "matches_regex" => {
499 if let Some(expected) = &assertion.value {
500 let dart_val = json_to_dart(expected);
501 let _ = writeln!(out, " expect({string_expr}, matches({dart_val}));");
502 }
503 }
504 "not_error" => {
505 }
507 "error" => {
508 }
510 "method_result" => {
511 let _ = writeln!(out, " // method_result assertions not yet implemented for Dart");
512 }
513 other => {
514 panic!("Dart e2e generator: unsupported assertion type: {other}");
515 }
516 }
517}
518
519fn json_to_dart(value: &serde_json::Value) -> String {
521 match value {
522 serde_json::Value::String(s) => format!("'{}'", escape_dart(s)),
523 serde_json::Value::Bool(b) => b.to_string(),
524 serde_json::Value::Number(n) => n.to_string(),
525 serde_json::Value::Null => "null".to_string(),
526 serde_json::Value::Array(arr) => {
527 let items: Vec<String> = arr.iter().map(json_to_dart).collect();
528 format!("[{}]", items.join(", "))
529 }
530 serde_json::Value::Object(_) => {
531 let json_str = serde_json::to_string(value).unwrap_or_default();
532 format!("'{}'", escape_dart(&json_str))
533 }
534 }
535}
536
537fn escape_dart(s: &str) -> String {
539 s.replace('\\', "\\\\")
540 .replace('\'', "\\'")
541 .replace('\n', "\\n")
542 .replace('\r', "\\r")
543 .replace('\t', "\\t")
544 .replace('$', "\\$")
545}