merge: W4 — 概览页「按路径」分组折叠(可读性)

This commit is contained in:
AskaEth committed 2026-10-04 18:09:18 +08:00
commit a6b4bae2a0
3 files changed
+562 -2

No files matched your search

@@ -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<PathHit>,
) {
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<String, Long>): List<PathGroup> =
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<PathHit> { it.count }.thenBy { it.path }),
)
}
.sortedWith(compareByDescending<PathGroup> { it.hits }.thenBy { it.prefix })
/**
* 展开状态:**只记用户动过的组**([toggled] = 组前缀 → 是否展开),
* 其余按 [PathGroup.expandedByDefault]。这样刷新拿到新数据后,默认收起的大组不会被
* 上一轮的点按状态锁死,新出现的分组也自然落到默认档。
*/
fun expanded(group: PathGroup, toggled: Map<String, Boolean> = emptyMap()): Boolean =
toggled[group.prefix] ?: group.expandedByDefault
/** 分组列表 + 展开状态 → 一维行序列,UI 直接顺序渲染(组头之后紧跟它的子行)。 */
fun rows(groups: List<PathGroup>, toggled: Map<String, Boolean> = emptyMap()): List<PathRow> =
groups.flatMap { group ->
// 两个分支都写明 [PathRow]:否则 `flatMap` 会拿第一个分支的元素类型
// (`PathRow.Leaf`)去约束第二个分支,组头就加不进去了。
if (group.pathCount == 1) {
listOf<PathRow>(PathRow.Leaf(group.paths.single(), nested = false))
} else {
buildList<PathRow> {
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<String> = path.split('/').filter { it.isNotEmpty() }
}
@@ -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<String, Boolean>() }
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<Pair<String, String>> = l
"子代理事件" to metrics.subagentEvents.toString(),
)
/** 「按方法」「状态码」两类计数仍是一行文本:它们最多三五条,分组反而更碎。 */
private fun countRows(metrics: AdminMetricsDto): List<Pair<String, String>> = 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<String, Long>, toggled: MutableMap<String, Boolean>) {
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(
@@ -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<PathRow.Header>().map { it.group.prefix },
)
}
private fun group(prefix: String, paths: List<String>): PathGroup =
PathStats.groupPaths(paths.associateWith { 1L }).single().also { assertEquals(prefix, it.prefix) }
}