Files
TerminalBasic/crates/tb-cli/tests/compat.rs
Chili Palmer 52ccbb5848 Phase 3 (Kern): Laufzeitbibliothek, Bildschirm und Datei-E/A
Setzt den OpenSpec-Change phase-3-runtime-bildschirm um (77/77 Aufgaben).
Abdeckung laut Inventar: 195 implementiert, 37 offen, 53 Non-Feature.

Vollstaendigkeits-Inventar
- docs/inventar.md mit 285 Eintraegen aus den Themenlisten von
  bas7advr.hlp und qb45advr.hlp, je mit Status und Fundstelle
- crates/tb-frontend/tests/inventar.rs haelt die Tabelle in beide
  Richtungen gegen den Code; der Abdeckungsstand kann nicht veralten

Bruchschritt (Puffer, Host, Korpus)
- Cell/TextScreen ziehen ratatui-frei nach tb-runtime::screen; tb-ui
  behaelt Farbabbildung, Widget und den neuen Terminal-Host
- Host wechselt vom Zeichenstrom auf Anzeige des Zellenpuffers plus
  Ereignisse (Taste, Groessenaenderung, Abbruch)
- Korpusvergleich auf getrimmten Bildschirm-Snapshot; die acht
  bestehenden Sollausgaben blieben dabei unveraendert

Groessenunabhaengigkeit
- 80x25 ist nirgends mehr eine feste Grenze; Groessenaenderungen waehrend
  der Ausfuehrung werden zugestellt (Inhalt oben links erhalten, Cursor
  und VIEW PRINT geklemmt)
- Korpusnachweis: dasselbe Programm bei 80x25 und 120x40 mit je eigener
  Sollausgabe, plus ein Programm mit Groessenwechsel mitten im Lauf

Bibliothek
- Breite Unicode-Zeichen belegen zwei Zellen (Cursor, POS, Randumbruch)
- Bildschirm: CLS, COLOR, LOCATE, WIDTH, VIEW PRINT, SCREEN, CSRLIN, POS
- Tastatur: INKEY$, INPUT$, Funktionstasten-Makros (KEY n / LIST / ON)
- PRINT USING, LPRINT USING, FORMAT$, SetFormatCC
- Mathematik mit kompatiblem PRNG (gleiche Saat, gleiche Folge)
- Datum und Zeit mit Serienwerten ab 1899
- Finanzmathematik: FV#, PV#, Pmt#, IPmt#, PPmt#, NPer#, Rate#, NPV#,
  IRR#, MIRR#, SLN#, SYD#, DDB#
- Datei-E/A: sequenziell, RANDOM (Recordpuffer und UDT-Variablen),
  BINARY, FIELD/LSET/RSET, Statusfunktionen, Dateisystem, MK$/CV
- System: ENVIRON, FRE, CLEAR, TRON/TROFF, STACK, ERDEV, ERR-Anweisung

Altlasten aus Phase 2
- ON ERROR GOTO auf Modulebene ist aus Prozeduren ansprechbar
  (prozeduruebergreifender Fixup im Codegenerator)
- DATA behaelt seinen Rohtext (Gross-/Kleinschreibung, innerer Leerraum)
- Die vier TODO-verify-Zellen der Konvertierungsmatrix sind aufgeloest

Vom Inventar aufgedeckte Fehler
- Zwoelf Non-Features wies der Compiler entgegen der Phase-1-Spec nicht
  ab (CALLS, SSEG, POINT, VIEW, COM, PEN, STRIG, STICK, die
  String*-Routinen, LINE und OPEN "COMn:") -- geschlossen
- Drei Gruppen fehlten im urspruenglichen Umfang: Finanzmathematik,
  Record-Konvertierung, Rest der Dateisystemfunktionen
- bas7advr.hlp allein ist keine vollstaendige Quelle; das Inventar
  bildet die Vereinigung mit qb45advr.hlp

Neue Changes
- phase-3-isam: schliesst Phase 3 ab (Speicherschicht redb)
- phase-3-ortszeit: zieht die UTC-Abweichung zurueck

Neue Abhaengigkeit: unicode-width.
Dokumentiert: sprachreferenz.md und tbvm-design.md sind TODO-frei,
docs/bibliothek.md neu, PLAN.md fortgeschrieben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 06:54:19 +02:00

388 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
use std::path::{Path, PathBuf};
use std::process::Command;
use tb_runtime::host::CaptureHost;
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()
.take(4)
.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 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:?}"),
}
}
/// 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 src = std::fs::read_to_string(path).unwrap();
let name = path.file_name().unwrap().to_string_lossy().to_string();
let modul_name = path.file_stem().unwrap().to_string_lossy().to_uppercase();
let (cols, rows) = groesse.unwrap_or_else(|| screen_groessen(&name, &src)[0]);
let module = tb_vm::compile_source(&modul_name, &src)
.unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display()));
let mut vm = Vm::new(module);
vm.rt.screen.resize(cols, rows);
// `' 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 },
);
}
match vm.run(&mut 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)
),
}
}
/// 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()) == Some("bas"))
.collect();
entries.sort();
for path in entries {
let name = path.file_name().unwrap().to_string_lossy().to_string();
let src = std::fs::read_to_string(&path).unwrap();
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);
checked += 1;
}
}
assert!(checked >= 5, "zu wenige Korpusdateien gefunden: {checked}");
}
// ---- 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");
}
#[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);
}
// ---- 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()) == Some("bas"))
.collect();
entries.sort();
for path in entries {
let name = path.file_name().unwrap().to_string_lossy().to_string();
let src = std::fs::read_to_string(&path).unwrap();
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());
}
}
}