#[path = "../../playr-core/tests/common/mod.rs"]
mod common;
use std::path::Path;
use std::time::{Duration, Instant};
use playr_app::action::Action;
use playr_app::command::CommandLine;
use playr_app::config::Config;
use playr_app::dispatch::{Confirm, Frontend};
use playr_app::message::Message;
use playr_app::model::{hold_peak, Input, Model, PEAK_HOLD};
use playr_app::View;
use playr_core::audio::Mode;
use playr_core::db::{self, query, Track};
use playr_core::notice::{Notice, Outcome, Task};
fn track(path: &str) -> Track {
Track {
path: path.into(),
mtime: 1,
size: 1,
..Default::default()
}
}
fn model() -> (Model, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mut conn = db::open(&dir.path().join("library.db")).unwrap();
let ids: Vec<i64> = ["/m/a.flac", "/m/b.flac", "/m/c.flac"]
.iter()
.map(|p| db::upsert(&conn, &track(p)).unwrap())
.collect();
query::save_playlist(&mut conn, "late", &ids[..2]).unwrap();
query::save_playlist(&mut conn, "early", &ids[2..]).unwrap();
let player = common::fake_player().0;
(Model::new(conn, player, Vec::new(), Config::default()), dir)
}
#[test]
fn tracks_handed_over_are_selected_played_and_shown() {
let tracks = vec![track("/x/one.wav"), track("/x/two.wav")];
let conn = db::open_memory().unwrap();
let model = Model::new(
conn,
common::fake_player().0,
tracks.clone(),
Config::default(),
);
assert_eq!(model.view(), View::Selection);
assert_eq!(model.session().selection(), &tracks[..]);
assert_eq!(model.playing(), &tracks[..]);
assert_eq!(model.cursors().selection, Some(0));
}
#[test]
fn a_question_waits_for_its_answer() {
let (mut model, _dir) = model();
model.perform(Action::ShowView(View::Playlists));
model.perform(Action::DeletePlaylist);
assert!(matches!(
model.input(),
Input::Confirm(Confirm::DeletePlaylist(p)) if p.name == "early"
));
model.answer(false);
assert_eq!(model.input(), &Input::None);
assert_eq!(model.message(), Some(&Message::Cancelled));
assert_eq!(model.session().playlists().len(), 2);
model.perform(Action::DeletePlaylist);
model.answer(true);
assert_eq!(
model.message(),
Some(&Message::Core(Notice::Done(Outcome::Deleted {
name: "early".into()
})))
);
assert_eq!(model.session().playlists().len(), 1);
model.answer(true);
assert_eq!(model.session().playlists().len(), 1);
}
#[test]
fn the_theme_starts_from_the_settings_and_changes_by_command() {
use playr_app::Theme;
let config = Config::parse("theme = \"light\"").unwrap();
let conn = db::open_memory().unwrap();
let mut model = Model::new(conn, common::fake_player().0, Vec::new(), config);
assert_eq!(model.theme(), Theme::Light);
model.run_command("theme dark");
assert_eq!(model.theme(), Theme::Dark);
assert_eq!(model.message(), Some(&Message::Theme(Theme::Dark)));
}
#[test]
fn command_lines_run_and_are_remembered_even_when_they_fail() {
let (mut model, _dir) = model();
model.set_input(Input::Command(CommandLine::default()));
model.run_command("mode shuffle");
assert_eq!(model.input(), &Input::None);
assert_eq!(
model.message(),
Some(&Message::Core(Notice::Done(Outcome::Mode(Mode::Shuffle))))
);
model.run_command("frob");
assert!(matches!(model.message(), Some(Message::Command(_))));
model.run_command(" ");
let mut line = CommandLine::default();
line.recall(true, model.history());
assert_eq!(line.text, "frob");
line.recall(true, model.history());
assert_eq!(line.text, "mode shuffle", "a blank line was remembered");
}
#[test]
fn a_search_shows_results_as_typed_and_ends_kept_or_cleared() {
let (mut model, _dir) = model();
model.search_as_typed("b".into());
assert_eq!(model.input(), &Input::Search("b".into()));
assert_eq!(model.results().map(<[Track]>::len), Some(1));
model.end_search(true);
assert_eq!(model.input(), &Input::None);
assert_eq!(model.results().map(<[Track]>::len), Some(1));
assert_eq!(model.message(), None);
model.search_as_typed("zzz".into());
model.end_search(true);
assert_eq!(model.message(), Some(&Message::NoMatches));
model.end_search(false);
assert_eq!(model.results(), None);
assert_eq!(model.listed().len(), 3);
}
fn refresh_until(model: &mut Model, done: impl Fn(&Message) -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
model.refresh();
if model.message().is_some_and(&done) {
return;
}
assert!(Instant::now() < deadline, "saw {:?}", model.message());
std::thread::sleep(Duration::from_millis(10));
}
}
fn music(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
for name in ["a.wav", "b.wav", "c.wav"] {
common::silence(&dir.join(name), 8000, 0.05);
}
}
#[test]
fn a_scan_fills_a_library_that_started_empty() {
let dir = tempfile::tempdir().unwrap();
let songs = dir.path().join("music");
music(&songs);
let conn = db::open_memory().unwrap();
let mut model = Model::new(conn, common::fake_player().0, Vec::new(), Config::default());
model
.session_mut()
.set_library_path(dir.path().join("library.db"));
model.perform(Action::Scan(songs.clone()));
assert_eq!(
model.message(),
Some(&Message::Core(Notice::Done(Outcome::ScanStarted {
dir: songs.clone()
})))
);
refresh_until(&mut model, |m| {
matches!(m, Message::Core(Notice::Done(Outcome::Scanned { .. })))
});
assert_eq!(model.session().tracks().len(), 3);
assert!(model.session().has_library_file());
assert_eq!(
model.cursors().library,
Some(0),
"no cursor on the new rows"
);
}
#[test]
fn opened_files_are_added_to_the_selection_and_played() {
let dir = tempfile::tempdir().unwrap();
let songs = dir.path().join("music");
music(&songs);
let (mut model, _library) = model();
model.perform(Action::Add);
assert_eq!(model.session().selection().len(), 1);
model.perform(Action::Open(vec![songs.clone()]));
refresh_until(&mut model, |m| {
matches!(m, Message::Core(Notice::Done(Outcome::Opened { .. })))
});
assert_eq!(
model.message(),
Some(&Message::Core(Notice::Done(Outcome::Opened {
tracks: 3,
skipped: 0
})))
);
assert_eq!(model.view(), View::Selection);
assert_eq!(model.session().selection().len(), 4);
assert_eq!(
model.cursors().selection,
Some(1),
"not on the first opened"
);
let playing: Vec<&str> = model.playing().iter().map(|t| t.path.as_str()).collect();
assert_eq!(playing.len(), 3);
assert!(playing[0].ends_with("a.wav"), "{playing:?}");
model.perform(Action::Open(vec![dir.path().join("gone")]));
refresh_until(&mut model, |m| {
matches!(m, Message::Core(Notice::Failed { .. }))
});
assert!(matches!(
model.message(),
Some(Message::Core(Notice::Failed {
task: Task::Open,
..
}))
));
assert_eq!(
model.session().selection().len(),
4,
"a failed open changed the selection"
);
}
#[test]
fn a_peak_is_held_then_released() {
let start = Instant::now();
let held = hold_peak(None, 0.9, start);
assert_eq!(held, Some((0.9, start)));
let soon = start + PEAK_HOLD / 2;
assert_eq!(hold_peak(held, 0.2, soon), held);
assert_eq!(hold_peak(held, 0.95, soon), Some((0.95, soon)));
let later = start + PEAK_HOLD;
assert_eq!(hold_peak(held, 0.2, later), Some((0.2, later)));
assert_eq!(hold_peak(held, 0.0, later), None);
}
fn wait_past(count: &std::sync::atomic::AtomicUsize, seen: usize) -> usize {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let now = count.load(std::sync::atomic::Ordering::SeqCst);
if now > seen {
return now;
}
assert!(Instant::now() < deadline, "no event arrived");
std::thread::sleep(Duration::from_millis(10));
}
}
#[cfg(unix)]
#[test]
fn only_the_latest_slicing_is_shown() {
use playr_app::action::Slicing;
use playr_app::sampler::{plan_text, Wave};
use playr_core::samples::Cut;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("long.wav");
common::silence(&file, 8000, 60.0);
let events = Arc::new(AtomicUsize::new(0));
let counted = events.clone();
let mut model = Model::waking(
db::open(&dir.path().join("library.db")).unwrap(),
common::fake_player().0,
vec![track(&file.to_string_lossy())],
Config::default(),
move || {
counted.fetch_add(1, Ordering::SeqCst);
},
);
model.perform(Action::ShowView(View::Sampler));
let deadline = Instant::now() + Duration::from_secs(5);
while !matches!(model.sampler().wave, Wave::Ready { .. }) {
assert!(Instant::now() < deadline, "no waveform");
model.refresh();
std::thread::sleep(Duration::from_millis(10));
}
let moved = dir.path().join("moved.wav");
std::fs::rename(&file, &moved).unwrap();
let made = std::process::Command::new("mkfifo")
.arg(&file)
.status()
.unwrap();
assert!(made.success());
let seen = events.load(Ordering::SeqCst);
model.perform(Action::Slice(Slicing::Region));
let seen = wait_past(&events, seen);
model.perform(Action::Slice(Slicing::Onsets(None)));
model.refresh();
assert!(model.sampler().pending.is_none(), "an older plan was shown");
assert_eq!(plan_text(model.sampler()), "planning slices");
let mut pipe = std::fs::OpenOptions::new().write(true).open(&file).unwrap();
std::io::copy(&mut std::fs::File::open(&moved).unwrap(), &mut pipe).unwrap();
drop(pipe);
wait_past(&events, seen);
model.refresh();
let pending = model.sampler().pending.as_ref().map(|p| p.job.cut);
assert!(
matches!(pending, Some(Cut::Onsets(_))),
"{pending:?}, {:?}",
model.message()
);
assert!(!plan_text(model.sampler()).contains("planning"));
}