trellis-testing 0.1.1

Companion testing support for Trellis graph invariants.
Documentation
use std::collections::BTreeSet;

use trellis_core::{
    DependencyList, Graph, HostResourceOutcome, InputNode, ResourceCommandKind,
    ResourceCommandTrace, ResourceKey, ResourcePlan, ResourceTransitionPolicy, Revision, ScopeId,
};
use trellis_testing::{
    FakeHost, HostStatusClass, HostStatusEvent, ResourceLedger, ResourceLedgerError,
};

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

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

fn members(values: &[u8]) -> BTreeSet<u8> {
    values.iter().copied().collect()
}

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

fn build_graph(initial: BTreeSet<u8>) -> (TestGraph, trellis_core::TransactionResult<Command>) {
    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();
    let status = tx.input::<HostStatusEvent>("resource-status").unwrap();
    tx.set_input(source, initial).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();
    let result = tx.commit().unwrap();
    drop(tx);

    (
        TestGraph {
            graph,
            source,
            status,
            scope,
        },
        result,
    )
}

fn feed_host_status(
    target: &mut TestGraph,
    status: HostStatusEvent,
) -> trellis_core::TransactionResult<Command> {
    let mut tx = target.graph.begin_transaction().unwrap();
    tx.set_input(target.status, status.clone()).unwrap();
    let result = tx.commit().unwrap();
    drop(tx);
    assert_eq!(
        target.graph.input_value(target.status).unwrap(),
        Some(&status)
    );
    result
}

fn set_source(
    target: &mut TestGraph,
    values: BTreeSet<u8>,
) -> trellis_core::TransactionResult<Command> {
    let mut tx = target.graph.begin_transaction().unwrap();
    tx.set_input(target.source, values).unwrap();
    let result = tx.commit().unwrap();
    drop(tx);
    target.graph.assert_incremental_equals_full().unwrap();
    result
}

