Phase 4: Steuerelemente implementieren

This commit is contained in:
2026-09-05 12:49:46 +02:00
parent 1cb6ae8fd9
commit 19804e0e2d
36 changed files with 6472 additions and 689 deletions

View File

@@ -13,9 +13,10 @@
//! nur die untere Schranke, nie eine feste Größe, und der Korpus muss das
//! nachweisen können.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Command;
use tb_runtime::host::CaptureHost;
use tb_runtime::host::{CaptureHost, Host};
use tb_vm::interp::{RunEvent, Vm};
fn compat_dir() -> PathBuf {
@@ -100,10 +101,14 @@ fn resize_direktive(name: &str, src: &str) -> Option<(usize, (usize, usize))> {
/// `<F1>`…`<F12>`, sonst je ein Zeichen; `+` davor bedeutet Umschalt,
/// `^` Strg, `%` Alt — gebraucht für die benutzerdefinierten Trap-Tasten.
fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
use tb_runtime::host::{taste, umschalt, Ereignis};
let Some(wert) = direktive(src, "tb-keys:") else {
return Vec::new();
};
tastenfolge(name, &wert)
}
fn tastenfolge(name: &str, wert: &str) -> Vec<tb_runtime::host::Ereignis> {
use tb_runtime::host::{taste, umschalt, Ereignis};
let mut out = Vec::new();
let zeichen: Vec<char> = wert.chars().collect();
let mut i = 0;
@@ -143,6 +148,21 @@ fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
"LEFT" => 0x4B,
"RIGHT" => 0x4D,
"DOWN" => 0x50,
"ENTER" => {
out.push(Ereignis::Taste(taste::ENTER.into(), shift));
i += ende + 1;
continue;
}
"ESC" => {
out.push(Ereignis::Taste(taste::ESC.into(), shift));
i += ende + 1;
continue;
}
"TAB" => {
out.push(Ereignis::Taste(taste::TAB.into(), shift));
i += ende + 1;
continue;
}
other => panic!("{name}: tb-keys kennt `<{other}>` nicht"),
};
out.push(Ereignis::Taste(taste::sonder(code), shift));
@@ -155,6 +175,98 @@ fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
out
}
enum Ereignisschritt {
Ereignis(tb_runtime::host::Ereignis),
Zeit(u64),
}
fn ereignis_direktive(name: &str, src: &str) -> Vec<Ereignisschritt> {
use tb_runtime::host::{Ereignis, MausArt, MausEreignis};
let Some(wert) = direktive(src, "tb-events:") else {
return Vec::new();
};
let mut out = Vec::new();
for teil in wert.split('|').map(str::trim) {
if let Some(keys) = teil.strip_prefix("key:") {
out.extend(
tastenfolge(name, keys)
.into_iter()
.map(Ereignisschritt::Ereignis),
);
} else if let Some(ms) = teil.strip_prefix("time:") {
let ms = ms.trim().strip_suffix("ms").unwrap_or(ms.trim());
out.push(Ereignisschritt::Zeit(
ms.parse()
.unwrap_or_else(|_| panic!("{name}: ungültige Zeit {ms:?}")),
));
} else if let Some(mouse) = teil.strip_prefix("mouse:") {
let fields: Vec<_> = mouse.split(',').map(str::trim).collect();
let [kind, button, shift, row, col] = fields.as_slice() else {
panic!("{name}: mouse erwartet art,taste,shift,zeile,spalte");
};
let art = match *kind {
"down" => MausArt::Druck,
"up" => MausArt::Loslassen,
"move" => MausArt::Bewegung,
_ => panic!("{name}: unbekannte Mausart {kind:?}"),
};
let parse = |value: &str| {
value
.parse::<usize>()
.unwrap_or_else(|_| panic!("{name}: ungültige Zahl {value:?}"))
};
out.push(Ereignisschritt::Ereignis(Ereignis::Maus(MausEreignis {
art,
taste: parse(button) as u8,
shift: parse(shift) as u8,
zeile: parse(row),
spalte: parse(col),
})));
} else {
panic!("{name}: unbekannter tb-events-Schritt {teil:?}");
}
}
out
}
struct EreignisHost {
inner: CaptureHost,
schritte: VecDeque<Ereignisschritt>,
pause: bool,
}
impl tb_runtime::host::Host for EreignisHost {
fn present(&mut self, screen: &tb_runtime::screen::TextScreen) {
self.inner.present(screen);
}
fn next_event(&mut self, blockierend: bool) -> Option<tb_runtime::host::Ereignis> {
if !blockierend && self.pause {
self.pause = false;
return None;
}
loop {
match self.schritte.pop_front() {
Some(Ereignisschritt::Zeit(ms)) => {
self.inner.uhr_vorruecken(ms);
if !blockierend {
return None;
}
}
Some(Ereignisschritt::Ereignis(event)) => {
self.pause = true;
return Some(event);
}
None => return self.inner.next_event(blockierend),
}
}
}
fn jetzt_ms(&mut self) -> u64 {
self.inner.jetzt_ms()
}
}
/// Virtuelle Uhr: `' tb-clock: <n>ms/Zustellpunkt`. Ohne die Direktive
/// steht die Uhr des Testhosts still — ein Programm mit Zeit-Traps liefe
/// dann endlos. Der Harness weist es deshalb ab, statt auf die Systemuhr
@@ -242,45 +354,77 @@ impl Drop for TempVerzeichnis {
/// 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 raw = 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 form = if path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
{
Some(tb_ui::frm::read_text(&name, &raw).unwrap_or_else(|error| panic!("{error}")))
} else {
None
};
let src = form
.as_ref()
.map_or(raw.as_str(), |form| form.code.as_str());
let modul_name = form.as_ref().map_or_else(
|| path.file_stem().unwrap().to_string_lossy().to_uppercase(),
|form| form.root.name.to_uppercase(),
);
let (cols, rows) = groesse.unwrap_or_else(|| screen_groessen(&name, src)[0]);
let module = match &form {
Some(form) => tb_vm::compile_source_with_forms(&modul_name, src, &form.catalog()),
None => tb_vm::compile_source(&modul_name, src),
}
.unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display()));
let mut vm = Vm::new(module);
if let Some(form) = &form {
form.apply(&mut vm.forms).unwrap();
vm.forms.show(0, false).unwrap();
}
vm.rt.screen.resize(cols, rows);
if let Some(z) = tz_direktive(&name, &src) {
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 _temp = direktive(src, "tb-tempdir").map(|_| TempVerzeichnis::neu(&name));
let mut host = CaptureHost::default();
if let Some((nach, (c, r))) = resize_direktive(&name, &src) {
if let Some((nach, (c, r))) = resize_direktive(&name, src) {
host.ereignis_nach(
nach,
tb_runtime::host::Ereignis::Groesse { cols: c, rows: r },
);
}
for t in tasten_direktive(&name, &src) {
for t in tasten_direktive(&name, src) {
host.ereignis(t);
}
let schritt = clock_schritt(&name, &src);
let schritt = clock_schritt(&name, src);
let schritte = ereignis_direktive(&name, src);
if !schritte.is_empty() {
assert!(
schritt.is_none(),
"{name}: tb-events: time und tb-clock nicht mischen"
);
let mut host = EreignisHost {
inner: host,
schritte: schritte.into(),
pause: false,
};
return run_to_snapshot(path, &mut vm, &mut host);
}
if let Some(schritt_ms) = schritt {
let mut host = UhrHost {
inner: host,
schritt_ms,
};
return 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)
),
};
return run_to_snapshot(path, &mut vm, &mut host);
}
match vm.run(&mut host) {
run_to_snapshot(path, &mut vm, &mut host)
}
fn run_to_snapshot(path: &Path, vm: &mut Vm, host: &mut dyn Host) -> String {
match vm.run(host) {
RunEvent::Ended => tb_runtime::snapshot::snapshot(&vm.rt.screen),
other => panic!(
"{}: unerwartetes Laufzeitende {other:?}\nBildschirm bisher:\n{}",
@@ -290,6 +434,21 @@ fn run_corpus_file(path: &Path, groesse: Option<(usize, usize)>) -> String {
}
}
fn source_for_directives(path: &Path) -> String {
let raw = std::fs::read_to_string(path).unwrap();
if path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
{
tb_ui::frm::read_text(&path.display().to_string(), &raw)
.unwrap()
.code
} else {
raw
}
}
/// 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) {
@@ -365,12 +524,16 @@ fn korpus_laeuft_mit_korrekter_ausgabe() {
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"))
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| matches!(ext, "bas" | "frm"))
})
.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 src = source_for_directives(&path);
let groessen = screen_groessen(&name, &src);
for (cols, rows) in &groessen {
// Bei mehreren Größen trägt jede ihre eigene Sollausgabe.
@@ -406,6 +569,25 @@ fn uhr_direktive_wird_gelesen() {
);
}
#[test]
fn ereignisfolge_liefert_taste_maus_und_zeit_in_reihenfolge() {
let steps = ereignis_direktive(
"form.frm",
"' tb-events: key:%o | mouse:down,1,0,5,7 | time:250ms | key:<ENTER>",
);
assert_eq!(steps.len(), 4);
assert!(
matches!(&steps[0], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, shift)) if key == "o" && *shift == 4)
);
assert!(
matches!(&steps[1], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Maus(mouse)) if mouse.zeile == 5 && mouse.spalte == 7)
);
assert!(matches!(steps[2], Ereignisschritt::Zeit(250)));
assert!(
matches!(&steps[3], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, _)) if key == tb_runtime::host::taste::ENTER)
);
}
#[test]
#[should_panic(expected = "deklariert aber keinen Zeitverlauf")]
fn zeittrap_ohne_uhr_direktive_wird_abgewiesen() {
@@ -467,6 +649,40 @@ fn tbc_build_erzeugt_tbc() {
let _ = std::fs::remove_file(&tbc);
}
#[test]
fn tbc_loest_include_projekt_und_run_datei_auf() {
let exe = env!("CARGO_BIN_EXE_tbc");
let dir = std::env::temp_dir().join(format!("tb_project_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("werte.bi"), "CONST N = 7\n").unwrap();
std::fs::write(
dir.join("main.bas"),
"'$INCLUDE: 'WERTE.BI'\nDECLARE SUB Ausgabe\nAusgabe\nEND\n",
)
.unwrap();
std::fs::write(dir.join("lib.bas"), "SUB Ausgabe\nPRINT N\nEND SUB\n").unwrap();
std::fs::write(dir.join("app.mak"), "MAIN.BAS\nLIB.BAS\n").unwrap();
let project = Command::new(exe)
.args(["run"])
.arg(dir.join("app.mak"))
.output()
.unwrap();
assert!(project.status.success(), "{project:?}");
assert_eq!(String::from_utf8_lossy(&project.stdout), " 7 \n");
std::fs::write(dir.join("start.bas"), "RUN \"ZIEL\"\n").unwrap();
std::fs::write(dir.join("ziel.bas"), "PRINT \"weiter\"\nEND\n").unwrap();
let chained = Command::new(exe)
.args(["run"])
.arg(dir.join("start.bas"))
.output()
.unwrap();
assert!(chained.status.success(), "{chained:?}");
assert_eq!(String::from_utf8_lossy(&chained.stdout), "weiter\n");
let _ = std::fs::remove_dir_all(&dir);
}
// ---- Harness meldet Abweichungen benannt (Spec kompat-testkorpus) ----------
fn meldung(want: &str, got: &str) -> String {
@@ -537,12 +753,16 @@ fn erzeuge_sollausgaben() {
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"))
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| matches!(ext, "bas" | "frm"))
})
.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 src = source_for_directives(&path);
let groessen = screen_groessen(&name, &src);
for (cols, rows) in &groessen {
let out_path = if groessen.len() == 1 {