use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SurfaceSpec {
pub name: String,
pub opener: String,
#[serde(default)]
pub marker: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManualControl {
pub label: String,
pub command: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AppProfile {
pub surfaces: Vec<SurfaceSpec>,
pub permanent_surfaces: Vec<String>,
pub navigation_controls: Vec<String>,
pub sections: Vec<String>,
pub transcript_region: Option<String>,
pub home_opener: Option<String>,
pub document_row_markers: Vec<String>,
pub close_prefixes: Vec<String>,
pub dismiss_controls: Vec<String>,
pub row_action_prefixes: Vec<String>,
pub fold_prefixes: Vec<String>,
#[serde(alias = "native_choosers")]
pub manual_controls: Vec<ManualControl>,
pub deferred_controls: Vec<String>,
pub isolated_controls: Vec<String>,
pub inert_controls: Vec<String>,
}
impl AppProfile {
pub fn load(path: Option<&Path>) -> Result<Self, String> {
let Some(path) = path.map(Path::to_path_buf).or_else(discover) else {
return Err(
"no application profile. Pass --app <path>, or put ps-qa.ron in the \
working directory: the harness knows nothing about any application \
without one, and guessing produces numbers measured against an \
application that does not exist."
.to_owned(),
);
};
if !path.exists() {
return Err(format!(
"no application profile at {}. Pass --app <path>, or put ps-qa.ron in \
the working directory.",
path.display()
));
}
let text = std::fs::read_to_string(&path)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
ron::from_str(&text).map_err(|error| format!("could not parse {}: {error}", path.display()))
}
pub fn dismisses_dialog(&self, name: &str) -> bool {
self.dismiss_controls
.iter()
.any(|control| name.eq_ignore_ascii_case(control))
}
pub fn folds_a_section(&self, name: &str) -> bool {
self.sections.iter().any(|section| {
name.strip_prefix(section.as_str())
.is_some_and(|rest| rest.is_empty() || rest.chars().all(|c| c.is_ascii_digit()))
})
}
pub fn is_permanent(&self, name: &str) -> bool {
self.permanent_surfaces.iter().any(|surface| {
name == surface.as_str() || name == format!("{surface}{surface}").as_str()
})
}
}
fn discover() -> Option<PathBuf> {
if let Some(pinned) = crate::cli::app_profile() {
return Some(pinned);
}
let beside = PathBuf::from("ps-qa.ron");
beside.exists().then_some(beside)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_absent_profile_is_an_error() {
let error = AppProfile::load(Some(Path::new("/nonexistent/ps-qa.ron")))
.expect_err("a missing profile must not load a default");
assert!(error.contains("no application profile"), "{error}");
assert!(error.contains("--app"), "{error}");
}
#[test]
fn a_section_matches_with_or_without_its_count() {
let profile = AppProfile {
sections: vec!["Records".to_owned(), "Activity".to_owned()],
..Default::default()
};
assert!(profile.folds_a_section("Records"));
assert!(profile.folds_a_section("Records12"));
assert!(profile.folds_a_section("Activity"));
assert!(!profile.folds_a_section("Record sorting"));
assert!(!profile.folds_a_section("Recorder"));
}
#[test]
fn a_permanent_surface_is_recognised_doubled() {
let profile = AppProfile {
permanent_surfaces: vec!["Dashboard".to_owned(), "Preferences".to_owned()],
..Default::default()
};
assert!(profile.is_permanent("Dashboard"));
assert!(profile.is_permanent("DashboardDashboard"));
assert!(!profile.is_permanent("Dashboard item"));
assert!(!profile.is_permanent("some document"));
}
#[test]
fn a_profile_round_trips_through_ron() {
let profile = AppProfile {
surfaces: vec![SurfaceSpec {
name: "dashboard".to_owned(),
opener: "Dashboard".to_owned(),
marker: Some("Overview heading".to_owned()),
}],
permanent_surfaces: vec!["Dashboard".to_owned()],
navigation_controls: vec!["Open Preferences".to_owned()],
sections: vec!["Records".to_owned()],
transcript_region: Some("Message history".to_owned()),
home_opener: Some("Dashboard".to_owned()),
deferred_controls: vec!["Create document".to_owned()],
isolated_controls: vec!["Restart application".to_owned()],
inert_controls: vec!["Synchronize".to_owned()],
document_row_markers: vec![" open ยท ".to_owned()],
close_prefixes: vec!["Close ".to_owned()],
dismiss_controls: vec!["Leave dialog".to_owned()],
row_action_prefixes: vec!["Rename ".to_owned()],
fold_prefixes: vec!["Collapse ".to_owned()],
manual_controls: vec![ManualControl {
label: "Import data".to_owned(),
command: "open_native_picker".to_owned(),
}],
};
let text = ron::to_string(&profile).expect("serialises");
let back: AppProfile = ron::from_str(&text).expect("parses");
assert_eq!(back.surfaces.len(), 1);
assert_eq!(back.sections, vec!["Records".to_owned()]);
assert_eq!(back.transcript_region.as_deref(), Some("Message history"));
assert!(back.dismisses_dialog("leave dialog"));
}
}