yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Headless shape-walk tests for the Steps tab widget — assert user-visible
//! text (framing badges, summary figures, tab headers, drill-in trees) lands
//! in the paint output. Structs are built directly, so the render path is
//! exercised without touching disk.

use std::collections::HashSet;

use serde_json::json;

use crate::git_tree::Framing;
use crate::steps_view::render::{StepTab, render};
use crate::steps_view::{Doc, StepDetail, StepSummary, StepsView, ToolIo};

fn screen() -> egui::RawInput {
    egui::RawInput {
        screen_rect: Some(egui::Rect::from_min_size(
            egui::Pos2::ZERO,
            egui::vec2(1024.0, 4096.0),
        )),
        ..Default::default()
    }
}

fn collect_text(shape: &egui::Shape, out: &mut String) {
    match shape {
        egui::Shape::Text(t) => {
            out.push_str(t.galley.text());
            out.push('\n');
        }
        egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect_text(s, out)),
        _ => {}
    }
}

pub(super) fn painted(
    view: &StepsView,
    selected: Option<usize>,
    detail: Option<&StepDetail>,
    tab: StepTab,
) -> String {
    let ctx = egui::Context::default();
    let mut collapsed = HashSet::new();
    let out = ctx.run(screen(), |ctx| {
        egui::CentralPanel::default().show(ctx, |ui| {
            render(ui, view, selected, detail, tab, &mut collapsed);
        });
    });
    let mut text = String::new();
    for clipped in out.shapes {
        collect_text(&clipped.shape, &mut text);
    }
    text
}

fn summary(seq: &str, framing: Framing) -> StepSummary {
    StepSummary {
        seq: seq.into(),
        framing,
        attempts: 2,
        tokens: crate::budgets::spend_from_bytes(
            br#"{"type":"usage","input_tokens":7,"output_tokens":0}"#,
        ),
        commit: Some("abcdef1234567890".into()),
        started_at: Some("t-start".into()),
        ended_at: Some("t-end".into()),
        auth_failed: false,
        no_response: false,
    }
}

#[test]
fn collect_text_descends_shape_vec_and_ignores_non_text() {
    // Simple widgets don't nest galleys in a `Shape::Vec`, so build one
    // directly to cover the walker's recursion arm and its non-text catch-all.
    use egui::{Color32, FontId, Pos2, Stroke};
    let ctx = egui::Context::default();
    let mut nested: Option<egui::Shape> = None;
    let _ = ctx.run(egui::RawInput::default(), |ctx| {
        let galley =
            ctx.fonts(|f| f.layout_no_wrap("nested".into(), FontId::default(), Color32::WHITE));
        nested = Some(egui::Shape::Vec(vec![egui::Shape::Text(
            egui::epaint::TextShape {
                pos: Pos2::ZERO,
                galley,
                underline: Stroke::NONE,
                fallback_color: Color32::WHITE,
                override_text_color: None,
                opacity_factor: 1.0,
                angle: 0.0,
            },
        )]));
    });
    let mut out = String::new();
    collect_text(nested.as_ref().unwrap(), &mut out);
    collect_text(&egui::Shape::Noop, &mut out);
    assert!(out.contains("nested"));
}

#[test]
fn empty_view_shows_placeholder() {
    let text = painted(&StepsView::default(), None, None, StepTab::Meta);
    assert!(text.contains("(no steps yet)"), "got:\n{text}");
}

