darq 0.1.0

darq CLI + TUI — autonomous issue → PR pipeline with SAT and a learning loop.
Documentation
//! Snapshot tests for TUI widgets using ratatui's TestBackend.
//!
//! Phase 7.7. These render each widget into a known-size buffer and assert key
//! visual elements appear at expected positions. We don't snapshot whole frames
//! (too brittle for animated content) — instead we assert on stable structural
//! invariants: heartbeat label present, station glyphs in the right order,
//! filter chips show 'all' as default, etc.

#![cfg(test)]

use ratatui::Terminal;
use ratatui::backend::TestBackend;

use crate::tui::app::{App, PanelMode, StationState, WaterfallFilter};
use crate::tui::panels::render_all;
use crate::tui::theme::{Palette, breathing_brightness, color_lerp, fade_factor};

fn render_to_buffer(app: &mut App, width: u16, height: u16) -> ratatui::buffer::Buffer {
    let backend = TestBackend::new(width, height);
    let mut terminal = Terminal::new(backend).unwrap();
    terminal.draw(|frame| render_all(frame, app)).unwrap();
    terminal.backend().buffer().clone()
}

fn buffer_text(buf: &ratatui::buffer::Buffer) -> String {
    let mut out = String::new();
    for y in 0..buf.area.height {
        for x in 0..buf.area.width {
            // ratatui >=0.29 deprecated `get`/`get_mut`; use the indexing API.
            out.push_str(buf[(x, y)].symbol());
        }
        out.push('\n');
    }
    out
}

#[test]
fn header_includes_brand_and_heartbeat_label() {
    let mut app = App::new();
    let buf = render_to_buffer(&mut app, 140, 24);
    let text = buffer_text(&buf);
    assert!(text.contains("darq"), "expected brand text 'darq'");
    assert!(
        text.contains("HEARTBEAT"),
        "expected HEARTBEAT label in header"
    );
    // Brand subtitle removed deliberately — the terminology + run state do
    // the work; the tagline read as gilding the lily.
    assert!(
        !text.contains("INDUSTRIAL CODE FORGE"),
        "brand subtitle should NOT be in the header"
    );
}

#[test]
fn chain_topology_shows_all_seven_stations() {
    let mut app = App::new();
    let buf = render_to_buffer(&mut app, 140, 24);
    let text = buffer_text(&buf);
    for station in [
        "PLAN",
        "IMPLEMENT",
        "REVIEW",
        "FIX",
        "MERGE",
        "SAT",
        "LEARN",
    ] {
        assert!(
            text.contains(station),
            "expected station label {station} in chain topology"
        );
    }
}

#[test]
fn waterfall_default_filter_is_all() {
    let mut app = App::new();
    assert_eq!(app.waterfall_filter, WaterfallFilter::All);
    // [all] chip rendered in the waterfall header
    let buf = render_to_buffer(&mut app, 140, 24);
    let text = buffer_text(&buf);
    assert!(text.contains("[all]"), "expected [all] filter chip");
}

#[test]
fn filter_cycle_advances_through_four_states() {
    let mut filter = WaterfallFilter::All;
    filter = filter.next();
    assert_eq!(filter, WaterfallFilter::Errors);
    filter = filter.next();
    assert_eq!(filter, WaterfallFilter::Tools);
    filter = filter.next();
    assert_eq!(filter, WaterfallFilter::Routing);
    filter = filter.next();
    assert_eq!(filter, WaterfallFilter::All);
}

#[test]
fn mode_toggle_returns_to_dials_on_second_press() {
    let mut app = App::new();
    assert_eq!(app.mode, PanelMode::SatDials);
    app.toggle_mode(PanelMode::Shifts);
    assert_eq!(app.mode, PanelMode::Shifts);
    app.toggle_mode(PanelMode::Shifts);
    assert_eq!(app.mode, PanelMode::SatDials);
}

#[test]
fn footer_is_status_bar_not_keybindings_and_omits_override() {
    let mut app = App::new();
    let buf = render_to_buffer(&mut app, 140, 24);
    let text = buffer_text(&buf);
    // Status bar pattern: idle hint on left, ? hint for help on right.
    assert!(
        text.contains("idle") || text.contains("shift focused"),
        "expected idle status on left when no shift focused"
    );
    assert!(
        text.contains("? keys"),
        "expected '? keys' help hint on right corner"
    );
    // The footer is no longer a keybinding reference. The `[s] shifts`,
    // `[b] blueprints` etc. live in the `?` help modal (follow-up).
    assert!(
        !text.contains("[s] shifts"),
        "footer should NOT enumerate keybindings — moved to ? modal"
    );
    // Per design-tui.md §Open Q3: override key MUST NOT appear anywhere.
    assert!(
        !text.contains("override"),
        "footer must not contain 'override' (design-tui.md open Q3)"
    );
}

