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

@@ -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"]);