trnovel 0.18.3

Terminal reader for novel
use super::*;
use clap::Parser;

#[test]
fn clap_routes_nested_commands_and_checks_flags() {
    use crate::TRNovel;
    for args in [
        vec!["trn", "tts", "show"],
        vec!["trn", "tts", "list", "--backend", "moss", "--model", "nano"],
        vec![
            "trn",
            "tts",
            "setup",
            "--program",
            "worker",
            "--voice",
            "A",
            "--device",
            "cpu",
        ],
        vec!["trn", "--tts-program", "worker", "tts", "setup"],
    ] {
        assert!(TRNovel::try_parse_from(args).is_ok());
    }
    for args in [
        vec!["trn", "tts"],
        vec!["trn", "tts", "list", "--model", "nano"],
        vec!["trn", "tts", "setup", "--device", "typo"],
        vec![
            "trn",
            "tts",
            "setup",
            "--program",
            "b",
            "--tts-program",
            "a",
        ],
    ] {
        assert!(TRNovel::try_parse_from(&args).is_err(), "{args:?}");
    }
}

#[tokio::test]
async fn setup_persists_reader_only_and_rejects_invalid_values() {
    let (fixture, program) = crate::tts::client::tests::fixture("cli");
    let store = ConfigStore::new(fixture.path().join("config.toml"));
    store
        .update(|config| {
            config.reader.follow_tts = true;
            config.browser.always_allow = true;
        })
        .unwrap();
    let args = SetupArgs {
        program: Some(program.clone()),
        backend: Some("moss".into()),
        model: Some("nano".into()),
        voice: Some("B".into()),
        device: Some(SetupDevice::Cpu),
        ..Default::default()
    };
    execute(&TtsCommand::Setup(args.clone()), None, &store)
        .await
        .unwrap();
    let config = store.load().unwrap();
    assert!(config.reader.follow_tts && config.browser.always_allow);
    assert_eq!(config.tts.program, Some(program.canonicalize().unwrap()));
    assert_eq!(config.tts.sound.as_ref().unwrap().voice, "B");
    let original = std::fs::read(fixture.path().join("config.toml")).unwrap();
    for invalid in [
        SetupArgs {
            voice: Some("missing".into()),
            ..Default::default()
        },
        SetupArgs {
            device: Some(SetupDevice::Metal),
            ..Default::default()
        },
        SetupArgs {
            model: Some("missing".into()),
            ..Default::default()
        },
        SetupArgs {
            style: Some("不支持".into()),
            ..Default::default()
        },
    ] {
        assert!(
            execute(&TtsCommand::Setup(invalid), None, &store)
                .await
                .is_err()
        );
        assert_eq!(
            std::fs::read(fixture.path().join("config.toml")).unwrap(),
            original
        );
    }
    let log = std::fs::read_to_string(program.with_extension("requests.jsonl")).unwrap();
    let commands: Vec<tts_protocol::Request> = log
        .lines()
        .map(|line| serde_json::from_str(line).unwrap())
        .collect();
    assert!(commands.iter().all(|request| matches!(
        request.command,
        Command::Hello | Command::GetConfig | Command::Shutdown
    )));
    let (client, ready) = Client::connect(&program).await.unwrap();
    let Event::Ready(catalog) = ready.event else {
        panic!()
    };
    client.close().await;
    let switched = SetupArgs {
        backend: Some("qwen".into()),
        model: Some("custom".into()),
        ..Default::default()
    }
    .sound(config.tts.sound.as_ref().unwrap(), &catalog)
    .unwrap();
    assert_eq!(switched.voice, "C");
    assert_eq!(switched.device, Device::Auto);
    assert_eq!(switched.style, None);
}

#[tokio::test]
async fn read_only_commands_and_failed_workers_do_not_write_config() {
    let (fixture, program) = crate::tts::client::tests::fixture("cli-read-only");
    let path = fixture.path().join("config.toml");
    let store = ConfigStore::new(path.clone());
    execute(&TtsCommand::Show, None, &store).await.unwrap();
    execute(
        &TtsCommand::List {
            backend: None,
            model: None,
        },
        Some(&program),
        &store,
    )
    .await
    .unwrap();
    assert!(!path.exists());
    let (_bad, bad) = crate::tts::client::tests::fixture("incompatible-cli");
    assert!(
        execute(
            &TtsCommand::Setup(SetupArgs {
                program: Some(bad),
                ..Default::default()
            }),
            None,
            &store
        )
        .await
        .is_err()
    );
    assert!(!path.exists());
    assert!(
        execute(&TtsCommand::Setup(SetupArgs::default()), None, &store)
            .await
            .is_err()
    );
    assert!(!path.exists());
    let configured = SetupArgs {
        program: Some(program.clone()),
        ..Default::default()
    };
    assert!(
        execute(
            &TtsCommand::Setup(configured.clone()),
            Some(&program),
            &store
        )
        .await
        .is_err()
    );
    assert!(!path.exists());
    execute(&TtsCommand::Setup(configured), None, &store)
        .await
        .unwrap();
    assert!(store.load().unwrap().tts.sound.is_none());
    assert_eq!(
        store.load().unwrap().tts.program,
        Some(program.canonicalize().unwrap())
    );
}

#[test]
fn default_model_resolution_keeps_voice_and_rejected_transactions_do_not_write() {
    let caps: Capabilities = serde_json::from_str(r#"{"backend":"moss","model":"nano","model_name":"Nano","voices":["A","B"],"default_voice":"A","compiled_devices":["cpu"],"native_streaming":true,"style":false,"cloning":false,"pronunciation":false}"#).unwrap();
    let base = SoundConfig {
        backend: "moss".into(),
        model: None,
        voice: "B".into(),
        style: None,
        device: Device::Cpu,
    };
    let sound = SetupArgs {
        device: Some(SetupDevice::Cpu),
        ..Default::default()
    }
    .sound(&base, &[caps])
    .unwrap();
    assert_eq!(sound.voice, "B");
    let directory = tempfile::tempdir().unwrap();
    let path = directory.path().join("config.toml");
    let original = "# preserve comments on rejected edits\n[tts]\nprogram = 'before'\n";
    std::fs::write(&path, original).unwrap();
    let store = ConfigStore::new(path.clone());
    assert!(
        store
            .try_update(|config| {
                config.tts.program = Some("after".into());
                anyhow::bail!("stale edit");
            })
            .is_err()
    );
    assert_eq!(std::fs::read_to_string(path).unwrap(), original);
}

#[tokio::test]
async fn setup_normalizes_blank_styles_and_rejects_long_styles_without_writing() {
    let (fixture, program) = crate::tts::client::tests::fixture("cli-style");
    let path = fixture.path().join("config.toml");
    let store = ConfigStore::new(path.clone());
    let args = SetupArgs {
        program: Some(program),
        backend: Some("qwen".into()),
        model: Some("custom".into()),
        style: Some("温".repeat(200)),
        ..Default::default()
    };
    execute(&TtsCommand::Setup(args.clone()), None, &store)
        .await
        .unwrap();
    let original = std::fs::read(&path).unwrap();
    assert!(
        execute(
            &TtsCommand::Setup(SetupArgs {
                style: Some("温".repeat(201)),
                ..args.clone()
            }),
            None,
            &store,
        )
        .await
        .is_err()
    );
    assert_eq!(std::fs::read(&path).unwrap(), original);
    execute(
        &TtsCommand::Setup(SetupArgs {
            style: Some(" \t\n".into()),
            ..args
        }),
        None,
        &store,
    )
    .await
    .unwrap();
    assert_eq!(store.load().unwrap().tts.sound.unwrap().style, None);
}