use blitz_control_protocol::SemanticNode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expectation {
Toggles { into: String },
Removes { subject: String },
Adds,
Changes,
Inert,
}
#[derive(Debug, Clone)]
pub struct Case {
pub id: u64,
pub name: String,
pub family: &'static str,
pub expect: Expectation,
}
pub fn expectation_for(name: &str, explicitly_inert: bool) -> Expectation {
let lower = name.to_lowercase();
if explicitly_inert {
return Expectation::Inert;
}
for (from, to) in [("collapse ", "Expand "), ("expand ", "Collapse ")] {
if lower.starts_with(from) {
let subject = &name[from.len()..];
return Expectation::Toggles {
into: format!("{to}{subject}"),
};
}
}
if lower.starts_with("hide ") {
return Expectation::Changes;
}
for prefix in ["delete ", "close ", "retire ", "remove "] {
if let Some(subject) = lower.strip_prefix(prefix) {
return Expectation::Removes {
subject: subject.to_owned(),
};
}
}
if lower.starts_with("add ") || lower.starts_with("new ") || lower.contains("create") {
return Expectation::Adds;
}
if lower.starts_with("copy") {
return Expectation::Inert;
}
Expectation::Changes
}
pub fn cases(
nodes: &[SemanticNode],
family: Option<&str>,
family_of: impl Fn(&str) -> &'static str,
is_inert: impl Fn(&str) -> bool,
) -> Vec<Case> {
nodes
.iter()
.filter(|node| node.role == "button" && node.visible && node.enabled)
.filter(|node| !node.name.trim().is_empty())
.filter(|node| node.bounds.is_some_and(|b| b[2] > 0.0 && b[3] > 0.0))
.map(|node| {
let family_name = family_of(&node.name);
Case {
id: node.id,
name: node.name.clone(),
family: family_name,
expect: expectation_for(&node.name, is_inert(&node.name)),
}
})
.filter(|case| family.is_none_or(|want| case.family == want))
.collect()
}
pub fn has_button(nodes: &[SemanticNode], name: &str) -> bool {
let wanted = name.to_lowercase();
nodes
.iter()
.any(|node| painted_button(node) && node.name.to_lowercase() == wanted)
}
fn painted_button(node: &SemanticNode) -> bool {
node.role == "button"
&& node.visible
&& node
.bounds
.is_some_and(|bounds| bounds[2] > 0.0 && bounds[3] > 0.0)
}
#[derive(Debug, Clone)]
pub struct Outcome {
pub case: Case,
pub failure: Option<String>,
}
pub fn judge(case: &Case, before: &[SemanticNode], after: &[SemanticNode]) -> Option<String> {
match &case.expect {
Expectation::Toggles { into } => {
if has_button(after, into) {
None
} else if has_button(after, &case.name) {
Some(format!("still reads {:?}; it did not toggle", case.name))
} else {
Some(format!(
"neither {:?} nor {into:?} is present now",
case.name
))
}
}
Expectation::Removes { subject } => {
if confirmation_appeared(before, after) {
return None;
}
if has_button(after, &case.name) {
Some(format!("{:?} is still there; nothing was removed", subject))
} else {
None
}
}
Expectation::Adds => {
if after.len() > before.len() || !has_button(after, &case.name) {
None
} else {
Some(format!(
"the tree did not grow: {} nodes before, {} after",
before.len(),
after.len()
))
}
}
Expectation::Changes => {
if tree_fingerprint(before) == tree_fingerprint(after) {
let selected_choice = before
.iter()
.find(|node| node.id == case.id)
.filter(|node| node.selected)
.is_some_and(|selected| {
before.iter().any(|sibling| {
sibling.id != selected.id
&& sibling.parent == selected.parent
&& sibling.role == selected.role
&& sibling.enabled
&& !sibling.selected
})
});
if selected_choice {
None
} else {
Some("nothing in the tree changed".to_owned())
}
} else {
None
}
}
Expectation::Inert => {
let controls = |nodes: &[SemanticNode]| {
let mut rows: Vec<(String, bool)> = nodes
.iter()
.filter(|node| {
let name = node.name.to_lowercase();
node.role == "button"
&& !name.starts_with("copy")
&& !name.starts_with("copied")
})
.map(|node| (node.name.clone(), node.enabled))
.collect();
rows.sort();
rows
};
if controls(before) == controls(after) {
None
} else {
Some(
"a control appeared, vanished or changed state; this should only copy"
.to_owned(),
)
}
}
}
}
fn confirmation_appeared(before: &[SemanticNode], after: &[SemanticNode]) -> bool {
let cancels = |nodes: &[SemanticNode]| {
nodes
.iter()
.filter(|node| {
let lower = node.name.to_lowercase();
painted_button(node)
&& (lower == "cancel" || lower.ends_with("cancel") || lower.contains("delete?"))
})
.count()
};
cancels(after) > cancels(before)
}
fn tree_fingerprint(
nodes: &[SemanticNode],
) -> Vec<(String, String, bool, bool, bool, Option<String>)> {
nodes
.iter()
.map(|node| {
(
node.role.clone(),
node.name.clone(),
node.enabled,
node.visible,
node.selected,
node.value.clone(),
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn button(name: &str) -> SemanticNode {
SemanticNode {
id: 0,
parent: None,
role: "button".to_owned(),
name: name.to_owned(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds: Some([0.0, 0.0, 10.0, 10.0]),
slot: None,
}
}
#[test]
fn a_delete_that_asks_first_has_done_its_job() {
let case = Case {
id: 1,
name: "Delete thing".to_owned(),
family: "delete",
expect: expectation_for("Delete thing", false),
};
let before = vec![button("Delete thing"), button("Rename thing")];
let after = vec![
button("Delete thing"),
button("Rename thing"),
button("Cancel"),
];
assert_eq!(judge(&case, &before, &after), None);
}
#[test]
fn a_hidden_retained_row_does_not_make_a_delete_fail() {
let case = Case {
id: 1,
name: "Delete thing".to_owned(),
family: "delete",
expect: expectation_for("Delete thing", false),
};
let before = vec![button("Delete thing")];
let mut retained = button("Delete thing");
retained.visible = false;
let after = vec![retained];
assert_eq!(judge(&case, &before, &after), None);
}
#[test]
fn a_hidden_retained_cancel_is_not_a_confirmation() {
let case = Case {
id: 1,
name: "Delete thing".to_owned(),
family: "delete",
expect: expectation_for("Delete thing", false),
};
let before = vec![button("Delete thing")];
let mut hidden_cancel = button("Cancel");
hidden_cancel.visible = false;
let after = vec![button("Delete thing"), hidden_cancel];
assert!(judge(&case, &before, &after).is_some());
}
#[test]
fn a_control_that_only_reveals_something_counts_as_acting() {
let case = Case {
id: 1,
name: "Rename thing".to_owned(),
family: "edit",
expect: expectation_for("Rename thing", false),
};
let mut field = button("Project name");
field.role = "textbox".to_owned();
field.visible = false;
let before = vec![button("Rename thing"), field.clone()];
let mut revealed = field;
revealed.visible = true;
let after = vec![button("Rename thing"), revealed];
assert_eq!(judge(&case, &before, &after), None);
assert!(judge(&case, &before, &before).is_some());
}
#[test]
fn selection_and_value_changes_count_as_acting() {
let selected_case = Case {
id: 1,
name: "Interface size".to_owned(),
family: "interface",
expect: Expectation::Changes,
};
let before = vec![button("Interface size")];
let mut selected = before.clone();
selected[0].selected = true;
assert_eq!(judge(&selected_case, &before, &selected), None);
let mut valued = before.clone();
valued[0].value = Some("large".to_owned());
assert_eq!(judge(&selected_case, &before, &valued), None);
}
#[test]
fn reselecting_the_current_choice_is_an_expected_noop_but_a_lone_toggle_is_not() {
let case = Case {
id: 1,
name: "Softness 0%".to_owned(),
family: "softness",
expect: Expectation::Changes,
};
let mut selected = button("Softness 0%");
selected.id = 1;
selected.parent = Some(9);
selected.selected = true;
let mut alternative = button("Softness 50%");
alternative.id = 2;
alternative.parent = Some(9);
let choices = vec![selected.clone(), alternative];
assert_eq!(judge(&case, &choices, &choices), None);
let lone = vec![selected];
assert!(judge(&case, &lone, &lone).is_some());
}
#[test]
fn a_delete_that_does_nothing_at_all_still_fails() {
let case = Case {
id: 1,
name: "Delete thing".to_owned(),
family: "delete",
expect: expectation_for("Delete thing", false),
};
let before = vec![button("Delete thing"), button("Rename thing")];
assert!(judge(&case, &before, &before).is_some());
}
#[test]
fn an_explicitly_inert_control_may_leave_the_tree_unchanged() {
for name in ["Synchronize", "Check remote state"] {
let case = Case {
id: 1,
name: name.to_owned(),
family: "other",
expect: expectation_for(name, true),
};
let tree = vec![button(name), button("Neighbour")];
assert!(
judge(&case, &tree, &tree).is_none(),
"{name} should be allowed to leave the tree alone"
);
}
}
#[test]
fn an_explicitly_inert_paint_toggle_is_not_called_dead() {
let name = "Appearance toggle";
let case = Case {
id: 1,
name: name.to_owned(),
family: "other",
expect: expectation_for(name, true),
};
let tree = vec![button(name), button("Neighbour")];
assert!(judge(&case, &tree, &tree).is_none());
}
#[test]
fn copy_feedback_on_the_activated_control_is_not_a_document_change() {
let case = Case {
id: 1,
name: "Copy session id".to_owned(),
family: "copy",
expect: expectation_for("Copy session id", false),
};
let mut copy = button("Copy session id");
copy.id = case.id;
let neighbour = button("Neighbour");
let before = vec![copy.clone(), neighbour.clone()];
copy.id = 3;
copy.name = "Copied session id".to_owned();
let after = vec![copy, neighbour];
assert_eq!(judge(&case, &before, &after), None);
}
#[test]
fn create_can_acknowledge_success_by_removing_its_action() {
let case = Case {
id: 1,
name: "Create recommended folder".to_owned(),
family: "add",
expect: expectation_for("Create recommended folder", false),
};
let before = vec![button("Create recommended folder"), button("Neighbour")];
let after = vec![button("Created"), button("Neighbour")];
assert_eq!(judge(&case, &before, &after), None);
}
#[test]
fn an_inert_control_that_wrecks_the_document_still_fails() {
let case = Case {
id: 1,
name: "Synchronize".to_owned(),
family: "other",
expect: expectation_for("Synchronize", true),
};
let before = vec![button("Synchronize"), button("Neighbour")];
let after = vec![button("Synchronize")];
assert!(judge(&case, &before, &after).is_some());
}
}