Spezifikationsabgleich und Regressionsnachweise abschließen

This commit is contained in:
2026-09-06 10:31:16 +02:00
parent c8b92f0619
commit 024336e29c
39 changed files with 2485 additions and 120 deletions

View File

@@ -0,0 +1,188 @@
//! Optionaler öffentlicher Bestand; kein Netzwerk und keine GUI.
use std::{path::PathBuf, process::Command, time::Duration};
use tb_runtime::host::{CaptureHost, Ereignis, Host};
use tb_vm::{
bytecode::CompiledModule,
interp::{RunEvent, Vm},
};
#[path = "../../../tests/support/prozessfrist.rs"]
mod prozessfrist;
#[derive(Default)]
struct PruefHost {
inner: CaptureHost,
dialogantwort: Option<Ereignis>,
dialog_gesehen: bool,
}
impl Host for PruefHost {
fn present(&mut self, screen: &tb_runtime::screen::TextScreen) {
self.dialog_gesehen |= tb_runtime::snapshot::text(screen).contains("Save changes to");
self.inner.present(screen);
}
fn next_event(&mut self, block: bool) -> Option<Ereignis> {
if block {
if let Some(event) = self.dialogantwort.take() {
return Some(event);
}
}
self.inner.next_event(block)
}
fn jetzt_ms(&mut self) -> u64 {
self.inner.jetzt_ms()
}
fn warten(&mut self, deadline: Option<u64>) -> Option<Ereignis> {
self.inner.warten(deadline)
}
}
const REVISION: &str = "1cdd2b32b829fe1721d0b6aecc433abc47a96fb6";
#[test]
#[ignore = "TB_VBDOS_REPO muss auf cout/vbdos in der dokumentierten Revision zeigen"]
fn oeffentliche_formularprogramme_sind_reproduzierbar_bedienbar() {
let _frist =
prozessfrist::Prozessfrist::neu("öffentlicher VBDOS-Bestand", Duration::from_secs(60));
let repo = PathBuf::from(std::env::var_os("TB_VBDOS_REPO").expect("TB_VBDOS_REPO fehlt"));
let rev = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
assert!(rev.status.success());
assert_eq!(String::from_utf8_lossy(&rev.stdout).trim(), REVISION);
let original = std::env::current_dir().unwrap();
let base = std::env::temp_dir().join(format!("tb-foreign-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let archive = base.join("source.tar");
assert!(Command::new("git")
.arg("-C")
.arg(&repo)
.args(["archive", "--format=tar", "-o"])
.arg(&archive)
.arg(REVISION)
.status()
.unwrap()
.success());
for (entry, keys) in [
("graphics/graphics.mak", vec![("x", 4)]),
("microsoft/check.mak", vec![("f", 4), ("x", 0)]),
("microsoft/qlbview.mak", vec![("\u{1b}", 0)]),
("microsoft/seek.mak", vec![("x", 4)]),
(
"microsoft/spindemo.mak",
vec![("\t", 0), ("\t", 0), ("\0H", 0)],
),
("microsoft/notepad.frm", vec![("f", 4), ("x", 0)]),
("misc/mentors/mentors.frm", vec![("f", 4), ("x", 0)]),
] {
let mut results = Vec::new();
for repeat in 0..2 {
let dir = base.join(format!("run-{repeat}"));
std::fs::create_dir_all(&dir).unwrap();
assert!(Command::new("tar")
.arg("-xf")
.arg(&archive)
.arg("-C")
.arg(&dir)
.status()
.unwrap()
.success());
let path = dir.join(entry);
let build = Command::new(env!("CARGO_BIN_EXE_tbc"))
.arg("build")
.arg(&path)
.output()
.unwrap();
assert!(
build.status.success() && build.stderr.is_empty(),
"{entry}: {build:?}"
);
let module =
CompiledModule::from_tbc(&std::fs::read(path.with_extension("tbc")).unwrap())
.unwrap();
let mut vm = Vm::new(module);
vm.rt.screen.resize(100, 30);
vm.forms.resize(100, 30);
std::env::set_current_dir(path.parent().unwrap()).unwrap();
let mut host = PruefHost::default();
assert_eq!(vm.run(&mut host), RunEvent::Ended, "{entry}: Start");
assert_eq!(
vm.run_visible_forms(&mut host),
RunEvent::Ended,
"{entry}: initiale Ereignisse"
);
assert!(
vm.forms.has_visible_forms(),
"{entry}: kein sichtbares Startformular"
);
let text1 = vm
.forms
.objects
.iter()
.position(|o| o.description.name.eq_ignore_ascii_case("Text1"));
let before = tb_runtime::snapshot::snapshot(&vm.rt.screen);
let mut states = Vec::new();
for (key, shift) in &keys {
vm.rt.ende = false;
if entry == "microsoft/notepad.frm" && *key == "x" {
host.dialogantwort = Some(Ereignis::Taste("n".into(), 0));
}
host.inner.ereignis(Ereignis::Taste((*key).into(), *shift));
assert_eq!(
vm.run_visible_forms(&mut host),
RunEvent::Ended,
"{entry}: Taste {key:?}"
);
states.push((
vm.forms.has_visible_forms(),
vm.forms.menu_is_open(),
vm.forms.active_form(),
tb_runtime::snapshot::snapshot(&vm.rt.screen),
));
}
if entry.contains("spindemo") {
let text_id =
tb_frontend::forms::property(tb_frontend::forms::ObjectClass::TextBox, "TEXT")
.unwrap()
.0;
assert_eq!(
vm.forms.get(text1.unwrap() as u16, text_id).unwrap(),
tb_ui::forms::PropertyValue::String(" 1".into()),
"{entry}: Spin-Handler hat Text1 nicht aktualisiert"
);
assert!(
states[2].3 != states[1].3,
"Pfeiltaste muss den sichtbaren Wert ändern"
);
} else {
assert!(
!vm.forms.has_visible_forms(),
"{entry}: Exit/Cancel hat das Formular nicht entladen"
);
}
if keys.len() == 2 {
assert!(
states[0].1 && !states[1].1,
"{entry}: Menü öffnen und Exit auswählen"
);
}
if entry == "microsoft/notepad.frm" {
assert!(
host.dialog_gesehen && host.dialogantwort.is_none(),
"Notepad: Save-Dialog mit N beantworten"
);
}
assert!(
states.iter().any(|s| !s.0 || s.3 != before),
"{entry}: keine sichtbare Zustandsänderung"
);
eprintln!("{entry}: Lauf {} bestanden", repeat + 1);
results.push(states);
std::env::set_current_dir(&original).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
assert_eq!(results[0], results[1], "{entry}: nicht reproduzierbar");
}
std::fs::remove_dir_all(base).unwrap();
}

View File

@@ -19,7 +19,7 @@ const OFFEN: &str = "offen";
const NON_FEATURE: &str = "Non-Feature";
fn wurzel() -> PathBuf {
// crates/tb-frontend/ → Projektwurzel
// crates/tb-cli/ → Projektwurzel
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}
@@ -599,3 +599,486 @@ fn signatur_greift_ohne_laufzeitverhalten() {
"Diagnose nennt `MKL$` nicht: {texte:?}"
);
}
// Die Vorlagen sind feste Eingaben, keine Statusliste aus Implementierungstabellen.
fn probe_source(e: &Eintrag) -> (String, tb_frontend::forms::FormCatalog) {
use tb_frontend::forms::{self, ObjectClass, PropertyType};
let mut catalog = forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("Probe", ObjectClass::TextBox, Some("Form1"), false);
if !matches!(e.art.as_str(), "Eigenschaft" | "Methode" | "Ereignis") {
let source = include_str!("../../../tests/support/inventar-quellen.tsv")
.lines()
.find_map(|line| {
let mut columns = line.split('\t');
(columns.next() == Some(e.name.as_str()) && columns.next() == Some(e.art.as_str()))
.then(|| columns.next().unwrap().replace("\\n", "\n"))
})
.unwrap_or_else(|| panic!("{}: Status {}, Programmvorlage fehlt", e.name, e.status));
if e.name == "$FORM" {
catalog.objects.clear();
}
return (source, catalog);
}
let (class_name, member) = e
.name
.split_once(if e.art == "Ereignis" { '_' } else { '.' })
.unwrap();
let class = ObjectClass::parse(class_name).unwrap();
let mut catalog = forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
let object = if class == ObjectClass::Form {
"Form1"
} else if class == ObjectClass::Screen {
"SCREEN"
} else {
catalog.add("Probe", class, Some("Form1"), false);
"Probe"
};
let source = match e.art.as_str() {
"Eigenschaft" => {
let (_, prop) = forms::property(class, &member.to_uppercase()).unwrap();
let suffix = if matches!(
member.to_uppercase().as_str(),
"LIST" | "ITEMDATA" | "SELECTED" | "CONTROLPANEL"
) {
"(0)"
} else {
""
};
if prop.ty == PropertyType::Object {
format!("DIM sink AS CONTROL\nsink = {object}.{member}{suffix}")
} else {
format!("PRINT {object}.{member}{suffix}")
}
}
"Methode" => {
let member = member.to_uppercase();
let args = match member.as_str() {
"MOVE" => " 1, 1",
"DRAG" => " 0",
"ADDITEM" => " \"x\"",
"REMOVEITEM" => " 0",
"TEXTHEIGHT" | "TEXTWIDTH" => "(\"x\")",
_ => "",
};
let prefix = if forms::method_return_type(class, &member).is_some() {
"PRINT "
} else {
""
};
format!("{prefix}{object}.{member}{args}")
}
_ => {
let args = forms::event_params(member)
.unwrap()
.iter()
.map(|(name, ty)| {
format!(
"{name} AS {}",
match ty {
forms::EventParamType::Integer => "INTEGER",
forms::EventParamType::Single => "SINGLE",
forms::EventParamType::Control => "CONTROL",
}
)
})
.collect::<Vec<_>>()
.join(", ");
let prefix = if class == ObjectClass::Form {
"Form"
} else {
object
};
format!("END\nSUB {prefix}_{member}({args})\nPRINT \"handler\"\nEND SUB")
}
};
(source, catalog)
}
fn pruefe_ausfuehrbaren_pfad(e: &Eintrag, bound: impl Fn(u16) -> bool) -> Result<(), String> {
let (src, catalog) = probe_source(e);
if matches!(e.name.as_str(), "$DYNAMIC" | "$STATIC") {
let parsed = tb_frontend::analyze_source("FORM1", &src);
let expected = e.name == "$STATIC";
if !parsed.module.body.iter().any(|s| matches!(s, tb_frontend::ast::Stmt::MetaArrays { static_arrays, .. } if *static_arrays == expected)) {
return Err(format!("{}: Status {}, fehlender Metabefehl-Pfad", e.name, e.status));
}
}
let compiled = if e.name == "$INCLUDE" {
// Einbindung gehört zum CLI-Projektlader, nicht zum Einzelmodul-Frontend.
let dir = std::env::temp_dir().join(format!("tb-inventar-include-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("probe.bas"), src).unwrap();
std::fs::write(dir.join("x.bi"), "CONST Included = 73\n").unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_tbc"))
.args(["build", dir.join("probe.bas").to_str().unwrap()])
.output()
.unwrap();
let result = if output.status.success() {
tb_vm::bytecode::CompiledModule::from_tbc(
&std::fs::read(dir.join("probe.tbc")).unwrap(),
)
.map_err(|err| {
format!(
"{}: Status {}, ungültiges Kompilat: {err}",
e.name, e.status
)
})
} else {
Err(format!(
"{}: Status {}, CLI-Pfad fehlgeschlagen: {}",
e.name,
e.status,
String::from_utf8_lossy(&output.stderr)
))
};
std::fs::remove_dir_all(dir).unwrap();
Ok(result?)
} else {
tb_vm::compile_source_with_forms("FORM1", &src, &catalog)
};
pruefe_kompilat(e, compiled, bound)
}
/// Feste Sollziele aus Sprache/Runtime-ABI; niemals aus dem gerade geprüften
/// Codegenerator gewonnen. Deklarationen prüfen ihr Modulresultat, Forms den
/// konkreten Member am tatsächlich abgesenkten Objekt.
fn pruefe_sollziel(e: &Eintrag, module: &tb_vm::bytecode::CompiledModule) -> Result<(), String> {
use tb_frontend::forms;
use tb_vm::bytecode::Instr;
let code: Vec<_> = module.procs.iter().flat_map(|p| &p.code).collect();
let member_matches = |object: u16, member: u16, method: bool| {
module.objects.get(object as usize).is_some_and(|obj| {
let name = if method {
forms::methods(obj.class).get(member as usize).copied()
} else {
forms::properties(obj.class)
.get(member as usize)
.map(|p| p.name)
};
name.is_some_and(|name| {
e.name
.eq_ignore_ascii_case(&format!("{}.{}", obj.class.name(), name))
})
})
};
let matches = match e.art.as_str() {
"Eigenschaft" => code.iter().any(|i| match i {
Instr::LoadObjectProperty(object, property, false)
| Instr::LoadObjectIndexedProperty(object, property) => {
member_matches(*object, *property, false)
}
_ => false,
}),
"Methode" => code.iter().any(|i| match i {
Instr::ObjectMethod(object, method, _) | Instr::ObjectMethodFn(object, method, _) => {
member_matches(*object, *method, true)
}
_ => false,
}),
"Ereignis" => module.event_procs.iter().any(|event| {
let class = module.objects[event.object as usize].class;
e.name
.eq_ignore_ascii_case(&format!("{}_{}", class.name(), event.event))
&& module.procs[event.proc as usize].code.iter().any(|i| {
matches!(
i,
Instr::CallBuiltin(tb_runtime::builtins::ids::PRINT_VAL, _)
)
})
}),
_ => {
let expected = include_str!("../../../tests/support/inventar-quellen.tsv")
.lines()
.find_map(|line| {
let columns: Vec<_> = line.split('\t').collect();
(columns[0] == e.name && columns[1] == e.art).then_some(columns[3])
})
.unwrap();
expected.split('|').all(|contract| {
if let Some(id) = contract.strip_prefix("builtin:") {
let id: u16 = id.parse().unwrap();
return code
.iter()
.any(|i| matches!(i, Instr::CallBuiltin(actual, _) if *actual == id));
}
if let Some(op) = contract.strip_prefix("code:") {
return code.iter().any(|i| format!("{i:?}").starts_with(op));
}
if let Some(global) = contract.strip_prefix("global:") {
let (name, ty) = global.split_once(':').unwrap();
return module
.global_names
.iter()
.zip(&module.globals_init)
.any(|(actual, init)| actual == name && format!("{init:?}") == ty);
}
if let Some(name) = contract.strip_prefix("proc:") {
return module.procs.iter().any(|p| p.name == name);
}
match contract {
"base:1" => module.option_base == 1,
"data:1" => module.data.iter().any(|d| d.text == "1"),
"udt:R" => module
.udts
.iter()
.any(|u| u.name == "R" && u.fields == [tb_runtime::value::TypeInit::Int]),
"comment" => code
.iter()
.all(|i| matches!(i, Instr::Source(..) | Instr::Stmt(_) | Instr::End)),
_ => panic!("{}: unbekannter Sollvertrag {contract}", e.name),
}
})
}
};
if matches {
Ok(())
} else {
Err(format!(
"{}: Status {}, falsches oder fehlendes Laufzeitziel: {code:?}",
e.name, e.status
))
}
}
fn pruefe_kompilat(
e: &Eintrag,
compiled: Result<tb_vm::bytecode::CompiledModule, Vec<tb_frontend::Diagnostic>>,
bound: impl Fn(u16) -> bool,
) -> Result<(), String> {
use tb_vm::bytecode::Instr;
let actual = match compiled {
Err(d) if d.iter().any(|d| d.message.contains("Feature unavailable")) => {
let name = e
.name
.to_uppercase()
.replace(" (EREIGNIS)", "")
.replace(" (GRAFIK)", "");
let name = name
.strip_prefix("ON ")
.or_else(|| name.strip_prefix("OPEN "))
.unwrap_or(&name);
if !d.iter().any(|d| {
d.message.contains("Feature unavailable") && d.message.to_uppercase().contains(name)
}) {
return Err(format!(
"{}: Status {}, falsche Non-Feature-Diagnose: {d:?}",
e.name, e.status
));
}
NON_FEATURE
}
Err(d) => {
return Err(format!(
"{}: Status {}, kein erreichbarer HIR-Pfad: {d:?}",
e.name, e.status
))
}
Ok(module) => {
if module
.procs
.iter()
.flat_map(|p| &p.code)
.any(|i| matches!(i, Instr::Unsupported(_)))
{
OFFEN
} else {
for i in module.procs.iter().flat_map(|p| &p.code) {
if let Instr::CallBuiltin(id, _) = i {
if !bound(*id) {
return Err(format!(
"{}: Status {}, fehlende Runtime-Bindung {id}",
e.name, e.status
));
}
}
}
module.validate().map_err(|d| {
format!(
"{}: Status {}, ungültiges Laufzeitziel: {d:?}",
e.name, e.status
)
})?;
pruefe_sollziel(e, &module)?;
IMPLEMENTIERT
}
}
};
if e.status == actual {
Ok(())
} else {
Err(format!(
"{}: Status {}, vorgefunden {actual}",
e.name, e.status
))
}
}
#[test]
fn jeder_inventareintrag_erreicht_hir_und_laufzeitziel() {
let errors: Vec<_> = inventar()
.iter()
.filter_map(|e| {
pruefe_ausfuehrbaren_pfad(e, |id| {
tb_runtime::builtins::builtin_table()
.get(id as usize)
.is_some()
})
.err()
})
.collect();
assert!(
errors.is_empty(),
"{} Abweichungen:\n{}",
errors.len(),
errors.join("\n")
);
}
#[test]
fn falscher_status_und_fehlende_runtimebindung_werden_namentlich_erkannt() {
let inv = inventar();
for (name, status) in [
("LOCATE", OFFEN),
("LOCATE", NON_FEATURE),
("PEEK", IMPLEMENTIERT),
] {
let mut e = inv.iter().find(|e| e.name == name).unwrap().clone();
e.status = status.into();
let error = pruefe_ausfuehrbaren_pfad(&e, |_| true).unwrap_err();
assert!(error.contains(name) && error.contains(status), "{error}");
}
let e = inv.iter().find(|e| e.name == "TIMEZONEKNOWN").unwrap();
let error = pruefe_ausfuehrbaren_pfad(e, |_| false).unwrap_err();
assert!(
error.contains("TIMEZONEKNOWN")
&& error.contains(IMPLEMENTIERT)
&& error.contains("Runtime-Bindung"),
"{error}"
);
}
#[test]
fn implementiert_mit_unsupported_marker_faellt_durch() {
let inv = inventar();
let e = inv.iter().find(|e| e.name == "LOCATE").unwrap();
let mut module = tb_vm::compile_source("TEST", "LOCATE 1,1").unwrap();
module.procs[0]
.code
.push(tb_vm::bytecode::Instr::Unsupported(0));
let error = pruefe_kompilat(e, Ok(module), |_| true).unwrap_err();
assert!(
error.contains("LOCATE") && error.contains(IMPLEMENTIERT) && error.contains(OFFEN),
"{error}"
);
}
#[test]
fn falsche_gueltige_builtinbindung_und_entfernte_absenkung_fallen_durch() {
use tb_vm::bytecode::Instr;
for e in inventar().iter().filter(|e| e.status == IMPLEMENTIERT) {
let (mut src, catalog) = probe_source(e);
if e.name == "$INCLUDE" {
src = "PRINT 73#".into();
}
let expected = include_str!("../../../tests/support/inventar-quellen.tsv")
.lines()
.find_map(|line| {
let columns: Vec<_> = line.split('\t').collect();
(columns[0] == e.name && columns[1] == e.art).then_some(columns[3])
});
if let Some(id) = expected.and_then(|s| s.strip_prefix("builtin:")) {
let id: u16 = id.split('|').next().unwrap().parse().unwrap();
let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap();
let mut changed = false;
for i in module.procs.iter_mut().flat_map(|p| &mut p.code) {
if let Instr::CallBuiltin(actual, _) = i {
if *actual == id {
*actual = (id + 1) % tb_runtime::builtins::ids::COUNT;
changed = true;
}
}
}
assert!(changed, "{}: Gegenprobe traf kein Ziel", e.name);
let error = pruefe_kompilat(e, Ok(module), |_| true).unwrap_err();
assert!(
error.contains(&e.name) && error.contains(IMPLEMENTIERT),
"{error}"
);
}
if expected.is_some_and(|s| s.starts_with("builtin:") || s.starts_with("code:"))
|| matches!(e.art.as_str(), "Eigenschaft" | "Methode" | "Ereignis")
{
let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap();
for proc in &mut module.procs {
proc.code.clear();
}
let error = pruefe_kompilat(e, Ok(module), |_| true).unwrap_err();
assert!(
error.contains(&e.name) && error.contains(IMPLEMENTIERT),
"{error}"
);
}
}
}
#[test]
fn forms_absenkung_muss_den_richtigen_member_erreichen() {
use tb_vm::bytecode::Instr;
for name in ["TextBox.Text", "Form.Show"] {
let e = inventar()
.into_iter()
.find(|e| e.name.eq_ignore_ascii_case(name))
.unwrap();
let (src, catalog) = probe_source(&e);
let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap();
let mut changed = false;
for i in module.procs.iter_mut().flat_map(|p| &mut p.code) {
match i {
Instr::LoadObjectProperty(object, property, _) => {
*property =
tb_frontend::forms::property(module.objects[*object as usize].class, "TAG")
.unwrap()
.0;
changed = true;
}
Instr::ObjectMethod(object, method, _) => {
*method = tb_frontend::forms::methods(module.objects[*object as usize].class)
.iter()
.position(|m| *m == "HIDE")
.unwrap() as u16;
changed = true;
}
_ => {}
}
}
assert!(changed);
let error = pruefe_kompilat(&e, Ok(module), |_| true).unwrap_err();
assert!(
error.contains(&e.name) && error.contains(IMPLEMENTIERT),
"{error}"
);
}
}
#[test]
fn gemeinsam_dispatchte_konverter_brauchen_den_richtigen_typselektor() {
use tb_vm::bytecode::Instr;
for name in ["MKI$", "CVD"] {
let e = inventar().into_iter().find(|e| e.name == name).unwrap();
let (src, catalog) = probe_source(&e);
let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap();
let mut changed = false;
for i in module.procs.iter_mut().flat_map(|p| &mut p.code) {
if let Instr::PushLng(kind) = i {
*kind = 2;
changed = true;
}
}
assert!(changed);
assert!(pruefe_kompilat(&e, Ok(module), |_| true)
.unwrap_err()
.contains(name));
}
}

View File

@@ -128,3 +128,58 @@ fn cli_meldet_physische_quellorte_auch_nach_verschachtelten_includes() {
assert!(tbc(&dir, "run", "empty.bas").status.success());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn erlaubte_grafik_und_benannte_nonfeatures_werden_per_cli_geprueft() {
let dir = std::env::temp_dir().join(format!("tb-graphics-check-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("probe.bas");
for mode in 0..=13 {
std::fs::write(
&path,
format!("SCREEN {mode}\nLINE (1,1)-(4,4),1\nPAINT (2,2),1\nVIEW (0,0)-(10,10)\n"),
)
.unwrap();
let out = tbc(&dir, "check", "probe.bas");
assert!(
out.status.success() && out.stderr.is_empty(),
"SCREEN {mode}: {out:?}"
);
}
for (name, source) in [
("POKE", "POKE 1,2"),
("PEEK", "PRINT PEEK(1)"),
("CIRCLE", "CIRCLE (1,1),1"),
("WINDOW", "WINDOW (0,0)-(10,10)"),
("IOCTL", "IOCTL #1, \"x\""),
("PSET", "PSET (1,1),2"),
("PSET", "PSET STEP (1,1),2"),
("PRESET", "PRESET (1,1),2"),
("PRESET", "PRESET STEP (1,1),2"),
("CIRCLE", "CIRCLE STEP (1,1),2"),
("WINDOW", "WINDOW SCREEN (0,0)-(10,10)"),
("WINDOW", "WINDOW"),
("PALETTE", "DIM a%(15)\nPALETTE USING a%(0)"),
("PALETTE", "PALETTE"),
("PALETTE", "IF 1 THEN PALETTE USING a%(0)"),
] {
std::fs::write(&path, source).unwrap();
let out = tbc(&dir, "check", "probe.bas");
let message = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success()
&& message.contains("Feature unavailable")
&& message.contains(name),
"{out:?}"
);
}
// Bibliotheksnamen sind keine reservierten Variablennamen.
std::fs::write(
&path,
"DIM PSET(2), PALETTE(2)\nPSET(1)=3\nPALETTE(1)=4\nPRINT PSET(1),PALETTE(1)",
)
.unwrap();
let out = tbc(&dir, "check", "probe.bas");
assert!(out.status.success(), "{out:?}");
std::fs::remove_dir_all(dir).unwrap();
}

View File

@@ -194,7 +194,6 @@ impl<'a> P<'a> {
fn parse_statement(&mut self) -> Option<Stmt> {
let pos = self.pos();
// Zeilennummern und Labels nur am Zeilenanfang
if self.at_line_start {
if let TokenKind::Num(NumValue::Int(n)) = self.k() {
@@ -611,7 +610,10 @@ impl<'a> P<'a> {
self.advance();
if self.k() == TokenKind::LParen {
// Grafikform GET (x1,y1)-(x2,y2): deklariertes Non-Feature
self.err("Feature unavailable");
self.err(format!(
"Feature unavailable: {}",
if k == Kw::Get { "GET" } else { "PUT" }
));
self.sync();
return None;
}
@@ -1804,7 +1806,7 @@ impl<'a> P<'a> {
// Non-Feature — an der Syntax erkennbar, weil `DEF` sonst nur
// `DEF FNname` einleitet.
if matches!(self.k(), TokenKind::Ident { ref name, suffix: None } if name == "SEG") {
self.err("Feature unavailable");
self.err("Feature unavailable: DEF SEG");
self.sync();
return None;
}
@@ -1885,6 +1887,21 @@ impl<'a> P<'a> {
self.sync();
return None;
}
// Ausgeschlossene Anweisungen besitzen eigene Koordinaten-/Dateisyntax.
// Erst Zuweisungen erkennen, damit gleichnamige Variablen/Arrays gültig bleiben.
if let Expr::Name {
name, suffix: None, ..
} = &target
{
if matches!(
name.as_str(),
"CIRCLE" | "WINDOW" | "IOCTL" | "PSET" | "PRESET" | "PALETTE"
) {
self.err(format!("Feature unavailable: {name}"));
self.sync();
return None;
}
}
// Ereignissteuerung: `TIMER ON`, `KEY(5) OFF`, `UEVENT STOP` …
if let Expr::Name {
ref name,

View File

@@ -265,7 +265,8 @@ fn builtin_fn(name: &str) -> Option<(u8, u8, &'static [ArgK], RetK)> {
(5, 5, &[N, N, N, N, N], Db)
}
"IPMT" | "IPMT#" | "PPMT" | "PPMT#" | "RATE" | "RATE#" => (6, 6, &[N, N, N, N, N, N], Db),
"NPV" | "NPV#" | "IRR" | "IRR#" => (2, 2, &[N, A], Db),
"NPV" | "NPV#" => (2, 2, &[N, A], Db),
"IRR" | "IRR#" => (2, 2, &[A, N], Db),
"MIRR" | "MIRR#" => (3, 3, &[A, N, N], Db),
"SLN" | "SLN#" => (3, 3, &[N, N, N], Db),
"SYD" | "SYD#" | "DDB" | "DDB#" => (4, 4, &[N, N, N, N], Db),
@@ -278,6 +279,7 @@ fn builtin_fn(name: &str) -> Option<(u8, u8, &'static [ArgK], RetK)> {
"CVD" | "CVDMBF" => (1, 1, &[S], Db),
"CVC" => (1, 1, &[S], Cu),
// Dateisystem und System
"SHELL" => (1, 1, &[S], L),
"CURDIR$" => (0, 1, &[S], St),
"DIR$" => (0, 1, &[S], St),
"LPOS" => (1, 1, &[N], I),
@@ -2861,7 +2863,7 @@ impl Sema {
} => {
let Some(art) = trap_art(device) else {
// COM/PEN/PLAY/STRIG sind Non-Feature — namentlich.
self.err(*pos, "Feature unavailable");
self.err(*pos, format!("Feature unavailable: {device}"));
return;
};
// Wertebereich prüfen, soweit die Kennung konstant ist.
@@ -2908,7 +2910,7 @@ impl Sema {
} => {
match device.as_str() {
"TIMER" | "KEY" | "UEVENT" | "SIGNAL" | "EVENT" => {}
_ => self.err(*pos, "Feature unavailable"),
_ => self.err(*pos, format!("Feature unavailable: {device}")),
}
if let Some(i) = index {
self.want_num(i, scope);
@@ -2939,7 +2941,7 @@ impl Sema {
return;
}
let Some(art) = trap_art(device) else {
self.err(*pos, "Feature unavailable");
self.err(*pos, format!("Feature unavailable: {device}"));
return;
};
if let Some(i) = index {
@@ -3566,7 +3568,7 @@ impl Sema {
for a in args {
self.lower_expr(a, scope);
}
self.err(pos, "Feature unavailable");
self.err(pos, format!("Feature unavailable: {name}"));
return;
}
self.err(pos, "Subprogram not defined");
@@ -4147,7 +4149,7 @@ impl Sema {
fn reject_com_device(&mut self, file: &Expr, pos: SourcePos) {
if let Expr::StrLit(s, _) = file {
if ist_com_geraet(s) {
self.err(pos, "Feature unavailable");
self.err(pos, format!("Feature unavailable: {s}"));
}
}
}
@@ -4922,7 +4924,7 @@ impl Sema {
for a in idx {
self.lower_expr(a, scope);
}
self.err(pos, "Feature unavailable");
self.err(pos, format!("Feature unavailable: {full_name}"));
return (HExpr::Int(0), Ty::Unknown);
}
// 4. Builtin-Funktion
@@ -4982,7 +4984,7 @@ impl Sema {
.is_some_and(|v| suffix.is_none_or(|s| v.ty == suffix_ty(s)));
if !declared {
if banned_feature(&full_name) {
self.err(pos, "Feature unavailable");
self.err(pos, format!("Feature unavailable: {full_name}"));
return (HExpr::Int(0), Ty::Unknown);
}
if let Some((0, _, _, _)) = builtin_fn(&full_name) {
@@ -5628,13 +5630,13 @@ mod tests {
#[test]
fn hardware_features_zur_compilezeit_abgelehnt() {
assert!(diags("POKE 100, 1").contains(&"Feature unavailable".to_string()));
assert!(diags("x = PEEK(100)").contains(&"Feature unavailable".to_string()));
assert!(diags("p = VARPTR(a%)").contains(&"Feature unavailable".to_string()));
assert!(diags("SOUND 440, 10").contains(&"Feature unavailable".to_string()));
assert!(diags("CHAIN \"prog\"").contains(&"Feature unavailable".to_string()));
assert!(diags("PLAY \"cde\"").contains(&"Feature unavailable".to_string()));
assert!(diags("CIRCLE 1, 2").contains(&"Feature unavailable".to_string()));
assert!(diags("POKE 100, 1").contains(&"Feature unavailable: POKE".to_string()));
assert!(diags("x = PEEK(100)").contains(&"Feature unavailable: PEEK".to_string()));
assert!(diags("p = VARPTR(a%)").contains(&"Feature unavailable: VARPTR".to_string()));
assert!(diags("SOUND 440, 10").contains(&"Feature unavailable: SOUND".to_string()));
assert!(diags("CHAIN \"prog\"").contains(&"Feature unavailable: CHAIN".to_string()));
assert!(diags("PLAY \"cde\"").contains(&"Feature unavailable: PLAY".to_string()));
assert!(diags("CIRCLE 1, 2").contains(&"Feature unavailable: CIRCLE".to_string()));
}
#[test]
@@ -5726,7 +5728,7 @@ mod tests {
fn datei_ea_und_events() {
let src = "OPEN \"test.dat\" FOR RANDOM AS #1 LEN = 64\nCLOSE #1\nOPEN \"o\", #2, \"f.txt\"\nCLOSE\nTIMER ON\nKEY(5) OFF";
assert!(diags(src).is_empty(), "{:?}", diags(src));
assert!(diags("PEN ON").contains(&"Feature unavailable".to_string()));
assert!(diags("PEN ON").contains(&"Feature unavailable: PEN".to_string()));
}
fn form_catalog() -> FormCatalog {

View File

@@ -75,6 +75,7 @@ pub struct FormsModel {
visible_forms: Vec<u16>,
active_form: Option<u16>,
active_control: Option<(u16, Option<i32>)>,
dropdown: Option<ObjectKey>,
width: usize,
height: usize,
screen_visible: bool,
@@ -103,16 +104,35 @@ impl FormsModel {
};
if self.root_form(control) == Some(form) {
self.active_control = None;
self.dropdown = None;
}
}
fn activate_form(&mut self, form: Option<u16>) {
if self.active_form == form {
return;
}
if let Some(old) = self.active_form {
self.queue_named((old, None), "LOSTFOCUS", vec![]);
}
self.active_form = form;
self.dropdown = None;
if let Some(new) = form {
self.queue_named((new, None), "GOTFOCUS", vec![]);
}
}
pub fn new(objects: Vec<FormObject>, width: usize, height: usize) -> Self {
let objects = objects
.into_iter()
.map(|description| {
let properties = forms::properties(description.class)
let mut properties: Vec<_> = forms::properties(description.class)
.into_iter()
.map(|p| PropertyValue::from_default(p.default, p.ty))
.collect();
if let Some((id, _)) = forms::property(description.class, "PARENT") {
properties[id as usize] =
PropertyValue::Object(description.parent.map(|id| (id, None)));
}
let loaded = description.class == ObjectClass::Screen;
ObjectInstance {
description,
@@ -132,6 +152,7 @@ impl FormsModel {
visible_forms: Vec::new(),
active_form: None,
active_control: None,
dropdown: None,
width,
height,
screen_visible: true,
@@ -251,10 +272,6 @@ impl FormsModel {
.unwrap_or(1);
return Ok(PropertyValue::Integer(value.saturating_sub(2)));
}
if spec.name == "PARENT" {
let parent = obj.description.parent.map(|id| (id, None));
return Ok(PropertyValue::Object(parent));
}
Ok(obj.properties[property as usize].clone())
}
@@ -420,6 +437,7 @@ impl FormsModel {
self.replace_selection(key, &replacement)?;
return Ok(());
}
let changed = self.value(key, spec.name) != Some(&value);
let timer_reset = class == ObjectClass::Timer
&& (spec.name == "INTERVAL"
|| (spec.name == "ENABLED" && self.value(key, "ENABLED") != Some(&value)));
@@ -433,6 +451,12 @@ impl FormsModel {
);
}
}
if changed && class == ObjectClass::Form && matches!(spec.name, "WIDTH" | "HEIGHT") {
self.queue_named(key, "RESIZE", vec![]);
}
if changed && class == ObjectClass::Label && spec.name == "CAPTION" {
self.queue_named(key, "CHANGE", vec![]);
}
if spec.name == "SORTED" && self.boolean(key, "SORTED") {
self.sort_list(key)?;
}
@@ -464,6 +488,17 @@ impl FormsModel {
) && matches!(spec.name, "PATH" | "DRIVE" | "PATTERN")
{
self.refresh_filesystem(key)?;
if changed {
let event = match (class, spec.name) {
(ObjectClass::FileListBox, "PATTERN") => "PATTERNCHANGE",
(ObjectClass::FileListBox | ObjectClass::DirListBox, "PATH") => "PATHCHANGE",
_ => "CHANGE",
};
self.queue_named(key, event, vec![]);
if class == ObjectClass::DirListBox {
self.queue_named(key, "CHANGE", vec![]);
}
}
}
self.dirty = true;
Ok(())
@@ -566,7 +601,8 @@ impl FormsModel {
Self::set_visible_property(obj, true);
self.visible_forms.retain(|id| *id != object);
self.visible_forms.push(object);
self.active_form = Some(object);
self.activate_form(Some(object));
self.queue_named((object, None), "PAINT", vec![]);
self.dirty = true;
Ok(if modal {
ShowResult::ModalWait
@@ -590,7 +626,7 @@ impl FormsModel {
self.modal.pop();
}
if self.active_form == Some(object) {
self.active_form = self.visible_forms.last().copied();
self.activate_form(self.visible_forms.last().copied());
}
self.clear_active_control_for_form(object);
self.reset_form_timers(object);
@@ -629,7 +665,7 @@ impl FormsModel {
self.modal.pop();
}
if self.active_form == Some(object) {
self.active_form = self.visible_forms.last().copied();
self.activate_form(self.visible_forms.last().copied());
}
self.clear_active_control_for_form(object);
self.reset_form_timers(object);
@@ -693,6 +729,9 @@ impl FormsModel {
.map(|_| ())
.ok_or(RuntimeError(340));
if removed.is_ok() {
if self.dropdown == Some((base, Some(index))) {
self.dropdown = None;
}
self.reset_timer((base, Some(index)));
self.lists.remove(&(base, Some(index)));
}
@@ -745,10 +784,21 @@ impl FormsModel {
.ok_or(RuntimeError(420))?
.description
};
if matches!(description.class, ObjectClass::Form | ObjectClass::Screen) {
if matches!(
description.class,
ObjectClass::Form
| ObjectClass::Screen
| ObjectClass::Frame
| ObjectClass::Label
| ObjectClass::Menu
| ObjectClass::Timer
) {
return Err(RuntimeError(421));
}
self.active_form = self.root_form(key);
self.activate_form(self.root_form(key));
if self.active_control != Some(key) {
self.dropdown = None;
}
self.active_control = Some(key);
Ok(())
}
@@ -822,14 +872,28 @@ impl FormsModel {
});
}
fn root_form(&self, key: ObjectKey) -> Option<u16> {
let mut current = key.0;
for _ in 0..self.objects.len() {
let obj = self.objects.get(current as usize)?;
if obj.description.class == ObjectClass::Form {
return Some(current);
fn parent_key(&self, key: ObjectKey) -> Option<ObjectKey> {
match self.value(key, "PARENT") {
Some(PropertyValue::Object(parent)) => {
parent.map(|(id, index)| (id, index.filter(|i| *i != 0)))
}
current = obj.description.parent?;
_ => self
.instance(key)
.ok()?
.description
.parent
.map(|id| (id, None)),
}
}
fn root_form(&self, key: ObjectKey) -> Option<u16> {
let mut current = key;
for _ in 0..self.objects.len() + self.dynamic.len() {
let obj = self.instance(current).ok()?;
if obj.description.class == ObjectClass::Form {
return Some(current.0);
}
current = self.parent_key(current)?;
}
None
}
@@ -872,7 +936,10 @@ impl FormsModel {
if let Some(old) = self.active_control {
self.queue_named(old, "LOSTFOCUS", vec![]);
}
self.active_form = self.root_form(key);
self.activate_form(self.root_form(key));
if self.active_control != Some(key) {
self.dropdown = None;
}
self.active_control = Some(key);
self.queue_named(key, "GOTFOCUS", vec![]);
self.dirty = true;
@@ -949,7 +1016,7 @@ impl FormsModel {
}
fn select_option(&mut self, key: ObjectKey) -> Result<(), RuntimeError> {
let parent = self.instance(key)?.description.parent;
let parent = self.parent_key(key);
let value_id = forms::property(ObjectClass::OptionButton, "VALUE")
.unwrap()
.0 as usize;
@@ -959,7 +1026,7 @@ impl FormsModel {
}
let same_group = self.instance(other).is_ok_and(|obj| {
obj.description.class == ObjectClass::OptionButton
&& obj.description.parent == parent
&& self.parent_key(other) == parent
});
if same_group {
self.instance_mut(other)?.properties[value_id] = PropertyValue::Integer(0);
@@ -994,10 +1061,8 @@ impl FormsModel {
let title_shortcut = name == "SHORTCUT"
&& matches!(value, PropertyValue::String(text) if !text.is_empty())
&& self
.instance(key)
.ok()
.and_then(|obj| obj.description.parent)
.and_then(|parent| self.objects.get(parent as usize))
.parent_key(key)
.and_then(|parent| self.instance(parent).ok())
.is_some_and(|parent| parent.description.class == ObjectClass::Form);
if invalid || title_shortcut {
Err(RuntimeError::ILLEGAL_FUNCTION_CALL)
@@ -1163,12 +1228,6 @@ impl FormsModel {
}
entries.sort_by_key(|entry| entry.to_uppercase());
self.lists.insert(key, entries);
let event = match class {
ObjectClass::DirListBox => "PATHCHANGE",
ObjectClass::FileListBox if !self.string(key, "PATTERN").is_empty() => "PATTERNCHANGE",
_ => "CHANGE",
};
self.queue_named(key, event, vec![]);
self.dirty = true;
Ok(())
}
@@ -1204,7 +1263,12 @@ impl FormsModel {
let class = self.instance(key)?.description.class;
match name {
"SETFOCUS" => self.focus(key)?,
"REFRESH" => self.dirty = true,
"REFRESH" => {
if matches!(class, ObjectClass::Form | ObjectClass::PictureBox) {
self.queue_named(key, "PAINT", vec![]);
}
self.dirty = true;
}
"ADDITEM" => self.add_item(
key,
args.first().map(Self::text_of).unwrap_or_default(),
@@ -1304,10 +1368,27 @@ impl FormsModel {
}
fn form_controls(&self, form: u16) -> Vec<ObjectKey> {
self.keys()
let mut controls = Vec::new();
let mut seen = std::collections::HashSet::new();
for key in self
.keys()
.into_iter()
.filter(|key| self.root_form(*key) == Some(form) && key.0 != form)
.collect()
{
// Auch ein nachträglich angelegtes Array-Containerobjekt muss unter
// seinen Kindern liegen. Übrige Erzeugungsreihenfolge beibehalten.
let mut ancestors = Vec::new();
let mut current = Some(key);
while let Some(parent) = current.filter(|p| p.0 != form && !seen.contains(p)) {
ancestors.push(parent);
current = self.parent_key(parent);
}
for key in ancestors.into_iter().rev() {
seen.insert(key);
controls.push(key);
}
}
controls
}
fn menu_children(&self, parent: ObjectKey) -> Vec<ObjectKey> {
@@ -1316,7 +1397,7 @@ impl FormsModel {
.filter(|key| {
self.instance(*key).is_ok_and(|object| {
object.description.class == ObjectClass::Menu
&& object.description.parent == Some(parent.0)
&& self.parent_key(*key) == Some(parent)
}) && self.boolean(*key, "VISIBLE")
})
.collect()
@@ -1370,7 +1451,7 @@ impl FormsModel {
&& self.boolean(*candidate, "VISIBLE")
&& self.instance(*candidate).is_ok_and(|obj| {
obj.description.class != ObjectClass::Menu
|| obj.description.parent == Some(form)
|| self.parent_key(*candidate) == Some((form, None))
})
}) {
if self
@@ -1410,7 +1491,29 @@ impl FormsModel {
PropertyValue::Integer(shift as i32),
],
);
let handled = self.control_key(active, key);
let dropdown_key = self
.instance(active)
.is_ok_and(|o| o.description.class == ObjectClass::ComboBox)
&& shift & umschalt::ALT != 0
&& key == taste::sonder(80);
let handled = if dropdown_key {
self.dropdown = if self.dropdown == Some(active) {
None
} else {
Some(active)
};
if self.dropdown.is_some() {
self.queue_named(active, "DROPDOWN", vec![]);
}
self.dirty = true;
true
} else if self.dropdown == Some(active) && matches!(key, taste::ESC | taste::ENTER) {
self.dropdown = None;
self.dirty = true;
true
} else {
self.control_key(active, key)
};
if key.chars().count() == 1 {
self.queue_named(active, "KEYPRESS", vec![PropertyValue::Integer(code)]);
}
@@ -1453,6 +1556,17 @@ impl FormsModel {
PropertyValue::Integer(shift as i32),
],
);
if key.chars().count() == 1 {
self.queue_named((form, None), "KEYPRESS", vec![PropertyValue::Integer(code)]);
}
self.queue_named(
(form, None),
"KEYUP",
vec![
PropertyValue::Integer(code),
PropertyValue::Integer(shift as i32),
],
);
true
}
@@ -1546,19 +1660,18 @@ impl FormsModel {
}
fn rect(&self, key: ObjectKey) -> Option<(usize, usize, usize, usize)> {
let obj = self.instance(key).ok()?;
self.instance(key).ok()?;
let mut left = self.integer(key, "LEFT").unwrap_or(0).max(0) as usize + 1;
let mut top = self.integer(key, "TOP").unwrap_or(0).max(0) as usize + 1;
let width = self.integer(key, "WIDTH").unwrap_or(1).max(1) as usize;
let height = self.integer(key, "HEIGHT").unwrap_or(1).max(1) as usize;
let mut parent = obj.description.parent;
for _ in 0..self.objects.len() {
let Some(id) = parent else { break };
let parent_obj = self.objects.get(id as usize)?;
let parent_key = (id, None);
let mut parent = self.parent_key(key);
for _ in 0..self.objects.len() + self.dynamic.len() {
let Some(parent_key) = parent else { break };
self.instance(parent_key).ok()?;
left += self.integer(parent_key, "LEFT").unwrap_or(0).max(0) as usize;
top += self.integer(parent_key, "TOP").unwrap_or(0).max(0) as usize;
parent = parent_obj.description.parent;
parent = self.parent_key(parent_key);
}
Some((left, top, width, height))
}
@@ -2192,7 +2305,7 @@ impl FormsModel {
format!("{text}")
};
let mut lines = vec![Self::fit(&first, width)];
if class == ObjectClass::ComboBox && style == 1 {
if class == ObjectClass::ComboBox && (style == 1 || self.dropdown == Some(key)) {
lines.extend(self.list_lines(key, width, height.saturating_sub(1)));
}
lines.truncate(height);
@@ -2317,12 +2430,7 @@ impl FormsModel {
let Ok(class) = self.instance(key).map(|obj| obj.description.class) else {
continue;
};
if class == ObjectClass::Menu
&& self
.instance(key)
.ok()
.is_some_and(|obj| obj.description.parent == Some(form))
{
if class == ObjectClass::Menu && self.parent_key(key) == Some((form, None)) {
if self.boolean(key, "VISIBLE") {
let caption = format!(" {} ", self.caption(key));
let focused = self.menu_path.first() == Some(&key);
@@ -2381,7 +2489,7 @@ impl FormsModel {
for root in self.form_controls(form).into_iter().filter(|key| {
self.instance(*key).is_ok_and(|object| {
object.description.class == ObjectClass::Menu
&& object.description.parent == Some(form)
&& self.parent_key(*key) == Some((form, None))
}) && self.boolean(*key, "VISIBLE")
}) {
if Some(&root) == self.menu_path.first() {
@@ -2749,6 +2857,90 @@ mod tests {
FormsModel::new(c.objects, 80, 25)
}
#[test]
fn combobox_dropdown_closes_on_focus_change_and_hide() {
let mut catalog = forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("Combo", ObjectClass::ComboBox, Some("Form1"), false);
catalog.add("Text", ObjectClass::TextBox, Some("Form1"), false);
let mut m = FormsModel::new(catalog.objects, 80, 25);
m.show(0, false).unwrap();
for hide in [false, true] {
m.focus((1, None)).unwrap();
assert!(m.handle_key(&taste::sonder(80), umschalt::ALT));
assert_eq!(m.dropdown, Some((1, None)));
if hide {
m.hide(0).unwrap();
} else {
m.focus((2, None)).unwrap();
}
assert_eq!(m.dropdown, None);
}
}
#[test]
fn indexed_containers_preserve_parent_geometry_and_option_groups() {
let form = crate::frm::read_text(
"parent.frm",
r#"VERSION 1.00
Begin Form Form1
Width = 80
Height = 25
Begin Frame Group
Index = 0
Left = 2
Begin OptionButton Choice
Index = 0
End
End
Begin Frame Group
Index = 1
Width = 25
Height = 12
Left = 30
Top = 4
Begin OptionButton Choice
Index = 1
Left = 2
Top = 2
End
Begin OptionButton Other
Caption = "Second"
Width = 12
Left = 2
Top = 4
End
End
End
"#,
)
.unwrap();
let catalog = form.catalog();
let id = |name| catalog.find(name).unwrap().0;
let (root, group, choice, other) = (id("Form1"), id("Group"), id("Choice"), id("Other"));
let mut m = FormsModel::new(catalog.objects, 80, 25);
form.apply(&mut m).unwrap();
m.show(root, false).unwrap();
assert_eq!(m.parent_key((choice, Some(1))), Some((group, Some(1))));
assert_eq!(m.root_form((choice, Some(1))), Some(root));
assert_eq!(
m.rect((choice, Some(1))).map(|(x, y, _, _)| (x, y)),
Some((33, 7))
);
assert_eq!(m.hit_test(root, 7, 33), (choice, Some(1)));
m.activate((choice, None)).unwrap();
m.activate((choice, Some(1))).unwrap();
assert_eq!(m.integer((choice, None), "VALUE"), Some(-1));
assert_eq!(m.integer((choice, Some(1)), "VALUE"), Some(-1));
m.activate((other, None)).unwrap();
assert_eq!(m.integer((choice, None), "VALUE"), Some(-1));
assert_eq!(m.integer((choice, Some(1)), "VALUE"), Some(0));
assert_eq!(m.hit_test(root, 9, 33), (other, None));
let mut screen = TextScreen::new();
m.render(&mut screen);
assert!(tb_runtime::snapshot::text(&screen).contains("Second"));
}
#[test]
fn defaults_bereich_und_implizites_laden() {
let mut m = model();
@@ -3633,7 +3825,8 @@ mod tests {
m.unload_array(2, 1).unwrap();
assert!(!m.events.iter().any(|e| e.array_index == Some(1)));
m.hide(0).unwrap();
assert!(m.events.is_empty());
assert_eq!(m.events.len(), 1);
assert_eq!(m.events.pop_front().unwrap().name, "LOSTFOCUS");
m.show(0, false).unwrap();
m.sync_timers(|| 2000);
assert_eq!(m.next_deadline(), Some(2100));

View File

@@ -84,8 +84,9 @@ impl FormFile {
) -> Result<Vec<FormInitial>, tb_runtime::errors::RuntimeError> {
fn collect(
node: &FormNode,
parent: Option<u16>,
parent: Option<(u16, Option<i32>)>,
catalog: &FormCatalog,
form: &str,
out: &mut Vec<FormInitial>,
depth: usize,
) -> Result<(), tb_runtime::errors::RuntimeError> {
@@ -98,7 +99,8 @@ impl FormFile {
.iter()
.position(|o| {
o.name.eq_ignore_ascii_case(&node.name)
&& o.parent == parent
&& ((o.array && catalog.belongs_to(o, form))
|| o.parent == parent.map(|p| p.0))
&& o.class == node.class
})
.ok_or(tb_runtime::errors::RuntimeError(420))? as u16;
@@ -115,18 +117,29 @@ impl FormFile {
if out.iter().any(|v| v.object == object && v.index == index) {
return Err(tb_runtime::errors::RuntimeError(5));
}
let mut properties = node.properties.clone();
if let Some((id, _)) = forms::property(node.class, "PARENT") {
properties.insert(id, PropertyValue::Object(parent));
}
out.push(FormInitial {
object,
index,
properties: node.properties.clone(),
properties,
});
for child in &node.children {
collect(child, Some(object), catalog, out, depth)?;
collect(
child,
Some((object, (index != 0).then_some(index))),
catalog,
form,
out,
depth,
)?;
}
Ok(())
}
let mut values = Vec::new();
collect(&self.root, None, catalog, &mut values, 0)?;
collect(&self.root, None, catalog, &self.root.name, &mut values, 0)?;
Ok(values)
}
@@ -941,7 +954,7 @@ fn decode_object(symbol: &BinarySymbol, record: &BinaryRecord, bytes: &[u8]) ->
set_property(&mut node, name, PropertyValue::Integer(value as i32));
}
}
if let Some(value) = record_u16(bytes, start, end, 4) {
if let Some(value) = record_u16(bytes, start, end, 4).filter(|_| symbol.is_array) {
set_property(
&mut node,
"INDEX",
@@ -1314,7 +1327,7 @@ fn write_node(node: &FormNode, depth: usize, out: &mut String) {
let Some(value) = node.properties.get(&(id as u16)) else {
continue;
};
if *value == default_value(spec) {
if spec.name != "INDEX" && *value == default_value(spec) {
continue;
}
out.push_str(&" ".repeat(depth + 1));
@@ -1437,6 +1450,54 @@ mod tests {
const EXAMPLE: &str = "VERSION 1.00\nBegin Form Form1\n Caption = \"Beispiel\"\n Height = 15\n Begin CommandButton cmdOK\n Caption = \"&OK\"\n End\nEnd\n\nSUB cmdOK_Click ()\n UNLOAD Form1\nEND SUB\n";
#[test]
fn explicit_default_is_preserved_until_canonical_write() {
let original = "VERSION 1.00\r\nBegin Form Form1\r\n Begin CommandButton Ok\r\n Enabled = -1\r\n End\r\nEnd\r\n";
let mut form = read_text("default.frm", original).unwrap();
assert_eq!(write_text(&form), original);
form.root.name = "Changed".into();
let canonical = write_text(&form);
assert_eq!(
canonical,
"VERSION 1.00\nBegin Form Changed\n Begin CommandButton Ok\n End\nEnd\n"
);
assert_eq!(
write_text(&read_text("canonical.frm", &canonical).unwrap()),
canonical
);
}
#[test]
fn canonical_index_zero_remains_an_array_and_array_parents_are_preserved() {
let mut form=read_text("array.frm", "VERSION 1.00\nBegin Form Form1\n Begin Frame A\n Begin Label Item\n Index = 0\n End\n End\n Begin Frame B\n Begin Label Item\n Index = 1\n End\n End\nEnd\n").unwrap();
form.code = "END\n".into();
let text = write_text(&form);
assert!(text.contains("Index = 0"));
let roundtrip = read_text("roundtrip.frm", &text).unwrap();
let catalog = roundtrip.catalog();
let item = catalog.find("Item").unwrap().0;
assert!(catalog.objects[item as usize].array);
let a = catalog.find("A").unwrap().0;
let b = catalog.find("B").unwrap().0;
let mut model = FormsModel::new(catalog.objects, 80, 25);
roundtrip.apply(&mut model).unwrap();
let parent = forms::property(ObjectClass::Label, "PARENT").unwrap().0;
assert_eq!(
model.get_at(item, Some(0), parent).unwrap(),
PropertyValue::Object(Some((a, None)))
);
assert_eq!(
model.get_at(item, Some(1), parent).unwrap(),
PropertyValue::Object(Some((b, None)))
);
form.root.children.pop();
let single = read_text("single.frm", &write_text(&form)).unwrap();
assert!(
single.catalog().objects[item as usize].array,
"Einzelelement mit Index 0 bleibt Array"
);
}
#[test]
fn reads_nested_form_and_preserves_source_exactly() {
let form = read_text("test.frm", EXAMPLE).unwrap();

View File

@@ -2365,7 +2365,15 @@ impl<'a> Decoder<'a> {
continue;
}
if matches!(kind, Some(1 | 2)) {
let line = self.pending[..marker].trim_end().to_owned();
// Binär-P-Code enthält den Include-Inhalt bereits. Die
// Herkunft bleibt Kommentar, keine erneut aktive Direktive.
let line = self.pending[..marker].trim_end();
let directive = line.trim_start().trim_start_matches('\'').trim_start();
let line = if directive.to_ascii_uppercase().starts_with("$INCLUDE:") {
format!("' Expanded INCLUDE:{}", &directive[9..])
} else {
line.to_owned()
};
self.lines.push(line);
self.pending.drain(..marker + 2);
continue;
@@ -2604,6 +2612,21 @@ mod tests {
(table, offsets)
}
#[test]
fn embedded_include_is_a_comment_but_literals_remain_unchanged() {
let mut decoder = Decoder::new("test.frm", &[], &[], 0);
decoder.pending=" '$INCLUDE: 'embedded.bi'\r\u{1}DECLARE SUB Test()\r\u{2}PRINT \"$INCLUDE: 'literal.bi'\"\r\u{1}".into();
decoder.postprocess(true);
assert_eq!(
decoder.lines,
vec![
"' Expanded INCLUDE: 'embedded.bi'",
"DECLARE SUB Test()",
"PRINT \"$INCLUDE: 'literal.bi'\""
]
);
}
#[test]
fn decodes_vbdos_declarations_without_losing_type_or_bounds() {
let (sym, ids) = symbols(&["PrepVal", "StringToPrep", "Oldcontents"]);

View File

@@ -632,6 +632,37 @@ impl CompiledModule {
}
}
}
// PARENT overrides carry design-array indices in the existing TBC4 property format.
let mut parents: std::collections::HashMap<_, _> = self
.objects
.iter()
.enumerate()
.map(|(id, object)| ((id as u16, 0), object.parent.map(|id| (id, 0))))
.collect();
for initial in &self.form_initial {
let object = &self.objects[initial.object as usize];
let parent = tb_frontend::forms::property(object.class, "PARENT")
.and_then(|(id, _)| initial.properties.get(&id));
parents.insert(
(initial.object, initial.index),
match parent {
Some(PropertyValue::Object(parent)) => {
parent.map(|(id, index)| (id, index.unwrap_or(0)))
}
_ => parents[&(initial.object, 0)],
},
);
}
for key in parents.keys() {
let mut seen = std::collections::HashSet::new();
let mut current = Some(*key);
while let Some(key) = current {
if !seen.insert(key) {
return Err(bad());
}
current = *parents.get(&key).ok_or_else(bad)?;
}
}
for p in &self.procs {
if p.module as usize >= self.modules.len()
|| p.n_params as usize != p.params.len()

View File

@@ -546,7 +546,19 @@ impl Vm {
}
fn dispatch_form_event(&mut self, event: FormEvent, on_return: FormEventReturn) -> bool {
if self
let Some(binding) = self
.module
.event_procs
.iter()
.find(|e| e.object == event.object && e.event.eq_ignore_ascii_case(&event.name))
.cloned()
else {
return false;
};
if matches!(
event.name.as_str(),
"CLICK" | "DBLCLICK" | "GOTFOCUS" | "KEYDOWN" | "KEYPRESS" | "KEYUP" | "MOUSEDOWN"
) && self
.module
.objects
.get(event.object as usize)
@@ -561,15 +573,6 @@ impl Vm {
.forms
.set_active_control(event.object, event.array_index);
}
let Some(binding) = self
.module
.event_procs
.iter()
.find(|e| e.object == event.object && e.event.eq_ignore_ascii_case(&event.name))
.cloned()
else {
return false;
};
if let Some(index) = event.array_index {
self.push(Value::Int(index as i16));
}

View File

@@ -626,3 +626,284 @@ fn intervallaenderung_im_handler_verwirft_bereits_faellige_altintervalle() {
assert_eq!(integer(&vm, "n"), 2);
assert_eq!(vm.forms.next_deadline(), None);
}
#[test]
fn formular_tastatur_fallback_fuehrt_alle_drei_basic_handler_aus() {
let mut catalog = FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
for (input, expected) in [("a", "DPU"), ("\0;", "DU")] {
let src = "DIM SHARED s$\nForm1.Show\nSTOP\nDOEVENTS\nEND\nSUB Form_KeyDown(KeyCode AS INTEGER, Shift AS INTEGER)\nSHARED s$\ns$ = s$ + \"D\"\nEND SUB\nSUB Form_KeyPress(KeyAscii AS INTEGER)\nSHARED s$\ns$ = s$ + \"P\"\nEND SUB\nSUB Form_KeyUp(KeyCode AS INTEGER, Shift AS INTEGER)\nSHARED s$\ns$ = s$ + \"U\"\nEND SUB";
let mut vm = Vm::new(tb_vm::compile_source_with_forms("FORM1", src, &catalog).unwrap());
let mut host = CaptureHost::default();
stop(&mut vm, &mut host);
host.ereignis(key(input));
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert_eq!(
string(&vm, "s"),
expected,
"FORM_KEYDOWN/KEYPRESS/KEYUP: Status implementiert, Hosttaste {input:?}"
);
}
}
#[test]
fn registrierte_form_ereignisse_erreichen_basic_aus_realen_quellen() {
use tb_frontend::forms::{self, EventParamType};
for name in forms::events(ObjectClass::Form) {
let mut catalog = FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("Source", ObjectClass::TextBox, Some("Form1"), false);
let args = forms::event_params(name)
.unwrap()
.iter()
.map(|(n, t)| {
format!(
"{n} AS {}",
match t {
EventParamType::Integer => "INTEGER",
EventParamType::Single => "SINGLE",
EventParamType::Control => "CONTROL",
}
)
})
.collect::<Vec<_>>()
.join(", ");
let operation = match *name {
"LOAD" | "GOTFOCUS" => "Form1.Show",
"LOSTFOCUS" => "Form1.Hide",
"UNLOAD" => "Form1.Unload",
"PAINT" => "Form1.Refresh",
"RESIZE" => "Form1.Width = 70",
"DRAGOVER" | "DRAGDROP" => "Source.Drag 1",
"KEYDOWN" | "KEYPRESS" | "KEYUP" | "MOUSEDOWN" | "MOUSEMOVE" | "MOUSEUP" | "CLICK"
| "DBLCLICK" => "",
other => panic!("FORM_{other}: Status implementiert, Auslöser fehlt"),
};
let setup = if matches!(*name, "LOAD" | "GOTFOCUS") {
""
} else {
"Form1.Show"
};
let src = format!("DIM SHARED n%\nSource.Left=2\nSource.Top=2\n{setup}\nn%=0\nSTOP\n{operation}\nDOEVENTS\nEND\nSUB Form_{name}({args})\nSHARED n%\nn%=n%+1\nEND SUB");
let mut vm = Vm::new(
tb_vm::compile_source_with_forms("FORM1", &src, &catalog)
.unwrap_or_else(|d| panic!("{name}: {d:?}")),
);
let mut host = CaptureHost::default();
stop(&mut vm, &mut host);
if name.starts_with("KEY") {
host.ereignis(key("a"));
}
let events: &[MausArt] = match *name {
"MOUSEDOWN" => &[MausArt::Druck],
"MOUSEMOVE" | "DRAGOVER" => &[MausArt::Bewegung],
"MOUSEUP" | "DRAGDROP" => &[MausArt::Bewegung, MausArt::Loslassen],
"CLICK" => &[MausArt::Druck, MausArt::Loslassen],
"DBLCLICK" => &[
MausArt::Druck,
MausArt::Loslassen,
MausArt::Druck,
MausArt::Loslassen,
],
_ => &[],
};
for art in events {
host.ereignis(Ereignis::Maus(MausEreignis {
art: *art,
taste: 1,
shift: 0,
zeile: 1,
spalte: 1,
}));
}
assert_eq!(vm.run(&mut host), RunEvent::Ended, "FORM_{name}");
event_nachgewiesen(&format!("FORM_{name}"), integer(&vm, "n")).unwrap();
}
}
#[test]
fn registrierte_control_ereignisse_erreichen_basic_aus_realen_quellen() {
use tb_frontend::forms::{self, EventParamType};
let mut failures = Vec::new();
for class in ObjectClass::ALL
.iter()
.copied()
.filter(|c| *c != ObjectClass::Form)
{
for name in forms::events(class) {
let mut catalog = FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
if class == ObjectClass::Menu {
catalog.add("Bar", ObjectClass::Menu, Some("Form1"), false);
}
catalog.add(
"Probe",
class,
Some(if class == ObjectClass::Menu {
"Bar"
} else {
"Form1"
}),
false,
);
catalog.add("Source", ObjectClass::TextBox, Some("Form1"), false);
let args = forms::event_params(name)
.unwrap()
.iter()
.map(|(n, t)| {
format!(
"{n} AS {}",
match t {
EventParamType::Integer => "INTEGER",
EventParamType::Single => "SINGLE",
EventParamType::Control => "CONTROL",
}
)
})
.collect::<Vec<_>>()
.join(", ");
let mut setup = String::from(
"Form1.Width=60\nForm1.Height=15\nForm1.Show\nSource.Left=40\nSource.Top=2\n",
);
if !matches!(class, ObjectClass::Timer | ObjectClass::Menu) {
setup.push_str("Probe.Left=10\nProbe.Top=4\n");
}
let operation = match *name {
"GOTFOCUS" => String::new(),
"LOSTFOCUS" => "Source.SetFocus".into(),
"PAINT" => "Probe.Refresh".into(),
"DRAGOVER" | "DRAGDROP" => "Source.Drag 1".into(),
"CHANGE" => match class {
ObjectClass::Label => "Probe.Caption=\"changed\"".into(),
ObjectClass::HScrollBar | ObjectClass::VScrollBar => "Probe.Value=1".into(),
ObjectClass::DirListBox => {
format!("Probe.Path=\"{}\"", std::env::temp_dir().display())
}
ObjectClass::DriveListBox => "Probe.Drive=\"/\"".into(),
_ => "Probe.Text=\"changed\"".into(),
},
"PATHCHANGE" => format!("Probe.Path=\"{}\"", std::env::temp_dir().display()),
"PATTERNCHANGE" => "Probe.Pattern=\"*.bas\"".into(),
"TIMER" => "Probe.Interval=1\nProbe.Enabled=-1".into(),
"CLICK" if class == ObjectClass::Menu => {
setup.push_str("Probe.Caption=\"&Go\"\nProbe.Shortcut=\"F3\"\n");
String::new()
}
"CLICK" | "DBLCLICK" | "MOUSEDOWN" | "MOUSEMOVE" | "MOUSEUP" | "KEYDOWN"
| "KEYPRESS" | "KEYUP" | "DROPDOWN" | "CUSTOM" => String::new(),
other => panic!(
"{}_{other}: Status implementiert, Auslöser fehlt",
class.name()
),
};
let src=format!("DIM SHARED n%\n{setup}\nn%=0\nSTOP\n{operation}\nSTOP\nDOEVENTS\nEND\nSUB Probe_{name}({args})\nSHARED n%\nn%=n%+1\nEND SUB");
let mut vm = Vm::new(
tb_vm::compile_source_with_forms("FORM1", &src, &catalog)
.unwrap_or_else(|d| panic!("{}_{name}: {d:?}", class.name())),
);
let mut host = CaptureHost::default();
let initial = vm.run(&mut host);
assert!(
matches!(initial, RunEvent::Stopped { .. }),
"{}_{name}: {initial:?}",
class.name()
);
if *name == "LOSTFOCUS" {
vm.forms.focus((1, None)).unwrap();
}
let second = vm.run(&mut host);
assert!(
matches!(second, RunEvent::Stopped { .. }),
"{}_{name}: {second:?}",
class.name()
);
if name.starts_with("KEY") || matches!(*name, "DROPDOWN" | "CUSTOM") {
vm.forms.focus((1, None)).unwrap();
}
if *name == "TIMER" {
host.uhr_vorruecken(2);
}
if name.starts_with("KEY") {
host.ereignis(key("a"));
}
if *name == "DROPDOWN" {
host.ereignis(Ereignis::Taste("\0P".into(), 4));
}
if *name == "CUSTOM" {
host.ereignis(key("\0H"));
}
if class == ObjectClass::Menu {
host.ereignis(key("\0="));
}
let events: &[MausArt] = match *name {
"MOUSEDOWN" | "GOTFOCUS" => &[MausArt::Druck],
"MOUSEMOVE" | "DRAGOVER" => &[MausArt::Bewegung],
"MOUSEUP" | "DRAGDROP" => &[MausArt::Bewegung, MausArt::Loslassen],
"CLICK" if class != ObjectClass::Menu => &[MausArt::Druck, MausArt::Loslassen],
"DBLCLICK" => &[
MausArt::Druck,
MausArt::Loslassen,
MausArt::Druck,
MausArt::Loslassen,
],
_ => &[],
};
for art in events {
host.ereignis(Ereignis::Maus(MausEreignis {
art: *art,
taste: 1,
shift: 0,
zeile: 5,
spalte: 11,
}));
}
let result = vm.run(&mut host);
if result != RunEvent::Ended || integer(&vm, "n") == 0 {
failures.push(format!(
"{}_{name}: Status implementiert, Handler unerreicht: {result:?}",
class.name()
));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn shell_und_irr_funktionssignaturen_erreichen_die_runtime() {
let mut vm = vm_for_source(
"DIM a(1) AS DOUBLE\na(0)=-100\na(1)=110\nr#=IRR#(a,.1)\ns&=SHELL(\"exit 7\")\nEND",
);
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
assert!(matches!(vm.inspect("r"),Some(Value::Dbl(n)) if (n-0.1).abs()<1e-8));
assert!(matches!(vm.inspect("s"), Some(Value::Lng(7))));
}
fn event_nachgewiesen(name: &str, count: i16) -> Result<(), String> {
if count > 0 {
Ok(())
} else {
Err(format!(
"{name}: Status implementiert, fehlender Auslösepfad zum BASIC-Handler"
))
}
}
#[test]
fn fehlender_ereignispfad_wird_namentlich_erkannt() {
let mut catalog = FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
let mut module=tb_vm::compile_source_with_forms("FORM1","DIM SHARED n%\nForm1.Show\nSTOP\nDOEVENTS\nEND\nSUB Form_KeyPress(KeyAscii AS INTEGER)\nSHARED n%\nn%=n%+1\nEND SUB",&catalog).unwrap();
module.event_procs.clear(); // künstlich fehlende Dispatch-Bindung
let mut vm = Vm::new(module);
let mut host = CaptureHost::default();
stop(&mut vm, &mut host);
host.ereignis(key("a"));
assert_eq!(vm.run(&mut host), RunEvent::Ended);
let error = event_nachgewiesen("FORM_KEYPRESS", integer(&vm, "n")).unwrap_err();
assert!(
error.contains("FORM_KEYPRESS")
&& error.contains("implementiert")
&& error.contains("Auslösepfad")
);
}

View File

@@ -249,7 +249,7 @@ fn beschaedigte_container_werden_vor_der_ausfuehrung_abgewiesen() {
let mut duplicate = bytes.clone();
duplicate[24..28].copy_from_slice(b"MODN");
assert!(CompiledModule::from_tbc(&duplicate).is_err());
for mutation in 0..8 {
for mutation in 0..10 {
let mut bad = CompiledModule::from_tbc(&bytes).unwrap();
match mutation {
0 => bad.sources[0].module = u16::MAX,
@@ -265,6 +265,23 @@ fn beschaedigte_container_werden_vor_der_ausfuehrung_abgewiesen() {
);
}
7 => bad.procs[0].code.push(Instr::Source(u32::MAX, 1)),
8 | 9 => {
let initial = &mut bad.form_initial[1];
let parent = tb_frontend::forms::property(
tb_frontend::forms::ObjectClass::TextBox,
"PARENT",
)
.unwrap()
.0;
initial.properties.insert(
parent,
tb_ui::forms::PropertyValue::Object(Some(if mutation == 8 {
(initial.object, Some(initial.index))
} else {
(0, Some(99))
})),
);
}
_ => unreachable!(),
}
assert!(