kcan 0.1.4

CAN controller primitives for actuator and motor control.
Documentation
#![cfg(feature = "tui")]

use kcan::protocols::robstride::RobStrideFeedback;
use kcan::tui::{
    CanMonitorCommand, TuiCanMonitor, TuiCommand, TuiControlIntent, TuiDashboard, TuiMode,
    TuiTarget,
};
use kcan::{
    ActuatorConfig, ActuatorFeedback, ActuatorHealth, ActuatorProtocol, ActuatorSnapshot, CanFrame,
    CanId, MotorFeedback, MotorModel, RobStrideModel,
};

#[test]
fn dashboard_tracks_selection_and_rows() {
    let mut dashboard = sample_dashboard();

    assert_eq!(dashboard.motor_count(), 2);
    assert_eq!(dashboard.selected_index(), 0);
    assert!(dashboard.row_strings()[0].contains("> left-knee"));

    dashboard.select_next();
    assert_eq!(dashboard.selected_index(), 1);
    assert!(dashboard.row_strings()[1].contains("> right-hip"));

    dashboard.select_next();
    assert_eq!(dashboard.selected_index(), 0);
}

#[test]
fn control_commands_require_arming() {
    let mut dashboard = sample_dashboard();
    let target = TuiTarget {
        id: 3,
        protocol: ActuatorProtocol::CubeMars(MotorModel::Ak60_6),
    };

    assert_eq!(
        dashboard.apply_command(TuiCommand::EnableSelected),
        TuiControlIntent::None
    );
    assert_eq!(dashboard.mode(), TuiMode::View);
    assert_eq!(
        dashboard.last_message(),
        Some("control command blocked; press 'a' to arm")
    );

    assert_eq!(
        dashboard.apply_command(TuiCommand::ToggleControlArm),
        TuiControlIntent::None
    );
    assert_eq!(dashboard.mode(), TuiMode::ControlArmed);
    assert_eq!(
        dashboard.apply_command(TuiCommand::EnableSelected),
        TuiControlIntent::Enable(target)
    );
    assert_eq!(dashboard.mode(), TuiMode::View);
    assert_eq!(
        dashboard.last_message(),
        Some("control intent emitted; commands locked")
    );
    assert_eq!(
        dashboard.apply_command(TuiCommand::EnableSelected),
        TuiControlIntent::None
    );
}

#[test]
fn control_arm_locks_on_context_changes() {
    let mut dashboard = sample_dashboard();

    dashboard.apply_command(TuiCommand::ArmControl);
    assert_eq!(dashboard.mode(), TuiMode::ControlArmed);

    dashboard.apply_command(TuiCommand::SelectNext);
    assert_eq!(dashboard.mode(), TuiMode::View);
    assert_eq!(
        dashboard.last_message(),
        Some("control commands locked after navigation")
    );

    dashboard.apply_command(TuiCommand::ArmControl);
    dashboard.set_snapshots(Vec::new());
    assert_eq!(dashboard.mode(), TuiMode::View);
    assert_eq!(
        dashboard.last_message(),
        Some("control commands locked after actuator update")
    );

    dashboard.apply_command(TuiCommand::ArmControl);
    assert_eq!(dashboard.mode(), TuiMode::View);
    assert_eq!(
        dashboard.last_message(),
        Some("cannot arm control; no actuators configured")
    );

    dashboard.apply_command(TuiCommand::EmergencyStopAll);
    assert_eq!(dashboard.mode(), TuiMode::View);
}

#[test]
fn read_only_dashboard_blocks_control_intents() {
    let mut dashboard = sample_dashboard().with_controls_enabled(false);

    assert!(!dashboard.controls_enabled());
    assert_eq!(dashboard.mode(), TuiMode::View);
    assert_eq!(
        dashboard.apply_command(TuiCommand::ArmControl),
        TuiControlIntent::None
    );
    assert_eq!(dashboard.mode(), TuiMode::View);
    assert_eq!(
        dashboard.last_message(),
        Some("control commands disabled in read-only dashboard")
    );

    assert_eq!(
        dashboard.apply_command(TuiCommand::EmergencyStopAll),
        TuiControlIntent::None
    );
    assert_eq!(
        dashboard.apply_command(TuiCommand::EnableSelected),
        TuiControlIntent::None
    );

    let output = dashboard.render_plain(96, 24);
    assert!(output.contains("READ ONLY"));
}

#[test]
fn command_key_mapping_covers_view_controls() {
    assert_eq!(TuiCommand::from_key('j'), Some(TuiCommand::SelectNext));
    assert_eq!(TuiCommand::from_key('k'), Some(TuiCommand::SelectPrevious));
    assert_eq!(TuiCommand::from_key('r'), Some(TuiCommand::RefreshSelected));
    assert_eq!(
        TuiCommand::from_key('a'),
        Some(TuiCommand::ToggleControlArm)
    );
    assert_eq!(
        TuiCommand::from_key('!'),
        Some(TuiCommand::EmergencyStopAll)
    );
    assert_eq!(TuiCommand::from_key('x'), None);
}

#[test]
fn dashboard_renders_plain_scrin_buffer() {
    let dashboard = sample_dashboard();
    let output = dashboard.render_plain(96, 24);

    assert!(output.contains("motors=2"));
    assert!(output.contains("left-knee"));
    assert!(output.contains("right-hip"));
    assert!(output.contains("Controls"));
}

#[test]
fn dashboard_renders_wide_pane_wall_metrics() {
    let dashboard = sample_dashboard();
    let output = dashboard.render_plain(128, 24);

    assert!(output.contains("Selected"));
    assert!(output.contains(ActuatorHealth::Nominal.label()));
    assert!(output.contains("left-knee"));
    assert!(output.contains("right-hip"));
    assert!(output.contains("pos"));
    assert!(output.contains("deg"));
    assert!(output.contains("rad"));
}

