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>
413 lines
15 KiB
Rust
413 lines
15 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.
|
||
|
||
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());
|
||
}
|
||
}
|
||
}
|