yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Roster / attention-strip / seen-acknowledgement tests, and the M2
//! convergence milestone (§6, §11, §15 Y11).

use super::Harness;
use crate::keymap::InspectorTab;
use crate::nav::ws_key;
use crate::ui_state::SeenKind;

#[test]
fn strip_total_counts_attention_bearing_agents() {
    let h = Harness::new();
    let (_c, model) = h.model();
    assert_eq!(model.strip_total(), 1, "the one unseen stop");
}

#[test]
fn focused_agent_resolves_the_selected_row_else_none() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    // No agent selected → None; a focused workspace alone is not enough.
    model.focus_workspace(&h.ws);
    assert!(model.focused_agent().is_none());
    // Selecting the agent resolves its snapshot row (the inspector target).
    model.focus_agent(&h.ws, "c-1");
    assert_eq!(
        model.focused_agent().map(|a| a.agent_id.as_str()),
        Some("c-1")
    );
    // A selected id absent from the tree resolves to None.
    model.focus_agent(&h.ws, "ghost");
    assert!(model.focused_agent().is_none());
    // A focused workspace with no snapshot (unfetched) resolves to None even
    // with an agent selected — the tree-absent arm.
    model.focus_agent(std::path::Path::new("/no/such/ws"), "x");
    assert!(model.focused_agent().is_none());
}

#[test]
fn the_tab_bar_and_conversation_list_carry_the_derived_facts() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    // The ad-hoc workspace is Foreign → an overflow entry with its attention.
    let bar = model.tab_bar();
    assert!(bar.tabs.is_empty(), "no named workspaces, no wall tabs");
    assert_eq!(bar.overflow.len(), 1);
    let entry = &bar.overflow[0];
    assert_eq!(entry.name, "ws");
    assert_eq!(entry.attention, 1);
    assert!(!entry.replay);
    // The focused workspace's conversation list: one row per root agent (§11),
    // its unseen stop counted as attention.
    model.focus_workspace(&h.ws);
    let rows = model.conversations(10);
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].root_id, "c-1");
    assert_eq!(rows[0].attention, 1);
    assert_eq!(rows[0].members, 1);
    // No focus → the empty list (the general empty path, not a special case).
    model.focus_workspace(std::path::Path::new("/no/such"));
    assert!(model.conversations(10).is_empty(), "unfetched ws: no rows");
}

#[test]
fn focus_workspace_selects_no_agent_and_records_nothing() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    model.focus_workspace(&h.ws);
    assert_eq!(model.focused_workspace(), Some(h.ws.as_path()));
    assert!(model.focus().agent.is_none());
    assert_eq!(model.strip_total(), 1, "focusing a workspace acks nothing");
}

#[test]
fn focus_agent_acknowledges_the_stop_watermark() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    let tip = model.tree(&h.ws).unwrap().agents[0].tip_oid.clone();
    let key = ws_key(&h.ws);
    assert!(!model.is_seen(SeenKind::Stopped, &key, "c-1", &tip));
    model.focus_agent(&h.ws, "c-1");
    assert_eq!(model.focus().agent.as_deref(), Some("c-1"));
    assert!(
        model.is_seen(SeenKind::Stopped, &key, "c-1", &tip),
        "recorded on focus"
    );
    assert_eq!(
        model.strip_total(),
        0,
        "the acked stop no longer draws attention"
    );
}

#[test]
fn focus_agent_on_a_missing_agent_is_a_no_op() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    model.focus_agent(&h.ws, "nope");
    assert_eq!(
        model.strip_total(),
        1,
        "no watermark recorded for a phantom"
    );
}

#[test]
fn jump_to_next_attention_focuses_and_acknowledges_it() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    // Startup focus is the workspace (no agent), so the jump starts from the
    // front and lands on the attention-bearing agent, acknowledging it.
    model.jump_next_attention();
    assert_eq!(model.focus().agent.as_deref(), Some("c-1"));
    assert_eq!(model.strip_total(), 0, "jumping acknowledges");
    // A second jump, now focused with an agent, finds nothing → no change.
    model.jump_next_attention();
    assert_eq!(model.focus().agent.as_deref(), Some("c-1"));
}

#[test]
fn roster_step_moves_focus_through_agents_and_acknowledges() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    // Startup: the workspace is focused with no agent, so the unseen stop draws
    // attention. ↓ steps onto the first (only) roster entry via focus_agent.
    assert_eq!(model.strip_total(), 1);
    model.roster_step(1);
    assert_eq!(model.focus().agent.as_deref(), Some("c-1"));
    assert_eq!(
        model.strip_total(),
        0,
        "stepping onto the agent acknowledges"
    );
    // ↑ wraps back to the same sole entry.
    model.roster_step(-1);
    assert_eq!(model.focus().agent.as_deref(), Some("c-1"));
}

#[test]
fn inspector_tab_selection_is_sticky_across_focus_changes() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    assert_eq!(
        model.inspector_tab(),
        InspectorTab::Transcript,
        "Transcript is the default tab"
    );
    model.select_tab(InspectorTab::Steps);
    assert_eq!(model.inspector_tab(), InspectorTab::Steps);
    // The selection survives both a workspace focus and an agent focus (§11).
    model.focus_workspace(&h.ws);
    assert_eq!(model.inspector_tab(), InspectorTab::Steps);
    model.focus_agent(&h.ws, "c-1");
    assert_eq!(
        model.inspector_tab(),
        InspectorTab::Steps,
        "sticky through the acknowledgement gesture"
    );
}

