use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use wvq_domain::{ContentHash, ObligationId, ProgramId};
use crate::program::{ProgramError, ProgramSource, Target, TestAction, TestProgram};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct BehaviorState {
pub route: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub component: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_phase: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_class: Option<String>,
#[serde(default)]
pub feature_flags: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub a11y_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub viewport: Option<String>,
}
impl BehaviorState {
#[must_use]
pub fn from_observation(observation: &crate::Observation) -> Option<Self> {
let route = observation
.route
.as_deref()
.map(str::trim)
.filter(|route| !route.is_empty())?;
Some(Self {
route: route.to_owned(),
a11y_digest: observation.a11y_digest.clone(),
viewport: observation.viewport.clone(),
..Self::default()
})
}
pub fn canonical_json(&self) -> Result<Vec<u8>, ProgramError> {
serde_json::to_vec(&canonical_value(self))
.map_err(|err| ProgramError::Malformed(err.to_string()))
}
pub fn digest(&self) -> Result<ContentHash, ProgramError> {
let bytes = self.canonical_json()?;
let hex = Sha256::digest(bytes)
.iter()
.fold(String::new(), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
});
ContentHash::new(hex).map_err(|err| ProgramError::Malformed(err.to_string()))
}
}
fn canonical_value(state: &BehaviorState) -> serde_json::Value {
let mut map = serde_json::Map::new();
insert_opt(&mut map, "a11y_digest", state.a11y_digest.as_ref());
insert_opt(&mut map, "actor", state.actor.as_ref());
insert_opt(&mut map, "component", state.component.as_ref());
insert_opt(&mut map, "data_class", state.data_class.as_ref());
map.insert(
"feature_flags".into(),
serde_json::to_value(&state.feature_flags).unwrap_or(serde_json::Value::Null),
);
insert_opt(&mut map, "modal", state.modal.as_ref());
insert_opt(&mut map, "network_phase", state.network_phase.as_ref());
map.insert(
"route".into(),
serde_json::Value::String(state.route.clone()),
);
insert_opt(&mut map, "viewport", state.viewport.as_ref());
serde_json::Value::Object(map)
}
fn insert_opt(
map: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: Option<&String>,
) {
if let Some(text) = value.filter(|item| !item.is_empty()) {
map.insert(key.to_owned(), serde_json::Value::String(text.clone()));
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BehaviorEdge {
pub src: ContentHash,
pub action: TestAction,
pub dst: ContentHash,
}
impl BehaviorEdge {
pub fn identity(&self) -> Result<ContentHash, ProgramError> {
let action = serde_json::to_string(&self.action)
.map_err(|err| ProgramError::Malformed(err.to_string()))?;
let bytes = format!("{}|{action}|{}", self.src, self.dst);
let hex = Sha256::digest(bytes.as_bytes())
.iter()
.fold(String::new(), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
});
ContentHash::new(hex).map_err(|err| ProgramError::Malformed(err.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecordedEvent {
pub action: TestAction,
pub after: BehaviorState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BehaviorTrace {
pub session_id: String,
pub fixture: Option<String>,
pub seed: Option<u64>,
#[serde(default)]
pub data: BTreeMap<String, serde_json::Value>,
pub obligations: Vec<ObligationId>,
pub api_operations: Vec<String>,
pub coverage: Vec<String>,
pub initial: BehaviorState,
pub events: Vec<RecordedEvent>,
}
impl BehaviorTrace {
pub fn state_digests(&self) -> Result<Vec<ContentHash>, ProgramError> {
let mut out = vec![self.initial.digest()?];
for event in &self.events {
out.push(event.after.digest()?);
}
Ok(out)
}
pub fn edges(&self) -> Result<Vec<BehaviorEdge>, ProgramError> {
let mut edges = Vec::new();
let mut src = self.initial.digest()?;
for event in &self.events {
let dst = event.after.digest()?;
edges.push(BehaviorEdge {
src,
action: event.action.clone(),
dst: dst.clone(),
});
src = dst;
}
Ok(edges)
}
}
#[derive(Debug, Clone)]
pub struct Recorder {
session_id: String,
fixture: Option<String>,
seed: Option<u64>,
initial: Option<BehaviorState>,
current: Option<BehaviorState>,
events: Vec<RecordedEvent>,
obligations: BTreeSet<ObligationId>,
api_operations: BTreeSet<String>,
coverage: BTreeSet<String>,
data: BTreeMap<String, serde_json::Value>,
}
impl Recorder {
#[must_use]
pub fn new(session_id: impl Into<String>, fixture: Option<String>, seed: Option<u64>) -> Self {
Self {
session_id: session_id.into(),
fixture,
seed,
initial: None,
current: None,
events: Vec::new(),
obligations: BTreeSet::new(),
api_operations: BTreeSet::new(),
coverage: BTreeSet::new(),
data: BTreeMap::new(),
}
}
pub fn start(&mut self, initial: BehaviorState) {
self.initial = Some(initial.clone());
self.current = Some(initial);
}
pub fn step(&mut self, action: TestAction, after: BehaviorState) -> Result<(), ProgramError> {
action.validate()?;
if self.initial.is_none() {
return Err(ProgramError::Invalid(
"recorder requires start() before step()".into(),
));
}
self.current = Some(after.clone());
self.events.push(RecordedEvent { action, after });
Ok(())
}
pub fn link_obligation(&mut self, id: ObligationId) {
self.obligations.insert(id);
}
pub fn link_api(&mut self, operation: impl Into<String>) {
self.api_operations.insert(operation.into());
}
pub fn link_coverage(&mut self, node: impl Into<String>) {
self.coverage.insert(node.into());
}
pub fn link_fixture(&mut self, name: impl Into<String>, value: serde_json::Value) {
self.data.insert(name.into(), value);
}
pub fn finish(self) -> Result<BehaviorTrace, ProgramError> {
let Some(initial) = self.initial else {
return Err(ProgramError::Invalid(
"recorder has no initial state".into(),
));
};
Ok(BehaviorTrace {
session_id: self.session_id,
fixture: self.fixture,
seed: self.seed,
data: self.data,
obligations: self.obligations.into_iter().collect(),
api_operations: self.api_operations.into_iter().collect(),
coverage: self.coverage.into_iter().collect(),
initial,
events: self.events,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GraphMemory {
pub known_states: BTreeSet<String>,
pub known_edges: BTreeSet<String>,
pub known_obligations: BTreeSet<String>,
pub known_apis: BTreeSet<String>,
pub known_coverage: BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoverageContribution {
pub existing_obligations: Vec<String>,
pub new_obligations: Vec<String>,
pub new_behavior_states: u64,
pub new_behavior_edges: u64,
pub new_api_operations: Vec<String>,
pub new_code_coverage: Vec<String>,
pub redundant_steps: u64,
}
pub fn coverage_contribution(
trace: &BehaviorTrace,
memory: &GraphMemory,
) -> Result<CoverageContribution, ProgramError> {
let mut seen_states = BTreeSet::new();
let mut new_behavior_states = 0_u64;
for digest in trace.state_digests()? {
if seen_states.insert(digest.as_str().to_owned())
&& !memory.known_states.contains(digest.as_str())
{
new_behavior_states = new_behavior_states.saturating_add(1);
}
}
let (existing_obligations, new_obligations) =
split_known(&trace.obligations, &memory.known_obligations);
let mut seen_edges = BTreeSet::new();
let mut new_behavior_edges = 0_u64;
for edge in trace
.edges()?
.into_iter()
.filter(|edge| edge.src != edge.dst)
{
let identity = edge.identity()?;
if seen_edges.insert(identity.to_string())
&& !memory.known_edges.contains(identity.as_str())
{
new_behavior_edges = new_behavior_edges.saturating_add(1);
}
}
let (_, new_api_operations) = split_known_str(&trace.api_operations, &memory.known_apis);
let (_, new_code_coverage) = split_known_str(&trace.coverage, &memory.known_coverage);
Ok(CoverageContribution {
existing_obligations,
new_obligations,
new_behavior_states,
new_behavior_edges,
new_api_operations,
new_code_coverage,
redundant_steps: count_redundant(trace)?,
})
}
fn split_known(ids: &[ObligationId], known: &BTreeSet<String>) -> (Vec<String>, Vec<String>) {
let mut existing = Vec::new();
let mut new = Vec::new();
for id in ids {
if known.contains(id.as_str()) {
existing.push(id.to_string());
} else {
new.push(id.to_string());
}
}
(existing, new)
}
fn split_known_str(ids: &[String], known: &BTreeSet<String>) -> (Vec<String>, Vec<String>) {
let mut existing = Vec::new();
let mut new = Vec::new();
for id in ids {
if known.contains(id) {
existing.push(id.clone());
} else {
new.push(id.clone());
}
}
(existing, new)
}
fn count_redundant(trace: &BehaviorTrace) -> Result<u64, ProgramError> {
let mut prev = trace.initial.digest()?;
let mut redundant = 0_u64;
for event in &trace.events {
let next = event.after.digest()?;
if next == prev {
redundant = redundant.saturating_add(1);
}
prev = next;
}
Ok(redundant)
}
pub fn promote(trace: &BehaviorTrace, program_id: ProgramId) -> Result<TestProgram, ProgramError> {
let declared = trace
.obligations
.iter()
.map(|obligation| obligation.as_str().to_owned())
.collect::<BTreeSet<_>>();
let mut steps = Vec::new();
let mut asserted = BTreeSet::new();
let mut measured = 0_usize;
let mut prev = trace.initial.digest()?;
for event in &trace.events {
let next = event.after.digest()?;
let assertion = match &event.action {
TestAction::Assert { obligation } => Some(obligation.as_str().to_owned()),
_ => None,
};
if let Some(obligation) = assertion {
if !declared.contains(&obligation) {
return Err(ProgramError::Invalid(format!(
"recorded assertion names undeclared obligation `{obligation}`"
)));
}
if asserted.insert(obligation) {
steps.push(event.action.clone());
}
} else if next != prev {
steps.push(event.action.clone());
measured += 1;
}
prev = next;
}
if measured == 0 {
return Err(ProgramError::Invalid(
"promotion candidate has no non-redundant steps".into(),
));
}
for obligation in &trace.obligations {
if asserted.insert(obligation.as_str().to_owned()) {
steps.push(TestAction::Assert {
obligation: obligation.clone(),
});
}
}
let program = TestProgram {
schema_v: 1,
id: program_id,
source: ProgramSource::Recorded,
obligations: trace.obligations.clone(),
preconditions: Vec::new(),
steps,
data: trace.data.clone(),
faults: BTreeMap::new(),
api_operations: BTreeMap::new(),
evidence_policy: crate::program::EvidencePolicy::default(),
deterministic_seed: trace.seed,
};
program.validate()?;
Ok(program)
}
pub trait ReplayHost {
fn apply(&mut self, action: &TestAction) -> Result<BehaviorState, ProgramError>;
}
pub fn replay_program(
program: &TestProgram,
seed: Option<u64>,
host: &mut dyn ReplayHost,
) -> Result<Vec<BehaviorState>, ProgramError> {
program.validate()?;
check_seed(program.deterministic_seed, seed)?;
let mut states = Vec::new();
for step in &program.steps {
states.push(host.apply(step)?);
}
Ok(states)
}
pub fn replay_trace(
trace: &BehaviorTrace,
fixture: Option<&str>,
seed: Option<u64>,
host: &mut dyn ReplayHost,
) -> Result<Vec<BehaviorState>, ProgramError> {
check_seed(trace.seed, seed)?;
match (&trace.fixture, fixture) {
(Some(recorded), Some(wanted)) if recorded != wanted => {
return Err(ProgramError::Invalid(
"replay fixture does not match the recorded session".into(),
));
}
_ => {}
}
let mut states = Vec::new();
for event in &trace.events {
let after = host.apply(&event.action)?;
if after.digest()? != event.after.digest()? {
return Err(ProgramError::Invalid(
"replay diverged from the recorded BehaviorGraph".into(),
));
}
states.push(after);
}
Ok(states)
}
fn check_seed(recorded: Option<u64>, requested: Option<u64>) -> Result<(), ProgramError> {
match (recorded, requested) {
(Some(left), Some(right)) if left != right => Err(ProgramError::Invalid(
"replay seed does not match the recorded session".into(),
)),
_ => Ok(()),
}
}
#[must_use]
pub fn semantic_target(role: &str, name: &str) -> Target {
Target {
role: Some(role.to_owned()),
accessible_name: Some(name.to_owned()),
..Target::default()
}
}