#[test]
fn resource_ledger_detects_lifecycle_and_status_classes() {
    let (mut target, initial) = build_graph(members(&[1, 2]));
    let broad_key = ResourceKey::wildcard("all-devices");
    assert_eq!(broad_key.as_str(), "wildcard:all-devices");

    let mut forbidden = ResourceLedger::new();
    forbidden.mark_forbidden_unless_explicit(key(1));
    forbidden.apply_result(&initial);
    let error = forbidden.assert_no_wildcard_resource_opened().unwrap_err();
    assert!(matches!(
        error,
        ResourceLedgerError::ForbiddenOpen {
            key: failed_key,
            context: Some(context),
        } if failed_key == key(1)
            && context.scope == target.scope
            && context.transaction_id == initial.transaction_id
            && context.revision == initial.revision
    ));

    let mut ledger = ResourceLedger::new();
    ledger.mark_forbidden_unless_explicit(broad_key);
    ledger.apply_result(&initial);
    ledger.assert_all_resources_have_owner().unwrap();
    ledger.assert_no_wildcard_resource_opened().unwrap();
    ledger.assert_resource_opened_once(&key(1)).unwrap();
    let first = ledger.snapshot(&key(1)).unwrap();
    assert!(first.is_open);
    assert_eq!(first.last_transaction_id, initial.transaction_id);
    assert_eq!(first.command_revision, initial.revision);
    assert_eq!(first.current_command.as_ref(), Some(&Command::Open(1)));

    let shrink = set_source(&mut target, members(&[1]));
    ledger.apply_result(&shrink);
    ledger.assert_resource_not_open(&key(2)).unwrap();
    ledger.assert_resource_closed_once(&key(2)).unwrap();
    ledger.assert_resource_generation(&key(2), 2).unwrap();
    ledger.assert_no_duplicate_close().unwrap();
    ledger
        .assert_command_order(&[
            ResourceCommandTrace {
                key: key(1),
                scope: target.scope,
                kind: ResourceCommandKind::Open,
                transition: ResourceTransitionPolicy::Open,
            },
            ResourceCommandTrace {
                key: key(2),
                scope: target.scope,
                kind: ResourceCommandKind::Open,
                transition: ResourceTransitionPolicy::Open,
            },
            ResourceCommandTrace {
                key: key(2),
                scope: target.scope,
                kind: ResourceCommandKind::Close,
                transition: ResourceTransitionPolicy::Close,
            },
        ])
        .unwrap();

    let status = HostStatusEvent {
        resource_key: key(1),
        scope: target.scope,
        command_revision: Revision::new(0),
        status_revision: Revision::new(100),
        status: HostResourceOutcome::Open,
    };
    assert_eq!(ledger.classify_status(status), HostStatusClass::Stale);
    ledger
        .assert_status_is_stale(&key(1), Revision::new(0))
        .unwrap();

    let current = HostStatusEvent {
        resource_key: key(1),
        scope: target.scope,
        command_revision: initial.revision,
        status_revision: Revision::new(101),
        status: HostResourceOutcome::Open,
    };
    assert_eq!(
        ledger.classify_status(current.clone()),
        HostStatusClass::Current
    );
    assert_eq!(ledger.classify_status(current), HostStatusClass::Duplicate);

    let failed = HostStatusEvent {
        resource_key: key(1),
        scope: target.scope,
        command_revision: initial.revision,
        status_revision: Revision::new(102),
        status: HostResourceOutcome::Failed("host failed".to_owned()),
    };
    assert_eq!(ledger.classify_status(failed), HostStatusClass::Current);

    let future = HostStatusEvent {
        resource_key: key(1),
        scope: target.scope,
        command_revision: Revision::new(10),
        status_revision: Revision::new(103),
        status: HostResourceOutcome::Open,
    };
    assert_eq!(ledger.classify_status(future), HostStatusClass::Future);

    let closed_ack = HostStatusEvent {
        resource_key: key(2),
        scope: target.scope,
        command_revision: shrink.revision,
        status_revision: Revision::new(104),
        status: HostResourceOutcome::Closed,
    };
    assert_eq!(ledger.classify_status(closed_ack), HostStatusClass::Current);

    let mut host = FakeHost::new();
    let host_result = set_source(&mut target, members(&[1, 3]));
    let events = host.apply_result(&mut ledger, &host_result);
    assert_eq!(events.len(), 1);
    assert_eq!(events[0].class, HostStatusClass::Current);
    let status_result = feed_host_status(&mut target, events[0].clone().into_status());
    assert_eq!(status_result.changed_inputs, vec![target.status.id()]);
    assert_eq!(
        host.open_failed(
            &mut ledger,
            key(3),
            target.scope,
            host_result.revision,
            "host failed"
        )
        .class,
        HostStatusClass::Current
    );
    let recovered =
        host.open_succeeds_later(&mut ledger, key(3), target.scope, host_result.revision);
    assert_eq!(recovered.class, HostStatusClass::Current);
    assert_eq!(
        host.duplicate_status(&mut ledger, &recovered).class,
        HostStatusClass::Duplicate
    );

    let mut tx = target.graph.begin_transaction().unwrap();
    tx.close_scope(target.scope).unwrap();
    let closed = tx.commit().unwrap();
    drop(tx);
    ledger.apply_result(&closed);
    assert_eq!(
        host.open_succeeds_later(&mut ledger, key(1), target.scope, initial.revision)
            .class,
        HostStatusClass::Late
    );
    ledger
        .assert_status_did_not_resurrect_closed_scope(target.scope)
        .unwrap();
}

#[test]
fn fake_host_close_status_is_current_for_the_close_command() {
    let (mut target, initial) = build_graph(members(&[9]));
    let mut ledger = ResourceLedger::new();
    let mut host = FakeHost::new();
    let opened = host.apply_result(&mut ledger, &initial);
    assert_eq!(opened.len(), 1);
    assert_eq!(opened[0].class, HostStatusClass::Current);

    let closed = set_source(&mut target, BTreeSet::new());
    let closed_statuses = host.apply_result(&mut ledger, &closed);
    assert_eq!(closed_statuses.len(), 1);
    assert_eq!(closed_statuses[0].status.resource_key, key(9));
    assert_eq!(closed_statuses[0].status.command_revision, closed.revision);
    assert_eq!(
        closed_statuses[0].status.status,
        HostResourceOutcome::Closed
    );
    assert_eq!(closed_statuses[0].class, HostStatusClass::Current);
    ledger.assert_resource_not_open(&key(9)).unwrap();
}