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:
2026-09-04 06:54:19 +02:00
parent a6f005d023
commit 52ccbb5848
94 changed files with 10421 additions and 792 deletions

View File

@@ -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());
}
}
}