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,
Enabled,
Disabled,
Absent,
Vanishes,
Grows,
PaintsMore,
Holds,
PaintsNamed,
DistinctPositions,
TargetPaints,
Above,
ValueChanges,
SelectionChanges,
NameChanges,
}
#[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>,
#[serde(default)]
pub prepare: Option<String>,
#[serde(default)]
pub prepare_press: bool,
#[serde(default)]
pub prepare_key: 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 covers: Vec<String>,
#[serde(default)]
pub press: bool,
#[serde(default)]
pub settle_after_ms: u64,
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| {
if let Some(slot) = want.strip_prefix('@') {
return node.slot.as_deref().is_some_and(|have| have == slot);
}
if let Some((role, name)) = want.split_once(':') {
return node.role.eq_ignore_ascii_case(role)
&& node.name.to_lowercase().contains(&name.to_lowercase());
}
node.name.to_lowercase().contains(&want.to_lowercase())
|| node.role.to_lowercase().contains(&want.to_lowercase())
})
.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::Enabled => {
if found.iter().any(|node| paints(node) && node.enabled) {
return Ok(());
}
let states = found
.iter()
.take(3)
.map(|node| {
format!(
"id={} role={:?} name={:?} enabled={} bounds={:?}",
node.id, node.role, node.name, node.enabled, node.bounds
)
})
.collect::<Vec<_>>();
return Err(format!(
"no painted, enabled node matching {:?} ({})",
check.subject,
states.join(", ")
));
}
Expect::Disabled => {
if found.iter().any(|node| paints(node) && !node.enabled) {
return Ok(());
}
let states = found
.iter()
.take(3)
.map(|node| {
format!(
"id={} role={:?} name={:?} enabled={} bounds={:?}",
node.id, node.role, node.name, node.enabled, node.bounds
)
})
.collect::<Vec<_>>();
return Err(format!(
"no painted, disabled node matching {:?} ({})",
check.subject,
states.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::DistinctPositions => {
let painted: Vec<_> = found.iter().copied().filter(|node| paints(node)).collect();
if painted.len() < 2 {
return Err(format!(
"{:?} has {} painted match(es); expected a positioned family",
check.subject,
painted.len()
));
}
let positions: std::collections::HashSet<(i64, i64)> = painted
.iter()
.map(|node| {
let bounds = node.bounds.expect("painted nodes have bounds");
(
((bounds[0] + bounds[2] / 2.0) * 10.0).round() as i64,
((bounds[1] + bounds[3] / 2.0) * 10.0).round() as i64,
)
})
.collect();
if positions.len() != painted.len() {
return Err(format!(
"{} painted node(s) matching {:?} occupy only {} distinct position(s)",
painted.len(),
check.subject,
positions.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)
})?;
value_changed(before_node.id, before, after)?;
}
Expect::SelectionChanges => {
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)
})?;
selection_changed(before_node.id, before, after)?;
}
Expect::NameChanges => {
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)
})?;
name_changed(before_node.id, before, after)?;
}
}
Ok(())
}
pub fn value_changed(
node_id: u64,
before: &[SemanticNode],
after: &[SemanticNode],
) -> Result<(), String> {
let before_node = before
.iter()
.find(|node| node.id == node_id)
.ok_or_else(|| format!("node {node_id} was absent before action"))?;
let after_node = after
.iter()
.find(|node| node.id == node_id)
.ok_or_else(|| format!("node {node_id} disappeared after action"))?;
let old = before_node
.value
.as_deref()
.ok_or_else(|| format!("node {node_id} has no semantic value before action"))?;
let new = after_node
.value
.as_deref()
.ok_or_else(|| format!("node {node_id} has no semantic value after action"))?;
if old == new {
return Err(format!("semantic value for node {node_id} stayed {old:?}"));
}
Ok(())
}
pub fn selection_changed(
node_id: u64,
before: &[SemanticNode],
after: &[SemanticNode],
) -> Result<(), String> {
let before_node = before
.iter()
.find(|node| node.id == node_id)
.ok_or_else(|| format!("node {node_id} was absent before action"))?;
let after_node = after
.iter()
.find(|node| node.id == node_id)
.ok_or_else(|| format!("node {node_id} disappeared after action"))?;
if before_node.selected == after_node.selected {
return Err(format!(
"selected state for node {node_id} stayed {}",
before_node.selected
));
}
Ok(())
}
pub fn name_changed(
node_id: u64,
before: &[SemanticNode],
after: &[SemanticNode],
) -> Result<(), String> {
let before_node = before
.iter()
.find(|node| node.id == node_id)
.ok_or_else(|| format!("node {node_id} was absent before action"))?;
let after_node = after
.iter()
.find(|node| node.id == node_id)
.ok_or_else(|| format!("node {node_id} disappeared after action"))?;
if before_node.name == after_node.name {
return Err(format!(
"accessible name for node {node_id} stayed {:?}",
before_node.name
));
}
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 = action_description(check);
out.push_str(&format!(
" {:<26} {}\n{:<29}{} -> {:?} {:?}\n",
check.id, check.what, "", action, check.expect, check.subject
));
if !check.covers.is_empty() {
out.push_str(&format!("{:<29}covers {:?}\n", "", check.covers));
}
}
out.push_str(&format!("\n{} checks in {} groups\n", all.len(), {
all.iter()
.map(|check| check.group.as_str())
.collect::<std::collections::HashSet<_>>()
.len()
}));
Ok(out)
}
fn action_description(check: &Check) -> String {
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(prepare) = check.prepare.as_deref() {
let preparation = if let Some(key) = check.prepare_key.as_deref() {
format!("prepare-key {key:?} on {prepare:?}")
} else if check.prepare_press {
format!("prepare-press {prepare:?}")
} else {
format!("prepare {prepare:?}")
};
action = format!("{preparation}, {action}");
}
if let Some(field) = check.type_into.as_deref() {
let typed = check.text.as_deref().map_or_else(
|| format!("focus {field:?}"),
|value| format!("type {value:?} into {field:?}"),
);
if action == "observe only" {
action = typed;
} else {
action.push_str(&format!(", {typed}"));
}
}
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:?}"));
}
action
}
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, action_description, name_changed, selection_changed, value_changed, 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_prepare_a_semantic_state_before_the_measured_action() {
let check = parse("prepare:Some(\"Draft\"),");
assert_eq!(check.prepare.as_deref(), Some("Draft"));
assert_eq!(
action_description(&check),
"prepare \"Draft\", activate \"Save\""
);
}
#[test]
fn checks_can_prepare_with_a_real_pointer_without_changing_the_action_mode() {
let check = parse("prepare:Some(\"Draft\"),prepare_press:true,");
assert!(check.prepare_press);
assert!(!check.press);
assert_eq!(
action_description(&check),
"prepare-press \"Draft\", activate \"Save\""
);
}
#[test]
fn checks_can_prepare_with_a_key_without_changing_the_measured_action() {
let check = parse("prepare:Some(\"Menu\"),prepare_key:Some(\"ArrowDown\"),");
assert_eq!(check.prepare_key.as_deref(), Some("ArrowDown"));
assert!(!check.press);
assert_eq!(
action_description(&check),
"prepare-key \"ArrowDown\" on \"Menu\", activate \"Save\""
);
}
#[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\"),\
covers:[\"button:Save row \"],settle_after_ms:1200,",
);
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"));
assert_eq!(check.covers, ["button:Save row "]);
assert_eq!(check.settle_after_ms, 1200);
assert_eq!(
action_description(&check),
"activate \"Save\", type \"latest fixture\" into \"New record\", key \"Enter\" on \"New record\""
);
}
#[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,
prepare: None,
prepare_press: false,
prepare_key: None,
hover: None,
click: None,
type_into: None,
text: None,
key: Some("Right".into()),
key_on: Some("Output level".into()),
compare: None,
covers: Vec::new(),
press: false,
settle_after_ms: 0,
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]),
slot: None,
};
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"
);
assert!(
value_changed(
8,
&[node(7, "0"), node(8, "0")],
&[node(7, "1"), node(8, "0")],
)
.is_err(),
"the exact activated id cannot be replaced by a same-name neighbour"
);
}
#[test]
fn a_selection_check_follows_the_same_node_id() {
let before = SemanticNode {
id: 7,
parent: None,
role: "radio".into(),
name: "Theme colour".into(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds: Some([0.0, 0.0, 20.0, 20.0]),
slot: None,
};
let mut after = before.clone();
after.selected = true;
assert!(
selection_changed(
7,
std::slice::from_ref(&before),
std::slice::from_ref(&after),
)
.is_ok()
);
assert!(
selection_changed(
7,
std::slice::from_ref(&before),
std::slice::from_ref(&before),
)
.is_err()
);
}
#[test]
fn a_name_check_follows_the_same_node_id() {
let node = |id, name: &str| SemanticNode {
id,
parent: None,
role: "status".into(),
name: name.into(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds: Some([0.0, 0.0, 100.0, 20.0]),
slot: None,
};
assert!(name_changed(7, &[node(7, "Refreshed 1")], &[node(7, "Refreshed 2")]).is_ok());
assert!(name_changed(7, &[node(7, "Refreshed 1")], &[node(7, "Refreshed 1")]).is_err());
assert!(
name_changed(
7,
&[node(7, "Refreshed 1")],
&[node(7, "Refreshed 1"), node(8, "Refreshed 2")],
)
.is_err(),
"a neighbouring status node 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,
slot: None,
};
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"));
}
#[test]
fn a_positioned_family_rejects_stacked_controls() {
let mut check = parse("");
check.subject = "radio:Theme color".into();
check.expect = Expect::DistinctPositions;
let node = |id, x, y| SemanticNode {
id,
parent: None,
role: "radio".into(),
name: format!("Theme color {id}"),
value: None,
enabled: true,
visible: true,
selected: false,
bounds: Some([x, y, 20.0, 20.0]),
slot: None,
};
assert!(verdict(&check, &[], &[node(1, 10.0, 10.0), node(2, 40.0, 10.0)]).is_ok());
let error = verdict(&check, &[], &[node(1, 10.0, 10.0), node(2, 10.0, 10.0)])
.expect_err("stacked controls are not a positioned family");
assert!(error.contains("only 1 distinct position"));
}
#[test]
fn enabled_requires_the_control_to_paint_and_accept_input() {
let mut check = parse("");
check.subject = "Save".into();
check.expect = Expect::Enabled;
let node = |enabled, bounds| SemanticNode {
id: 7,
parent: None,
role: "button".into(),
name: "Save".into(),
value: None,
enabled,
visible: true,
selected: false,
bounds,
slot: None,
};
assert!(verdict(&check, &[], &[node(true, Some([0.0, 0.0, 20.0, 20.0]))]).is_ok());
assert!(verdict(&check, &[], &[node(false, Some([0.0, 0.0, 20.0, 20.0]))]).is_err());
assert!(verdict(&check, &[], &[node(true, Some([0.0, 0.0, 0.0, 0.0]))]).is_err());
check.expect = Expect::Disabled;
assert!(verdict(&check, &[], &[node(false, Some([0.0, 0.0, 20.0, 20.0]))]).is_ok());
assert!(verdict(&check, &[], &[node(true, Some([0.0, 0.0, 20.0, 20.0]))]).is_err());
assert!(verdict(&check, &[], &[node(false, Some([0.0, 0.0, 0.0, 0.0]))]).is_err());
}
#[test]
fn verdict_subjects_honor_role_qualified_names() {
let mut check = parse("");
check.subject = "button:Send".into();
check.expect = Expect::Disabled;
let node = |role: &str| SemanticNode {
id: 7,
parent: None,
role: role.into(),
name: "Send".into(),
value: None,
enabled: false,
visible: true,
selected: false,
bounds: Some([0.0, 0.0, 20.0, 20.0]),
slot: None,
};
assert!(verdict(&check, &[], &[node("button")]).is_ok());
assert!(verdict(&check, &[], &[node("textbox")]).is_err());
}
}