use blitz_control_protocol::SemanticNode;
use std::collections::HashMap;
#[derive(Clone, Copy, Debug)]
pub enum Expect {
Paints,
Absent,
Vanishes,
Grows,
PaintsMore,
Holds,
}
pub struct Check {
pub id: &'static str,
pub group: &'static str,
pub what: &'static str,
pub hover: Option<&'static str>,
pub click: Option<&'static str>,
pub press: bool,
pub subject: &'static str,
pub expect: Expect,
pub panel_only: bool,
}
pub fn checks() -> Vec<Check> {
vec![
Check {
id: "icons-paint",
group: "icons",
what: "icon nodes occupy a box on screen",
hover: None,
click: None,
subject: "presentation",
expect: Expect::Paints,
press: false,
panel_only: false,
},
Check {
id: "hover-1",
group: "hover",
what: "hovering an item row reveals its move-up arrow",
hover: Some("Change the status of"),
click: None,
subject: "Move ",
expect: Expect::Paints,
press: false,
panel_only: true,
},
Check {
id: "hover-2",
group: "hover",
what: "hovering an item row reveals its edit control",
hover: Some("Change the status of"),
click: None,
subject: "Edit ",
expect: Expect::Paints,
press: false,
panel_only: true,
},
Check {
id: "hover-3",
group: "hover",
what: "hovering an item row reveals its delete control",
hover: Some("Change the status of"),
click: None,
subject: "Delete ",
expect: Expect::Paints,
press: false,
panel_only: true,
},
Check {
id: "status-1",
group: "status",
what: "clicking the status marker does not remove the row",
hover: Some("Change the status of"),
click: Some("Change the status of"),
subject: "Edit ",
expect: Expect::Holds,
press: false,
panel_only: true,
},
Check {
id: "status-2",
group: "status",
what: "the marker never cycles a row into a terminal state",
hover: Some("Change the status of"),
click: Some("Change the status of"),
subject: "(Finished)",
expect: Expect::Absent,
press: false,
panel_only: true,
},
Check {
id: "sections-1",
group: "sections",
what: "the Items section header is on screen",
hover: None,
click: None,
subject: "Items",
expect: Expect::Paints,
press: false,
panel_only: true,
},
Check {
id: "sections-2",
group: "sections",
what: "the Task log section header is on screen",
hover: None,
click: None,
subject: "Task log",
expect: Expect::Paints,
press: false,
panel_only: true,
},
Check {
id: "sections-3",
group: "sections",
what: "the Agent I/O section header is on screen",
hover: None,
click: None,
subject: "Agent I/O",
expect: Expect::Paints,
press: false,
panel_only: true,
},
Check {
id: "sections-4",
group: "sections",
what: "collapsing a section is acknowledged",
hover: None,
click: Some("Collapse Task log"),
subject: "Expand Task log",
expect: Expect::Paints,
press: false,
panel_only: true,
},
Check {
id: "sections-5",
group: "sections",
what: "expanding it again restores the control",
hover: None,
click: Some("Expand Task log"),
subject: "Collapse Task log",
expect: Expect::Paints,
press: false,
panel_only: true,
},
Check {
id: "tasklog-1",
group: "tasklog",
what: "task log rows render their per-row copy control",
hover: None,
click: None,
subject: "Copy this task-log entry",
expect: Expect::Paints,
press: false,
panel_only: true,
},
Check {
id: "tasklog-2",
group: "tasklog",
what: "revealing earlier entries adds rows",
hover: None,
click: Some("Show 20 earlier"),
subject: "Copy this task-log entry",
expect: Expect::Grows,
press: false,
panel_only: true,
},
Check {
id: "rename-opens-editor",
group: "rename",
what: "pressing the pencil opens an editor the owner can type into",
hover: None,
click: Some("Rename "),
press: true,
subject: "textbox",
expect: Expect::PaintsMore,
panel_only: false,
},
Check {
id: "dialog-opens",
group: "dialog",
what: "the fork dialog opens when its control is pressed",
hover: None,
click: Some("Fork "),
press: true,
subject: "Start fork",
expect: Expect::Paints,
panel_only: false,
},
Check {
id: "dialog-cancel-dismisses",
group: "dialog",
what: "the fork dialog's Cancel actually dismisses it",
hover: None,
click: Some("Cancel"),
press: true,
subject: "Start fork",
expect: Expect::Vanishes,
panel_only: false,
},
]
}
pub const PANEL_LEFT: f64 = 900.0;
fn matching<'a>(nodes: &'a [SemanticNode], want: &str, panel_only: bool) -> Vec<&'a SemanticNode> {
nodes
.iter()
.filter(|node| node.name.contains(want) || node.role.contains(want))
.filter(|node| {
!panel_only
|| node
.bounds
.is_none_or(|b| b[0] >= PANEL_LEFT || b[2] == 0.0)
})
.collect()
}
fn paints(node: &SemanticNode) -> bool {
node.visible && node.bounds.is_some_and(|b| b[2] > 0.0 && b[3] > 0.0)
}
pub fn verdict(
check: &Check,
before: &[SemanticNode],
after: &[SemanticNode],
) -> Result<(), String> {
let found = matching(after, check.subject, check.panel_only);
match check.expect {
Expect::Vanishes => {
let on_screen: Vec<&SemanticNode> =
found.iter().copied().filter(|node| paints(node)).collect();
if let Some(node) = on_screen.first() {
let b = node.bounds.unwrap_or([0.0; 4]);
return Err(format!(
"{:?} is still on screen at {:.0}x{:.0}; it did not close",
check.subject, b[2], b[3]
));
}
}
Expect::Paints => {
if found.is_empty() {
return Err(format!("no node matching {:?} exists", check.subject));
}
if !found.iter().any(|node| paints(node)) {
let hidden = found.iter().filter(|node| !node.visible).count();
let zero = found
.iter()
.filter(|node| {
node.visible && !node.bounds.is_some_and(|b| b[2] > 0.0 && b[3] > 0.0)
})
.count();
let boxes: Vec<String> = found
.iter()
.take(3)
.map(|node| {
let size = node
.bounds
.map(|b| format!("{:.0}x{:.0}", b[2], b[3]))
.unwrap_or_else(|| "no box".into());
format!("{size}{}", if node.visible { "" } else { " hidden" })
})
.collect();
return Err(format!(
"{} node(s) matching {:?} exist but none paints: \
{hidden} hidden, {zero} visible with no area ({})",
found.len(),
check.subject,
boxes.join(", ")
));
}
}
Expect::Absent => {
if !found.is_empty() {
return Err(format!(
"{} node(s) matching {:?} should not exist",
found.len(),
check.subject
));
}
}
Expect::PaintsMore => {
let was = matching(before, check.subject, check.panel_only)
.into_iter()
.filter(|node| paints(node))
.count();
let now = found.iter().filter(|node| paints(node)).count();
if now <= was {
return Err(format!(
"{:?} on screen went {was} -> {now}, expected one more",
check.subject
));
}
}
Expect::Grows => {
let was = matching(before, check.subject, check.panel_only).len();
let now = found.len();
if now <= was {
return Err(format!(
"{:?} went {was} -> {now}, expected more",
check.subject
));
}
}
Expect::Holds => {
let was = matching(before, check.subject, check.panel_only).len();
let now = found.len();
if now != was {
return Err(format!(
"{:?} went {was} -> {now}, expected no change",
check.subject
));
}
}
}
Ok(())
}
pub fn manifest() -> String {
let all = checks();
let mut out = String::new();
let mut current = "";
for check in &all {
if check.group != current {
current = check.group;
out.push_str(&format!("\n{current}\n"));
}
let action = match (check.hover, check.click, check.press) {
(Some(h), Some(c), true) => format!("hover {h:?}, press {c:?}"),
(Some(h), Some(c), false) => format!("hover {h:?}, click {c:?}"),
(Some(h), None, _) => format!("hover {h:?}"),
(None, Some(c), true) => format!("press {c:?}"),
(None, Some(c), false) => format!("click {c:?}"),
(None, None, _) => "observe only".to_owned(),
};
out.push_str(&format!(
" {:<26} {}\n{:<29}{} -> {:?} {:?}\n",
check.id, check.what, "", action, check.expect, check.subject
));
}
out.push_str(&format!("\n{} checks in {} groups\n", all.len(), {
let mut groups: Vec<&str> = all.iter().map(|c| c.group).collect();
groups.dedup();
groups.len()
}));
out
}
pub fn tally(results: &[(&Check, Result<(), String>)]) -> HashMap<&'static str, (usize, usize)> {
let mut by_group: HashMap<&'static str, (usize, usize)> = HashMap::new();
for (check, outcome) in results {
let entry = by_group.entry(check.group).or_insert((0, 0));
entry.1 += 1;
if outcome.is_ok() {
entry.0 += 1;
}
}
by_group
}