feat(admin): 概览「按路径」改成分组可折叠列表 —— 26 条路径不再挤成一行

`perPath`(真实响应 26 条)此前被 `formatCounts` 压成一行右对齐文本,手机上极密。
本次只改呈现层,取数(DTO / Retrofit / ViewModel)一字未动。

- domain/admin/AdminPathStats.kt(新增,纯函数):分组键 = **剥掉 `/api/v1` 后的首段**
  (`/api/v1/admin/llm/catalog` → `/admin`,`/sw.js` → `/sw.js`,`/` → `/`)。
  规则是纯字符串推导、**没有任何路径清单**:服务端加路由不用改客户端
  (`/api/v1/quantum/*` 自动成 `/quantum` 组);逐段比较,`/api/v1x/foo` 不会被误剥成 `/x`。
  剥离只作用于分组键,行里显示的仍是完整路径。
  排序:组按命中次数倒序、组内同样倒序,**两处同数都按路径升序**——指标页每次刷新都会重排,
  没有稳定次序读者就跟丢了。`hits`(次数)与 `pathCount`(条数)分开,是组头的两个数。
  长路径(段数 > 4)展示时折叠中间段、保留尾部 3 段:`…/admin/observability/metrics`;
  尾部才是区分同类路径的部分,整串在手机上会被尾部省略号吃掉、两条路径就长得一样了。
- ui/admin/AdminOverviewScreen.kt:「按路径」从 `countRows` 里挪出来单独成块——
  区块标题给「26 条 · 326 次」,卡片内一行一组,组头「N 条 · M 次」+ 可旋转箭头,
  整行可点即展开/收起;组内一行一条路径,右侧计数右对齐、路径等宽字体 + 中间省略。
  **条数 > 4 的组默认收起**(真实数据里 15 条 admin 路径收成一行组头),小分组默认展开省一次点击;
  折叠状态只记**用户动过的组**(`remember { mutableStateMapOf() }`,放在 LazyColumn 之外),
  刷新拿到新数据不会被上一轮点按锁死。
  只用 Miuix 现成组件(`PreferenceCard` + `BasicComponent` + `HorizontalDivider` +
  `MiuixIcons.ChevronForward` 旋转 90°),**没有自造折叠控件**;路径行用内容重载自己画 Text
  才能拿到 `maxLines = 1` + `MiddleEllipsis`(收 `String` 的那个重载只会尾部省略)。
  失败态不变:`metrics` 缺席时整块不渲染(上面那张「指标不可用」卡已经说明了原因),
  只有 `perPath` **请求成功且为空**时才给「暂无路径统计」。

测试:353 → 370 全绿(+17,AdminPathStatsTest):空输入 / 单条(不产生组头)/ 真实 26 条 →
10 组且总次数 326 = 同一份真实响应里的 `totalRequests`(分组不吞路径也不重算次数)/
未知前缀自动成组 / 基址逐段剥离的边界(`/api/v1x/foo`、基址本身)/ 组与组内的排序与并列次序 /
长路径折叠(两条兄弟路径折叠后仍不同)/ 折叠默认值的阈值边界(4 条展开、5 条收起)/
显式切换双向覆盖默认。

未验证(无模拟器、也不许碰 adb,只有构建 + 单测):
- 观感(组头取 `primary`、路径等宽字号取 `footnote1`、子行缩进 12dp、箭头旋转)**没有截图**,
  取舍待主人拍板;
- `BasicComponent` 对长路径会不会折行没有实机确认,所以路径文本额外做了「段级中间折叠」,
  不依赖库的省略行为;
- 深色模式未单独验证,颜色全部取自 `MiuixTheme.colorScheme`(与页面其它卡片同源)。
This commit is contained in:
AskaEth committed 2026-10-04 18:07:22 +08:00
1 parent 664fd8dcef
commit f91a8558ea
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) }
}