yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Table tests for the conversation-list view-model (§11, §15 Z9).

use super::*;

fn agent(id: &str, state: AgentState, ts: i64) -> Agent {
    Agent {
        branch_name: format!("agents/{id}"),
        agent_id: id.to_string(),
        tip_oid: "a".repeat(40),
        tip_short_oid: "aaaaaaaa".into(),
        tip_timestamp_unix: ts,
        steps: vec![],
        preview: None,
        streaming_text: None,
        tool_calls: vec![],
        state,
        state_uncertain: false,
        pending_messages: 0,
        conflicted_oid: None,
        budget_oid: None,
        abandoned_oid: None,
        notify_oid: None,
        goal_ball: None,
    }
}

/// The all-unseen closure: every watermark reads unacknowledged.
fn unseen(_: SeenKind, _: &str, _: &str, _: &str) -> bool {
    false
}

/// A ball resolver that knows nothing: any stamped id renders from source 1 only
/// (no join facts). Never actually invoked by the goal_ball-`None` fixtures.
fn plain(id: &str) -> ConvBall {
    ConvBall {
        id: id.to_owned(),
        state: None,
        title: None,
        badge: None,
    }
}

#[test]
fn one_row_per_root_with_subtree_member_counts() {
    // Two conversations: `r1` with one child, `r2` alone. Quiescent so no
    // attention muddies the count.
    let agents = [
        agent("r1", AgentState::Quiescent, 10),
        agent("r1-c1", AgentState::Quiescent, 20),
        agent("r2", AgentState::Quiescent, 30),
    ];
    let rows = build(&agents, "/ws", &unseen, 100, &plain);
    assert_eq!(rows.len(), 2);
    // Recency: r2 (30) leads r1 (20).
    assert_eq!(rows[0].root_id, "r2");
    assert!(!rows[0].has_children());
    assert_eq!(rows[1].root_id, "r1");
    assert_eq!(rows[1].members, 2);
    assert!(rows[1].has_children());
    assert_eq!(rows[1].age_secs, 80, "age from the subtree's latest tip");
}

#[test]
fn sort_is_attention_then_running_then_recency() {
    let agents = [
        agent("idle-new", AgentState::Quiescent, 90),
        agent("idle-old", AgentState::Quiescent, 10),
        agent("running", AgentState::Live, 5),
        // An unseen stop is attention (§6 rule 2) — leads everything.
        agent("dead", AgentState::Stopped, 1),
    ];
    let rows = build(&agents, "/ws", &unseen, 100, &plain);
    let ids: Vec<&str> = rows.iter().map(|r| r.root_id.as_str()).collect();
    assert_eq!(ids, ["dead", "running", "idle-new", "idle-old"]);
    assert_eq!(rows[0].attention, 1);
    assert_eq!(rows[1].attention, 0);
}

#[test]
fn subtree_state_aggregates_in_flight_over_live_over_root() {
    // A quiescent root with a streaming child reads InFlight (and pulses).
    let streaming = [
        agent("r", AgentState::Quiescent, 1),
        agent("r-c", AgentState::InFlight, 2),
    ];
    let row = &build(&streaming, "/ws", &unseen, 10, &plain)[0];
    assert_eq!(row.state, AgentState::InFlight);
    assert!(row.streaming);
    // A live child (no stream) reads Live, no pulse.
    let live = [
        agent("r", AgentState::Quiescent, 1),
        agent("r-c", AgentState::Live, 2),
    ];
    let row = &build(&live, "/ws", &unseen, 10, &plain)[0];
    assert_eq!(row.state, AgentState::Live);
    assert!(!row.streaming);
    // All settled: the root's own state decides, its uncertainty carried.
    let mut root = agent("r", AgentState::Stopped, 1);
    root.state_uncertain = true;
    let settled = [root, agent("r-c", AgentState::Quiescent, 2)];
    let row = &build(&settled, "/ws", &unseen, 10, &plain)[0];
    assert_eq!(row.state, AgentState::Stopped);
    assert!(row.uncertain);
}

#[test]
fn deciding_agents_uncertainty_is_carried() {
    let mut child = agent("r-c", AgentState::InFlight, 2);
    child.state_uncertain = true;
    let agents = [agent("r", AgentState::Quiescent, 1), child];
    assert!(build(&agents, "/ws", &unseen, 10, &plain)[0].uncertain);
}

