voice_audio_multi/tests/unittests/test_cli_request_options.cpp
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

508 lines
28 KiB
C++

#include "../../app/cli/args.h"
#include "../../app/cli/request.h"
#include "engine/framework/io/json.h"
#include "engine/framework/runtime/errors.h"
#include "engine/framework/runtime/options.h"
#include "test_assert.h"
#include <chrono>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <unordered_map>
#include <vector>
namespace {
std::string option_from_json_number(const std::string & number) {
const auto root = engine::io::json::parse("{\"value\":" + number + "}");
const auto * value = root.find("value");
engine::test::require(value != nullptr, "json value field parsed");
return minitts::cli::json_option_string(*value);
}
uint64_t parse_seed(const std::string & value) {
const std::unordered_map<std::string, std::string> options{{"seed", value}};
const auto parsed = engine::runtime::parse_u64_option(options, {"seed"});
if (!parsed.has_value()) {
throw std::runtime_error("seed option was not parsed");
}
return *parsed;
}
void require_seed_rejected(const std::string & value) {
try {
(void) parse_seed(value);
} catch (const engine::runtime::InvalidRequestError &) {
return;
}
throw std::runtime_error("unsafe seed option was parsed unexpectedly: " + value);
}
void test_large_safe_integer_numbers_stay_decimal() {
const std::string one_quadrillion = option_from_json_number("1000000000000000");
engine::test::require_eq(one_quadrillion, "1000000000000000", "1e15 option string");
engine::test::require_eq(parse_seed(one_quadrillion), uint64_t{1000000000000000}, "1e15 seed");
const std::string nearby = option_from_json_number("1000000000000001");
engine::test::require_eq(nearby, "1000000000000001", "1e15+1 option string");
engine::test::require_eq(parse_seed(nearby), uint64_t{1000000000000001}, "1e15+1 seed");
const std::string negative = option_from_json_number("-1000000000000000");
engine::test::require_eq(negative, "-1000000000000000", "negative 1e15 option string");
}
void test_float_numbers_keep_json_formatting() {
engine::test::require_eq(option_from_json_number("0.7"), "0.7", "float option string");
engine::test::require_eq(option_from_json_number("1.25"), "1.25", "fractional option string");
}
void test_unsafe_integer_numbers_are_not_silently_rounded() {
const std::string boundary = option_from_json_number("9007199254740992");
engine::test::require(
boundary.find('e') != std::string::npos || boundary.find('E') != std::string::npos,
"2^53 boundary keeps scientific formatting");
require_seed_rejected(boundary);
const std::string rounded = option_from_json_number("9007199254740993");
engine::test::require(
rounded.find('e') != std::string::npos || rounded.find('E') != std::string::npos,
"2^53+1 does not become a rounded decimal option");
require_seed_rejected(rounded);
}
void test_malformed_seeds_are_invalid_requests() {
// A JSON null option reaches the parser as "null".
for (const char * value : {"null", " -1", "18446744073709551616"}) {
require_seed_rejected(value);
}
}
void test_audio_only_language_is_request_option() {
const char * argv[] = {"audiocpp_cli", "--task", "asr", "--family", "qwen3_asr", "--language", "en"};
const auto request = minitts::cli::build_request_from_cli(7, const_cast<char **>(argv));
engine::test::require(!request.text_input.has_value(), "audio-only language does not synthesize text input");
engine::test::require_eq(request.options.at("language"), std::string("en"), "audio-only language request option");
}
void test_audio_chunk_shortcut_uses_canonical_name() {
const char * argv[] = {"audiocpp_cli", "--audio-chunk-seconds", "5"};
const auto request = minitts::cli::build_request_from_cli(3, const_cast<char **>(argv));
engine::test::require_eq(request.options.at("audio_chunk_duration_sec"), std::string("5"),
"audio chunk shortcut uses canonical option");
engine::test::require(request.options.count("audio_chunk_seconds") == 0,
"audio chunk shortcut does not emit legacy key");
const char * legacy_argv[] = {"audiocpp_cli", "--request-option", "audio_chunk_seconds=5"};
const auto legacy = minitts::cli::build_request_from_cli(3, const_cast<char **>(legacy_argv));
engine::test::require_eq(legacy.options.at("audio_chunk_seconds"), std::string("5"),
"explicit legacy options remain available to model compatibility handling");
}
void test_normalized_request_option_aliases() {
using engine::runtime::find_option;
using engine::test::require;
using engine::test::require_eq;
const std::vector<std::pair<std::string, std::string>> aliases = {
{"instruction", "instruct"},
{"speed", "speaking_rate"},
{"max_tokens", "max_new_tokens"},
{"max_tokens", "max_text_tokens"},
{"num_beams", "beam_size"},
{"duration_frames", "tokens"},
{"text_temperature", "moss_tts_v15.text_temperature"},
{"text_temperature", "moss_ttsd.text_temperature"},
{"text_temperature", "moss_voicegen.text_temperature"},
{"duration_scale", "duration_factor"},
{"duration_scale", "length_adjust"},
{"reference_duration_sec", "max_ref_sec"},
{"max_speech_duration_sec", "max_speech_duration_s"},
{"mem_saver", "memory_saver"},
{"punc", "pnc"},
{"punc", "punctuation"},
{"punc", "punctuated"},
{"sampler_mode", "sampler"},
{"flow_guidance_scale", "s3gen_cfg_rate"},
{"max_frames", "max_steps"},
{"text_chunk_tokens", "max_tokens"},
{"max_duration_sec", "max_seconds"},
{"min_duration_sec", "min_seconds"},
{"reference_duration_sec", "ref_seconds"},
{"duration_sec", "target_duration_seconds"},
{"reference_duration_sec", "reference_duration_seconds"},
{"max_audio_steps", "max_new_audio_steps"},
{"min_audio_steps", "min_new_audio_steps"},
{"max_text_tokens", "max_new_text_tokens"},
{"audio_chunk_duration_sec", "audio_chunk_seconds"},
{"lfm2_audio.max_pass_duration_sec", "lfm2_audio.max_pass_seconds"},
{"video_file", "video"},
{"vieneu_v3_turbo.g2p_dict_file", "vieneu_v3_turbo.g2p_dict"},
{"vieneu_v3_turbo.g2p_dict_file", "g2p_dict"},
{"language", "lang"},
{"shift", "t_shift"},
{"zipvoice.shift", "zipvoice.t_shift"},
{"auto_frame_cap", "frame_cap"},
{"text_chunk_min_size", "text_chunk_min"},
{"voice_id", "speaker"},
{"duration_sec", "duration_seconds"},
{"lm_guidance_scale", "lm_cfg_scale"},
{"enable_thinking", "thinking"},
{"gigaam_asr.vad_model_path", "vad_model_path"},
{"qwen3_asr.vad_model_path", "qwen3_asr.vad_path"},
{"noise_file", "noise_path"},
{"flow_noise_file", "flow_noise_path"},
{"hift_source_random_file", "hift_source_random_path"},
{"speaker_embedding_file", "speaker_embedding_path"},
{"voice_embedding_file", "voice_embedding_path"},
{"voice_embedding_file", "pocket_tts.voice_embedding_path"},
{"export_voice_state_file", "export_voice_state_path"},
{"export_voice_state_file", "pocket_tts.export_voice_state_path"},
{"pitch_file", "pitch_path"},
{"retrieval_index_file", "retrieval_index_path"},
{"reference_image_file", "reference_image_path"},
{"reference_image_file", "ref_image"},
{"reference_video_file", "reference_video_path"},
{"outetts.forced_aligner_path", "outetts.aligner_path"},
{"kokoro_tts.predictor_duration_graph_arena_mb", "kokoro_tts.predictor_duration_graph_mb"},
{"kokoro_tts.predictor_text_graph_arena_mb", "kokoro_tts.predictor_text_graph_mb"},
{"kokoro_tts.predictor_tail_graph_arena_mb", "kokoro_tts.predictor_tail_graph_mb"},
{"minimax_music3.graph_arena_mb", "minimax_music3.graph_context_mb"},
{"pocket_tts.flow_step_graph_arena_mb", "pocket_tts.flow_step_graph_context_mb"},
{"pocket_tts.mimi_encoder_graph_arena_mb", "pocket_tts.mimi_encoder_graph_context_mb"},
{"pocket_tts.mimi_conv_graph_arena_mb", "pocket_tts.mimi_conv_graph_context_mb"},
{"pocket_tts.mimi_transformer_graph_arena_mb", "pocket_tts.mimi_transformer_graph_context_mb"},
{"pocket_tts.mimi_tail_graph_arena_mb", "pocket_tts.mimi_tail_graph_context_mb"},
{"vevo2.ar_prefill_graph_arena_mb", "vevo2.ar_prefill_graph_context_mb"},
{"vevo2.ar_decode_graph_arena_mb", "vevo2.ar_decode_graph_context_mb"},
{"vevo2.tokenizer_graph_arena_mb", "vevo2.tokenizer_graph_context_mb"},
{"vevo2.whisper_graph_arena_mb", "vevo2.whisper_graph_context_mb"},
{"vevo2.fm_graph_arena_mb", "vevo2.fm_graph_context_mb"},
{"vevo2.vocoder_graph_arena_mb", "vevo2.vocoder_graph_context_mb"},
{"voxcpm1.text_embedding_graph_arena_mb", "voxcpm1.text_embedding_graph_context_mb"},
{"voxcpm1.lm_step_graph_arena_mb", "voxcpm1.lm_step_graph_context_mb"},
{"voxcpm1.projection_graph_arena_mb", "voxcpm1.projection_graph_context_mb"},
{"voxcpm1.local_encoder_graph_arena_mb", "voxcpm1.local_encoder_graph_context_mb"},
{"voxcpm1.dit_graph_arena_mb", "voxcpm1.dit_graph_context_mb"},
{"voxcpm1.audiovae_graph_arena_mb", "voxcpm1.audiovae_graph_context_mb"},
{"voxcpm1.audiovae_encoder_graph_arena_mb", "voxcpm1.audiovae_encoder_graph_context_mb"},
{"voxcpm2.text_embedding_graph_arena_mb", "voxcpm2.text_embedding_graph_context_mb"},
{"voxcpm2.lm_step_graph_arena_mb", "voxcpm2.lm_step_graph_context_mb"},
{"voxcpm2.projection_graph_arena_mb", "voxcpm2.projection_graph_context_mb"},
{"voxcpm2.local_encoder_graph_arena_mb", "voxcpm2.local_encoder_graph_context_mb"},
{"voxcpm2.dit_graph_arena_mb", "voxcpm2.dit_graph_context_mb"},
{"voxcpm2.audiovae_graph_arena_mb", "voxcpm2.audiovae_graph_context_mb"},
{"voxcpm2.audiovae_encoder_graph_arena_mb", "voxcpm2.audiovae_encoder_graph_context_mb"},
};
for (const auto & [canonical, legacy] : aliases) {
require(!find_option({}, {canonical, legacy}), "absent aliases retain model defaults");
for (const auto & key : {canonical, legacy}) {
const std::string option = key + "=2";
const char * argv[] = {"audiocpp_cli", "--task", "tts", "--request-option", option.c_str()};
const auto request = minitts::cli::build_request_from_cli(5, const_cast<char **>(argv));
const auto parsed = find_option(request.options, {canonical, legacy});
require(parsed.has_value(), "CLI preserves option " + key);
require_eq(*parsed, std::string("2"), "alias value " + key);
}
const std::unordered_map<std::string, std::string> matching{{canonical, "2"}, {legacy, "2"}};
require_eq(*find_option(matching, {canonical, legacy}), std::string("2"), "matching aliases");
bool rejected = false;
try {
(void)find_option({{canonical, "2"}, {legacy, "3"}}, {canonical, legacy});
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("conflicting option values") != std::string::npos;
}
require(rejected, "conflicting aliases rejected: " + canonical + "/" + legacy);
}
// This same-value speed alias is for Inflect, not sanoTTS's reciprocal control.
require_eq(*engine::runtime::parse_finite_float_option({{"speaking_rate", "1.5"}},
{"speed", "speaking_rate"}), 1.5F, "legacy Inflect speed");
require_eq(*engine::runtime::parse_finite_float_option({{"speed", "1.5"}},
{"speed", "speaking_rate"}), 1.5F, "normalized Inflect speed");
}
void test_out_format_selects_wav_sample_format() {
const char * no_flag[] = {"audiocpp_cli", "--task", "gen"};
const auto defaults = minitts::cli::wav_write_options_from_cli(3, const_cast<char **>(no_flag));
engine::test::require(
defaults.format == engine::audio::WavSampleFormat::Pcm16,
"no --out-format keeps pcm16");
engine::test::require(
defaults.peak_policy == engine::audio::WavPeakPolicy::HardClip,
"no --out-format keeps hard clip");
const char * float_flag[] = {"audiocpp_cli", "--out", "a.wav", "--out-format", "float32"};
engine::test::require(
minitts::cli::wav_write_options_from_cli(5, const_cast<char **>(float_flag)).format ==
engine::audio::WavSampleFormat::Float32,
"--out-format float32");
engine::test::require(
minitts::cli::parse_wav_sample_format("pcm24") == engine::audio::WavSampleFormat::Pcm24,
"--out-format pcm24");
try {
(void) minitts::cli::parse_wav_sample_format("wav");
} catch (const std::runtime_error &) {
return;
}
throw std::runtime_error("unknown --out-format value was accepted");
}
engine::runtime::TaskRequest request_with_artifacts(
const std::string & artifacts,
const std::filesystem::path & base_dir) {
return minitts::cli::build_request_from_json(
engine::io::json::parse("{\"text\":\"hi\",\"artifacts\":" + artifacts + "}"), base_dir);
}
void require_artifacts_rejected(
const std::string & artifacts,
const std::filesystem::path & base_dir,
const std::string & message) {
try {
(void) request_with_artifacts(artifacts, base_dir);
} catch (const engine::runtime::InvalidRequestError & error) {
engine::test::require(
std::string(error.what()).find(message) != std::string::npos,
"artifacts " + artifacts + " failed with '" + error.what() + "', expected '" + message + "'");
return;
}
throw std::runtime_error("artifacts were accepted: " + artifacts);
}
std::string artifact_text(const engine::runtime::VoiceArtifact & artifact) {
std::string text;
for (const std::byte byte : artifact.payload) {
text.push_back(static_cast<char>(byte));
}
return text;
}
void test_request_json_artifacts() {
static_assert(std::is_base_of_v<std::runtime_error, engine::runtime::InvalidRequestError>,
"the CLI reports artifact errors like any other bad input");
const auto dir = std::filesystem::temp_directory_path() /
("audiocpp-request-artifacts-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
struct RemoveDir {
std::filesystem::path path;
~RemoveDir() {
std::error_code ignored;
std::filesystem::remove_all(path, ignored);
}
} remove_dir{dir};
std::filesystem::create_directories(dir / "turns");
std::ofstream(dir / "turns" / "reply.bin", std::ios::binary) << std::string("\x01\x00\xff\x7f", 4);
std::ofstream(dir / "empty.bin", std::ios::binary).close();
// Inline and file payloads, in order, ids repeated, meta values as strings.
const auto request = request_with_artifacts(
"[{\"id\":\"turn.question\",\"kind\":\"custom\",\"payload\":\"aGVsbG8=\","
"\"meta\":{\"mime\":\"audio/wav\",\"steps\":12,\"ended\":true}},"
"{\"id\":\"turn.reply\",\"kind\":\"acoustic_tokens\",\"path\":\"turns/reply.bin\"},"
"{\"id\":\"turn.question\",\"kind\":\"custom\",\"payload\":\"data:audio/wav;base64,d29y\\nbGQ=\",\"meta\":null},"
"{\"id\":\"empty\",\"kind\":\"midi\",\"path\":" +
engine::io::json::stringify_string((dir / "empty.bin").string()) + "},"
"{\"id\":\"none\",\"kind\":\"vad_state\",\"payload\":\"\",\"path\":null}]",
dir);
const auto & artifacts = request.input_artifacts;
engine::test::require_eq(artifacts.size(), size_t{5}, "request artifact count");
engine::test::require(
artifacts[0].id == "turn.question" && artifacts[0].kind == engine::runtime::ArtifactKind::Custom &&
artifact_text(artifacts[0]) == "hello",
"inline artifact");
engine::test::require(
artifacts[0].meta.size() == 3 && artifacts[0].meta.at("mime") == "audio/wav" &&
artifacts[0].meta.at("steps") == "12" && artifacts[0].meta.at("ended") == "true",
"artifact meta values");
engine::test::require(
artifacts[1].id == "turn.reply" && artifacts[1].kind == engine::runtime::ArtifactKind::AcousticTokens &&
artifact_text(artifacts[1]) == std::string("\x01\x00\xff\x7f", 4) && artifacts[1].meta.empty(),
"file artifact, path relative to the request file");
engine::test::require(
artifacts[2].id == "turn.question" && artifact_text(artifacts[2]) == "world" && artifacts[2].meta.empty(),
"data URI artifact with a line break");
engine::test::require(
artifacts[3].kind == engine::runtime::ArtifactKind::Midi && artifacts[3].payload.empty() &&
artifacts[4].kind == engine::runtime::ArtifactKind::VadState && artifacts[4].payload.empty(),
"empty payloads");
// The kind names the server writes in responses.
const std::vector<std::pair<std::string, engine::runtime::ArtifactKind>> kinds{
{"speaker_embedding", engine::runtime::ArtifactKind::SpeakerEmbedding},
{"style_embedding", engine::runtime::ArtifactKind::StyleEmbedding},
{"prompt_embedding", engine::runtime::ArtifactKind::PromptEmbedding},
{"acoustic_tokens", engine::runtime::ArtifactKind::AcousticTokens},
{"midi", engine::runtime::ArtifactKind::Midi},
{"transcript_alignment", engine::runtime::ArtifactKind::TranscriptAlignment},
{"diarization_state", engine::runtime::ArtifactKind::DiarizationState},
{"vad_state", engine::runtime::ArtifactKind::VadState},
{"custom", engine::runtime::ArtifactKind::Custom},
};
for (const auto & [name, kind] : kinds) {
const auto parsed = request_with_artifacts("[{\"id\":\"k\",\"kind\":\"" + name + "\",\"payload\":\"\"}]", dir);
engine::test::require(parsed.input_artifacts.front().kind == kind, "artifact kind " + name);
}
engine::test::require(request_with_artifacts("null", dir).input_artifacts.empty(), "null artifacts");
engine::test::require(
minitts::cli::build_request_from_json(engine::io::json::parse("{\"text\":\"hi\"}"), dir).input_artifacts.empty(),
"no artifacts field");
require_artifacts_rejected("{}", dir, "artifacts must be an array of artifact objects");
require_artifacts_rejected("[1]", dir, "artifacts[0] must be an object with id, kind, and payload or path");
require_artifacts_rejected("[{\"kind\":\"custom\",\"payload\":\"\"}]", dir, "artifacts[0]: id must be a non-empty string");
require_artifacts_rejected("[{\"id\":\"\",\"kind\":\"custom\",\"payload\":\"\"}]", dir, "artifacts[0]: id must be a non-empty string");
require_artifacts_rejected("[{\"id\":7,\"kind\":\"custom\",\"payload\":\"\"}]", dir, "artifacts[0]: id must be a non-empty string");
require_artifacts_rejected("[{\"id\":\"x\",\"payload\":\"\"}]", dir, "artifacts[0] (x): kind must be a string");
require_artifacts_rejected(
"[{\"id\":\"ok\",\"kind\":\"custom\",\"payload\":\"\"},{\"id\":\"x\",\"kind\":\"tokens\",\"payload\":\"\"}]", dir,
"artifacts[1] (x): unknown kind 'tokens', expected one of speaker_embedding, style_embedding,");
require_artifacts_rejected("[{\"id\":\"x\",\"kind\":\"custom\"}]", dir, "artifacts[0] (x): give exactly one of payload");
require_artifacts_rejected(
"[{\"id\":\"x\",\"kind\":\"custom\",\"payload\":\"\",\"path\":\"turns/reply.bin\"}]", dir,
"artifacts[0] (x): give exactly one of payload");
require_artifacts_rejected("[{\"id\":\"x\",\"kind\":\"custom\",\"payload\":5}]", dir, "artifacts[0] (x): payload must be a base64 string");
require_artifacts_rejected(
"[{\"id\":\"x\",\"kind\":\"custom\",\"payload\":\"aGVsbG8*\"}]", dir,
"artifacts[0] (x): payload is not valid base64 (malformed base64 payload: invalid character)");
require_artifacts_rejected("[{\"id\":\"x\",\"kind\":\"custom\",\"path\":\"\"}]", dir, "artifacts[0] (x): path must be a non-empty string");
require_artifacts_rejected("[{\"id\":\"x\",\"kind\":\"custom\",\"path\":\"missing.bin\"}]", dir, "artifacts[0] (x): path is not a regular file");
require_artifacts_rejected("[{\"id\":\"x\",\"kind\":\"custom\",\"path\":\"turns\"}]", dir, "artifacts[0] (x): path is not a regular file");
require_artifacts_rejected("[{\"id\":\"x\",\"kind\":\"custom\",\"payload\":\"\",\"meta\":[]}]", dir, "artifacts[0] (x): meta must be an object");
require_artifacts_rejected(
"[{\"id\":\"x\",\"kind\":\"custom\",\"payload\":\"\",\"meta\":{\"k\":{}}}]", dir,
"artifacts[0] (x): meta.k must be a string, number or boolean");
require_artifacts_rejected(
"[{\"id\":\"x\",\"kind\":\"custom\",\"payload_hex\":\"00\"}]", dir,
"artifacts[0]: unknown field 'payload_hex', an artifact has id, kind, payload or path, and meta");
#if !defined(_WIN32)
// The payload limit counts every artifact of the request. The file is
// sparse and too big to be read, so nothing near the limit is written.
std::ofstream(dir / "limit.bin", std::ios::binary).close();
std::filesystem::resize_file(dir / "limit.bin", minitts::cli::kMaxRequestArtifactBytes - 4);
require_artifacts_rejected(
"[{\"id\":\"x\",\"kind\":\"custom\",\"payload\":\"aGVsbG8=\"},"
"{\"id\":\"y\",\"kind\":\"custom\",\"path\":\"limit.bin\"}]",
dir, "artifacts[1] (y): the request's artifact payloads total more than 2147483648 bytes");
#endif
}
void test_component_exec_plans() {
using engine::core::BackendType;
using engine::runtime::parse_exec_plan;
const auto parse = [](const std::unordered_map<std::string, std::string> & options) {
return parse_exec_plan(
options, "toy.exec_plan",
{{"decoder", "ggml",
{{"ggml", {BackendType::Cpu}, {}, {}},
{"native", {BackendType::Cpu}, {}, {}},
{"compiled", {}, {"main"}, {"first"}, {"overlap", "gain", "future"}}}},
{"encoder", "ggml", {{"ggml", {BackendType::Cpu}, {}, {}}}}});
};
const auto defaults = parse({});
const auto & default_plan = defaults.at("decoder");
engine::test::require_eq(default_plan.impl, std::string("ggml"), "missing plan preserves default");
engine::test::require(!default_plan.backend && default_plan.artifacts.empty() && default_plan.params.empty(),
"missing plan has no overrides");
engine::test::require_eq(defaults.at("encoder").impl, std::string("ggml"), "all components receive defaults");
for (const auto & value : {"{}", "{\"decoder\":{}}", "{\"decoder\":{\"impl\":\"default\"}}"}) {
const auto plans = parse({{"toy.exec_plan", value}});
engine::test::require(plans.at("decoder").impl == "ggml" && plans.at("encoder").impl == "ggml",
"empty, omitted and explicit default components preserve defaults");
}
const auto plans = parse({{"toy.exec_plan", R"({"decoder":{"impl":"native","backend":"cpu"}})"}});
const auto & native = plans.at("decoder");
engine::test::require(native.backend == BackendType::Cpu && native.impl == "native", "native CPU selection");
engine::test::require_eq(plans.at("encoder").impl, std::string("ggml"), "selecting decoder leaves encoder unchanged");
const auto body = engine::io::json::parse(R"({"toy.exec_plan":{
"encoder":{"backend":"cpu"},
"decoder":{"impl":"compiled","artifacts":{"main":"main.bin","first":"first.bin"},
"params":{"overlap":4,"gain":true,"future":{"nested":[1,2]}}}
}})");
const auto combined = parse(minitts::cli::json_options_map(&body));
const auto & compiled = combined.at("decoder");
engine::test::require(compiled.impl == "compiled" && compiled.artifacts.at("main") == "main.bin" &&
compiled.artifacts.at("first") == "first.bin", "named compiled artifacts");
engine::test::require(compiled.params.at("overlap").as_i64() == 4 && compiled.params.at("gain").as_bool() &&
compiled.params.at("future").require("nested").as_array().size() == 2,
"parameters retain JSON types and support model-local structured settings");
engine::test::require(combined.at("encoder").backend == BackendType::Cpu,
"multiple component settings are resolved independently");
const std::vector<std::pair<std::string, std::string>> rejected = {
{"", "toy.exec_plan"},
{"[]", "must be a JSON object"},
{R"({"unknown":{}})", "unsupported component 'unknown'"},
{R"({"impl":"native"})", "unsupported component 'impl'"},
{R"({"decoder":null})", "toy.exec_plan.decoder must be a JSON object"},
{R"({"decoder":{"impl":false}})", ".impl must be a nonempty string"},
{R"({"decoder":{"impl":""}})", ".impl must be a nonempty string"},
{R"({"decoder":{"impl":"missing"}})", "unsupported impl"},
{R"({"decoder":{"implementation":"native"}})", "unknown field"},
{R"({"decoder":{"backend":1}})", ".backend must be a string"},
{R"({"decoder":{"backend":"missing"}})", "unknown backend"},
{R"({"decoder":{"backend":"cuda"}})", "requested backend is not supported"},
{R"({"decoder":{"impl":"compiled"}})", "requires artifacts.main"},
{R"({"decoder":{"impl":"compiled","artifacts":{"first":"first.bin"}}})", "requires artifacts.main"},
{R"({"decoder":{"impl":"compiled","backend":"cpu","artifacts":{"main":"main.bin"}}})", "requested backend is not supported"},
{R"({"decoder":{"artifacts":[]}})", ".artifacts must be an object"},
{R"({"decoder":{"artifacts":{"main":0}}})", "must be a nonempty file path"},
{R"({"decoder":{"artifacts":{"main":""}}})", "must be a nonempty file path"},
{R"({"decoder":{"artifacts":{"main":"main.bin"}}})", "unsupported artifact role"},
{R"({"decoder":{"impl":"compiled","artifacts":{"main":"main.bin","other":"other.bin"}}})", "unsupported artifact role"},
{R"({"decoder":{"params":[]}})", ".params must be an object"},
{R"({"decoder":{"params":{"overlap":4}}})", "unsupported parameter"},
{R"({"decoder":{"impl":"compiled","artifacts":{"main":"main.bin"},"params":{"unknown":true}}})", "unsupported parameter"},
};
for (const auto & [value, expected] : rejected) {
bool rejected = false;
try {
(void) parse({{"toy.exec_plan", value}});
} catch (const std::runtime_error & error) {
rejected = true;
engine::test::require(std::string(error.what()).find(expected) != std::string::npos,
"exec plan error identifies invalid field: " + value);
}
engine::test::require(rejected, "invalid exec plan must not be ignored: " + value);
}
}
void test_non_negative_int_flag_values() {
using minitts::cli::parse_non_negative_int;
const std::string flag = "--resample-kernel-cache-mb";
engine::test::require_eq(parse_non_negative_int("0", flag), 0, "0 parses");
engine::test::require_eq(parse_non_negative_int("32", flag), 32, "32 parses");
engine::test::require_eq(parse_non_negative_int("2147483647", flag), 2147483647, "INT_MAX parses");
for (const std::string value : {"-1", "abc", "32abc", "1.5", "", " 8", "+5", "2147483648"}) {
bool rejected = false;
try {
(void) parse_non_negative_int(value, flag);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()) == flag + " expects a non-negative integer, got: " + value;
}
engine::test::require(rejected, flag + " rejects '" + value + "' and names the flag");
}
}
} // namespace
int main() {
test_large_safe_integer_numbers_stay_decimal();
test_float_numbers_keep_json_formatting();
test_unsafe_integer_numbers_are_not_silently_rounded();
test_malformed_seeds_are_invalid_requests();
test_audio_only_language_is_request_option();
test_audio_chunk_shortcut_uses_canonical_name();
test_normalized_request_option_aliases();
test_out_format_selects_wav_sample_format();
test_request_json_artifacts();
test_component_exec_plans();
test_non_negative_int_flag_values();
std::cout << "cli_request_options_test passed\n";
return 0;
}