diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminPathStats.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminPathStats.kt new file mode 100644 index 0000000..9ed70a4 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminPathStats.kt @@ -0,0 +1,156 @@ +package top.yeij.cyrene.domain.admin + +/** + * `perPath` 的一项:路径原文 + 命中次数 + 展示文本。 + * + * [path] **不做任何改写**(便于与网关日志 / 后端逐字对齐、复制粘贴), + * 手机上要读的那一份在 [display](见 [PathStats.displayPath])。 + */ +data class PathHit( + val path: String, + val count: Long, + val display: String, +) + +/** + * 同一个前缀下的一组路径,组内已按命中次数倒序(同数按路径升序)。 + * [hits] / [pathCount] 是组头要显示的两个数:**条数与次数不是一回事** + * (真实响应里 `/admin` 一组 = 15 条路径、283 次命中)。 + */ +data class PathGroup( + val prefix: String, + val paths: List, +) { + val hits: Long get() = paths.sumOf { it.count } + + val pathCount: Int get() = paths.size + + /** + * 默认展开还是收起:条数多的大组默认**收起**,首屏因此只留一行组头, + * 展开是用户的一次明确动作(见 [PathStats.COLLAPSE_THRESHOLD])。 + */ + val expandedByDefault: Boolean get() = pathCount <= PathStats.COLLAPSE_THRESHOLD +} + +/** + * 分组列表里的一行。[Header] 可点、可折叠;[Leaf] 是一条路径。 + * 只有一条路径的组**不产生组头**(组头与内容完全重复),直接给一条 [Leaf]。 + */ +sealed interface PathRow { + data class Header(val group: PathGroup) : PathRow + + /** [nested] = 该行在一个已展开的组里,UI 据此缩进;组外散条不缩进。 */ + data class Leaf(val hit: PathHit, val nested: Boolean) : PathRow +} + +/** + * 「按路径」统计的分组与展示模型(纯函数,不含任何 Compose / Android 依赖)。 + * + * ## 为什么要客户端分组 + * 契约(api-client §2「可观测性契约」)里 `perPath` 是**扁平**的 `路径 → 次数` 映射—— + * 真实响应 26 条,后端不提供层级信息。之前它被塞进一行右对齐文本([formatCounts]), + * 手机上 26 条挤成一团、不可读,所以这里定义一套**可解释的分组规则**: + * + * **分组键 = 剥掉 [API_BASE_PREFIX] 后的首段**(`/api/v1/admin/llm/catalog` → `/admin`, + * `/assets/index-x.js` → `/assets`,`/sw.js` → `/sw.js`,`/` → `/`)。 + * + * - **没有任何路径清单**:规则是纯字符串推导,服务端新增路由、新增路径形状都不需要改客户端 + * (未知前缀自动成组,见单测里的 `/api/v1/quantum/entangle`)。 + * - 剥掉 `/api/v1` 是因为它对 21/26 条路径完全相同,占满宽度却零区分度; + * 剥离**只影响分组键**,行里显示的仍是完整路径。 + * - 比「按前两段分组」好:那样 `/admin/automations`、`/admin/personas` … 会切出十几个 + * 只有一条路径的组,组头与内容重复,比不分组还难读。 + * - 深层路径(段数 > [MAX_DISPLAY_SEGMENTS])在展示时**折叠中间段、保留尾部**: + * 尾部才是把同类路径区分开的部分(`…/admin/observability/metrics`),而整串在手机上 + * 会被省略号吃掉尾部、丢掉区分度。 + * + * 排序:组按 [PathGroup.hits] 倒序(热路径在前),组内按次数倒序——两处同数都按路径升序, + * 保证刷新前后顺序稳定(指标页每次刷新都重排会让读者跟丢)。 + */ +object PathStats { + + /** 所有 REST 端点共有的基址段,只在**分组键**里剥掉。 */ + const val API_BASE_PREFIX = "/api/v1" + + /** 组内路径条数超过它就默认收起。4 条以内的小组直接展开,省一次点击。 */ + const val COLLAPSE_THRESHOLD = 4 + + /** 段数超过它的路径在展示时折叠中间段。 */ + const val MAX_DISPLAY_SEGMENTS = 4 + + /** 折叠后保留的尾部段数。 */ + const val RETAINED_TAIL_SEGMENTS = 3 + + /** 根路径(`/`)与异常空串的组名。 */ + private const val ROOT_PREFIX = "/" + + private val API_BASE_SEGMENTS = segments(API_BASE_PREFIX) + + /** + * `perPath` → 分组列表:组按命中次数倒序,组内路径同样倒序。 + * 空输入给空列表(UI 据此渲染「暂无路径统计」,**它是成功后的空**,与失败态无关)。 + */ + fun groupPaths(perPath: Map): List = + perPath.entries + .groupBy( + keySelector = { groupPrefix(it.key) }, + valueTransform = { PathHit(path = it.key, count = it.value, display = displayPath(it.key)) }, + ) + .map { (prefix, paths) -> + PathGroup( + prefix = prefix, + paths = paths.sortedWith(compareByDescending { it.count }.thenBy { it.path }), + ) + } + .sortedWith(compareByDescending { it.hits }.thenBy { it.prefix }) + + /** + * 展开状态:**只记用户动过的组**([toggled] = 组前缀 → 是否展开), + * 其余按 [PathGroup.expandedByDefault]。这样刷新拿到新数据后,默认收起的大组不会被 + * 上一轮的点按状态锁死,新出现的分组也自然落到默认档。 + */ + fun expanded(group: PathGroup, toggled: Map = emptyMap()): Boolean = + toggled[group.prefix] ?: group.expandedByDefault + + /** 分组列表 + 展开状态 → 一维行序列,UI 直接顺序渲染(组头之后紧跟它的子行)。 */ + fun rows(groups: List, toggled: Map = emptyMap()): List = + groups.flatMap { group -> + // 两个分支都写明 [PathRow]:否则 `flatMap` 会拿第一个分支的元素类型 + // (`PathRow.Leaf`)去约束第二个分支,组头就加不进去了。 + if (group.pathCount == 1) { + listOf(PathRow.Leaf(group.paths.single(), nested = false)) + } else { + buildList { + add(PathRow.Header(group)) + if (expanded(group, toggled)) { + group.paths.forEach { add(PathRow.Leaf(it, nested = true)) } + } + } + } + } + + /** + * 展示文本:段数不超过 [MAX_DISPLAY_SEGMENTS] 时**原样返回**(不做任何归一化, + * 尾斜杠之类都保留);超过则折叠中间段、保留尾部 [RETAINED_TAIL_SEGMENTS] 段, + * 前缀 `…/` 明确表示左边还有内容被省掉。 + */ + fun displayPath(path: String): String { + val parts = segments(path) + if (parts.size <= MAX_DISPLAY_SEGMENTS) return path + return "…/" + parts.takeLast(RETAINED_TAIL_SEGMENTS).joinToString("/") + } + + /** 分组键:剥掉 [API_BASE_PREFIX] 后的首段,逐段比较(`/api/v1x/a` 不算基址下的路径)。 */ + private fun groupPrefix(path: String): String { + val parts = segments(path) + if (parts.isEmpty()) return ROOT_PREFIX + val rest = if (parts.take(API_BASE_SEGMENTS.size) == API_BASE_SEGMENTS) { + parts.drop(API_BASE_SEGMENTS.size) + } else { + parts + } + return rest.firstOrNull()?.let { "/$it" } ?: API_BASE_PREFIX + } + + private fun segments(path: String): List = path.split('/').filter { it.isNotEmpty() } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt index ab82f71..e8140b0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt @@ -13,13 +13,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import top.yeij.cyrene.data.api.dto.AdminMetricsDto import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto import top.yeij.cyrene.domain.admin.AdminFailure import top.yeij.cyrene.domain.admin.AdminPage import top.yeij.cyrene.domain.admin.AdminRole +import top.yeij.cyrene.domain.admin.PathGroup +import top.yeij.cyrene.domain.admin.PathHit +import top.yeij.cyrene.domain.admin.PathRow +import top.yeij.cyrene.domain.admin.PathStats import top.yeij.cyrene.domain.admin.formatCounts import top.yeij.cyrene.domain.admin.formatDouble import top.yeij.cyrene.domain.admin.formatNanos @@ -28,16 +38,28 @@ import top.yeij.cyrene.domain.admin.formatRfc3339 import top.yeij.cyrene.domain.admin.orDash import top.yeij.cyrene.domain.admin.traceKindLabel import top.yeij.cyrene.domain.admin.traceStatusLabel +import top.yeij.cyrene.ui.common.PreferenceCard +import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.HorizontalDivider +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.ChevronForward import top.yukonga.miuix.kmp.theme.MiuixTheme /** - * 概览页:`metrics` 卡片 + 其下的 `trace` 列表(两次请求,见 api-client §2「可观测性契约」)。 + * 概览页:`metrics` 卡片 + 「按路径」分组列表 + 其下的 `trace` 列表 + * (两次请求,见 api-client §2「可观测性契约」)。 */ @Composable fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) { val state by viewModel.overview.collectAsState() val reload = { viewModel.load(AdminPage.Overview, role) } + // 折叠状态放在 LazyColumn 之外:行随滚动回收,状态不能挂在 item 里。 + // 只记**用户动过的组**,其余按分组默认档(见 PathStats.expanded),刷新不会把大组锁在打开状态。 + val toggledGroups = remember { mutableStateMapOf() } + LaunchedEffect(Unit) { reload() } AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding -> @@ -72,6 +94,13 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () - } } } + item(key = "__paths__") { + // metrics 缺席时这里整段不渲染:上面那张卡已经说明了「指标不可用」, + // 再给一个空列表就是把失败画成「没有路径」。 + data.metrics?.let { metrics -> + PathStatsSection(perPath = metrics.perPath, toggled = toggledGroups) + } + } item(key = "__trace_title__") { AdminSectionTitle("最近 trace") } when { // trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。 @@ -103,12 +132,112 @@ private fun metricRows(metrics: AdminMetricsDto): List> = l "子代理事件" to metrics.subagentEvents.toString(), ) +/** 「按方法」「状态码」两类计数仍是一行文本:它们最多三五条,分组反而更碎。 */ private fun countRows(metrics: AdminMetricsDto): List> = listOf( "按方法" to formatCounts(metrics.perMethod).ifBlank { "-" }, - "按路径" to formatCounts(metrics.perPath).ifBlank { "-" }, "状态码" to formatCounts(metrics.statusCounts).ifBlank { "-" }, ) +/** + * 「按路径」区块:分组 + 可折叠(分组规则与排序全在 [PathStats],这里只负责画)。 + * + * 首屏可读性靠两件事:**条数多的大组默认收起**(真实响应里 15 条 admin 路径收成一行组头)、 + * 小组默认展开(省一次点击);行里同时给「条数」与「次数」,因为「15 条路径」和「283 次命中」 + * 是两个不同的信息。 + */ +@Composable +private fun PathStatsSection(perPath: Map, toggled: MutableMap) { + val groups = remember(perPath) { PathStats.groupPaths(perPath) } + val pathCount = groups.sumOf { it.pathCount } + val hits = groups.sumOf { it.hits } + // 这一行每次重组都要重算(toggled 是快照状态,读它才能跟着点按更新);纯计算,成本可忽略。 + val rows = PathStats.rows(groups, toggled) + + AdminSectionTitle("按路径($pathCount 条 · $hits 次)") + if (rows.isEmpty()) { + // 走到这里说明 metrics 请求**成功**了而 `perPath` 为空——成功后的空,不是失败。 + AdminInlineHint("暂无路径统计") + return + } + PreferenceCard { + Column { + rows.forEachIndexed { index, row -> + if (index > 0) HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + when (row) { + is PathRow.Header -> PathGroupRow( + group = row.group, + expanded = PathStats.expanded(row.group, toggled), + onToggle = { toggled[row.group.prefix] = !PathStats.expanded(row.group, toggled) }, + ) + + is PathRow.Leaf -> PathLeafRow(hit = row.hit, nested = row.nested) + } + } + } + } +} + +/** 组头:前缀 + 「N 条 · M 次」+ 箭头(展开时转向下)。整行可点,是唯一的折叠入口。 */ +@Composable +private fun PathGroupRow(group: PathGroup, expanded: Boolean, onToggle: () -> Unit) { + BasicComponent( + onClick = onToggle, + endActions = { + Text( + text = "${group.pathCount} 条 · ${group.hits} 次", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + Icon( + imageVector = MiuixIcons.ChevronForward, + contentDescription = if (expanded) "收起" else "展开", + modifier = Modifier + .padding(start = 6.dp) + .rotate(if (expanded) 90f else 0f), + tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + }, + ) { + Text( + text = group.prefix, + fontSize = MiuixTheme.textStyles.main.fontSize, + fontWeight = FontWeight.Medium, + color = MiuixTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * 路径行:左路径、右计数。 + * + * 路径用**等宽字体**(路径是给人对比字符用的,等宽最好扫读),字号取 `footnote1` 与计数一致; + * 过长时**中间省略**——尾部(资源名)才是区分同类路径的部分,尾部省略等于把两条路径画成一样。 + */ +@Composable +private fun PathLeafRow(hit: PathHit, nested: Boolean) { + BasicComponent( + endActions = { + Text( + text = hit.count.toString(), + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + }, + ) { + Text( + text = hit.display, + modifier = if (nested) Modifier.padding(start = 12.dp) else Modifier, + fontSize = MiuixTheme.textStyles.footnote1.fontSize, + fontFamily = FontFamily.Monospace, + color = MiuixTheme.colorScheme.onSurfaceContainer, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } +} + @Composable private fun TraceSpanCard(span: AdminTraceSpanDto) { AdminTextCard( diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminPathStatsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminPathStatsTest.kt new file mode 100644 index 0000000..157bfab --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminPathStatsTest.kt @@ -0,0 +1,275 @@ +package top.yeij.cyrene.domain.admin + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 「按路径」分组与折叠模型(C-2 概览页)。 + * + * `realPerPath` 是**真实响应体**里的 `perPath`(26 条,与 `AdminDtoRealPayloadTest` 同一份样本), + * 不是编造的样例——分组规则必须在真实数据上成立才算数。 + */ +class AdminPathStatsTest { + + private val realPerPath = mapOf( + "/" to 2L, + "/api/v1/admin/automations" to 17L, + "/api/v1/admin/backthink" to 1L, + "/api/v1/admin/channels" to 30L, + "/api/v1/admin/iot" to 34L, + "/api/v1/admin/llm" to 18L, + "/api/v1/admin/llm/catalog" to 2L, + "/api/v1/admin/memories" to 21L, + "/api/v1/admin/observability/metrics" to 24L, + "/api/v1/admin/observability/trace" to 1L, + "/api/v1/admin/personas" to 31L, + "/api/v1/admin/plugins" to 23L, + "/api/v1/admin/plugins/installed" to 17L, + "/api/v1/admin/rag" to 17L, + "/api/v1/admin/sessions" to 30L, + "/api/v1/admin/users" to 17L, + "/api/v1/auth/login" to 5L, + "/api/v1/health" to 27L, + "/api/v1/meta/enums" to 2L, + "/api/v1/personas" to 1L, + "/api/v1/sessions" to 1L, + "/api/v1/sessions/web/messages" to 1L, + "/assets/index-DrDuaeXG.js" to 1L, + "/assets/index-SA9l26Q_.css" to 1L, + "/sw.js" to 1L, + "/ws/chat" to 1L, + ) + + // ------------------------------------------------------------------ + // 空输入 / 单条 + // ------------------------------------------------------------------ + + @Test + fun `empty input yields no groups and no rows`() { + assertTrue(PathStats.groupPaths(emptyMap()).isEmpty()) + assertTrue(PathStats.rows(emptyList()).isEmpty()) + } + + @Test + fun `a single path is one group and renders as a headerless row`() { + val groups = PathStats.groupPaths(mapOf("/api/v1/health" to 7L)) + + assertEquals(1, groups.size) + assertEquals("/health", groups[0].prefix) + assertEquals(1, groups[0].pathCount) + assertEquals(7L, groups[0].hits) + // 只有一条路径的组不产生组头:组头与内容完全重复,只会让列表变长。 + assertEquals(listOf(PathRow.Leaf(PathHit("/api/v1/health", 7L, "/api/v1/health"), false)), PathStats.rows(groups)) + } + + // ------------------------------------------------------------------ + // 分组规则(无路径清单) + // ------------------------------------------------------------------ + + @Test + fun `grouping keys off the first segment below the api base, not a hardcoded route list`() { + val groups = PathStats.groupPaths( + mapOf( + "/api/v1/quantum/entangle" to 3L, // 客户端不认识的新路由:照样自动成组 + "/api/v1/admin/automations" to 1L, + ), + ) + + assertEquals(listOf("/quantum", "/admin"), groups.map { it.prefix }) + } + + @Test + fun `api base prefix is stripped for grouping only, the row keeps the full path`() { + val group = PathStats.groupPaths(mapOf("/api/v1/admin/llm/catalog" to 2L)).single() + + assertEquals("/admin", group.prefix) + assertEquals("/api/v1/admin/llm/catalog", group.paths.single().path) + } + + @Test + fun `a path that only looks like the base is not stripped`() { + // 逐段比较:`/api/v1x/...` 不是 `/api/v1` 下的路径,不能被剥成 `/x`。 + assertEquals("/api", PathStats.groupPaths(mapOf("/api/v1x/foo" to 1L)).single().prefix) + // 基址本身(没有更深的一段)自成一档,而不是落进空字符串。 + assertEquals("/api/v1", PathStats.groupPaths(mapOf("/api/v1" to 1L)).single().prefix) + } + + @Test + fun `root and non-api paths each get their own group`() { + val groups = PathStats.groupPaths( + mapOf( + "/" to 2L, + "" to 1L, + "/assets/index-DrDuaeXG.js" to 1L, + "/assets/index-SA9l26Q_.css" to 1L, + "/sw.js" to 1L, + "/ws/chat" to 1L, + ), + ) + + assertEquals(listOf("/", "/assets", "/sw.js", "/ws"), groups.map { it.prefix }) + assertEquals(2, groups.first { it.prefix == "/assets" }.pathCount) + assertEquals(2, groups.first { it.prefix == "/" }.pathCount) + } + + // ------------------------------------------------------------------ + // 真实 26 条 + // ------------------------------------------------------------------ + + @Test + fun `real payload groups into ten prefixes without losing a path or a hit`() { + val groups = PathStats.groupPaths(realPerPath) + + assertEquals( + listOf("/admin", "/health", "/auth", "/", "/assets", "/meta", "/sessions", "/personas", "/sw.js", "/ws"), + groups.map { it.prefix }, + ) + assertEquals(26, groups.sumOf { it.pathCount }) + // 与同一份真实响应里的 totalRequests 一致:分组既不吞路径也不重算次数。 + assertEquals(326L, groups.sumOf { it.hits }) + assertEquals(15, groups.first().pathCount) + assertEquals(283L, groups.first().hits) + } + + @Test + fun `groups are ordered by hits, ties by prefix`() { + val groups = PathStats.groupPaths(realPerPath) + + val hits = groups.map { it.hits } + assertEquals(hits.sortedDescending(), hits) + // 并列的 2 次:按前缀升序,保证刷新前后顺序稳定。 + assertEquals(listOf("/", "/assets", "/meta", "/sessions"), groups.filter { it.hits == 2L }.map { it.prefix }) + } + + @Test + fun `paths inside a group are ordered by count, ties by path`() { + val admin = PathStats.groupPaths(realPerPath).first().paths + + val counts = admin.map { it.count } + assertEquals(counts.sortedDescending(), counts) + assertEquals( + listOf( + "/api/v1/admin/automations", + "/api/v1/admin/plugins/installed", + "/api/v1/admin/rag", + "/api/v1/admin/users", + ), + admin.filter { it.count == 17L }.map { it.path }, + ) + assertEquals("/api/v1/admin/iot", admin.first().path) + } + + // ------------------------------------------------------------------ + // 长路径的展示文本 + // ------------------------------------------------------------------ + + @Test + fun `deep paths collapse the middle and keep the tail`() { + assertEquals("…/admin/observability/metrics", PathStats.displayPath("/api/v1/admin/observability/metrics")) + assertEquals("…/sessions/web/messages", PathStats.displayPath("/api/v1/sessions/web/messages")) + } + + @Test + fun `paths within the segment budget are rendered verbatim`() { + // 不归一化:尾斜杠、文件名里的点、问号都原样留着(键名就是后端给的原串)。 + assertEquals("/api/v1/auth/login", PathStats.displayPath("/api/v1/auth/login")) + assertEquals("/assets/index-DrDuaeXG.js", PathStats.displayPath("/assets/index-DrDuaeXG.js")) + assertEquals("/sw.js", PathStats.displayPath("/sw.js")) + assertEquals("/", PathStats.displayPath("/")) + assertEquals("", PathStats.displayPath("")) + } + + @Test + fun `collapsing never makes two sibling paths look identical`() { + val a = PathStats.displayPath("/api/v1/admin/observability/metrics") + val b = PathStats.displayPath("/api/v1/admin/observability/trace") + + assertTrue(a, a != b) + assertTrue(a, a.endsWith("/metrics")) + assertTrue(b, b.endsWith("/trace")) + } + + @Test + fun `display text is wired into the grouped rows`() { + val hit = PathStats.groupPaths(realPerPath) + .flatMap { it.paths } + .first { it.path == "/api/v1/admin/observability/metrics" } + + assertEquals("…/admin/observability/metrics", hit.display) + } + + // ------------------------------------------------------------------ + // 折叠状态 + // ------------------------------------------------------------------ + + @Test + fun `big groups collapse by default, small ones expand`() { + val groups = PathStats.groupPaths(realPerPath) + + val admin = groups.first { it.prefix == "/admin" } + assertFalse(admin.expandedByDefault) + assertTrue(groups.first { it.prefix == "/assets" }.expandedByDefault) + + // 阈值边界:恰好 [PathStats.COLLAPSE_THRESHOLD] 条仍展开,多一条就收起。 + val atThreshold = group("/t", (1..PathStats.COLLAPSE_THRESHOLD).map { "/api/v1/t/p$it" }) + val aboveThreshold = group("/t", (1..PathStats.COLLAPSE_THRESHOLD + 1).map { "/api/v1/t/p$it" }) + assertTrue(atThreshold.expandedByDefault) + assertFalse(aboveThreshold.expandedByDefault) + } + + @Test + fun `default rows show only the header of a collapsed group`() { + val rows = PathStats.rows(PathStats.groupPaths(realPerPath)) + + // 15 条 admin 路径 + 其余散条都在,但首屏只有组头承载它们。 + assertEquals(14, rows.size) + assertEquals(PathRow.Header(PathStats.groupPaths(realPerPath).first()), rows.first()) + assertEquals(1, rows.count { it is PathRow.Header && it.group.prefix == "/admin" }) + assertTrue(rows.none { it is PathRow.Leaf && it.hit.path.startsWith("/api/v1/admin/") }) + // 小分组默认展开:组头之后紧跟它自己的子行。 + val assetsHeader = rows.indexOfFirst { it is PathRow.Header && it.group.prefix == "/assets" } + assertTrue(rows[assetsHeader + 1] is PathRow.Leaf) + assertEquals(listOf(true, true), (assetsHeader + 1..assetsHeader + 2).map { (rows[it] as PathRow.Leaf).nested }) + } + + @Test + fun `explicit toggles win over the default in both directions`() { + val groups = PathStats.groupPaths(realPerPath) + val admin = groups.first { it.prefix == "/admin" } + val assets = groups.first { it.prefix == "/assets" } + + // 收起的大组被点开:组头之后紧跟着它的 [admin.pathCount] 条子行(缩进)。 + val opened = PathStats.rows(groups, mapOf("/admin" to true)) + val adminHeader = opened.indexOfFirst { it is PathRow.Header && it.group.prefix == "/admin" } + val children = opened.subList(adminHeader + 1, adminHeader + 1 + admin.pathCount) + assertEquals(admin.paths.map { it.path }, children.map { (it as PathRow.Leaf).hit.path }) + assertTrue(children.all { it is PathRow.Leaf && it.nested }) + assertEquals("/api/v1/admin/iot", (opened[adminHeader + 1] as PathRow.Leaf).hit.path) + + // 默认展开的小组被收起:只剩组头。 + val closed = PathStats.rows(groups, mapOf("/assets" to false)) + assertTrue(closed.none { it is PathRow.Leaf && it.hit.path.startsWith("/assets/") }) + assertTrue(PathStats.expanded(assets, mapOf("/assets" to false)).not()) + } + + @Test + fun `rows keep the group order and never drop a path when everything is expanded`() { + val groups = PathStats.groupPaths(realPerPath) + val toggled = groups.associate { it.prefix to true } + + val rows = PathStats.rows(groups, toggled) + // 只有多路径的组才有组头(单条组直接是一行路径),全部展开时行数 = 组头 + 全部路径。 + val multiPathGroups = groups.filter { it.pathCount > 1 } + + assertEquals(multiPathGroups.size + 26, rows.size) + assertEquals( + multiPathGroups.map { it.prefix }, + rows.filterIsInstance().map { it.group.prefix }, + ) + } + + private fun group(prefix: String, paths: List): PathGroup = + PathStats.groupPaths(paths.associateWith { 1L }).single().also { assertEquals(prefix, it.prefix) } +}