use blitz_control_protocol::SemanticNode;
use std::collections::HashMap;
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub enum Expect {
Paints,
Absent,
Vanishes,
Grows,
PaintsMore,
Holds,
PaintsNamed,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Check {
pub id: String,
pub group: String,
pub what: String,
pub open: Option<String>,
pub hover: Option<String>,
pub click: Option<String>,
pub press: bool,
pub subject: String,
pub expect: Expect,
pub panel_only: bool,
}
pub fn checks(dir: Option<&std::path::Path>) -> Result<Vec<Check>, String> {
let dir = dir
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("tests/ps-qa"));
if !dir.is_dir() {
return Err(format!(
"no checks at {}. Point --checks at the application's check \
directory.",
dir.display()
));
}
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)
.map_err(|error| format!("could not read {}: {error}", dir.display()))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "ron"))
.collect();
files.sort();
let mut all = Vec::new();
for file in files {
let text = std::fs::read_to_string(&file)
.map_err(|error| format!("could not read {}: {error}", file.display()))?;
let group: Vec<Check> = ron::from_str(&text)
.map_err(|error| format!("could not parse {}: {error}", file.display()))?;
all.extend(group);
}
Ok(all)
}
#[allow(dead_code)]
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.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::PaintsNamed => {
let (role, name) = check
.subject
.split_once(':')
.unwrap_or(("", &check.subject));
let hit = after
.iter()
.filter(|node| node.role == role && node.name.contains(name))
.find(|node| node.bounds.is_some_and(|b| b[2] > 0.0 && b[3] > 0.0));
if hit.is_none() {
let present = after
.iter()
.filter(|node| node.role == role && node.name.contains(name))
.count();
return Err(format!(
"no {role} named {name:?} has a box ({present} in the tree)"
));
}
}
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(dir: Option<&std::path::Path>) -> Result<String, String> {
let all = checks(dir)?;
let mut out = String::new();
let mut current = String::new();
for check in &all {
if check.group != current {
current = check.group.clone();
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.as_str()).collect();
groups.dedup();
groups.len()
}));
Ok(out)
}
pub fn tally<'a>(results: &[(&'a Check, Result<(), String>)]) -> HashMap<&'a str, (usize, usize)> {
let mut by_group: HashMap<&str, (usize, usize)> = HashMap::new();
for (check, outcome) in results {
let entry = by_group.entry(check.group.as_str()).or_insert((0, 0));
entry.1 += 1;
if outcome.is_ok() {
entry.0 += 1;
}
}
by_group
}