feat: 初始化 Android 客户端(M0 骨架 + M1 登录 + M2 基础模块)
M0:Gradle 9.7.1 / AGP 9.2.1 / Kotlin 2.4 / Miuix 0.9.3,minSdk 33,compileSdk 37。 M1:登录页(服务器地址可配、服务端/本地错误分流、EncryptedSharedPreferences、自动登录与静默重登、401 登出)。 M2 基础模块:消息内容解析器(<p>/<action>/<narration> + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。 45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。 devdocs/ 收录设计文档、前后端协同事项与细化方案。
This commit is contained in:
commit
84de51c7b7
67 files changed
+3960
No files matched your search
@@ -0,0 +1,88 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MessageMergeTest {
|
||||
|
||||
private fun msg(id: String, createdAt: Long, content: String = id) = HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = "user",
|
||||
content = content,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun idsOf(messages: List<HistoryMessageDto>) = messages.map { it.id }
|
||||
|
||||
@Test
|
||||
fun `overlapping page dedupes by id`() {
|
||||
val existing = listOf(msg("m2", 200), msg("m1", 100))
|
||||
val incoming = listOf(msg("m3", 300), msg("m2", 200))
|
||||
|
||||
val merged = mergeMessages(existing, incoming)
|
||||
|
||||
assertEquals(listOf("m1", "m2", "m3"), idsOf(merged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out of order incoming page is sorted ascending`() {
|
||||
val existing = listOf(msg("m1", 100), msg("m2", 200))
|
||||
val incoming = listOf(msg("m4", 400), msg("m3", 300))
|
||||
|
||||
val merged = mergeMessages(existing, incoming)
|
||||
|
||||
assertEquals(listOf("m1", "m2", "m3", "m4"), idsOf(merged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same millisecond falls back to id ordering`() {
|
||||
val existing = listOf(msg("b", 100), msg("a", 100))
|
||||
val incoming = listOf(msg("c", 100))
|
||||
|
||||
val merged = mergeMessages(existing, incoming)
|
||||
|
||||
assertEquals(listOf("a", "b", "c"), idsOf(merged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty incoming page keeps existing`() {
|
||||
val existing = listOf(msg("m1", 100), msg("m2", 200))
|
||||
|
||||
val merged = mergeMessages(existing, emptyList())
|
||||
|
||||
assertEquals(listOf("m1", "m2"), idsOf(merged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty existing returns sorted incoming`() {
|
||||
val incoming = listOf(msg("m3", 300), msg("m1", 100))
|
||||
|
||||
val merged = mergeMessages(emptyList(), incoming)
|
||||
|
||||
assertEquals(listOf("m1", "m3"), idsOf(merged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incoming wins on duplicate id`() {
|
||||
val existing = listOf(msg("m1", 100, content = "old"))
|
||||
val incoming = listOf(msg("m1", 100, content = "new"))
|
||||
|
||||
val merged = mergeMessages(existing, incoming)
|
||||
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals("new", merged.single().content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merge does not mutate inputs`() {
|
||||
val existing = listOf(msg("m2", 200), msg("m1", 100))
|
||||
val incoming = listOf(msg("m3", 300))
|
||||
|
||||
mergeMessages(existing, incoming)
|
||||
|
||||
assertEquals(listOf("m2", "m1"), idsOf(existing))
|
||||
assertEquals(listOf("m3"), idsOf(incoming))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package top.yeij.cyrene.domain.parse
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* ChatContentParser 单测:覆盖气泡切分、旁白依附、漂移兼容、畸形回落与流式边界。
|
||||
* 规则来源见 `devdocs/03-客户端细化开发方案.md` §5 与 Web `parseContent.ts`。
|
||||
*/
|
||||
class ChatContentParserTest {
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 气泡切分
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun multipleParagraphTagsBecomeSeparateBubbles() {
|
||||
val bubbles = parse("<p>第一</p><p>第二</p>")
|
||||
assertEquals(listOf("第一", "第二"), bubbles.map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankLineFallbackSplitsBubbles() {
|
||||
val bubbles = parse("第一段\n\n第二段")
|
||||
assertEquals(listOf("第一段", "第二段"), bubbles.map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankLineWithWhitespaceAlsoSplits() {
|
||||
val bubbles = parse("第一段\n \n第二段")
|
||||
assertEquals(listOf("第一段", "第二段"), bubbles.map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleNewlineDoesNotSplit() {
|
||||
assertEquals(listOf("第一行\n第二行"), parse("第一行\n第二行").map { it.body })
|
||||
assertEquals(listOf("第一行\n第二行"), parse("<p>第一行\n第二行</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyAndWhitespaceInputYieldsNoBubbles() {
|
||||
assertTrue(parse("").isEmpty())
|
||||
assertTrue(parse(" ").isEmpty())
|
||||
assertTrue(parse("<p> </p>").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unclosedParagraphStaysLiteralText() {
|
||||
assertEquals(listOf("<p>第一"), parse("<p>第一").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun textOutsideParagraphsIsPreserved() {
|
||||
val bubbles = parse("<action>笑</action><p>你好</p>")
|
||||
assertEquals(1, bubbles.size)
|
||||
assertEquals("你好", bubbles[0].body)
|
||||
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trailingActionOutsideParagraphsAttachesToTail() {
|
||||
val bubbles = parse("<p>你好</p><action>笑</action>")
|
||||
assertEquals(1, bubbles.size)
|
||||
assertEquals("你好", bubbles[0].body)
|
||||
assertEquals(listOf(ContentPart.Action("笑")), bubbles[0].tail)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 旁白依附:首部 → head,尾部 → tail,中间 → 上提 head
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun leadingNarrationAttachesToHead() {
|
||||
val bubble = parse("<p><narration>其实很开心</narration>你好</p>").single()
|
||||
assertEquals("你好", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Narration("其实很开心")), bubble.head)
|
||||
assertTrue(bubble.tail.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trailingNarrationAttachesToTail() {
|
||||
val bubble = parse("<p>你好<narration>其实很开心</narration></p>").single()
|
||||
assertEquals("你好", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Narration("其实很开心")), bubble.tail)
|
||||
assertTrue(bubble.head.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun middleActionLiftsToHead() {
|
||||
val bubble = parse("<p>开头<action>笑</action>结尾</p>").single()
|
||||
assertEquals("开头结尾", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("笑")), bubble.head)
|
||||
assertTrue(bubble.tail.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partsKeepOriginalOrder() {
|
||||
val bubble = parse("<p>甲<action>乙</action>丙</p>").single()
|
||||
assertEquals(
|
||||
listOf(ContentPart.Text("甲"), ContentPart.Action("乙"), ContentPart.Text("丙")),
|
||||
bubble.parts,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eachParagraphKeepsItsOwnNote() {
|
||||
val bubbles = parse("<p><action>a</action>一</p><p><action>b</action>二</p>")
|
||||
assertEquals(listOf("一", "二"), bubbles.map { it.body })
|
||||
assertEquals(listOf(ContentPart.Action("a")), bubbles[0].head)
|
||||
assertEquals(listOf(ContentPart.Action("b")), bubbles[1].head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notesOnlyParagraphHasEmptyBody() {
|
||||
val bubble = parse("<p><action>弯起眼睛笑了</action></p>").single()
|
||||
assertEquals("", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("弯起眼睛笑了")), bubble.head)
|
||||
assertFalse(bubble.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notesOnlyMessageWithoutParagraphTag() {
|
||||
val bubble = parse("<action>轻轻拉住你的手</action>").single()
|
||||
assertEquals("", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("轻轻拉住你的手")), bubble.head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankTextBeforeNoteStillRendersTheNote() {
|
||||
val bubble = parse("<p> <action>笑</action></p>").single()
|
||||
assertEquals("", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("笑")), bubble.head + bubble.tail)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 漂移兼容:全角 / 半角星号
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun shortFullWidthStarWithoutKeywordBecomesAction() {
|
||||
val bubble = parse("<p>你好*弯起眼睛*</p>").single()
|
||||
assertEquals("你好", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("弯起眼睛")), bubble.tail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullWidthStarWithNarrationKeywordBecomesNarration() {
|
||||
val bubble = parse("<p>*旁白:心想*</p>").single()
|
||||
assertEquals("", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Narration("心想")), bubble.head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullWidthStarWithActionKeywordBecomesAction() {
|
||||
val bubble = parse("<p>*动作:笑*</p>").single()
|
||||
assertEquals(listOf(ContentPart.Action("笑")), bubble.head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullWidthStarWithInnerKeywordBecomesNarration() {
|
||||
val bubble = parse("<p>*内心:有点开心*</p>").single()
|
||||
assertEquals(listOf(ContentPart.Narration("有点开心")), bubble.head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longFullWidthStarStaysText() {
|
||||
val long = "a".repeat(41)
|
||||
val bubble = parse("<p>你好*$long*</p>").single()
|
||||
assertTrue(bubble.head.isEmpty())
|
||||
assertTrue(bubble.tail.isEmpty())
|
||||
assertEquals("你好*$long*", bubble.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiLineFullWidthStarStaysText() {
|
||||
val bubble = parse("<p>你好*弯起\n眼睛*</p>").single()
|
||||
assertTrue(bubble.head.isEmpty())
|
||||
assertTrue(bubble.tail.isEmpty())
|
||||
assertTrue(bubble.body.contains("*弯起\n眼睛*"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun halfWidthStarWithKeywordBecomesAction() {
|
||||
val bubble = parse("<p>你好*动作:笑*</p>").single()
|
||||
assertEquals("你好", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("笑")), bubble.tail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun halfWidthStarWithNarrationKeywordBecomesNarration() {
|
||||
val bubble = parse("<p>*内心:有点开心*你好</p>").single()
|
||||
assertEquals("你好", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Narration("有点开心")), bubble.head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun halfWidthStarWithoutKeywordStaysMarkdownText() {
|
||||
val bubble = parse("<p>这是*强调*文字</p>").single()
|
||||
assertEquals("这是*强调*文字", bubble.body)
|
||||
assertTrue(bubble.head.isEmpty())
|
||||
assertTrue(bubble.tail.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun halfWidthDoubleStarStaysMarkdownText() {
|
||||
val bubble = parse("<p>这是**加粗**文字</p>").single()
|
||||
assertEquals("这是**加粗**文字", bubble.body)
|
||||
assertTrue(bubble.head.isEmpty())
|
||||
assertTrue(bubble.tail.isEmpty())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 畸形 / 未闭合:一律回落文本,绝不抛异常
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun unclosedActionFallsBackToText() {
|
||||
val bubble = parse("<p>你好<action>笑</p>").single()
|
||||
assertEquals("你好<action>笑", bubble.body)
|
||||
assertTrue(bubble.parts.none { it is ContentPart.Action })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unclosedNarrationFallsBackToText() {
|
||||
val bubble = parse("<p>你好<narration>心里暖暖的</p>").single()
|
||||
assertEquals("你好<narration>心里暖暖的", bubble.body)
|
||||
assertTrue(bubble.parts.none { it is ContentPart.Narration })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strayClosingTagStaysText() {
|
||||
val bubble = parse("<p>你好</action></p>").single()
|
||||
assertEquals("你好</action>", bubble.body)
|
||||
assertTrue(bubble.head.isEmpty())
|
||||
assertTrue(bubble.tail.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mismatchedTagsDoNotThrow() {
|
||||
val bubbles = parse("<p><action>笑<narration>内心</action></p>")
|
||||
assertEquals(1, bubbles.size)
|
||||
assertTrue(bubbles[0].parts.any { it is ContentPart.Action })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 流式增量:最终结果必须与一次性解析一致
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private val sampleText = """
|
||||
<p><action>轻轻拉住你的手</action>亲爱的,今天过得怎么样?♪</p>
|
||||
<p>人家给你留了灯呢。</p>
|
||||
<p>第一行
|
||||
第二行</p>
|
||||
<p>你好*弯起眼睛*</p>
|
||||
<p>这是*强调*文字</p>
|
||||
<p>*旁白:其实偷偷开心了一下*</p>
|
||||
<p>晚安。</p>
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun streamingCharByCharMatchesOneShot() {
|
||||
val expected = parse(sampleText)
|
||||
val parser = StreamingContentParser()
|
||||
for (ch in sampleText) {
|
||||
parser.onDelta(ch.toString())
|
||||
}
|
||||
assertEquals(expected, parser.finish())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingRandomChunksMatchOneShot() {
|
||||
val expected = parse(sampleText)
|
||||
val random = Random(20260908)
|
||||
repeat(25) {
|
||||
val parser = StreamingContentParser()
|
||||
var index = 0
|
||||
while (index < sampleText.length) {
|
||||
val size = 1 + random.nextInt(7)
|
||||
val end = minOf(index + size, sampleText.length)
|
||||
parser.onDelta(sampleText.substring(index, end))
|
||||
index = end
|
||||
}
|
||||
assertEquals(expected, parser.finish())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingStableInputEqualsOneShotBeforeFinish() {
|
||||
val text = "<p>你好</p><p><action>笑</action>再见</p>"
|
||||
val parser = StreamingContentParser()
|
||||
assertEquals(parse(text), parser.onDelta(text))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingWithholdsPartialTag() {
|
||||
val parser = StreamingContentParser()
|
||||
val partial = parser.onDelta("<p>你好</p><act")
|
||||
assertTrue(partial.none { it.body.contains("<act") })
|
||||
assertTrue(partial.none { bubble -> bubble.parts.any { it.text.contains("<act") } })
|
||||
|
||||
parser.onDelta("ion>笑</action>")
|
||||
assertEquals(parse("<p>你好</p><action>笑</action>"), parser.finish())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingWithholdsUnclosedParagraph() {
|
||||
val parser = StreamingContentParser()
|
||||
val first = parser.onDelta("<p>第一</p><p>第二")
|
||||
assertEquals(listOf("第一"), first.map { it.body })
|
||||
|
||||
val second = parser.onDelta("</p>")
|
||||
assertEquals(listOf("第一", "第二"), second.map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingUnclosedActionFallsBackOnFinish() {
|
||||
val parser = StreamingContentParser()
|
||||
parser.onDelta("<p>你好<action>笑")
|
||||
val done = parser.finish()
|
||||
assertEquals(1, done.size)
|
||||
// 未闭合的 <p> 与 <action> 一并回落为字面文本(与一次性解析一致)。
|
||||
assertEquals("<p>你好<action>笑", done[0].body)
|
||||
assertTrue(done[0].parts.none { it is ContentPart.Action })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingMalformedInputNeverThrows() {
|
||||
val parser = StreamingContentParser()
|
||||
val chunks = listOf(
|
||||
"<", "p", ">", "你", "<act", "ion", ">", "笑",
|
||||
"</", "act", "ion", ">", "</p", ">", "*", "动",
|
||||
)
|
||||
for (chunk in chunks) {
|
||||
parser.onDelta(chunk)
|
||||
}
|
||||
assertTrue(parser.finish().isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetClearsBuffer() {
|
||||
val parser = StreamingContentParser()
|
||||
parser.onDelta("<p>旧</p>")
|
||||
parser.reset()
|
||||
assertTrue(parser.onDelta("").isEmpty())
|
||||
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user