feat(admin): 「最近日志」接上 logs REST 引导读取 —— 补齐 W10 只显示进页面之后那段日志的缺口
W10 接上的 `observability_log` 帧以「订阅那一刻的最新条目」为锚、不回灌历史,而客户端没有 `GET /api/v1/admin/observability/logs` 的接入 ⇒ 概览页的「最近日志」只能显示进页面**之后**产生的 条目。本次把 REST 引导读取补上,并把 W10 那条取舍(「一帧都没到过 → 整段不渲染」,为的是不把 「没有推送」说成「没有数据」)改成契约要求的「**REST 成功即渲染、帧是增量**」。 - **取数**:`AdminApi` + `AdminRepository` 接上该端点,显式发 `limit=200`(与客户端保留窗口、 帧的单帧上限同源);**不暴露** `?level=`——它是服务端「最低等级」过滤,只在服务端裁剪一次, 会饿死界面上那排要来回切换的等级档,所以一律取全量、由客户端本地过滤(契约 §3.12 亦然)。 - **DTO 统一**:REST 与帧同形(后端同一个 `gateway.AdminLogEntry`),因此把 `data/ws/ObservabilityLogEntry.kt` 挪进 `data/api/dto/AdminDto.kt` 并改名 `AdminLogEntryDto` (顺带与 `AdminMetricsDto` / `AdminTraceSpanDto` 对齐),删掉旧文件——不留下两份等价形状。 - **合并纯函数**:日志改成「去重 → 按 `time` 倒序 → 截窗口」。三步顺序不能换:窗口是**位置** 截断,先截再排会让一条更旧的条目挤掉更新的那条;排序用解析出的 epoch 秒 + **稳定排序**保住 同秒条目的到达顺序(服务端 `time` 只有秒精度);时间判不出来的条目落到尾部但**不丢**。 trace 那条路径不动(span 有服务端写入序号,重排反而把顺序排乱)。 - **界面**:`logs` 改为可空——null = 还没有基线(不渲染);非 null(含空列表)= REST 读过且成功 ⇒ 渲染,于是「加载中 / 不可用 / 暂无日志 / 列表」四种表现分明,未知等级条目照旧一律保留。 三路的失败原因也分开记了:日志挂了不再让 trace 那块显示「不可用」(那是把一次成功说成失败)。 - **角色分档**:新增纯函数 `canReadObservabilityLogHistory`(日志历史与概览页同档, v0.40 的 viewerAuth 只读监控档 ⇒ **viewer 也读得到**),与只发 owner/admin 的帧门刻意不对称。 ⇒ viewer 看得见历史、收不到增量,界面不得因此报错或报空(区块是否渲染只看历史这条门)。 测试 567 → 591(DTO 边界、合并去重/排序/窗口、角色分档、仓库线级请求与 501/403/坏载荷)。 `assembleDebug` 与 `testDebugUnitTest` 均通过(591 测试 / 0 失败 / 0 错误)。未做真机与真后端验收。
This commit is contained in:
1 parent
e59ef08cf9
commit
642bdb13f0
15 files changed
+681
-119
No files matched your search
@@ -6,6 +6,7 @@ import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -28,6 +29,64 @@ class AdminDtoDecodingTest {
|
||||
private val personaList = ListSerializer(PersonaSummaryDto.serializer())
|
||||
private val memoryList = ListSerializer(MemorySummaryDto.serializer())
|
||||
private val backthinkList = ListSerializer(AdminBackthinkItemDto.serializer())
|
||||
private val logTail = ListSerializer(AdminLogEntryDto.serializer())
|
||||
|
||||
@Test
|
||||
fun `the logs tail decodes newest first and keeps the four fields verbatim`() {
|
||||
val body = """
|
||||
{"data":[{"time":"2026-10-04T04:36:15Z","level":"warn","source":"gateway","msg":"慢请求"},
|
||||
{"time":"2026-10-04T04:36:14Z","level":"info","source":"","msg":"启动完成"}]}
|
||||
""".trimIndent()
|
||||
|
||||
val entries = json.decodeFromString(ApiEnvelope.serializer(logTail), body).data!!
|
||||
|
||||
// 服务端已按 time 倒序(最新在前);这一层**不重排**,顺序原样交给合并层。
|
||||
assertEquals(listOf("慢请求", "启动完成"), entries.map { it.msg })
|
||||
assertEquals("2026-10-04T04:36:15Z", entries.first().time)
|
||||
assertEquals("warn", entries.first().level)
|
||||
// 记录没带来源时是空串(不是 null)——原样保留,不做归一。
|
||||
assertEquals("", entries[1].source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty logs tail is an empty success rather than a missing payload`() {
|
||||
// `{"data":[]}` 是**成功且确实没有日志**:解出来必须是空列表而不是 null,
|
||||
// 否则 apiCall 会把它判成 Parse 失败,UI 就会把「没有日志」说成「日志不可用」。
|
||||
val data = json.decodeFromString(ApiEnvelope.serializer(logTail), """{"data":[]}""").data
|
||||
|
||||
assertNotNull(data)
|
||||
assertTrue(data!!.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a log entry tolerates missing fields and keeps an unknown level raw`() {
|
||||
val entries = json.decodeFromString(
|
||||
ApiEnvelope.serializer(logTail),
|
||||
"""{"data":[{"msg":"只有消息"},{"time":"2026-10-04T04:36:15Z","level":"trace","source":"x","msg":"未来等级"}]}""",
|
||||
).data!!
|
||||
|
||||
// 后端恒给四个键;缺了只回落空串,绝不让整条(乃至整页)解不出来。
|
||||
assertEquals("", entries[0].time)
|
||||
assertEquals("", entries[0].level)
|
||||
assertEquals("", entries[0].source)
|
||||
assertEquals("只有消息", entries[0].msg)
|
||||
// level 是开放集合:未知取值原样保留,不丢弃也不归一。
|
||||
assertEquals("trace", entries[1].level)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the log time stays a verbatim string instead of being parsed or converted`() {
|
||||
// time 是 RFC3339(秒精度),而 backthink 的 at 是 Unix 毫秒:DTO 层两边都只当字符串收,
|
||||
// 解析留给各自的解析器(domain/admin/AdminFormat.kt)——这里钉的是「不在这层偷换形状」。
|
||||
val entries = json.decodeFromString(
|
||||
ApiEnvelope.serializer(logTail),
|
||||
"""{"data":[{"time":"2026-10-04T04:36:15Z","msg":"真时间"},{"time":"1730721600000","msg":"毫秒形状"}]}""",
|
||||
).data!!
|
||||
|
||||
assertEquals("2026-10-04T04:36:15Z", entries[0].time)
|
||||
// 毫秒形状**原样**留着(不转成 RFC3339),它会在排序时被判成「没有时间」而不是排到前面。
|
||||
assertEquals("1730721600000", entries[1].time)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metrics envelope decodes integer-shaped doubles and count maps`() {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.domain.admin.AdminFailure
|
||||
import top.yeij.cyrene.domain.admin.toAdminFailure
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* 日志 tail 端点的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端
|
||||
* (与 [UploadRepositoryTest] 同一手法,不引新依赖)。
|
||||
*
|
||||
* 这里要证明的不是「能解析一段 JSON」,而是三件线上真会出事的事:
|
||||
* 1. 请求真的是契约里那条路径(`GET /api/v1/admin/observability/logs`),`limit` 真的是客户端的
|
||||
* 保留窗口——服务端给 200 条而本地只留 100 条,就是无声地丢数据;
|
||||
* 2. `{"data":[]}` 是**成功且确实没有日志**,而 `501 not_implemented`(组合根没挂日志环形缓冲)
|
||||
* 与 `403` 都是**失败**:UI 上「暂无日志」与「日志不可用」必须是两件不同的事;
|
||||
* 3. 形状不对的载荷(`time` 是数字而不是 RFC3339 串)判**失败**,绝不静默解成一条都没有。
|
||||
*/
|
||||
class AdminRepositoryLogsTest {
|
||||
|
||||
private val backend = FakeLogsBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = AdminRepository(
|
||||
ApiClientFactory.create(baseUrl = backend.baseUrl, service = AdminApi::class.java),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the tail is fetched from the contract path with the client window as the limit`() = runBlocking {
|
||||
backend.reply(200, backend.tailBody())
|
||||
|
||||
repository().logs().getOrThrow()
|
||||
|
||||
val request = backend.requests.single()
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("/api/v1/admin/observability/logs", request.path)
|
||||
// 服务端缺省也是 200,但这里**显式**发出去:两端必须同源,否则改动任意一侧都会悄悄丢数据。
|
||||
assertEquals("limit=${AdminRepository.DEFAULT_LOG_LIMIT}", request.query)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the requested limit is clamped to what the server accepts`() = runBlocking {
|
||||
backend.reply(200, """{"data":[]}""")
|
||||
|
||||
repository().logs(limit = 100_000).getOrThrow()
|
||||
|
||||
assertEquals("limit=${AdminRepository.MAX_LOG_LIMIT}", backend.requests.single().query)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tail keeps the server order and every field verbatim`() = runBlocking {
|
||||
backend.reply(200, backend.tailBody())
|
||||
|
||||
val entries = repository().logs().getOrThrow()
|
||||
|
||||
assertEquals(listOf("慢请求", "启动完成"), entries.map { it.msg })
|
||||
assertEquals("2026-10-04T04:36:15Z", entries.first().time)
|
||||
assertEquals("warn", entries.first().level)
|
||||
assertEquals("gateway", entries.first().source)
|
||||
// 记录没带来源时是空串——原样保留,不补值。
|
||||
assertEquals("", entries[1].source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty tail is a success with no entries`() = runBlocking {
|
||||
// 「确实没有日志」是成功的一种:解成空列表,而不是 Parse 失败——
|
||||
// 否则界面上「暂无日志」会被说成「日志不可用」。
|
||||
backend.reply(200, """{"data":[]}""")
|
||||
|
||||
assertTrue(repository().logs().getOrThrow().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 501 without the ring buffer is a failure, never an empty tail`() = runBlocking {
|
||||
// 组合根没挂日志缓冲时后端如实回 501(不伪造空 tail):这条必须走失败分支,
|
||||
// 让它一路走到 UI 的「日志不可用」。
|
||||
backend.reply(
|
||||
501,
|
||||
"""{"error":{"code":"not_implemented","message":"日志环形缓冲未注入(组合根未挂载 logger.RingBuffer)"}}""",
|
||||
)
|
||||
|
||||
val error = repository().logs().exceptionOrNull() as CyreneException
|
||||
|
||||
assertEquals(
|
||||
CyreneError.Server("not_implemented", "日志环形缓冲未注入(组合根未挂载 logger.RingBuffer)", 501),
|
||||
error.error,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 403 becomes the forbidden state instead of empty logs`() = runBlocking {
|
||||
// 角色不够是「无权限」,不是「没有日志」:这条链路一直走到 domain 的收敛函数。
|
||||
backend.reply(403, """{"error":{"code":"forbidden","message":"auth: forbidden"}}""")
|
||||
|
||||
val error = repository().logs().exceptionOrNull()!!
|
||||
|
||||
assertEquals(AdminFailure.Forbidden, error.toAdminFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed entry fails instead of quietly returning nothing`() = runBlocking {
|
||||
// `time` 是数字而不是 RFC3339 串:形状不对就得失败,不能当成「一条都没有」。
|
||||
backend.reply(200, """{"data":[{"time":1730721600000,"level":"info","source":"x","msg":"m"}]}""")
|
||||
|
||||
val error = repository().logs().exceptionOrNull() as CyreneException
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable server is a local failure`() = runBlocking {
|
||||
backend.close()
|
||||
|
||||
val error = repository().logs().exceptionOrNull() as CyreneException
|
||||
|
||||
assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local)
|
||||
}
|
||||
|
||||
/** 记录请求并按 `reply(...)` 回话的假后端(只服务日志 tail 一条路径)。 */
|
||||
private inner class FakeLogsBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/api/v1/admin/observability/logs", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
val requests = CopyOnWriteArrayList<RecordedRequest>()
|
||||
|
||||
@Volatile
|
||||
private var status: Int = 200
|
||||
|
||||
@Volatile
|
||||
private var body: String = ""
|
||||
|
||||
fun reply(status: Int, body: String) {
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
/** 线上的成功形状:`{data:[{time,level,source,msg}]}`,最新在前(后端 `adminLogOf`)。 */
|
||||
fun tailBody(): String = """
|
||||
{"data":[
|
||||
{"time":"2026-10-04T04:36:15Z","level":"warn","source":"gateway","msg":"慢请求"},
|
||||
{"time":"2026-10-04T04:36:14Z","level":"info","source":"","msg":"启动完成"}]}
|
||||
""".trimIndent().replace("\n", "")
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
requests += RecordedRequest(
|
||||
method = exchange.requestMethod,
|
||||
path = exchange.requestURI.path,
|
||||
query = exchange.requestURI.query.orEmpty(),
|
||||
)
|
||||
val bytes = body.toByteArray()
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { server.stop(0) }
|
||||
}
|
||||
}
|
||||
|
||||
private data class RecordedRequest(
|
||||
val method: String,
|
||||
val path: String,
|
||||
val query: String,
|
||||
)
|
||||
}
|
||||
@@ -90,4 +90,38 @@ class AdminAccessTest {
|
||||
assertFalse(canReceiveObservabilityFrames(AdminRole.of("superowner")))
|
||||
assertFalse(canReceiveObservabilityFrames(AdminRole.of(null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `viewer reads the log history but never the increments`() {
|
||||
// 契约 §2.0 / §3.12:`GET /admin/observability/logs` 在 v0.40 的 viewerAuth 只读监控档里
|
||||
// (viewer 读得到),而 `observability_log` 帧 fail-closed 只发 owner + admin。
|
||||
// 两条门**刻意不对称**:viewer 看得到进页面那一刻的历史、收不到之后的增量——
|
||||
// 所以「最近日志」区块的存在不看帧到没到过,UI 不得据此报错或报空。
|
||||
assertTrue(canReadObservabilityLogHistory(AdminRole.Viewer))
|
||||
assertFalse(canReceiveObservabilityFrames(AdminRole.Viewer))
|
||||
|
||||
listOf(AdminRole.Admin, AdminRole.Owner).forEach { role ->
|
||||
assertTrue(role.name, canReadObservabilityLogHistory(role))
|
||||
assertTrue(role.name, canReceiveObservabilityFrames(role))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown role reads the log history and still gets no increments`() {
|
||||
// 未知角色按 viewer 处理:历史这条门是「一次普通只读监控请求」,照旧读得到;
|
||||
// 增量那条 fail-closed,照旧收不到。
|
||||
listOf(AdminRole.of("superowner"), AdminRole.of(null), AdminRole.of(" ")).forEach { role ->
|
||||
assertTrue(canReadObservabilityLogHistory(role))
|
||||
assertFalse(canReceiveObservabilityFrames(role))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the log history follows the overview page tier instead of a second allowlist`() {
|
||||
// 日志历史与概览页同档(都是 Monitor),这条相等关系是契约里的分档在客户端的落点:
|
||||
// 一旦后端把该端点挪出 viewerAuth,改这里的档位就同时改了「读不读」与「有没有区块」。
|
||||
AdminRole.entries.forEach { role ->
|
||||
assertEquals(role.name, canAccess(role, AdminPage.Overview), canReadObservabilityLogHistory(role))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,31 @@ class AdminFormatTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rfc3339 epoch seconds agree across the two spellings and the fractions`() {
|
||||
// 排序用:同一时刻的两种写法必须给出同一个 key,纳秒精度也要吃得下。
|
||||
assertEquals(1_772_598_896L, rfc3339EpochSecond("2026-03-04T04:34:56Z"))
|
||||
assertEquals(
|
||||
rfc3339EpochSecond("2026-03-04T04:34:56Z"),
|
||||
rfc3339EpochSecond("2026-03-04T12:34:56+08:00"),
|
||||
)
|
||||
assertEquals(
|
||||
rfc3339EpochSecond("2026-03-04T04:34:56Z"),
|
||||
rfc3339EpochSecond("2026-03-04T04:34:56.123456789Z"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a unix millisecond value is never parsed as rfc3339`() {
|
||||
// 排序用的解析器只认 RFC3339:一串毫秒数字(backthink 的 `at` 形状)必须判成「没有时间」,
|
||||
// 否则它会被当成一个有时间的条目排到前面,把真实顺序排乱。
|
||||
assertNull(rfc3339EpochSecond("1772598896000"))
|
||||
assertNull(rfc3339EpochSecond("2026-03-04"))
|
||||
assertNull(rfc3339EpochSecond("not-a-time"))
|
||||
assertNull(rfc3339EpochSecond(null))
|
||||
assertNull(rfc3339EpochSecond(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nanos pick a unit by magnitude`() {
|
||||
assertEquals("512 ns", formatNanos(512))
|
||||
|
||||
@@ -4,22 +4,25 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
|
||||
/**
|
||||
* 可观测推送帧的合并与本地过滤(契约 v0.55 §3.12)的纯函数。
|
||||
* 可观测面合并与本地过滤的纯函数(契约 §2「可观测性契约」+ §3.12)。
|
||||
*
|
||||
* 断言的是**三条不会写进类型里的语义**:新帧在前(列表两端本来就最新在前,不排序)、
|
||||
* 按业务键去重(帧与 REST 竞态时不会出现两条同样的数据)、窗口截断(不许无界增长);
|
||||
* 以及日志的等级过滤保留「判不出等级」的条目。
|
||||
* 断言的是**四条不会写进类型里的语义**:
|
||||
* 1. 合并的两端形态不同——REST 基线是**全量**、帧是**增量**,空增量不改任何东西;
|
||||
* 2. 日志列表始终满足「**无重复 + 时间倒序 + 至多窗口条**」这个不变式(trace 不排序,见文件内说明);
|
||||
* 3. 窗口是**位置**截断,所以必须排在排序**之后**,否则一条更旧的条目会挤掉更新的那条;
|
||||
* 4. 日志的等级过滤保留「判不出等级」的条目。
|
||||
*/
|
||||
class AdminOverviewMergeTest {
|
||||
|
||||
private fun span(id: String) = AdminTraceSpanDto(id = id, name = "span-$id", startedAt = "2026-10-04T12:00:00Z")
|
||||
|
||||
private fun entry(msg: String, level: String = "info") =
|
||||
ObservabilityLogEntryDto(time = "2026-10-04T12:00:00Z", level = level, source = "gateway", msg = msg)
|
||||
private fun entry(msg: String, level: String = "info", time: String = "2026-10-04T12:00:00Z") =
|
||||
AdminLogEntryDto(time = time, level = level, source = "gateway", msg = msg)
|
||||
|
||||
@Test
|
||||
fun `an empty delta leaves the snapshot untouched`() {
|
||||
@@ -57,13 +60,107 @@ class AdminOverviewMergeTest {
|
||||
|
||||
@Test
|
||||
fun `log entries are deduplicated by their whole business key`() {
|
||||
// 服务端两端都是**最新在前**,合并保留各自的相对顺序(不排序):帧里新到的在前,
|
||||
// 已经在列表里的那条不会被重复追加。
|
||||
val merged = mergeLogEntries(listOf(entry("启动")), listOf(entry("慢请求"), entry("启动")))
|
||||
|
||||
assertEquals(listOf("慢请求", "启动"), merged.map { it.msg })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rest baseline and the frame delta merge into one time ordered list`() {
|
||||
// REST 基线(最新在前)+ 之后的帧增量:去重、按 time 倒序,同一条不出现两次。
|
||||
val baseline = listOf(
|
||||
entry("b3", time = "2026-10-04T12:00:03Z"),
|
||||
entry("b2", time = "2026-10-04T12:00:02Z"),
|
||||
entry("b1", time = "2026-10-04T12:00:01Z"),
|
||||
)
|
||||
val delta = listOf(
|
||||
entry("f5", time = "2026-10-04T12:00:05Z"),
|
||||
// 订阅握手与引导读取之间可能重叠:同一条既在快照里、又被帧带了一次。
|
||||
entry("b3", time = "2026-10-04T12:00:03Z"),
|
||||
)
|
||||
|
||||
val merged = mergeLogEntries(baseline, delta)
|
||||
|
||||
assertEquals(listOf("f5", "b3", "b2", "b1"), merged.map { it.msg })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stale entry arriving with a frame does not jump to the top`() {
|
||||
// 拼接顺序不等于时间顺序:帧里若夹带一条**更旧**的条目(服务端锚点用的是纳秒内部时间、
|
||||
// 下发的 time 只有秒精度,拼接口上就有这种可能),它必须落到该落的位置。
|
||||
val merged = mergeLogEntries(
|
||||
listOf(entry("new", time = "2026-10-04T12:00:09Z")),
|
||||
listOf(entry("old", time = "2026-10-04T12:00:01Z")),
|
||||
)
|
||||
|
||||
assertEquals(listOf("new", "old"), merged.map { it.msg })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the window keeps the newest by time, not the ones that arrived last`() {
|
||||
// 窗口是**位置**截断 ⇒ 必须先排序再截断。否则这条更旧的 "old" 会占掉一格、把 b2 挤掉。
|
||||
val merged = mergeLogEntries(
|
||||
existing = listOf(entry("b1", time = "2026-10-04T12:00:05Z"), entry("b2", time = "2026-10-04T12:00:04Z")),
|
||||
incoming = listOf(entry("old", time = "2026-10-04T12:00:03Z")),
|
||||
window = 2,
|
||||
)
|
||||
|
||||
assertEquals(listOf("b1", "b2"), merged.map { it.msg })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entries sharing a second keep their arrival order`() {
|
||||
// time 只有秒精度,一秒内多条是常态:稳定排序必须保住到达顺序(新到的在前),
|
||||
// 否则同一秒里的时间线会被排乱。
|
||||
val merged = mergeLogEntries(
|
||||
existing = listOf(entry("先", time = "2026-10-04T12:00:07Z")),
|
||||
incoming = listOf(entry("后", time = "2026-10-04T12:00:07Z")),
|
||||
)
|
||||
|
||||
assertEquals(listOf("后", "先"), merged.map { it.msg })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unparsable time sinks to the tail but is never dropped`() {
|
||||
val entries = listOf(
|
||||
entry("没有时间", time = ""),
|
||||
entry("有时间", time = "2026-10-04T12:00:01Z"),
|
||||
entry("时间坏了", time = "not-a-time"),
|
||||
)
|
||||
|
||||
val normalized = normalizeLogEntries(entries)
|
||||
|
||||
assertEquals(listOf("有时间", "没有时间", "时间坏了"), normalized.map { it.msg })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a unix millisecond timestamp is not mistaken for an rfc3339 one`() {
|
||||
// 两种时间形状刻意不共用解析器:1730721600000 是**毫秒**,按 RFC3339 解析必须失败
|
||||
//(否则会被当成「有时间的条目」排到前面,把真实顺序排乱)。
|
||||
val normalized = normalizeLogEntries(
|
||||
listOf(
|
||||
entry("毫秒形状", time = "1730721600000"),
|
||||
entry("真时间", time = "2026-10-04T12:00:01Z"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("真时间", "毫秒形状"), normalized.map { it.msg })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizing a rest snapshot is idempotent`() {
|
||||
// 服务端本来就给「最新在前」,归一化不该改变它;再归一化一次也必须一样(不变式自洽)。
|
||||
val snapshot = listOf(
|
||||
entry("a", time = "2026-10-04T12:00:03Z"),
|
||||
entry("b", time = "2026-10-04T12:00:02Z"),
|
||||
)
|
||||
|
||||
val once = normalizeLogEntries(snapshot)
|
||||
|
||||
assertEquals(snapshot, once)
|
||||
assertEquals(once, normalizeLogEntries(once))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `log entries differing only in level are distinct`() {
|
||||
val merged = mergeLogEntries(
|
||||
@@ -131,5 +228,8 @@ class AdminOverviewMergeTest {
|
||||
assertEquals(100, TRACE_WINDOW)
|
||||
assertEquals(200, LOG_WINDOW)
|
||||
assertTrue(TRACE_WINDOW > 0)
|
||||
// 「向服务端要多少条」与「留多少条」必须同源:不一致就是悄悄丢数据。
|
||||
assertEquals(AdminRepository.DEFAULT_LOG_LIMIT, LOG_WINDOW)
|
||||
assertEquals(AdminRepository.DEFAULT_TRACE_LIMIT, TRACE_WINDOW)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user