trellis-testing 0.2.0

Companion testing support for Trellis graph invariants.
Documentation
#![cfg(feature = "serde")]

use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};
use trellis_core::{
    DependencyList, Graph, GraphResult, ResourceCommandKind, ResourceCommandTrace, ResourceKey,
    ResourcePlan, ScopeId, Transaction,
};
use trellis_testing::{
    DataTransactionScript, ScenarioError, ScenarioTarget, SerializedScenario, TRACE_FORMAT_VERSION,
    TrellisHarness,
};

const GOLDEN_SCRIPT: &str = r#"{
  "formatVersion": 1,
  "steps": [
    {
      "name": "open",
      "operations": [
        {
          "type": "set",
          "members": [1, 2]
        }
      ]
    },
    {
      "name": "shrink",
      "operations": [
        {
          "type": "set",
          "members": [1]
        }
      ]
    }
  ]
}"#;

const GOLDEN_TRACE: &str = include_str!("fixtures/serialized_trace_v1.json");

#[derive(Clone, Debug, Eq, PartialEq)]
enum Command {
    Open(u8),
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum Operation {
    Set { members: Vec<u8> },
}

struct TestGraph {
    graph: Graph<Command>,
    source: trellis_core::InputNode<BTreeSet<u8>>,
    scope: ScopeId,
}

impl ScenarioTarget<Command> for TestGraph {
    fn graph(&self) -> &Graph<Command> {
        &self.graph
    }

    fn graph_mut(&mut self) -> &mut Graph<Command> {
        &mut self.graph
    }
}

#[test]
fn golden_data_script_replays_after_json_round_trip() {
    let script = DataTransactionScript::<Operation>::from_json(GOLDEN_SCRIPT).unwrap();
    assert_eq!(script.format_version(), TRACE_FORMAT_VERSION);

    let encoded = script.to_json().unwrap();
    let decoded = DataTransactionScript::<Operation>::from_json(&encoded).unwrap();
    let first = replay(&decoded).unwrap();
    let second = replay(&decoded).unwrap();

    first.assert_replay_matches(&second).unwrap();
    first
        .scenario()
        .assert_step_resource_commands(
            "shrink",
            &[command_trace(
                2,
                first.target().scope,
                ResourceCommandKind::Close,
            )],
        )
        .unwrap();
}

#[test]
fn versioned_trace_file_round_trips_to_scenario() {
    let script = DataTransactionScript::<Operation>::from_json(GOLDEN_SCRIPT).unwrap();
    let first = replay(&script).unwrap();
    let trace_file = SerializedScenario::from_scenario(first.scenario());
    let json = trace_file.to_json().unwrap();
    assert_eq!(json, GOLDEN_TRACE.trim_end());

    let decoded = SerializedScenario::from_json(GOLDEN_TRACE).unwrap();
    assert_eq!(decoded.format_version(), TRACE_FORMAT_VERSION);
    decoded.assert_matches_scenario(first.scenario()).unwrap();
    let scenario = decoded.into_scenario().unwrap();

    first.scenario().assert_replay_matches(&scenario).unwrap();
    assert!(json.contains("resource:2"));
}

#[test]
fn resource_key_json_accepts_legacy_strings_and_structured_segments() {
    let legacy: ResourceKey = serde_json::from_str(r#""resource:2""#).unwrap();
    assert_eq!(legacy.segments().collect::<Vec<_>>(), vec!["resource:2"]);
    assert_eq!(serde_json::to_string(&legacy).unwrap(), r#""resource:2""#);

    let structured = ResourceKey::from_segments(["article-feed", "acct/a", "home/main"]);
    let json = serde_json::to_string(&structured).unwrap();
    assert_eq!(json, r#"["article-feed","acct/a","home/main"]"#);
    let decoded: ResourceKey = serde_json::from_str(&json).unwrap();
    assert_eq!(decoded, structured);
}

#[test]
fn unsupported_script_version_is_a_graceful_replay_error() {
    let json = GOLDEN_SCRIPT.replace("\"formatVersion\": 1", "\"formatVersion\": 99");
    let script = DataTransactionScript::<Operation>::from_json(&json).unwrap();
    let error = match replay(&script) {
        Ok(_) => panic!("unsupported script version replayed"),
        Err(error) => error,
    };

    assert!(matches!(
        error,
        ScenarioError::TraceFormatVersionMismatch {
            expected: TRACE_FORMAT_VERSION,
            actual: 99
        }
    ));
}

#[test]
fn unsupported_trace_file_version_is_a_graceful_error() {
    let script = DataTransactionScript::<Operation>::from_json(GOLDEN_SCRIPT).unwrap();
    let first = replay(&script).unwrap();
    let json = SerializedScenario::from_scenario(first.scenario())
        .to_json()
        .unwrap()
        .replace("\"formatVersion\": 1", "\"formatVersion\": 99");
    let trace_file = SerializedScenario::from_json(&json).unwrap();
    let error = match trace_file.into_scenario() {
        Ok(_) => panic!("unsupported trace version loaded"),
        Err(error) => error,
    };

    assert!(matches!(
        error,
        ScenarioError::TraceFormatVersionMismatch {
            expected: TRACE_FORMAT_VERSION,
            actual: 99
        }
    ));
}

fn replay(
    script: &DataTransactionScript<Operation>,
) -> Result<TrellisHarness<TestGraph, Command>, ScenarioError> {
    let seed = build_target();
    let source = seed.source;
    drop(seed);
    TrellisHarness::replay_data(build_target, script, move |operation, tx| {
        apply_operation(source, operation, tx)
    })
}

fn apply_operation(
    source: trellis_core::InputNode<BTreeSet<u8>>,
    operation: &Operation,
    tx: &mut Transaction<'_, Command>,
) -> GraphResult<()> {
    match operation {
        Operation::Set { members } => tx.set_input(source, members.iter().copied().collect()),
    }
}

fn build_target() -> TestGraph {
    let mut graph = Graph::<Command>::new_with_command_type();
    let mut tx = graph.begin_transaction().unwrap();
    let scope = tx.create_scope("scope").unwrap();
    let source = tx.input::<BTreeSet<u8>>("source").unwrap();
    tx.set_input(source, BTreeSet::new()).unwrap();
    let collection = tx
        .set_collection(
            "demand",
            DependencyList::new([source.id()]).unwrap(),
            move |ctx| Ok(ctx.input(source)?.clone()),
        )
        .unwrap();
    tx.set_resource_planner(collection, scope, move |ctx| {
        let mut plan = ResourcePlan::new();
        for added in &ctx.diff().added {
            plan.open(key(added.value), ctx.scope(), Command::Open(added.value));
        }
        for removed in &ctx.diff().removed {
            plan.close(key(removed.value), ctx.scope());
        }
        Ok(plan)
    })
    .unwrap();
    tx.commit().unwrap();
    drop(tx);

    TestGraph {
        graph,
        source,
        scope,
    }
}

fn command_trace(value: u8, scope: ScopeId, kind: ResourceCommandKind) -> ResourceCommandTrace {
    ResourceCommandTrace {
        key: key(value),
        scope,
        kind,
    }
}

fn key(value: u8) -> ResourceKey {
    ResourceKey::new(format!("resource:{value}"))
}