yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Headless shape-walk tests for the transcript widget — assert user-visible
//! text lands in the paint output, and the in-progress tool chip pulses.

use crate::transcript::{Block, Entry, EntryKind, Transcript, render};

/// A screen big enough that the `ScrollArea` lays out all rows.
fn input() -> egui::RawInput {
    egui::RawInput {
        screen_rect: Some(egui::Rect::from_min_size(
            egui::Pos2::ZERO,
            egui::vec2(1024.0, 4096.0),
        )),
        ..Default::default()
    }
}

/// Run the widget headlessly and concatenate every painted galley's text.
fn rendered_text(t: &Transcript, raw: bool) -> String {
    let ctx = egui::Context::default();
    let output = ctx.run(input(), |ctx| {
        egui::CentralPanel::default().show(ctx, |ui| render(ui, t, raw));
    });
    let mut out = String::new();
    for clipped in output.shapes {
        collect_text(&clipped.shape, &mut out);
    }
    out
}

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) => {
            for s in shapes {
                collect_text(s, out);
            }
        }
        _ => {}
    }
}

/// Second-frame steady-state repaint delay (egui returns 0 on frame one).
fn repaint_delay(t: &Transcript) -> std::time::Duration {
    let ctx = egui::Context::default();
    for _ in 0..2 {
        let _ = ctx.run(input(), |ctx| {
            egui::CentralPanel::default().show(ctx, |ui| render(ui, t, false));
        });
    }
    ctx.run(input(), |ctx| {
        egui::CentralPanel::default().show(ctx, |ui| render(ui, t, false));
    })
    .viewport_output
    .get(&egui::ViewportId::ROOT)
    .expect("root viewport output present")
    .repaint_delay
}

fn entry(kind: EntryKind) -> Entry {
    Entry {
        name: "001-x.json".into(),
        raw: b"RAWBYTES".to_vec(),
        kind,
    }
}

fn tx(entries: Vec<Entry>) -> Transcript {
    Transcript { entries }
}

#[test]
fn empty_transcript_shows_placeholder() {
    assert!(rendered_text(&Transcript::default(), false).contains("(no messages yet)"));
}

#[test]
fn delivered_renders_sender_and_body() {
    let t = tx(vec![entry(EntryKind::Delivered {
        sender: "alice".into(),
        body: "hello world".into(),
    })]);
    let painted = rendered_text(&t, false);
    assert!(painted.contains("alice:"), "got:\n{painted}");
    assert!(painted.contains("hello world"));
}

#[test]
fn model_text_and_thinking_render() {
    let t = tx(vec![entry(EntryKind::Model {
        model_id: "opus".into(),
        blocks: vec![
            Block::Text("visible answer".into()),
            Block::Thinking("private reasoning".into()),
        ],
    })]);
    let painted = rendered_text(&t, false);
    assert!(painted.contains("opus"));
    assert!(painted.contains("visible answer"));
    assert!(
        painted.contains("thinking: private reasoning"),
        "got:\n{painted}"
    );
}

fn tool_use_entry() -> Entry {
    entry(EntryKind::Model {
        model_id: "opus".into(),
        blocks: vec![Block::ToolUse {
            id: "toolu_9".into(),
            name: "Read".into(),
            input_summary: r#"{"path":"/x"}"#.into(),
        }],
    })
}

