831 lines
30 KiB
Rust
831 lines
30 KiB
Rust
//! 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")
|
||
}
|
||
|
||
/// `<cols>x<rows>` 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 <spalten>x<zeilen> lesbar"));
|
||
let parse = |s: &str, was: &str| {
|
||
s.trim()
|
||
.parse::<usize>()
|
||
.unwrap_or_else(|_| panic!("{name}: {was} {s:?} ist keine Zahl"))
|
||
};
|
||
(parse(c, "Spaltenzahl"), parse(r, "Zeilenzahl"))
|
||
}
|
||
|
||
/// Wert einer Kopfzeilen-Direktive `' <marke>: <wert>` aus den ersten Zeilen.
|
||
fn direktive(src: &str, marke: &str) -> Option<String> {
|
||
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: <cols>x<rows>[, …]`.
|
||
/// 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: <spalten>x<zeilen>`"
|
||
)
|
||
});
|
||
wert.split(',').map(|w| groesse_lesen(name, w)).collect()
|
||
}
|
||
|
||
/// Optionale Zeitzone `' tb-tz: ±HH:MM`. 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<tb_runtime::datetime::Zeitzone> {
|
||
let wert = direktive(src, "tb-tz:")?;
|
||
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::<i32>()
|
||
.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 <n> auf <cols>x<rows>`.
|
||
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 <n> auf <spalten>x<zeilen>`, fand {wert:?}"),
|
||
}
|
||
}
|
||
|
||
/// Vorbereitete Tastendrücke: `' tb-keys: <folge>`. Sondertasten als
|
||
/// `<F1>`…`<F12>`, sonst je ein Zeichen; `+` davor bedeutet Umschalt,
|
||
/// `^` Strg, `%` Alt — gebraucht für die benutzerdefinierten Trap-Tasten.
|
||
fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
|
||
let Some(wert) = direktive(src, "tb-keys:") else {
|
||
return Vec::new();
|
||
};
|
||
tastenfolge(name, &wert)
|
||
}
|
||
|
||
fn tastenfolge(name: &str, wert: &str) -> Vec<tb_runtime::host::Ereignis> {
|
||
use tb_runtime::host::{taste, umschalt, Ereignis};
|
||
let mut out = Vec::new();
|
||
let zeichen: Vec<char> = 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<Ereignisschritt> {
|
||
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::<usize>()
|
||
.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<Ereignisschritt>,
|
||
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<tb_runtime::host::Ereignis> {
|
||
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 jetzt_ms(&mut self) -> u64 {
|
||
self.inner.jetzt_ms()
|
||
}
|
||
}
|
||
|
||
/// Virtuelle Uhr: `' tb-clock: <n>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<u64> {
|
||
let wert = direktive(src, "tb-clock:")?;
|
||
let ms = wert
|
||
.trim()
|
||
.strip_suffix("ms/Zustellpunkt")
|
||
.unwrap_or_else(|| panic!("{name}: tb-clock erwartet `<n>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<u64> {
|
||
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: <n>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<tb_runtime::host::Ereignis> {
|
||
self.inner.next_event(blockierend)
|
||
}
|
||
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::<String>();
|
||
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<PathBuf> = 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:<ENTER>",
|
||
);
|
||
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::<String>()
|
||
.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::<String>().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<PathBuf> = 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());
|
||
}
|
||
}
|
||
}
|