#[test]
fn list_paints_badges_selection_and_figures() {
    let view = StepsView {
        steps: vec![
            summary("001", Framing::Complete),
            summary("002", Framing::Failed),
            StepSummary {
                seq: "003".into(),
                framing: Framing::Killed,
                attempts: 0,
                tokens: crate::budgets::BudgetSpend::default(),
                commit: None,
                started_at: None,
                ended_at: None,
                auth_failed: false,
                no_response: false,
            },
        ],
    };
    let text = painted(&view, Some(0), None, StepTab::Meta);
    // One badge per outcome.
    assert!(text.contains('✓'), "got:\n{text}");
    assert!(text.contains('✗'));
    assert!(text.contains('■'));
    // Selected step 0 carries the ▶ marker; the figures render.
    assert!(text.contains("▶ 001"));
    assert!(text.contains("2 attempts"));
    assert!(text.contains("7 tok"));
    // Short commit (7 chars) for step 001, and none for the commit-less 003.
    assert!(text.contains("abcdef1"));
    assert!(text.contains("t-start"));
    assert!(text.contains("→ t-end"));
}

fn detail_fixture() -> StepDetail {
    StepDetail {
        seq: "001".into(),
        meta: Doc::Json(json!({"commit": "c0ffee"})),
        request: Doc::Json(json!({"model": "opus"})),
        staging: Doc::Raw(b"raw-staging".to_vec()),
        response: vec![
            Doc::Json(json!({"type": "end"})),
            Doc::Raw(b"bad-line".to_vec()),
        ],
        tools: vec![
            ToolIo {
                tool_id: "toolu_ok".into(),
                input: Doc::Json(json!({"name": "Read"})),
                output: Doc::Json(json!({"exit_code": 0})),
                is_error: false,
            },
            ToolIo {
                tool_id: "toolu_err".into(),
                input: Doc::Json(json!({"name": "Bash"})),
                output: Doc::Raw(Vec::new()),
                is_error: true,
            },
        ],
    }
}

#[test]
fn detail_tabs_render_their_records() {
    let view = StepsView {
        steps: vec![summary("001", Framing::Complete)],
    };
    let d = detail_fixture();
    // Every tab header is always present; the active one is still text.
    let meta = painted(&view, Some(0), Some(&d), StepTab::Meta);
    for header in ["meta", "request", "staging", "response", "tools"] {
        assert!(meta.contains(header), "missing tab {header}:\n{meta}");
    }
    assert!(meta.contains("commit:"), "meta tree:\n{meta}");

    let request = painted(&view, Some(0), Some(&d), StepTab::Request);
    assert!(request.contains("model:"));

    // Staging is raw bytes here → shown verbatim.
    let staging = painted(&view, Some(0), Some(&d), StepTab::Staging);
    assert!(staging.contains("raw-staging"));

    // Response: one parsed event tree, one raw malformed line.
    let response = painted(&view, Some(0), Some(&d), StepTab::Response);
    assert!(response.contains("type:"));
    assert!(response.contains("bad-line"));

    // Tools: ids, ok/error glyphs, and the input/output section labels.
    let tools = painted(&view, Some(0), Some(&d), StepTab::Tools);
    assert!(tools.contains("toolu_ok"));
    assert!(tools.contains("toolu_err"));
    assert!(tools.contains("input"));
    assert!(tools.contains("output"));
    assert!(tools.contains('✓'));
    assert!(tools.contains('✗'));
}

#[test]
fn empty_drill_ins_and_absent_docs_show_their_placeholders() {
    let view = StepsView {
        steps: vec![summary("001", Framing::Complete)],
    };
    let empty = StepDetail {
        seq: "001".into(),
        meta: Doc::Raw(Vec::new()),
        request: Doc::Raw(Vec::new()),
        staging: Doc::Raw(Vec::new()),
        response: Vec::new(),
        tools: Vec::new(),
    };
    // Absent (empty raw) doc under the Meta tab.
    let meta = painted(&view, Some(0), Some(&empty), StepTab::Meta);
    assert!(meta.contains("(absent)"), "got:\n{meta}");
    // Empty event list and empty tool list.
    let response = painted(&view, Some(0), Some(&empty), StepTab::Response);
    assert!(response.contains("(no events)"));
    let tools = painted(&view, Some(0), Some(&empty), StepTab::Tools);
    assert!(tools.contains("(no tool calls)"));
}