//! End-to-End-Test: lade Beispielmodul, kompiliere zu Binary, führe es aus. //! //! Dieses Test schützt die wichtigste Eigenschaft der gesamten Pipeline: //! AST → typecheck → LLVM IR → clang → Binary → korrekter stdout. use std::path::Path; use std::process::Command; fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") } fn build_and_run(example: &str) -> String { // Workspace-Root liegt zwei Ebenen über dem Crate-Manifest. let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join(example); let tmp = std::env::temp_dir().join(format!( "ailang_e2e_{}_{}", example.replace('.', "_"), std::process::id() )); std::fs::create_dir_all(&tmp).unwrap(); let out = tmp.join("bin"); let status = Command::new(ail_bin()) .args(["build", src.to_str().unwrap(), "-o"]) .arg(&out) .status() .expect("ail build failed to run"); assert!(status.success(), "ail build failed for {example}"); let output = Command::new(&out).output().expect("execute binary"); assert!( output.status.success(), "binary {} exited non-zero", out.display() ); String::from_utf8(output.stdout).expect("stdout utf8") } #[test] fn sum_1_to_10_is_55() { let stdout = build_and_run("sum.ail.json"); assert_eq!(stdout.trim(), "55"); } /// Schützt das Block-Tracking im Codegen: max3 hat verschachtelte `if`s, /// und falsches phi-Block-Tracking würde hier zu falschem Ergebnis führen. #[test] fn max3_picks_largest() { let stdout = build_and_run("max3.ail.json"); assert_eq!(stdout.trim(), "17"); } #[test] fn hello_world_str_lit() { let stdout = build_and_run("hello.ail.json"); assert_eq!(stdout.trim(), "Hallo, AILang."); } /// Schützt ADT-Codegen + Match: rekursive Liste, sum_list via match auf Cons/Nil. #[test] fn list_sum_via_match() { let stdout = build_and_run("list.ail.json"); assert_eq!(stdout.trim(), "42"); } /// Schützt `ail diff`: ein modifizierter Body ändert den Hash von `sum`, /// während `main` unverändert bleibt. Erwartet Exit-Code 1, `changed` /// enthält genau `sum`, `unchanged` enthält `main`, `added`/`removed` leer. #[test] fn diff_detects_changed_def() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src_a = workspace.join("examples").join("sum.ail.json"); // Variante: lade sum.ail.json, mutiere den `then`-Zweig (statt 0 nun 1) // in der `sum`-Definition. `main` bleibt bitidentisch. let raw = std::fs::read(&src_a).expect("read sum.ail.json"); let mut module: serde_json::Value = serde_json::from_slice(&raw).expect("parse sum.ail.json"); { let defs = module .get_mut("defs") .and_then(|d| d.as_array_mut()) .expect("defs array"); for def in defs.iter_mut() { if def.get("name").and_then(|n| n.as_str()) == Some("sum") { // Ersetze den then-Zweig literal 0 → literal 1. let new_then = serde_json::json!({ "t": "lit", "lit": { "kind": "int", "value": 1 } }); def["body"]["then"] = new_then; } } } let tmp = std::env::temp_dir().join(format!( "ailang_diff_changed_{}", std::process::id() )); std::fs::create_dir_all(&tmp).unwrap(); let path_b = tmp.join("sum_v2.ail.json"); std::fs::write(&path_b, serde_json::to_vec_pretty(&module).unwrap()) .expect("write sum_v2.ail.json"); let output = Command::new(ail_bin()) .args([ "diff", src_a.to_str().unwrap(), path_b.to_str().unwrap(), "--json", ]) .output() .expect("ail diff failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!( code, 1, "expected exit code 1 for differing modules; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let added = v["added"].as_array().expect("added array"); let removed = v["removed"].as_array().expect("removed array"); let changed = v["changed"].as_array().expect("changed array"); let unchanged = v["unchanged"].as_array().expect("unchanged array"); assert!(added.is_empty(), "expected added empty: {added:?}"); assert!(removed.is_empty(), "expected removed empty: {removed:?}"); assert_eq!(changed.len(), 1, "expected one changed entry: {changed:?}"); assert_eq!( changed[0].get("name").and_then(|n| n.as_str()), Some("sum") ); assert_ne!( changed[0].get("hash_a").and_then(|n| n.as_str()), changed[0].get("hash_b").and_then(|n| n.as_str()), "hash_a and hash_b must differ for a changed def" ); assert!( unchanged.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("main")), "expected `main` in unchanged: {unchanged:?}" ); } /// Diff eines Moduls mit sich selbst: Exit 0, alle Listen außer `unchanged` leer. #[test] fn diff_no_changes_exit_zero() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join("sum.ail.json"); let output = Command::new(ail_bin()) .args([ "diff", src.to_str().unwrap(), src.to_str().unwrap(), "--json", ]) .output() .expect("ail diff failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!( code, 0, "expected exit code 0 for identical modules; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); assert!(v["added"].as_array().unwrap().is_empty()); assert!(v["removed"].as_array().unwrap().is_empty()); assert!(v["changed"].as_array().unwrap().is_empty()); assert!( !v["unchanged"].as_array().unwrap().is_empty(), "self-diff should report unchanged defs" ); } /// Schützt den Workspace-Loader (Iter 5a): das Eintrittsmodul `ws_main` /// importiert `ws_lib`, beide müssen vom Loader gefunden, geladen und im /// JSON-Output aufgelistet sein. Cross-Module-Typcheck/Codegen ist explizit /// nicht Teil dieses Tests — er verifiziert nur die Lader-Pipeline. #[test] fn workspace_lists_imported_modules() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args(["workspace", entry.to_str().unwrap(), "--json"]) .output() .expect("ail workspace failed to run"); assert!( output.status.success(), "ail workspace exited non-zero; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); assert_eq!( v.get("entry").and_then(|n| n.as_str()), Some("ws_main"), "entry must be ws_main: {stdout}" ); let modules = v["modules"].as_array().expect("modules must be array"); assert_eq!(modules.len(), 2, "expected 2 modules: {stdout}"); let names: Vec<&str> = modules .iter() .filter_map(|m| m.get("name").and_then(|n| n.as_str())) .collect(); assert!(names.contains(&"ws_main"), "ws_main missing: {names:?}"); assert!(names.contains(&"ws_lib"), "ws_lib missing: {names:?}"); } /// Schützt Iter 5b: `ail check examples/ws_main.ail.json --json` muss den /// Cross-Module-Aufruf `ws_lib.add` auflösen können. Erwartet: Exit 0, /// stdout exakt `[]` (leeres Diagnostic-Array). #[test] fn check_workspace_resolves_import() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args(["check", entry.to_str().unwrap(), "--json"]) .output() .expect("ail check --json failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!( code, 0, "expected exit 0; stderr: {}; stdout: {}", String::from_utf8_lossy(&output.stderr), String::from_utf8_lossy(&output.stdout), ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); assert_eq!(stdout.trim(), "[]", "expected empty diagnostic array"); } /// Schützt Iter 5c (Cross-Module-Codegen): `ail build` über /// `examples/ws_main.ail.json` muss das Workspace inkl. `ws_lib` lowern, /// `@ail_ws_main_main` muss `@ail_ws_lib_add(2,3)` aufrufen und `5` drucken. #[test] fn workspace_build_runs_imported_fn() { let stdout = build_and_run("ws_main.ail.json"); assert_eq!(stdout.trim(), "5", "ws_lib.add(2,3) should print 5"); } /// Schützt Iter 5d: `ail manifest --workspace --json` listet Defs aus allen /// Modulen des Workspaces — sichtbar an einem `module`-Feld pro Eintrag. #[test] fn manifest_workspace_lists_all_defs() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args([ "manifest", entry.to_str().unwrap(), "--workspace", "--json", ]) .output() .expect("ail manifest --workspace failed to run"); assert!( output.status.success(), "ail manifest --workspace exited non-zero; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let symbols = v["symbols"].as_array().expect("symbols must be array"); let modules: Vec<&str> = symbols .iter() .filter_map(|s| s.get("module").and_then(|m| m.as_str())) .collect(); assert!( modules.contains(&"ws_main"), "expected at least one symbol with module=ws_main; got {modules:?}" ); assert!( modules.contains(&"ws_lib"), "expected at least one symbol with module=ws_lib; got {modules:?}" ); } /// Schützt Iter 5d: `ail describe --workspace ws_lib.add` löst die /// qualifizierte Punkt-Notation in das tatsächlich importierte Modul auf. #[test] fn describe_workspace_resolves_qualified_name() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args([ "describe", entry.to_str().unwrap(), "ws_lib.add", "--workspace", "--json", ]) .output() .expect("ail describe --workspace failed to run"); assert!( output.status.success(), "ail describe --workspace exited non-zero; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); assert_eq!( v.get("module").and_then(|m| m.as_str()), Some("ws_lib"), "module field must be ws_lib; got {stdout}" ); assert_eq!( v.get("name").and_then(|n| n.as_str()), Some("add"), "def name must be add; got {stdout}" ); } /// Schützt Iter 5d: `ail deps --workspace` enthält eine Cross-Module-Edge /// `ws_main.main -> ws_lib.add`, weil `ws_main` `ws_lib.add(2,3)` aufruft. #[test] fn deps_workspace_includes_cross_module() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace.join("examples").join("ws_main.ail.json"); let output = Command::new(ail_bin()) .args(["deps", entry.to_str().unwrap(), "--workspace", "--json"]) .output() .expect("ail deps --workspace failed to run"); assert!( output.status.success(), "ail deps --workspace exited non-zero; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let edges = v["edges"].as_array().expect("edges must be array"); let has_cross_edge = edges.iter().any(|e| { e.get("from_module").and_then(|s| s.as_str()) == Some("ws_main") && e.get("from_def").and_then(|s| s.as_str()) == Some("main") && e.get("to_module").and_then(|s| s.as_str()) == Some("ws_lib") && e.get("to_def").and_then(|s| s.as_str()) == Some("add") }); assert!( has_cross_edge, "expected cross-module edge ws_main.main -> ws_lib.add; got {stdout}" ); } /// Schützt Iter 5d: `ail diff --workspace` erkennt ein zusätzliches Modul /// im B-Workspace als `added_modules`-Eintrag und exitet mit Code 1. #[test] fn diff_workspace_added_module() { use std::fs; fn write_module(dir: &Path, name: &str, body: serde_json::Value) { let p = dir.join(format!("{name}.ail.json")); fs::write(&p, serde_json::to_vec_pretty(&body).unwrap()).unwrap(); } let dir_a = std::env::temp_dir().join(format!( "ailang_diff_ws_a_{}", std::process::id() )); let dir_b = std::env::temp_dir().join(format!( "ailang_diff_ws_b_{}", std::process::id() )); let _ = fs::remove_dir_all(&dir_a); let _ = fs::remove_dir_all(&dir_b); fs::create_dir_all(&dir_a).unwrap(); fs::create_dir_all(&dir_b).unwrap(); // Beide Workspaces haben ein leeres Modul `root`. B importiert // zusätzlich `extra`. Loader-Konvention: `/.ail.json`, // Modul-Name muss zum Dateinamen passen. let empty_root = serde_json::json!({ "schema": "ailang/v0", "name": "root", "imports": [], "defs": [], }); let root_with_import = serde_json::json!({ "schema": "ailang/v0", "name": "root", "imports": [{ "module": "extra" }], "defs": [], }); let extra = serde_json::json!({ "schema": "ailang/v0", "name": "extra", "imports": [], "defs": [], }); write_module(&dir_a, "root", empty_root); write_module(&dir_b, "root", root_with_import); write_module(&dir_b, "extra", extra); let entry_a = dir_a.join("root.ail.json"); let entry_b = dir_b.join("root.ail.json"); let output = Command::new(ail_bin()) .args([ "diff", entry_a.to_str().unwrap(), entry_b.to_str().unwrap(), "--workspace", "--json", ]) .output() .expect("ail diff --workspace failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!( code, 1, "expected exit 1 when workspaces differ; stderr: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let added = v["added_modules"] .as_array() .expect("added_modules must be array"); assert!( added.iter().any(|e| e.get("name").and_then(|n| n.as_str()) == Some("extra")), "expected `extra` in added_modules; got {stdout}" ); let removed = v["removed_modules"] .as_array() .expect("removed_modules must be array"); assert!( removed.is_empty(), "expected removed_modules empty; got {stdout}" ); } /// Schützt das `--json`-Diagnostic-Format für Tooling-Konsumenten. /// `broken_unbound.ail.json` referenziert eine nicht-existente Variable; /// erwartet wird Exit-Code 1 und mindestens ein Diagnostic mit /// `severity == "error"` und `code == "unbound-var"`. #[test] fn check_json_unbound_var() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join("broken_unbound.ail.json"); let output = Command::new(ail_bin()) .args(["check", src.to_str().unwrap(), "--json"]) .output() .expect("ail check --json failed to run"); let code = output.status.code().expect("process terminated by signal"); assert_eq!(code, 1, "expected exit code 1, stderr: {}", String::from_utf8_lossy(&output.stderr)); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); let diags: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); let arr = diags.as_array().expect("diagnostics must be a JSON array"); assert!( arr.iter().any(|d| { d.get("severity").and_then(|v| v.as_str()) == Some("error") && d.get("code").and_then(|v| v.as_str()) == Some("unbound-var") }), "expected at least one error diagnostic with code unbound-var; got: {stdout}" ); }