yog 0.0.23

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The §4.10 fan's envelopes: both round-trip, the optional obligation half
//! reads both ways, and a malformed one refuses by name.

use serde_json::json;

use super::p;
use crate::boundary::codec::decode;
use crate::boundary::{Action, Gesture};
use crate::fan::{Obligation, Verb};
use crate::opslog::Origin;
use crate::start::Prepared;

fn prepared(binding: Option<&str>) -> Prepared {
    Prepared {
        workspace: "ws".into(),
        binding: binding.map(p),
        goal: "Ball bl-1f2a: do it".into(),
        origin: Origin::Balls,
        lineage: None,
    }
}

fn obligation(ball: Option<&str>) -> Obligation {
    Obligation {
        project: "dev/proj".into(),
        ball: ball.map(str::to_owned),
    }
}

/// A ball fan and the bare project-repo fan are one envelope with and without
/// its `ball` — absence is a value, and it survives the wire as one. N of one
/// is a lawful fan (the ordinary path), and so is N of none.
pub(super) fn surface() -> Vec<Gesture> {
    let mut out = Vec::new();
    for ball in [Some("bl-1f2a"), None] {
        out.push(Gesture::Act(Action::Fan(Verb::Spread {
            prepared: prepared(Some("/claim")),
            obligation: obligation(ball),
            n: 3,
        })));
        out.push(Gesture::Act(Action::Fan(Verb::Retire {
            obligation: obligation(ball),
            handle: "at-0badcafe".into(),
        })));
        out.push(Gesture::Act(Action::Fan(Verb::Deliver {
            obligation: obligation(ball),
            handle: "at-0badcafe".into(),
            summary: "take the winner — spaces stay".into(),
        })));
    }
    for n in [0, 1] {
        out.push(Gesture::Act(Action::Fan(Verb::Spread {
            prepared: prepared(None),
            obligation: obligation(Some("bl-1f2a")),
            n,
        })));
    }
    out
}

/// A fan with no `ball` field fans the project's own integration branch: the
/// bare obligation says so by saying nothing, as `--body` does.
#[test]
fn a_fan_without_a_ball_field_is_the_bare_obligation() {
    let envelope = json!({"op": "fan", "project": "dev/proj", "n": 2,
                          "prepared": {"workspace": "ws",
                                       "binding": null, "goal": "g", "origin": "balls"}});
    assert_eq!(
        decode(&envelope),
        Ok(Gesture::Act(Action::Fan(Verb::Spread {
            prepared: Prepared {
                goal: "g".into(),
                ..prepared(None)
            },
            obligation: obligation(None),
            n: 2,
        }))),
    );
    assert!(
        !encoded(&envelope).contains("\"ball\""),
        "and it re-encodes without the field it never had"
    );
}

/// Re-encode whatever an envelope decodes to, as text.
fn encoded(envelope: &serde_json::Value) -> String {
    let Ok(gesture) = decode(envelope) else {
        return String::new();
    };
    crate::boundary::codec::encode(&gesture).to_string()
}

#[test]
fn a_malformed_fan_retirement_or_delivery_refuses_with_a_reason() {
    let prepared = json!({"name": "n", "workspace": "/ws", "binding": null,
                          "goal": "g", "origin": "balls"});
    let cases: Vec<(serde_json::Value, &str)> = vec![
        (
            json!({"op": "fan", "project": "/dev/proj", "prepared": prepared}),
            "field \"n\"",
        ),
        (
            json!({"op": "fan", "project": "/dev/proj", "n": 2}),
            "fan: missing prepared",
        ),
        (
            json!({"op": "fan", "n": 2, "prepared": prepared}),
            "field \"project\"",
        ),
        (
            json!({"op": "fan", "project": "/p", "n": 2, "ball": 7, "prepared": prepared}),
            // `str_of`'s wording since bl-7067 folded every optional reader
            // onto one `opt` — the promise was always that it NAMES the field.
            "field \"ball\"",
        ),
        (
            json!({"op": "fan", "project": "/p", "n": -1, "prepared": prepared}),
            "field \"n\"",
        ),
        (
            json!({"op": "retire", "project": "/dev/proj"}),
            "field \"handle\"",
        ),
        (
            json!({"op": "deliver", "project": "/dev/proj", "summary": "s"}),
            "field \"handle\"",
        ),
        (
            json!({"op": "deliver", "project": "/dev/proj", "handle": "at-1"}),
            "field \"summary\"",
        ),
    ];
    for (envelope, needle) in cases {
        let refusal = decode(&envelope).expect_err("must refuse");
        assert!(refusal.contains(needle), "{envelope} refused {refusal:?}");
    }
}