#[test]
fn toggle_pin_hoists_and_unhoists_the_workspace_tab() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    let key = ws_key(&h.ws);
    // The foreign workspace starts in the overflow; pinning hoists it (§11).
    assert!(model.tab_bar().tabs.is_empty());
    model.toggle_pin(&key);
    let bar = model.tab_bar();
    assert_eq!(bar.tabs.len(), 1);
    assert!(bar.tabs[0].pinned);
    assert!(bar.overflow.is_empty(), "hoisted out of the menu");
    model.toggle_pin(&key);
    assert!(model.tab_bar().tabs.is_empty(), "unpinned again");
    assert_eq!(model.tab_bar().overflow.len(), 1);
}

#[test]
fn set_collapsed_overrides_a_section() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    // The balls section's persisted fold (§4.1/§11): default open, override
    // sticks through the ui.json write path.
    assert!(!model.is_collapsed("balls"));
    model.set_collapsed("balls", true);
    assert!(model.is_collapsed("balls"));
    model.set_collapsed("balls", false);
    assert!(!model.is_collapsed("balls"));
}

/// **The Z9 proof on the live fixture shape** (§6/§11): a conversation whose
/// latest step failed on auth (the stench-pug `kind:auth` response) classifies
/// Stopped, stirs the strip while unseen, and keys the Login affordance;
/// acknowledging clears the *signal*, never the settled failure the badge and
/// banner keep rendering.
#[test]
fn an_auth_failed_conversation_stirs_the_strip_and_flags_login() {
    let h = Harness::new();
    h.write_response(
        "c-1",
        b"{\"type\":\"error\",\"kind\":\"auth\",\"message\":\"no credential for this provider: run `bz --login --provider <id>`\",\"provider_detail\":null}\n{\"type\":\"end\"}\n",
    );
    let (_c, mut model) = h.model();
    assert_eq!(
        model.strip_total(),
        1,
        "the dead conversation stirs (§6 rule 2)"
    );
    model.focus_workspace(&h.ws);
    let rows = model.conversations(10);
    assert_eq!(rows[0].state, crate::git_tree::AgentState::Stopped);
    assert_eq!(rows[0].attention, 1);
    assert!(
        crate::login::auth::latest_step_auth_failed(
            &h.ws,
            "c-1",
            crate::git_tree::AgentState::Stopped
        ),
        "the center's Login banner keys on the same response"
    );
    // Acknowledge (§6): the signal clears; the settled failure still renders.
    model.focus_agent(&h.ws, "c-1");
    assert_eq!(model.strip_total(), 0, "acknowledged");
    let rows = model.conversations(10);
    assert_eq!(rows[0].state, crate::git_tree::AgentState::Stopped);
    assert!(crate::login::auth::latest_step_auth_failed(
        &h.ws,
        "c-1",
        crate::git_tree::AgentState::Stopped
    ));
}

#[test]
fn conversation_members_returns_the_focused_conversations_subtree() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    assert!(model.conversation_members().is_empty(), "no agent focused");
    model.focus_agent(&h.ws, "c-1");
    assert_eq!(model.conversation_members().len(), 1, "the lone root");
    model.focus_agent(&h.ws, "ghost");
    assert!(
        model.conversation_members().is_empty(),
        "an id absent from the tree has no conversation root"
    );
    model.focus_agent(std::path::Path::new("/no/such"), "c-1");
    assert!(model.conversation_members().is_empty(), "no snapshot");
}

#[test]
fn flush_ui_writes_only_when_dirty() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    model.toggle_pin("k");
    assert!(model.flush_ui().unwrap(), "a pending change is written");
    assert!(!model.flush_ui().unwrap(), "nothing pending → no write");
}

/// **M2 milestone (§15 Y11):** two instances against one fixture set converge
/// on the seen watermark through `ui.json` — instance A's acknowledgement,
/// written to the file, is adopted by instance B via the fs-event path, so B's
/// attention for that agent clears. No windows; fakes + tempdirs only.
#[test]
fn m2_two_instances_converge_on_seen_through_ui_json() {
    let h = Harness::new();
    let (_ca, mut a) = h.model();
    let (_cb, mut b) = h.model();
    assert_eq!(a.strip_total(), 1, "both see the unseen stop");
    assert_eq!(b.strip_total(), 1);

    // A acknowledges by focusing the stopped agent, and flushes ui.json.
    a.focus_agent(&h.ws, "c-1");
    a.flush_ui().unwrap();
    assert_eq!(a.strip_total(), 0, "A's own view clears");
    assert_eq!(b.strip_total(), 1, "B has not adopted yet");

    // B's yog-state watcher fires: B adopts A's ui.json wholesale.
    b.dirty_handle().mark_all([h.roots.yog_state.clone()]);
    b.tick();
    assert_eq!(b.strip_total(), 0, "B converged on the acknowledgement");
    let tip = b.tree(&h.ws).unwrap().agents[0].tip_oid.clone();
    let key = ws_key(&h.ws);
    assert!(
        b.is_seen(SeenKind::Stopped, &key, "c-1", &tip),
        "the concrete watermark converged, not just the count",
    );
}