yog 0.0.15

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The per-seat tables: what verbs each seat fires, in roster order. The
//! universally-quantified half — the sole-carrier sweep over every seat — is
//! [`doctrine`]. Split from [`super`] per §12's 300-line budget.

use super::*;

/// The §11 doctrine swept over the whole seat space, one budget below this one.
mod doctrine;

/// Every join state the ball-row seat can be opened on — the §3.5 table's seven
/// rows, listed so a new one has to be added here to compile.
pub(super) const JOIN_STATES: [JoinState; 7] = [
    JoinState::ReadyStartable,
    JoinState::Blocked,
    JoinState::Bound,
    JoinState::ClaimedElsewhere,
    JoinState::Delivered,
    JoinState::UnassignedWorkspace,
    JoinState::OrphanedProject,
];

/// The verbs a seat fires, in render order — the shape every per-seat table
/// below asserts on.
fn verbs(seat: Seat) -> Vec<Verb> {
    entries(seat).into_iter().map(|e| e.verb).collect()
}

#[test]
fn a_named_pinned_tab_carries_delete_then_unpin() {
    let seat = Seat::WorkspaceTab {
        named: true,
        pinned: true,
    };
    assert_eq!(verbs(seat.clone()), [Verb::DeleteWorkspace, Verb::Unpin]);
    assert_eq!(entries(seat)[0].label, "delete this workspace…");
}

#[test]
fn a_foreign_tab_offers_no_delete_and_an_unpinned_one_no_unpin() {
    // §3.6 scope: foreign/replay workspaces are litany's territory.
    assert_eq!(
        verbs(Seat::WorkspaceTab {
            named: false,
            pinned: true,
        }),
        [Verb::Unpin]
    );
    assert_eq!(
        verbs(Seat::WorkspaceTab {
            named: true,
            pinned: false,
        }),
        [Verb::DeleteWorkspace]
    );
}

#[test]
fn a_foreign_unpinned_tab_has_no_menu_at_all() {
    assert!(
        entries(Seat::WorkspaceTab {
            named: false,
            pinned: false,
        })
        .is_empty()
    );
}

#[test]
fn a_live_conversation_with_children_carries_stop_both_ways_then_flush() {
    let seat = Seat::ConversationRow {
        stoppable: true,
        has_children: true,
        named: false,
    };
    assert_eq!(
        verbs(seat.clone()),
        [
            Verb::Stop { children: false },
            Verb::Stop { children: true },
            Verb::Flush,
        ]
    );
    assert_eq!(entries(seat)[1].label, "stop + children");
}

#[test]
fn a_lone_live_conversation_offers_no_children_cascade() {
    assert_eq!(
        verbs(Seat::ConversationRow {
            stoppable: true,
            has_children: false,
            named: false,
        }),
        [Verb::Stop { children: false }, Verb::Flush]
    );
}

#[test]
fn a_settled_conversation_still_carries_flush() {
    // Flush is workspace-scoped and unconditional (§8.2 Scan has no predicate),
    // so a conversation row always has a menu — even with nothing to stop.
    for has_children in [false, true] {
        assert_eq!(
            verbs(Seat::ConversationRow {
                stoppable: false,
                has_children,
                named: false,
            }),
            [Verb::Flush]
        );
    }
}

#[test]
fn a_named_workspaces_conversation_carries_delete_last_and_a_foreign_ones_none() {
    // §3.6 scope, the workspace tab's own rule: yog offers no delete inside a
    // workspace it did not place. On a named one the entry is the danger-zone
    // tail — after every recoverable verb, opening the dialog and never past it.
    let seat = Seat::ConversationRow {
        stoppable: true,
        has_children: true,
        named: true,
    };
    assert_eq!(
        verbs(seat.clone()),
        [
            Verb::Stop { children: false },
            Verb::Stop { children: true },
            Verb::Flush,
            Verb::DeleteAgent,
        ]
    );
    assert_eq!(entries(seat)[3].label, "delete this conversation…");
    assert_eq!(
        verbs(Seat::ConversationRow {
            stoppable: false,
            has_children: false,
            named: true,
        }),
        [Verb::Flush, Verb::DeleteAgent]
    );
}

#[test]
fn a_ready_ball_offers_only_assign_and_only_with_a_focused_workspace() {
    assert_eq!(
        verbs(Seat::BallRow {
            state: JoinState::ReadyStartable,
            assign_to: Some("alba-koi".to_owned()),
        }),
        [Verb::Assign("alba-koi".to_owned())],
        "unclaimed ⇒ nothing to release or close"
    );
    assert!(
        entries(Seat::BallRow {
            state: JoinState::ReadyStartable,
            assign_to: None,
        })
        .is_empty(),
        "no focused workspace ⇒ no destination ⇒ no menu at all"
    );
}

#[test]
fn a_bound_ball_carries_release_close_in_roster_order() {
    let seat = Seat::BallRow {
        state: JoinState::Bound,
        assign_to: Some("alba-koi".to_owned()),
    };
    assert_eq!(
        verbs(seat.clone()),
        [Verb::Release, Verb::CloseBall],
        "bound ⇒ no assign"
    );
    let rows = entries(seat);
    assert_eq!(rows[0].label, "release");
    assert_eq!(rows[1].label, "close");
}

#[test]
fn a_bound_ball_needs_no_focused_workspace_for_its_own_verbs() {
    assert_eq!(
        verbs(Seat::BallRow {
            state: JoinState::Bound,
            assign_to: None,
        }),
        [Verb::Release, Verb::CloseBall],
        "release and close are the ball's own, not the focus's"
    );
}

#[test]
fn an_unactionable_ball_row_has_no_menu_at_all() {
    // Blocked / claimed-elsewhere / delivered / unassigned / orphaned: every §8.2
    // predicate refuses, so the roster paints nothing rather than a dead popup.
    for state in JOIN_STATES {
        if matches!(state, JoinState::ReadyStartable | JoinState::Bound) {
            continue;
        }
        assert!(
            entries(Seat::BallRow {
                state,
                assign_to: Some("alba-koi".to_owned()),
            })
            .is_empty(),
            "{state:?} carries no ball verb"
        );
    }
}