#[test]
fn dashboard_tracks_read_only_analytics() {
    let mut dashboard = sample_dashboard();

    assert_eq!(dashboard.analytics().len(), 2);
    assert_eq!(dashboard.selected_analytics().unwrap().samples, 1);
    assert_eq!(
        dashboard.selected_analytics().unwrap().age_ticks(0),
        Some(0)
    );

    dashboard.advance_tick();
    dashboard.upsert_snapshot(ActuatorSnapshot {
        config: ActuatorConfig {
            id: 3,
            label: Some("left-knee".to_owned()),
            protocol: ActuatorProtocol::CubeMars(MotorModel::Ak60_6),
        },
        feedback: Some(ActuatorFeedback::CubeMars(MotorFeedback {
            reported_motor_id: Some(3),
            position_degrees: 15.0,
            speed_erpm: 900,
            current_amps: -2.4,
            temperature_celsius: 35,
            error_code: 0,
        })),
    });

    let analytics = dashboard.selected_analytics().unwrap();
    assert_eq!(analytics.samples, 2);
    assert_eq!(analytics.age_ticks(1), Some(0));
    assert!((analytics.position_span().unwrap() - 2.5).abs() < f64::EPSILON);
    assert!((analytics.peak_abs_effort.unwrap() - 2.4).abs() < f64::EPSILON);

    dashboard.advance_tick();
    dashboard.upsert_snapshot(ActuatorSnapshot {
        config: ActuatorConfig {
            id: 3,
            label: Some("left-knee".to_owned()),
            protocol: ActuatorProtocol::CubeMars(MotorModel::Ak60_6),
        },
        feedback: Some(ActuatorFeedback::CubeMars(MotorFeedback {
            reported_motor_id: Some(3),
            position_degrees: 14.0,
            speed_erpm: 700,
            current_amps: 0.5,
            temperature_celsius: 35,
            error_code: 2,
        })),
    });

    let analytics = dashboard.selected_analytics().unwrap();
    assert_eq!(analytics.samples, 3);
    assert_eq!(analytics.fault_samples, 1);
    assert_eq!(analytics.age_ticks(2), Some(0));

    let output = dashboard.render_plain(128, 24);
    assert!(output.contains("samples=3"));
    assert!(output.contains("faults=1"));
    assert!(output.contains("span=2.500 deg"));
    assert!(output.contains("peak=2.40 A"));
}

#[test]
fn can_monitor_view_renders_frames_and_stats() {
    let monitor = TuiCanMonitor::sample().unwrap();
    let output = monitor.render_plain(110, 28);

    assert!(output.contains("CAN monitor"));
    assert!(output.contains("frames=3"));
    assert!(output.contains("standard 0x123"));
    assert!(output.contains("Frame data"));
}

#[test]
fn can_monitor_pause_and_clear_commands_work() {
    let mut monitor = TuiCanMonitor::sample().unwrap();

    assert!(!monitor.is_paused());
    assert!(!monitor.apply_command(CanMonitorCommand::TogglePause));
    assert!(monitor.is_paused());
    assert_eq!(monitor.last_message(), Some("CAN capture paused"));

    assert!(!monitor.apply_command(CanMonitorCommand::Clear));
    assert_eq!(monitor.monitor().stats().frames_seen, 0);
    assert_eq!(
        monitor.row_strings(),
        vec!["waiting for CAN frames".to_owned()]
    );

    assert!(monitor.apply_command(CanMonitorCommand::Quit));
}

#[test]
fn can_monitor_can_observe_new_frames() {
    let mut monitor = TuiCanMonitor::new(kcan::CanMonitor::new(4));
    monitor.observe(CanFrame::new(CanId::standard(0x123).unwrap(), b"abc").unwrap());

    assert_eq!(monitor.monitor().stats().frames_seen, 1);
    assert!(monitor.row_strings()[0].contains("61 62 63"));
}

#[test]
fn can_monitor_key_mapping_covers_basic_controls() {
    assert_eq!(
        CanMonitorCommand::from_key('j'),
        Some(CanMonitorCommand::SelectNext)
    );
    assert_eq!(
        CanMonitorCommand::from_key(' '),
        Some(CanMonitorCommand::TogglePause)
    );
    assert_eq!(
        CanMonitorCommand::from_key('c'),
        Some(CanMonitorCommand::Clear)
    );
    assert_eq!(CanMonitorCommand::from_key('x'), None);
}

fn sample_dashboard() -> TuiDashboard {
    TuiDashboard::new(vec![
        ActuatorSnapshot {
            config: ActuatorConfig {
                id: 3,
                label: Some("left-knee".to_owned()),
                protocol: ActuatorProtocol::CubeMars(MotorModel::Ak60_6),
            },
            feedback: Some(ActuatorFeedback::CubeMars(MotorFeedback {
                reported_motor_id: Some(3),
                position_degrees: 12.5,
                speed_erpm: 800,
                current_amps: 1.2,
                temperature_celsius: 34,
                error_code: 0,
            })),
        },
        ActuatorSnapshot {
            config: ActuatorConfig {
                id: 4,
                label: Some("right-hip".to_owned()),
                protocol: ActuatorProtocol::RobStride(RobStrideModel::Rs01),
            },
            feedback: Some(ActuatorFeedback::RobStride(RobStrideFeedback {
                position_rad: 0.25,
                velocity_rad_s: 0.1,
                torque_nm: 1.1,
                mode: Some(1),
                error_code: Some(0),
            })),
        },
    ])
}