met 1.0.0-beta-preview

Metrale Engine: pure Rust LLM inference server (HTTP API, scheduler, TUI, CLI).
// SPDX-License-Identifier: MIT OR Apache-2.0

//! 2026-09-26: Tests for `ProgressModel`: reset, the load window and rate, the bars, and out-of-range events.
//!
//! Owner: server tui.
//! Invariants: none beyond the types.

use super::*;

/// 2026-09-26: After `reset` every phase is `Pending` again and can re-enter
/// `Running`; `enter_phase` alone never moves a `Done` phase back.
#[test]
fn a_second_load_starts_from_a_clean_checklist() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::Phase {
        phase: 0,
        name: "banner".into(),
    });
    m.apply(ProgressEvent::Ready { port: 8888 });
    assert!(m.ready);
    assert_eq!(m.phases[0].state, PhaseState::Done);

    m.reset();

    assert!(!m.ready, "a new load has not finished");
    assert_eq!(m.port, 0, "and has not bound yet");
    assert!(
        m.phases.iter().all(|p| p.state == PhaseState::Pending),
        "every phase is pending again"
    );

    m.apply(ProgressEvent::Phase {
        phase: 0,
        name: "banner".into(),
    });
    assert_eq!(m.phases[0].state, PhaseState::Running);
}

/// 2026-09-26: `reset` clears the closed load window, so the next load is
/// measured afresh.
#[test]
fn reset_reopens_the_frozen_load_window() {
    let mut m = ProgressModel::default();
    // 2026-09-26: The window opens on the first shard, so one must start
    // before there is anything to close.
    m.apply(ProgressEvent::ShardStart {
        shard: 1,
        total: 2,
        name: "shard-1".into(),
    });
    m.apply(ProgressEvent::Ready { port: 8888 });
    assert!(
        m.load_secs.is_some(),
        "the window should be frozen after a load completes"
    );

    m.reset();
    assert!(
        m.load_secs.is_none(),
        "the frozen window survived a reset: the second load would report \
         the first load's rate, since freeze_load_window keeps the FIRST close"
    );
    assert!(m.load_started.is_none(), "and the window is not yet open");
}

#[test]
fn phase_entry_closes_earlier_phases() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::Phase {
        phase: 0,
        name: "banner".into(),
    });
    m.apply(ProgressEvent::Phase {
        phase: 3,
        name: "gpu init".into(),
    });
    assert_eq!(m.phases[0].state, PhaseState::Done);
    assert_eq!(m.phases[1].state, PhaseState::Done);
    assert_eq!(m.phases[3].state, PhaseState::Running);
    assert_eq!(m.phases[4].state, PhaseState::Pending);
}

#[test]
fn ready_completes_everything() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::Phase {
        phase: 5,
        name: "weight load".into(),
    });
    m.apply(ProgressEvent::Ready { port: 8888 });
    assert!(m.ready);
    assert!(m.phases.iter().all(|p| p.state == PhaseState::Done));
}

#[test]
fn shard_rollover_snaps_shard_bar() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::ShardDone {
        shard: 1,
        total: 4,
        used_gb: 2.0,
        free_gb: 100.0,
    });
    assert_eq!(m.shard_target(), 1.0);
    m.apply(ProgressEvent::ShardStart {
        shard: 2,
        total: 4,
        name: "s2".into(),
    });
    assert_eq!(m.shard_target(), 0.0);
}

/// 2026-09-26: GB/s is measured from the first shard, not from `started_at`,
/// and stops changing once the last shard is done.
#[test]
fn load_rate_is_windowed_and_freezes_at_the_last_shard() {
    let ms = |n| std::thread::sleep(std::time::Duration::from_millis(n));
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::Preflight {
        disk_gb: 10.0,
        free_gb: 100.0,
    });
    ms(120); // 2026-09-26: before the first shard; not in the rate.
    for shard in 1..=2u64 {
        m.apply(ProgressEvent::ShardStart {
            shard,
            total: 2,
            name: "s".into(),
        });
        ms(60);
        m.apply(ProgressEvent::ShardDone {
            shard,
            total: 2,
            used_gb: 1.0,
            free_gb: 9.0,
        });
    }
    let secs = m.load_secs().expect("last shard closes the window");
    assert!(
        secs < m.started_at.elapsed().as_secs_f64() - 0.1,
        "window {secs}s must exclude the 120ms of pre-load work"
    );
    let (rate, eta) = m.rate_eta().expect("rate known once shards are in");
    assert_eq!(eta, 0.0, "nothing left to load");
    assert!(rate > 0.0);

    ms(120);
    let (rate_later, _) = m.rate_eta().unwrap();
    assert_eq!(rate, rate_later, "a finished load's rate must not drift");
}

