use blitz_control_protocol::SemanticNode;
use std::collections::HashMap;
use crate::target::selector_matches_node;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum Hover {
Once(String),
Times(String, u8),
}
impl Hover {
pub fn target(&self) -> &str {
match self {
Hover::Once(name) | Hover::Times(name, _) => name,
}
}
pub fn times(&self) -> u8 {
match self {
Hover::Once(_) => 1,
Hover::Times(_, times) => (*times).max(1),
}
}
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Expect {
Paints,
Enabled,
Disabled,
Absent,
Vanishes,
Grows,
PaintsMore,
FamilyChanges,
Holds,
Count,
PaintsNamed,
DistinctPositions,
ContainedBy,
TargetPaints,
Measures,
Above,
RightOf,
CenterAlignedY,
PixelsHold,
PixelsHoldAfterHover,
PixelsChange,
VisibleInk,
InteriorInk,
OpaqueBackground,
TransparentBackground,
FullOpacity,
TransparentWindowTint,
Contrast,
FontSizeGrows,
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_unless: Option<String>,
#[serde(default)]
pub prepare_press: bool,
#[serde(default)]
pub prepare_key: Option<String>,
pub hover: Option<Hover>,
#[serde(default)]
pub hover_unless: Option<String>,
#[serde(default)]
pub after_prepare_hover: Option<Hover>,
#[serde(default)]
pub reveal_before_capture: Option<String>,
#[serde(default)]
pub setup_type_into: Option<String>,
#[serde(default)]
pub setup_text: Option<String>,
pub click: Option<String>,
pub type_into: Option<String>,
pub text: Option<String>,
pub key: Option<String>,
pub key_on: Option<String>,
#[serde(default)]
pub scroll_over: Option<String>,
#[serde(default)]
pub scroll_ticks: usize,
#[serde(default)]
pub scroll_delta: f64,
pub compare: Option<String>,
#[serde(default)]
pub expect_size: Option<String>,
#[serde(default)]
pub expect_count: Option<usize>,
#[serde(default)]
pub covers: Vec<String>,
#[serde(default)]
pub press: bool,
#[serde(default)]
pub settle_after_ms: u64,
#[serde(default)]
pub outcome_timeout_ms: u64,
#[serde(default)]
pub stable_for_ms: u64,
#[serde(default)]
pub destructive: bool,
pub subject: String,
pub expect: Expect,
}
pub fn default_checks_path() -> std::path::PathBuf {
std::path::PathBuf::from("tests/ps-qa")
}
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(default_checks_path);
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();
let mut ids = HashMap::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()))?;
for check in &group {
validate_check(check, &file, &mut ids)?;
}
all.extend(group);
}
Ok(all)
}
fn validate_check(
check: &Check,
file: &std::path::Path,
ids: &mut HashMap<String, std::path::PathBuf>,
) -> Result<(), String> {
if let Some(previous) = ids.insert(check.id.clone(), file.to_path_buf()) {
return Err(format!(
"duplicate check id {:?} in {} (already declared in {})",
check.id,
file.display(),
previous.display()
));
}
if check.expect == Expect::Vanishes
&& check.prepare_key.is_some()
&& check.prepare_unless.as_deref() == Some(check.subject.as_str())
&& check.click.is_none()
&& check.key.is_none()
&& check.type_into.is_none()
{
return Err(format!(
concat!(
"{}: check {:?} can pass without opening {:?}: prepare_key replaces prepare ",
"activation and is skipped when prepare_unless already paints; open with ",
"prepare, then send the dismiss key with key and key_on"
),
file.display(),
check.id,
check.subject
));
}
if check.setup_type_into.is_some() != check.setup_text.is_some() {
return Err(format!(
"{}: check {:?} must declare setup_type_into and setup_text together",
file.display(),
check.id
));
}
if check.scroll_over.is_some() && (check.scroll_ticks == 0 || check.scroll_delta == 0.0) {
return Err(format!(
"{}: check {:?} must declare non-zero scroll_ticks and scroll_delta with scroll_over",
file.display(),
check.id,
));
}
if check.expect == Expect::Count && check.expect_count.is_none() {
return Err(format!(
"{}: check {:?} must declare expect_count with Count",
file.display(),
check.id,
));
}
Ok(())
}
fn matching<'a>(nodes: &'a [SemanticNode], want: &str) -> Vec<&'a SemanticNode> {
nodes
.iter()
.filter(|node| selector_matches_node(node, 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| node.visible && 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));
}
let broken: Vec<_> = found.iter().copied().filter(|node| !paints(node)).collect();
if !broken.is_empty() {
let hidden = broken.iter().filter(|node| !node.visible).count();
let zero = broken
.iter()
.filter(|node| {
node.visible && !node.bounds.is_some_and(|b| b[2] > 0.0 && b[3] > 0.0)
})
.count();
let boxes: Vec<String> = broken
.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!(
"{} of {} node(s) matching {:?} do not paint: \
{hidden} hidden, {zero} visible with no area ({})",
broken.len(),
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 => {
if !found.iter().any(|node| paints(node)) {
let state = found
.iter()
.map(|node| {
format!(
"id={} parent={:?} visible={} bounds={:?}",
node.id, node.parent, node.visible, node.bounds
)
})
.collect::<Vec<_>>()
.join("; ");
let other_painted = check
.subject
.split_once(':')
.map(|(role, name)| {
after
.iter()
.filter(|node| {
!node.role.eq_ignore_ascii_case(role)
&& crate::target::name_matches(&node.name, name)
&& paints(node)
})
.take(3)
.map(|node| format!("{} id={}", node.role, node.id))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let other_painted = if other_painted.is_empty() {
"none".to_owned()
} else {
other_painted
};
return Err(format!(
"no painted node matching {:?} has a box ({} in the tree: {state}); \
other painted matches: {other_painted}",
check.subject,
found.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::ContainedBy => {
let compare = check
.compare
.as_deref()
.ok_or_else(|| "ContainedBy requires compare".to_owned())?;
let painted: Vec<_> = found.iter().copied().filter(|node| paints(node)).collect();
if painted.is_empty() {
return Err(format!("no painted node matching {:?}", check.subject));
}
let container = matching(after, compare)
.into_iter()
.filter(|node| !painted.iter().any(|subject| subject.id == node.id))
.find_map(|node| paints(node).then_some(node.bounds).flatten())
.ok_or_else(|| format!("no painted comparison node matching {compare:?}"))?;
const SLACK: f64 = 1.0;
for node in painted {
let bounds = node.bounds.expect("painted nodes have bounds");
let inside = bounds[0] >= container[0] - SLACK
&& bounds[1] >= container[1] - SLACK
&& bounds[0] + bounds[2] <= container[0] + container[2] + SLACK
&& bounds[1] + bounds[3] <= container[1] + container[3] + SLACK;
if !inside {
return Err(format!(
"{:?} id={} at {:.0},{:.0} {:.0}x{:.0} escapes {compare:?} at \
{:.0},{:.0} {:.0}x{:.0}",
check.subject,
node.id,
bounds[0],
bounds[1],
bounds[2],
bounds[3],
container[0],
container[1],
container[2],
container[3]
));
}
}
}
Expect::TargetPaints => {
return Err("TargetPaints must be resolved by the live QA runner".to_owned());
}
Expect::Measures => {
let want = check
.expect_size
.as_deref()
.ok_or_else(|| "Measures requires expect_size".to_owned())?;
let painted = found
.iter()
.copied()
.filter(|node| paints(node))
.collect::<Vec<_>>();
if painted.is_empty() {
return Err(format!("no painted node matching {:?}", check.subject));
}
let (want_w, want_h) = want.split_once('x').ok_or_else(|| {
format!("expect_size {want:?} is not WxH; use `190x24`, `x24` or `190x`")
})?;
for node in painted {
let box_ = node.bounds.expect("painted nodes have bounds");
for (axis, spec, actual) in
[("width", want_w, box_[2]), ("height", want_h, box_[3])]
{
let spec = spec.trim();
if spec.is_empty() {
continue;
}
const SLACK: f64 = 1.0;
let (compare, number) = match spec.strip_prefix("<=") {
Some(rest) => ("at most", rest),
None => match spec.strip_prefix('=') {
Some(rest) => ("exactly", rest),
None => ("at least", spec),
},
};
let target: f64 = number
.trim()
.parse()
.map_err(|_| format!("expect_size {axis} {number:?} is not a number"))?;
let ok = match compare {
"at most" => actual <= target + SLACK,
"exactly" => (actual - target).abs() <= SLACK,
_ => actual >= target - SLACK,
};
if !ok {
return Err(format!(
"{:?} id={} is {actual:.0}px {axis}, expected {compare} {target:.0}",
check.subject, node.id
));
}
}
}
}
Expect::Above => {
let compare = check
.compare
.as_deref()
.ok_or_else(|| "Above requires compare".to_owned())?;
if found.is_empty() {
return Err(format!("no node matching {:?}", check.subject));
}
let others: Vec<_> = matching(after, compare)
.into_iter()
.filter(|node| paints(node))
.collect();
if others.is_empty() {
return Err(format!("no painted comparison node matching {compare:?}"));
}
for subject_node in &found {
if !paints(subject_node) {
return Err(format!(
"{:?} id={} does not paint",
check.subject, subject_node.id
));
}
let subject = subject_node.bounds.expect("painted nodes have bounds");
if !others.iter().any(|other| {
other.id != subject_node.id
&& other.bounds.is_some_and(|bounds| subject[1] < bounds[1])
}) {
return Err(format!(
"{:?} id={} is at y={:.0}, not above any {compare:?}",
check.subject, subject_node.id, subject[1]
));
}
}
}
Expect::RightOf => {
let compare = check
.compare
.as_deref()
.ok_or_else(|| "RightOf requires compare".to_owned())?;
if found.is_empty() {
return Err(format!("no node matching {:?}", check.subject));
}
let others: Vec<_> = matching(after, compare)
.into_iter()
.filter(|node| paints(node))
.collect();
if others.is_empty() {
return Err(format!("no painted comparison node matching {compare:?}"));
}
const SLACK: f64 = 1.0;
for subject_node in &found {
if !paints(subject_node) {
return Err(format!(
"{:?} id={} does not paint",
check.subject, subject_node.id
));
}
let subject = subject_node.bounds.expect("painted nodes have bounds");
if !others.iter().any(|other| {
other.id != subject_node.id
&& other
.bounds
.is_some_and(|bounds| subject[0] >= bounds[0] + bounds[2] - SLACK)
}) {
return Err(format!(
"{:?} id={} starts at x={:.0}, not right of any {compare:?}",
check.subject, subject_node.id, subject[0]
));
}
}
}
Expect::CenterAlignedY => {
let compare = check
.compare
.as_deref()
.ok_or_else(|| "CenterAlignedY requires compare".to_owned())?;
if found.is_empty() {
return Err(format!("no node matching {:?}", check.subject));
}
let others: Vec<_> = matching(after, compare)
.into_iter()
.filter(|node| paints(node))
.collect();
if others.is_empty() {
return Err(format!("no painted comparison node matching {compare:?}"));
}
const SLACK: f64 = 2.0;
for subject_node in &found {
if !paints(subject_node) {
return Err(format!(
"{:?} id={} does not paint",
check.subject, subject_node.id
));
}
let subject = subject_node.bounds.expect("painted nodes have bounds");
let subject_center = subject[1] + subject[3] / 2.0;
if !others.iter().any(|other| {
other.id != subject_node.id
&& other.bounds.is_some_and(|bounds| {
let other_center = bounds[1] + bounds[3] / 2.0;
(subject_center - other_center).abs() <= SLACK
})
}) {
return Err(format!(
"{:?} id={} is centered at y={subject_center:.0}, not aligned with any {compare:?}",
check.subject, subject_node.id
));
}
}
}
Expect::PixelsHold
| Expect::PixelsHoldAfterHover
| Expect::PixelsChange
| Expect::VisibleInk
| Expect::InteriorInk
| Expect::OpaqueBackground
| Expect::TransparentBackground
| Expect::FullOpacity
| Expect::TransparentWindowTint
| Expect::Contrast
| Expect::FontSizeGrows => {
return Err("paint expectations must be resolved by the live QA runner".to_owned());
}
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::FamilyChanges => {
let family = |nodes: &[SemanticNode]| {
matching(nodes, &check.subject)
.into_iter()
.filter(|node| paints(node))
.map(|node| (node.id, node.name.clone()))
.collect::<std::collections::HashSet<_>>()
};
let was = family(before);
let now = family(after);
if now.is_empty() {
return Err(format!(
"no painted node matching {:?} after pagination",
check.subject
));
}
if now == was {
return Err(format!(
"{:?} kept the same {} rendered member(s)",
check.subject,
now.len()
));
}
}
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::Count => {
let want = check
.expect_count
.ok_or_else(|| "Count requires expect_count".to_owned())?;
if found.len() != want {
return Err(format!(
"{:?} has {} member(s), expected {want}",
check.subject,
found.len()
));
}
}
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), Some(value)) = (
check.setup_type_into.as_deref(),
check.setup_text.as_deref(),
) {
action = format!("setup {value:?} in {field:?}, {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:?}"));
}
if let Some(target) = check.scroll_over.as_deref() {
action.push_str(&format!(
", scroll {} x {:.0} over {target:?}",
check.scroll_ticks, check.scroll_delta
));
}
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, validate_check,
value_changed, verdict,
};
use blitz_control_protocol::SemanticNode;
use std::collections::HashMap;
use std::path::Path;
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")
}
fn painted_node(id: u64, name: &str, width: f64, height: f64) -> SemanticNode {
SemanticNode {
dom_id: None,
id,
parent: None,
role: "generic".into(),
name: name.into(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds: Some([0.0, 0.0, width, height]),
slot: None,
}
}
#[test]
fn measures_rejects_any_matching_node_that_breaks_the_contract() {
let mut check = parse("");
check.click = None;
check.expect = Expect::Measures;
check.expect_size = Some("x<=24".into());
let compact = painted_node(1, "Saved", 80.0, 24.0);
let wrapped = painted_node(2, "Saved", 80.0, 52.0);
let error = verdict(&check, &[], &[compact.clone(), wrapped])
.expect_err("one valid representative cannot hide a wrapped sibling");
assert!(error.contains("id=2"));
assert!(error.contains("52px height"));
assert!(verdict(&check, &[], &[compact.clone(), compact]).is_ok());
}
#[test]
fn family_verdicts_reject_a_broken_sibling() {
let good = painted_node(1, "Saved", 20.0, 20.0);
let mut hidden = painted_node(2, "Saved", 20.0, 20.0);
hidden.visible = false;
hidden.bounds = Some([0.0, 0.0, 0.0, 0.0]);
let mut check = parse("");
check.click = None;
check.expect = Expect::Paints;
assert!(
verdict(&check, &[], &[good.clone(), hidden]).is_err(),
"one painted representative must not hide a broken sibling"
);
let comparison = SemanticNode {
id: 9,
name: "Anchor".into(),
bounds: Some([100.0, 100.0, 20.0, 20.0]),
..painted_node(9, "Anchor", 20.0, 20.0)
};
check.compare = Some("Anchor".into());
for (expect, valid_bounds, broken_bounds) in [
(
Expect::Above,
[100.0, 70.0, 20.0, 20.0],
[100.0, 130.0, 20.0, 20.0],
),
(
Expect::RightOf,
[130.0, 100.0, 20.0, 20.0],
[90.0, 100.0, 20.0, 20.0],
),
(
Expect::CenterAlignedY,
[130.0, 100.0, 20.0, 20.0],
[130.0, 130.0, 20.0, 20.0],
),
] {
let mut valid = good.clone();
valid.bounds = Some(valid_bounds);
let mut broken = good.clone();
broken.id = 2;
broken.bounds = Some(broken_bounds);
check.expect = expect;
assert!(
verdict(
&check,
&[],
&[valid.clone(), broken.clone(), comparison.clone()]
)
.is_err(),
"{expect:?} must validate every matching subject"
);
}
}
#[test]
fn checks_default_to_semantic_actions_and_can_opt_into_pointer_press() {
assert!(!parse("").press);
assert!(parse("press:true,").press);
}
#[test]
fn destructive_checks_are_explicit_and_default_to_the_shared_body() {
assert!(!parse("").destructive);
assert!(parse("destructive:true,").destructive);
}
#[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_make_preparation_idempotent() {
let check = parse("prepare:Some(\"Menu\"),prepare_unless:Some(\"menuitem:First\"),");
assert_eq!(check.prepare.as_deref(), Some("Menu"));
assert_eq!(check.prepare_unless.as_deref(), Some("menuitem:First"));
}
#[test]
fn checks_can_make_hover_idempotent() {
let check = parse("hover_unless:Some(\"Dialog\"),");
assert_eq!(check.hover_unless.as_deref(), Some("Dialog"));
}
#[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 duplicate_check_ids_are_rejected_before_a_run() {
let check = parse("");
let mut ids = HashMap::new();
validate_check(&check, Path::new("first.ron"), &mut ids).expect("first id is unique");
let error = validate_check(&check, Path::new("second.ron"), &mut ids)
.expect_err("duplicate id must fail");
assert!(error.contains("duplicate check id \"action\""));
assert!(error.contains("first.ron"));
assert!(error.contains("second.ron"));
}
#[test]
fn a_dismissal_cannot_pass_by_sending_prepare_key_to_a_closed_subject() {
let mut check = parse(
"prepare:Some(\"Menu\"),prepare_unless:Some(\"menuitem:First\"),\
prepare_key:Some(\"Escape\"),",
);
check.click = None;
check.subject = "menuitem:First".into();
check.expect = Expect::Vanishes;
let error = validate_check(&check, Path::new("menu.ron"), &mut HashMap::new())
.expect_err("closed-menu false green must fail validation");
assert!(error.contains("can pass without opening"));
assert!(error.contains("key and key_on"));
}
#[test]
fn checks_can_repeat_hover_after_preparation() {
let check = parse("after_prepare_hover:Some((\"menuitem:low\",5)),");
let hover = check.after_prepare_hover.expect("post-prepare hover");
assert_eq!(hover.target(), "menuitem:low");
assert_eq!(hover.times(), 5);
}
#[test]
fn checks_can_reveal_a_region_before_their_first_capture() {
let check = parse("reveal_before_capture:Some(\"heading:Appearance\"),");
assert_eq!(
check.reveal_before_capture.as_deref(),
Some("heading:Appearance")
);
}
#[test]
fn checks_can_establish_typed_state_before_the_measured_action() {
let check = parse("setup_type_into:Some(\"Search projects\"),setup_text:Some(\"theta\"),");
assert_eq!(
action_description(&check),
"setup \"theta\" in \"Search projects\", activate \"Save\""
);
validate_check(&check, Path::new("search.ron"), &mut HashMap::new())
.expect("paired setup input is valid");
}
#[test]
fn typed_setup_requires_both_the_field_and_value() {
let check = parse("setup_type_into:Some(\"Search projects\"),");
let error = validate_check(&check, Path::new("search.ron"), &mut HashMap::new())
.expect_err("an incomplete setup must fail validation");
assert!(error.contains("setup_type_into and setup_text together"));
}
#[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!(check.stable_for_ms, 0);
assert_eq!(
action_description(&check),
"activate \"Save\", type \"latest fixture\" into \"New record\", key \"Enter\" on \"New record\""
);
}
#[test]
fn checks_can_describe_real_wheel_input() {
let check = parse("scroll_over:Some(\"listitem:\"),scroll_ticks:4,scroll_delta:-300.0,");
assert_eq!(check.scroll_over.as_deref(), Some("listitem:"));
assert_eq!(check.scroll_ticks, 4);
assert_eq!(check.scroll_delta, -300.0);
assert_eq!(
action_description(&check),
"activate \"Save\", scroll 4 x -300 over \"listitem:\""
);
}
#[test]
fn count_checks_require_an_exact_count() {
let mut check = parse("");
check.expect = Expect::Count;
let error = validate_check(&check, Path::new("count.ron"), &mut HashMap::new())
.expect_err("Count without an exact value is ambiguous");
assert!(error.contains("expect_count"));
check.expect_count = Some(12);
validate_check(&check, Path::new("count.ron"), &mut HashMap::new())
.expect("an exact count is valid");
}
#[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_unless: None,
prepare_press: false,
prepare_key: None,
hover: None,
hover_unless: None,
after_prepare_hover: None,
reveal_before_capture: None,
setup_type_into: None,
setup_text: None,
click: None,
type_into: None,
text: None,
key: Some("Right".into()),
key_on: Some("Output level".into()),
scroll_over: None,
scroll_ticks: 0,
scroll_delta: 0.0,
compare: None,
expect_size: None,
expect_count: None,
covers: Vec::new(),
press: false,
settle_after_ms: 0,
outcome_timeout_ms: 0,
stable_for_ms: 0,
destructive: false,
subject: "Output level".into(),
expect: Expect::ValueChanges,
};
let node = |id, value: &str| SemanticNode {
dom_id: None,
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_virtualized_family_can_advance_without_growing() {
let node = |id, name: &str| SemanticNode {
dom_id: None,
id,
parent: None,
role: "button".into(),
name: name.into(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds: Some([0.0, 0.0, 100.0, 24.0]),
slot: None,
};
let first = node(1, "Rename project alpha");
let mut second = node(2, "Rename project beta");
let mut check = parse("");
check.subject = "button:Rename project ".into();
check.expect = Expect::FamilyChanges;
assert!(
verdict(
&check,
std::slice::from_ref(&first),
std::slice::from_ref(&second)
)
.is_ok()
);
assert!(
verdict(
&check,
std::slice::from_ref(&first),
std::slice::from_ref(&first)
)
.is_err()
);
second.bounds = Some([0.0, 0.0, 0.0, 0.0]);
assert!(verdict(&check, &[first], &[second]).is_err());
}
#[test]
fn a_selection_check_follows_the_same_node_id() {
let before = SemanticNode {
dom_id: None,
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 {
dom_id: None,
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 {
dom_id: None,
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 paints_named_uses_the_shared_selector_language() {
let node = SemanticNode {
dom_id: Some("project-rename".into()),
id: 7,
parent: None,
role: "textbox".into(),
name: "Rename project".into(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds: Some([10.0, 10.0, 100.0, 20.0]),
slot: Some("inline-edit".into()),
};
let mut check = parse("");
check.expect = Expect::PaintsNamed;
for selector in [
"TEXTBOX:*PROJECT",
"rename proj*",
"#project-rename",
"@inline-edit",
] {
check.subject = selector.into();
verdict(&check, &[], std::slice::from_ref(&node)).unwrap_or_else(|error| {
panic!("{selector:?} did not use shared semantics: {error}")
});
}
}
#[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 {
dom_id: None,
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 a_rendered_family_must_stay_inside_its_comparison_box() {
let mut check = parse("compare:Some(\"group:Surface colour\"),");
check.subject = "radio:Theme color".into();
check.expect = Expect::ContainedBy;
let child = |id, x, y| SemanticNode {
dom_id: None,
id,
parent: Some(1),
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,
};
let container = SemanticNode {
dom_id: None,
id: 1,
parent: None,
role: "group".into(),
name: "Surface colour".into(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds: Some([10.0, 10.0, 190.0, 190.0]),
slot: None,
};
assert!(
verdict(
&check,
&[],
&[
container.clone(),
child(2, 20.0, 20.0),
child(3, 160.0, 160.0)
]
)
.is_ok()
);
let error = verdict(
&check,
&[],
&[container, child(2, 20.0, 20.0), child(3, 195.0, 195.0)],
)
.expect_err("a detached petal must fail containment");
assert!(error.contains("escapes \"group:Surface colour\""));
}
#[test]
fn vertical_center_alignment_compares_rendered_boxes() {
let mut check = parse("compare:Some(\"@adjustments\"),");
check.subject = "@color-wheel-flower".into();
check.expect = Expect::CenterAlignedY;
let node = |id, slot: &str, bounds| SemanticNode {
dom_id: None,
id,
parent: None,
role: "generic".into(),
name: String::new(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds: Some(bounds),
slot: Some(slot.into()),
};
let wheel = node(1, "color-wheel-flower", [10.0, 110.0, 190.0, 190.0]);
let aligned = node(2, "adjustments", [220.0, 20.0, 400.0, 370.0]);
assert!(verdict(&check, &[], &[wheel.clone(), aligned]).is_ok());
let pinned_to_top = node(2, "adjustments", [220.0, 110.0, 400.0, 370.0]);
let error = verdict(&check, &[], &[wheel, pinned_to_top])
.expect_err("top-pinned wheel must fail vertical centering");
assert!(error.contains("not aligned"));
}
#[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 {
dom_id: None,
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 {
dom_id: None,
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());
}
}