package vip.mate.stt.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Base64;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for {@link DashScopeSttProvider}'s wire-format helpers. The
* HTTP round trip itself isn't exercised (no mock server); request-body
* construction, response/transcript parsing, and error extraction are the
* parts with encoding rules worth pinning:
*
*
* - The audio must ride as a {@code data:;base64,} URI plus an explicit
* {@code format} field — dropping either breaks decoding server-side.
* - {@code asr_options} must be omitted entirely when no language hint
* is supplied, so the service auto-detects.
* - Transcript extraction must tolerate both plain-string and
* content-part-array response shapes.
*
*/
class DashScopeSttProviderTest {
private DashScopeSttProvider provider;
private ObjectMapper mapper;
@BeforeEach
void setUp() {
mapper = new ObjectMapper();
provider = new DashScopeSttProvider(null, mapper);
}
@Test
@DisplayName("buildRequestBody serialises model, base64 audio, format and stream=false")
void buildRequestBody_coreShape() throws Exception {
byte[] audio = "fake-wav-bytes".getBytes(StandardCharsets.UTF_8);
String json = provider.buildRequestBody("qwen3-asr-flash", audio, "wav", null);
JsonNode node = mapper.readTree(json);
assertEquals("qwen3-asr-flash", node.path("model").asText());
assertEquals(false, node.path("stream").asBoolean(true));
JsonNode content = node.path("messages").path(0).path("content").path(0);
assertEquals("user", node.path("messages").path(0).path("role").asText());
assertEquals("input_audio", content.path("type").asText());
assertEquals("wav", content.path("input_audio").path("format").asText());
String data = content.path("input_audio").path("data").asText();
assertTrue(data.startsWith("data:;base64,"), "audio must be a base64 data URI");
assertEquals(Base64.getEncoder().encodeToString(audio),
data.substring("data:;base64,".length()));
}
@Test
@DisplayName("buildRequestBody adds asr_options.language with locale stripped")
void buildRequestBody_languageHint() throws Exception {
String json = provider.buildRequestBody("qwen3-asr-flash", new byte[]{1}, "wav", "zh-CN");
JsonNode node = mapper.readTree(json);
assertEquals("zh", node.path("asr_options").path("language").asText());
}
@Test
@DisplayName("buildRequestBody omits asr_options when language is null — auto-detect")
void buildRequestBody_omitsAsrOptionsWhenNoLanguage() throws Exception {
// An empty or null language means "let the service detect the
// language"; sending asr_options with a null/blank language field
// would be rejected as a parameter error.
String json = provider.buildRequestBody("qwen3-asr-flash", new byte[]{1}, "wav", null);
assertTrue(mapper.readTree(json).path("asr_options").isMissingNode());
String jsonBlank = provider.buildRequestBody("qwen3-asr-flash", new byte[]{1}, "wav", " ");
assertTrue(mapper.readTree(jsonBlank).path("asr_options").isMissingNode());
}
@Test
@DisplayName("stripLocale: zh-CN → zh, en → en, null/blank → null")
void stripLocale_variants() {
assertEquals("zh", DashScopeSttProvider.stripLocale("zh-CN"));
assertEquals("zh", DashScopeSttProvider.stripLocale("ZH-Hant"));
assertEquals("en", DashScopeSttProvider.stripLocale("en"));
assertNull(DashScopeSttProvider.stripLocale(null));
assertNull(DashScopeSttProvider.stripLocale(" "));
}
@Test
@DisplayName("resolveFormat maps filename/content-type to the input_audio format value")
void resolveFormat_variants() {
assertEquals("wav", DashScopeSttProvider.resolveFormat("clip.wav", null));
assertEquals("mp3", DashScopeSttProvider.resolveFormat(null, "audio/mpeg"));
assertEquals("ogg", DashScopeSttProvider.resolveFormat("note.ogg", "audio/ogg"));
assertEquals("webm", DashScopeSttProvider.resolveFormat(null, "audio/webm; codecs=opus"));
// Unknown everything → wav (matches the web recorder's output).
assertEquals("wav", DashScopeSttProvider.resolveFormat(null, null));
}
@Test
@DisplayName("parseTranscript reads plain-string message content")
void parseTranscript_stringContent() throws Exception {
String response = """
{"choices":[{"message":{"role":"assistant","content":"你好世界"},
"finish_reason":"stop"}],"usage":{}}
""";
assertEquals("你好世界", provider.parseTranscript(response));
}
@Test
@DisplayName("parseTranscript concatenates content-part arrays")
void parseTranscript_arrayContent() throws Exception {
String response = """
{"choices":[{"message":{"content":[{"text":"你好"},{"text":"世界"}]}}]}
""";
assertEquals("你好世界", provider.parseTranscript(response));
}
@Test
@DisplayName("parseTranscript returns empty string on missing/odd shapes instead of throwing")
void parseTranscript_missingContent() throws Exception {
assertEquals("", provider.parseTranscript("{}"));
assertEquals("", provider.parseTranscript("{\"choices\":[]}"));
}
@Test
@DisplayName("parseErrorMessage handles compatible-mode and native error bodies")
void parseErrorMessage_variants() {
assertEquals("InvalidApiKey — Invalid API-key provided.",
provider.parseErrorMessage("""
{"error":{"code":"InvalidApiKey","message":"Invalid API-key provided."}}
"""));
assertEquals("Throttling — Requests throttled.",
provider.parseErrorMessage("""
{"code":"Throttling","message":"Requests throttled."}
"""));
assertEquals("just a message",
provider.parseErrorMessage("{\"message\":\"just a message\"}"));
assertEquals("", provider.parseErrorMessage("not json"));
assertEquals("", provider.parseErrorMessage(null));
}
@Test
@DisplayName("computePcmPeakRms returns 0,0 on silence; non-zero on synthetic tone")
void computePcmPeakRms_distinguishesSilenceFromSignal() {
// The diagnostic distinguishing "mic captured silence" (peak=0) from
// "provider rejected non-empty audio" is a critical user-visible
// signal — pin its math.
byte[] silent = new byte[1000]; // all zeros
int[] silentStats = DashScopeSttProvider.computePcmPeakRms(silent);
assertEquals(0, silentStats[0]);
assertEquals(0, silentStats[1]);
// Two samples: 0x4000 (16384, positive) and 0xC000 (-16384, negative).
// peak should be 16384, rms = sqrt((16384^2 + 16384^2) / 2) = 16384.
byte[] tone = new byte[]{
0x00, 0x40, // 16384 little-endian
0x00, (byte) 0xC0 // -16384 little-endian
};
int[] toneStats = DashScopeSttProvider.computePcmPeakRms(tone);
assertEquals(16384, toneStats[0]);
assertEquals(16384, toneStats[1]);
}
@Test
@DisplayName("autoDetectOrder boosts DashScope on Chinese, defaults otherwise")
void autoDetectOrder_languageRouting() {
assertEquals(60, provider.autoDetectOrder("zh"));
assertEquals(60, provider.autoDetectOrder("zh-CN"));
assertEquals(60, provider.autoDetectOrder("ZH-Hant")); // case-insensitive
assertEquals(150, provider.autoDetectOrder("en-US")); // default order
assertEquals(150, provider.autoDetectOrder(null)); // language unknown
}
}