#![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),
})),
},
])
}