//! Kompatibilitäts-Harness: jede Korpusdatei `tests/compat/*.bas` wird //! kompiliert, ohne Terminal ausgeführt und ihr Bildschirmzustand gegen die //! `.out` verglichen — getrimmt auf die belegte Fläche, mit Attributebene nur //! bei gesetzten Farben (Entscheidung 2026-09-03, D3). //! //! Jede Korpusdatei nennt ihre Bildschirmgröße in der ersten Zeile: //! //! ```text //! ' tb-screen: 80x25 //! ``` //! //! Es gibt bewusst keinen stillschweigenden Vorgabewert — 80×25 ist überall //! nur die untere Schranke, nie eine feste Größe, und der Korpus muss das //! nachweisen können. #[path = "../../../tests/support/prozessfrist.rs"] mod prozessfrist; use std::collections::VecDeque; use std::path::{Path, PathBuf}; use std::process::Command; use tb_runtime::host::{CaptureHost, Host}; use tb_vm::interp::{RunEvent, Vm}; fn compat_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/compat") } /// `x` lesen. fn groesse_lesen(name: &str, wert: &str) -> (usize, usize) { let (c, r) = wert .trim() .split_once(['x', 'X']) .unwrap_or_else(|| panic!("{name}: Größe {wert:?} nicht als x lesbar")); let parse = |s: &str, was: &str| { s.trim() .parse::() .unwrap_or_else(|_| panic!("{name}: {was} {s:?} ist keine Zahl")) }; (parse(c, "Spaltenzahl"), parse(r, "Zeilenzahl")) } /// Wert einer Kopfzeilen-Direktive `' : ` aus den ersten Zeilen. fn direktive(src: &str, marke: &str) -> Option { src.lines() // Kopfzeilenfenster: Groesse, Zeitzone, Uhr, Tasten, Tempdir, // Resize passen zusammen in die ersten Zeilen. .take(8) .find(|l| l.contains(marke)) .map(|l| l.split(marke).nth(1).unwrap().trim().to_string()) } /// Bildschirmgrößen aus der Kopfzeile `' tb-screen: x[, …]`. /// Fehlt sie, schlägt der Test fehl — ohne Vorgabewert. Mehrere Größen /// bedeuten: das Programm läuft je Größe einmal, mit eigener Sollausgabe. fn screen_groessen(name: &str, src: &str) -> Vec<(usize, usize)> { let wert = direktive(src, "tb-screen:").unwrap_or_else(|| { panic!( "{name}: keine Bildschirmgröße deklariert — erste Zeile braucht \ `\' tb-screen: x`" ) }); wert.split(',').map(|w| groesse_lesen(name, w)).collect() } /// Optionale Zeitzone `' tb-tz: ±HH:MM` oder `unknown` für UTC-Rückfall. /// Ohne Angabe gilt die Zone des /// Rechners — Zeitprogramme im Korpus setzen sie, damit ihre Sollausgabe /// unabhängig vom Standort des Testrechners bleibt. fn tz_direktive(name: &str, src: &str) -> Option { let wert = direktive(src, "tb-tz:")?; if wert == "unknown" { return Some(tb_runtime::datetime::Zeitzone::Unbekannt); } let (vorzeichen, rest) = match wert.strip_prefix('-') { Some(r) => (-1i32, r), None => (1i32, wert.trim_start_matches('+')), }; let (h, m) = rest .split_once(':') .unwrap_or_else(|| panic!("{name}: tb-tz erwartet ±HH:MM, fand {wert:?}")); let parse = |s: &str| { s.trim() .parse::() .unwrap_or_else(|_| panic!("{name}: {s:?} ist keine Zahl")) }; Some(tb_runtime::datetime::Zeitzone::Fest( vorzeichen * (parse(h) * 3600 + parse(m) * 60), )) } /// Optionale Größenänderung mitten im Lauf: /// `' tb-resize: nach auf x`. fn resize_direktive(name: &str, src: &str) -> Option<(usize, (usize, usize))> { let wert = direktive(src, "tb-resize:")?; let teile: Vec<&str> = wert.split_whitespace().collect(); match teile.as_slice() { ["nach", n, "auf", g] => Some(( n.parse() .unwrap_or_else(|_| panic!("{name}: {n:?} ist keine Zahl")), groesse_lesen(name, g), )), _ => panic!("{name}: tb-resize erwartet `nach auf x`, fand {wert:?}"), } } /// Vorbereitete Tastendrücke: `' tb-keys: `. Sondertasten als /// ``…``, sonst je ein Zeichen; `+` davor bedeutet Umschalt, /// `^` Strg, `%` Alt — gebraucht für die benutzerdefinierten Trap-Tasten. fn tasten_direktive(name: &str, src: &str) -> Vec { let Some(wert) = direktive(src, "tb-keys:") else { return Vec::new(); }; tastenfolge(name, &wert) } fn tastenfolge(name: &str, wert: &str) -> Vec { use tb_runtime::host::{taste, umschalt, Ereignis}; let mut out = Vec::new(); let zeichen: Vec = wert.chars().collect(); let mut i = 0; while i < zeichen.len() { let mut shift = 0u8; while i < zeichen.len() && matches!(zeichen[i], '+' | '^' | '%') { shift |= match zeichen[i] { '+' => umschalt::SHIFT, '^' => umschalt::CTRL, _ => umschalt::ALT, }; i += 1; } if i >= zeichen.len() { panic!("{name}: tb-keys endet mit einem Umschaltzeichen"); } if zeichen[i] == '<' { let ende = zeichen[i..] .iter() .position(|c| *c == '>') .unwrap_or_else(|| panic!("{name}: tb-keys: `<` ohne `>`")); let nm: String = zeichen[i + 1..i + ende].iter().collect(); let code = match nm.to_uppercase().as_str() { "F1" => 0x3B, "F2" => 0x3C, "F3" => 0x3D, "F4" => 0x3E, "F5" => 0x3F, "F6" => 0x40, "F7" => 0x41, "F8" => 0x42, "F9" => 0x43, "F10" => 0x44, "F11" => 0x85, "F12" => 0x86, "UP" => 0x48, "LEFT" => 0x4B, "RIGHT" => 0x4D, "DOWN" => 0x50, "ENTER" => { out.push(Ereignis::Taste(taste::ENTER.into(), shift)); i += ende + 1; continue; } "ESC" => { out.push(Ereignis::Taste(taste::ESC.into(), shift)); i += ende + 1; continue; } "TAB" => { out.push(Ereignis::Taste(taste::TAB.into(), shift)); i += ende + 1; continue; } other => panic!("{name}: tb-keys kennt `<{other}>` nicht"), }; out.push(Ereignis::Taste(taste::sonder(code), shift)); i += ende + 1; } else { out.push(Ereignis::Taste(zeichen[i].to_string(), shift)); i += 1; } } out } enum Ereignisschritt { Ereignis(tb_runtime::host::Ereignis), Zeit(u64), } fn ereignis_direktive(name: &str, src: &str) -> Vec { use tb_runtime::host::{Ereignis, MausArt, MausEreignis}; let Some(wert) = direktive(src, "tb-events:") else { return Vec::new(); }; let mut out = Vec::new(); for teil in wert.split('|').map(str::trim) { if let Some(keys) = teil.strip_prefix("key:") { out.extend( tastenfolge(name, keys) .into_iter() .map(Ereignisschritt::Ereignis), ); } else if let Some(ms) = teil.strip_prefix("time:") { let ms = ms.trim().strip_suffix("ms").unwrap_or(ms.trim()); out.push(Ereignisschritt::Zeit( ms.parse() .unwrap_or_else(|_| panic!("{name}: ungültige Zeit {ms:?}")), )); } else if let Some(mouse) = teil.strip_prefix("mouse:") { let fields: Vec<_> = mouse.split(',').map(str::trim).collect(); let [kind, button, shift, row, col] = fields.as_slice() else { panic!("{name}: mouse erwartet art,taste,shift,zeile,spalte"); }; let art = match *kind { "down" => MausArt::Druck, "up" => MausArt::Loslassen, "move" => MausArt::Bewegung, _ => panic!("{name}: unbekannte Mausart {kind:?}"), }; let parse = |value: &str| { value .parse::() .unwrap_or_else(|_| panic!("{name}: ungültige Zahl {value:?}")) }; out.push(Ereignisschritt::Ereignis(Ereignis::Maus(MausEreignis { art, taste: parse(button) as u8, shift: parse(shift) as u8, zeile: parse(row), spalte: parse(col), }))); } else { panic!("{name}: unbekannter tb-events-Schritt {teil:?}"); } } out } struct EreignisHost { inner: CaptureHost, schritte: VecDeque, pause: bool, } impl tb_runtime::host::Host for EreignisHost { fn present(&mut self, screen: &tb_runtime::screen::TextScreen) { self.inner.present(screen); } fn next_event(&mut self, blockierend: bool) -> Option { if !blockierend && self.pause { self.pause = false; return None; } loop { match self.schritte.pop_front() { Some(Ereignisschritt::Zeit(ms)) => { self.inner.uhr_vorruecken(ms); if !blockierend { return None; } } Some(Ereignisschritt::Ereignis(event)) => { self.pause = true; return Some(event); } None => return self.inner.next_event(blockierend), } } } fn warten(&mut self, deadline_ms: Option) -> Option { if let Some(event) = self.next_event(false) { return Some(event); } self.inner.warten(deadline_ms) } fn jetzt_ms(&mut self) -> u64 { self.inner.jetzt_ms() } } /// Virtuelle Uhr: `' tb-clock: ms/Zustellpunkt`. Ohne die Direktive /// steht die Uhr des Testhosts still — ein Programm mit Zeit-Traps liefe /// dann endlos. Der Harness weist es deshalb ab, statt auf die Systemuhr /// auszuweichen (design.md, D6). fn clock_direktive(name: &str, src: &str) -> Option { let wert = direktive(src, "tb-clock:")?; let ms = wert .trim() .strip_suffix("ms/Zustellpunkt") .unwrap_or_else(|| panic!("{name}: tb-clock erwartet `ms/Zustellpunkt`, fand {wert:?}")) .trim(); Some( ms.parse() .unwrap_or_else(|_| panic!("{name}: {ms:?} ist keine Zahl")), ) } /// Nutzt das Programm zeitgesteuerte Traps? Grobe, aber ausreichende /// Prüfung: ohne `ON TIMER` gibt es keine Fälligkeit zu rechnen. fn braucht_uhr(src: &str) -> bool { src.to_uppercase().contains("ON TIMER") } fn clock_schritt(name: &str, src: &str) -> Option { let schritt = clock_direktive(name, src); assert!( schritt.is_some() || !braucht_uhr(src), "{name}: nutzt zeitgesteuerte Traps, deklariert aber keinen Zeitverlauf — \ erste Zeilen brauchen `' tb-clock: ms/Zustellpunkt`" ); schritt } /// Testhost mit virtueller Uhr: sie rückt bei jedem Blick auf die Zeit um /// einen festen Schritt vor. Damit ist der Lauf reproduzierbar und /// braucht keine Wartezeit. struct UhrHost { inner: CaptureHost, schritt_ms: u64, } impl tb_runtime::host::Host for UhrHost { fn present(&mut self, s: &tb_runtime::screen::TextScreen) { self.inner.present(s); } fn next_event(&mut self, blockierend: bool) -> Option { self.inner.next_event(blockierend) } fn warten(&mut self, deadline_ms: Option) -> Option { if let Some(event) = self.next_event(false) { return Some(event); } self.inner.warten(deadline_ms) } fn jetzt_ms(&mut self) -> u64 { let jetzt = self.inner.jetzt_ms(); self.inner.uhr_vorruecken(self.schritt_ms); jetzt } } /// Arbeitsverzeichnis für die Dauer eines Korpusprogramms. Programme mit /// Datei-E/A laufen in einem frischen temporären Verzeichnis und hinterlassen /// nichts im Projektbaum; beim Verlassen wird es samt Inhalt entfernt. struct TempVerzeichnis { vorher: PathBuf, dir: PathBuf, } impl TempVerzeichnis { fn neu(name: &str) -> TempVerzeichnis { let vorher = std::env::current_dir().unwrap(); let dir = std::env::temp_dir().join(format!( "tb_korpus_{}_{}", name.replace('.', "_"), std::process::id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::env::set_current_dir(&dir).unwrap(); TempVerzeichnis { vorher, dir } } } impl Drop for TempVerzeichnis { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.vorher); let _ = std::fs::remove_dir_all(&self.dir); } } /// Korpusdatei ausführen und den Bildschirm-Snapshot liefern. fn run_corpus_file(path: &Path, groesse: Option<(usize, usize)>) -> String { let _frist = prozessfrist::Prozessfrist::neu( &path.display().to_string(), std::time::Duration::from_secs(30), ); let raw = std::fs::read_to_string(path).unwrap(); let name = path.file_name().unwrap().to_string_lossy().to_string(); let form = if path .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("frm")) { Some(tb_ui::frm::read_text(&name, &raw).unwrap_or_else(|error| panic!("{error}"))) } else { None }; let src = form .as_ref() .map_or(raw.as_str(), |form| form.code.as_str()); let modul_name = form.as_ref().map_or_else( || path.file_stem().unwrap().to_string_lossy().to_uppercase(), |form| form.root.name.to_uppercase(), ); let (cols, rows) = groesse.unwrap_or_else(|| screen_groessen(&name, src)[0]); let module = match &form { Some(form) => tb_vm::compile_source_with_forms(&modul_name, src, &form.catalog()), None => tb_vm::compile_source(&modul_name, src), } .unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display())); let mut vm = Vm::new(module); if let Some(form) = &form { form.apply(&mut vm.forms).unwrap(); vm.forms.show(0, false).unwrap(); } vm.rt.screen.resize(cols, rows); if let Some(z) = tz_direktive(&name, src) { vm.rt.zeitzone = z; } // `' tb-tempdir` — das Programm arbeitet mit Dateien. let _temp = direktive(src, "tb-tempdir").map(|_| TempVerzeichnis::neu(&name)); let mut host = CaptureHost::default(); if let Some((nach, (c, r))) = resize_direktive(&name, src) { host.ereignis_nach( nach, tb_runtime::host::Ereignis::Groesse { cols: c, rows: r }, ); } for t in tasten_direktive(&name, src) { host.ereignis(t); } let schritt = clock_schritt(&name, src); let schritte = ereignis_direktive(&name, src); if !schritte.is_empty() { assert!( schritt.is_none(), "{name}: tb-events: time und tb-clock nicht mischen" ); let mut host = EreignisHost { inner: host, schritte: schritte.into(), pause: false, }; return run_to_snapshot(path, &mut vm, &mut host); } if let Some(schritt_ms) = schritt { let mut host = UhrHost { inner: host, schritt_ms, }; return run_to_snapshot(path, &mut vm, &mut host); } run_to_snapshot(path, &mut vm, &mut host) } fn run_to_snapshot(path: &Path, vm: &mut Vm, host: &mut dyn Host) -> String { match vm.run(host) { RunEvent::Ended => tb_runtime::snapshot::snapshot(&vm.rt.screen), other => panic!( "{}: unerwartetes Laufzeitende {other:?}\nBildschirm bisher:\n{}", path.display(), tb_runtime::snapshot::snapshot(&vm.rt.screen) ), } } fn source_for_directives(path: &Path) -> String { let raw = std::fs::read_to_string(path).unwrap(); if path .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("frm")) { tb_ui::frm::read_text(&path.display().to_string(), &raw) .unwrap() .code } else { raw } } /// Snapshots vergleichen: erst das Textbild, dann die Attributebene. /// Bei Abweichung im Text zählt die Zeile, bei Attributen Zeile und Spalte. fn assert_output_matches(file: &str, want: &str, got: &str) { if want == got { return; } let marke = tb_runtime::snapshot::ATTRIBUT_MARKE; let teile = |s: &str| match s.split_once(&format!("{marke}\n")) { Some((t, a)) => (t.to_string(), Some(a.to_string())), None => (s.to_string(), None), }; let (want_text, want_attr) = teile(want); let (got_text, got_attr) = teile(got); if want_text != got_text { // `lines()` statt `split('\n')`: Snapshots enden je Zeile mit `\n`, // sonst zählt der leere Rest als zusätzliche Zeile und die // Zeilenanzahl-Meldung wäre nie erreichbar. let w: Vec<&str> = want_text.lines().collect(); let g: Vec<&str> = got_text.lines().collect(); for (i, (a, b)) in w.iter().zip(g.iter()).enumerate() { if a != b { panic!( "{file}: Abweichung in Zeile {}:\n Soll: {a:?}\n Ist: {b:?}", i + 1 ); } } panic!( "{file}: Zeilenanzahl weicht ab (Soll {} / Ist {}).\nSoll:\n{want_text}\nIst:\n{got_text}", w.len(), g.len() ); } match (want_attr, got_attr) { (Some(w), None) => panic!("{file}: Attributebene fehlt in der Ausgabe.\nSoll:\n{w}"), (None, Some(g)) => panic!( "{file}: unerwartete Attributebene — das Programm setzt Farben, \ die Sollausgabe kennt keine.\nIst:\n{g}" ), (Some(w), Some(g)) => { for (z, (wz, gz)) in w.lines().zip(g.lines()).enumerate() { if wz != gz { // Je Zelle zwei Hexziffern → Spalte aus dem Byteoffset. let spalte = wz .chars() .zip(gz.chars()) .position(|(a, b)| a != b) .map(|i| i / 2 + 1) .unwrap_or(0); let paar = |s: &str| s.chars().skip((spalte - 1) * 2).take(2).collect::(); panic!( "{file}: Farbabweichung in Zeile {}, Spalte {spalte}:\n \ Soll: {}\n Ist: {}", z + 1, paar(wz), paar(gz) ); } } panic!("{file}: Attributebenen unterschiedlich lang.\nSoll:\n{w}\nIst:\n{g}"); } (None, None) => unreachable!("Snapshots gleich, aber Vergleich schlug fehl"), } } #[test] fn korpus_laeuft_mit_korrekter_ausgabe() { let dir = compat_dir(); let mut checked = 0; let mut entries: Vec = std::fs::read_dir(&dir) .expect("tests/compat fehlt") .map(|e| e.unwrap().path()) .filter(|p| { p.extension() .and_then(|e| e.to_str()) .is_some_and(|ext| matches!(ext, "bas" | "frm")) }) .collect(); entries.sort(); for path in entries { let name = path.file_name().unwrap().to_string_lossy().to_string(); let src = source_for_directives(&path); let groessen = screen_groessen(&name, &src); for (cols, rows) in &groessen { // Bei mehreren Größen trägt jede ihre eigene Sollausgabe. let out_path = if groessen.len() == 1 { path.with_extension("out") } else { path.with_extension(format!("{cols}x{rows}.out")) }; let want = std::fs::read_to_string(&out_path) .unwrap_or_else(|_| panic!("{name}: Sollausgabe {} fehlt", out_path.display())); // .out-Dateien sind LF-normiert (.gitattributes); zur Sicherheit // CRLF des Checkouts entfernen. let want = want.replace("\r\n", "\n"); let got = run_corpus_file(&path, Some((*cols, *rows))); let etikett = format!("{name} @ {cols}x{rows}"); assert_output_matches(&etikett, &want, &got); let wiederholt = run_corpus_file(&path, Some((*cols, *rows))); assert_eq!( got, wiederholt, "{etikett}: zwei Läufe weichen voneinander ab" ); checked += 1; } } assert!(checked >= 5, "zu wenige Korpusdateien gefunden: {checked}"); } #[test] fn uhr_direktive_wird_gelesen() { assert_eq!( clock_schritt("uhr.bas", "' tb-clock: 25ms/Zustellpunkt\nON TIMER(1)"), Some(25) ); } #[test] fn ereignisfolge_liefert_taste_maus_und_zeit_in_reihenfolge() { let steps = ereignis_direktive( "form.frm", "' tb-events: key:%o | mouse:down,1,0,5,7 | time:250ms | key:", ); assert_eq!(steps.len(), 4); assert!( matches!(&steps[0], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, shift)) if key == "o" && *shift == 4) ); assert!( matches!(&steps[1], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Maus(mouse)) if mouse.zeile == 5 && mouse.spalte == 7) ); assert!(matches!(steps[2], Ereignisschritt::Zeit(250))); assert!( matches!(&steps[3], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, _)) if key == tb_runtime::host::taste::ENTER) ); } #[test] #[should_panic(expected = "deklariert aber keinen Zeitverlauf")] fn zeittrap_ohne_uhr_direktive_wird_abgewiesen() { clock_schritt("uhr.bas", "ON TIMER(1) GOSUB Tick"); } // ---- tbc-Binary (Exit-Codes nach D6) ---------------------------------------- #[test] fn tbc_run_hello() { let exe = env!("CARGO_BIN_EXE_tbc"); let out = Command::new(exe) .args(["run"]) .arg(compat_dir().join("hello.bas")) .output() .expect("tbc startet"); assert!(out.status.success(), "{out:?}"); assert_eq!(String::from_utf8_lossy(&out.stdout), "Hallo, Welt!\n"); } /// OPEN darf keinen zweiten Writer auf dieselbe Datenbank anfordern. /// Der Unterprozess wird auch bei einer Regression nach fünf Sekunden beendet. #[test] fn tbc_isam_open_in_transaktion_hat_eine_harte_frist() { use std::process::Stdio; use std::time::{Duration, Instant}; let dir = std::env::temp_dir().join(format!("tb_isam_open_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let mut child = Command::new(env!("CARGO_BIN_EXE_tbc")) .arg("run") .arg(compat_dir().join("isamopen.bas")) .current_dir(&dir) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("tbc startet"); let start = Instant::now(); let beendet = loop { match child.try_wait() { Ok(Some(_)) => break true, Ok(None) if start.elapsed() < Duration::from_secs(5) => { std::thread::sleep(Duration::from_millis(10)); } _ => { let _ = child.kill(); break false; } } }; let out = child.wait_with_output().unwrap(); let _ = std::fs::remove_dir_all(&dir); assert!( beendet, "OPEN innerhalb einer Transaktion hat die Prozessfrist überschritten: {out:?}" ); assert!(out.status.success(), "{out:?}"); assert_eq!( String::from_utf8_lossy(&out.stdout), include_str!("../../../tests/compat/isamopen.out") ); } #[test] fn tbc_run_stop_exitcode() { let exe = env!("CARGO_BIN_EXE_tbc"); let dir = std::env::temp_dir(); let f = dir.join("tb_phase2_stop_test.bas"); std::fs::write(&f, "PRINT \"x\"\nSTOP\n").unwrap(); let out = Command::new(exe).args(["run"]).arg(&f).output().unwrap(); assert_eq!(out.status.code(), Some(3), "{out:?}"); let err = String::from_utf8_lossy(&out.stderr); assert!(err.contains("STOP in line 2"), "{err}"); let _ = std::fs::remove_file(&f); } #[test] fn tbc_run_laufzeitfehler_exitcode() { let exe = env!("CARGO_BIN_EXE_tbc"); let dir = std::env::temp_dir(); let f = dir.join("tb_phase2_err_test.bas"); std::fs::write(&f, "i% = 40000\n").unwrap(); let out = Command::new(exe).args(["run"]).arg(&f).output().unwrap(); assert_eq!(out.status.code(), Some(2), "{out:?}"); let err = String::from_utf8_lossy(&out.stderr); assert!(err.contains("Overflow"), "{err}"); let _ = std::fs::remove_file(&f); } #[test] fn tbc_build_erzeugt_tbc() { let exe = env!("CARGO_BIN_EXE_tbc"); let dir = std::env::temp_dir(); let f = dir.join("tb_phase2_build_test.bas"); std::fs::write(&f, "PRINT 1\n").unwrap(); let out = Command::new(exe).args(["build"]).arg(&f).output().unwrap(); assert!(out.status.success(), "{out:?}"); let tbc = f.with_extension("tbc"); let bytes = std::fs::read(&tbc).unwrap(); assert_eq!(&bytes[..4], b"TBC\0"); let _ = std::fs::remove_file(&f); let _ = std::fs::remove_file(&tbc); } #[test] fn tbc_loest_include_projekt_und_run_datei_auf() { let exe = env!("CARGO_BIN_EXE_tbc"); let dir = std::env::temp_dir().join(format!("tb_project_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("werte.bi"), "CONST N = 7\n").unwrap(); std::fs::write( dir.join("main.bas"), "'$INCLUDE: 'WERTE.BI'\nDECLARE SUB Ausgabe\nAusgabe\nEND\n", ) .unwrap(); std::fs::write(dir.join("lib.bas"), "SUB Ausgabe\nPRINT N\nEND SUB\n").unwrap(); std::fs::write(dir.join("app.mak"), "MAIN.BAS\nLIB.BAS\n").unwrap(); let project = Command::new(exe) .args(["run"]) .arg(dir.join("app.mak")) .output() .unwrap(); assert!(project.status.success(), "{project:?}"); assert_eq!(String::from_utf8_lossy(&project.stdout), " 7 \n"); std::fs::write(dir.join("start.bas"), "RUN \"ZIEL\"\n").unwrap(); std::fs::write(dir.join("ziel.bas"), "PRINT \"weiter\"\nEND\n").unwrap(); let chained = Command::new(exe) .args(["run"]) .arg(dir.join("start.bas")) .output() .unwrap(); assert!(chained.status.success(), "{chained:?}"); assert_eq!(String::from_utf8_lossy(&chained.stdout), "weiter\n"); let _ = std::fs::remove_dir_all(&dir); } // ---- Harness meldet Abweichungen benannt (Spec kompat-testkorpus) ---------- fn meldung(want: &str, got: &str) -> String { let r = std::panic::catch_unwind(|| assert_output_matches("probe.bas", want, got)); let e = r.expect_err("erwartete eine Abweichung"); e.downcast_ref::() .cloned() .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())) .unwrap_or_default() } #[test] fn textabweichung_nennt_zeile_soll_und_ist() { let m = meldung("a\nb\n", "a\nX\n"); assert!(m.contains("Zeile 2"), "{m}"); assert!(m.contains("\"b\""), "{m}"); assert!(m.contains("\"X\""), "{m}"); } #[test] fn zeilenanzahl_wird_benannt() { let m = meldung("a\nb\n", "a\n"); assert!(m.contains("Zeilenanzahl"), "{m}"); } #[test] fn farbabweichung_nennt_zeile_spalte_soll_und_ist() { let marke = tb_runtime::snapshot::ATTRIBUT_MARKE; let want = format!("ab\n{marke}\n7070\n"); let got = format!("ab\n{marke}\n70e1\n"); let m = meldung(&want, &got); assert!(m.contains("Farbabweichung"), "{m}"); assert!(m.contains("Zeile 1"), "{m}"); assert!(m.contains("Spalte 2"), "{m}"); assert!(m.contains("70") && m.contains("e1"), "{m}"); } #[test] fn fehlende_und_unerwartete_attributebene_werden_benannt() { let marke = tb_runtime::snapshot::ATTRIBUT_MARKE; let mit = format!("ab\n{marke}\n7070\n"); assert!(meldung(&mit, "ab\n").contains("Attributebene fehlt")); assert!(meldung("ab\n", &mit).contains("unerwartete Attributebene")); } #[test] fn fehlende_groessenangabe_wird_abgewiesen() { let dir = std::env::temp_dir().join("tb_korpus_ohne_groesse"); std::fs::create_dir_all(&dir).unwrap(); let f = dir.join("ohne.bas"); std::fs::write(&f, "PRINT 1\n").unwrap(); let r = std::panic::catch_unwind(|| run_corpus_file(&f, None)); let e = r.expect_err("Datei ohne Größenangabe muss abgewiesen werden"); let m = e.downcast_ref::().cloned().unwrap_or_default(); assert!(m.contains("keine Bildschirmgröße deklariert"), "{m}"); let _ = std::fs::remove_dir_all(&dir); } /// Sollausgaben neu erzeugen (nur auf Anforderung): /// `cargo test -p tb-cli --test compat -- --ignored erzeuge_sollausgaben` /// /// Die erzeugten Dateien sind zu prüfen, bevor sie eingecheckt werden — ein /// unerwarteter Diff ist ein Befund, kein Anpassungsgrund. #[test] #[ignore = "erzeugt Golden Files; nur bewusst aufrufen"] fn erzeuge_sollausgaben() { let dir = compat_dir(); let mut entries: Vec = std::fs::read_dir(&dir) .expect("tests/compat fehlt") .map(|e| e.unwrap().path()) .filter(|p| { p.extension() .and_then(|e| e.to_str()) .is_some_and(|ext| matches!(ext, "bas" | "frm")) }) .collect(); entries.sort(); for path in entries { let name = path.file_name().unwrap().to_string_lossy().to_string(); let src = source_for_directives(&path); let groessen = screen_groessen(&name, &src); for (cols, rows) in &groessen { let out_path = if groessen.len() == 1 { path.with_extension("out") } else { path.with_extension(format!("{cols}x{rows}.out")) }; let got = run_corpus_file(&path, Some((*cols, *rows))); std::fs::write(&out_path, &got).unwrap(); println!("geschrieben: {}", out_path.display()); } } }