/// 2026-09-26: The shard total is zero until the first shard event, and the
/// overall fraction must still be finite.
#[test]
fn the_overall_fraction_is_zero_at_the_start_one_at_the_end_and_never_a_nan() {
    let mut m = ProgressModel::default();
    assert_eq!(m.shard_total, 0);
    assert_eq!(m.overall_target(), 0.0, "nothing known yet");
    assert!(m.overall_target().is_finite(), "0/0 must not reach the bar");

    m.apply(ProgressEvent::ShardDone {
        shard: 0,
        total: 4,
        used_gb: 0.0,
        free_gb: 1.0,
    });
    assert_eq!(m.overall_target(), 0.0, "no shard done is 0%");

    m.apply(ProgressEvent::ShardDone {
        shard: 4,
        total: 4,
        used_gb: 1.0,
        free_gb: 1.0,
    });
    assert_eq!(m.overall_target(), 1.0, "the last shard is 100%");
}

/// 2026-09-26: With no shard events the overall bar reads 100% once ready.
#[test]
fn a_load_with_no_shards_reads_full_only_once_ready() {
    let mut m = ProgressModel::default();
    assert_eq!(m.overall_target(), 0.0);
    m.apply(ProgressEvent::Ready { port: 8888 });
    assert_eq!(m.overall_target(), 1.0);
}

/// 2026-09-26: A shard number past its total is clamped to a full bar.
#[test]
fn an_overshooting_shard_count_is_clamped_to_full() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::ShardDone {
        shard: 9,
        total: 4,
        used_gb: 1.0,
        free_gb: 1.0,
    });
    assert_eq!(m.overall_target(), 1.0);
}

#[test]
fn the_rate_is_unknown_rather_than_infinite_before_anything_is_measured() {
    let mut m = ProgressModel::default();
    assert_eq!(m.rate_eta(), None, "no denominator, no rate");
    m.apply(ProgressEvent::Preflight {
        disk_gb: 0.0,
        free_gb: 100.0,
    });
    m.apply(ProgressEvent::ShardStart {
        shard: 1,
        total: 2,
        name: "s".into(),
    });
    assert_eq!(m.rate_eta(), None, "a zero-GB checkpoint has no rate");
    m.disk_gb = 10.0;
    m.shard_total = 0;
    assert_eq!(
        m.rate_eta(),
        None,
        "and a zero shard total is not a division"
    );
}

#[test]
fn a_ready_before_any_shard_leaves_the_load_window_unopened() {
    // 2026-09-26: `Ready` closes the window only if a shard opened it.
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::Ready { port: 8888 });
    assert_eq!(m.load_secs(), None);
    assert_eq!(m.rate_eta(), None);
}

#[test]
fn easing_converges_on_the_target_and_snaps_rather_than_creeping() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::ShardDone {
        shard: 4,
        total: 4,
        used_gb: 1.0,
        free_gb: 1.0,
    });
    assert_eq!(m.displayed_overall(), 0.0, "the bar starts where it was");
    for _ in 0..20 {
        m.ease_tick();
        assert!(m.displayed_overall().is_finite());
    }
    assert_eq!(
        m.displayed_overall(),
        1.0,
        "the last fraction of a percent snaps; an asymptote never fills the bar"
    );
}

#[test]
fn easing_a_model_that_knows_nothing_stays_at_zero() {
    let mut m = ProgressModel::default();
    for _ in 0..5 {
        m.ease_tick();
    }
    assert_eq!(m.displayed_overall(), 0.0);
}

