Skip to main content

shuyi_app/audio/
voice.rs

1//! Where the game's voice clips are (game ADR-0046), and their manifest: the
2//! shared player plays them ([`shuyi_platform::audio::player`]).
3
4use std::path::Path;
5
6use super::folder_beside;
7
8/// Where the voice clips of the script's language `lang` are: `<lang>` in
9/// the voice `folder` next to the executable when it's there (a shipped
10/// build), else relative to the working directory (`cargo run` from the
11/// repo root, or the web build, which fetches from beside the page).
12pub(crate) fn platform_voice_dir(folder: &str, lang: &str) -> String {
13    let voice = if cfg!(target_arch = "wasm32") {
14        folder.to_owned()
15    } else {
16        let exe = std::env::current_exe().ok();
17        folder_beside(folder, exe.as_deref(), Path::is_dir)
18    };
19    format!("{voice}/{lang}")
20}
21
22/// The text of the voice manifest `file` in `dir`, or `None` if there is
23/// none (no voices: not a problem). `Err` if the file is there and can't
24/// be read.
25#[cfg(not(target_arch = "wasm32"))]
26#[allow(clippy::unused_async)] // The web build fetches it.
27pub async fn read_manifest(dir: &str, file: &str) -> Result<Option<String>, String> {
28    let path = format!("{dir}/{file}");
29    match std::fs::read_to_string(&path) {
30        Ok(text) => Ok(Some(text)),
31        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
32        Err(e) => Err(format!("{path}: {e}")),
33    }
34}
35
36/// The text of the voice manifest `file` in `dir`, fetched from beside the
37/// page, or `None` if it can't be had: a build without voices has no such
38/// file, and a failed fetch doesn't say why it failed.
39#[cfg(target_arch = "wasm32")]
40pub async fn read_manifest(dir: &str, file: &str) -> Result<Option<String>, String> {
41    let path = format!("{dir}/{file}");
42    Ok(macroquad::file::load_string(&path).await.ok())
43}
44
45#[cfg(test)]
46mod tests {
47    use std::future::Future;
48    use std::pin::pin;
49    use std::task::{Context, Poll, Waker};
50
51    use super::*;
52
53    /// Runs a future that never waits on anything external.
54    fn block_on<F: Future>(future: F) -> F::Output {
55        let mut future = pin!(future);
56        let mut cx = Context::from_waker(Waker::noop());
57        loop {
58            if let Poll::Ready(v) = future.as_mut().poll(&mut cx) {
59                return v;
60            }
61        }
62    }
63
64    /// Acceptance: with no voice folder the game starts and logs nothing
65    /// about voices.
66    #[test]
67    fn without_a_voice_folder_there_is_no_manifest() {
68        let nowhere = tempfile::tempdir().unwrap();
69        let dir = format!("{}/voice/en", nowhere.path().display());
70        assert_eq!(block_on(read_manifest(&dir, "voice.ron")), Ok(None));
71    }
72
73    #[test]
74    fn a_manifest_that_cannot_be_read_is_an_error_naming_it() {
75        // A folder where the file should be.
76        let odd = tempfile::tempdir().unwrap();
77        std::fs::create_dir_all(odd.path().join("voice.ron")).unwrap();
78        let dir = odd.path().display().to_string();
79        let error = block_on(read_manifest(&dir, "voice.ron")).unwrap_err();
80        let start = format!("{dir}/voice.ron: ");
81        assert!(error.starts_with(&start), "{error}");
82    }
83
84    #[test]
85    fn a_manifest_is_read_whole() {
86        let dir = tempfile::tempdir().unwrap();
87        std::fs::write(dir.path().join("voices.ron"), "(clips: [])").unwrap();
88        let dir = dir.path().display().to_string();
89        let text = block_on(read_manifest(&dir, "voices.ron"));
90        assert_eq!(text, Ok(Some("(clips: [])".to_owned())));
91    }
92
93    #[test]
94    fn the_voice_folder_is_the_scripts_language_under_the_voice_folder() {
95        let dir = platform_voice_dir("voice", "en");
96        assert!(dir.ends_with("voice/en"), "{dir}");
97        assert!(!dir.contains('\\'), "{dir}");
98    }
99}