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>
This commit is contained in:
@@ -1,7 +1,17 @@
|
||||
//! Kompatibilitäts-Harness (Phase-2-Meilenstein): jede Korpusdatei
|
||||
//! `tests/compat/*.bas` wird kompiliert, im Capture-Host ausgeführt und
|
||||
//! byte-genau gegen ihre `.out` verglichen. Bei Abweichung nennt der
|
||||
//! Test Datei, erste abweichende Zeile sowie Soll und Ist.
|
||||
//! 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;
|
||||
@@ -12,43 +22,185 @@ fn compat_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/compat")
|
||||
}
|
||||
|
||||
fn run_corpus_file(path: &Path) -> String {
|
||||
/// `<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_stem().unwrap().to_string_lossy().to_uppercase();
|
||||
let module = tb_vm::compile_source(&name, &src)
|
||||
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 => host.output,
|
||||
RunEvent::Ended => tb_runtime::snapshot::snapshot(&vm.rt.screen),
|
||||
other => panic!(
|
||||
"{}: unerwartetes Laufzeitende {other:?}\nAusgabe bisher:\n{}",
|
||||
"{}: unerwartetes Laufzeitende {other:?}\nBildschirm bisher:\n{}",
|
||||
path.display(),
|
||||
host.output
|
||||
tb_runtime::snapshot::snapshot(&vm.rt.screen)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Erste abweichende Zeile melden (byte-genau, inkl. Leerzeichen am Ende).
|
||||
/// 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 want_lines: Vec<&str> = want.split('\n').collect();
|
||||
let got_lines: Vec<&str> = got.split('\n').collect();
|
||||
for (i, (w, g)) in want_lines.iter().zip(got_lines.iter()).enumerate() {
|
||||
if w != g {
|
||||
panic!(
|
||||
"{file}: Abweichung in Zeile {}:\n Soll: {w:?}\n Ist: {g:?}",
|
||||
i + 1
|
||||
);
|
||||
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"),
|
||||
}
|
||||
panic!(
|
||||
"{file}: Zeilenanzahl weicht ab (Soll {} / Ist {}).\nSoll:\n{want}\nIst:\n{got}",
|
||||
want_lines.len(),
|
||||
got_lines.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -63,15 +215,25 @@ fn korpus_laeuft_mit_korrekter_ausgabe() {
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let out_path = path.with_extension("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);
|
||||
assert_output_matches(&name, &want, &got);
|
||||
checked += 1;
|
||||
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}");
|
||||
}
|
||||
@@ -130,3 +292,96 @@ fn tbc_build_erzeugt_tbc() {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user