use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::behavior::{BehaviorState, ReplayHost, replay_program};
use crate::program::{Observation, ProgramError, TestProgram};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiffAxis {
Route,
A11y,
SemanticText,
Component,
Network,
Console,
Storage,
Geometry,
#[serde(alias = "pixel")]
VisualDigest,
}
impl DiffAxis {
#[must_use]
pub fn is_structured(&self) -> bool {
*self != Self::VisualDigest
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Route => "route",
Self::A11y => "a11y",
Self::SemanticText => "semantic_text",
Self::Component => "component",
Self::Network => "network",
Self::Console => "console",
Self::Storage => "storage",
Self::Geometry => "geometry",
Self::VisualDigest => "visual_digest",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AxisDelta {
pub axis: DiffAxis,
pub base: String,
pub head: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BehaviorDelta {
pub axes: Vec<AxisDelta>,
pub first_structured: Option<DiffAxis>,
#[serde(alias = "pixel_compared")]
pub visual_compared: bool,
}
impl BehaviorDelta {
#[must_use]
pub fn changed(&self) -> bool {
!self.axes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StructuredView {
pub route: Option<String>,
pub a11y_digest: Option<String>,
pub semantic_text: Option<String>,
pub component: Option<String>,
pub network: Vec<String>,
pub console: Vec<String>,
pub storage: Vec<String>,
pub viewport: Option<String>,
pub visual_digest: Option<String>,
pub visual_surface: Option<String>,
}
impl StructuredView {
#[must_use]
pub fn from_replay(observation: &Observation, state: Option<&BehaviorState>) -> Self {
let mut view = Self {
route: observation.route.clone(),
a11y_digest: observation.a11y_digest.clone(),
semantic_text: None,
component: None,
network: if observation.network_requests.is_empty() {
observation
.network
.iter()
.map(|event| network_event_identity(event))
.collect()
} else {
observation
.network_requests
.iter()
.map(crate::NetworkRequestObservation::identity_key)
.collect()
},
console: observation.console.clone(),
storage: observation
.storage
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect(),
viewport: observation.viewport.clone(),
visual_digest: observation.visual_digest.clone(),
visual_surface: observation.visual_surface.clone(),
};
view.storage.sort();
if let Some(state) = state {
if view.route.is_none() && !state.route.is_empty() {
view.route = Some(state.route.clone());
}
if view.a11y_digest.is_none() {
view.a11y_digest.clone_from(&state.a11y_digest);
}
let mut component = Vec::new();
if let Some(name) = &state.component {
component.push(name.clone());
}
if let Some(modal) = &state.modal {
component.push(format!("modal:{modal}"));
}
if let Some(class) = &state.data_class {
component.push(format!("data:{class}"));
}
if !component.is_empty() {
view.component = Some(component.join("|"));
}
}
view
}
}
fn network_event_identity(event: &str) -> String {
let mut fields = event.splitn(3, ' ');
let method = fields.next().unwrap_or_default();
let url = fields.next().unwrap_or_default();
let status = fields.next().unwrap_or_default();
let identity = url_identity(url);
if status.is_empty() {
format!("{method} {identity}").trim().to_owned()
} else {
format!("{method} {identity} {status}")
}
}
fn url_identity(url: &str) -> &str {
let Some((_, after_scheme)) = url.split_once("://") else {
return url;
};
after_scheme
.find('/')
.map_or("/", |index| &after_scheme[index..])
}
#[must_use]
pub fn behavior_delta(base: &StructuredView, head: &StructuredView) -> BehaviorDelta {
let mut changed = Vec::new();
push_opt(
&mut changed,
DiffAxis::Route,
base.route.as_ref(),
head.route.as_ref(),
);
push_opt(
&mut changed,
DiffAxis::A11y,
base.a11y_digest.as_ref(),
head.a11y_digest.as_ref(),
);
push_opt(
&mut changed,
DiffAxis::SemanticText,
base.semantic_text.as_ref(),
head.semantic_text.as_ref(),
);
push_opt(
&mut changed,
DiffAxis::Component,
base.component.as_ref(),
head.component.as_ref(),
);
push_set(
&mut changed,
DiffAxis::Network,
&base.network,
&head.network,
);
push_set(
&mut changed,
DiffAxis::Console,
&base.console,
&head.console,
);
push_set(
&mut changed,
DiffAxis::Storage,
&base.storage,
&head.storage,
);
push_opt(
&mut changed,
DiffAxis::Geometry,
base.viewport.as_ref(),
head.viewport.as_ref(),
);
let first_structured = changed
.iter()
.map(|item| item.axis)
.find(DiffAxis::is_structured);
let mut visual_compared = false;
if first_structured.is_none()
&& let (Some(left), Some(right)) =
(base.visual_digest.as_ref(), head.visual_digest.as_ref())
{
visual_compared = true;
if left != right {
changed.push(AxisDelta {
axis: DiffAxis::VisualDigest,
base: format!(
"{}:{}",
base.visual_surface.as_deref().unwrap_or("screenshot_png"),
left
),
head: format!(
"{}:{}",
head.visual_surface.as_deref().unwrap_or("screenshot_png"),
right
),
});
}
}
BehaviorDelta {
axes: changed,
first_structured,
visual_compared,
}
}
fn push_opt(
changed: &mut Vec<AxisDelta>,
axis: DiffAxis,
base: Option<&String>,
head: Option<&String>,
) {
let left = base.cloned().unwrap_or_default();
let right = head.cloned().unwrap_or_default();
if left != right {
changed.push(AxisDelta {
axis,
base: left,
head: right,
});
}
}
fn push_set(changed: &mut Vec<AxisDelta>, axis: DiffAxis, base: &[String], head: &[String]) {
let left: BTreeSet<&str> = base.iter().map(String::as_str).collect();
let right: BTreeSet<&str> = head.iter().map(String::as_str).collect();
if left != right {
changed.push(AxisDelta {
axis,
base: join_set(&left),
head: join_set(&right),
});
}
}
fn join_set(set: &BTreeSet<&str>) -> String {
set.iter().copied().collect::<Vec<_>>().join(",")
}
pub fn replay_base_head(
program: &TestProgram,
seed: Option<u64>,
base: &mut dyn ReplayHost,
head: &mut dyn ReplayHost,
) -> Result<(Vec<BehaviorState>, Vec<BehaviorState>), ProgramError> {
let base_states = replay_program(program, seed, base)?;
let head_states = replay_program(program, seed, head)?;
Ok((base_states, head_states))
}