Spezifikationsabgleich und Regressionsnachweise abschließen
This commit is contained in:
188
crates/tb-cli/tests/foreign.rs
Normal file
188
crates/tb-cli/tests/foreign.rs
Normal 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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user