#[test]
fn footer_shows_approval_prompt_when_gate_pending() {
    let mut app = App::new();
    // Synthesize an approval-pending shift.
    app.approvals.push(crate::tui::app::RunInfo {
        id: "abcd1234-test".into(),
        status: "awaiting_approval".into(),
        issue: "#99".into(),
        duration: "0s".into(),
    });
    let buf = render_to_buffer(&mut app, 140, 24);
    let text = buffer_text(&buf);
    assert!(
        text.contains("gate awaiting"),
        "expected 'gate awaiting' status when approval pending"
    );
    assert!(
        text.contains("[enter]"),
        "expected [enter] hint when approval pending"
    );
}

#[test]
fn station_glyphs_match_design_system() {
    assert_eq!(StationState::Pending.glyph(), "");
    assert_eq!(StationState::Active.glyph(), "");
    assert_eq!(StationState::Completed.glyph(), "");
    assert_eq!(StationState::Failed.glyph(), "");
}

#[test]
fn breathing_brightness_oscillates_in_band() {
    // Should never go below 0.86 nor above 1.0 — design-system motion contract.
    for ms in (0u64..2400).step_by(33) {
        let b = breathing_brightness(ms, 1200);
        assert!(
            (0.86..=1.0).contains(&b),
            "breathing out of band at {ms}ms: {b}"
        );
    }
}

#[test]
fn fade_factor_clamps_at_06() {
    assert!((fade_factor(0.0) - 1.0).abs() < 0.001);
    assert!((fade_factor(8.0) - 0.6).abs() < 0.001);
    assert!((fade_factor(100.0) - 0.6).abs() < 0.001);
}

#[test]
fn color_lerp_truecolor_interpolates() {
    use ratatui::style::Color;
    let a = Color::Rgb(0, 0, 0);
    let b = Color::Rgb(100, 100, 100);
    let mid = color_lerp(a, b, 0.5);
    if let Color::Rgb(r, g, blue) = mid {
        assert_eq!((r, g, blue), (50, 50, 50));
    } else {
        panic!("expected Rgb mid color");
    }
}

#[test]
fn reduce_motion_env_var_picked_up() {
    // Setting env var → App::new should detect it.
    // SAFETY: tests run in the same process; this affects parallel tests of App::new
    // that DON'T look at this var. None do.
    // SAFETY: std::env::set_var is unsafe in 2024 edition due to multi-thread races.
    unsafe {
        std::env::set_var("DARQ_TUI_REDUCE_MOTION", "1");
    }
    let app = App::new();
    assert!(app.reduce_motion, "DARQ_TUI_REDUCE_MOTION=1 should enable");
    unsafe {
        std::env::remove_var("DARQ_TUI_REDUCE_MOTION");
    }
}

#[test]
fn responsive_renders_at_80x24_without_crash() {
    // The minimum supported terminal size — must not crash, must show core elements.
    let mut app = App::new();
    let buf = render_to_buffer(&mut app, 80, 24);
    let text = buffer_text(&buf);
    assert!(text.contains("darq"));
    // Footer ? keys hint visible (replaces the old keybindings reference).
    assert!(text.contains("? keys"));
}

#[test]
fn responsive_renders_below_80_cols_without_crash() {
    // < 80 cols: chain hidden, waterfall full-width, no panic.
    let mut app = App::new();
    let _buf = render_to_buffer(&mut app, 60, 24);
    // Just verify no panic during render.
}

#[test]
fn palette_truecolor_brand_matches_logo_hex() {
    use ratatui::style::Color;
    let p = Palette::truecolor();
    assert_eq!(p.brand_green, Color::Rgb(0xa0, 0xce, 0x29));
    assert_eq!(p.heartbeat, Color::Rgb(0xff, 0x2e, 0x7e));
    assert_eq!(p.ember, Color::Rgb(0xff, 0x7b, 0x3e));
}