use std::collections::{HashMap, VecDeque};
use std::sync::mpsc::Receiver;
use eframe::egui;
use nord_usb::wire::{Bank, Dependency, ProgramInfo, Status};
use nord_usb::{Location, ObjectClass};
use crate::log::Log;
use crate::strings::{folder, place, shown};
use crate::tabs::Tabs;
use crate::workspace::{Origin, Workspace};
mod scan;
mod worker;
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(not(target_arch = "wasm32"))]
use native::Link;
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
use web::Link;
pub use scan::{Progress, Scan};
pub use worker::{Emit, Flow};
pub const BROWSED: [ObjectClass; 6] = [
ObjectClass::Program,
ObjectClass::SetList,
ObjectClass::Sample,
ObjectClass::Piano,
ObjectClass::Live,
ObjectClass::Settings,
];
#[derive(Clone)]
pub enum DeviceCmd {
ScanClass {
class: ObjectClass,
slots: u32,
banks: u32,
},
ScanBank {
class: ObjectClass,
bank: u32,
slots: u32,
},
SlotInfo {
class: ObjectClass,
at: Location,
},
Deps {
class: ObjectClass,
at: Location,
},
Get {
class: ObjectClass,
at: Location,
body: bool,
open: bool,
},
Put {
id: u64,
class: ObjectClass,
at: Location,
name: String,
bytes: Vec<u8>,
},
SendAll {
class: ObjectClass,
items: Vec<Outgoing>,
},
Move {
class: ObjectClass,
from: Location,
to: Location,
},
Duplicate {
class: ObjectClass,
from: Location,
to: Location,
},
Delete {
class: ObjectClass,
at: Location,
},
Rename {
class: ObjectClass,
at: Location,
name: String,
},
Select {
class: ObjectClass,
at: Location,
},
Disconnect,
}
#[derive(Clone)]
pub struct Outgoing {
pub id: u64,
pub at: Location,
pub name: String,
pub bytes: Vec<u8>,
}
#[derive(Clone)]
pub struct Words {
pub doing: String,
pub done: String,
pub failed: String,
}
fn words(verbs: (&str, &str, &str), what: String) -> Words {
Words {
doing: format!("{} {what}…", verbs.0),
done: format!("{} {what}", verbs.1),
failed: format!("Could not {} {what}", verbs.2),
}
}
const READING: (&str, &str, &str) = ("Reading", "Read", "read");
const COPYING: (&str, &str, &str) = ("Copying", "Copied", "copy");
impl DeviceCmd {
pub fn label(&self) -> String {
match self {
DeviceCmd::ScanClass { class, .. } => format!("scan {}", class.label()),
DeviceCmd::ScanBank { bank, .. } => format!("scan bank {bank}"),
DeviceCmd::SlotInfo { at, .. } => format!("info {}", shown(*at)),
DeviceCmd::Deps { at, .. } => format!("deps {}", shown(*at)),
DeviceCmd::Get { at, body, .. } => match body {
true => format!("get {} (raw body)", shown(*at)),
false => format!("get {}", shown(*at)),
},
DeviceCmd::Put { at, name, .. } => format!("put {name} -> {}", shown(*at)),
DeviceCmd::SendAll { class, items } => {
format!("put {} objects -> {}", items.len(), class.label())
}
DeviceCmd::Move { from, to, .. } => {
format!("move {} -> {}", shown(*from), shown(*to))
}
DeviceCmd::Duplicate { from, to, .. } => {
format!("duplicate {} -> {}", shown(*from), shown(*to))
}
DeviceCmd::Delete { at, .. } => format!("delete {}", shown(*at)),
DeviceCmd::Rename { at, name, .. } => format!("rename {} to {name:?}", shown(*at)),
DeviceCmd::Select { at, .. } => format!("select {}", shown(*at)),
DeviceCmd::Disconnect => "disconnect".into(),
}
}
pub fn words(&self) -> Words {
match self {
DeviceCmd::ScanClass { class, .. } => words(READING, folder(*class).to_string()),
DeviceCmd::ScanBank { class, bank, .. } => {
words(READING, format!("{} — bank {bank}", folder(*class)))
}
DeviceCmd::SlotInfo { class, at } => words(READING, place(*class, *at)),
DeviceCmd::Deps { class, at } => {
words(READING, format!("what {} needs", place(*class, *at)))
}
DeviceCmd::Get { class, at, .. } => {
words(COPYING, format!("{} to this computer", place(*class, *at)))
}
DeviceCmd::Put {
class, at, name, ..
} => words(
("Sending", "Sent", "send"),
format!("“{name}” to {}", place(*class, *at)),
),
DeviceCmd::SendAll { class, items } => words(
("Sending", "Sent", "send"),
match items.len() {
1 => format!("1 sound to {}", folder(*class)),
n => format!("{n} sounds to {}", folder(*class)),
},
),
DeviceCmd::Move { class, from, to } => words(
("Moving", "Moved", "move"),
format!("{} to {}", place(*class, *from), place(*class, *to)),
),
DeviceCmd::Duplicate { class, from, to } => words(
COPYING,
format!("{} to {}", place(*class, *from), place(*class, *to)),
),
DeviceCmd::Delete { class, at } => {
words(("Deleting", "Deleted", "delete"), place(*class, *at))
}
DeviceCmd::Rename { class, at, name } => words(
("Renaming", "Renamed", "rename"),
format!("{} to “{name}”", place(*class, *at)),
),
DeviceCmd::Select { class, at } => words(
("Loading", "Loaded", "load"),
format!("{} on the instrument", place(*class, *at)),
),
DeviceCmd::Disconnect => words(
("Releasing", "Released", "release"),
"the instrument".into(),
),
}
}
}
pub enum DeviceEvent {
Connected(DeviceCard),
ConnectFailed(String),
Disconnected {
lost: bool,
},
Started(String),
Finished,
ClassStatus {
class: ObjectClass,
status: Status,
banks: Option<u32>,
},
Geometry {
class: ObjectClass,
banks: Vec<Bank>,
},
Focus {
class: ObjectClass,
at: Option<Location>,
},
BankScanned {
class: ObjectClass,
bank: u32,
slots: Vec<Option<ProgramInfo>>,
},
SlotInfo {
class: ObjectClass,
at: Location,
info: Option<ProgramInfo>,
},
Deps {
class: ObjectClass,
at: Location,
deps: Vec<Dependency>,
},
Got {
name: String,
origin: Origin,
bytes: Vec<u8>,
open: bool,
},
Sent {
id: u64,
class: ObjectClass,
at: Location,
},
Rescued {
at: Location,
name: String,
bytes: Vec<u8>,
},
Note(String),
OpOk(String),
OpFailed(String),
InstrumentChanged,
}
#[derive(Clone)]
pub struct DeviceCard {
pub product: String,
pub manufacturer: Option<String>,
pub vendor_id: u16,
pub product_id: u16,
pub serial: Option<String>,
pub interface: Option<u8>,
pub firmware: Option<u16>,
pub build: Option<u16>,
pub kind: Option<u16>,
pub max_transfer: Option<u32>,
}
#[derive(Default)]
pub enum Connection {
#[default]
Disconnected,
Connecting,
Connected(DeviceCard),
}
#[derive(Default)]
pub struct Detail {
pub at: Option<Location>,
pub info: Option<ProgramInfo>,
pub asked: bool,
pub deps: Option<Vec<Dependency>>,
}
#[derive(Default)]
pub struct DeviceState {
pub connection: Connection,
pub in_flight: Option<Words>,
pub inventory: Vec<Status>,
pub scan: Scan,
selected: HashMap<u32, Location>,
focus: HashMap<u32, Option<Location>>,
geometry: HashMap<u32, Vec<Bank>>,
banks: HashMap<(u32, u32), Vec<Option<ProgramInfo>>>,
pub detail: Detail,
}
impl DeviceState {
pub fn connected(&self) -> bool {
matches!(self.connection, Connection::Connected(_))
}
pub fn product(&self) -> Option<&str> {
self.card().map(|card| card.product.as_str())
}
pub fn card(&self) -> Option<&DeviceCard> {
match &self.connection {
Connection::Connected(card) => Some(card),
_ => None,
}
}
pub fn firmware(&self) -> Option<String> {
self.card()?
.firmware
.map(|held| format!("{}.{:02}", held / 100, held % 100))
}
pub fn bank_name(&self, class: ObjectClass, bank: u32) -> Option<&str> {
let name = self
.geometry
.get(&class.to_raw())?
.iter()
.find(|held| held.index + 1 == bank)?
.name
.trim();
(!name.is_empty()).then_some(name)
}
pub fn slots_in(&self, class: ObjectClass, bank: u32) -> Option<u32> {
self.geometry
.get(&class.to_raw())?
.iter()
.find(|held| held.index + 1 == bank)
.filter(|held| held.is_bounded())
.map(|held| held.slots)
}
pub fn focused(&self, class: ObjectClass) -> Option<Location> {
self.focus.get(&class.to_raw()).copied().flatten()
}
pub fn focus_applies(&self, class: ObjectClass) -> bool {
self.focus.contains_key(&class.to_raw())
}
pub fn dependency_name(
&self,
slot: Option<(ObjectClass, Location)>,
class: ObjectClass,
id: u32,
) -> Option<&str> {
let (_, at) = slot?;
if self.detail.at != Some(at) {
return None;
}
self.detail
.deps
.as_ref()?
.iter()
.find(|dep| dep.class == class && dep.id == id)
.map(|dep| dep.name.trim())
}
pub fn bank(&self, class: ObjectClass, bank: u32) -> Option<&[Option<ProgramInfo>]> {
self.banks.get(&(class.to_raw(), bank)).map(Vec::as_slice)
}
pub fn banks_of(&self, class: ObjectClass) -> Vec<u32> {
let mut banks: Vec<u32> = self
.banks
.keys()
.filter(|(raw, _)| *raw == class.to_raw())
.map(|(_, bank)| *bank)
.collect();
banks.sort_unstable();
banks
}
pub fn slot(&self, class: ObjectClass, at: Location) -> Option<Option<&ProgramInfo>> {
let bank = self.bank(class, at.bank + 1)?;
bank.get(at.slot as usize).map(Option::as_ref)
}
pub fn formats_in(&self, class: ObjectClass) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for bank in self.banks_of(class) {
for info in self.bank(class, bank).into_iter().flatten().flatten() {
let format = info.format.trim();
if !format.is_empty() && !seen.iter().any(|held| held == format) {
seen.push(format.to_string());
}
}
}
seen
}
pub fn first_free(&self, class: ObjectClass) -> Option<Location> {
self.banks_of(class).into_iter().find_map(|bank| {
let slots = self.bank(class, bank)?;
let slot = slots.iter().position(Option::is_none)?;
Some(Location::from_user(bank, slot as u32 + 1))
})
}
fn forget_bank(&mut self, class: ObjectClass, bank: u32) {
self.banks.remove(&(class.to_raw(), bank));
}
fn forget_everything(&mut self) {
self.banks.clear();
self.focus.clear();
self.geometry.clear();
self.inventory.clear();
self.detail = Detail::default();
self.scan.clear();
self.selected.clear();
}
}
pub const MAX_BANKS: u32 = 32;
pub fn slots_per_bank(class: ObjectClass) -> u32 {
match class {
ObjectClass::Live => 3,
ObjectClass::Settings => 1,
_ => 50,
}
}
pub fn occupancy(class: ObjectClass, inventory: &[Status]) -> Option<String> {
let status = inventory.iter().find(|status| status.class == class)?;
Some(match status.slots() {
Some(slots) => format!("{}/{slots}", status.count),
None => format!("{} items", status.count),
})
}
pub fn read_only(class: ObjectClass) -> bool {
matches!(class, ObjectClass::Piano)
}
pub fn sendable(class: ObjectClass) -> bool {
!read_only(class)
}
pub fn write_warning(class: ObjectClass) -> Option<&'static str> {
match class {
ObjectClass::Settings => Some(
"Writing settings reloads the selected program on the instrument. \
Panel changes that have not been stored will be lost.",
),
_ => None,
}
}
pub struct Device {
pub state: DeviceState,
events: Receiver<DeviceEvent>,
#[cfg(test)]
from_worker: std::sync::mpsc::Sender<DeviceEvent>,
link: Link,
pending: VecDeque<DeviceCmd>,
reading: Option<ObjectClass>,
rescan: Vec<(ObjectClass, u32)>,
reselect: Vec<(ObjectClass, Location)>,
}
impl Device {
pub fn new(ctx: egui::Context) -> Device {
let (sender, events) = std::sync::mpsc::channel();
Device {
state: DeviceState::default(),
events,
#[cfg(test)]
from_worker: sender.clone(),
link: Link::new(ctx, sender),
pending: VecDeque::new(),
reading: None,
rescan: Vec::new(),
reselect: Vec::new(),
}
}
pub fn connect(&mut self, log: &mut Log) {
if !matches!(self.state.connection, Connection::Disconnected) {
return;
}
self.state.connection = Connection::Connecting;
log.say("Looking for an instrument…");
self.link.connect();
}
pub fn disconnect(&mut self, log: &mut Log) {
if !self.state.connected() {
return;
}
log.say("Releasing the instrument…");
self.pending.clear();
self.state.scan.clear();
self.link.disconnect();
}
pub fn send(&mut self, cmd: DeviceCmd, log: &mut Log) {
if !self.state.connected() {
log.trouble("No instrument is attached.");
return;
}
self.pending.push_back(cmd);
}
pub fn read_class(&mut self, class: ObjectClass) {
self.state.scan.start(class);
}
pub fn resync(&mut self) {
for class in BROWSED {
self.read_class(class);
}
}
pub fn pump(&mut self) {
if !self.state.connected() || self.state.in_flight.is_some() {
return;
}
if let Some(cmd) = self.pending.pop_front() {
self.reading = None;
return self.dispatch(cmd);
}
let Some(class) = self.state.scan.take() else {
return;
};
self.reading = Some(class);
self.dispatch(DeviceCmd::ScanClass {
class,
slots: slots_per_bank(class),
banks: MAX_BANKS,
});
}
fn dispatch(&mut self, cmd: DeviceCmd) {
self.rescan = match &cmd {
DeviceCmd::Delete { class, at }
| DeviceCmd::Rename { class, at, .. }
| DeviceCmd::Put { class, at, .. } => vec![(*class, at.bank + 1)],
DeviceCmd::Move { class, from, to } | DeviceCmd::Duplicate { class, from, to } => {
vec![(*class, from.bank + 1), (*class, to.bank + 1)]
}
DeviceCmd::SendAll { class, items } => {
let mut banks: Vec<(ObjectClass, u32)> = items
.iter()
.map(|item| (*class, item.at.bank + 1))
.collect();
banks.sort_unstable_by_key(|(class, bank)| (class.to_raw(), *bank));
banks.dedup();
banks
}
_ => Vec::new(),
};
for (class, bank) in &self.rescan {
self.state.forget_bank(*class, *bank);
}
let loaded = |state: &DeviceState, class: &ObjectClass, at: &Location| {
state
.selected
.get(&class.to_raw())
.filter(|held| *held == at)
.map(|at| (*class, *at))
};
self.reselect = match &cmd {
DeviceCmd::Put { class, at, .. } | DeviceCmd::Rename { class, at, .. } => {
loaded(&self.state, class, at).into_iter().collect()
}
DeviceCmd::SendAll { class, items } => items
.iter()
.filter_map(|item| loaded(&self.state, class, &item.at))
.collect(),
_ => Vec::new(),
};
if let DeviceCmd::Select { class, at } = &cmd {
self.state.selected.insert(class.to_raw(), *at);
}
self.state.in_flight = Some(cmd.words());
self.link.send(cmd);
}
#[cfg(test)]
pub fn pretend(&mut self, event: DeviceEvent) {
let _ = self.from_worker.send(event);
}
#[cfg(test)]
pub fn queued(&self) -> &VecDeque<DeviceCmd> {
&self.pending
}
#[cfg(test)]
pub fn pretend_scanned(&mut self, class: ObjectClass, bank: u32, names: &[&str]) {
use nord_usb::wire::ProgramInfo;
self.state.connection = Connection::Connected(DeviceCard {
build: Some(7),
firmware: Some(204),
interface: Some(3),
kind: Some(1),
manufacturer: Some("Clavia DMI AB".into()),
max_transfer: Some(4096),
product: "Electro 5".into(),
product_id: 0,
serial: None,
vendor_id: 0x0ffc,
});
let slots = names
.iter()
.enumerate()
.map(|(slot, name)| {
(!name.is_empty()).then(|| ProgramInfo {
location: Location::from_user(bank, slot as u32 + 1),
body_len: 121,
format: "ne5p".into(),
version: 4,
crc32: None,
name: (*name).to_string(),
})
})
.collect();
self.state.banks.insert((class.to_raw(), bank), slots);
}
#[cfg(test)]
pub fn pretend_geometry(&mut self, class: ObjectClass, banks: &[(&str, u32)]) {
let banks = banks
.iter()
.enumerate()
.map(|(index, (name, slots))| Bank {
index: index as u32,
name: (*name).to_string(),
slots: *slots,
})
.collect();
self.state.geometry.insert(class.to_raw(), banks);
}
#[cfg(test)]
pub fn pretend_focused(&mut self, class: ObjectClass, at: Location) {
self.state.focus.insert(class.to_raw(), Some(at));
}
pub fn poll(&mut self, log: &mut Log, workspace: &mut Workspace, tabs: &mut Tabs) {
while let Ok(event) = self.events.try_recv() {
match event {
DeviceEvent::Connected(card) => {
log.info(format!(
"connected: {} ({:04x}:{:04x})",
card.product, card.vendor_id, card.product_id
));
log.say(format!("{} is attached.", card.product));
self.state.connection = Connection::Connected(card);
self.state.forget_everything();
self.pending.clear();
self.resync();
}
DeviceEvent::ConnectFailed(why) => {
log.error(why);
log.trouble("No instrument could be opened.");
self.state.connection = Connection::Disconnected;
}
DeviceEvent::Disconnected { lost } => {
match lost {
true => log.trouble("The instrument went away — reconnect when it's back."),
false => log.say("The instrument was released."),
}
self.state.connection = Connection::Disconnected;
self.state.in_flight = None;
self.state.forget_everything();
self.pending.clear();
self.reading = None;
self.rescan.clear();
self.reselect.clear();
}
DeviceEvent::Started(what) => log.info(what),
DeviceEvent::Finished => {
if let Some(class) = self.reading.take() {
self.state.scan.finished(class);
}
for (class, at) in std::mem::take(&mut self.reselect) {
self.pending.push_back(DeviceCmd::Select { class, at });
}
for (class, bank) in std::mem::take(&mut self.rescan) {
let slots = self
.state
.slots_in(class, bank)
.unwrap_or_else(|| slots_per_bank(class));
self.pending
.push_back(DeviceCmd::ScanBank { class, bank, slots });
}
self.state.in_flight = None;
}
DeviceEvent::ClassStatus {
class,
status,
banks,
} => {
self.state.inventory.retain(|held| held.class != class);
self.state.inventory.push(status);
self.state.scan.expect(class, banks);
}
DeviceEvent::Geometry { class, banks } => {
self.state.geometry.insert(class.to_raw(), banks);
}
DeviceEvent::Focus { class, at } => {
self.state.focus.insert(class.to_raw(), at);
}
DeviceEvent::BankScanned { class, bank, slots } => {
if !slots.is_empty() {
self.state.banks.insert((class.to_raw(), bank), slots);
}
self.state.scan.bank(class, bank);
}
DeviceEvent::SlotInfo { at, info, .. } => {
self.state.detail = Detail {
at: Some(at),
info,
asked: true,
deps: None,
};
}
DeviceEvent::Deps { at, deps, .. } => {
if self.state.detail.at != Some(at) {
self.state.detail = Detail {
at: Some(at),
..Detail::default()
};
}
self.state.detail.deps = Some(deps);
}
DeviceEvent::Got {
name,
origin,
bytes,
open,
} => {
let id = match open {
true => workspace.view(name, origin, bytes, log),
false => workspace.ingest(name, origin, bytes, log),
};
if open {
tabs.open(id, workspace);
}
}
DeviceEvent::Rescued { at, name, bytes } => {
log.error(format!(
"{} could not be restored; its bytes are in the local list as {name}",
shown(at)
));
log.trouble(format!(
"{} is empty — what was in it is on this computer as “{name}”.",
shown(at)
));
workspace.ingest(name, Origin::Rescued { at }, bytes, log);
}
DeviceEvent::Sent { id, .. } => workspace.mark_pending(id, false),
DeviceEvent::Note(text) => log.info(text),
DeviceEvent::OpOk(text) => {
log.info(text);
if let Some(words) = &self.state.in_flight {
log.say(format!("{}.", words.done));
}
}
DeviceEvent::OpFailed(text) => {
log.error(text);
match &self.state.in_flight {
Some(words) => {
let failed = words.failed.clone();
log.trouble(format!("{failed}. The details are below."));
}
None => log.trouble("Something went wrong. The details are below."),
}
}
DeviceEvent::InstrumentChanged => {
log.warn("the instrument changed under us — every cached name is dropped");
log.say("Something changed on the instrument. Reading it again…");
self.resync();
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_browsed_class_but_pianos_can_be_written() {
for class in BROWSED.iter().filter(|c| **c != ObjectClass::Piano) {
assert!(sendable(*class), "{}", folder(*class));
}
assert!(!sendable(ObjectClass::Piano));
}
#[test]
fn a_settings_write_warns_that_the_panel_reloads() {
let why = write_warning(ObjectClass::Settings).expect("must warn");
assert!(why.contains("reloads the selected program"), "{why}");
for class in BROWSED.iter().filter(|c| **c != ObjectClass::Settings) {
assert!(write_warning(*class).is_none(), "{}", folder(*class));
}
}
#[test]
fn only_pianos_are_read_only() {
assert!(read_only(ObjectClass::Piano));
for class in BROWSED.iter().filter(|c| **c != ObjectClass::Piano) {
assert!(!read_only(*class), "{}", folder(*class));
}
}
#[test]
fn a_sent_event_clears_the_object_it_names_and_no_other() {
use crate::workspace::{Fresh, Origin};
let ctx = egui::Context::default();
let mut workspace = Workspace::new(ctx.clone());
let mut device = Device::new(ctx);
let mut log = Log::default();
let mut tabs = Tabs::default();
let bytes = {
let id = workspace.create(Fresh::Program, &mut log).unwrap();
let bytes = workspace.get(id).unwrap().bytes.clone();
workspace.remove(id, &mut log);
bytes
};
let at = |slot| Location { bank: 6, slot };
let landed = workspace.ingest(
"Africa-Split.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at: at(3),
},
bytes.clone(),
&mut log,
);
let still_owed = workspace.ingest(
"Squabble-B.ne5p".into(),
Origin::Device {
class: ObjectClass::Program,
at: at(4),
},
bytes,
&mut log,
);
workspace.mark_pending(landed, true);
workspace.mark_pending(still_owed, true);
device.pretend(DeviceEvent::Sent {
id: landed,
class: ObjectClass::Program,
at: at(3),
});
device.poll(&mut log, &mut workspace, &mut tabs);
assert!(!workspace.get(landed).unwrap().pending, "it was written");
assert!(workspace.get(still_owed).unwrap().pending, "still waiting");
let owed: Vec<u64> = workspace.pending().iter().map(|e| e.id).collect();
assert_eq!(owed, vec![still_owed]);
}
#[test]
fn a_dependency_name_answers_only_for_what_was_asked() {
let at = Location { bank: 6, slot: 3 };
let elsewhere = Location { bank: 0, slot: 0 };
let detail = Detail {
at: Some(at),
info: None,
asked: true,
deps: Some(vec![Dependency {
flag: 0,
class: ObjectClass::Piano,
id: 0x0102_0304,
name: "Royal Grand 3D ".into(),
location: None,
}]),
};
let state = DeviceState {
detail,
..DeviceState::default()
};
let piano = |slot, id| {
state
.dependency_name(Some((ObjectClass::Program, slot)), ObjectClass::Piano, id)
.map(str::to_string)
};
assert_eq!(piano(at, 0x0102_0304).as_deref(), Some("Royal Grand 3D"));
assert_eq!(piano(elsewhere, 0x0102_0304), None, "another slot's list");
assert_eq!(piano(at, 0x0999_0999), None, "an id it did not report");
assert_eq!(
state.dependency_name(
Some((ObjectClass::Program, at)),
ObjectClass::Sample,
0x0102_0304
),
None,
"a piano is not a sample"
);
assert_eq!(state.dependency_name(None, ObjectClass::Piano, 1), None);
}
#[test]
fn the_status_sentences_never_quote_the_protocol() {
let cmd = DeviceCmd::Put {
id: 1,
class: ObjectClass::Program,
at: Location { bank: 6, slot: 3 },
name: "Africa Split".into(),
bytes: Vec::new(),
};
let words = cmd.words();
assert_eq!(words.doing, "Sending “Africa Split” to Programs 7:4…");
assert_eq!(words.done, "Sent “Africa Split” to Programs 7:4");
assert_eq!(
words.failed,
"Could not send “Africa Split” to Programs 7:4"
);
}
#[test]
fn a_place_reads_as_a_folder_and_a_slot() {
let at = Location { bank: 0, slot: 0 };
assert_eq!(place(ObjectClass::SetList, at), "Set lists 1:1");
assert_eq!(folder(ObjectClass::Unknown(9)), "Other");
}
}