#[test]
fn preview_is_the_roots_first_line_capped_with_streaming_fallback() {
    let mut a = agent("r", AgentState::Quiescent, 1);
    a.preview = Some("first line\nsecond line".into());
    assert_eq!(
        build(&[a], "/ws", &unseen, 10, &plain)[0].preview,
        "first line"
    );
    let mut b = agent("r", AgentState::InFlight, 1);
    b.streaming_text = Some("streamed so far".into());
    assert_eq!(
        build(&[b], "/ws", &unseen, 10, &plain)[0].preview,
        "streamed so far",
        "streaming text backfills a missing request preview"
    );
    let mut c = agent("r", AgentState::Quiescent, 1);
    c.preview = Some("x".repeat(200));
    assert_eq!(
        build(&[c], "/ws", &unseen, 10, &plain)[0]
            .preview
            .chars()
            .count(),
        80,
        "capped at the preview cap"
    );
    let d = agent("r", AgentState::Quiescent, 1);
    assert_eq!(build(&[d], "/ws", &unseen, 10, &plain)[0].preview, "");
}

#[test]
fn the_ball_badge_is_the_roots_goal_stamp_coloured_by_the_join() {
    use crate::projects::join::JoinState;
    // The resolver is the §3.5 join: the stamped id `b1` is Bound with a title.
    let resolve = |id: &str| ConvBall {
        id: id.to_owned(),
        state: Some(JoinState::Bound),
        title: Some("Do the thing".to_owned()),
        badge: None,
    };
    // A root carrying the goal stamp; its quiescent child stamps nothing (only the
    // root's goal.md is a yog ball attribution).
    let mut root = agent("r", AgentState::Quiescent, 2);
    root.goal_ball = Some("b1".to_owned());
    let child = agent("r-c", AgentState::Quiescent, 1);
    let rows = build(&[root, child], "/ws", &unseen, 10, &resolve);
    let ball = rows[0].ball.as_ref().expect("the root's stamped ball");
    assert_eq!(ball.id, "b1");
    assert_eq!(ball.state, Some(JoinState::Bound));
    assert_eq!(ball.title.as_deref(), Some("Do the thing"));
    // A conversation with no stamp carries no ball.
    let bare = agent("bare", AgentState::Quiescent, 1);
    assert!(build(&[bare], "/ws", &unseen, 10, &plain)[0].ball.is_none());
}

#[test]
fn age_clamps_clock_skew_to_zero() {
    let a = agent("r", AgentState::Quiescent, 100);
    assert_eq!(build(&[a], "/ws", &unseen, 50, &plain)[0].age_secs, 0);
}

#[test]
fn age_label_buckets() {
    assert_eq!(age_label(-5), "0s");
    assert_eq!(age_label(42), "42s");
    assert_eq!(age_label(420), "7m");
    assert_eq!(age_label(7200), "2h");
    assert_eq!(age_label(200_000), "2d");
}

#[test]
fn members_returns_the_subtree_in_descent_order() {
    let agents = [
        agent("r1", AgentState::Quiescent, 1),
        agent("r1-b", AgentState::Quiescent, 2),
        agent("r1-a", AgentState::Quiescent, 3),
        agent("r2", AgentState::Quiescent, 4),
    ];
    let rows = members(&agents, "r1");
    let ids: Vec<&str> = rows
        .iter()
        .filter_map(|r| agents.get(r.index))
        .map(|a| a.agent_id.as_str())
        .collect();
    assert_eq!(
        ids,
        ["r1", "r1-a", "r1-b"],
        "root first, children id-sorted"
    );
    assert_eq!(rows[0].depth, 0);
    assert_eq!(rows[1].depth, 1);
    assert!(members(&agents, "r1-a").is_empty(), "a child is not a root");
    assert!(members(&agents, "ghost").is_empty());
}

#[test]
fn root_of_resolves_any_member_to_its_conversation_root() {
    let agents = [
        agent("r1", AgentState::Quiescent, 1),
        agent("r1-a", AgentState::Quiescent, 2),
        agent("r2", AgentState::Quiescent, 3),
    ];
    assert_eq!(root_of(&agents, "r1").as_deref(), Some("r1"));
    assert_eq!(root_of(&agents, "r1-a").as_deref(), Some("r1"));
    assert_eq!(root_of(&agents, "r2").as_deref(), Some("r2"));
    assert_eq!(root_of(&agents, "ghost"), None);
    assert_eq!(root_of(&[], "r1"), None);
}