yog 0.0.4

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Tick / sweep / re-derivation / adoption tests (§7.2, §7.3), plus the §6
//! attention facts that ride on a derivation: the held acknowledgement and what
//! a conversation's classified rest does to the strip.

use super::{Harness, agent};
use crate::git_tree::{AgentState, GitTree};
use crate::watch::Mark;
use std::path::{Path, PathBuf};
use std::time::Duration;

fn agent_count(model: &super::Rig, ws: &Path) -> usize {
    model.tree(ws).map_or(0, |t| t.agents.len())
}

/// Drive one dirty workspace root through the 100 ms debounce and re-derive it.
pub(super) fn settle(model: &mut super::Rig, clock: &crate::test_support::FakeClock) {
    clock.advance(Duration::from_millis(10));
    model.tick(); // opens the debounce window
    clock.advance(Duration::from_millis(150));
    model.tick(); // window elapsed → re-derive
}

#[test]
fn a_dirty_workspace_root_re_derives_its_snapshot() {
    let h = Harness::new();
    let (clock, mut model) = h.model();
    assert_eq!(agent_count(&model, &h.ws), 1);
    h.build_more("c-2", "world"); // a second agent lands on disk
    assert_eq!(agent_count(&model, &h.ws), 1, "stale until re-derived");

    model.dirty_handle().mark_all([(h.ws.clone(), Mark::Watch)]);
    clock.advance(Duration::from_millis(10));
    assert!(!model.tick(), "debounced within the window");
    clock.advance(Duration::from_millis(150));
    assert!(model.tick(), "window elapsed → snapshot replaced");
    assert_eq!(agent_count(&model, &h.ws), 2);
}

#[test]
fn an_unchanged_re_derivation_replaces_nothing() {
    let h = Harness::new();
    let (clock, mut model) = h.model();
    model.dirty_handle().mark_all([(h.ws.clone(), Mark::Watch)]);
    clock.advance(Duration::from_millis(10));
    model.tick();
    clock.advance(Duration::from_millis(150));
    assert!(
        !model.tick(),
        "an unchanged tree suppresses the replacement"
    );
}

#[test]
fn a_failed_re_read_keeps_the_last_snapshot() {
    let h = Harness::new();
    let (clock, mut model) = h.model();
    // A bogus workspace root that cannot derive: routed as a workspace, debounced,
    // then `probes.derive` fails → no snapshot, no change.
    let bogus = PathBuf::from("/nonexistent/workspace");
    model
        .dirty_handle()
        .mark_all([(bogus.clone(), Mark::Watch)]);
    settle(&mut model, &clock);
    assert!(model.tree(&bogus).is_none(), "a failed read stores nothing");
    assert_eq!(agent_count(&model, &h.ws), 1, "the good snapshot is intact");
}

#[test]
fn the_full_sweep_re_derives_every_workspace() {
    let h = Harness::new();
    let (clock, mut model) = h.model();
    h.build_more("c-2", "yo");
    clock.advance(Duration::from_secs(15)); // full sweep marks everything dirty
    model.tick();
    clock.advance(Duration::from_millis(150)); // debounce elapses
    model.tick();
    assert_eq!(agent_count(&model, &h.ws), 2, "the 15 s sweep re-derived");
}

#[test]
fn the_cheap_sweep_re_probes_and_invalidates_only_live_workspaces() {
    let h = Harness::new();
    let (clock, mut model) = h.model();
    // The fixture agent is Stopped: a cheap sweep leaves it alone.
    clock.advance(crate::app::dirty::CHEAP_SWEEP);
    assert!(!model.tick(), "a quiescent workspace: no targeted re-probe");
    // Inject a Live agent: the next cheap sweep invalidates its lock cache and
    // schedules a re-derive (which re-reads the real Stopped fixture).
    model.deriver.trees.insert(
        h.ws.clone(),
        GitTree {
            commits: vec![],
            agents: vec![agent("c-1", AgentState::Live)],
        },
    );
    clock.advance(crate::app::dirty::CHEAP_SWEEP);
    model.tick(); // cheap sweep: invalidate_liveness + mark dirty
    clock.advance(Duration::from_millis(150));
    assert!(model.tick(), "a live agent: the cheap sweep re-derives");
    assert_eq!(agent_count(&model, &h.ws), 1, "re-read the real fixture");
}

#[test]
fn an_enumeration_root_event_reconciles_the_workspace_set() {
    let mut h = Harness::new();
    let (clock, mut model) = h.model();
    assert_eq!(model.workspaces().len(), 1);
    // A second workspace appears on disk, then the workspaces enum root fires.
    let ws2 = h.add_workspace("ws2", "d-1");
    model
        .dirty_handle()
        .mark_all([(h.roots.workspaces(), Mark::Watch)]);
    model.tick(); // dispatch → reconcile: re-enumerate + watch + mark new dirty
    assert_eq!(
        model.workspaces().len(),
        2,
        "the new workspace is enumerated"
    );
    // Before its debounce elapses it has no snapshot → the roster counts zero.
    assert_eq!(model.workspace_stats(&ws2), (0, 0, false));
    // After the debounce it derives.
    clock.advance(Duration::from_millis(150));
    model.tick();
    assert_eq!(agent_count(&model, &ws2), 1);
}

