@mzzsfy/dsh-usage-dash 0.9.1 → 0.10.0

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 CHANGED
@@ -10,7 +10,7 @@
10
10
  - 汇总卡六张:Tokens 用量(服务商总口径)、会话数量、请求数量、最常用模型、平均缓存命中率、活跃天数(恒按天口径,不随视图切换);配置定价规则后 Tokens 卡头部行右侧显示估算费用
11
11
  - 活动热力图:GitHub 风格周列×星期行,26 周窗口,五档色阶,悬停明细(配置定价规则后附「费用 ≈」估算行,受「费用显示」开关;费用数据依赖小时桶保留期,仅最近 15 天的天显示,更早的天无该行);仅按天视图展示
12
12
  - 缓存命中率曲线:日粒度命中率 + 右侧副轴,并叠加平均生成速度曲线与首 token 延迟曲线(各自单独颜色,读数走悬停;仅含配对数据的槽参与,整图无数据不绘制),悬停显示当前时段命中率、平均生成速度、首 token 延迟与 token 明细(配置定价规则后附当前时段估算费用行,受「费用显示」开关;三粒度趋势图通用)
13
- - 模型 donut 与列表:按 token 前 5 模型占比环形图(中心为总量);每行名称两行展示(厂商/模型名,其余折叠为「其他」),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
13
+ - 模型 donut 与列表:按 token 排序的模型占比环形图(中心为总量,模型不超过 5 个时全展示,超过时折叠为前 4 +「其他」,「其他」可展开),每行名称两行展示(厂商/模型名),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
14
14
  - 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);节头「金额」开关把柱状图从 token 消耗切到金额消耗——柱按模型堆叠估算费用,标题与左轴同步切金额读数(悬停明细保持 token 口径与估算费用行不变,命中率/速度/首 token 延迟曲线不受影响),三粒度通用;开关仅在存在正计价数据时显示(未配置规则或全量未计价时不渲染,避免全零金额柱误导);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
15
15
  - 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;折叠且有待读信息(异常日志或写入失败)时箭头带红点提醒,展开后消失;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
16
16
  - 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项、趋势 tooltip 费用行与回合费用芯片);三开关全关时与官方逐字节一致
@@ -93,4 +93,4 @@ dsh --profile <profile> --no-open --port 9191
93
93
 
94
94
  ## dsh 版本兼容
95
95
 
96
- 三版本全部通过:用量仪表盘渲染、激活 live。
96
+ 三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)兼容通过:激活 live;0.1.7-rc.1 依赖会话用量数据夹具(ENV-003),装载与激活面正常。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-usage-dash",
3
3
  "description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图,会话底栏信息接管与官方动作行回合费用芯片,多条件分时段定价规则与费用估算;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
4
- "version": "0.9.1",
4
+ "version": "0.10.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -20,8 +20,8 @@ const DAY_MAX_SLOTS = 180
20
20
  const API_PREFIX = '/api/usage-dash/'
21
21
  const ENDPOINTS = { range: 'range', hours: 'hours', minutes: 'minutes', status: 'status', reset: 'reset', restore: 'restore', pricing: 'pricing' }
22
22
 
23
- // 宿主语义 token 之外的插件本地模型色板容量与哨兵
24
- const GROUP_TOP_COUNT = 5
23
+ // 模型免折叠上限:条目不超它全展示,超过才折叠为 top(上限-1)+Other
24
+ const GROUP_UNFOLDED_MAX = 5
25
25
  const OTHER_MODEL = '\u0000other'
26
26
 
27
27
  const PAD_WIDTH = 2
@@ -603,9 +603,10 @@ const moneyViewOf = (slots, costView) => {
603
603
  }
604
604
 
