yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! View-model tests: enumeration, the reused framing/attempts/token folds,
//! forgiving meta parsing, and the on-demand drill-in.

use tempfile::tempdir;

use super::{AGENT, step_dir, write_file, write_tool};
use crate::git_tree::{AgentState, Framing};
use crate::steps_view::{Doc, build, detail};

const COMPLETE: &[u8] = br#"{"type":"message_start"}
{"type":"usage","input_tokens":10,"output_tokens":5}
{"type":"finish","reason":"stop"}
{"type":"end"}
"#;
const FAILED: &[u8] = br#"{"type":"error","kind":"x"}
{"type":"end"}
"#;
const AUTH_FAILED: &[u8] = br#"{"type":"error","status":401,"message":"Unauthorized"}
{"type":"end"}
"#;

#[test]
fn build_summarizes_steps_in_order_reusing_framing_attempts_tokens() {
    let dir = tempdir().unwrap();
    let ws = dir.path();
    write_file(ws, "002", "response.json", FAILED);
    write_file(ws, "001", "response.json", COMPLETE);
    write_file(
        ws,
        "001",
        "meta.json",
        br#"{"commit":"abcdef1234567890","started_at":"t-start","ended_at":"t-end"}"#,
    );
    // A step with no response.json at all → killed, zero attempts/tokens.
    write_file(ws, "003", "meta.json", b"{}");
    // Non-step entries: wrong width, non-digits, and a file named like a seq
    // (a 3-digit name that is not a directory).
    write_file(ws, "0004", "meta.json", b"{}");
    std::fs::create_dir_all(step_dir(ws, "notaseq")).unwrap();
    std::fs::write(ws.join("steps").join(AGENT).join("006"), b"x").unwrap();

    let view = build(ws, AGENT, AgentState::Stopped);
    let seqs: Vec<&str> = view.steps.iter().map(|s| s.seq.as_str()).collect();
    assert_eq!(seqs, vec!["001", "002", "003"]);

    let s1 = &view.steps[0];
    assert_eq!(s1.framing, Framing::Complete);
    assert_eq!(s1.attempts, 1);
    assert_eq!(s1.tokens.total_tokens(), 15);
    assert_eq!(s1.commit.as_deref(), Some("abcdef1234567890"));
    assert_eq!(s1.started_at.as_deref(), Some("t-start"));
    assert_eq!(s1.ended_at.as_deref(), Some("t-end"));

    assert_eq!(view.steps[1].framing, Framing::Failed);
    assert_eq!(view.steps[2].framing, Framing::Killed);
    assert_eq!(view.steps[2].attempts, 0);
    // 003's meta is `{}` — every timestamp/commit field absent.
    assert_eq!(view.steps[2].commit, None);
}

#[test]
fn build_flags_auth_shaped_step_failures_for_the_login_affordance() {
    let dir = tempdir().unwrap();
    let ws = dir.path();
    write_file(ws, "001", "response.json", COMPLETE);
    write_file(ws, "002", "response.json", FAILED); // a generic failure
    write_file(ws, "003", "response.json", AUTH_FAILED); // a 401 auth failure
    let view = build(ws, AGENT, AgentState::Stopped);
    // Only the auth-shaped failure carries the Login affordance (§8.3 detection):
    // a complete step and a non-auth failure do not.
    assert!(!view.steps[0].auth_failed);
    assert!(!view.steps[1].auth_failed);
    assert!(view.steps[2].auth_failed);
}

#[test]
fn build_missing_tree_is_empty() {
    let dir = tempdir().unwrap();
    assert_eq!(
        build(dir.path(), AGENT, AgentState::Stopped).steps,
        Vec::new()
    );
}

#[test]
fn build_forgiving_when_meta_is_malformed_or_absent() {
    let dir = tempdir().unwrap();
    let ws = dir.path();
    write_file(ws, "001", "response.json", COMPLETE);
    write_file(ws, "001", "meta.json", b"not json at all");
    let view = build(ws, AGENT, AgentState::Stopped);
    // Malformed meta → all meta-derived fields absent, framing still read.
    assert_eq!(view.steps[0].commit, None);
    assert_eq!(view.steps[0].started_at, None);
    assert_eq!(view.steps[0].framing, Framing::Complete);
}

#[test]
fn detail_parses_records_splits_response_and_derives_tool_is_error() {
    let dir = tempdir().unwrap();
    let ws = dir.path();
    write_file(ws, "001", "meta.json", br#"{"commit":"c"}"#);
    write_file(ws, "001", "request.json", br#"{"model":"opus"}"#);
    write_file(
        ws,
        "001",
        "staging.json",
        br#"[{"type":"text","text":"hi"}]"#,
    );
    write_file(
        ws,
        "001",
        "response.json",
        b"{\"type\":\"message_start\"}\nnot json\n{\"type\":\"end\"}\n",
    );
    // exit 0 → ok; exit 2 → error; malformed output → raw, not an error;
    // output with no exit_code → not an error.
    write_tool(ws, "001", "toolu_1", b"{}", Some(br#"{"exit_code":0}"#));
    write_tool(ws, "001", "toolu_2", b"{}", Some(br#"{"exit_code":2}"#));
    write_tool(ws, "001", "toolu_0", b"{}", Some(b"oops"));
    write_tool(ws, "001", "toolu_3", b"{}", Some(br#"{"stdout":"x"}"#));
    // A stray file at the tools/ level is not a tool call.
    std::fs::write(step_dir(ws, "001").join("tools").join(".keep"), b"").unwrap();

    let d = detail(ws, AGENT, "001");
    assert!(matches!(d.meta, Doc::Json(_)));
    assert!(matches!(d.request, Doc::Json(_)));
    assert!(matches!(d.staging, Doc::Json(_)));

    // response: three events, the middle one kept raw; the empty trailing
    // line dropped.
    assert_eq!(d.response.len(), 3);
    assert!(matches!(d.response[0], Doc::Json(_)));
    assert_eq!(d.response[1], Doc::Raw(b"not json".to_vec()));
    assert!(matches!(d.response[2], Doc::Json(_)));

    // tools sorted by id; is_error derived from exit_code.
    let ids: Vec<&str> = d.tools.iter().map(|t| t.tool_id.as_str()).collect();
    assert_eq!(ids, vec!["toolu_0", "toolu_1", "toolu_2", "toolu_3"]);
    let errs: Vec<bool> = d.tools.iter().map(|t| t.is_error).collect();
    assert_eq!(errs, vec![false, false, true, false]);
    assert_eq!(d.tools[0].output, Doc::Raw(b"oops".to_vec()));
}

#[test]
fn detail_is_forgiving_when_every_file_is_absent() {
    let dir = tempdir().unwrap();
    let d = detail(dir.path(), AGENT, "001");
    assert_eq!(d.meta, Doc::Raw(Vec::new()));
    assert_eq!(d.request, Doc::Raw(Vec::new()));
    assert_eq!(d.staging, Doc::Raw(Vec::new()));
    assert!(d.response.is_empty());
    assert!(d.tools.is_empty());
    assert_eq!(d.seq, "001");
}