use blitz_control_protocol::SemanticNode;
use std::collections::HashMap;
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Expect {
Paints,
Absent,
Vanishes,
Grows,
PaintsMore,
Holds,
PaintsNamed,
TargetPaints,
Above,
ValueChanges,
}
#[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 type_into: Option<String>,
pub text: Option<String>,
pub key: Option<String>,
pub key_on: Option<String>,
pub compare: Option<String>,
#[serde(default)]
pub press: bool,
pub subject: String,
pub expect: Expect,
}
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)
}
fn matching<'a>(nodes: &'a [SemanticNode], want: &str) -> Vec<&'a SemanticNode> {
nodes
.iter()
.filter(|node| node.name.contains(want) || node.role.contains(want))
.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);
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 matches: Vec<_> = after
.iter()
.filter(|node| node.role == role && node.name.contains(name))
.collect();
let state = matches
.iter()
.map(|node| {
format!(
"id={} parent={:?} visible={} bounds={:?}",
node.id, node.parent, node.visible, node.bounds
)
})
.collect::<Vec<_>>()
.join("; ");
let other_painted = after
.iter()
.filter(|node| {
node.role != role
&& node.name.contains(name)
&& node.bounds.is_some_and(|b| b[2] > 0.0 && b[3] > 0.0)
})
.take(3)
.map(|node| format!("{} id={}", node.role, node.id))
.collect::<Vec<_>>()
.join(", ");
let other_painted = if other_painted.is_empty() {
"none".to_owned()
} else {
other_painted
};
return Err(format!(
"no {role} named {name:?} has a box ({} in the tree: {state}); \
other painted matches: {other_painted}",
matches.len(),
));
}
}
Expect::TargetPaints => {
return Err("TargetPaints must be resolved by the live QA runner".to_owned());
}
Expect::Above => {
let compare = check
.compare
.as_deref()
.ok_or_else(|| "Above requires compare".to_owned())?;
let subject_node = found
.iter()
.find(|node| paints(node))
.ok_or_else(|| format!("no painted node matching {:?}", check.subject))?;
let subject = subject_node.bounds.expect("painted nodes have bounds");
let other = matching(after, compare)
.into_iter()
.filter(|node| node.id != subject_node.id)
.find_map(|node| paints(node).then_some(node.bounds).flatten())
.ok_or_else(|| format!("no painted comparison node matching {compare:?}"))?;
if subject[1] >= other[1] {
return Err(format!(
"{:?} is at y={:.0}, not above {compare:?} at y={:.0}",
check.subject, subject[1], other[1]
));
}
}
Expect::PaintsMore => {
let was = matching(before, &check.subject)
.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).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).len();
let now = found.len();
if now != was {
return Err(format!(
"{:?} went {was} -> {now}, expected no change",
check.subject
));
}
}
Expect::ValueChanges => {
let before_node = matching(before, &check.subject)
.into_iter()
.find(|node| paints(node))
.ok_or_else(|| {
format!("no painted node matching {:?} before action", check.subject)
})?;
let after_node = after
.iter()
.find(|node| node.id == before_node.id)
.ok_or_else(|| format!("node {} disappeared after action", before_node.id))?;
let old = before_node.value.as_deref().ok_or_else(|| {
format!(
"node {} has no semantic value before action",
before_node.id
)
})?;
let new = after_node.value.as_deref().ok_or_else(|| {
format!("node {} has no semantic value after action", after_node.id)
})?;
if old == new {
return Err(format!(
"semantic value for {:?} stayed {:?}",
check.subject, old
));
}
}
}
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 mut 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:?}, activate {c:?}"),
(Some(h), None, _) => format!("hover {h:?}"),
(None, Some(c), true) => format!("press {c:?}"),
(None, Some(c), false) => format!("activate {c:?}"),
(None, None, _) => "observe only".to_owned(),
};
if let (Some(key), Some(target)) = (
&check.key,
check.key_on.as_ref().or(check.type_into.as_ref()),
) {
action.push_str(&format!(", key {key:?} on {target:?}"));
}
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
}
#[cfg(test)]
mod tests {
use super::{Check, Expect, verdict};
use blitz_control_protocol::SemanticNode;
fn parse(extra: &str) -> Check {
let ron = format!(
"(id:\"action\",group:\"group\",what:\"outcome\",open:None,hover:None,\
click:Some(\"Save\"),{extra}subject:\"Saved\",expect:Paints)"
);
ron::from_str(&ron).expect("check parses")
}
#[test]
fn checks_default_to_semantic_actions_and_can_opt_into_pointer_press() {
assert!(!parse("").press);
assert!(parse("press:true,").press);
}
#[test]
fn checks_can_describe_literal_semantic_input() {
let check = parse(
"type_into:Some(\"New record\"),text:Some(\"latest fixture\"),\
key:Some(\"Enter\"),compare:Some(\"older\"),",
);
assert_eq!(check.type_into.as_deref(), Some("New record"));
assert_eq!(check.text.as_deref(), Some("latest fixture"));
assert_eq!(check.key.as_deref(), Some("Enter"));
assert_eq!(check.compare.as_deref(), Some("older"));
}
#[test]
fn a_value_check_follows_the_same_node_id() {
let check = Check {
id: "slider".into(),
group: "settings".into(),
what: "the slider moves".into(),
open: None,
hover: None,
click: None,
type_into: None,
text: None,
key: Some("Right".into()),
key_on: Some("Output level".into()),
compare: None,
press: false,
subject: "Output level".into(),
expect: Expect::ValueChanges,
};
let node = |id, value: &str| SemanticNode {
id,
parent: None,
role: "slider".into(),
name: "Output level".into(),
value: Some(value.into()),
enabled: true,
visible: true,
selected: false,
bounds: Some([0.0, 0.0, 100.0, 20.0]),
};
assert!(verdict(&check, &[node(7, "0")], &[node(7, "1")]).is_ok());
assert!(verdict(&check, &[node(7, "0")], &[node(7, "0")]).is_err());
assert!(
verdict(&check, &[node(7, "0")], &[node(7, "0"), node(8, "1")],).is_err(),
"a neighbouring repeated control cannot satisfy the check"
);
}
#[test]
fn a_named_paint_failure_reports_a_still_painted_activator() {
let mut check = parse("");
check.subject = "textbox:Rename project".into();
check.expect = Expect::PaintsNamed;
let node = |id, role: &str, bounds: Option<[f64; 4]>| SemanticNode {
id,
parent: None,
role: role.into(),
name: "Rename project".into(),
value: None,
enabled: true,
visible: bounds.is_some(),
selected: false,
bounds,
};
let error = verdict(
&check,
&[],
&[
node(7, "textbox", Some([0.0, 0.0, 0.0, 0.0])),
node(8, "button", Some([10.0, 10.0, 20.0, 20.0])),
],
)
.expect_err("the textbox does not paint");
assert!(error.contains("other painted matches: button id=8"));
}
}