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) -> Expectation {
let lower = name.to_lowercase();
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;
}
if lower == "refresh" || lower == "re-check" || lower == "recheck" {
return Expectation::Inert;
}
if lower.starts_with("extra thinking") {
return Expectation::Inert;
}
Expectation::Changes
}
pub fn cases(
nodes: &[SemanticNode],
family: Option<&str>,
family_of: impl Fn(&str) -> &'static str,
) -> 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),
}
})
.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| node.role == "button" && node.name.to_lowercase() == wanted)
}
#[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() {
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) {
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| node.role == "button")
.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();
lower == "cancel" || lower.ends_with("cancel") || lower.contains("delete?")
})
.count()
};
cancels(after) > cancels(before)
}
fn tree_fingerprint(nodes: &[SemanticNode]) -> Vec<(String, String, bool, bool)> {
nodes
.iter()
.map(|node| {
(
node.role.clone(),
node.name.clone(),
node.enabled,
node.visible,
)
})
.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]),
}
}
#[test]
fn a_delete_that_asks_first_has_done_its_job() {
let case = Case {
id: 1,
name: "Delete e".to_owned(),
family: "delete",
expect: expectation_for("Delete e"),
};
let before = vec![button("Delete e"), button("Rename e")];
let after = vec![
button("Delete e"),
button("Rename e"),
button("Cancel"),
];
assert_eq!(judge(&case, &before, &after), None);
}
#[test]
fn a_control_that_only_reveals_something_counts_as_acting() {
let case = Case {
id: 1,
name: "Rename e".to_owned(),
family: "edit",
expect: expectation_for("Rename e"),
};
let mut field = button("Project name");
field.role = "textbox".to_owned();
field.visible = false;
let before = vec![button("Rename e"), field.clone()];
let mut revealed = field;
revealed.visible = true;
let after = vec![button("Rename e"), revealed];
assert_eq!(judge(&case, &before, &after), None);
assert!(judge(&case, &before, &before).is_some());
}
#[test]
fn a_delete_that_does_nothing_at_all_still_fails() {
let case = Case {
id: 1,
name: "Delete e".to_owned(),
family: "delete",
expect: expectation_for("Delete e"),
};
let before = vec![button("Delete e"), button("Rename e")];
assert!(judge(&case, &before, &before).is_some());
}
#[test]
fn a_refetch_that_finds_nothing_new_is_not_a_dead_button() {
for name in ["Refresh", "Re-check"] {
let case = Case {
id: 1,
name: name.to_owned(),
family: "other",
expect: expectation_for(name),
};
let tree = vec![button(name), button("Default agent")];
assert!(
judge(&case, &tree, &tree).is_none(),
"{name} should be allowed to leave the tree alone"
);
}
}
#[test]
fn a_toggle_that_only_moves_a_colour_is_not_a_dead_button() {
let name = "Extra Thinking: let the model reason before it answers.";
let case = Case {
id: 1,
name: name.to_owned(),
family: "other",
expect: expectation_for(name),
};
let tree = vec![button(name), button("Model")];
assert!(judge(&case, &tree, &tree).is_none());
}
#[test]
fn a_refresh_that_wrecks_the_document_still_fails() {
let case = Case {
id: 1,
name: "Refresh".to_owned(),
family: "other",
expect: expectation_for("Refresh"),
};
let before = vec![button("Refresh"), button("Default agent")];
let after = vec![button("Refresh")];
assert!(judge(&case, &before, &after).is_some());
}
}