diff --git a/Cargo.lock b/Cargo.lock
index 7f77fde..0f81f81 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2723,6 +2723,7 @@ dependencies = [
"reqwest",
"rustls",
"serde",
+ "serde_json",
"sha2 0.11.1",
"sinuous-client",
"tokio",
diff --git a/Cargo.toml b/Cargo.toml
index 5a00bed..5fc266c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,3 +36,6 @@ url = "2"
[profile.release]
strip = "symbols"
+
+[dev-dependencies]
+serde_json = "1.0.154"
diff --git a/README.md b/README.md
index 53cc7a7..12bab72 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,7 @@ sinuous --device "Living Room"
| g | Switch to another group, from a panel showing what each group is playing |
| G | Add speakers to or remove speakers from the group |
| f | Play one of your Sonos favorites |
+| i | System info: the details of each speaker, for troubleshooting |
| , | Settings |
| ? | Keyboard shortcuts |
| q | Close what is open, or quit |
diff --git a/src/sonos.rs b/src/sonos.rs
index cac4766..851f252 100644
--- a/src/sonos.rs
+++ b/src/sonos.rs
@@ -14,7 +14,8 @@ use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use futures::StreamExt;
use sinuous_client::{
- Event, EventPayload, FavoriteId, GroupId, Household, PlayerId, Subscription, Topology,
+ Event, EventPayload, FavoriteId, GroupId, Household, HouseholdId, PlayerId, Subscription,
+ Topology,
favorites::Favorite,
playback::{
LoadAction, LoadOptions, PlayModes, PlayModesUpdate, PlaybackActions, PlaybackState,
@@ -63,6 +64,9 @@ impl ProvidedDevices {
/// What the service knows about the household.
#[derive(Debug, Clone, Default)]
pub struct HouseholdState {
+ pub household_id: Option,
+ /// The groups and players as the household last reported them, with all their details.
+ pub topology: Arc,
/// Sorted by name.
pub groups: Vec,
/// Sorted by name.
@@ -147,6 +151,7 @@ pub struct PlayerInfo {
#[derive(Debug, Clone, Copy)]
pub struct Playback {
+ pub state: PlaybackState,
pub is_playing: bool,
pub play_modes: PlayModes,
/// What the current source supports.
@@ -173,6 +178,7 @@ impl Playback {
fn from_parts(state: PlaybackState, position: Duration, now: Instant) -> Self {
Self {
+ state,
is_playing: matches!(state, PlaybackState::Playing | PlaybackState::Buffering),
play_modes: PlayModes::default(),
actions: PlaybackActions::default(),
@@ -368,7 +374,10 @@ impl SonosHandle {
///
/// The groups and speakers of the household are in the state right away.
pub fn spawn(household: Household) -> SonosHandle {
- let (state_tx, state_rx) = watch::channel(HouseholdState::default());
+ let (state_tx, state_rx) = watch::channel(HouseholdState {
+ household_id: Some(household.id().clone()),
+ ..HouseholdState::default()
+ });
let (cmd_tx, cmd_rx) = mpsc::channel(COMMAND_QUEUE_SIZE);
tokio::spawn(run_commands(household.clone(), cmd_rx, state_tx.clone()));
@@ -536,11 +545,13 @@ impl Service {
/// Update the groups and players from the household's topology, keeping what we know about
/// them.
fn update_groups(&mut self) {
- let (groups, players) = {
+ let (topology, groups, players) = {
let topology = self.topology_rx.borrow_and_update();
- (group_states(&topology), player_infos(&topology))
+ let (groups, players) = (group_states(&topology), player_infos(&topology));
+ (Arc::new(topology.clone()), groups, players)
};
self.state_tx.send_modify(|state| {
+ state.topology = topology;
let old_groups = std::mem::replace(&mut state.groups, groups);
keep_group_states(&mut state.groups, &old_groups);
let old_players = std::mem::replace(&mut state.players, players);
diff --git a/src/theme.rs b/src/theme.rs
index 8202d91..a3f2098 100644
--- a/src/theme.rs
+++ b/src/theme.rs
@@ -57,6 +57,8 @@ pub struct Theme {
pub text: Color,
/// Less important text, e.g. labels.
pub secondary: Color,
+ /// Labels standing out from their values, where both matter, e.g. in the system info.
+ pub label: Color,
/// Unimportant text, e.g. separators, and actions that aren't supported.
pub muted: Color,
/// The name of the app, and titles.
@@ -88,6 +90,7 @@ impl Theme {
selection: None,
text: Color::Reset,
secondary: Color::Gray,
+ label: Color::Cyan,
muted: Color::DarkGray,
title: Color::Yellow,
title_background: Color::DarkGray,
@@ -116,6 +119,7 @@ impl Theme {
selection: Some(color(tokens::BG_SELECTION)),
text: color(tokens::TEXT_PRIMARY),
secondary: color(tokens::TEXT_SECONDARY),
+ label: color(tokens::ACCENT_SECONDARY),
muted: color(tokens::TEXT_DIM),
title: color(tokens::ACCENT_PRIMARY),
title_background: color(tokens::BG_HIGHLIGHT),
diff --git a/src/view.rs b/src/view.rs
index ef72ec5..7de51b0 100644
--- a/src/view.rs
+++ b/src/view.rs
@@ -21,6 +21,7 @@ mod input;
mod now_playing;
mod panel;
mod popup;
+mod system_info;
pub use input::handle_input;
use now_playing::{AlbumArt, now_playing_width, render_now_playing};
@@ -29,6 +30,7 @@ use popup::{
render_config_dialog, render_favorites_picker, render_help, render_playback_menu,
render_theme_picker, render_volume_mixer,
};
+use system_info::render_system_info;
/// How long errors are displayed for.
const ERROR_DISPLAY_TIME: Duration = Duration::from_secs(5);
@@ -68,6 +70,8 @@ enum Mode {
PlaybackMenu(usize),
/// The keyboard shortcuts.
Help,
+ /// The details of each speaker, in place of everything but the title bar.
+ SystemInfo(Scroll),
/// The configuration dialog, and the theme picker on top of it, if open.
Config {
/// The setting highlighted: one of [`UiSetting::ALL`], or the theme after them.
@@ -145,6 +149,15 @@ impl GroupEditor {
}
}
+/// How far a fullscreen view is scrolled.
+#[derive(Debug, Clone, Copy, Default)]
+struct Scroll {
+ /// The first line shown.
+ offset: usize,
+ /// How many lines were shown last time, to scroll by pages.
+ page: usize,
+}
+
/// The highlighted item of a list in the panel on the left, and the first one shown.
#[derive(Debug, Clone, Copy)]
struct PanelHighlight {
@@ -200,16 +213,16 @@ impl UiState {
Some(rows) => clamp(highlighted, rows),
None => self.mode = Mode::NowPlaying,
},
- Mode::NowPlaying | Mode::Help | Mode::Config { .. } => {}
+ Mode::NowPlaying | Mode::Help | Mode::SystemInfo(_) | Mode::Config { .. } => {}
}
}
/// Whether a popup is open in the middle of the screen. The group panel and the group editor
- /// are beside what is playing, not on top of it.
+ /// are beside what is playing, not on top of it, and the system info takes its place.
fn popup_open(&self) -> bool {
!matches!(
self.mode,
- Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_)
+ Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) | Mode::SystemInfo(_)
)
}
@@ -285,6 +298,9 @@ pub fn render_ui(
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(frame.area());
render_title_bar(state, ui, group, symbols, theme, frame, title);
+ if let Mode::SystemInfo(scroll) = &mut ui.mode {
+ return render_system_info(state, theme, scroll, frame, body);
+ }
let panel_help = match ui.mode {
Mode::GroupPicker(_) => Some(GROUP_PANEL_HELP),
Mode::GroupEditor(_) => Some(GROUP_EDITOR_HELP),
@@ -346,7 +362,7 @@ pub fn render_ui(
render_theme_picker(&settings.theme, theme, highlighted, frame);
}
}
- Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) => {}
+ Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) | Mode::SystemInfo(_) => {}
}
}
diff --git a/src/view/input.rs b/src/view/input.rs
index 20b050d..599cc10 100644
--- a/src/view/input.rs
+++ b/src/view/input.rs
@@ -12,7 +12,7 @@ use crate::config::{UiConfig, UiSetting};
use crate::sonos::{Command, GroupAction, GroupState, HouseholdState, PlayerAction};
use crate::theme::THEMES;
-use super::{GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, Selection, UiState};
+use super::{GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, Scroll, Selection, UiState};
/// How much the volume changes with each key press.
const VOLUME_STEP: i8 = 1;
@@ -55,6 +55,10 @@ pub fn handle_input(
Mode::PlaybackMenu(highlighted) => {
handle_playback_menu_input(input, state, ui, highlighted)
}
+ Mode::SystemInfo(scroll) => {
+ handle_system_info_input(input, ui, scroll);
+ None
+ }
Mode::Help => {
if matches!(input.code, KeyCode::Esc | KeyCode::Char('?')) {
ui.mode = Mode::NowPlaying;
@@ -93,6 +97,7 @@ fn handle_now_playing_input(
KeyCode::Char('.') => Mode::PlaybackMenu(0),
KeyCode::Char('?') => Mode::Help,
+ KeyCode::Char('i') => Mode::SystemInfo(Scroll::default()),
KeyCode::Char(',') => Mode::Config {
highlighted: 0,
theme_picker: None,
@@ -169,6 +174,26 @@ fn handle_theme_picker_input(
None
}
+/// Update `ui` according to the key pressed while the system info is open, scrolled to `scroll`.
+fn handle_system_info_input(input: &KeyEvent, ui: &mut UiState, scroll: Scroll) {
+ // Scrolling past the end is undone when drawing it, which is when its length is known.
+ let page = scroll.page.max(1);
+ let offset = match input.code {
+ KeyCode::Up | KeyCode::Char('k') => scroll.offset.saturating_sub(1),
+ KeyCode::Down | KeyCode::Char('j') => scroll.offset.saturating_add(1),
+ KeyCode::PageUp => scroll.offset.saturating_sub(page),
+ KeyCode::PageDown => scroll.offset.saturating_add(page),
+ KeyCode::Home => 0,
+ KeyCode::End => usize::MAX,
+ KeyCode::Esc | KeyCode::Char('i') => {
+ ui.mode = Mode::NowPlaying;
+ return;
+ }
+ _ => return,
+ };
+ ui.mode = Mode::SystemInfo(Scroll { offset, ..scroll });
+}
+
/// Update `ui` according to the key pressed while the group picker is open, with the group at
/// `highlight` highlighted.
fn handle_group_picker_input(
@@ -618,7 +643,7 @@ mod tests {
let mut ui = UiState::default();
ui.sync(&state);
- for opening in [',', 'v', '.', 'f', 'g', 'G', '?'] {
+ for opening in [',', 'v', '.', 'f', 'g', 'G', 'i', '?'] {
assert!(press(&[KeyCode::Char(opening)], &state, &mut ui).is_none());
assert!(!matches!(ui.mode, Mode::NowPlaying), "{opening}");
assert!(press(&[KeyCode::Char('q')], &state, &mut ui).is_none());
diff --git a/src/view/popup.rs b/src/view/popup.rs
index 5223dca..b03d060 100644
--- a/src/view/popup.rs
+++ b/src/view/popup.rs
@@ -180,6 +180,7 @@ pub(super) fn render_help(theme: &Theme, frame: &mut Frame) {
("f", "Play a favorite"),
("g", "Switch to another group"),
("G", "Add or remove speakers from the group"),
+ ("i", "System info, for troubleshooting"),
(",", "Configuration"),
("?", "This help"),
("q", "Close what is open, or quit"),
diff --git a/src/view/system_info.rs b/src/view/system_info.rs
new file mode 100644
index 0000000..e116b30
--- /dev/null
+++ b/src/view/system_info.rs
@@ -0,0 +1,415 @@
+//! The system info: the details of each speaker, for troubleshooting.
+
+use ratatui::{
+ Frame,
+ layout::{Margin, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{
+ Block, BorderType::Rounded, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
+ ScrollbarState,
+ },
+};
+use sinuous_client::{
+ groups::{Capability, DeviceInfo, Player},
+ playback::PlaybackState,
+};
+
+use crate::sonos::{HouseholdState, PlayerInfo};
+use crate::theme::Theme;
+
+use super::Scroll;
+
+/// The keys to use in the system info.
+const HELP: &str = " ↑↓ scroll • PGUP/PGDN page • ESC close ";
+
+/// How wide labels are, for the values to line up.
+const LABEL_WIDTH: usize = 14;
+
+/// How far the details of the devices of a speaker are indented.
+const DEVICE_INDENT: usize = 2;
+
+/// The details of the household and of each of its speakers, in `area`, scrolled to `scroll`.
+pub(super) fn render_system_info(
+ state: &HouseholdState,
+ theme: &Theme,
+ scroll: &mut Scroll,
+ frame: &mut Frame,
+ area: Rect,
+) {
+ let block = Block::bordered()
+ .border_type(Rounded)
+ .border_style(Style::default().fg(theme.border))
+ .padding(Padding::horizontal(1))
+ .title_top(" System info ")
+ .title_bottom(
+ Line::from(HELP)
+ .centered()
+ .style(Style::default().fg(theme.muted)),
+ );
+ let inner = block.inner(area);
+ let lines = system_info_lines(state, theme, usize::from(inner.width));
+
+ // Don't scroll past the last line.
+ let len = lines.len();
+ scroll.page = usize::from(inner.height);
+ let max_offset = len.saturating_sub(scroll.page);
+ scroll.offset = scroll.offset.min(max_offset);
+
+ let offset = u16::try_from(scroll.offset).unwrap_or(u16::MAX);
+ frame.render_widget(Paragraph::new(lines).block(block).scroll((offset, 0)), area);
+ if max_offset > 0 {
+ let mut scrollbar = ScrollbarState::new(max_offset).position(scroll.offset);
+ frame.render_stateful_widget(
+ Scrollbar::new(ScrollbarOrientation::VerticalRight)
+ .begin_symbol(None)
+ .end_symbol(None)
+ .track_style(Style::default().fg(theme.border))
+ .thumb_style(Style::default().fg(theme.secondary)),
+ area.inner(Margin::new(0, 1)),
+ &mut scrollbar,
+ );
+ }
+}
+
+/// The lines to show, with long lists wrapped to fit in `width`.
+fn system_info_lines(state: &HouseholdState, theme: &Theme, width: usize) -> Vec> {
+ let rows = Rows { theme, width };
+ let household = state
+ .household_id
+ .as_ref()
+ .map_or_else(|| "unknown".to_owned(), ToString::to_string);
+ let mut lines = vec![
+ rows.row(0, "Household", household),
+ rows.row(0, "Speakers", state.players.len().to_string()),
+ rows.row(0, "Groups", state.groups.len().to_string()),
+ ];
+ for player in &state.players {
+ lines.push(Line::default());
+ lines.extend(player_lines(state, player, &rows));
+ }
+ lines
+}
+
+/// The details of `player`, and of each of its devices.
+fn player_lines(state: &HouseholdState, player: &PlayerInfo, rows: &Rows) -> Vec> {
+ let theme = rows.theme;
+ let mut lines = vec![Line::styled(
+ player.name.clone(),
+ Style::default()
+ .fg(theme.title)
+ .add_modifier(Modifier::BOLD),
+ )];
+
+ if let Some(group) = state
+ .groups
+ .iter()
+ .find(|g| g.player_ids.contains(&player.id))
+ {
+ let mut value = group.name.clone();
+ if group.coordinator_id == player.id {
+ value.push_str(" · coordinator");
+ }
+ if let Some(playback) = group.playback {
+ value.push_str(" · ");
+ value.push_str(playback_state(playback.state));
+ }
+ lines.push(rows.row(0, "Group", value));
+ }
+ let volume = player
+ .volume
+ .map_or_else(|| "unknown".to_owned(), |volume| volume.to_string());
+ lines.push(rows.row(0, "Volume", volume));
+
+ let Some(details) = state.topology.players.get(&player.id) else {
+ return lines;
+ };
+ lines.extend([
+ rows.row(0, "Address", address(&details.websocket_url)),
+ rows.row(0, "ID", details.id.to_string()),
+ rows.row(0, "Software", details.software_version.clone()),
+ rows.row(
+ 0,
+ "API version",
+ format!("{} (min {})", details.api_version, details.min_api_version),
+ ),
+ ]);
+ let registered = if details.is_unregistered {
+ Span::styled("no", Style::default().fg(theme.error))
+ } else {
+ Span::raw("yes")
+ };
+ lines.push(rows.row(0, "Registered", registered));
+ lines.extend(rows.list(0, "Capabilities", capabilities(details.capabilities.iter())));
+
+ for device in &details.devices {
+ lines.extend(device_lines(details, device, rows));
+ }
+ lines
+}
+
+/// The details of `device`, one of the devices making up `player`.
+fn device_lines(player: &Player, device: &DeviceInfo, rows: &Rows) -> Vec> {
+ let mut lines = vec![
+ rows.row(
+ 0,
+ "Device",
+ Span::styled(
+ device.model_display_name.clone(),
+ Style::default().fg(rows.theme.name),
+ ),
+ ),
+ rows.row(DEVICE_INDENT, "Model", device.model.clone()),
+ ];
+ // The main device of a player has its ID and address.
+ if device.id != player.id {
+ lines.push(rows.row(DEVICE_INDENT, "ID", device.id.to_string()));
+ lines.push(rows.row(DEVICE_INDENT, "Address", address(&device.websocket_url)));
+ }
+ lines.extend([
+ rows.row(DEVICE_INDENT, "Serial", device.serial_number.clone()),
+ rows.row(DEVICE_INDENT, "Hardware", device.hw_version.clone()),
+ rows.row(
+ DEVICE_INDENT,
+ "Software",
+ format!("{} (Sonos S{})", device.software_version, device.sw_gen),
+ ),
+ ]);
+ if let Some(color) = &device.color {
+ lines.push(rows.row(DEVICE_INDENT, "Colour", color.clone()));
+ }
+ lines
+}
+
+/// Lays out labelled values.
+struct Rows<'a> {
+ theme: &'a Theme,
+ /// How wide lines may be.
+ width: usize,
+}
+
+impl Rows<'_> {
+ /// `value`, labelled with `label`, indented by `indent`.
+ fn row(&self, indent: usize, label: &str, value: impl Into>) -> Line<'static> {
+ let label = format!(
+ "{}{label:) -> Vec> {
+ let available = self.width.saturating_sub(LABEL_WIDTH).max(1);
+ let mut values: Vec = Vec::new();
+ for (i, item) in items.iter().enumerate() {
+ let item = if i + 1 < items.len() {
+ format!("{item},")
+ } else {
+ (*item).to_owned()
+ };
+ match values.last_mut() {
+ Some(line) if line.len() + 1 + item.len() <= available => {
+ line.push(' ');
+ line.push_str(&item);
+ }
+ _ => values.push(item),
+ }
+ }
+ if values.is_empty() {
+ values.push("none".to_owned());
+ }
+ values
+ .into_iter()
+ .enumerate()
+ .map(|(i, value)| self.row(indent, if i == 0 { label } else { "" }, value))
+ .collect()
+ }
+}
+
+/// The IP address (or host name) of a speaker, from the URL of its websocket.
+fn address(websocket_url: &url::Url) -> String {
+ websocket_url
+ .host_str()
+ .map_or_else(|| websocket_url.to_string(), ToOwned::to_owned)
+}
+
+fn playback_state(state: PlaybackState) -> &'static str {
+ match state {
+ PlaybackState::Buffering => "buffering",
+ PlaybackState::Idle => "idle",
+ PlaybackState::Paused => "paused",
+ PlaybackState::Playing => "playing",
+ PlaybackState::Unknown => "unknown",
+ }
+}
+
+/// The names of `capabilities`, as the Sonos API calls them, to be looked up in its documentation.
+fn capabilities(capabilities: impl Iterator- ) -> Vec<&'static str> {
+ capabilities
+ .map(|capability| match capability {
+ Capability::Playback => "PLAYBACK",
+ Capability::Cloud => "CLOUD",
+ Capability::HtPlayback => "HT_PLAYBACK",
+ Capability::HtPowerState => "HT_POWER_STATE",
+ Capability::Airplay => "AIRPLAY",
+ Capability::LineIn => "LINE_IN",
+ Capability::AudioClip => "AUDIO_CLIP",
+ Capability::Voice => "VOICE",
+ Capability::SpeakerDetection => "SPEAKER_DETECTION",
+ Capability::FixedVolume => "FIXED_VOLUME",
+ Capability::MicrophoneSwitch => "MICROPHONE_SWITCH",
+ Capability::IrControl => "IR_CONTROL",
+ Capability::Hdmi => "HDMI",
+ Capability::Svc => "SVC",
+ Capability::Unknown => "unknown",
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+
+ use crossterm::event::KeyCode;
+ use ratatui::{Terminal, backend::TestBackend};
+ use sinuous_client::Topology;
+
+ use super::*;
+ use crate::artwork::Artwork;
+ use crate::config::UiConfig;
+ use crate::view::tests::{household, press};
+ use crate::view::{UiState, render_ui};
+
+ /// The bedroom, as a soundbar with a sub.
+ fn bedroom() -> Player {
+ let device = |id: &str, model: &str, name: &str, ip: &str| {
+ serde_json::json!({
+ "_objectType": "deviceInfo",
+ "id": id,
+ "name": "Bedroom",
+ "serialNumber": format!("{id}-serial"),
+ "model": model,
+ "modelDisplayName": name,
+ "color": "Black",
+ "capabilities": ["PLAYBACK"],
+ "apiVersion": "1.44.1",
+ "minApiVersion": "1.1.0",
+ "websocketUrl": format!("wss://{ip}:1443/websocket/api"),
+ "softwareVersion": "85.0-65270",
+ "hwVersion": "1.36.1.2-1.0",
+ "swGen": 2,
+ })
+ };
+ serde_json::from_value(serde_json::json!({
+ "_objectType": "player",
+ "id": "b",
+ "name": "Bedroom",
+ "websocketUrl": "wss://10.0.0.2:1443/websocket/api",
+ "apiVersion": "1.44.1",
+ "minApiVersion": "1.1.0",
+ "softwareVersion": "85.0-65270",
+ "capabilities": [
+ "PLAYBACK", "CLOUD", "HT_PLAYBACK", "HT_POWER_STATE", "AIRPLAY",
+ "AUDIO_CLIP", "VOICE", "SPEAKER_DETECTION", "FIXED_VOLUME",
+ ],
+ "isUnregistered": false,
+ "deviceIds": ["b", "sub"],
+ "devices": [
+ device("b", "S45", "Arc Ultra", "10.0.0.2"),
+ device("sub", "S41", "Sub 4", "10.0.0.3"),
+ ],
+ }))
+ .unwrap()
+ }
+
+ fn screen(state: &HouseholdState, ui: &mut UiState, width: u16, height: u16) -> Vec {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
+ let (mut artwork, _) = Artwork::new(ratatui_image::picker::Picker::halfblocks(), None);
+ terminal
+ .draw(|f| {
+ render_ui(
+ f,
+ state,
+ ui,
+ &mut artwork,
+ &UiConfig::default(),
+ &Theme::TERMINAL,
+ );
+ })
+ .unwrap();
+ let buffer = terminal.backend().buffer();
+ (0..buffer.area.height)
+ .map(|y| {
+ (0..buffer.area.width)
+ .map(|x| buffer[(x, y)].symbol())
+ .collect()
+ })
+ .collect()
+ }
+
+ fn state() -> HouseholdState {
+ let mut topology = Topology::default();
+ topology.players.insert_overwrite(bedroom());
+ HouseholdState {
+ household_id: Some("Sonos_1".into()),
+ topology: Arc::new(topology),
+ ..household()
+ }
+ }
+
+ #[test]
+ fn shows_the_details_of_speakers_and_their_devices() {
+ let state = state();
+ let mut ui = UiState::default();
+ ui.sync(&state);
+ press(&[KeyCode::Char('i')], &state, &mut ui);
+
+ let screen = screen(&state, &mut ui, 60, 60).join("\n");
+ for shown in [
+ "Household Sonos_1",
+ "Group Bedroom + Office · coordinator",
+ "Address 10.0.0.2",
+ "Device Arc Ultra",
+ " Model S45",
+ "Device Sub 4",
+ " Address 10.0.0.3",
+ " Software 85.0-65270 (Sonos S2)",
+ // Speakers the topology doesn't know about yet still show what is known.
+ "Kitchen",
+ ] {
+ assert!(screen.contains(shown), "{shown}\n{screen}");
+ }
+ // The capabilities are wrapped, and lined up.
+ assert!(
+ screen.contains("Capabilities PLAYBACK, CLOUD,"),
+ "{screen}"
+ );
+ assert!(
+ screen.contains(" VOICE, SPEAKER_DETECTION"),
+ "{screen}"
+ );
+ // The main device has the speaker's address, so it isn't repeated.
+ assert_eq!(screen.matches("10.0.0.2").count(), 1, "{screen}");
+ }
+
+ #[test]
+ fn scrolls_no_further_than_the_last_line() {
+ let state = state();
+ let mut ui = UiState::default();
+ ui.sync(&state);
+ press(&[KeyCode::Char('i'), KeyCode::End], &state, &mut ui);
+
+ let at_end = screen(&state, &mut ui, 60, 12);
+ // The title bar, then the border: the last line is just above the bottom border.
+ assert!(at_end[10].contains("Volume"), "{at_end:#?}");
+
+ press(&[KeyCode::Down], &state, &mut ui);
+ assert_eq!(screen(&state, &mut ui, 60, 12), at_end);
+ }
+}