#[test]
fn the_memory_history_is_bounded() {
    let mut m = ProgressModel::default();
    for shard in 1..=200u64 {
        m.apply(ProgressEvent::ShardDone {
            shard,
            total: 500,
            used_gb: shard as f64,
            free_gb: 0.0,
        });
    }
    assert_eq!(
        m.mem_history.len(),
        64,
        "the history is a window, not a log"
    );
    assert_eq!(
        m.mem_history.last().copied(),
        Some(2000),
        "and it keeps the NEWEST samples"
    );
}

#[test]
fn a_phase_index_past_the_checklist_completes_it_instead_of_panicking() {
    // 2026-09-26: An index past `PHASE_NAMES` finishes every phase and does
    // not panic.
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::Phase {
        phase: 99,
        name: "from a newer build".into(),
    });
    assert!(m.phases.iter().all(|p| p.state == PhaseState::Done));
    let (done, total, secs) = m.phase_counts();
    assert_eq!((done, total), (PHASE_NAMES.len(), PHASE_NAMES.len()));
    assert!(secs >= 0.0 && secs.is_finite());
}

#[test]
fn re_entering_the_phase_that_is_running_does_not_restart_its_clock() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::Phase {
        phase: 5,
        name: "weight load".into(),
    });
    let started = m.phases[5].started.expect("running phases are stamped");
    m.apply(ProgressEvent::Phase {
        phase: 5,
        name: "weight load".into(),
    });
    assert_eq!(m.phases[5].started, Some(started));
    assert_eq!(m.phases[5].state, PhaseState::Running);
}

#[test]
fn phase_counts_start_at_none_done() {
    let m = ProgressModel::default();
    let (done, total, _) = m.phase_counts();
    assert_eq!(done, 0);
    assert_eq!(total, PHASE_NAMES.len());
    assert_eq!(total, 12, "the checklist the Main tab draws");
}

#[test]
fn preflight_and_layer_events_land_where_the_panel_reads_them() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::Preflight {
        disk_gb: 42.5,
        free_gb: 96.0,
    });
    assert_eq!(m.disk_gb, 42.5);
    assert_eq!(m.gpu_free_gb, 96.0);
    m.apply(ProgressEvent::Layer {
        layer: 39,
        total: 40,
    });
    assert_eq!((m.layer, m.layer_total), (39, 40));
}

#[test]
fn a_shard_done_with_no_total_does_not_close_the_load_window() {
    // 2026-09-26: A zero total never closes the window (`total > 0` check).
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::ShardStart {
        shard: 1,
        total: 0,
        name: "s".into(),
    });
    m.apply(ProgressEvent::ShardDone {
        shard: 1,
        total: 0,
        used_gb: 1.0,
        free_gb: 1.0,
    });
    assert_eq!(m.load_secs(), None, "the load is still running");
}

#[test]
fn the_shard_bar_fills_on_done_and_only_resets_on_a_new_shard() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::ShardStart {
        shard: 1,
        total: 4,
        name: "s1".into(),
    });
    assert_eq!(m.shard_target(), 0.0);
    m.apply(ProgressEvent::ShardDone {
        shard: 1,
        total: 4,
        used_gb: 1.0,
        free_gb: 1.0,
    });
    assert_eq!(m.shard_target(), 1.0);
    // 2026-09-26: A repeated `ShardStart` for the same shard keeps the bar
    // full.
    m.apply(ProgressEvent::ShardStart {
        shard: 1,
        total: 4,
        name: "s1".into(),
    });
    assert_eq!(m.shard_target(), 1.0);
}

#[test]
fn ready_records_the_port_and_the_time_it_took() {
    let mut m = ProgressModel::default();
    m.apply(ProgressEvent::Ready { port: 8890 });
    assert!(m.ready);
    assert_eq!(m.port, 8890);
    assert!(
        m.ready_in_secs >= 0.0 && m.ready_in_secs.is_finite(),
        "{}",
        m.ready_in_secs
    );
}