605
605
  const topWithOther = (ranked) => {
606
- const models = ranked.slice(0, GROUP_TOP_COUNT)
607
- if (ranked.length > GROUP_TOP_COUNT) {
608
- const rest = ranked.slice(GROUP_TOP_COUNT)
606
+ const folded = ranked.length > GROUP_UNFOLDED_MAX
607
+ const models = ranked.slice(0, folded ? GROUP_UNFOLDED_MAX - 1 : GROUP_UNFOLDED_MAX)
608
+ if (folded) {
609
+ const rest = ranked.slice(models.length)
609
610
  // 四桶与费用同源条件挂载:输入条目无该字段(旧形数据/未配价)时哨兵同样不带,防零值假数据
610
611
  models.push({
611
612
  model: OTHER_MODEL,
@@ -640,7 +641,7 @@ function groupPointSlots(slots) {
640
641
  // (预设与自定义挡)用点端点聚合,字段同形;点数据未回为 null,调用方判空不渲染
641
642
  const cardsStatsOf = (view, stats, pointView) => (view === 'day' ? stats : pointView)
642
643
 
643
- // 模型用量列表:天视图用预折叠分组,时/分视图折叠点窗口模型(top5+other),点数据未回为 null
644
+ // 模型用量列表:天视图用预折叠分组,时/分视图折叠点窗口模型(超上限折叠 top(上限-1)+other),点数据未回为 null
644
645
  const usageModelsOf = (view, grouped, pointView) => (
645
646
  view === 'day' ? grouped?.models ?? null : pointView ? topWithOther(pointView.models) : null
646
647
  )
@@ -2125,14 +2126,14 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2125
2126
  .ud-donut-center{font-size:18px;font-weight:600;fill:var(--dsw-alias-label-primary)}
2126
2127
  .ud-donut-label{font-size:11px;fill:var(--dsw-alias-label-tertiary)}
2127
2128
  .ud-models{flex:1 1 260px;min-width:240px;display:flex;flex-direction:column}
2128
- .ud-model-row{display:flex;align-items:center;gap:8px;min-height:48px;padding:2px 4px;border-bottom:1px solid var(--dsw-alias-border-l1)}
2129
+ .ud-model-row{display:flex;align-items:center;gap:8px;min-height:46px;padding:1px 4px;border-bottom:1px solid var(--dsw-alias-border-l1)}
2129
2130
  .ud-model-row--expand{cursor:pointer}
2130
2131
  .ud-model-row--expand:hover{background:var(--dsw-alias-interactive-bg-hover)}
2131
2132
  .ud-model-swatch{width:10px;height:10px;border-radius:2px;flex:none}
2132
- .ud-model-id{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
2133
+ .ud-model-id{display:flex;flex-direction:column;min-width:0;flex:1}
2133
2134
  .ud-model-name{font-size:14px;font-weight:500;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2134
2135
  .ud-model-provider{font-size:12px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2135
- .ud-model-values{display:flex;flex-direction:column;align-items:flex-end;gap:1px;font-variant-numeric:tabular-nums;flex:none}
2136
+ .ud-model-values{display:flex;flex-direction:column;align-items:flex-end;font-variant-numeric:tabular-nums;flex:none}
2136
2137
  .ud-model-tokens{font-size:12px;color:var(--dsw-alias-label-secondary)}
2137
2138
  .ud-model-io{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
2138
2139
  .ud-model-meta{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
@@ -2293,7 +2294,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2293
2294
  const colorForModel = (models) => (model) => {
2294
2295
  if (model === OTHER_MODEL) return 'var(--ud-chart-other)'
2295
2296
  const slot = models.findIndex((item) => item.model === model)
2296
- const rank = Math.min(slot < 0 ? 0 : slot, GROUP_TOP_COUNT - 1) + 1
2297
+ const rank = Math.min(slot < 0 ? 0 : slot, GROUP_UNFOLDED_MAX - 1) + 1
2297
2298
  return `var(--ud-chart-${rank})`
2298
2299
  }
2299
2300
 
package/src/index.js CHANGED
@@ -55,6 +55,21 @@ const SETTINGS_SCHEMA = schemastery.object({
55
55
  .description('定价规则(wire 由 /api/usage-dash/pricing 读写)'),
56
56
  })
57
57
 
58
+ // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
59
+ // settings.register 注册命名空间。根级 volatile 包装:整节 live 表单,节写经
60
+ // loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
61
+ export const Config = volatileWrap(SETTINGS_SCHEMA)
62
+
63
+ function volatileWrap(schema) {
64
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
65
+ }
66
+
67
+ // volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
68
+ // legacy 宿主 config 为普通对象原样透传
69
+ function unwrapVolatile(value) {
70
+ return typeof value?.get === 'function' ? value.get() : value
71
+ }
72
+
58
73
  export function apply(ctx, config) {
59
74
  const store = sharedStore(ctx.storageDomain)
60
75
  const collector = new UsageCollector(ctx, store)
@@ -70,19 +85,44 @@ export function apply(ctx, config) {
70
85
  }
71
86
  ctx.inject(['settings'], (settingsCtx) => {
72
87
  const settings = settingsCtx.settings
73
- settings.register(SETTINGS_NAMESPACE, SETTINGS_SCHEMA, { base: config })
74
- // 每日本地日首次写入时按保留值清理分钟/小时桶;启动回扫前的触发在下方回扫入口
75
- store.retentionDays = () =>
76
- settings.get(SETTINGS_NAMESPACE)?.minuteRetentionDays ?? DEFAULT_MINUTE_RETENTION_DAYS
88
+ // legacy(≤0.1.6)方法面:register/get/update/describe 命名空间语义
89
+ if (typeof settings.get === 'function' && typeof settings.register === 'function') {
90
+ settings.register(SETTINGS_NAMESPACE, SETTINGS_SCHEMA, { base: config })
91
+ // 每日本地日首次写入时按保留值清理分钟/小时桶;启动回扫前的触发在下方回扫入口
92
+ store.retentionDays = () =>
93
+ settings.get(SETTINGS_NAMESPACE)?.minuteRetentionDays ?? DEFAULT_MINUTE_RETENTION_DAYS
94
+ pricing.active = true
95
+ pricing.rules = () => settings.get(SETTINGS_NAMESPACE)?.pricing?.rules ?? []
96
+ // revision 取 describe 中本 ns 描述符,settings 任何写入都会自增
97
+ pricing.revision = () =>
98
+ settings.describe().find((descriptor) => descriptor.ns === SETTINGS_NAMESPACE)?.revision ?? 0
99
+ pricing.replace = async (rules) => {
100
+ // update 深合并下数组整体替换,不动 minuteRetentionDays 用户层
101
+ await settings.update(SETTINGS_NAMESPACE, { pricing: { rules } })
102
+ return settings.get(SETTINGS_NAMESPACE)?.pricing?.rules ?? []
103
+ }
104
+ return
105
+ }
106
+ // 0.1.7+ 形态:静态 Config 导出生成节表单。关闭原生自动页(maintain 同构),
107
+ // 读经 apply 入参 config 整节解包,写经 configEditor.edit(fiber.entry 与命名空间
108
+ // 解耦),revision 读 describe 按条目 id 定位(0.1.7 描述符以 entry.options.id 为 ns)
109
+ if (typeof settings.configure === 'function') {
110
+ settingsCtx.effect(() => settings.configure({ auto: false }, ctx.fiber))
111
+ }
112
+ store.retentionDays = () => unwrapVolatile(config)?.minuteRetentionDays ?? DEFAULT_MINUTE_RETENTION_DAYS
77
113
  pricing.active = true
78
- pricing.rules = () => settings.get(SETTINGS_NAMESPACE)?.pricing?.rules ?? []
79
- // revision 取 describe 中本 ns 描述符,settings 任何写入都会自增
114
+ pricing.rules = () => unwrapVolatile(config)?.pricing?.rules ?? []
80
115
  pricing.revision = () =>
81
- settings.describe().find((descriptor) => descriptor.ns === SETTINGS_NAMESPACE)?.revision ?? 0
116
+ settings.describe?.().find((descriptor) => descriptor.ns === ctx.fiber?.entry?.options?.id)?.revision ?? 0
82
117
  pricing.replace = async (rules) => {
83
- // update 深合并下数组整体替换,不动 minuteRetentionDays 用户层
84
- await settings.update(SETTINGS_NAMESPACE, { pricing: { rules } })
85
- return settings.get(SETTINGS_NAMESPACE)?.pricing?.rules ?? []
118
+ const editor = ctx.get('configEditor')
119
+ const entry = ctx.fiber?.entry
120
+ if (!editor || typeof editor.edit !== 'function' || !entry) {
121
+ throw new Error('usage-dash: configEditor inactive')
122
+ }
123
+ // change 回调整体替换 pricing 节,不动 minuteRetentionDays 用户层
124
+ await editor.edit(entry, (current) => ({ ...current, pricing: { rules } }))
125
+ return unwrapVolatile(config)?.pricing?.rules ?? []
86
126
  }
87
127
  })
88
128
  const bootScan = async () => {
@@ -420,7 +420,7 @@ test('空态判定仅四项全零成立', () => {
420
420
  assert.equal(isEmptyRange({ tokens: 0, cacheHit: 0, requests: 0, turns: 3 }), false)
421
421
  })
422
422
 
423
- test('groupStats 折叠 top5 之外为其他哨兵并归并逐日', () => {
423
+ test('groupStats 折叠超免折叠上限条目为其他哨兵并归并逐日', () => {
424
424
  const stats = {
425
425
  models: [
426
426
  { model: 'p/m1', tokens: 500 },
@@ -436,8 +436,8 @@ test('groupStats 折叠 top5 之外为其他哨兵并归并逐日', () => {
436
436
  ],
437
437
  }
438
438
  const grouped = groupStats(stats)
439
- assert.deepEqual(grouped.models.map((m) => m.model), ['p/m1', 'p/m2', 'p/m3', 'p/m4', 'p/m5', OTHER_MODEL])
440
- assert.equal(grouped.models[5].tokens, 50)
439
+ assert.deepEqual(grouped.models.map((m) => m.model), ['p/m1', 'p/m2', 'p/m3', 'p/m4', OTHER_MODEL])
440
+ assert.equal(grouped.models[4].tokens, 100 + 50)
441
441
  assert.deepEqual(grouped.daily[0].byModel, { 'p/m1': 400, 'p/m2': 500, [OTHER_MODEL]: 100 })
442
442
  assert.deepEqual(grouped.daily[1].byModel, { 'p/m3': 300, [OTHER_MODEL]: 250 })
443
443
  })
@@ -460,8 +460,8 @@ test('groupPointSlots 窗口模型超上限折叠其他', () => {
460
460
  ]
461
461
  const grouped = groupPointSlots(slots)
462
462
  assert.equal(grouped.models.at(-1).model, OTHER_MODEL)
463
- assert.equal(grouped.models.at(-1).tokens, 1)
464
- assert.deepEqual(grouped.daily[0].byModel, { m1: 1, m2: 1, m3: 1, m4: 1, m6: 1, [OTHER_MODEL]: 1 })
463
+ assert.equal(grouped.models.at(-1).tokens, 1 + 1)
464
+ assert.deepEqual(grouped.daily[0].byModel, { m1: 1, m2: 1, m3: 1, m6: 1, [OTHER_MODEL]: 1 + 1 })
465
465
  })
466
466
 
467
467
  test('cardsStatsOf 天视图用 range 聚合,时/分视图用点聚合,点未回为 null', () => {
@@ -485,9 +485,9 @@ test('usageModelsOf 天视图用预折叠分组,时/分视图折叠点窗口模
485
485
  { model: 'p/m6', tokens: 1 },
486
486
  ]
487
487
  const folded = usageModelsOf('hour', null, { models: ranked })
488
- assert.equal(folded.length, 6)
488
+ assert.equal(folded.length, 5)
489
489
  assert.equal(folded.at(-1).model, OTHER_MODEL)
490
- assert.equal(folded.at(-1).tokens, 1)
490
+ assert.equal(folded.at(-1).tokens, 1 + 1)
491
491
  // 空窗口契约:返回空数组渲染空环(与天视图空数据行为一致),非 null 隐藏
492
492
  assert.deepEqual(usageModelsOf('minute', null, { models: [] }), [])
493
493
  assert.equal(usageModelsOf('minute', null, null), null)
@@ -873,14 +873,14 @@ test('groupStats 哨兵折叠四桶求和,top 模型四桶透传', () => {
873
873
  const grouped = groupStats(stats)
874
874
  assert.equal(grouped.models[0].inputTokens, 300)
875
875
  assert.equal(grouped.models[0].outputTokens, 200)
876
- assert.deepEqual(grouped.models[5], {
876
+ assert.deepEqual(grouped.models[4], {
877
877
  model: OTHER_MODEL,
878
- tokens: 50,
879
- inputTokens: 10,
880
- outputTokens: 30,
878
+ tokens: 100 + 50,
879
+ inputTokens: 60 + 10,
880
+ outputTokens: 40 + 30,
881
881
  cacheReadTokens: 5,
882
882
  cacheWriteTokens: 5,
883
- items: [stats.models[5]],
883
+ items: [stats.models[4], stats.models[5]],
884
884
  })
885
885
  })
886
886
 
@@ -894,7 +894,7 @@ test('groupStats 哨兵费用同源:无 cost 输入不挂 cost,有 cost 求和',
894
894
  { model: 'p/m5', tokens: 100 },
895
895
  { model: 'p/m6', tokens: 50 },
896
896
  ], daily: [] })
897
- assert.equal('cost' in bare.models[5], false)
897
+ assert.equal('cost' in bare.models[4], false)
898
898
  const priced = groupStats({ models: [
899
899
  { model: 'p/m1', tokens: 500, cost: 1 },
900
900
  { model: 'p/m2', tokens: 400, cost: 2 },
@@ -904,7 +904,7 @@ test('groupStats 哨兵费用同源:无 cost 输入不挂 cost,有 cost 求和',
904
904
  { model: 'p/m6', tokens: 50, cost: 16 },
905
905
  { model: 'p/m7', tokens: 25, cost: 32 },
906
906
  ], daily: [] })
907
- assert.equal(priced.models[5].cost, 16 + 32)
907
+ assert.equal(priced.models[4].cost, 8 + 16 + 32)
908
908
  })
909
909
 
910
910
  test('groupStats 哨兵保留其他模型明细供展开与提示', () => {
@@ -920,9 +920,26 @@ test('groupStats 哨兵保留其他模型明细供展开与提示', () => {
920
920
  daily: [],
921
921
  }
922
922
  const grouped = groupStats(stats)
923
- assert.deepEqual(grouped.models[5].items, [{ model: 'p/m6', tokens: 50 }])
923
+ // Given 超过免折叠上限 When 折叠 Then 保留 top4,哨兵在末位并携带其余明细
924
+ assert.deepEqual(grouped.models[4].items, [{ model: 'p/m5', tokens: 100 }, { model: 'p/m6', tokens: 50 }])
924
925
  // Given 旧形条目无四桶 When 折叠 Then 哨兵不合成零值四桶,与 top 条目降级形态一致
925
- assert.equal('inputTokens' in grouped.models[5], false)
926
+ assert.equal('inputTokens' in grouped.models[4], false)
927
+ })
928
+
929
+ test('groupStats 条目不超免折叠上限时全展示', () => {
930
+ const stats = {
931
+ models: [
932
+ { model: 'p/m1', tokens: 500 },
933
+ { model: 'p/m2', tokens: 400 },
934
+ { model: 'p/m3', tokens: 300 },
935
+ { model: 'p/m4', tokens: 200 },
936
+ { model: 'p/m5', tokens: 100 },
937
+ ],
938
+ daily: [],
939
+ }
940
+ const grouped = groupStats(stats)
941
+ assert.equal(grouped.models.length, 5)
942
+ assert.equal(grouped.models.some((item) => item.model === OTHER_MODEL), false)
926
943
  })
927
944
 
928
945
  test('groupStats 逐日保留其他明细映射供 tooltip', () => {
@@ -0,0 +1,237 @@
1
+ // settings 注入集成测试:apply 的 pricing 门面双形分派(legacy 方法面 / 0.1.7
2
+ // 静态 Config + configEditor 面)。stub ctx 装载真实 src/index.js,经捕获的
3
+ // /api/usage-dash/pricing 路由驱动门面,验证激活与读写链路。
4
+
5
+ import test from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { Readable } from 'node:stream'
8
+
9
+ const { apply } = await import('../src/index.js')
10
+
11
+ const PATH_PRICING = '/api/usage-dash/pricing'
12
+ const DEFAULT_MINUTE_RETENTION_DAYS = 7
13
+
14
+ const RULE = {
15
+ model: 'default/deepseek-chat',
16
+ currency: '¥',
17
+ price: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
18
+ conditions: [],
19
+ }
20
+
21
+ // sharedStore 单例按 storageDomain 缓存:每例独立 domain 防串扰;域 open 缺席时
22
+ // store 走内存降级,单测无落盘
23
+ function makeDomain() {
24
+ return { open: async () => ({}) }
25
+ }
26
+
27
+ function makeRes() {
28
+ return {
29
+ statusCode: null,
30
+ body: null,
31
+ writeHead(status) { this.statusCode = status },
32
+ end(body) { this.body = body },
33
+ }
34
+ }
35
+
36
+ function makeReq({ url, method = 'POST', headers = {}, body } = {}) {
37
+ const req = new Readable({ read() {} })
38
+ req.method = method
39
+ req.url = url
40
+ req.headers = headers
41
+ if (body !== undefined) req.push(body)
42
+ req.push(null)
43
+ return req
44
+ }
45
+
46
+ const invoke = async (routes, path, reqOptions = {}) => {
47
+ const route = routes.get(path)
48
+ assert.ok(route, `route ${path} 未注册`)
49
+ const res = makeRes()
50
+ await route.handler(makeReq({ url: path, ...reqOptions }), res)
51
+ return res
52
+ }
53
+
54
+ const invokeJson = (routes, path, { headers, payload, ...rest } = {}) =>
55
+ invoke(routes, path, {
56
+ headers: { 'content-type': 'application/json', ...headers },
57
+ body: payload === undefined ? undefined : JSON.stringify(payload),
58
+ ...rest,
59
+ })
60
+
61
+ // legacy 方法面桩(≤0.1.6 SettingsProvider):register/get/update/describe 命名空间语义
62
+ function makeLegacySettings(store = {}) {
63
+ let revision = 0
64
+ return {
65
+ register() {},
66
+ get: () => store,
67
+ update: async (_ns, patch) => {
68
+ const pricing = patch.pricing
69
+ if (pricing) store.pricing = { ...(store.pricing ?? {}), ...pricing }
70
+ if (patch.minuteRetentionDays !== undefined) store.minuteRetentionDays = patch.minuteRetentionDays
71
+ revision += 1
72
+ },
73
+ describe: () => [{ ns: 'usage-dash', revision }],
74
+ }
75
+ }
76
+
77
+ // 0.1.7 形态桩:settings 无 register/get(configure/describe 在),configRef 为整节
78
+ // 单 ref 桩,configEditor 桩合并写回 configStore
79
+ function makeRc17({ configStore = {}, configEditorAvailable = true, withRoutes = true } = {}) {
80
+ const routes = new Map()
81
+ const editCalls = []
82
+ const configRef = { get: () => ({ ...configStore }) }
83
+ const settingsService = {
84
+ configure: (presentation, owner) => { return () => {} },
85
+ describe: () => [{ ns: 'usage-dash', revision: configStore.__revision ?? 0 }],
86
+ }
87
+ const configEditor = {
88
+ async edit(entry, change) {
89
+ editCalls.push(entry)
90
+ const next = change({ ...configStore }, {})
91
+ for (const key of Object.keys(next)) configStore[key] = next[key]
92
+ configStore.__revision = (configStore.__revision ?? 0) + 1
93
+ },
94
+ }
95
+ const pendingInjects = []
96
+ const ctx = {
97
+ on() {},
98
+ logger: { warn() {} },
99
+ fiber: { entry: { options: { id: 'usage-dash', name: '@mzzsfy/dsh-usage-dash' } } },
100
+ get(name) {
101
+ if (name === 'settings') return settingsService
102
+ if (name === 'configEditor') return configEditorAvailable ? configEditor : undefined
103
+ if (name === 'storageDomain') return makeDomain()
104
+ return undefined
105
+ },
106
+ effect(fn) { fn() },
107
+ inject(_deps, fn) { pendingInjects.push(fn) },
108
+ webServer: {
109
+ register(route) {
110
+ routes.set(route.path, route)
111
+ return () => routes.delete(route.path)
112
+ },
113
+ },
114
+ }
115
+ return {
116
+ ctx,
117
+ routes,
118
+ configRef,
119
+ configStore,
120
+ editCalls,
121
+ activateSettings: () => {
122
+ for (const fn of pendingInjects.splice(0)) {
123
+ fn({
124
+ settings: settingsService,
125
+ interval: () => {},
126
+ // 注入上下文的 effect 桩:0.1.7 分支经它注册 configure 装配
127
+ effect(stubEffect) { stubEffect() },
128
+ })
129
+ }
130
+ },
131
+ }
132
+ }
133
+
134
+ test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', async () => {
135
+ const mod = await import('../src/index.js')
136
+ assert.ok(mod.Config, 'Config 导出必须在场')
137
+ const resolved = mod.Config['~standard'].validate({})
138
+ assert.equal(resolved.issues, undefined)
139
+ const section = resolved.value.get()
140
+ assert.equal(section.minuteRetentionDays, DEFAULT_MINUTE_RETENTION_DAYS)
141
+ // pricing 缺省对象校验补全:rules 数组缺省为空
142
+ assert.deepEqual(section.pricing?.rules ?? [], [])
143
+ })
144
+
145
+ test('0.1.7 形态:settings 激活即门面生效,GET 读 configRef,POST 经 configEditor.edit 整节替换', async () => {
146
+ // Given configRef 携带用户值 + 路由捕获
147
+ const rc = makeRc17({ configStore: { minuteRetentionDays: 7, pricing: { rules: [] } } })
148
+ apply(rc.ctx, rc.configRef)
149
+ // When 激活 settings 注入
150
+ rc.activateSettings()
151
+ // Then GET pricing 回读 configRef 节值
152
+ const read = await invoke(rc.routes, PATH_PRICING, { method: 'GET' })
153
+ const readParsed = JSON.parse(read.body)
154
+ assert.equal(read.statusCode, 200)
155
+ assert.deepEqual(readParsed.value.rules, [])
156
+ // When POST 合法规则
157
+ const saved = await invokeJson(rc.routes, PATH_PRICING, { payload: { rules: [RULE] } })
158
+ // Then edit 定位本条目,pricing 整节替换,回读新值,revision 自增
159
+ const savedParsed = JSON.parse(saved.body)
160
+ assert.equal(saved.statusCode, 200)
161
+ assert.equal(savedParsed.ok, true)
162
+ assert.equal(rc.editCalls.length, 1)
163
+ assert.equal(rc.editCalls[0].options.id, 'usage-dash')
164
+ assert.deepEqual(rc.configStore.pricing.rules, [RULE])
165
+ assert.equal(rc.configStore.minuteRetentionDays, 7, 'minuteRetentionDays 用户层不动')
166
+ assert.deepEqual(savedParsed.value.rules, [RULE])
167
+ assert.equal(savedParsed.value.revision, 1)
168
+ })
169
+
170
+ test('0.1.7 形态:configEditor 缺失即 POST 拒绝,GET 回读不受影响', async () => {
171
+ // Given configEditor 服务缺席
172
+ const rc = makeRc17({ configEditorAvailable: false })
173
+ apply(rc.ctx, rc.configRef)
174
+ rc.activateSettings()
175
+ // When POST 写规则
176
+ const saved = await invokeJson(rc.routes, PATH_PRICING, { payload: { rules: [RULE] } })
177
+ // Then ok:false 错误响应,进程存活(测试走完即证)
178
+ const savedParsed = JSON.parse(saved.body)
179
+ assert.equal(savedParsed.ok, false)
180
+ // When GET 回读
181
+ const read = await invoke(rc.routes, PATH_PRICING, { method: 'GET' })
182
+ // Then 仍回 configRef 当前值
183
+ assert.equal(read.statusCode, 200)
184
+ })
185
+
186
+ test('0.1.7 形态:configure 在场即关闭原生自动页,register 缺失不抛错', () => {
187
+ // Given configure 捕获桩
188
+ const rc = makeRc17()
189
+ const configureCalls = []
190
+ rc.ctx.get('settings').configure = (presentation, owner) => { configureCalls.push({ presentation, owner }); return () => {} }
191
+ // When apply + 激活
192
+ apply(rc.ctx, rc.configRef)
193
+ rc.activateSettings()
194
+ // Then 关闭自动页且 owner 为本插件 fiber
195
+ assert.equal(configureCalls.length, 1)
196
+ assert.deepEqual(configureCalls[0].presentation, { auto: false })
197
+ })
198
+
199
+ // 独立 legacy 用例:register/get/update 命名空间面行为与现状全同
200
+ test('legacy 形态:settings 激活即门面生效,replace 经命名空间 update,revision 经 describe', async () => {
201
+ const routes = new Map()
202
+ const store = {}
203
+ const settingsService = makeLegacySettings(store)
204
+ const pendingInjects = []
205
+ const ctx = {
206
+ on() {},
207
+ logger: { warn() {} },
208
+ get(name) {
209
+ if (name === 'settings') return settingsService
210
+ if (name === 'storageDomain') return makeDomain()
211
+ return undefined
212
+ },
213
+ effect(fn) { fn() },
214
+ inject(_deps, fn) { pendingInjects.push(fn) },
215
+ webServer: {
216
+ register(route) {
217
+ routes.set(route.path, route)
218
+ return () => routes.delete(route.path)
219
+ },
220
+ },
221
+ }
222
+ apply(ctx, {})
223
+ for (const fn of pendingInjects.splice(0)) {
224
+ fn({ settings: settingsService, interval: () => {}, effect(f) { f() } })
225
+ }
226
+ // GET 回读命名空间存储值
227
+ const read = await invoke(routes, PATH_PRICING, { method: 'GET' })
228
+ assert.equal(read.statusCode, 200)
229
+ // POST 合法规则经 update 落命名空间
230
+ const saved = await invokeJson(routes, PATH_PRICING, { payload: { rules: [RULE] } })
231
+ const savedParsed = JSON.parse(saved.body)
232
+ assert.equal(savedParsed.ok, true)
233
+ assert.deepEqual(store.pricing.rules, [RULE])
234
+ assert.deepEqual(savedParsed.value.rules, [RULE])
235
+ assert.equal(savedParsed.value.revision, 1)
236
+ })
237
+