1262 lines
43 KiB
Rust
1262 lines
43 KiB
Rust
//! MIDI actually travelling along a route.
|
|
//!
|
|
//! The configuration tests prove a route resolves. This proves bytes move, which is a different
|
|
//! claim and the one that matters.
|
|
|
|
#![allow(
|
|
clippy::expect_used,
|
|
clippy::indexing_slicing,
|
|
clippy::panic,
|
|
clippy::unwrap_used
|
|
)]
|
|
|
|
mod common;
|
|
|
|
use midi_harbor_core::config::RouteConfig;
|
|
use midi_harbor_core::endpoint::Direction;
|
|
use midi_harbor_core::fingerprint::DeviceFingerprint;
|
|
use midi_harbor_core::midi::{Channel, MidiMessage};
|
|
use midi_harbor_core::router::RouteValidity;
|
|
use midi_harbor_daemon::Daemon;
|
|
use midi_harbor_platform::fake::FakeMidiPlatform;
|
|
use midi_harbor_platform::midi::DiscoveredDevice;
|
|
use midi_harbor_platform::midi::{MidiPlatform, PortHandle};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
/// Builds a daemon over a fake platform, rooted in its own temporary directory.
|
|
async fn daemon(label: &str) -> (Arc<Daemon>, Arc<FakeMidiPlatform>) {
|
|
let root =
|
|
common::scratch("midi-harbor-routing").join(format!("{label}-{}", uuid::Uuid::new_v4()));
|
|
let platform = Arc::new(FakeMidiPlatform::new());
|
|
|
|
let daemon = Daemon::start(
|
|
common::quiet(root),
|
|
Arc::clone(&platform) as Arc<dyn MidiPlatform>,
|
|
)
|
|
.await
|
|
.expect("the daemon starts over a scratch directory");
|
|
(daemon, platform)
|
|
}
|
|
|
|
/// Builds a daemon whose configuration, written by hand, gives a port and a network port the name
|
|
/// Keystation, which creating either refuses but a hand-edited file can still hold.
|
|
async fn sharing_a_name(label: &str) -> (Arc<Daemon>, Arc<FakeMidiPlatform>) {
|
|
let root =
|
|
common::scratch("midi-harbor-routing").join(format!("{label}-{}", uuid::Uuid::new_v4()));
|
|
let paths = common::quiet(root);
|
|
std::fs::write(
|
|
paths.config_file(),
|
|
"preferences:\n\
|
|
\x20 advertise_sessions: false\n\
|
|
endpoints:\n\
|
|
- name: Keystation\n\
|
|
\x20 kind: virtual_port\n\
|
|
- name: Keystation\n\
|
|
\x20 kind: network_port\n\
|
|
\x20 control_port: 0\n",
|
|
)
|
|
.expect("the hand-written configuration is saved");
|
|
let platform = Arc::new(FakeMidiPlatform::new());
|
|
|
|
let daemon = Daemon::start(paths, Arc::clone(&platform) as Arc<dyn MidiPlatform>)
|
|
.await
|
|
.expect("the daemon starts over the hand-written configuration");
|
|
(daemon, platform)
|
|
}
|
|
|
|
/// Creates a one-in, one-out virtual port for each name.
|
|
async fn ports(daemon: &Arc<Daemon>, names: &[&str]) {
|
|
for name in names {
|
|
daemon
|
|
.create_virtual_port(name, 1, 1)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("the port {name} is created: {error}"));
|
|
}
|
|
}
|
|
|
|
/// Returns the platform handle backing a named port.
|
|
fn handle_for(platform: &FakeMidiPlatform, name: &str) -> PortHandle {
|
|
platform
|
|
.port_handle(name)
|
|
.unwrap_or_else(|| panic!("no platform handle for {name}"))
|
|
}
|
|
|
|
/// Returns a note-on for `n` on the first channel.
|
|
fn note(n: u8) -> MidiMessage {
|
|
MidiMessage::NoteOn {
|
|
channel: Channel::new(0).expect("channel 0 is a valid channel"),
|
|
note: n,
|
|
velocity: 100,
|
|
}
|
|
}
|
|
|
|
/// Waits for a condition, so the test does not depend on dispatch timing.
|
|
async fn eventually(mut check: impl FnMut() -> bool) -> bool {
|
|
for _ in 0..200 {
|
|
if check() {
|
|
return true;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Returns what a route has carried, once its counters reach at least `least`.
|
|
async fn carried(daemon: &Arc<Daemon>, route: &RouteConfig, least: u64) -> u64 {
|
|
let id = route.id();
|
|
let mut seen = 0;
|
|
for _ in 0..200 {
|
|
seen = daemon
|
|
.route_counters()
|
|
.await
|
|
.get(&id)
|
|
.map(|counters| counters.messages_sent)
|
|
.unwrap_or_default();
|
|
if seen >= least {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
seen
|
|
}
|
|
|
|
/// Proves that MIDI fed into a port reaches every destination routed from it, in the order it
|
|
/// was played, and that each route of the fan-out counts only what it delivered.
|
|
///
|
|
/// The endpoint's own counters cannot say which route is working: one message leaving Keyboard
|
|
/// is two deliveries. A route that has carried nothing reports zero rather than being absent, so
|
|
/// a route row answers "is it carrying" rather than only "is it valid".
|
|
#[tokio::test]
|
|
async fn midi_reaches_every_destination_of_a_fan_out_and_each_route_counts_its_own() {
|
|
let (daemon, platform) = daemon("fanout").await;
|
|
ports(&daemon, &["Keyboard", "Synth", "Recorder"]).await;
|
|
let (to_synth, _) = daemon
|
|
.create_route("Keyboard", "Synth")
|
|
.await
|
|
.expect("the route from Keyboard to Synth is created");
|
|
let (to_recorder, _) = daemon
|
|
.create_route("Keyboard", "Recorder")
|
|
.await
|
|
.expect("the route from Keyboard to Recorder is created");
|
|
|
|
let counters = daemon.route_counters().await;
|
|
for route in [&to_synth, &to_recorder] {
|
|
assert_eq!(
|
|
counters.get(&route.id()).map(|c| c.messages_sent),
|
|
Some(0),
|
|
"a route that has carried nothing must say zero rather than be absent"
|
|
);
|
|
}
|
|
|
|
// Push MIDI in as another application sending into the port would.
|
|
assert!(
|
|
platform.feed(handle_for(&platform, "Keyboard"), &[note(60), note(64)]),
|
|
"the fake accepts MIDI fed into an open port"
|
|
);
|
|
|
|
for destination in ["Synth", "Recorder"] {
|
|
let handle = handle_for(&platform, destination);
|
|
assert!(
|
|
eventually(|| platform.sent(handle).len() >= 2).await,
|
|
"midi did not reach {destination}"
|
|
);
|
|
assert_eq!(
|
|
platform.sent(handle),
|
|
vec![note(60), note(64)],
|
|
"{destination} must receive both notes once each, in the order they were played"
|
|
);
|
|
}
|
|
// Two notes were fed, so each route delivered two messages.
|
|
assert_eq!(
|
|
carried(&daemon, &to_synth, 2).await,
|
|
2,
|
|
"the route to Synth counts the two notes it delivered, not the fan-out's four"
|
|
);
|
|
assert_eq!(
|
|
carried(&daemon, &to_recorder, 2).await,
|
|
2,
|
|
"the route to Recorder counts the two notes it delivered, not the fan-out's four"
|
|
);
|
|
}
|
|
|
|
/// Proves that MIDI goes only where a route sends it directly.
|
|
///
|
|
/// A port with no route must be inert, not quietly broadcasting to everything. Delivery is
|
|
/// direct, so adding Synth to Recorder must not silently change where Keyboard's traffic goes.
|
|
#[tokio::test]
|
|
async fn midi_goes_nowhere_a_route_does_not_send_it_directly() {
|
|
struct Case {
|
|
name: &'static str,
|
|
routes: &'static [(&'static str, &'static str)],
|
|
reached: Option<&'static str>,
|
|
silent: &'static str,
|
|
}
|
|
let cases = [
|
|
Case {
|
|
name: "no route at all",
|
|
routes: &[],
|
|
reached: None,
|
|
silent: "Synth",
|
|
},
|
|
Case {
|
|
name: "a route onward from the destination",
|
|
routes: &[("Keyboard", "Synth"), ("Synth", "Recorder")],
|
|
reached: Some("Synth"),
|
|
silent: "Recorder",
|
|
},
|
|
];
|
|
|
|
for case in cases {
|
|
let (daemon, platform) = daemon("nowhere").await;
|
|
ports(&daemon, &["Keyboard", "Synth", "Recorder"]).await;
|
|
for (from, to) in case.routes {
|
|
daemon
|
|
.create_route(from, to)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("{}: the route {from} to {to}: {error}", case.name));
|
|
}
|
|
|
|
assert!(
|
|
platform.feed(handle_for(&platform, "Keyboard"), &[note(60)]),
|
|
"{}: the fake accepts MIDI fed into an open port",
|
|
case.name
|
|
);
|
|
if let Some(reached) = case.reached {
|
|
let handle = handle_for(&platform, reached);
|
|
assert!(
|
|
eventually(|| !platform.sent(handle).is_empty()).await,
|
|
"{}: midi did not reach {reached}, which is routed directly",
|
|
case.name
|
|
);
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
|
|
|
assert!(
|
|
platform.sent(handle_for(&platform, case.silent)).is_empty(),
|
|
"{}: midi reached {}, which no route from Keyboard names",
|
|
case.name,
|
|
case.silent
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Proves that a switched-off route carries nothing and that switching it back on resumes
|
|
/// delivery without the route being recreated.
|
|
#[tokio::test]
|
|
async fn a_disabled_route_carries_nothing_then_resumes() {
|
|
let (daemon, platform) = daemon("disabled").await;
|
|
ports(&daemon, &["Keyboard", "Synth"]).await;
|
|
let (route, _) = daemon
|
|
.create_route("Keyboard", "Synth")
|
|
.await
|
|
.expect("the route from Keyboard to Synth is created");
|
|
daemon
|
|
.set_route_enabled(&route.id().to_string(), false)
|
|
.await
|
|
.expect("the route is switched off");
|
|
|
|
assert!(
|
|
platform.feed(handle_for(&platform, "Keyboard"), &[note(60)]),
|
|
"the fake accepts MIDI fed into an open port"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
|
assert!(
|
|
platform.sent(handle_for(&platform, "Synth")).is_empty(),
|
|
"a switched-off route carried MIDI"
|
|
);
|
|
|
|
daemon
|
|
.set_route_enabled(&route.id().to_string(), true)
|
|
.await
|
|
.expect("the route is switched back on");
|
|
assert!(
|
|
platform.feed(handle_for(&platform, "Keyboard"), &[note(62)]),
|
|
"the fake accepts MIDI fed into an open port"
|
|
);
|
|
|
|
let synth = handle_for(&platform, "Synth");
|
|
assert!(
|
|
eventually(|| !platform.sent(synth).is_empty()).await,
|
|
"delivery did not resume when the route was re-enabled"
|
|
);
|
|
}
|
|
|
|
/// Proves that a route's count survives a rename of its source but starts from zero when the
|
|
/// route is deleted and made again.
|
|
///
|
|
/// A rename rewrites routes rather than dropping them, so it must not read as a reset. A remade
|
|
/// route has the same identifier, since it is derived from the pair of names, but it is not the
|
|
/// same route to the person who just deleted it.
|
|
#[tokio::test]
|
|
async fn a_routes_count_survives_a_rename_but_not_being_made_again() {
|
|
#[derive(Clone, Copy)]
|
|
enum Edit {
|
|
RenameSource,
|
|
DeleteAndRemake,
|
|
}
|
|
struct Case {
|
|
name: &'static str,
|
|
edit: Edit,
|
|
from_after: &'static str,
|
|
want: u64,
|
|
}
|
|
let cases = [
|
|
Case {
|
|
name: "source renamed",
|
|
edit: Edit::RenameSource,
|
|
from_after: "Stage Keyboard",
|
|
want: 1,
|
|
},
|
|
Case {
|
|
name: "route deleted and made again",
|
|
edit: Edit::DeleteAndRemake,
|
|
from_after: "Keyboard",
|
|
want: 0,
|
|
},
|
|
];
|
|
|
|
for case in cases {
|
|
let (daemon, platform) = daemon("counted-edit").await;
|
|
ports(&daemon, &["Keyboard", "Synth"]).await;
|
|
let keyboard = daemon
|
|
.resolve("Keyboard")
|
|
.await
|
|
.expect("the keyboard port is listed");
|
|
let (route, _) = daemon
|
|
.create_route("Keyboard", "Synth")
|
|
.await
|
|
.expect("the route from Keyboard to Synth is created");
|
|
assert!(
|
|
platform.feed(handle_for(&platform, "Keyboard"), &[note(60)]),
|
|
"{}: the fake accepts MIDI fed into an open port",
|
|
case.name
|
|
);
|
|
assert_eq!(
|
|
carried(&daemon, &route, 1).await,
|
|
1,
|
|
"{}: one note was fed, so the route carried one message",
|
|
case.name
|
|
);
|
|
|
|
match case.edit {
|
|
Edit::RenameSource => {
|
|
daemon
|
|
.rename_endpoint(keyboard, "Stage Keyboard", true)
|
|
.await
|
|
.expect("the keyboard is renamed");
|
|
}
|
|
Edit::DeleteAndRemake => {
|
|
daemon
|
|
.delete_route(&route.id().to_string())
|
|
.await
|
|
.expect("the route is deleted");
|
|
daemon
|
|
.create_route("Keyboard", "Synth")
|
|
.await
|
|
.expect("the route is made again");
|
|
}
|
|
}
|
|
|
|
let after = RouteConfig {
|
|
from: case.from_after.to_owned(),
|
|
to: "Synth".to_owned(),
|
|
from_kind: None,
|
|
to_kind: None,
|
|
from_connector: None,
|
|
to_connector: None,
|
|
both_ways: false,
|
|
enabled: true,
|
|
};
|
|
assert_eq!(
|
|
daemon
|
|
.route_counters()
|
|
.await
|
|
.get(&after.id())
|
|
.map(|counters| counters.messages_sent),
|
|
Some(case.want),
|
|
"{}: the route's count after the edit",
|
|
case.name
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Proves that renaming a port renames it where other applications see it, keeps its platform
|
|
/// identity, and keeps it carrying MIDI along its rewritten route.
|
|
///
|
|
/// Renaming changed only the configuration, so other applications kept seeing the old name until
|
|
/// the daemon restarted. A new identity would lose every connection other applications had made
|
|
/// to the port.
|
|
#[tokio::test]
|
|
async fn a_renamed_port_is_renamed_where_other_applications_see_it() {
|
|
let (daemon, platform) = daemon("platform-rename").await;
|
|
ports(&daemon, &["Keyboard", "Synth"]).await;
|
|
let keyboard = daemon
|
|
.resolve("Keyboard")
|
|
.await
|
|
.expect("the keyboard port is listed");
|
|
daemon
|
|
.create_route("Keyboard", "Synth")
|
|
.await
|
|
.expect("the route from Keyboard to Synth is created");
|
|
let pinned = |config: &midi_harbor_core::config::Configuration| {
|
|
config.endpoint(keyboard).and_then(|e| match &e.kind {
|
|
midi_harbor_core::endpoint::EndpointKind::VirtualPort(port) => {
|
|
port.output_ids.first().copied()
|
|
}
|
|
_ => None,
|
|
})
|
|
};
|
|
let identity = daemon.read(|config, _| pinned(config)).await;
|
|
assert!(
|
|
identity.is_some(),
|
|
"a created port records the identifier the platform gave it"
|
|
);
|
|
|
|
daemon
|
|
.rename_endpoint(keyboard, "Stage Keyboard", true)
|
|
.await
|
|
.expect("the keyboard is renamed");
|
|
|
|
assert_eq!(
|
|
platform.port_names(),
|
|
vec!["Stage Keyboard".to_owned(), "Synth".to_owned()],
|
|
"other applications must see the new name without a restart"
|
|
);
|
|
assert_eq!(
|
|
daemon.read(|config, _| pinned(config)).await,
|
|
identity,
|
|
"the port came back with a different identity, so connections to it are lost"
|
|
);
|
|
let synth = handle_for(&platform, "Synth");
|
|
assert!(
|
|
platform.feed(handle_for(&platform, "Stage Keyboard"), &[note(61)]),
|
|
"the fake accepts MIDI fed into the renamed port"
|
|
);
|
|
assert!(
|
|
eventually(|| platform.sent(synth).contains(¬e(61))).await,
|
|
"the renamed port no longer carries MIDI along its route"
|
|
);
|
|
}
|
|
|
|
/// Proves that hardware sharing a name with one of the daemon's own ports is still listed.
|
|
///
|
|
/// Our own ports are told apart from hardware by the identifier the platform gave them. Telling
|
|
/// them apart by name made a real Keystation vanish once a port was named after it.
|
|
#[tokio::test]
|
|
async fn a_port_named_after_a_device_does_not_hide_the_device() {
|
|
let (daemon, platform) = daemon("namesake").await;
|
|
ports(&daemon, &["Keystation"]).await;
|
|
platform.attach(DiscoveredDevice {
|
|
fingerprint: DeviceFingerprint {
|
|
name: "Keystation".to_owned(),
|
|
unique_id: Some(0x4B53),
|
|
..DeviceFingerprint::default()
|
|
},
|
|
direction: Direction::Bidirectional,
|
|
claimed_by: None,
|
|
software: false,
|
|
});
|
|
daemon.refresh_devices().await;
|
|
|
|
assert_eq!(
|
|
remembered(&daemon).await,
|
|
vec!["Keystation".to_owned()],
|
|
"the device was hidden behind a port of the same name"
|
|
);
|
|
}
|
|
|
|
/// Proves that a name two endpoints share is refused as ambiguous rather than missing, and that
|
|
/// a route naming its ends by identifier carries from exactly the endpoint it was given.
|
|
///
|
|
/// The contract accepts a name or an identifier, and an identifier is the only way to tell apart
|
|
/// two endpoints sharing a name. Only names were looked up, so a client passing identifiers was
|
|
/// told the endpoints did not exist; then routes stored only names, and resolving them by name
|
|
/// alone bound this one to the network port created after the virtual port, so the port's MIDI
|
|
/// went nowhere.
|
|
#[tokio::test]
|
|
async fn a_shared_name_is_ambiguous_and_an_identifier_says_which_endpoint_a_route_means() {
|
|
let (daemon, platform) = sharing_a_name("by-identifier").await;
|
|
let keys = daemon
|
|
.read(|config, _| {
|
|
config
|
|
.virtual_ports()
|
|
.find(|e| e.name.as_str() == "Keystation")
|
|
.cloned()
|
|
})
|
|
.await
|
|
.expect("the hand-written Keystation port is listed");
|
|
let synth = daemon
|
|
.create_virtual_port("Synth", 1, 1)
|
|
.await
|
|
.expect("the synth port is created");
|
|
|
|
let refused = daemon.create_route("Keystation", "Synth").await;
|
|
assert!(
|
|
matches!(
|
|
refused,
|
|
Err(midi_harbor_daemon::DaemonError::Ambiguous { count: 2, .. })
|
|
),
|
|
"a name two endpoints share must be refused as ambiguous between both, got {refused:?}"
|
|
);
|
|
|
|
daemon
|
|
.create_route(&keys.id.to_string(), &synth.id.to_string())
|
|
.await
|
|
.expect("the route named by identifiers is created");
|
|
|
|
assert!(
|
|
platform.feed(handle_for(&platform, "Keystation"), &[note(71)]),
|
|
"the fake accepts MIDI fed into an open port"
|
|
);
|
|
let synth_handle = handle_for(&platform, "Synth");
|
|
assert!(
|
|
eventually(|| platform.sent(synth_handle).contains(¬e(71))).await,
|
|
"the port's MIDI never reached the synth"
|
|
);
|
|
}
|
|
|
|
/// Proves that a route to a switched-off endpoint reads as suspended, carries and counts
|
|
/// nothing while it waits, and resumes with no further action when the endpoint comes back.
|
|
///
|
|
/// A route that reports ok while dropping everything is the worst state to debug: the display
|
|
/// agrees with the user that it should be working. It is not broken either, since nothing needs
|
|
/// restoring.
|
|
#[tokio::test]
|
|
async fn a_route_to_a_switched_off_endpoint_waits_and_resumes_when_it_is_back() {
|
|
let (daemon, platform) = daemon("suspended").await;
|
|
ports(&daemon, &["Keyboard", "Synth"]).await;
|
|
let (route, _) = daemon
|
|
.create_route("Keyboard", "Synth")
|
|
.await
|
|
.expect("the route from Keyboard to Synth is created");
|
|
|
|
let synth = daemon.resolve("Synth").await.expect("the synth is listed");
|
|
daemon
|
|
.set_enabled(synth, false)
|
|
.await
|
|
.expect("the synth is switched off");
|
|
|
|
let router = daemon.router().await;
|
|
assert_eq!(
|
|
router.routes().first().map(|r| &r.validity),
|
|
Some(&RouteValidity::Suspended {
|
|
waiting_on: vec!["Synth".to_owned()]
|
|
}),
|
|
"a route to a switched-off endpoint must say what it waits on rather than read as fine"
|
|
);
|
|
assert!(
|
|
router.broken().is_empty(),
|
|
"a switched-off endpoint is not a broken route: nothing needs restoring"
|
|
);
|
|
|
|
assert!(
|
|
platform.feed(handle_for(&platform, "Keyboard"), &[note(60)]),
|
|
"the fake accepts MIDI fed into an open port"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
assert_eq!(
|
|
daemon
|
|
.route_counters()
|
|
.await
|
|
.get(&route.id())
|
|
.map(|counters| counters.messages_sent),
|
|
Some(0),
|
|
"a suspended route counted traffic it did not carry"
|
|
);
|
|
|
|
daemon
|
|
.set_enabled(synth, true)
|
|
.await
|
|
.expect("the synth is switched back on");
|
|
assert_eq!(
|
|
daemon.router().await.routes().first().map(|r| &r.validity),
|
|
Some(&RouteValidity::Valid),
|
|
"the route must read valid again once its endpoint is back"
|
|
);
|
|
|
|
assert!(
|
|
platform.feed(handle_for(&platform, "Keyboard"), &[note(60)]),
|
|
"the fake accepts MIDI fed into an open port"
|
|
);
|
|
let reopened = handle_for(&platform, "Synth");
|
|
assert!(
|
|
eventually(|| !platform.sent(reopened).is_empty()).await,
|
|
"delivery did not resume when the endpoint came back"
|
|
);
|
|
}
|
|
|
|
/// Describes hardware the fake can present as attached.
|
|
fn device(name: &str) -> DiscoveredDevice {
|
|
DiscoveredDevice {
|
|
fingerprint: DeviceFingerprint {
|
|
name: name.to_owned(),
|
|
..DeviceFingerprint::default()
|
|
},
|
|
direction: Direction::Bidirectional,
|
|
claimed_by: None,
|
|
software: false,
|
|
}
|
|
}
|
|
|
|
/// Returns how many endpoints have a name beginning with `prefix`.
|
|
async fn named_like(daemon: &Arc<Daemon>, prefix: &str) -> usize {
|
|
daemon
|
|
.read(|config, _| {
|
|
config
|
|
.endpoints
|
|
.iter()
|
|
.filter(|e| e.name.as_str().starts_with(prefix))
|
|
.count()
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Proves that enumerating hardware again neither adds it a second time nor reopens it.
|
|
///
|
|
/// A device known only by its name matched nothing, not even the entry made for it, so every
|
|
/// enumeration added it again under the same name and opened it again.
|
|
#[tokio::test]
|
|
async fn enumerating_again_neither_duplicates_nor_reopens_hardware() {
|
|
let (daemon, platform) = daemon("re-enumerate").await;
|
|
platform.attach(device("Keystation"));
|
|
daemon.refresh_devices().await;
|
|
let handle = platform.device_handle("Keystation");
|
|
assert!(handle.is_some(), "attached hardware is opened when found");
|
|
|
|
daemon.refresh_devices().await;
|
|
daemon.refresh_devices().await;
|
|
|
|
assert_eq!(
|
|
named_like(&daemon, "Keystation").await,
|
|
1,
|
|
"the device was added again"
|
|
);
|
|
assert_eq!(
|
|
platform.device_handle("Keystation"),
|
|
handle,
|
|
"the device was reopened"
|
|
);
|
|
}
|
|
|
|
/// Proves that a route to unplugged hardware reads as suspended, and that plugging it back in
|
|
/// resumes the route and carries MIDI with no user action.
|
|
///
|
|
/// The configuration is kept so the route survives the unplug, which is what makes it read as
|
|
/// valid. Saying nothing more would leave a route reporting ok while carrying nothing.
|
|
#[tokio::test]
|
|
async fn a_route_to_unplugged_hardware_waits_rather_than_claiming_to_work() {
|
|
let (daemon, platform) = daemon("unplugged").await;
|
|
platform.attach(device("Keystation"));
|
|
daemon.refresh_devices().await;
|
|
ports(&daemon, &["Synth"]).await;
|
|
daemon
|
|
.create_route("Keystation", "Synth")
|
|
.await
|
|
.expect("the route from Keystation to Synth is created");
|
|
assert_eq!(
|
|
daemon.router().await.routes().first().map(|r| &r.validity),
|
|
Some(&RouteValidity::Valid),
|
|
"a route between attached hardware and an open port is valid"
|
|
);
|
|
|
|
platform.detach("Keystation");
|
|
daemon.refresh_devices().await;
|
|
|
|
assert_eq!(
|
|
daemon.router().await.routes().first().map(|r| &r.validity),
|
|
Some(&RouteValidity::Suspended {
|
|
waiting_on: vec!["Keystation".to_owned()]
|
|
}),
|
|
"a route to unplugged hardware must say it waits on the hardware"
|
|
);
|
|
|
|
platform.attach(device("Keystation"));
|
|
daemon.refresh_devices().await;
|
|
assert_eq!(
|
|
daemon.router().await.routes().first().map(|r| &r.validity),
|
|
Some(&RouteValidity::Valid),
|
|
"the route did not resume when the hardware came back"
|
|
);
|
|
|
|
// Fed repeatedly rather than once: the watcher re-enumerates on its own schedule, so the
|
|
// handle the fake reports can be a moment behind the one the daemon has just opened.
|
|
let synth = handle_for(&platform, "Synth");
|
|
let mut arrived = false;
|
|
for _ in 0..40 {
|
|
if let Some(keystation) = platform.device_handle("Keystation") {
|
|
let _ = platform.feed(keystation, &[note(60)]);
|
|
}
|
|
if !platform.sent(synth).is_empty() {
|
|
arrived = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
assert!(
|
|
arrived,
|
|
"the replugged hardware was listed as open but carried nothing"
|
|
);
|
|
}
|
|
|
|
/// Proves that hardware plugged in while the daemon runs is listed within two seconds with no
|
|
/// command (FR-015c, SC-010a).
|
|
///
|
|
/// The backend reports arrivals from its own thread and holds them until they are taken; nothing
|
|
/// took them, so the enumeration only ever ran at startup. SC-010a promises two seconds, so that
|
|
/// is the whole of the wait.
|
|
#[tokio::test]
|
|
async fn hardware_plugged_in_while_running_appears_on_its_own() {
|
|
let (daemon, platform) = daemon("hotplug").await;
|
|
assert!(
|
|
daemon.read(|config, _| config.endpoints.is_empty()).await,
|
|
"the daemon started with endpoints it should not have"
|
|
);
|
|
|
|
platform.attach(device("Keystation"));
|
|
|
|
let started = std::time::Instant::now();
|
|
let mut appeared = false;
|
|
while started.elapsed() < Duration::from_secs(2) {
|
|
appeared = named_like(&daemon, "Keystation").await > 0;
|
|
if appeared {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
assert!(
|
|
appeared,
|
|
"hardware plugged in while running did not appear within two seconds"
|
|
);
|
|
}
|
|
|
|
/// Proves that hardware replugged into another port keeps its endpoint and that the route drawn
|
|
/// to it carries MIDI again without being drawn anew (FR-015e).
|
|
///
|
|
/// A device replugged elsewhere is the same device, and its USB serial settles that even though
|
|
/// its topology path changed.
|
|
#[tokio::test]
|
|
async fn hardware_moved_to_another_port_keeps_its_endpoint_and_its_routes() {
|
|
let (daemon, platform) = daemon("moved").await;
|
|
let at = |socket: &str| DiscoveredDevice {
|
|
fingerprint: DeviceFingerprint {
|
|
name: "Keystation".to_owned(),
|
|
usb_serial: Some("KS-0042#0".to_owned()),
|
|
topology_path: Some(format!("usb-{socket}:0")),
|
|
..DeviceFingerprint::default()
|
|
},
|
|
direction: Direction::Bidirectional,
|
|
claimed_by: None,
|
|
software: false,
|
|
};
|
|
ports(&daemon, &["Synth"]).await;
|
|
platform.attach(at("1-1.1"));
|
|
daemon.refresh_devices().await;
|
|
let id = daemon
|
|
.resolve("Keystation")
|
|
.await
|
|
.expect("the device is listed once attached");
|
|
daemon
|
|
.create_route("Keystation", "Synth")
|
|
.await
|
|
.expect("the route from Keystation to Synth is created");
|
|
|
|
platform.detach("Keystation");
|
|
daemon.refresh_devices().await;
|
|
platform.attach(at("3-2"));
|
|
daemon.refresh_devices().await;
|
|
|
|
assert_eq!(
|
|
daemon
|
|
.resolve("Keystation")
|
|
.await
|
|
.expect("the moved device is still listed"),
|
|
id,
|
|
"the moved device came back as a new endpoint"
|
|
);
|
|
assert_eq!(
|
|
named_like(&daemon, "Keystation").await,
|
|
1,
|
|
"the moved device was listed a second time beside its old entry"
|
|
);
|
|
let keys = platform
|
|
.device_handle("Keystation")
|
|
.expect("the moved device is reopened");
|
|
let synth = handle_for(&platform, "Synth");
|
|
assert!(
|
|
platform.feed(keys, &[note(67)]),
|
|
"the fake accepts MIDI fed from the reopened device"
|
|
);
|
|
assert!(
|
|
eventually(|| platform.sent(synth).contains(¬e(67))).await,
|
|
"the route no longer carries MIDI from the moved device"
|
|
);
|
|
}
|
|
|
|
/// Proves that forgetting unplugged hardware removes its entry and names the routes it
|
|
/// orphaned, which are kept and read broken rather than being quietly removed.
|
|
///
|
|
/// Every device ever seen is remembered so its routes survive being unplugged, which means there
|
|
/// has to be a way to say a device is not coming back. The user decides what happens to the
|
|
/// routes.
|
|
#[tokio::test]
|
|
async fn forgetting_hardware_removes_it_and_names_what_it_orphaned() {
|
|
let (daemon, platform) = daemon("forget").await;
|
|
platform.attach(device("Keystation"));
|
|
daemon.refresh_devices().await;
|
|
ports(&daemon, &["Synth"]).await;
|
|
daemon
|
|
.create_route("Keystation", "Synth")
|
|
.await
|
|
.expect("the route from Keystation to Synth is created");
|
|
|
|
platform.detach("Keystation");
|
|
daemon.refresh_devices().await;
|
|
let id = daemon
|
|
.resolve("Keystation")
|
|
.await
|
|
.expect("unplugged hardware is still remembered");
|
|
|
|
let (orphaned, present) = daemon
|
|
.forget_device(id)
|
|
.await
|
|
.expect("the unplugged device is forgotten");
|
|
assert!(
|
|
!present,
|
|
"the hardware was not attached, so it must not be reported as present"
|
|
);
|
|
assert_eq!(
|
|
orphaned,
|
|
vec!["Keystation -> Synth".to_owned()],
|
|
"forgetting must name the one route the device fed"
|
|
);
|
|
|
|
assert_eq!(
|
|
named_like(&daemon, "Keystation").await,
|
|
0,
|
|
"the forgotten device is still remembered"
|
|
);
|
|
|
|
let router = daemon.router().await;
|
|
assert_eq!(
|
|
router.routes().len(),
|
|
1,
|
|
"the orphaned route must be kept for the user to decide on"
|
|
);
|
|
assert!(
|
|
!router.broken().is_empty(),
|
|
"the orphaned route must read broken"
|
|
);
|
|
}
|
|
|
|
/// Proves that forgetting hardware that is still attached says so, and that the hardware is
|
|
/// listed again at once as a new entry.
|
|
///
|
|
/// That is how to start over with a device whose settings have gone wrong. Saying so is the
|
|
/// difference between that and a command that looks like it did nothing. Nothing changed on the
|
|
/// platform, so no event would have prompted a refresh; the new entry must appear without one.
|
|
#[tokio::test]
|
|
async fn forgetting_hardware_that_is_still_attached_says_it_is_still_attached() {
|
|
let (daemon, platform) = daemon("forget-present").await;
|
|
platform.attach(device("Keystation"));
|
|
daemon.refresh_devices().await;
|
|
let id = daemon
|
|
.resolve("Keystation")
|
|
.await
|
|
.expect("the device is listed once attached");
|
|
|
|
let (_orphaned, present) = daemon
|
|
.forget_device(id)
|
|
.await
|
|
.expect("the attached device is forgotten");
|
|
assert!(
|
|
present,
|
|
"the hardware was attached and should be reported so"
|
|
);
|
|
|
|
let back = daemon
|
|
.resolve("Keystation")
|
|
.await
|
|
.expect("the attached device is listed again at once");
|
|
assert_ne!(
|
|
back, id,
|
|
"it came back as the same entry rather than a new one"
|
|
);
|
|
}
|
|
|
|
/// Builds hardware that is identical but for where it is plugged in.
|
|
fn twin(name: &str, port: &str) -> DiscoveredDevice {
|
|
DiscoveredDevice {
|
|
fingerprint: DeviceFingerprint {
|
|
name: name.to_owned(),
|
|
topology_path: Some(format!("alsa:128:{port}")),
|
|
..DeviceFingerprint::default()
|
|
},
|
|
direction: Direction::Bidirectional,
|
|
claimed_by: None,
|
|
software: false,
|
|
}
|
|
}
|
|
|
|
/// Proves that with two identical devices attached, the remembered entry is not guessed onto
|
|
/// either, and saying which one was meant binds it to that one (FR-015g).
|
|
///
|
|
/// Two identical devices cannot be told apart by anything but where they are plugged in, so the
|
|
/// entry is left unbound rather than guessing which one a route meant.
|
|
#[tokio::test]
|
|
async fn saying_which_identical_device_was_meant_binds_it() {
|
|
let (daemon, platform) = daemon("ambiguous").await;
|
|
platform.attach(twin("Acme K61", "0"));
|
|
daemon.refresh_devices().await;
|
|
let stored = daemon
|
|
.resolve("Acme K61")
|
|
.await
|
|
.expect("the first device is listed");
|
|
|
|
// A second, identical one arrives.
|
|
platform.attach(twin("Acme K61", "1"));
|
|
daemon.refresh_devices().await;
|
|
|
|
let names = remembered(&daemon).await;
|
|
assert_eq!(
|
|
names
|
|
.iter()
|
|
.filter(|name| name.as_str() == "Acme K61")
|
|
.count(),
|
|
1,
|
|
"more than one endpoint answers to the same name: {names:?}"
|
|
);
|
|
|
|
// The user says which one the remembered entry means.
|
|
let chosen = DeviceFingerprint {
|
|
name: "Acme K61".to_owned(),
|
|
topology_path: Some("alsa:128:1".to_owned()),
|
|
..DeviceFingerprint::default()
|
|
};
|
|
let resolved = daemon
|
|
.resolve_device(stored, &chosen)
|
|
.await
|
|
.expect("the remembered entry is bound to the chosen device");
|
|
assert_eq!(
|
|
resolved.name.as_str(),
|
|
"Acme K61",
|
|
"the bound entry keeps the name routes know it by"
|
|
);
|
|
|
|
// The stand-in entry is gone, and the remembered one is bound to the hardware chosen.
|
|
let devices = daemon
|
|
.read(|config, _| {
|
|
config
|
|
.endpoints
|
|
.iter()
|
|
.filter_map(|endpoint| match &endpoint.kind {
|
|
midi_harbor_core::endpoint::EndpointKind::PhysicalDevice(device) => Some((
|
|
endpoint.name.to_string(),
|
|
device.present,
|
|
device.fingerprint.topology_path.clone(),
|
|
)),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.await;
|
|
assert_eq!(
|
|
devices.len(),
|
|
2,
|
|
"two devices are attached, so exactly two entries remain: {devices:?}"
|
|
);
|
|
let bound = devices
|
|
.iter()
|
|
.find(|(name, _, _)| name == "Acme K61")
|
|
.expect("the remembered entry is still listed");
|
|
assert_eq!(
|
|
bound.2.as_deref(),
|
|
Some("alsa:128:1"),
|
|
"the remembered entry must be bound to the device plugged in where the user chose"
|
|
);
|
|
assert!(bound.1, "the resolved entry should be present");
|
|
}
|
|
|
|
/// Proves that a route from an endpoint with no input side is refused as an invalid request,
|
|
/// while the same endpoint is accepted as a route's destination.
|
|
///
|
|
/// It was reported as an invalid configuration, which read "configuration is invalid" and then
|
|
/// gave the same reason again as the thing to correct, though no configuration was involved. The
|
|
/// gRPC code and message are what the CLI and GUI show. Hardware is where a one-way endpoint
|
|
/// remains: a virtual port always has both sides.
|
|
#[tokio::test]
|
|
async fn a_route_from_an_endpoint_that_sends_nothing_is_an_invalid_request() {
|
|
let (daemon, platform) = daemon("not-a-source").await;
|
|
platform.attach(DiscoveredDevice {
|
|
direction: Direction::Output,
|
|
..device("Speaker")
|
|
});
|
|
daemon.refresh_devices().await;
|
|
ports(&daemon, &["Synth"]).await;
|
|
|
|
let refused = daemon
|
|
.create_route("Speaker", "Synth")
|
|
.await
|
|
.expect_err("a route from an output-only device is refused");
|
|
assert!(
|
|
matches!(
|
|
refused,
|
|
midi_harbor_daemon::DaemonError::InvalidRoute(
|
|
midi_harbor_core::router::RouteError::NotASource(_)
|
|
)
|
|
),
|
|
"the refusal must say the endpoint is not a source, got {refused:?}"
|
|
);
|
|
let status = tonic::Status::from(refused);
|
|
assert_eq!(
|
|
status.code(),
|
|
tonic::Code::InvalidArgument,
|
|
"a bad route request is the caller's argument, not the configuration"
|
|
);
|
|
assert_eq!(
|
|
status.message(),
|
|
"Speaker has no input side, so it cannot be a route source",
|
|
"the message must say once what is wrong"
|
|
);
|
|
|
|
daemon
|
|
.create_route("Synth", "Speaker")
|
|
.await
|
|
.expect("an output-only device is accepted as a route's destination");
|
|
}
|
|
|
|
/// Returns the names of the device entries the configuration holds.
|
|
async fn remembered(daemon: &Arc<Daemon>) -> Vec<String> {
|
|
daemon
|
|
.read(|config, _| {
|
|
config
|
|
.endpoints
|
|
.iter()
|
|
.filter(|e| {
|
|
matches!(
|
|
e.kind,
|
|
midi_harbor_core::endpoint::EndpointKind::PhysicalDevice(_)
|
|
)
|
|
})
|
|
.map(|e| e.name.as_str().to_owned())
|
|
.collect()
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Proves that another application's port is remembered after it closes only while a route
|
|
/// names it, while hardware is remembered after an unplug whether routed or not.
|
|
///
|
|
/// Every program that ever opened a port stayed in the configuration as absent hardware:
|
|
/// aseqdump on Linux, Apple's network session on macOS.
|
|
#[tokio::test]
|
|
async fn an_applications_port_is_remembered_only_while_a_route_names_it() {
|
|
let (daemon, platform) = daemon("software-ports").await;
|
|
ports(&daemon, &["Keys"]).await;
|
|
let software = |name: &str| DiscoveredDevice {
|
|
software: true,
|
|
..device(name)
|
|
};
|
|
|
|
// A passing tool is shown while it is there, and leaves nothing behind.
|
|
platform.attach(software("aseqdump"));
|
|
assert!(
|
|
eventually_async(|| async { remembered(&daemon).await.contains(&"aseqdump".to_owned()) })
|
|
.await,
|
|
"an application's open port must be listed while it is there"
|
|
);
|
|
platform.detach("aseqdump");
|
|
assert!(
|
|
eventually_async(|| async { !remembered(&daemon).await.contains(&"aseqdump".to_owned()) })
|
|
.await,
|
|
"an unrouted application's port must be forgotten when it closes"
|
|
);
|
|
|
|
// A synth someone routes to is kept while it is closed, so the route resumes.
|
|
platform.attach(software("FluidSynth"));
|
|
assert!(
|
|
eventually_async(|| async { remembered(&daemon).await.contains(&"FluidSynth".to_owned()) })
|
|
.await,
|
|
"an application's open port must be listed while it is there"
|
|
);
|
|
daemon
|
|
.create_route("Keys", "FluidSynth")
|
|
.await
|
|
.expect("the route from Keys to FluidSynth is created");
|
|
platform.detach("FluidSynth");
|
|
|
|
// Hardware is kept while unplugged, as before, route or not.
|
|
platform.attach(device("Keystation"));
|
|
assert!(
|
|
eventually_async(|| async { remembered(&daemon).await.contains(&"Keystation".to_owned()) })
|
|
.await,
|
|
"plugged-in hardware must be listed"
|
|
);
|
|
platform.detach("Keystation");
|
|
tokio::time::sleep(Duration::from_millis(1_500)).await;
|
|
|
|
let kept = remembered(&daemon).await;
|
|
assert!(
|
|
kept.contains(&"FluidSynth".to_owned()),
|
|
"a closed application's port a route names must be remembered: {kept:?}"
|
|
);
|
|
assert!(
|
|
kept.contains(&"Keystation".to_owned()),
|
|
"unplugged hardware must be remembered, route or not: {kept:?}"
|
|
);
|
|
assert!(
|
|
!kept.contains(&"aseqdump".to_owned()),
|
|
"a closed application's port no route names must not come back: {kept:?}"
|
|
);
|
|
}
|
|
|
|
/// Waits for an asynchronous condition.
|
|
async fn eventually_async<F, Fut>(mut check: F) -> bool
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: std::future::Future<Output = bool>,
|
|
{
|
|
for _ in 0..200 {
|
|
if check().await {
|
|
return true;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Proves that an IAC bus edited in Audio MIDI Setup keeps its one entry and its routes, which
|
|
/// go on carrying MIDI to it.
|
|
///
|
|
/// Editing a bus gives it a new CoreMIDI unique identifier. It came back listed a second time,
|
|
/// and the route went on waiting for the entry that would never return.
|
|
#[tokio::test]
|
|
async fn an_iac_bus_edited_in_audio_midi_setup_keeps_its_routes() {
|
|
let (daemon, platform) = daemon("iac-edit").await;
|
|
ports(&daemon, &["Keys"]).await;
|
|
let bus = |unique_id: u32| DiscoveredDevice {
|
|
fingerprint: DeviceFingerprint {
|
|
unique_id: Some(unique_id),
|
|
name: "IAC Driver Bus 1".to_owned(),
|
|
..DeviceFingerprint::default()
|
|
},
|
|
software: true,
|
|
..device("IAC Driver Bus 1")
|
|
};
|
|
// Far above the identifiers the fake gives this daemon's own ports.
|
|
platform.attach(bus(9_001));
|
|
daemon.refresh_devices().await;
|
|
daemon
|
|
.create_route("Keys", "IAC Driver Bus 1")
|
|
.await
|
|
.expect("the route from Keys to the bus is created");
|
|
|
|
platform.detach("IAC Driver Bus 1");
|
|
daemon.refresh_devices().await;
|
|
platform.attach(bus(9_002));
|
|
daemon.refresh_devices().await;
|
|
|
|
let listed = remembered(&daemon).await;
|
|
assert_eq!(
|
|
listed
|
|
.iter()
|
|
.filter(|name| name.starts_with("IAC Driver Bus 1"))
|
|
.count(),
|
|
1,
|
|
"the edited bus must keep its one entry rather than be listed again: {listed:?}"
|
|
);
|
|
let router = daemon.router().await;
|
|
assert!(
|
|
router
|
|
.routes()
|
|
.iter()
|
|
.all(|route| route.validity == RouteValidity::Valid),
|
|
"the route to the edited bus must read valid rather than wait: {:?}",
|
|
router.routes()
|
|
);
|
|
let keys = handle_for(&platform, "Keys");
|
|
let to_bus = platform
|
|
.device_handle("IAC Driver Bus 1")
|
|
.expect("the edited bus is open");
|
|
assert!(
|
|
platform.feed(keys, &[note(60)]),
|
|
"the fake accepts MIDI fed into an open port"
|
|
);
|
|
assert!(
|
|
eventually(|| platform.sent(to_bus) == vec![note(60)]).await,
|
|
"the route to the edited bus carried nothing"
|
|
);
|
|
}
|
|
|
|
/// Returns how many messages an endpoint has received, once it reaches at least `least`.
|
|
async fn received(daemon: &Arc<Daemon>, name: &str, least: u64) -> u64 {
|
|
let id = daemon
|
|
.resolve(name)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("{name} is listed: {error}"));
|
|
let mut seen = 0;
|
|
for _ in 0..200 {
|
|
seen = daemon
|
|
.counters(id)
|
|
.await
|
|
.map(|counters| counters.messages_received)
|
|
.unwrap_or_default();
|
|
if seen >= least {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
seen
|
|
}
|
|
|
|
/// Proves that a device's counters survive an unplug and go on from there when it returns.
|
|
///
|
|
/// Every replug started the device's counters from zero, so a pad controller played all session
|
|
/// reported receiving nothing, beside a route from it that had carried every note.
|
|
#[tokio::test]
|
|
async fn a_device_keeps_its_counts_when_it_is_unplugged() {
|
|
let (daemon, platform) = daemon("counted-unplug").await;
|
|
platform.attach(device("Keystation"));
|
|
daemon.refresh_devices().await;
|
|
ports(&daemon, &["Synth"]).await;
|
|
daemon
|
|
.create_route("Keystation", "Synth")
|
|
.await
|
|
.expect("the route from Keystation to Synth is created");
|
|
|
|
let keystation = platform
|
|
.device_handle("Keystation")
|
|
.expect("attached hardware is opened");
|
|
assert!(
|
|
platform.feed(keystation, &[note(60), note(62)]),
|
|
"the fake accepts MIDI fed from an open device"
|
|
);
|
|
assert_eq!(
|
|
received(&daemon, "Keystation", 2).await,
|
|
2,
|
|
"two notes were fed, so the device received two messages"
|
|
);
|
|
|
|
platform.detach("Keystation");
|
|
daemon.refresh_devices().await;
|
|
assert_eq!(
|
|
received(&daemon, "Keystation", 2).await,
|
|
2,
|
|
"unplugged, the device must still show the two messages it received"
|
|
);
|
|
|
|
platform.attach(device("Keystation"));
|
|
daemon.refresh_devices().await;
|
|
let keystation = platform
|
|
.device_handle("Keystation")
|
|
.expect("replugged hardware is reopened");
|
|
assert!(
|
|
platform.feed(keystation, &[note(64)]),
|
|
"the fake accepts MIDI fed from the reopened device"
|
|
);
|
|
assert_eq!(
|
|
received(&daemon, "Keystation", 3).await,
|
|
3,
|
|
"two notes before the unplug and one after make three"
|
|
);
|
|
}
|