feat(voice): 接收侧语音消息 —— 显示 + 播放(W62 / 契约 v0.79 §4.4 与 §1.2 U19)
别的客户端(QQ / 微信)发来的语音,现在能正确显示并播放。只做接收侧,不做本端录音/发送。
契约口径(逐条落地,不自己发明键名):
- **判据 = `voice` 键存在**:绝不从 `content` 正文里认媒体类型(`[语音]` 占位标记自 v0.67 起不再
下发,正文里也可能有用户自己打的同样字样);
- **按 `status` 分支**(`transcribed` / `empty` / `failed` 三态),正文一律照常显示、一个字不改;
- **`name` / `mime` / `size` / `url` 四者同生共死** ⇒ 凑不齐就**不画播放控件**(不给必然失败的按钮);
- **`durationMs` 缺席就不显示时长**(不反算、不显示 `0:00` 冒充);
- **不在 WS 实时帧上等 `voice`**(覆盖率:只有四个 REST 读取面填充)—— 实时到达的先按普通文本显示,
由既有的「按 id 合并、新到的胜出」在下一次拉取时自动升级成语音气泡(新增单测钉住这条依赖)。
改了什么:
- `VoiceDto`(7 个键逐字照抄)+ `HistoryMessageDto.voice: VoiceDto? = null`(缺席与 null 同一件事);
- `domain/chat/VoiceMessage.kt`:判据与展示换算的**唯一落点**(`isVoiceMessage` / `voiceAudio` /
`voiceDurationLabel` / `voiceNote` / `voiceMediaUrl` / `voicePlaybackFailureMessage`);
- `domain/chat/VoicePlayback.kt`:播放通道的纯状态机(Idle / Loading / Playing / Failed),
每次转换都按 `messageId` 比对 ⇒ 迟到回调不许改状态;
- `ui/chat/VoicePlayer.kt`:`android.media.MediaPlayer`(平台 API,**不引任何新依赖**)驱动那条通道,
三处收干净:换目标先停旧、换会话换实例、组合销毁 `onDispose` 释放;
- `ui/chat/VoiceBubble.kt`:`[播放/暂停] 语音 · 时长` + `empty` 那句补充说明 + 失败提示(一行,error 色);
图标用 AAR 里真实存在的 `MiuixIcons.Play` / `Pause`(extended 包,basic 那 7 枚里没有);
- `data/api/MediaApi.kt` + `data/repository/VoiceStore.kt`:`GET {当前地址}{voice.url}`(自带的
Bearer + 401 续期走 `ApiClientFactory`),字节落 `cacheDir/media/<存储名>`(复用 `DocumentCache`
的名字判据 / 命中判定 / 原子落盘;**不落 `documents/`**,那是 FileProvider 唯一暴露的根);
- `MessageBubble` 渲染语音形态;`isContentlessUserMessage` 加一条「语音消息不算什么都没有」
(否则空转写的语音会被扣上「内容没有被传过来」那顶**假**帽子);
- `devtools/fake-backend/`:新增语音夹具(`__control/messages?voice=&duration=` + 真 2 秒 WAV 的
`/api/v1/media/{name}`)—— 生产库里一条带 `voice` 的真消息都没有,设备验收只能靠造。
为什么这么改:`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路,而语音是唯一「会出声」的
消息形态,生命周期必须有一处说清楚(写在 `VoicePlayer` 的 KDoc);播放失败必须可见(按钮回未播 +
一行短提示),一条点了没反应的语音在用户眼里就是坏的。
验收:`assembleDebug` 通过;`testDebugUnitTest` 120 suites / 1458 tests / 0 failures / 0 errors
(基线 115 / 1405,新增 5 个套件 53 条)。
This commit is contained in:
1 parent
55612a3922
commit
2fbf24a675
20 files changed
+2330
-8
No files matched your search
@@ -0,0 +1,218 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.domain.chat.VoiceSource
|
||||
import top.yeij.cyrene.domain.chat.VoiceStatus
|
||||
|
||||
/**
|
||||
* `voice` 键的解码钉子(契约 **v0.67 登记、v0.79 已实现**,api-client.md §4.4 与 §1.2 U19)。
|
||||
*
|
||||
* 形状来源就是契约里那两处(示例 JSON + 语义段),**逐键手写**(没有 v0.79 的真实抓包);
|
||||
* 解码器用 [ApiClientFactory.json](与生产同一份配置),所以这里过不了的形状线上也过不了。
|
||||
*
|
||||
* 要钉住的六件事:
|
||||
* 1. **七个键逐字照抄**:`source` / `status` / `name` / `mime` / `size` / `durationMs` / `url`
|
||||
* —— 键名不许自己发明(形状错一个字母线上就是「不是语音」);
|
||||
* 2. **缺席与 `null` 是同一件事**:非语音消息整键缺席 ⇒ `null`(不是 `{}`,也不是别的哨兵);
|
||||
* 3. **四者同生共死**:`name` / `mime` / `size` / `url` 全在或全不在(这里两侧都钉);
|
||||
* 4. **`durationMs` 可以缺席**(服务端「读不出来就省略键,绝不估算」);
|
||||
* 5. **`source` / `status` 的闭集取值原样保留**(含认不出的取值:本端不猜、不报错、不丢);
|
||||
* 6. **未知键不炸**(`ignoreUnknownKeys`:服务端加字段不该让整条历史解码失败)。
|
||||
*/
|
||||
class VoiceDtoTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
/** 契约 §4.4 的示例逐字(含四个副本键 + `durationMs`)。 */
|
||||
private val transcribed = """
|
||||
{
|
||||
"id": "msg-1",
|
||||
"sessionId": "web",
|
||||
"role": "user",
|
||||
"content": "明天几点出发",
|
||||
"createdAt": 1700000000000,
|
||||
"voice": {
|
||||
"source": "asr",
|
||||
"status": "transcribed",
|
||||
"name": "$name",
|
||||
"mime": "audio/ogg",
|
||||
"size": 12345,
|
||||
"durationMs": 3200,
|
||||
"url": "/api/v1/media/$name"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun decode(body: String) = json.decodeFromString(HistoryMessageDto.serializer(), body)
|
||||
|
||||
@Test
|
||||
fun `a transcribed voice decodes the seven keys verbatim`() {
|
||||
val voice = decode(transcribed).voice!!
|
||||
|
||||
assertEquals(VoiceSource.ASR, voice.source)
|
||||
assertEquals(VoiceStatus.TRANSCRIBED, voice.status)
|
||||
assertEquals(name, voice.name)
|
||||
assertEquals("audio/ogg", voice.mime)
|
||||
assertEquals(12345L, voice.size)
|
||||
assertEquals(3200, voice.durationMs)
|
||||
// url 是**服务端相对路径**:本端绝不把它当绝对地址,也绝不自己拼这条路。
|
||||
assertEquals("/api/v1/media/$name", voice.url)
|
||||
assertFalse(voice.url!!.startsWith("http"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message without the voice key decodes to null, not to an empty object`() {
|
||||
val body = """
|
||||
{"id":"msg-2","sessionId":"web","role":"user","content":"在的","createdAt":1700000000001}
|
||||
""".trimIndent()
|
||||
|
||||
assertNull("🎤 判据 = 键存在:缺席必须是 null", decode(body).voice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit null is the same thing as an absent key`() {
|
||||
// 契约说 `voice` 是**指针**、非语音消息整键缺席(不是 null);真收到 null 也按「没有」处理。
|
||||
val body = """
|
||||
{"id":"msg-3","sessionId":"web","role":"user","content":"在的","createdAt":1700000000002,
|
||||
"voice":null}
|
||||
""".trimIndent()
|
||||
|
||||
assertNull(decode(body).voice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice with no audio copy has all four copy keys absent`() {
|
||||
// 微信(weixin-ilink):平台自带转写就不白下载那份音频 ⇒ 只有 🎤 标记,四个键全缺席。
|
||||
// ⚠️ 时长与副本**无关**:渠道给了就留着(所以这条夹具带着 durationMs,而四个副本键照样全缺)。
|
||||
val body = """
|
||||
{"id":"msg-4","sessionId":"web","role":"user","content":"在忙吗","createdAt":1700000000003,
|
||||
"voice":{"source":"platform","status":"transcribed","durationMs":5000}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(VoiceSource.PLATFORM, voice.source)
|
||||
assertNull(voice.name)
|
||||
assertNull(voice.mime)
|
||||
assertNull(voice.size)
|
||||
assertNull(voice.url)
|
||||
assertEquals(5000, voice.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy keeps the four keys together including size zero and a missing duration`() {
|
||||
// durationMs 缺席(服务端读不出来时不估算)——**其余四个键照样齐全**。
|
||||
val body = """
|
||||
{"id":"msg-5","sessionId":"web","role":"user","content":"","createdAt":1700000000004,
|
||||
"voice":{"source":"asr","status":"empty","name":"$name","mime":"audio/ogg",
|
||||
"size":0,"url":"/api/v1/media/$name"}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(name, voice.name)
|
||||
assertEquals("audio/ogg", voice.mime)
|
||||
assertEquals("size 为 0 也照样解码(本端不拿它推导任何东西)", 0L, voice.size)
|
||||
assertEquals("/api/v1/media/$name", voice.url)
|
||||
assertNull("缺席 ≠ 0:必须是 null,才能让界面「一个字符都不画」", voice.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration reported by the channel is kept as is`() {
|
||||
val body = """
|
||||
{"id":"msg-6","sessionId":"web","role":"user","content":"","createdAt":1700000000005,
|
||||
"voice":{"source":"asr","status":"transcribed","durationMs":60000}}
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(60_000, decode(body).voice!!.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the three statuses and both sources are kept verbatim, and an unknown one is not guessed`() {
|
||||
val cases = listOf(
|
||||
VoiceStatus.TRANSCRIBED to VoiceSource.ASR,
|
||||
VoiceStatus.EMPTY to VoiceSource.ASR,
|
||||
VoiceStatus.FAILED to VoiceSource.PLATFORM,
|
||||
// 认不出的取值:原样保留(本端不猜、不报错、不丢——显示层对它们与本端不认识时同形)。
|
||||
"future-status" to "future-source",
|
||||
)
|
||||
|
||||
cases.forEach { (status, source) ->
|
||||
val body = """
|
||||
{"id":"msg-7","sessionId":"web","role":"user","content":"x","createdAt":1700000000006,
|
||||
"voice":{"source":"$source","status":"$status"}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice!!
|
||||
assertEquals(status, voice.status)
|
||||
assertEquals(source, voice.source)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown extra key inside voice does not break the message`() {
|
||||
val body = """
|
||||
{"id":"msg-8","sessionId":"web","role":"user","content":"x","createdAt":1700000000007,
|
||||
"voice":{"source":"asr","status":"transcribed","waveform":[1,2,3]}}
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(VoiceStatus.TRANSCRIBED, decode(body).voice!!.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty voice object decodes as present`() {
|
||||
// 契约说不会下发 `{}`(「不是 {} 也不是 null」),真收到时本端只认「键存在」这一条判据
|
||||
// ⇒ 它算一条语音消息(四个副本键全缺 ⇒ 不画播放控件)。**刻意**在这里钉住这个选择:
|
||||
// 「键在不在」是契约给的唯一判据,本端不额外发明一条「对象够不够完整」的判据。
|
||||
val body = """
|
||||
{"id":"msg-9","sessionId":"web","role":"user","content":"","createdAt":1700000000008,
|
||||
"voice":{}}
|
||||
""".trimIndent()
|
||||
|
||||
val voice = decode(body).voice
|
||||
assertTrue(voice != null)
|
||||
assertEquals("", voice!!.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoding a full voice keeps the seven key names`() {
|
||||
// 反向也要钉一次:形状变了一处(键名写错 / 少一个键),这条用例立刻红。
|
||||
val encoded = json.encodeToJsonElement(
|
||||
VoiceDto.serializer(),
|
||||
VoiceDto(
|
||||
source = VoiceSource.ASR,
|
||||
status = VoiceStatus.TRANSCRIBED,
|
||||
name = name,
|
||||
mime = "audio/ogg",
|
||||
size = 12345,
|
||||
durationMs = 3200,
|
||||
url = "/api/v1/media/$name",
|
||||
),
|
||||
).jsonObject
|
||||
|
||||
assertEquals(
|
||||
setOf("source", "status", "name", "mime", "size", "durationMs", "url"),
|
||||
encoded.keys,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encoding a voice with no copy omits the nullable keys`() {
|
||||
// `explicitNulls = false`(与后端 `omitempty` 同一口径):无副本时那四个键**不写出来**,
|
||||
// 而不是写成 `null`——否则服务端与别的客户端会看到一个「有键但值是 null」的陌生形状。
|
||||
val encoded = json.encodeToJsonElement(
|
||||
VoiceDto.serializer(),
|
||||
VoiceDto(source = VoiceSource.PLATFORM, status = VoiceStatus.TRANSCRIBED),
|
||||
).jsonObject
|
||||
|
||||
assertEquals(setOf("source", "status"), encoded.keys)
|
||||
assertEquals(VoiceSource.PLATFORM, encoded["source"]!!.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.MediaApi
|
||||
import top.yeij.cyrene.domain.chat.voicePlaybackFailureMessage
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* `GET /api/v1/media/{name}` 的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带的 HTTP 服务端
|
||||
* (与 `DocumentRepositoryTest` / `ImageCacheTest` 同一手法,不引新依赖)。
|
||||
*
|
||||
* 形状与状态码来源:契约 **v0.79** 的 §1.2 **U19** 一行(`200` / `206` / `304` / `401` / `403` /
|
||||
* `404` / `416`;`Content-Type` 是音频类型;`404 media_not_found`)与 §4.5。
|
||||
* **没有真实抓包**:这里的响应是按契约逐条手写的。
|
||||
*
|
||||
* 要证明的四件事:
|
||||
* 1. 请求真的发到**服务端给的那条路径**(`/api/v1/media/<name>`;本端一个字都不拼);
|
||||
* 2. **必须带 `Authorization: Bearer`**(U19 是 `owner|admin` 端点:语音是私密内容);
|
||||
* 3. `200` 回的是**字节本身**(不是 JSON 信封),且一个字不改;
|
||||
* 4. `404 media_not_found` / `401` / 非信封错误体 / 0 字节都折成可分支的失败,
|
||||
* **0 字节不算成功**(那会让下一次播放「命中缓存」一份什么都没有的文件)。
|
||||
*/
|
||||
class MediaRepositoryTest {
|
||||
|
||||
private val backend = FakeMediaBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = MediaRepository(
|
||||
ApiClientFactory.create(
|
||||
baseUrl = backend.baseUrl,
|
||||
service = MediaApi::class.java,
|
||||
tokenProvider = { TOKEN },
|
||||
),
|
||||
)
|
||||
|
||||
/** 一段假的音频字节:这里只关心「字节原样过」,不关心它是不是真能解码。 */
|
||||
private val bytes = ByteArray(64) { it.toByte() }
|
||||
|
||||
@Test
|
||||
fun `a copy is fetched from the path the server gave, with a bearer token`() = runBlocking {
|
||||
backend.reply(200, bytes, "audio/ogg")
|
||||
|
||||
val fetched = repository().download(backend.mediaUrl(backend.name)).getOrThrow()
|
||||
|
||||
assertArrayEquals(bytes, fetched)
|
||||
val request = backend.lastRequest()
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals(
|
||||
"请求路径必须**逐字**是服务端给的 voice.url(客户端绝不自己拼)",
|
||||
"/api/v1/media/${backend.name}",
|
||||
request.path,
|
||||
)
|
||||
assertEquals("语音是私密内容:U19 要求 owner|admin,必须带 Bearer", "Bearer $TOKEN", request.authorization)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing copy is reported as not found, not as an empty audio`() = runBlocking {
|
||||
backend.reply(
|
||||
404,
|
||||
"""{"error":{"code":"media_not_found","message":"media not found"}}""".toByteArray(),
|
||||
"application/json",
|
||||
)
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertTrue(error is CyreneException)
|
||||
// 非法名字 / 穿越 / 不存在服务端统一同码(不给探测面)⇒ 用户看到的就是本端那一句
|
||||
// 「服务端上已经没有这条语音了」(**不是**把 media not found 原样抖给用户)。
|
||||
assertEquals("服务端上已经没有这条语音了", voicePlaybackFailureMessage(error!!))
|
||||
assertNull(repository().download(backend.mediaUrl(backend.name)).getOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non envelope error body is not parsed as an envelope`() = runBlocking {
|
||||
// 416(Range 不可满足)由 Go 标准库直写 text/plain + Content-Range,**不套** JSON 信封
|
||||
// (契约 §0.3 / §4.5)⇒ 本端不许拿 error.code 去解析它,也不能因此崩。
|
||||
backend.reply(416, "invalid range".toByteArray(), "text/plain")
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertTrue(error is CyreneException)
|
||||
val mapped = (error as CyreneException).error
|
||||
assertTrue(mapped is CyreneError.Server)
|
||||
assertEquals(
|
||||
"非信封响应不许编出一个 code,也不许炸",
|
||||
"",
|
||||
(mapped as CyreneError.Server).code,
|
||||
)
|
||||
assertEquals(416, mapped.httpStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unauthorized response is mapped as unauthorized`() = runBlocking {
|
||||
backend.reply(
|
||||
401,
|
||||
"""{"error":{"code":"unauthorized","message":"no token"}}""".toByteArray(),
|
||||
"application/json",
|
||||
)
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertTrue((error as CyreneException).error is CyreneError.Unauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty two hundred is a failure, never a cached empty file`() = runBlocking {
|
||||
backend.reply(200, ByteArray(0), "audio/ogg")
|
||||
|
||||
val error = repository().download(backend.mediaUrl(backend.name)).exceptionOrNull()
|
||||
|
||||
assertEquals(
|
||||
"0 字节必须判失败(否则下一次播放会「命中」一份什么都没有的文件)",
|
||||
CyreneError.Local(CyreneError.LocalKind.Parse),
|
||||
(error as CyreneException).error,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that is not there fails and is classified as a local failure`() = runBlocking {
|
||||
// 对端不在听(换服务器 / adb reverse 掉了)。
|
||||
val dead = MediaRepository(
|
||||
ApiClientFactory.create(
|
||||
baseUrl = "http://127.0.0.1:1/",
|
||||
service = MediaApi::class.java,
|
||||
tokenProvider = { TOKEN },
|
||||
),
|
||||
)
|
||||
|
||||
val error = dead.download("http://127.0.0.1:1/api/v1/media/${backend.name}").exceptionOrNull()
|
||||
|
||||
assertTrue(error is CyreneException)
|
||||
// 只要求「是本地那一档」:具体是哪一种由系统决定,不逐条钉死。
|
||||
assertTrue((error as CyreneException).error is CyreneError.Local)
|
||||
}
|
||||
|
||||
private class FakeMediaBackend {
|
||||
|
||||
val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
private val requests = CopyOnWriteArrayList<Request>()
|
||||
private var status = 200
|
||||
private var body = ByteArray(0)
|
||||
private var contentType = "audio/ogg"
|
||||
|
||||
private val server: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/") { exchange -> handle(exchange) }
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
fun mediaUrl(name: String): String = "${baseUrl}api/v1/media/$name"
|
||||
|
||||
fun reply(status: Int, body: ByteArray, contentType: String) {
|
||||
this.status = status
|
||||
this.body = body
|
||||
this.contentType = contentType
|
||||
}
|
||||
|
||||
fun lastRequest(): Request = requests.lastOrNull() ?: error("服务端一个请求都没收到")
|
||||
|
||||
fun close() = server.stop(0)
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
requests += Request(
|
||||
method = exchange.requestMethod,
|
||||
path = exchange.requestURI.path,
|
||||
authorization = exchange.requestHeaders.getFirst("Authorization"),
|
||||
)
|
||||
exchange.responseHeaders.add("Content-Type", contentType)
|
||||
// 空体用 -1(不是 0):0 会让标准库改用 chunked,那样「0 字节」这条用例测的就不是
|
||||
// 我们想测的那件事了(见 ImageCacheTest 的同名手法)。
|
||||
if (body.isEmpty()) {
|
||||
exchange.sendResponseHeaders(status, -1)
|
||||
} else {
|
||||
exchange.sendResponseHeaders(status, body.size.toLong())
|
||||
}
|
||||
exchange.responseBody.use { it.write(body) }
|
||||
}
|
||||
|
||||
data class Request(val method: String, val path: String, val authorization: String?)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOKEN = "fake-token-42"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -86,6 +87,34 @@ class MessageMergeTest {
|
||||
assertEquals(listOf("m3"), idsOf(incoming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a later page carrying voice upgrades the earlier copy of the same message`() {
|
||||
// 契约 v0.79(api-client §4.4 覆盖率):`voice` **只在四个 REST 读取面**填充,
|
||||
// `message_persisted` / 握手 `history` 回放**不带**它(入站语音的 voice 列是落库之后回填的)。
|
||||
// ⇒ 设计上「实时到达的语音先按普通文本显示,等下一次拉取自动升级成语音气泡」,
|
||||
// 而这条设计**完全依赖**下面这条既有规则:按 id 合并时**新到的那一份胜出**。
|
||||
// 这条用例存在的意义是:谁哪天把「incoming 胜出」改成「保留旧的」,语音气泡就永远不会出现。
|
||||
val withoutVoice = msg("m1", 100, content = "明天几点出发")
|
||||
val withVoice = withoutVoice.copy(
|
||||
voice = VoiceDto(
|
||||
source = "asr",
|
||||
status = "transcribed",
|
||||
name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",
|
||||
mime = "audio/ogg",
|
||||
size = 12_345,
|
||||
durationMs = 3_200,
|
||||
url = "/api/v1/media/a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",
|
||||
),
|
||||
)
|
||||
|
||||
val merged = mergeMessages(listOf(withoutVoice), listOf(withVoice))
|
||||
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals("m1", merged.single().id)
|
||||
assertEquals("asr", merged.single().voice?.source)
|
||||
assertEquals(3_200, merged.single().voice?.durationMs)
|
||||
}
|
||||
|
||||
private fun msgIn(sessionId: String, id: String) = HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = sessionId,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.domain.chat.VoiceAudio
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* 「播放前取字节」这一层([VoiceStore]):地址怎么拼、缓存怎么命中、落哪儿、失败留不留残骸。
|
||||
*
|
||||
* 用真临时目录 + 一个假的下载器(`suspend (String) -> Result<ByteArray>`)跑完,不需要网络也不需要
|
||||
* Android([VoiceStore] 这一层的行为全是文件系统与地址拼接)。**真的走 HTTP** 那一半在
|
||||
* [MediaRepositoryTest](JDK 自带服务端 + 真 OkHttp/Retrofit)。
|
||||
*
|
||||
* 要钉住的六件事:
|
||||
* 1. **请求地址 = 当前服务器地址 + 服务端给的相对路径**(`voice.url` 逐字保留,本端不拼路径);
|
||||
* 2. **重复播放一个请求都不发**(内容寻址 ⇒ 命中永不失效);
|
||||
* 3. **落盘位置是 `<cacheDir>/media/`**,不在 `documents/`(那是 FileProvider 唯一暴露出去的根,
|
||||
* 本片不扩大暴露面);
|
||||
* 4. 名字不可信 / 地址拼不出来 ⇒ **如实失败且不发请求**(绝不退回「自己拼一条路径」);
|
||||
* 5. 下载失败**不留半截文件**(否则下一次会命中一份被截断的音频);
|
||||
* 6. 0 字节的残留当**未命中**(重新取),缓存目录写不进去则如实报缓存不可用。
|
||||
*/
|
||||
class VoiceStoreTest {
|
||||
|
||||
private lateinit var cacheRoot: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cacheRoot = Files.createTempDirectory("cyrene-voice").toFile()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
cacheRoot.deleteRecursively()
|
||||
}
|
||||
|
||||
private val name = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
private val ogg = ByteArray(64) { it.toByte() }
|
||||
|
||||
private fun audio(name: String = this.name, url: String? = null) =
|
||||
VoiceAudio(
|
||||
name = name,
|
||||
mime = "audio/ogg",
|
||||
size = ogg.size.toLong(),
|
||||
url = url ?: "/api/v1/media/$name",
|
||||
durationMs = 3_200,
|
||||
)
|
||||
|
||||
private fun store(
|
||||
baseUrl: String = BASE_URL,
|
||||
download: suspend (String) -> Result<ByteArray>,
|
||||
) = VoiceStore(
|
||||
download = download,
|
||||
cacheDir = cacheRoot,
|
||||
baseUrlProvider = { baseUrl },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the request goes to the current server plus the path the server gave`() = runBlocking {
|
||||
val requested = mutableListOf<String>()
|
||||
val store = store(download = { url -> requested += url; Result.success(ogg) })
|
||||
|
||||
val file = store.load(audio()).getOrThrow()
|
||||
|
||||
assertEquals(listOf("$BASE_URL/api/v1/media/$name"), requested)
|
||||
// 落盘位置:`<cacheDir>/media/<存储名>`(不是 documents/ —— 那是 FileProvider 那一层)。
|
||||
assertEquals(File(VoiceStore.directory(cacheRoot), name), file)
|
||||
assertEquals(ogg.size.toLong(), file.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing the same copy twice asks the server only once`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val first = store.load(audio()).getOrThrow()
|
||||
val second = store.load(audio()).getOrThrow()
|
||||
|
||||
assertEquals("名字由内容决定 ⇒ 命中一个请求都不发", 1, downloads)
|
||||
assertEquals(first, second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero byte leftover is treated as a miss and refetched`() = runBlocking {
|
||||
val leftover = File(VoiceStore.directory(cacheRoot), name)
|
||||
leftover.parentFile!!.mkdirs()
|
||||
leftover.writeBytes(ByteArray(0))
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val file = store.load(audio()).getOrThrow()
|
||||
|
||||
assertEquals(1, downloads)
|
||||
assertEquals(ogg.size.toLong(), file.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unusable server address fails without any request`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(baseUrl = "", download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val result = store.load(audio())
|
||||
|
||||
assertTrue(result.exceptionOrNull() is VoiceUrlUnavailableException)
|
||||
assertEquals("拼不出可信地址就如实失败,绝不自己拼一条", 0, downloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a relative path that is not a media path never becomes a request`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
val result = store.load(audio(url = "/api/v1/documents/$name"))
|
||||
|
||||
assertTrue(result.exceptionOrNull() is VoiceUrlUnavailableException)
|
||||
assertEquals(0, downloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an untrustworthy name is never turned into a cache path`() = runBlocking {
|
||||
var downloads = 0
|
||||
val store = store(download = { downloads++; Result.success(ogg) })
|
||||
|
||||
// 地址那一半照旧可信(服务端给的路径正常),不可信的是**存储名**自己:拼缓存路径的那一步
|
||||
// 必须挡住它(否则能爬出 `cacheDir/media/`)。
|
||||
val result = store.load(audio(name = "../../escape.ogg", url = "/api/v1/media/escape.ogg"))
|
||||
|
||||
assertEquals(
|
||||
CyreneError.Local(CyreneError.LocalKind.Parse),
|
||||
(result.exceptionOrNull() as CyreneException).error,
|
||||
)
|
||||
assertEquals(0, downloads)
|
||||
assertFalse("名字不可信时连缓存目录都不该被碰", VoiceStore.directory(cacheRoot).exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed download leaves no half file for the next play to believe`() = runBlocking {
|
||||
var attempted = 0
|
||||
val failing = store(download = {
|
||||
attempted++
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)))
|
||||
})
|
||||
|
||||
val first = failing.load(audio())
|
||||
|
||||
assertTrue(first.isFailure)
|
||||
val directory = VoiceStore.directory(cacheRoot)
|
||||
assertTrue(
|
||||
"失败不许留下任何文件(`.part` 临时文件也要清干净)",
|
||||
!directory.exists() || directory.listFiles()!!.isEmpty(),
|
||||
)
|
||||
|
||||
// 再点一次:真的重发(不是「命中一份空文件」)。
|
||||
val retry = store(download = { attempted++; Result.success(ogg) }).load(audio()).getOrThrow()
|
||||
assertEquals(2, attempted)
|
||||
assertEquals(ogg.size.toLong(), retry.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cache directory that cannot be written is reported as such`() = runBlocking {
|
||||
// 拿一个**普通文件**当缓存目录:建目录必然失败。
|
||||
val blocked = File(cacheRoot, "blocked").apply { writeBytes("not a directory".toByteArray()) }
|
||||
val store = VoiceStore(
|
||||
download = { Result.success(ogg) },
|
||||
cacheDir = blocked,
|
||||
baseUrlProvider = { BASE_URL },
|
||||
)
|
||||
|
||||
val result = store.load(audio())
|
||||
|
||||
assertTrue(result.exceptionOrNull() is DocumentCacheUnavailableException)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BASE_URL = "http://192.168.1.9:8080"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.VoiceTooLargeException
|
||||
import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
|
||||
import top.yeij.cyrene.ui.chat.isContentlessUserMessage
|
||||
|
||||
/**
|
||||
* 语音消息的**呈现判据**(纯函数,接收侧)。
|
||||
*
|
||||
* 契约的权威形状见 `Cyrene_v2/Docs/api-client.md` §4.4 与 §1.2 U19,五条口径逐条钉在这里:
|
||||
* 1. **🎤 判据 = `voice` 键存在**(`isVoiceMessage`)——**绝不**从正文里认媒体类型
|
||||
* (正文里可能出现用户自己打出的 `[语音]` 字样);
|
||||
* 2. **无副本 ⇒ 不画播放控件**(`voiceAudio` 要四个键齐全);
|
||||
* 3. **`durationMs` 缺席就不显示时长**(`voiceDurationLabel`),也不显示 `0:00` 冒充;
|
||||
* 4. **三态 `status` 的差别只有一句补充说明**(`voiceNote`),正文一律照旧由调用方显示;
|
||||
* 5. **相对路径 `url` 接到当前服务器地址上**(`voiceMediaUrl`),拼不出来就**如实说拼不出来**,
|
||||
* 绝不退回「自己拼一条 `/api/v1/media/<name>`」。
|
||||
*/
|
||||
class VoiceMessageTest {
|
||||
|
||||
private fun message(
|
||||
content: String = "",
|
||||
role: String = "user",
|
||||
voice: VoiceDto? = null,
|
||||
) = HistoryMessageDto(
|
||||
id = "msg-1",
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = content,
|
||||
voice = voice,
|
||||
createdAt = 1_791_088_571_785L,
|
||||
)
|
||||
|
||||
private val copyName = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg"
|
||||
|
||||
private fun voice(
|
||||
source: String = VoiceSource.ASR,
|
||||
status: String = VoiceStatus.TRANSCRIBED,
|
||||
name: String? = copyName,
|
||||
mime: String? = "audio/ogg",
|
||||
size: Long? = 12_345,
|
||||
durationMs: Int? = 3_200,
|
||||
url: String? = "/api/v1/media/$copyName",
|
||||
) = VoiceDto(
|
||||
source = source,
|
||||
status = status,
|
||||
name = name,
|
||||
mime = mime,
|
||||
size = size,
|
||||
durationMs = durationMs,
|
||||
url = url,
|
||||
)
|
||||
|
||||
// —— 1. 判据 = 键存在 ——
|
||||
|
||||
@Test
|
||||
fun `the voice key is the only criterion`() {
|
||||
assertTrue(isVoiceMessage(message(voice = voice())))
|
||||
assertFalse(isVoiceMessage(message(content = "在的")))
|
||||
// 正文里出现 `[语音]` 字样**不是**判据:QQ 那个占位标记自 v0.67 起不再下发,
|
||||
// 而用户完全可以自己打出这四个字(这是契约点名的两个方向)。
|
||||
assertFalse(isVoiceMessage(message(content = "[语音]")))
|
||||
assertFalse(isVoiceMessage(message(content = "语音")))
|
||||
assertTrue(isVoiceMessage(message(content = "[语音]", voice = voice())))
|
||||
}
|
||||
|
||||
// —— 2. 四者同生共死 ⇒ 能不能播 ——
|
||||
|
||||
@Test
|
||||
fun `an audio copy is playable only when all four keys are there`() {
|
||||
val audio = voiceAudio(voice())!!
|
||||
assertEquals(copyName, audio.name)
|
||||
assertEquals("audio/ogg", audio.mime)
|
||||
assertEquals(12_345L, audio.size)
|
||||
assertEquals("/api/v1/media/$copyName", audio.url)
|
||||
assertEquals(3_200, audio.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing key on any of the four makes the copy unplayable`() {
|
||||
// 四者同生共死:缺任何一个 ⇒ 一个播放控件都不画(不能给一个点下去必然失败的按钮)。
|
||||
assertNull(voiceAudio(voice(name = null)))
|
||||
assertNull(voiceAudio(voice(mime = null)))
|
||||
assertNull(voiceAudio(voice(size = null)))
|
||||
assertNull(voiceAudio(voice(url = null)))
|
||||
// 空白与 null 同等对待(空白串拼出来是一条没有名字的地址)。
|
||||
assertNull(voiceAudio(voice(name = " ")))
|
||||
assertNull(voiceAudio(voice(mime = "")))
|
||||
assertNull(voiceAudio(voice(url = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent voice key has no copy and a zero byte copy is still a copy`() {
|
||||
assertNull(voiceAudio(null))
|
||||
// size 只要求在(**不要求 > 0**):契约说它恒在,本端也不拿它推导别的任何东西。
|
||||
assertEquals(0L, voiceAudio(voice(size = 0))!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice without a copy keeps its duration`() {
|
||||
// 微信那条路:`source = platform`、四个副本键全缺 ⇒ 不可播,但时长照留(渠道给了)。
|
||||
val platform = voice(
|
||||
source = VoiceSource.PLATFORM,
|
||||
name = null,
|
||||
mime = null,
|
||||
size = null,
|
||||
url = null,
|
||||
durationMs = 5_000,
|
||||
)
|
||||
assertNull(voiceAudio(platform))
|
||||
assertEquals("0:05", voiceDurationLabel(platform.durationMs))
|
||||
}
|
||||
|
||||
// —— 3. 时长 ——
|
||||
|
||||
@Test
|
||||
fun `a missing duration shows nothing at all`() {
|
||||
// 「缺席就不显示时长」:不拿 size / 码率反算,也不显示 `0:00` 冒充。
|
||||
assertNull(voiceDurationLabel(null))
|
||||
assertNull(voiceDurationLabel(0))
|
||||
assertNull(voiceDurationLabel(-1))
|
||||
// 不足一秒时**向下取整会发生什么**:100ms 会变成 `0:00` —— 那正是「冒充」,
|
||||
// 所以这一条也当缺席(契约里不存在 0 秒的语音)。
|
||||
assertNull(voiceDurationLabel(100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration is formatted as minutes and seconds`() {
|
||||
assertEquals("0:01", voiceDurationLabel(1_000))
|
||||
assertEquals("0:03", voiceDurationLabel(3_200))
|
||||
assertEquals("0:59", voiceDurationLabel(59_999))
|
||||
assertEquals("1:00", voiceDurationLabel(60_000))
|
||||
assertEquals("1:05", voiceDurationLabel(65_000))
|
||||
assertEquals("10:00", voiceDurationLabel(600_000))
|
||||
// 不折算成小时制:语音消息不会长到那个量级,多一种表达就多一种自己发明的说法。
|
||||
assertEquals("124:03", voiceDurationLabel(7_443_000))
|
||||
}
|
||||
|
||||
// —— 4. 三态 status 在界面上多说的那一句 ——
|
||||
|
||||
@Test
|
||||
fun `only an empty transcription with blank content gets the extra line`() {
|
||||
assertEquals(VOICE_EMPTY_NOTE, voiceNote(VoiceStatus.EMPTY, ""))
|
||||
assertEquals(VOICE_EMPTY_NOTE, voiceNote(VoiceStatus.EMPTY, " "))
|
||||
// transcribed:正文就是转写文本,多一句都是噪声。
|
||||
assertNull(voiceNote(VoiceStatus.TRANSCRIBED, "明天几点出发"))
|
||||
// failed:正文**就是**服务端那行诚实提示,本端一个字都不改、也不添油加醋。
|
||||
assertNull(voiceNote(VoiceStatus.FAILED, "这段语音没能转成文字(未配置语音识别)"))
|
||||
// 认不出的 status:不猜(与 failed 同形:只显示正文)。
|
||||
assertNull(voiceNote("", ""))
|
||||
assertNull(voiceNote("future-status", ""))
|
||||
// empty 而正文非空(契约说不会出现):不补一句自相矛盾的话。
|
||||
assertNull(voiceNote(VoiceStatus.EMPTY, "其实有字"))
|
||||
}
|
||||
|
||||
// —— 5. 相对路径接到当前服务器地址 ——
|
||||
|
||||
@Test
|
||||
fun `a server relative path is rebased onto the current server`() {
|
||||
assertEquals(
|
||||
"http://192.168.1.9:8080/api/v1/media/$copyName",
|
||||
voiceMediaUrl("/api/v1/media/$copyName", "http://192.168.1.9:8080"),
|
||||
)
|
||||
// 尾斜杠、带路径的 baseUrl、大小写 scheme:都按「scheme://authority」处理(与 ImageHostRebase 同)。
|
||||
assertEquals(
|
||||
"http://host:8080/api/v1/media/$copyName",
|
||||
voiceMediaUrl("/api/v1/media/$copyName", "http://host:8080/"),
|
||||
)
|
||||
assertEquals(
|
||||
"HTTPS://host/api/v1/media/$copyName",
|
||||
voiceMediaUrl("/api/v1/media/$copyName", "HTTPS://host/cyrene"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unusable base url yields nothing instead of a guessed path`() {
|
||||
// 拼不出可信地址就回 null(调用方据此如实报「暂时播不了」),**绝不**退回自己拼一条路径。
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", ""))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "192.168.1.9:8080"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "http://"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/$copyName", "ftp://host"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a path that is not exactly one media name is refused`() {
|
||||
// 只看服务端给的那条路径:不是「恰好一段 /api/v1/media/<name>」就不接(看不懂的不改、不发)。
|
||||
assertNull(voiceMediaUrl("/api/v1/files/$copyName", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/.", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/..", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/a/../b.ogg", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/%2e%2e%2fsecret", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/a.ogg?token=x", "http://host"))
|
||||
assertNull(voiceMediaUrl("/api/v1/media/a.ogg#f", "http://host"))
|
||||
assertNull(voiceMediaUrl("http://evil/api/v1/media/a.ogg", "http://host"))
|
||||
}
|
||||
|
||||
// —— 失败文案 ——
|
||||
|
||||
@Test
|
||||
fun `a failure always says something, and says the server's own words when it has them`() {
|
||||
// 服务端有话说(404 media_not_found 的两种情形同码)⇒ 只说本端能确定的那半句。
|
||||
val notFound = CyreneException(
|
||||
CyreneError.Server(code = "media_not_found", message = "media not found", httpStatus = 404),
|
||||
)
|
||||
assertEquals("服务端上已经没有这条语音了", voicePlaybackFailureMessage(notFound))
|
||||
// 其它服务端错 / 本地网络错:原样带上服务端那句(或本端的分类说明),**绝不静默**。
|
||||
assertEquals(
|
||||
"语音加载失败,服务器开小差了",
|
||||
voicePlaybackFailureMessage(
|
||||
CyreneException(CyreneError.Server(code = "x", message = "服务器开小差了", httpStatus = 500)),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"语音加载失败,网络不可用,请检查连接",
|
||||
voicePlaybackFailureMessage(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
|
||||
),
|
||||
)
|
||||
assertEquals("当前服务器地址不可用,暂时播不了这条语音", voicePlaybackFailureMessage(VoiceUrlUnavailableException()))
|
||||
assertEquals("这条语音太大,本机放不了", voicePlaybackFailureMessage(VoiceTooLargeException()))
|
||||
assertEquals(
|
||||
"本地缓存不可用,暂时播不了这条语音",
|
||||
voicePlaybackFailureMessage(DocumentCacheUnavailableException()),
|
||||
)
|
||||
assertEquals("语音加载失败,请稍后重试", voicePlaybackFailureMessage(IllegalStateException("boom")))
|
||||
}
|
||||
|
||||
// —— 与「这条消息什么都没有」那条既有判据的交界 ——
|
||||
|
||||
@Test
|
||||
fun `a voice message is never reported as contentless`() {
|
||||
// 语音的正文可能为空(status = empty,或服务端那行提示没到),但那条消息**不是什么都没有**
|
||||
// ——它有语音形态。少了这一条,屏幕上会出现一句**假的**「内容没有被传过来」。
|
||||
val empty = message(content = "", voice = voice(status = VoiceStatus.EMPTY, name = null, mime = null, size = null, url = null))
|
||||
assertFalse(isContentlessUserMessage(empty))
|
||||
// 对照:同样空的正文,没有 voice 键 ⇒ 照旧成立(既有的空气泡修复一个字都没变)。
|
||||
assertTrue(isContentlessUserMessage(message(content = "")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 语音播放通道的状态机(纯函数)。
|
||||
*
|
||||
* 它要钉住的是**跨气泡**的那几条事实——少了任何一条,真机上就会出现「两条一起响」、
|
||||
* 「界面显示 A 在响而喇叭里是 B」或者「A 的失败提示挂在 B 上」:
|
||||
*
|
||||
* 1. 再点一下同一目标 = 停(**加载中也算**:用户不该被慢下载困住);
|
||||
* 2. 点另一条 = 从它重新开始(旧的那条由调用方停下并释放,纯函数只说状态变成什么);
|
||||
* 3. **迟到的回调不许改状态**:`prepared` / `failed` / `finished` 三个转换都只认「目标就是当前
|
||||
* 这一条」,而且 `prepared` 只从 `Loading` 出发(正在响的那一条不会被一个迟到的失败说成失败);
|
||||
* 4. `Failed` 不粘:再点一次就是重试(回 `Loading`);
|
||||
* 5. `silenced`(停下 / 换会话 / 组件销毁)**收所有态**,包括 `Failed` 那句提示。
|
||||
*/
|
||||
class VoicePlaybackTest {
|
||||
|
||||
private val a = "msg-a"
|
||||
private val b = "msg-b"
|
||||
|
||||
// —— 1 / 2. 按下与换目标 ——
|
||||
|
||||
@Test
|
||||
fun `pressing from idle starts loading that message`() {
|
||||
assertEquals(VoicePlaybackState.Loading(a), VoicePlaybackState.Idle.pressed(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing the message that is playing stops it`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).pressed(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing the message that is loading cancels it`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Loading(a).pressed(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing another message switches to it`() {
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Playing(a).pressed(b))
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(a).pressed(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pressing a failed message retries it`() {
|
||||
val failed = VoicePlaybackState.Failed(a, "网络不可用")
|
||||
|
||||
assertFalse("失败不算「占着这条」,否则再点一下只会被当成「停」", failed.isActiveFor(a))
|
||||
assertEquals(VoicePlaybackState.Loading(a), failed.pressed(a))
|
||||
}
|
||||
|
||||
// —— 3. 迟到的回调 ——
|
||||
|
||||
@Test
|
||||
fun `prepared only moves the message that was loading`() {
|
||||
assertEquals(VoicePlaybackState.Playing(a), VoicePlaybackState.Loading(a).prepared(a))
|
||||
// 用户已经点了别的 / 点了停:迟到的「准备好」不许把界面拽回去。
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(b).prepared(a))
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Idle.prepared(a))
|
||||
// 正在响的那一条收到 prepared 也原样(它已经在响了)。
|
||||
assertEquals(VoicePlaybackState.Playing(a), VoicePlaybackState.Playing(a).prepared(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed only moves the message that was loading`() {
|
||||
assertEquals(VoicePlaybackState.Failed(a, "坏了"), VoicePlaybackState.Loading(a).failed(a, "坏了"))
|
||||
// 正在响的那一条不该被一个迟到的失败回调说成失败(解码错误的真实顺序是「先停再报」)。
|
||||
assertEquals(
|
||||
VoicePlaybackState.Playing(a),
|
||||
VoicePlaybackState.Playing(a).failed(a, "坏了"),
|
||||
)
|
||||
assertEquals(VoicePlaybackState.Loading(b), VoicePlaybackState.Loading(b).failed(a, "坏了"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finished only clears the message that was playing`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).finished(a))
|
||||
assertEquals(VoicePlaybackState.Playing(b), VoicePlaybackState.Playing(b).finished(a))
|
||||
assertEquals(VoicePlaybackState.Loading(a), VoicePlaybackState.Loading(a).finished(a))
|
||||
}
|
||||
|
||||
// —— 4 / 5. 停下来收所有态 ——
|
||||
|
||||
@Test
|
||||
fun `silencing collects every state including a failure note`() {
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Idle.silenced)
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Loading(a).silenced)
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Playing(a).silenced)
|
||||
assertEquals(VoicePlaybackState.Idle, VoicePlaybackState.Failed(a, "坏了").silenced)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a silenced channel is active for nobody`() {
|
||||
assertFalse(VoicePlaybackState.Idle.silenced.isActiveFor(a))
|
||||
assertTrue(VoicePlaybackState.Loading(a).isActiveFor(a))
|
||||
assertTrue(VoicePlaybackState.Playing(a).isActiveFor(a))
|
||||
}
|
||||
|
||||
// —— 视图查询(气泡据此决定画什么)——
|
||||
|
||||
@Test
|
||||
fun `only playing counts as playing`() {
|
||||
assertTrue(VoicePlaybackState.Playing(a).isPlayingFor(a))
|
||||
assertFalse("加载中不算在响", VoicePlaybackState.Loading(a).isPlayingFor(a))
|
||||
assertFalse(VoicePlaybackState.Playing(b).isPlayingFor(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading and playing are two different questions`() {
|
||||
// 界面那一句「加载中…」只能在**真的在取字节**时出现(`isActiveFor` 会把「正在响」也算进来,
|
||||
// 拿它当条件会让一条 30 秒的语音整段播放期间都写着「加载中…」)。
|
||||
assertTrue(VoicePlaybackState.Loading(a).isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Playing(a).isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Loading(b).isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Idle.isLoadingFor(a))
|
||||
assertFalse(VoicePlaybackState.Failed(a, "坏了").isLoadingFor(a))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure note is only reported for its own message`() {
|
||||
val failed = VoicePlaybackState.Failed(a, "这条语音太大,本机放不了")
|
||||
|
||||
assertEquals("这条语音太大,本机放不了", failed.failureOf(a))
|
||||
assertNull(failed.failureOf(b))
|
||||
assertNull(VoicePlaybackState.Idle.failureOf(a))
|
||||
assertNull(VoicePlaybackState.Playing(a).failureOf(a))
|
||||
}
|
||||
|
||||
// —— 一条完整的用户路径 ——
|
||||
|
||||
@Test
|
||||
fun `a full press load failure retry stop path`() {
|
||||
var state: VoicePlaybackState = VoicePlaybackState.Idle
|
||||
|
||||
state = state.pressed(a)
|
||||
assertEquals(VoicePlaybackState.Loading(a), state)
|
||||
state = state.failed(a, "语音加载失败,请稍后重试")
|
||||
assertEquals("失败提示挂在这一条上", "语音加载失败,请稍后重试", state.failureOf(a))
|
||||
state = state.pressed(a)
|
||||
assertEquals("重试清掉上一句", null, state.failureOf(a))
|
||||
state = state.prepared(a)
|
||||
assertEquals(VoicePlaybackState.Playing(a), state)
|
||||
state = state.finished(a)
|
||||
assertEquals("放完回初始外观,不留痕迹", VoicePlaybackState.Idle, state)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
|
||||
/**
|
||||
* 「这条消息什么都没传过来」的判据(纯函数,`user` 消息的空气泡修复)。
|
||||
@@ -15,8 +16,9 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
*
|
||||
* 这个测试要钉住两件事:
|
||||
* 1. 真的什么都没有时成立(空 / 纯空白);
|
||||
* 2. **任何**有内容的形态都不成立 —— 有正文(哪怕只有一个空格之外的字)、有图、有附件、有思维链,
|
||||
* 以及**所有 assistant 消息**。误伤一条就等于给一条好好的消息扣上「内容没传过来」的帽子。
|
||||
* 2. **任何**有内容的形态都不成立 —— 有正文(哪怕只有一个空格之外的字)、有图、有附件、有思维链、
|
||||
* 有 `voice` 键(语音形态),以及**所有 assistant 消息**。误伤一条就等于给一条好好的消息扣上
|
||||
* 「内容没传过来」的帽子。
|
||||
*/
|
||||
class ContentlessMessageTest {
|
||||
|
||||
@@ -26,6 +28,7 @@ class ContentlessMessageTest {
|
||||
image: String? = null,
|
||||
documents: List<DocumentRefDto> = emptyList(),
|
||||
thinking: String? = null,
|
||||
voice: VoiceDto? = null,
|
||||
) = HistoryMessageDto(
|
||||
id = "m1",
|
||||
sessionId = "web",
|
||||
@@ -34,6 +37,7 @@ class ContentlessMessageTest {
|
||||
image = image,
|
||||
documents = documents,
|
||||
thinking = thinking,
|
||||
voice = voice,
|
||||
createdAt = 1_791_088_571_785L,
|
||||
)
|
||||
|
||||
@@ -65,6 +69,17 @@ class ContentlessMessageTest {
|
||||
assertTrue(isContentlessUserMessage(message(image = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice message is never contentless`() {
|
||||
// 语音的转写文本可能为空(`status = empty`,或微信那条根本没有副本),但那条消息
|
||||
// **不是什么都没有**:它有语音形态。判据只看 `voice` 键在不在(契约 §4.4)。
|
||||
assertFalse(
|
||||
isContentlessUserMessage(
|
||||
message(content = "", voice = VoiceDto(source = "platform", status = "empty")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 不成立:凡有一点点内容就一个字都不说 ——
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user