Files
TerminalBasic/crates/tb-cli/tests/compat.rs
Chili Palmer a0fb41420e Ortszeit statt UTC
Setzt den OpenSpec-Change phase-3-ortszeit um (21/21 Aufgaben) und zieht
damit die in Phase 3 eingegangene Abweichung zurueck: DATE$, TIME$, TIMER
und NOW rechnen in der Zeitzone des Rechners, TIMER zaehlt ab der lokalen
Mitternacht.

Versatzermittlung
- jiff 0.2.35 als Abhaengigkeit von tb-runtime
- datetime::utc_versatz(zeitpunkt) ist der einzige Aufrufpunkt der
  Bibliothek; die Kalenderrechnung darunter bleibt Eigenimplementierung
  und sieht sie nie -- zwei Kalendermodelle nebeneinander waeren eine
  dauerhafte Fehlerquelle
- Der Versatz gilt je Zeitpunkt, nicht je Programmlauf: ein Programm, das
  ueber eine Zeitumstellung hinweg laeuft, folgt ihr

Zonenzustand
- Zeitzone { System, Fest(i32), Unbekannt } im RtState
- Fest(n) macht Tests und Korpusprogramme unabhaengig von der Zone des
  Testrechners; TZ waere prozessweit und set_var seit Rust 2024 unsafe

Rueckfallebene
- Ohne ermittelbare Zone gilt UTC, das Programm laeuft weiter
- tbc run meldet den Rueckfall einmal beim Start auf der Fehlerausgabe,
  nicht je Abfrage -- ein stiller Wechsel waere ein Verstoss gegen den
  Guiding Principle

Korpus
- Neue Direktive ' tb-tz: +-HH:MM im Harness
- zeitzone.bas prueft Ortszeit, TIMER ab lokaler Mitternacht und den
  Programmversatz gegen eine gesetzte Uhrzeit
- datumzeit.bas auf die Direktive umgestellt; Sollausgabe unveraendert

Nebenbefund
- TIMER trennt jetzt ganze Sekunden (Ortszeit) vom Bruchteil
  (Systemuhr). Ohne die Trennung waere TIMER in Zonen mit halbstuendigem
  Versatz gesprungen.

Dokumentation: die Abweichung "Uhr laeuft in UTC" entfaellt aus
sprachreferenz.md; bibliothek.md und PLAN.md nachgezogen, jiff steht im
Stufe-2-Ideenspeicher neben crossterm.

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

413 lines
15 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 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:?}"),
}
}
/// 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);
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 },
);
}
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());
}
}
}