#[test]
fn in_progress_tool_chip_pulses_and_paints() {
    // No matching tool_result → in progress.
    let t = tx(vec![tool_use_entry()]);
    assert!(
        repaint_delay(&t) < std::time::Duration::from_secs(1),
        "in-progress tool must schedule a near-term repaint"
    );
    let painted = rendered_text(&t, false);
    assert!(
        painted.contains(r#"Read({"path":"/x"})"#),
        "got:\n{painted}"
    );
    assert!(painted.contains("(in-flight)"));
    assert!(painted.contains("toolu_9"));
}

#[test]
fn resolved_tool_chip_is_static() {
    // A matching tool_result elsewhere resolves the call → no pulse.
    let t = tx(vec![
        tool_use_entry(),
        entry(EntryKind::ToolResult {
            tool_use_id: "toolu_9".into(),
            content: "file body".into(),
            is_error: false,
        }),
    ]);
    assert_eq!(
        repaint_delay(&t),
        std::time::Duration::MAX,
        "resolved tools must not pull repaints"
    );
    let painted = rendered_text(&t, false);
    assert!(painted.contains(r#"Read({"path":"/x"})"#));
    assert!(!painted.contains("(in-flight)"));
}

#[test]
fn tool_result_renders_content_and_error_glyph() {
    let ok = tx(vec![entry(EntryKind::ToolResult {
        tool_use_id: "t".into(),
        content: "all good".into(),
        is_error: false,
    })]);
    let ok_painted = rendered_text(&ok, false);
    assert!(ok_painted.contains("all good"));
    assert!(ok_painted.contains("✓"));
    let err = tx(vec![entry(EntryKind::ToolResult {
        tool_use_id: "t".into(),
        content: "boom".into(),
        is_error: true,
    })]);
    let err_painted = rendered_text(&err, false);
    assert!(err_painted.contains("boom"));
    assert!(err_painted.contains("✗"));
}

#[test]
fn streaming_tail_renders_live_marker() {
    let t = tx(vec![entry(EntryKind::Streaming {
        text: "partial output".into(),
    })]);
    let painted = rendered_text(&t, false);
    assert!(painted.contains("live"));
    assert!(painted.contains("partial output"));
}

#[test]
fn raw_kind_renders_bytes_in_parsed_mode() {
    let painted = rendered_text(&tx(vec![entry(EntryKind::Raw)]), false);
    assert!(painted.contains("RAWBYTES"), "got:\n{painted}");
    assert!(painted.contains("001-x.json"));
}

#[test]
fn raw_toggle_shows_verbatim_bytes_over_parsed() {
    let t = tx(vec![entry(EntryKind::Model {
        model_id: "opus".into(),
        blocks: vec![Block::Text("parsed answer".into())],
    })]);
    // Parsed mode shows the answer, not the backing bytes.
    let parsed = rendered_text(&t, false);
    assert!(parsed.contains("parsed answer"));
    assert!(!parsed.contains("RAWBYTES"));
    // Raw mode shows the verbatim bytes and filename instead.
    let raw = rendered_text(&t, true);
    assert!(raw.contains("RAWBYTES"), "got:\n{raw}");
    assert!(raw.contains("001-x.json"));
    assert!(!raw.contains("parsed answer"));
}

#[test]
fn collect_text_recurses_into_shape_vec() {
    // egui can wrap shapes in `Shape::Vec`; the walker must descend, and its
    // catch-all must ignore non-text shapes. Build all three arms directly.
    use egui::{Color32, FontId, Pos2, Stroke};
    let ctx = egui::Context::default();
    let mut nested: Option<egui::Shape> = None;
    let mut flat: Option<egui::Shape> = None;
    let _ = ctx.run(egui::RawInput::default(), |ctx| {
        let inner = ctx
            .fonts(|f| f.layout_no_wrap("nested-text".into(), FontId::default(), Color32::WHITE));
        let outer =
            ctx.fonts(|f| f.layout_no_wrap("flat-text".into(), FontId::default(), Color32::WHITE));
        let shape = |galley| {
            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,
            })
        };
        nested = Some(egui::Shape::Vec(vec![shape(inner)]));
        flat = Some(shape(outer));
    });
    let mut out = String::new();
    collect_text(nested.as_ref().unwrap(), &mut out);
    collect_text(flat.as_ref().unwrap(), &mut out);
    collect_text(&egui::Shape::Noop, &mut out);
    assert!(out.contains("nested-text"));
    assert!(out.contains("flat-text"));
}