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:
AskaEth committed 2026-09-08 20:40:58 +08:00
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 })
}
}