@mzzsfy/dsh-usage-dash 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +9 -2
- package/src/client.js +101 -200
- package/test/stats-line-render.test.mjs +75 -0
- package/test/stats-line.test.mjs +23 -212
- package/test/usage-dialog.test.mjs +106 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mzzsfy/dsh-usage-dash
|
|
2
2
|
|
|
3
|
-
用量统计面板(dsh 插件)。天/小时/分钟三粒度 token 与请求统计,设置页自绘面板:汇总卡、活动热力图、缓存命中率曲线、模型 donut
|
|
3
|
+
用量统计面板(dsh 插件)。天/小时/分钟三粒度 token 与请求统计,设置页自绘面板:汇总卡、活动热力图、缓存命中率曲线、模型 donut 与列表、回扫状态行、会话底栏追加与回合费用芯片,支持 en/zh 双语与可选费用估算。复刻自 [HaoyueQin/dsh-usage-statistics-panel](https://github.com/HaoyueQin/dsh-usage-statistics-panel),感谢原作者。
|
|
4
4
|
|
|
5
5
|
## 功能(全阶段已交付)
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
- 模型 donut 与列表:按 token 排序的模型占比环形图(中心为总量,模型不超过 5 个时全展示,超过时折叠为前 4 +「其他」,「其他」可展开),每行名称两行展示(厂商/模型名),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
|
|
14
14
|
- 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);节头「金额」开关把柱状图从 token 消耗切到金额消耗——柱按模型堆叠估算费用,标题与左轴同步切金额读数(悬停明细保持 token 口径与估算费用行不变,命中率/速度/首 token 延迟曲线不受影响),三粒度通用;开关仅在存在正计价数据时显示(未配置规则或全量未计价时不渲染,避免全零金额柱误导);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
|
|
15
15
|
- 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;折叠且有待读信息(异常日志或写入失败)时箭头带红点提醒,展开后消失;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
|
|
16
|
-
-
|
|
16
|
+
- 会话底栏追加(append 模式):本插件在官方 activity/usage 胶囊之后仅显示估算费用,不另建明细按钮或浮层。精确缓存命中率开关(`cachePrecision`)原位修改官方 usage 胶囊的命中率文字、按钮 `aria-label` 与官方弹窗原命中率 `dd`,统一显示两位小数;关闭开关或卸载增强时还原,保留官方节点与交互。「会话 Token 明细」开关(`tokenDetail`)只在已打开的官方 `[data-session-stats-usage]` 用量弹窗 `dl` 末尾追加「输入合计(含缓存)」(未缓存输入+缓存读+缓存写),仅当 `cacheWriteTokens > 0` 再追加「未命中合计(含缓存写入)」(未缓存输入+缓存写入)。合计使用完整千分位计数,官方已有的总量、缓存读写、输出与命中率不重复追加;无输入用量时不追加零值合计。偏好卡三个开关仍默认全开,已有偏好继续生效;「费用显示」控制底栏费用、趋势 tooltip 费用行与回合费用芯片。官方弹窗关闭时不挂载补充行;空用量无插件可见条目,精度或明细单独开启均不产生底栏入口。官方未公开用量弹窗扩展 slot,上述增强依赖宿主 DOM 语义标记,标记改变或无法唯一确认弹窗归属时停用相应增强,底栏费用仍独立显示。
|
|
17
17
|
- 回合费用芯片:经官方 `conversation.chat.assistant-actions` 槽注入动作行(复制与分支图标之间,官方赞/踩与上下文跳转同排),每轮对话结束后显示该轮估算费用(悬停 title 带 token 摘要与估算口径;显隐节奏随官方动作行——最新一轮常显,历史轮悬停显现;受「费用显示」开关;官方用量芯片弹窗已有 token 明细,芯片只承载费用)。旧宿主无该插槽时告警禁用
|
|
18
18
|
- 定价规则编辑器:设置面板内按**模型分组**编辑,组头 = 模型键(整组一次改名,改后即时重新聚合)+ 删除整组;组内 = **默认价**槽(价格四桶,不设条件——附加规则全不命中时兜底,禁排序/删除)+ **附加计费规则**列表(四桶单价 + 条件组合,「+添加条件」追加条件行,行内下拉切换类型,规则带 ↑/↓ 调序与删除),「添加额外计费规则」追加一条(默认带全天时段条件);货币为全局切换(「定价规则」标题右侧,仅 ¥/$ 两档,整表统一),显式「保存」整表写入
|
|
19
19
|
- 双语:跟随宿主语言设置(设置 → 通用 → 语言)即时切换 en/zh
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
|
|
44
44
|
本插件复刻自 [HaoyueQin/dsh-usage-statistics-panel](https://github.com/HaoyueQin/dsh-usage-statistics-panel)(npm 包 [dsh-usage-statistics-panel](https://www.npmjs.com/package/dsh-usage-statistics-panel)),感谢原作者 HaoyueQin 的开源实现。在其基础上补足缺失的小时/分钟粒度与热力图/曲线/donut,移除其远程访问限制。路由(`/api/usage-dash/*`)与存储域(`usage_stats`)均不冲突,共存只是重复采集。
|
|
45
45
|
|
|
46
|
-
同装时两插件争抢会话底栏 'stats'
|
|
46
|
+
同装时两插件争抢会话底栏 'stats' 槽位(append 后位增强条目):槽注册表对同 id 同 priority 直接抛错,本插件以更低 priority 注册遮蔽原插件(lowest renders),同装时本插件胜出、卸载本插件后原插件恢复。仍建议卸载原插件以避免重复采集。
|
|
47
47
|
|
|
48
48
|
## 存档兼容
|
|
49
49
|
|
|
@@ -93,4 +93,4 @@ dsh --profile <profile> --no-open --port 9191
|
|
|
93
93
|
|
|
94
94
|
## dsh 版本兼容
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。兼容面以窗口实测为准,不逐版本承诺。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md` 与本包 `兼容性测试.md`。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-usage-dash",
|
|
3
|
-
"description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26
|
|
4
|
-
"version": "0.10.
|
|
3
|
+
"description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图、缓存命中率/生成速度/首 token 延迟曲线、模型占比与四桶明细,会话底栏官方数据后追加增强项与官方动作行回合费用芯片,多条件分时段定价规则与费用估算,en/zh 双语;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
|
|
4
|
+
"version": "0.10.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
},
|
|
16
16
|
"client": {
|
|
17
17
|
"platform": "web"
|
|
18
|
+
},
|
|
19
|
+
"compatibility": {
|
|
20
|
+
"dshReleases": {
|
|
21
|
+
"0.2.0-rc.1": "unknown",
|
|
22
|
+
"0.2.0-rc.2": "compatible",
|
|
23
|
+
"0.2.1-alpha.1": "unknown"
|
|
24
|
+
}
|
|
18
25
|
}
|
|
19
26
|
},
|
|
20
27
|
"scripts": {
|
package/src/client.js
CHANGED
|
@@ -246,19 +246,14 @@ const MESSAGES_ZH = {
|
|
|
246
246
|
anomalyLog: '扫描异常日志',
|
|
247
247
|
logKindSkipped: '跳过会话',
|
|
248
248
|
logKindRecord: '写入失败',
|
|
249
|
-
'stats.
|
|
250
|
-
'stats.
|
|
251
|
-
'stats.toolCall': '工具调用 {duration}',
|
|
252
|
-
'stats.ttftAverage': '首 token 平均 {duration}',
|
|
253
|
-
'stats.tokensPerSecond': '{throughput} tok/s',
|
|
254
|
-
'stats.cacheHit': '缓存命中 {percent}%',
|
|
249
|
+
'stats.inputTotal': '输入合计(含缓存)',
|
|
250
|
+
'stats.missTotal': '未命中合计(含缓存写入)',
|
|
255
251
|
'stats.tokens': '输入 {input} tok · 输出 {output} tok',
|
|
256
252
|
'turnCostChip': '{cost}',
|
|
257
|
-
'stats.tokensDetail': '总 {total} tok · 输入 {input} tok · 命中缓存 {hit} tok · 未命中缓存 {miss} tok · 输出 {output} tok',
|
|
258
253
|
cachePrecision: '精确缓存命中率',
|
|
259
|
-
cachePrecisionDesc: '
|
|
254
|
+
cachePrecisionDesc: '将官方用量胶囊及弹窗中的命中率显示为两位小数。',
|
|
260
255
|
tokenDetail: '会话 Token 明细',
|
|
261
|
-
tokenDetailDesc: '
|
|
256
|
+
tokenDetailDesc: '在官方 Token 用量弹窗末尾追加含缓存输入合计;存在缓存写入时追加未命中合计。',
|
|
262
257
|
costDisplay: '费用显示',
|
|
263
258
|
costDisplayDesc: '在信息栏、趋势悬浮与回合费用芯片中显示按当前费率估算的费用。',
|
|
264
259
|
costTitle: '按当前费率对历史用量估算,精度为小时级',
|
|
@@ -394,19 +389,14 @@ const MESSAGES_EN = {
|
|
|
394
389
|
anomalyLog: 'Scan anomaly log',
|
|
395
390
|
logKindSkipped: 'skipped',
|
|
396
391
|
logKindRecord: 'write failed',
|
|
397
|
-
'stats.
|
|
398
|
-
'stats.
|
|
399
|
-
'stats.toolCall': 'Tool call {duration}',
|
|
400
|
-
'stats.ttftAverage': 'TTFT avg {duration}',
|
|
401
|
-
'stats.tokensPerSecond': '{throughput} tok/s',
|
|
402
|
-
'stats.cacheHit': 'Cache hit {percent}%',
|
|
392
|
+
'stats.inputTotal': 'Total input (including cache)',
|
|
393
|
+
'stats.missTotal': 'Total cache miss (including writes)',
|
|
403
394
|
'stats.tokens': 'Input {input} tok · Output {output} tok',
|
|
404
395
|
'turnCostChip': '{cost}',
|
|
405
|
-
'stats.tokensDetail': 'Total {total} tok · Input {input} tok · Cache hit {hit} tok · Cache miss {miss} tok · Output {output} tok',
|
|
406
396
|
cachePrecision: 'Precise cache-hit rate',
|
|
407
|
-
cachePrecisionDesc: 'Show
|
|
397
|
+
cachePrecisionDesc: 'Show two decimals in the official usage pill and Token usage dialog.',
|
|
408
398
|
tokenDetail: 'Session token detail',
|
|
409
|
-
tokenDetailDesc: '
|
|
399
|
+
tokenDetailDesc: 'Append total input including cache to the official Token usage dialog; add total cache misses when cache writes exist.',
|
|
410
400
|
costDisplay: 'Cost display',
|
|
411
401
|
costDisplayDesc: 'Show costs estimated at current rates in the stats line, trend tooltips and the turn cost chip.',
|
|
412
402
|
costTitle: 'Estimated at current rates over historical usage, hourly precision',
|
|
@@ -1029,19 +1019,13 @@ function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX)
|
|
|
1029
1019
|
return { left, top }
|
|
1030
1020
|
}
|
|
1031
1021
|
|
|
1032
|
-
// =====
|
|
1022
|
+
// ===== 底部信息栏(append 模式):官方 pill 数据后追加增强项 + usp 双开关 =====
|
|
1033
1023
|
const STATS_LINE_STORAGE_KEY = 'dsh-usage-dash:stats-line'
|
|
1034
1024
|
const LOCALE_NS = 'usage-dash'
|
|
1035
|
-
const STATS_ITEM_SEPARATOR = ' · '
|
|
1036
1025
|
const MS_PER_SECOND = 1000
|
|
1037
1026
|
const SECONDS_PER_MINUTE = 60
|
|
1038
1027
|
const NUMBER_ONE_DECIMAL = 10
|
|
1039
1028
|
const NUMBER_COMPACT_INT_THRESHOLD = 100
|
|
1040
|
-
const PERCENT_TENTH_SCALE = 10
|
|
1041
|
-
const PERCENT_GAP_DOUBLE_SCALE = 2 * PERCENT_SCALE
|
|
1042
|
-
const PERCENT_LOSS_BASE = 10
|
|
1043
|
-
const PERCENT_LOSS_CAP = 4
|
|
1044
|
-
const PERCENT_LOSS_DEFAULT = 5
|
|
1045
1029
|
const PERCENT_PRECISE_CAP = 99.99
|
|
1046
1030
|
const DURATION_MINUTE_SECONDS = 60
|
|
1047
1031
|
const TPS_INTEGER_THRESHOLD = 10
|
|
@@ -1051,65 +1035,6 @@ function billedInputTokens(usage) {
|
|
|
1051
1035
|
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
|
1052
1036
|
}
|
|
1053
1037
|
|
|
1054
|
-
// 官方二分取整:求最大 units 使 hit ≥ (2u-1)q + ceil((2u-1)r/2s),Math.round(hit·scale/D) 的保守复刻
|
|
1055
|
-
function roundedPercentUnits(cacheReadTokens, denominator, decimalPlaces) {
|
|
1056
|
-
const scale = (decimalPlaces === 0 ? 1 : PERCENT_TENTH_SCALE) * PERCENT_SCALE
|
|
1057
|
-
const doubledScale = scale * 2
|
|
1058
|
-
const quotient = Math.floor(denominator / doubledScale)
|
|
1059
|
-
const remainder = denominator % doubledScale
|
|
1060
|
-
const holds = (candidate) => {
|
|
1061
|
-
const doubled = candidate * 2 - 1
|
|
1062
|
-
return cacheReadTokens >= doubled * quotient + Math.ceil((doubled * remainder) / doubledScale)
|
|
1063
|
-
}
|
|
1064
|
-
let low = 0
|
|
1065
|
-
let high = scale
|
|
1066
|
-
while (low < high) {
|
|
1067
|
-
const mid = Math.floor((low + high + 1) / 2)
|
|
1068
|
-
if (holds(mid)) low = mid
|
|
1069
|
-
else high = mid - 1
|
|
1070
|
-
}
|
|
1071
|
-
return low
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
function displayPercentUnits(units, decimalPlaces) {
|
|
1075
|
-
if (decimalPlaces === 0) return String(units)
|
|
1076
|
-
const whole = Math.floor(units / PERCENT_TENTH_SCALE)
|
|
1077
|
-
const tenths = units % PERCENT_TENTH_SCALE
|
|
1078
|
-
return tenths === 0 ? String(whole) : `${whole}.${tenths}`
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// 官方整数百分比:0.5 进位;舍入溢出 100 而仍有 miss 时以 99.x 闭式诚实呈现
|
|
1082
|
-
function formatCacheHitPercent(cacheReadTokens, promptTokens, decimalPlaces = 0) {
|
|
1083
|
-
if (promptTokens === 0) return null
|
|
1084
|
-
const missed = promptTokens - cacheReadTokens
|
|
1085
|
-
if (missed === 0) return '100'
|
|
1086
|
-
const roundedUnits = roundedPercentUnits(cacheReadTokens, promptTokens, decimalPlaces)
|
|
1087
|
-
const unitsCeiling = decimalPlaces === 0 ? PERCENT_SCALE : PERCENT_SCALE * PERCENT_TENTH_SCALE
|
|
1088
|
-
if (roundedUnits < unitsCeiling) return displayPercentUnits(roundedUnits, decimalPlaces)
|
|
1089
|
-
let distinguishingPlaces = 1
|
|
1090
|
-
let scaledDoubleGap = missed * PERCENT_GAP_DOUBLE_SCALE
|
|
1091
|
-
const denominatorTens = Math.floor(promptTokens / PERCENT_TENTH_SCALE)
|
|
1092
|
-
while (scaledDoubleGap <= denominatorTens) {
|
|
1093
|
-
scaledDoubleGap *= PERCENT_TENTH_SCALE
|
|
1094
|
-
distinguishingPlaces += 1
|
|
1095
|
-
}
|
|
1096
|
-
const denominatorOnes = promptTokens % PERCENT_TENTH_SCALE
|
|
1097
|
-
let roundedLoss = PERCENT_LOSS_DEFAULT
|
|
1098
|
-
for (let loss = 1; loss <= PERCENT_LOSS_CAP; loss++) {
|
|
1099
|
-
const factor = loss * 2 + 1
|
|
1100
|
-
const threshold = factor * denominatorTens + Math.floor((factor * denominatorOnes) / PERCENT_TENTH_SCALE)
|
|
1101
|
-
if (scaledDoubleGap <= threshold) {
|
|
1102
|
-
roundedLoss = loss
|
|
1103
|
-
break
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
return `99.${'9'.repeat(distinguishingPlaces - 1)}${PERCENT_LOSS_BASE - roundedLoss}`
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
function cacheHitPercent(usage) {
|
|
1110
|
-
return formatCacheHitPercent(usage.cacheReadTokens, billedInputTokens(usage), 0)
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
1038
|
// usp 增强:恒两位小数,有 miss 即 99.99 封顶(镜像官方整数路径的诚实性)
|
|
1114
1039
|
function cacheHitPercentPrecise(usage) {
|
|
1115
1040
|
const billed = billedInputTokens(usage)
|
|
@@ -1148,90 +1073,68 @@ function formatTokensPerSecond(tps) {
|
|
|
1148
1073
|
return String(Math.round(clamped * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL)
|
|
1149
1074
|
}
|
|
1150
1075
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
:
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
if (node
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
}
|
|
1182
|
-
const reading = assistantStepReading(node)
|
|
1183
|
-
if (reading.ttftMs !== null) {
|
|
1184
|
-
stats.ttftMs += reading.ttftMs
|
|
1185
|
-
stats.ttftSteps += 1
|
|
1076
|
+
function buildUsageDialogRows(usage, prefs, t) {
|
|
1077
|
+
if (!usage || billedInputTokens(usage) === 0) return []
|
|
1078
|
+
const rows = []
|
|
1079
|
+
if (prefs.tokenDetail) {
|
|
1080
|
+
rows.push({ key: 'input', label: t('stats.inputTotal'), value: `${formatTokens(billedInputTokens(usage))} tok` })
|
|
1081
|
+
if (usage.cacheWriteTokens > 0) rows.push({ key: 'miss', label: t('stats.missTotal'), value: `${formatTokens(usage.uncachedInputTokens + usage.cacheWriteTokens)} tok` })
|
|
1082
|
+
}
|
|
1083
|
+
return rows
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// 仅改写百分比文本,保留官方节点与事件;卸载时只还原仍由本次增强持有的值。
|
|
1087
|
+
function observeUsageDialog(anchor, document, Observer, onTarget, percent) {
|
|
1088
|
+
let current
|
|
1089
|
+
const changes = new Map()
|
|
1090
|
+
const patch = (node, key, read, write) => {
|
|
1091
|
+
const before = read()
|
|
1092
|
+
if (typeof before !== 'string') return
|
|
1093
|
+
const after = before.replace(/\d+(?:\.\d+)?%/g, `${percent}%`)
|
|
1094
|
+
if (after === before) return
|
|
1095
|
+
let fields = changes.get(node)
|
|
1096
|
+
if (!fields) changes.set(node, fields = new Map())
|
|
1097
|
+
fields.set(key, { before, after, read, write })
|
|
1098
|
+
write(after)
|
|
1099
|
+
}
|
|
1100
|
+
const patchText = (element) => {
|
|
1101
|
+
if (!element || percent === null) return
|
|
1102
|
+
const walker = document.createTreeWalker(element, 4)
|
|
1103
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
1104
|
+
const textNode = node
|
|
1105
|
+
patch(textNode, 'text', () => textNode.nodeValue, (value) => { textNode.nodeValue = value })
|
|
1186
1106
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1107
|
+
}
|
|
1108
|
+
const releaseChanged = (records) => {
|
|
1109
|
+
for (const record of records) {
|
|
1110
|
+
const fields = changes.get(record.target)
|
|
1111
|
+
if (record.type === 'characterData') fields?.delete('text')
|
|
1112
|
+
if (record.type === 'attributes' && record.attributeName === 'aria-label') fields?.delete('aria')
|
|
1190
1113
|
}
|
|
1114
|
+
for (const [node, fields] of changes) if (fields.size === 0 || !document.contains(node)) changes.delete(node)
|
|
1191
1115
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs, t) }))
|
|
1204
|
-
if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs, t) }))
|
|
1205
|
-
if (durations.length > 0) groups.push(durations.join(STATS_ITEM_SEPARATOR))
|
|
1206
|
-
const speeds = []
|
|
1207
|
-
if (stats.ttftSteps > 0) speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps, t) }))
|
|
1208
|
-
if (stats.decodeMs > 0) speeds.push(t('stats.tokensPerSecond', {
|
|
1209
|
-
throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / MS_PER_SECOND)),
|
|
1210
|
-
}))
|
|
1211
|
-
if (speeds.length > 0) groups.push(speeds.join(STATS_ITEM_SEPARATOR))
|
|
1116
|
+
const update = (records = []) => {
|
|
1117
|
+
releaseChanged(records)
|
|
1118
|
+
const pill = anchor?.closest('[data-slot="conversation.composer.dock"]')?.querySelector('[data-composer-stat="usage"]')
|
|
1119
|
+
const button = pill?.querySelector('button')
|
|
1120
|
+
patchText(pill)
|
|
1121
|
+
if (button && percent !== null) patch(button, 'aria', () => button.getAttribute('aria-label'), (value) => button.setAttribute('aria-label', value))
|
|
1122
|
+
const dialogs = document.querySelectorAll('[data-session-stats-usage]')
|
|
1123
|
+
const next = button?.getAttribute('aria-expanded') === 'true' && dialogs.length === 1 ? dialogs[0] : null
|
|
1124
|
+
patchText(next?.querySelector('dd'))
|
|
1125
|
+
observer.takeRecords()
|
|
1126
|
+
if (next !== current) { current = next; onTarget(next) }
|
|
1212
1127
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
hit: formatTokensCompact(usage.cacheReadTokens, t),
|
|
1222
|
-
miss: formatTokensCompact(usage.uncachedInputTokens + usage.cacheWriteTokens, t),
|
|
1223
|
-
output: formatTokensCompact(usage.outputTokens, t),
|
|
1224
|
-
}))
|
|
1225
|
-
} else {
|
|
1226
|
-
groups.push(t('stats.tokens', {
|
|
1227
|
-
input: formatTokensCompact(billedInputTokens(usage), t),
|
|
1228
|
-
output: formatTokensCompact(usage.outputTokens, t),
|
|
1229
|
-
}))
|
|
1128
|
+
const observer = new Observer(update)
|
|
1129
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['aria-expanded', 'aria-label'] })
|
|
1130
|
+
update()
|
|
1131
|
+
return () => {
|
|
1132
|
+
releaseChanged(observer.takeRecords())
|
|
1133
|
+
observer.disconnect()
|
|
1134
|
+
for (const fields of changes.values()) for (const change of fields.values()) {
|
|
1135
|
+
if (change.read() === change.after) change.write(change.before)
|
|
1230
1136
|
}
|
|
1231
|
-
const costItem = buildCostItem(usage, pricingRules, prefs, t)
|
|
1232
|
-
if (costItem !== null) groups.push(costItem)
|
|
1233
1137
|
}
|
|
1234
|
-
return groups
|
|
1235
1138
|
}
|
|
1236
1139
|
|
|
1237
1140
|
// usp stats-line 偏好状态工厂:storage 注入便于 Node 测试,读写全防御,内存值始终生效
|
|
@@ -1949,7 +1852,6 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1949
1852
|
// 遮蔽语义:同 id 同 priority 属注册冲突(注册表抛错),需取更低值压过
|
|
1950
1853
|
// 同格竞争者;usp 同款接管注册用 -1,本插件取次低值,官方无显式 priority(默认 0)
|
|
1951
1854
|
const STATS_SLOT_PRIORITY = -2
|
|
1952
|
-
const STATS_LINE_TITLE_SEPARATOR = ' | '
|
|
1953
1855
|
const VIEW_TABS = [
|
|
1954
1856
|
{ id: 'day', labelKey: 'viewDay' },
|
|
1955
1857
|
{ id: 'hour', labelKey: 'viewHour' },
|
|
@@ -2192,8 +2094,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2192
2094
|
.ud-pricing-actions{display:flex;align-items:center;gap:8px}
|
|
2193
2095
|
.ud-rule-add{border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;cursor:pointer}
|
|
2194
2096
|
.ud-rule-add:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
2195
|
-
|
|
2196
|
-
.ud-statsline-
|
|
2097
|
+
/* dock 内并列条目:官方 pill 满宽不动,空间不足时高收缩因子让自身先收缩(min-width:0 配合 ellipsis),挤压力不转嫁官方 */
|
|
2098
|
+
.ud-statsline-root{text-align:center;max-width:var(--dsh-chat-content-width);box-sizing:border-box;flex:0 999 auto;min-width:0;padding:0;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-overflow:ellipsis;margin:0;margin-left:calc(0px - var(--ud-dock-gap,0px));text-indent:var(--ud-dock-gap,0px);display:block;overflow:hidden}
|
|
2197
2099
|
/* 芯片 portal 至动作行末尾,历史轮悬停/焦点显隐随父级 data-actions-reveal,无需自绘规则 */
|
|
2198
2100
|
.ud-turn-cost{font-size:var(--dsh-content-font-size-secondary,13px);color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;white-space:nowrap;margin-left:${TURN_COST_GAP_PX}px}
|
|
2199
2101
|
`
|
|
@@ -2691,17 +2593,18 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2691
2593
|
h('span', { className: 'ud-switch__thumb' })))
|
|
2692
2594
|
}
|
|
2693
2595
|
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2596
|
+
function useUsageDialogTarget(anchorRef, percent) {
|
|
2597
|
+
const [target, setTarget] = useState(null)
|
|
2598
|
+
useEffect(() => observeUsageDialog(anchorRef.current, document, MutationObserver, setTarget, percent), [percent])
|
|
2599
|
+
return target
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// 官方明细末尾仅追加补充项,底栏保留费用。
|
|
2603
|
+
const StatsLineAppended = React.memo(function StatsLineAppended({ useProjection, t = defaultT }) {
|
|
2604
|
+
if (typeof useProjection !== 'function') return null
|
|
2698
2605
|
const usage = useProjection('tokenUsage')
|
|
2699
|
-
const projected = useProjection('sessionStats')
|
|
2700
|
-
const settledNodes = useChat((state) => state.legacy.nodes)
|
|
2701
|
-
const stats = useMemo(() => projected ?? deriveStats(settledNodes ?? []), [projected, settledNodes])
|
|
2702
2606
|
const [prefs, setPrefs] = useState(() => statsLineState.get())
|
|
2703
2607
|
useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
|
|
2704
|
-
// 价格异步首帧可能未回:回包经状态刷新补渲染,未回期间费用组不渲染
|
|
2705
2608
|
const [pricingRules, setPricingRules] = useState(null)
|
|
2706
2609
|
useEffect(() => {
|
|
2707
2610
|
let alive = true
|
|
@@ -2710,31 +2613,28 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
2710
2613
|
})
|
|
2711
2614
|
return () => { alive = false }
|
|
2712
2615
|
}, [])
|
|
2713
|
-
const
|
|
2714
|
-
const
|
|
2715
|
-
const entries = groups.map((text) => ({ text }))
|
|
2716
|
-
if (costItem !== null && groups[groups.length - 1] === costItem) {
|
|
2717
|
-
entries[entries.length - 1] = { text: costItem, title: t('statsCostTitle') }
|
|
2718
|
-
}
|
|
2719
|
-
const rootRef = useRef(null)
|
|
2720
|
-
const [truncated, setTruncated] = useState(false)
|
|
2616
|
+
const anchorRef = useRef(null)
|
|
2617
|
+
const costRef = useRef(null)
|
|
2721
2618
|
useLayoutEffect(() => {
|
|
2722
|
-
const element =
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2619
|
+
const element = costRef.current
|
|
2620
|
+
const dock = anchorRef.current?.closest('[data-slot="conversation.composer.dock"]')?.parentElement
|
|
2621
|
+
if (element && dock) element.style.setProperty('--ud-dock-gap', getComputedStyle(dock).columnGap)
|
|
2622
|
+
})
|
|
2623
|
+
const percent = prefs.cachePrecision && usage ? cacheHitPercentPrecise(usage) : null
|
|
2624
|
+
const target = useUsageDialogTarget(anchorRef, percent)
|
|
2625
|
+
const rows = buildUsageDialogRows(usage, prefs, t)
|
|
2626
|
+
const costItem = usage && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
|
|
2627
|
+
? buildCostItem(usage, pricingRules, prefs, t)
|
|
2628
|
+
: null
|
|
2629
|
+
const details = target && createPortal && rows.length > 0
|
|
2630
|
+
? createPortal(rows.map((row) => h(React.Fragment, { key: row.key },
|
|
2631
|
+
h('dt', { 'data-ud-usage-detail': row.key }, row.label),
|
|
2632
|
+
h('dd', { 'data-ud-usage-detail': row.key }, row.value))), target)
|
|
2633
|
+
: null
|
|
2634
|
+
return h(React.Fragment, null,
|
|
2635
|
+
h('span', { ref: anchorRef, hidden: true }),
|
|
2636
|
+
costItem !== null && h('div', { ref: costRef, className: 'ud-statsline-root', title: t('statsCostTitle') }, costItem),
|
|
2637
|
+
details)
|
|
2738
2638
|
})
|
|
2739
2639
|
|
|
2740
2640
|
// 注入点B 组件:回合费用芯片,官方动作行内渲染(复制与分支图标之间,赞踩/上下文跳转同排);
|
|
@@ -3550,12 +3450,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
|
|
|
3550
3450
|
{ name: 'settings.section', id: 'usage-dash', order: 45, label: () => ctx.locale.bind(LOCALE_NS)('nav'), locale: LOCALE_NS },
|
|
3551
3451
|
UsageDashPanel,
|
|
3552
3452
|
))
|
|
3553
|
-
//
|
|
3453
|
+
// 两段式注册官方 dock 槽增强条目:宿主缺该插槽时注册抛错即禁用本功能;
|
|
3454
|
+
// order 2 排官方 activity(0)/usage(1) 之后,append 在官方数据后面
|
|
3554
3455
|
try {
|
|
3555
3456
|
ctx.slots.inject('conversation.composer.dock', () =>
|
|
3556
3457
|
ctx.slots.register(
|
|
3557
|
-
{ name: 'conversation.composer.dock', id: 'stats', order:
|
|
3558
|
-
|
|
3458
|
+
{ name: 'conversation.composer.dock', id: 'stats', order: 2, priority: STATS_SLOT_PRIORITY, locale: LOCALE_NS },
|
|
3459
|
+
StatsLineAppended,
|
|
3559
3460
|
))
|
|
3560
3461
|
} catch (error) {
|
|
3561
3462
|
console.warn('[usage-dash] 底部信息栏未注册(宿主无 conversation.composer.dock 插槽)', error)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
|
|
4
|
+
|
|
5
|
+
const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
|
|
6
|
+
const usage = { uncachedInputTokens: 1000, cacheReadTokens: 4000, cacheWriteTokens: 0, outputTokens: 500 }
|
|
7
|
+
const on = { cachePrecision: true, tokenDetail: true, costDisplay: true }
|
|
8
|
+
const rules = [{ model: '*/*', currency: '¥', price: { input: 2, cacheRead: 0, cacheWrite: 0, output: 0 }, conditions: [] }]
|
|
9
|
+
|
|
10
|
+
function render(options = {}) {
|
|
11
|
+
const { prefs = on, lang = 'zh', pricing = rules, target = {} } = options
|
|
12
|
+
const value = Object.hasOwn(options, 'value') ? options.value : usage
|
|
13
|
+
const source = CLIENT_BODY.match(/const StatsLineAppended = React\.memo\(([\s\S]*?)\n \}\)/)?.[1] + '\n}'
|
|
14
|
+
let stateIndex = 0
|
|
15
|
+
const React = { Fragment: 'fragment', useId: () => ':test:' }
|
|
16
|
+
const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity).filter((x) => x != null && x !== false) })
|
|
17
|
+
const useState = (init) => [stateIndex++ === 1 ? pricing : typeof init === 'function' ? init() : init, () => {}]
|
|
18
|
+
const noop = () => {}
|
|
19
|
+
const names = Object.keys(core)
|
|
20
|
+
const component = new Function(...names, 'React', 'h', 'useState', 'useEffect', 'useLayoutEffect', 'useRef', 'statsLineState', 'defaultT', 'fetchPricing', 'useUsageDialogTarget', 'createPortal', `return (${source})`)(...Object.values(core), React, h, useState, noop, noop, () => ({ current: null }), { get: () => prefs }, core.createTranslator(core.MESSAGES_ZH), noop, () => target, (content, container) => ({ type: 'portal', props: { container }, children: [content].flat(Infinity) }))
|
|
21
|
+
return component({ useProjection: () => value, t: core.createTranslator(lang === 'en' ? core.MESSAGES_EN : core.MESSAGES_ZH) })
|
|
22
|
+
}
|
|
23
|
+
const nodes = (tree) => tree && typeof tree === 'object' ? [tree, ...tree.children.flatMap(nodes)] : []
|
|
24
|
+
const text = (tree) => typeof tree === 'string' ? tree : tree?.children.map(text).join('') ?? ''
|
|
25
|
+
const inlineText = (tree) => tree?.type === 'portal' ? '' : typeof tree === 'string' ? tree : tree?.children.map(inlineText).join('') ?? ''
|
|
26
|
+
|
|
27
|
+
test('Given 旧三开偏好 When 渲染 Then 底栏只有费用,补充项追加到官方弹窗', () => {
|
|
28
|
+
const target = {}
|
|
29
|
+
const tree = render({ target })
|
|
30
|
+
assert.equal(inlineText(tree), '费用 ≈ ¥0.0020')
|
|
31
|
+
assert.equal(nodes(tree).filter((n) => n.type === 'button' || n.props.popover).length, 0)
|
|
32
|
+
const portal = nodes(tree).find((n) => n.type === 'portal')
|
|
33
|
+
assert.equal(portal.props.container, target)
|
|
34
|
+
assert.doesNotMatch(text(portal), /命中率/)
|
|
35
|
+
assert.match(text(portal), /输入合计(含缓存)5,000 tok/)
|
|
36
|
+
assert.doesNotMatch(text(portal), /总 |输出|未命中缓存|命中缓存/)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('Given 英文与费用关闭 When 渲染 Then 仅向官方弹窗追加英文补充项', () => {
|
|
40
|
+
const tree = render({ prefs: { ...on, costDisplay: false }, lang: 'en' })
|
|
41
|
+
assert.equal(inlineText(tree), '')
|
|
42
|
+
assert.doesNotMatch(text(tree), /cache-hit/)
|
|
43
|
+
assert.match(text(tree), /Total input \(including cache\)5,000 tok/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('Given 官方弹窗未打开 When 渲染 Then 没有额外入口或补充节点', () => {
|
|
47
|
+
const tree = render({ target: null })
|
|
48
|
+
assert.equal(text(tree), '费用 ≈ ¥0.0020')
|
|
49
|
+
assert.equal(nodes(tree).filter((n) => n.type === 'portal').length, 0)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('Given 明细开关全关 When 渲染 Then 只显示费用', () => {
|
|
53
|
+
const tree = render({ prefs: { cachePrecision: false, tokenDetail: false, costDisplay: true } })
|
|
54
|
+
assert.equal(text(tree), '费用 ≈ ¥0.0020')
|
|
55
|
+
assert.equal(nodes(tree).filter((n) => n.type === 'portal').length, 0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('Given 空用量或全关 When 渲染 Then 不留下空条目', () => {
|
|
59
|
+
for (const value of [undefined, { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }]) assert.equal(text(render({ value })), '')
|
|
60
|
+
assert.equal(text(render({ prefs: { cachePrecision: false, tokenDetail: false, costDisplay: false } })), '')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('Given 只有输出且费用未加载 When 渲染 Then 官方已有输出,不追加零值明细', () => {
|
|
64
|
+
assert.equal(text(render({ value: { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 500 }, pricing: null })), '')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('Given 有缓存写入 When 渲染 Then 追加合计未命中量且两开关独立', () => {
|
|
68
|
+
const value = { ...usage, cacheWriteTokens: 2000 }
|
|
69
|
+
const detailOnly = render({ value, prefs: { cachePrecision: false, tokenDetail: true, costDisplay: false } })
|
|
70
|
+
assert.match(text(detailOnly), /输入合计(含缓存)7,000 tok/)
|
|
71
|
+
assert.match(text(detailOnly), /未命中合计(含缓存写入)3,000 tok/)
|
|
72
|
+
assert.doesNotMatch(text(detailOnly), /命中率/)
|
|
73
|
+
const precisionOnly = render({ value, prefs: { cachePrecision: true, tokenDetail: false, costDisplay: false } })
|
|
74
|
+
assert.equal(text(precisionOnly), '')
|
|
75
|
+
})
|
package/test/stats-line.test.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 底部信息栏 append 条目纯函数层测试:官方 pill 数据后追加增强项 + usp 双开关状态
|
|
2
2
|
// Given/When/Then 场景内嵌于用例描述
|
|
3
3
|
// client.js 为经典 script bundle(禁 import/export),整文件 IIFE 书挡;经 client-eval 剥壳后整源求值,按顶层声明名收集
|
|
4
4
|
import { test } from 'node:test'
|
|
@@ -12,24 +12,16 @@ const {
|
|
|
12
12
|
MESSAGES_EN,
|
|
13
13
|
MESSAGES_ZH,
|
|
14
14
|
billedInputTokens,
|
|
15
|
-
roundedPercentUnits,
|
|
16
|
-
displayPercentUnits,
|
|
17
|
-
formatCacheHitPercent,
|
|
18
|
-
cacheHitPercent,
|
|
19
15
|
cacheHitPercentPrecise,
|
|
20
16
|
formatTokensCompact,
|
|
21
17
|
formatDuration,
|
|
22
18
|
formatTokensPerSecond,
|
|
23
|
-
assistantStepReading,
|
|
24
|
-
deriveStats,
|
|
25
|
-
buildStatsGroups,
|
|
26
19
|
buildCostItem,
|
|
27
20
|
createStatsLineState,
|
|
28
21
|
createTranslator,
|
|
29
22
|
} = core
|
|
30
23
|
|
|
31
24
|
const USAGE = { uncachedInputTokens: 1000, cacheReadTokens: 4000, cacheWriteTokens: 0, outputTokens: 500 }
|
|
32
|
-
const STATS = { turns: 2, steps: 3, llmMs: 1500, toolMs: 2000, ttftMs: 200, ttftSteps: 1, decodeMs: 800, decodeTokens: 50 }
|
|
33
25
|
const PREFS_OFF = { cachePrecision: false, tokenDetail: false, costDisplay: false }
|
|
34
26
|
const PREFS_COST_ON = { cachePrecision: false, tokenDetail: false, costDisplay: true }
|
|
35
27
|
const zhT = createTranslator(MESSAGES_ZH)
|
|
@@ -54,20 +46,13 @@ function fakeStorage(initial = {}) {
|
|
|
54
46
|
}
|
|
55
47
|
}
|
|
56
48
|
|
|
57
|
-
test('stats
|
|
49
|
+
test('stats 词典关键值逐字节,en 族逐字节', () => {
|
|
58
50
|
assert.equal(STATS_LINE_STORAGE_KEY, 'dsh-usage-dash:stats-line')
|
|
59
|
-
assert.equal(MESSAGES_ZH['stats.counts'], '{turns} 轮 · {steps} 步')
|
|
60
|
-
assert.equal(MESSAGES_ZH['stats.llm'], 'LLM {duration}')
|
|
61
|
-
assert.equal(MESSAGES_ZH['stats.toolCall'], '工具调用 {duration}')
|
|
62
|
-
assert.equal(MESSAGES_ZH['stats.ttftAverage'], '首 token 平均 {duration}')
|
|
63
|
-
assert.equal(MESSAGES_ZH['stats.tokensPerSecond'], '{throughput} tok/s')
|
|
64
|
-
assert.equal(MESSAGES_ZH['stats.cacheHit'], '缓存命中 {percent}%')
|
|
65
51
|
assert.equal(MESSAGES_ZH['stats.tokens'], '输入 {input} tok · 输出 {output} tok')
|
|
66
|
-
assert.equal(MESSAGES_ZH['stats.tokensDetail'], '总 {total} tok · 输入 {input} tok · 命中缓存 {hit} tok · 未命中缓存 {miss} tok · 输出 {output} tok')
|
|
67
52
|
assert.equal(MESSAGES_ZH.cachePrecision, '精确缓存命中率')
|
|
68
|
-
assert.equal(MESSAGES_ZH.cachePrecisionDesc, '
|
|
53
|
+
assert.equal(MESSAGES_ZH.cachePrecisionDesc, '将官方用量胶囊及弹窗中的命中率显示为两位小数。')
|
|
69
54
|
assert.equal(MESSAGES_ZH.tokenDetail, '会话 Token 明细')
|
|
70
|
-
assert.equal(MESSAGES_ZH.tokenDetailDesc, '
|
|
55
|
+
assert.equal(MESSAGES_ZH.tokenDetailDesc, '在官方 Token 用量弹窗末尾追加含缓存输入合计;存在缓存写入时追加未命中合计。')
|
|
71
56
|
assert.equal(MESSAGES_ZH['stats.cost'], '费用 ≈ {cost}')
|
|
72
57
|
assert.equal(MESSAGES_ZH.costDisplay, '费用显示')
|
|
73
58
|
assert.equal(MESSAGES_EN['stats.cost'], 'Cost ≈ {cost}')
|
|
@@ -76,14 +61,6 @@ test('stats 词典 zh 关键值逐字节对齐官方,en 族逐字节取官方 ch
|
|
|
76
61
|
assert.equal(MESSAGES_ZH['duration.compactMinutes'], '{minutes}分{seconds}秒')
|
|
77
62
|
assert.equal(MESSAGES_ZH['number.thousand'], '{value}K')
|
|
78
63
|
assert.equal(MESSAGES_ZH['number.million'], '{value}M')
|
|
79
|
-
assert.equal(MESSAGES_EN['stats.counts'], '{turns} turns · {steps} steps')
|
|
80
|
-
assert.equal(MESSAGES_EN['stats.llm'], 'LLM {duration}')
|
|
81
|
-
assert.equal(MESSAGES_EN['stats.toolCall'], 'Tool call {duration}')
|
|
82
|
-
assert.equal(MESSAGES_EN['stats.ttftAverage'], 'TTFT avg {duration}')
|
|
83
|
-
assert.equal(MESSAGES_EN['stats.tokensPerSecond'], '{throughput} tok/s')
|
|
84
|
-
assert.equal(MESSAGES_EN['stats.cacheHit'], 'Cache hit {percent}%')
|
|
85
|
-
assert.equal(MESSAGES_EN['stats.tokens'], 'Input {input} tok · Output {output} tok')
|
|
86
|
-
assert.equal(MESSAGES_EN['stats.tokensDetail'], 'Total {total} tok · Input {input} tok · Cache hit {hit} tok · Cache miss {miss} tok · Output {output} tok')
|
|
87
64
|
assert.equal(MESSAGES_EN['duration.compactSeconds'], '{seconds}s')
|
|
88
65
|
assert.equal(MESSAGES_EN['duration.compactMinutes'], '{minutes}m{seconds}s')
|
|
89
66
|
assert.equal(MESSAGES_EN['number.thousand'], '{value}K')
|
|
@@ -95,46 +72,6 @@ test('billedInputTokens 为三个互斥 prompt 桶之和', () => {
|
|
|
95
72
|
assert.equal(billedInputTokens({ uncachedInputTokens: 100, cacheReadTokens: 200, cacheWriteTokens: 300, outputTokens: 0 }), 600)
|
|
96
73
|
})
|
|
97
74
|
|
|
98
|
-
test('roundedPercentUnits 整数位 0.5 进位', () => {
|
|
99
|
-
// 125*100/200 = 62.5 → 63(0.5 进位,非银行家舍入)
|
|
100
|
-
assert.equal(roundedPercentUnits(125, 200, 0), 63)
|
|
101
|
-
assert.equal(roundedPercentUnits(75, 100, 0), 75)
|
|
102
|
-
assert.equal(roundedPercentUnits(999946, 1000000, 0), 100)
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
test('roundedPercentUnits 十分位单位 0.5 进位', () => {
|
|
106
|
-
// 755*1000/1000 = 755 个十分位 → 755('75.5')
|
|
107
|
-
assert.equal(roundedPercentUnits(755, 1000, 1), 755)
|
|
108
|
-
assert.equal(roundedPercentUnits(125, 200, 1), 625)
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
test('displayPercentUnits 按小数位渲染,十分位为 0 时省略小数点', () => {
|
|
112
|
-
assert.equal(displayPercentUnits(75, 0), '75')
|
|
113
|
-
assert.equal(displayPercentUnits(805, 1), '80.5')
|
|
114
|
-
assert.equal(displayPercentUnits(800, 1), '80')
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('formatCacheHitPercent 无分母为 null,无 miss 为 100', () => {
|
|
118
|
-
assert.equal(formatCacheHitPercent(100, 0, 0), null)
|
|
119
|
-
assert.equal(formatCacheHitPercent(100, 100, 0), '100')
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
test('formatCacheHitPercent 整数位四舍五入', () => {
|
|
123
|
-
assert.equal(formatCacheHitPercent(75, 100), '75')
|
|
124
|
-
assert.equal(formatCacheHitPercent(125, 200), '63')
|
|
125
|
-
assert.equal(formatCacheHitPercent(52, 100), '52')
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
test('formatCacheHitPercent 溢出 100 时闭式构造 99.x', () => {
|
|
129
|
-
assert.equal(formatCacheHitPercent(999946, 1000000), '99.99')
|
|
130
|
-
assert.equal(formatCacheHitPercent(99995, 100000), '99.995')
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
test('cacheHitPercent 官方整数口径与空数据 null', () => {
|
|
134
|
-
assert.equal(cacheHitPercent(USAGE), '80')
|
|
135
|
-
assert.equal(cacheHitPercent({ uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 500 }), null)
|
|
136
|
-
})
|
|
137
|
-
|
|
138
75
|
test('cacheHitPercentPrecise 恒两位小数且 99.99 封顶', () => {
|
|
139
76
|
assert.equal(cacheHitPercentPrecise({ uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }), null)
|
|
140
77
|
assert.equal(cacheHitPercentPrecise({ uncachedInputTokens: 0, cacheReadTokens: 8000, cacheWriteTokens: 0, outputTokens: 10 }), '100.00')
|
|
@@ -163,123 +100,6 @@ test('formatTokensPerSecond 钳负值且阈值上下分别取整', () => {
|
|
|
163
100
|
assert.equal(formatTokensPerSecond(3.24), '3.2')
|
|
164
101
|
})
|
|
165
102
|
|
|
166
|
-
test('assistantStepReading 时间戳齐全时产出 TTFT/decode/输出 token', () => {
|
|
167
|
-
assert.deepEqual(
|
|
168
|
-
assistantStepReading({ timing: { stepStartTime: 1000, firstTokenTime: 1200, completedTime: 2000 }, usage: 50 }),
|
|
169
|
-
{ ttftMs: 200, decodeMs: 800, outputTokens: 50 },
|
|
170
|
-
)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
test('assistantStepReading 负时长钳 0 且非法 usage 为 null', () => {
|
|
174
|
-
// completedTime 早于 firstTokenTime → decode 钳 0
|
|
175
|
-
assert.deepEqual(
|
|
176
|
-
assistantStepReading({ timing: { stepStartTime: 1000, firstTokenTime: 2000, completedTime: 1500 }, usage: 30 }),
|
|
177
|
-
{ ttftMs: 1000, decodeMs: 0, outputTokens: 30 },
|
|
178
|
-
)
|
|
179
|
-
assert.equal(assistantStepReading({ timing: { stepStartTime: 0, firstTokenTime: 100, completedTime: 200 }, usage: 'oops' }).outputTokens, null)
|
|
180
|
-
assert.equal(assistantStepReading({ timing: { stepStartTime: 0, firstTokenTime: 100, completedTime: 200 }, usage: -1 }).outputTokens, null)
|
|
181
|
-
assert.equal(assistantStepReading({ timing: { stepStartTime: 0, firstTokenTime: 100, completedTime: 200 }, usage: Number.NaN }).outputTokens, null)
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
test('assistantStepReading 无 timing 时读数双 null', () => {
|
|
185
|
-
assert.deepEqual(assistantStepReading({ usage: 50 }), { ttftMs: null, decodeMs: null, outputTokens: 50 })
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
test('deriveStats 折叠窗口节点:轮次去重/负值钳制/读数按有值步聚合', () => {
|
|
189
|
-
// When:混合 tool-result 与 assistant(含 turn 重复、stepStart null、firstToken null、非法 usage)
|
|
190
|
-
const nodes = [
|
|
191
|
-
{ kind: 'user' },
|
|
192
|
-
{ kind: 'tool-result', time: 5000, callTime: 3000 },
|
|
193
|
-
{ kind: 'tool-result', time: 1000, callTime: 3000 },
|
|
194
|
-
{ kind: 'tool-result', time: 9000, callTime: null },
|
|
195
|
-
{ kind: 'assistant', turn: 1, timing: { stepStartTime: 1000, firstTokenTime: 1200, completedTime: 2000 }, usage: 50 },
|
|
196
|
-
{ kind: 'assistant', turn: 1, timing: { stepStartTime: null, firstTokenTime: 1300, completedTime: 2100 }, usage: 10 },
|
|
197
|
-
{ kind: 'assistant', turn: 2, timing: { stepStartTime: 3000, firstTokenTime: null, completedTime: 3500 }, usage: 30 },
|
|
198
|
-
{ kind: 'assistant', turn: 3, timing: { stepStartTime: 4000, firstTokenTime: 4100, completedTime: 4600 }, usage: 'oops' },
|
|
199
|
-
]
|
|
200
|
-
// Then:llm 1000+0+500+600;ttft 分母=有值 2 步;decode 只算时间戳与 usage 双全的步
|
|
201
|
-
assert.deepEqual(deriveStats(nodes), {
|
|
202
|
-
turns: 3,
|
|
203
|
-
steps: 4,
|
|
204
|
-
llmMs: 2100,
|
|
205
|
-
toolMs: 2000,
|
|
206
|
-
ttftMs: 300,
|
|
207
|
-
ttftSteps: 2,
|
|
208
|
-
decodeMs: 1600,
|
|
209
|
-
decodeTokens: 60,
|
|
210
|
-
})
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
test('deriveStats 空节点为零值统计', () => {
|
|
214
|
-
assert.deepEqual(deriveStats([]), { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 })
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
test('buildStatsGroups 双开关全关与官方行逐字节一致', () => {
|
|
218
|
-
assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_OFF, zhT), [
|
|
219
|
-
'2 轮 · 3 步',
|
|
220
|
-
'LLM 1.5秒 · 工具调用 2秒',
|
|
221
|
-
'首 token 平均 0.2秒 · 63 tok/s',
|
|
222
|
-
'缓存命中 80%',
|
|
223
|
-
'输入 5K tok · 输出 500 tok',
|
|
224
|
-
])
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
test('buildStatsGroups 传 en 翻译器输出官方 en 口径逐字节', () => {
|
|
228
|
-
assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_OFF, enT), [
|
|
229
|
-
'2 turns · 3 steps',
|
|
230
|
-
'LLM 1.5s · Tool call 2s',
|
|
231
|
-
'TTFT avg 0.2s · 63 tok/s',
|
|
232
|
-
'Cache hit 80%',
|
|
233
|
-
'Input 5K tok · Output 500 tok',
|
|
234
|
-
])
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
test('buildStatsGroups en 零命中率呈现 Cache hit 0%', () => {
|
|
238
|
-
const zeroHitStats = { turns: 2, steps: 3, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
|
|
239
|
-
const zeroHitUsage = { uncachedInputTokens: 4000, cacheReadTokens: 0, cacheWriteTokens: 1000, outputTokens: 0 }
|
|
240
|
-
assert.deepEqual(buildStatsGroups(zeroHitStats, zeroHitUsage, PREFS_OFF, enT), [
|
|
241
|
-
'2 turns · 3 steps',
|
|
242
|
-
'Cache hit 0%',
|
|
243
|
-
'Input 5K tok · Output 0 tok',
|
|
244
|
-
])
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
test('buildStatsGroups tokenDetail 开关切换五项明细组', () => {
|
|
248
|
-
const groups = buildStatsGroups(STATS, USAGE, { cachePrecision: false, tokenDetail: true }, zhT)
|
|
249
|
-
assert.deepEqual(groups.slice(0, 4), [
|
|
250
|
-
'2 轮 · 3 步',
|
|
251
|
-
'LLM 1.5秒 · 工具调用 2秒',
|
|
252
|
-
'首 token 平均 0.2秒 · 63 tok/s',
|
|
253
|
-
'缓存命中 80%',
|
|
254
|
-
])
|
|
255
|
-
assert.equal(groups[4], '总 5.5K tok · 输入 5K tok · 命中缓存 4K tok · 未命中缓存 1K tok · 输出 500 tok')
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
test('buildStatsGroups cachePrecision 开关切精确命中率', () => {
|
|
259
|
-
const groups = buildStatsGroups(STATS, USAGE, { cachePrecision: true, tokenDetail: false }, zhT)
|
|
260
|
-
assert.equal(groups[3], '缓存命中 80.00%')
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
test('buildStatsGroups usage 缺席时命中与 Token 组整体缺席', () => {
|
|
264
|
-
assert.deepEqual(buildStatsGroups(STATS, undefined, PREFS_OFF, zhT), [
|
|
265
|
-
'2 轮 · 3 步',
|
|
266
|
-
'LLM 1.5秒 · 工具调用 2秒',
|
|
267
|
-
'首 token 平均 0.2秒 · 63 tok/s',
|
|
268
|
-
])
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
test('buildStatsGroups 无步进且用量空为空数组', () => {
|
|
272
|
-
const zeroStats = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
|
|
273
|
-
const zeroUsage = { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
|
|
274
|
-
assert.deepEqual(buildStatsGroups(zeroStats, zeroUsage, PREFS_OFF, zhT), [])
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
test('buildStatsGroups 仅输出时命中率组因 null 丢弃', () => {
|
|
278
|
-
const zeroStats = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
|
|
279
|
-
const usage = { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 500 }
|
|
280
|
-
assert.deepEqual(buildStatsGroups(zeroStats, usage, PREFS_OFF, zhT), ['输入 0 tok · 输出 500 tok'])
|
|
281
|
-
})
|
|
282
|
-
|
|
283
103
|
test('createStatsLineState 默认三开,非法 JSON 回落三开,显式 false 才视为关', () => {
|
|
284
104
|
assert.deepEqual(createStatsLineState(fakeStorage()).get(), { cachePrecision: true, tokenDetail: true, costDisplay: true })
|
|
285
105
|
assert.deepEqual(createStatsLineState(null).get(), { cachePrecision: true, tokenDetail: true, costDisplay: true })
|
|
@@ -334,15 +154,8 @@ test('createStatsLineState 写入失败仅丢持久化,内存仍生效', () => {
|
|
|
334
154
|
|
|
335
155
|
// —— S14 费用组(buildCostItem) ——
|
|
336
156
|
|
|
337
|
-
test('buildCostItem 开关关恒为 null
|
|
157
|
+
test('buildCostItem 开关关恒为 null', () => {
|
|
338
158
|
assert.equal(buildCostItem(COST_USAGE, COST_RULES, PREFS_OFF, zhT, COST_NOW), null)
|
|
339
|
-
assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_OFF, zhT, COST_RULES), [
|
|
340
|
-
'2 轮 · 3 步',
|
|
341
|
-
'LLM 1.5秒 · 工具调用 2秒',
|
|
342
|
-
'首 token 平均 0.2秒 · 63 tok/s',
|
|
343
|
-
'缓存命中 80%',
|
|
344
|
-
'输入 5K tok · 输出 500 tok',
|
|
345
|
-
])
|
|
346
159
|
})
|
|
347
160
|
|
|
348
161
|
test('buildCostItem routes 缺席按全通配规则匹配,符号取全局口径(首个非空货币)', () => {
|
|
@@ -376,24 +189,22 @@ test('buildCostItem usage 缺席为 null', () => {
|
|
|
376
189
|
assert.equal(buildCostItem(undefined, COST_RULES, PREFS_COST_ON, zhT, COST_NOW), null)
|
|
377
190
|
})
|
|
378
191
|
|
|
379
|
-
test('
|
|
380
|
-
|
|
381
|
-
assert.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
])
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
assert.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
'输入 5K tok · 输出 500 tok',
|
|
398
|
-
])
|
|
192
|
+
test('dock 注册 append 契约:order 排官方 pill(activity 0/usage 1)之后,条目只消费 useProjection', () => {
|
|
193
|
+
assert.ok(CLIENT_BODY.includes("id: 'stats', order: 2, priority: STATS_SLOT_PRIORITY"))
|
|
194
|
+
assert.ok(CLIENT_BODY.includes('StatsLineAppended({ useProjection, t = defaultT })'))
|
|
195
|
+
assert.ok(!CLIENT_BODY.includes("useProjection('sessionStats')"))
|
|
196
|
+
assert.ok(!CLIENT_BODY.includes('deriveStats'))
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('dock append 条目布局契约:官方 pill 满宽不受挤压,空间不足时增强条目先让位', () => {
|
|
200
|
+
// 失效方式:回退为整行 width:100% 或去掉让位收缩/截断,官方胶囊被压缩或被溢出文本遮挡(独立环境实测 147→96px)
|
|
201
|
+
const rule = CLIENT_BODY.match(/\.ud-statsline-root\{[^}]*\}/)?.[0]
|
|
202
|
+
assert.ok(rule, '存在 .ud-statsline-root 规则')
|
|
203
|
+
assert.ok(!/(^|[^-a-z])width:100%/.test(rule), '不再整行占满,dock 内为并列条目')
|
|
204
|
+
assert.ok(!rule.includes('--dsh-composer-side-clearance'), '不再沿用整行侧边距')
|
|
205
|
+
assert.ok(rule.includes('flex:0 999 auto'), '高收缩因子锁定,空间不足时先收缩自身,挤压亏损不由官方 pill 承担')
|
|
206
|
+
assert.ok(rule.includes('padding:0;'), '费用收缩到零时不留下固定内边距挤压官方胶囊')
|
|
207
|
+
assert.ok(rule.includes('margin-left:calc(0px - var(--ud-dock-gap,0px))') && rule.includes('text-indent:var(--ud-dock-gap,0px)'), '抵消新增 flex gap,文字缩进提供可收缩间距')
|
|
208
|
+
assert.ok(rule.includes('min-width:0'), '允许收缩到零')
|
|
209
|
+
assert.ok(rule.includes('overflow:hidden') && rule.includes('white-space:nowrap') && rule.includes('text-overflow:ellipsis'), '收缩残态截断三件套在位,溢出不遮挡官方 pill')
|
|
399
210
|
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
|
|
4
|
+
const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
|
|
5
|
+
|
|
6
|
+
function fixture() {
|
|
7
|
+
const label = { nodeValue: '缓存命中 80%' }
|
|
8
|
+
const other = { nodeValue: '5.5K tok' }
|
|
9
|
+
const value = { nodeValue: '80%' }
|
|
10
|
+
const button = { aria: '5.5K tok · 缓存命中 80%', expanded: 'false', getAttribute(name) { return name === 'aria-expanded' ? this.expanded : this.aria }, setAttribute(name, v) { this.aria = v } }
|
|
11
|
+
const pill = { querySelector: () => button, texts: [other, label] }
|
|
12
|
+
const dock = { querySelector: () => pill }
|
|
13
|
+
const anchor = { closest: () => dock }
|
|
14
|
+
const dd = { texts: [value] }
|
|
15
|
+
const dialog = { querySelector: () => dd }
|
|
16
|
+
let dialogs = []
|
|
17
|
+
let callback
|
|
18
|
+
let disconnected = false
|
|
19
|
+
let pending = []
|
|
20
|
+
const document = { body: {}, contains: (node) => !node.detached, querySelectorAll: () => dialogs, createTreeWalker(node) { let i = 0; return { nextNode: () => node.texts[i++] ?? null } } }
|
|
21
|
+
class Observer { constructor(cb) { callback = cb } observe() {} takeRecords() { const records = pending; pending = []; return records } disconnect() { disconnected = true } }
|
|
22
|
+
return { label, other, value, button, anchor, dialog, document, Observer, notify: (records = []) => callback(records), setDialogs: (v) => { dialogs = v }, setPending: (v) => { pending = v }, disconnected: () => disconnected }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('Given 官方命中率 When 精度增强 Then 原位替换文本及 aria,关闭时还原,不增加精度行', () => {
|
|
26
|
+
const f = fixture(), seen = []
|
|
27
|
+
const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, (v) => seen.push(v), '80.00')
|
|
28
|
+
assert.equal(f.label.nodeValue, '缓存命中 80.00%')
|
|
29
|
+
assert.equal(f.button.aria, '5.5K tok · 缓存命中 80.00%')
|
|
30
|
+
assert.equal(f.other.nodeValue, '5.5K tok')
|
|
31
|
+
f.button.expanded = 'true'; f.setDialogs([f.dialog]); f.notify([{ type: 'attributes', attributeName: 'aria-expanded', target: f.button }])
|
|
32
|
+
assert.equal(f.value.nodeValue, '80.00%')
|
|
33
|
+
f.notify()
|
|
34
|
+
assert.deepEqual(seen, [null, f.dialog])
|
|
35
|
+
stop()
|
|
36
|
+
assert.equal(f.disconnected(), true)
|
|
37
|
+
assert.equal(f.label.nodeValue, '缓存命中 80%')
|
|
38
|
+
assert.equal(f.button.aria, '5.5K tok · 缓存命中 80%')
|
|
39
|
+
assert.equal(f.value.nodeValue, '80%')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('Given 官方重渲染和重建弹窗 When 观察变更 Then 更新读数且清理不覆盖官方新值', () => {
|
|
43
|
+
const f = fixture(), seen = []
|
|
44
|
+
const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, (v) => seen.push(v), '80.00')
|
|
45
|
+
f.label.nodeValue = 'Cache hit 81%'; f.button.aria = '5.5K tok · Cache hit 81%'; f.notify()
|
|
46
|
+
assert.equal(f.label.nodeValue, 'Cache hit 80.00%')
|
|
47
|
+
f.label.nodeValue = 'Cache hit 82%'
|
|
48
|
+
f.button.expanded = 'true'; f.setDialogs([f.dialog]); f.notify()
|
|
49
|
+
f.setDialogs([]); f.notify()
|
|
50
|
+
f.setDialogs([f.dialog]); f.notify()
|
|
51
|
+
assert.deepEqual(seen, [null, f.dialog, null, f.dialog])
|
|
52
|
+
f.label.nodeValue = 'Cache hit 83%'
|
|
53
|
+
stop()
|
|
54
|
+
assert.equal(f.label.nodeValue, 'Cache hit 83%')
|
|
55
|
+
assert.equal(f.button.aria, '5.5K tok · Cache hit 81%')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('Given 另一窗格用量弹窗已打开 When 本胶囊未展开 Then 不注入也不修改别窗格读数', () => {
|
|
59
|
+
const f = fixture(), seen = []
|
|
60
|
+
f.setDialogs([f.dialog])
|
|
61
|
+
const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, (v) => seen.push(v), '80.00')
|
|
62
|
+
assert.deepEqual(seen, [null])
|
|
63
|
+
assert.equal(f.value.nodeValue, '80%')
|
|
64
|
+
f.button.expanded = 'true'; f.setDialogs([f.dialog, {}]); f.notify()
|
|
65
|
+
assert.deepEqual(seen, [null])
|
|
66
|
+
stop()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('Given 官方更新恰好等于增强值 When 关闭增强 Then 保留官方新值', () => {
|
|
70
|
+
const f = fixture()
|
|
71
|
+
f.label.nodeValue = 'Cache hit 99.999%'
|
|
72
|
+
const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, () => {}, '99.99')
|
|
73
|
+
f.label.nodeValue = 'Cache hit 99.99%'
|
|
74
|
+
f.notify([{ type: 'characterData', target: f.label }])
|
|
75
|
+
stop()
|
|
76
|
+
assert.equal(f.label.nodeValue, 'Cache hit 99.99%')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('Given 弹窗节点已脱离文档 When 后续更新 Then 清理不再持有或还原旧节点', () => {
|
|
80
|
+
const f = fixture()
|
|
81
|
+
f.button.expanded = 'true'; f.setDialogs([f.dialog])
|
|
82
|
+
const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, () => {}, '80.00')
|
|
83
|
+
f.value.detached = true; f.setDialogs([]); f.notify()
|
|
84
|
+
stop()
|
|
85
|
+
assert.equal(f.value.nodeValue, '80.00%')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('Given 官方更新记录尚未投递 When 立即卸载 Then 不恢复过期值', () => {
|
|
89
|
+
const f = fixture()
|
|
90
|
+
f.label.nodeValue = 'Cache hit 99.999%'
|
|
91
|
+
const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, () => {}, '99.99')
|
|
92
|
+
f.label.nodeValue = 'Cache hit 99.99%'
|
|
93
|
+
f.setPending([{ type: 'characterData', target: f.label }])
|
|
94
|
+
stop()
|
|
95
|
+
assert.equal(f.label.nodeValue, 'Cache hit 99.99%')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('Given 精度关闭 When 弹窗出现 Then 原始读数不变但仍可定位追加合计项', () => {
|
|
99
|
+
const f = fixture(), seen = []
|
|
100
|
+
f.button.expanded = 'true'; f.setDialogs([f.dialog])
|
|
101
|
+
const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, (v) => seen.push(v), null)
|
|
102
|
+
assert.equal(f.label.nodeValue, '缓存命中 80%')
|
|
103
|
+
assert.equal(f.value.nodeValue, '80%')
|
|
104
|
+
assert.deepEqual(seen, [f.dialog])
|
|
105
|
+
stop()
|
|
106
|
+
})
|