#[test]
fn a_vanished_workspace_is_pruned_on_reconcile() {
    let mut h = Harness::new();
    let ws2 = h.add_workspace("ws2", "d-1");
    let (_c, mut model) = h.model();
    assert_eq!(
        model.workspaces().len(),
        2,
        "both enumerated at construction"
    );
    assert_eq!(agent_count(&model, &ws2), 1);
    // Remove the symlink and reconcile via the enum root.
    std::fs::remove_file(&ws2).unwrap();
    model
        .dirty_handle()
        .mark_all([(h.roots.workspaces(), Mark::Watch)]);
    model.tick();
    assert_eq!(model.workspaces().len(), 1, "the removed workspace is gone");
    assert!(model.tree(&ws2).is_none(), "its snapshot is pruned");
}

#[test]
fn every_enum_root_and_the_state_root_are_routed() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    // One drain carrying all enum roots, the state root, and a workspace root
    // exercises each dispatch arm (adopt / reconcile ×3 / workspace debounce).
    model.dirty_handle().mark_all([
        (h.roots.yog_state.clone(), Mark::Watch),
        (h.roots.names(), Mark::Watch),
        (h.roots.workspaces(), Mark::Watch),
        (h.roots.replays(), Mark::Watch),
        (h.ws.clone(), Mark::Watch),
    ]);
    assert!(
        !model.tick(),
        "no snapshot delta this tick (workspace only debounced)"
    );
    assert_eq!(model.workspaces().len(), 1, "reconcile kept the set");
}

/// The clock's one setting, end to end (bl-3381): a `cadence.yaml` present at
/// boot tunes the first pass; a change re-tunes on its own announcement and
/// rides the published snapshot; deleting the file is the reset to defaults.
#[test]
fn cadence_yaml_tunes_the_clock_at_boot_on_change_and_resets_on_delete() {
    let h = Harness::new();
    let file = h.roots.yog_state.join(crate::app::cadence::CADENCE_YAML);
    std::fs::write(&file, "cadence:\n  watcher:\n    cheap_sweep_ms: 5000\n").unwrap();
    let (_c, mut model) = h.model();
    assert_eq!(
        model.cadence().cheap_sweep,
        Duration::from_secs(5),
        "boot adopted the file before the first schedule decision"
    );
    std::fs::write(&file, "cadence:\n  watcher:\n    cheap_sweep_ms: 9000\n").unwrap();
    model
        .dirty_handle()
        .mark_all([(h.roots.yog_state.clone(), Mark::Watch)]);
    assert!(model.tick(), "a re-tune publishes");
    assert_eq!(model.cadence().cheap_sweep, Duration::from_secs(9));
    std::fs::remove_file(&file).unwrap();
    model
        .dirty_handle()
        .mark_all([(h.roots.yog_state.clone(), Mark::Watch)]);
    assert!(model.tick(), "the reset publishes too");
    assert_eq!(
        model.cadence(),
        crate::app::Cadence::default(),
        "deleting the file is the reset — severability, not an error"
    );
}

#[test]
fn adopting_our_own_ui_json_echo_is_suppressed() {
    let h = Harness::new();
    let (_c, mut model) = h.model();
    // Pin the real workspace (write-through to ui.json), then let the state
    // root fire.
    let key = crate::naming::leaf(&h.ws);
    model.toggle_pin(&key);
    model
        .dirty_handle()
        .mark_all([(h.roots.yog_state.clone(), Mark::Watch)]);
    model.tick(); // adopt_ui reads our own bytes → is_echo true → skip adopt
    let bar = model.tab_bar();
    assert!(
        bar.tabs.first().is_some_and(|t| t.pinned),
        "the pin survives the echo: {bar:?}"
    );
}

/// The armed loops the published snapshot carries — the same `board::build` the
/// §8.5 `Query::Board` answers and the §11 fold now paints over the wire
/// (bl-adcb retired the model's own `board()` with that migration). Spelled here
/// because the subject of the test below is the **worker's** side: what the
/// derivation adopted, read through the derivation that reads it.
fn fleet(model: &crate::AppModel) -> Vec<crate::fleet::Facts> {
    crate::board::build(&model.snap, &model.ui, model.now_unix()).fleet
}

/// The §4.3 fleet arming rides the clock's own file and the clock's own
/// announcement (bl-66fb): an entry published on the snapshot the board reads,
/// deleted the same way. The **burden check from the worker's side** — with no
/// entry, the published snapshot carries no loop at all.
#[test]
fn a_fleet_entry_is_adopted_and_deleted_on_the_clocks_own_announcement() {
    let h = Harness::new();
    let file = h.roots.yog_state.join(crate::app::cadence::CADENCE_YAML);
    let (_c, mut model) = h.model();
    assert!(
        fleet(&model).is_empty(),
        "unarmed: the board is today's balls section"
    );
    std::fs::write(
        &file,
        "fleet:\n  /ws/a:\n    project: /dev/yog\n    cap: 3\n",
    )
    .unwrap();
    model
        .dirty_handle()
        .mark_all([(h.roots.yog_state.clone(), Mark::Watch)]);
    assert!(model.tick(), "an arming publishes");
    let armed = fleet(&model);
    assert_eq!(armed.len(), 1);
    assert_eq!(armed[0].cap, 3);
    assert_eq!(armed[0].project, std::path::PathBuf::from("/dev/yog"));
    std::fs::remove_file(&file).unwrap();
    model
        .dirty_handle()
        .mark_all([(h.roots.yog_state.clone(), Mark::Watch)]);
    assert!(model.tick(), "the disarming publishes too");
    assert!(
        fleet(&model).is_empty(),
        "deleting the entry deletes the loop, not a code path"
    );
}