use std::time::Duration;
use calloop::EventLoop;
use calloop::channel::Event as ChannelEvent;
use tablero::bluetooth::bluetooth_from_bluez;
use tablero::producer::{ProducerBridge, from_fn};
use tablero::render::Bounds;
use tablero::widget::{
Bluetooth, BluetoothState, BluetoothWidget, ClickButton, Dashboard, Msg, Widget,
};
struct Harness {
dashboard: Dashboard,
labels: Vec<String>,
redraws: usize,
}
impl Harness {
fn new() -> Self {
Self {
dashboard: Dashboard::new(vec![Box::new(BluetoothWidget::new(Bounds::new(
0, 0, 320, 32,
)))]),
labels: Vec::new(),
redraws: 0,
}
}
}
fn pump_until(
event_loop: &mut EventLoop<Harness>,
harness: &mut Harness,
done: impl Fn(&Harness) -> bool,
) {
let mut waited = Duration::ZERO;
let step = Duration::from_millis(20);
while !done(harness) && waited < Duration::from_secs(5) {
event_loop
.dispatch(step, harness)
.expect("loop dispatch succeeds");
waited += step;
}
}
fn drive(readings: Vec<(usize, Option<bool>, u32)>) -> (Vec<String>, usize) {
let mut harness = Harness::new();
let mut event_loop: EventLoop<Harness> = EventLoop::try_new().expect("event loop");
let handle = event_loop.handle();
let (bridge, channel) = ProducerBridge::new().expect("runtime starts");
let expected = readings.len();
handle
.insert_source(channel, |event, _, h: &mut Harness| {
if let ChannelEvent::Msg(msg) = event {
if h.dashboard.update(&msg) {
h.redraws += 1;
}
if let Msg::Bluetooth(snapshot) = &msg {
h.labels.push(snapshot.label());
}
}
})
.expect("channel registers");
bridge.spawn(from_fn("fake-bluez", move |tx| async move {
for (adapter_count, powered, connected) in readings {
tx.send(Msg::Bluetooth(bluetooth_from_bluez(
adapter_count,
powered,
connected,
)))?;
}
Ok(())
}));
pump_until(&mut event_loop, &mut harness, |h| {
h.labels.len() >= expected
});
drop(bridge);
(harness.labels, harness.redraws)
}
#[test]
fn common_adapter_states_render_expected_labels() {
let (labels, redraws) = drive(vec![
(0, None, 0),
(1, Some(true), 2),
(1, Some(false), 99),
(1, Some(true), 0),
]);
assert_eq!(
labels,
vec![
"unavailable".to_string(),
"2 connected".to_string(),
"off".to_string(),
"on".to_string(),
]
);
assert_eq!(redraws, 3, "3 distinct state changes repaint once each");
}
#[test]
fn an_unpowered_adapter_with_a_stale_connected_count_renders_off() {
let (labels, _) = drive(vec![(1, Some(false), 99)]);
assert_eq!(labels, vec!["off".to_string()]);
}
#[test]
fn missing_powered_with_a_present_adapter_renders_unavailable() {
let (labels, _) = drive(vec![(1, None, 0)]);
assert_eq!(labels, vec!["unavailable".to_string()]);
}
#[test]
fn an_unavailable_reading_after_unavailable_is_not_a_redraw() {
let (labels, redraws) = drive(vec![(0, None, 0), (0, None, 0)]);
assert_eq!(
labels,
vec!["unavailable".to_string(), "unavailable".to_string()]
);
assert_eq!(redraws, 0, "no-op state changes must not repaint");
}
#[test]
fn an_on_to_off_transition_drives_a_redraw() {
let (labels, redraws) = drive(vec![(1, Some(true), 2), (1, Some(false), 0)]);
assert_eq!(labels, vec!["2 connected".to_string(), "off".to_string()]);
assert_eq!(redraws, 2, "two distinct state changes, two redraws");
}
#[test]
fn the_widget_reserves_a_slot_even_before_any_reading() {
let mut widget = BluetoothWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.label(), "unavailable");
let mut ctx = tablero::render::RenderContext::new(320, 32);
assert!(
widget.measure(&mut ctx, 32) > 0,
"bluetooth widget always measures a non-zero slot width"
);
assert!(!widget.update(&Msg::Bluetooth(Bluetooth::new(
BluetoothState::Unavailable,
0
))));
}
#[test]
fn a_configured_on_click_emits_a_run_program_command() {
use std::path::PathBuf;
use tablero::widget::Command;
let no_click = BluetoothWidget::new(Bounds::new(0, 0, 200, 32));
assert_eq!(no_click.on_click(10, 10, ClickButton::Left), None);
let clicked = BluetoothWidget::new(Bounds::new(0, 0, 200, 32))
.with_on_click(Some(PathBuf::from("/usr/bin/blueman-manager")));
assert_eq!(
clicked.on_click(10, 10, ClickButton::Left),
Some(Command::RunProgram(PathBuf::from(
"/usr/bin/blueman-manager"
)))
);
let far_clicked = BluetoothWidget::new(Bounds::new(50, 10, 200, 32))
.with_on_click(Some(PathBuf::from("/usr/bin/blueman-manager")));
assert_eq!(far_clicked.on_click(0, 0, ClickButton::Left), None);
assert_eq!(far_clicked.on_click(260, 10, ClickButton::Left), None);
}
#[test]
fn a_run_program_command_actually_spawns_the_program() {
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use tablero::command::{command_channel, run_commands};
use tablero::widget::Command;
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("clicked.marker");
let script = dir.path().join("on-click.sh");
std::fs::write(&script, format!("#!/bin/sh\ntouch {}\n", marker.display()))
.expect("write script");
let mut perms = std::fs::metadata(&script).expect("stat").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).expect("chmod");
let widget =
BluetoothWidget::new(Bounds::new(0, 0, 200, 32)).with_on_click(Some(script.clone()));
let Some(Command::RunProgram(path)) = widget.on_click(10, 10, ClickButton::Left) else {
panic!("widget should emit a RunProgram command");
};
assert_eq!(path, PathBuf::from(&script));
let (bridge, _channel) = ProducerBridge::new().expect("runtime starts");
let (cmd_tx, cmd_rx) = command_channel();
bridge.spawn_task("run-commands", run_commands(cmd_rx));
cmd_tx.send(Command::RunProgram(script.clone())).unwrap();
drop(cmd_tx);
let mut waited = Duration::ZERO;
let step = Duration::from_millis(20);
while !marker.exists() && waited < Duration::from_secs(2) {
std::thread::sleep(step);
waited += step;
}
assert!(
marker.exists(),
"executor spawned the script and the marker file appeared"
);
}