dsh-spendingframe 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +218 -2
- package/client/client.js +523 -0
- package/cordis.patch.yml +4 -0
- package/lib/index.js +165 -0
- package/lib/pricing.js +184 -0
- package/lib/session-logs.js +197 -0
- package/lib/summary.js +209 -0
- package/package.json +58 -4
- package/tests/balance-probe.mjs +132 -0
- package/tests/client-harness-test.mjs +378 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Functional harness for the dsh-spendingframe client half.
|
|
2
|
+
//
|
|
3
|
+
// It loads the hand-written __ModuleLoader__ bundle with a fake loader, a fake
|
|
4
|
+
// react (stateful useState/useRef, effects that actually run), a fake
|
|
5
|
+
// react-dom (createPortal), a fake document/window and a stub fetch, then
|
|
6
|
+
// drives the three interactions the plugin promises:
|
|
7
|
+
//
|
|
8
|
+
// 1. hover -> a small bubble with exactly 本会话消耗 / 子智能体消耗 / 统计更新时间
|
|
9
|
+
// 2. click -> the full information panel (portal, dialog semantics, breakdown)
|
|
10
|
+
// 3. dismiss -> Escape and outside pointerdown both close the panel
|
|
11
|
+
//
|
|
12
|
+
// Usage: node tests/client-harness-test.mjs [path/to/client.js]
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
14
|
+
|
|
15
|
+
const clientPath = process.argv[2] ?? fileURLToPath(new URL('../client/client.js', import.meta.url))
|
|
16
|
+
|
|
17
|
+
const failures = []
|
|
18
|
+
function check(label, actual, expected) {
|
|
19
|
+
const ok = JSON.stringify(actual) === JSON.stringify(expected)
|
|
20
|
+
if (!ok) failures.push(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
|
21
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label} = ${JSON.stringify(actual)}`)
|
|
22
|
+
}
|
|
23
|
+
function contains(label, haystack, needle) {
|
|
24
|
+
const ok = typeof haystack === 'string' && haystack.includes(needle)
|
|
25
|
+
if (!ok) failures.push(`${label}: expected to contain ${JSON.stringify(needle)}, got ${JSON.stringify(haystack)}`)
|
|
26
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`)
|
|
27
|
+
}
|
|
28
|
+
function near(label, actual, expected, tolerance = 0.01) {
|
|
29
|
+
const ok = typeof actual === 'number' && Math.abs(actual - expected) <= tolerance
|
|
30
|
+
if (!ok) failures.push(`${label}: expected ~${expected}, got ${actual}`)
|
|
31
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label} = ${actual}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- fake timers / globals ------------------------------------------------
|
|
35
|
+
// setTimeout fires immediately: the hover bubble has a 200ms delay (native
|
|
36
|
+
// Tooltip delayMs) and the harness should not have to wait for it. The delay
|
|
37
|
+
// passed in is recorded so an assertion can pin it to the native value.
|
|
38
|
+
// setInterval must never really tick, otherwise node would keep the process
|
|
39
|
+
// alive after the checks finish.
|
|
40
|
+
const intervals = []
|
|
41
|
+
const timeouts = []
|
|
42
|
+
const timeoutDelays = []
|
|
43
|
+
globalThis.setTimeout = (fn, delay) => { timeouts.push(fn); timeoutDelays.push(delay); fn(); return { timeout: true } }
|
|
44
|
+
globalThis.clearTimeout = () => {}
|
|
45
|
+
globalThis.setInterval = (fn) => { intervals.push(fn); return intervals.length }
|
|
46
|
+
globalThis.clearInterval = () => {}
|
|
47
|
+
let fetchImpl = () => new Promise(() => {})
|
|
48
|
+
globalThis.fetch = (...args) => fetchImpl(...args)
|
|
49
|
+
|
|
50
|
+
const documentListeners = new Map()
|
|
51
|
+
globalThis.document = {
|
|
52
|
+
baseURI: 'http://127.0.0.1:19387/',
|
|
53
|
+
body: { tag: 'body' },
|
|
54
|
+
documentElement: { clientWidth: 1200, clientHeight: 800 },
|
|
55
|
+
addEventListener: (type, fn) => { documentListeners.set(type, [...(documentListeners.get(type) ?? []), fn]) },
|
|
56
|
+
removeEventListener: (type, fn) => { documentListeners.set(type, (documentListeners.get(type) ?? []).filter((item) => item !== fn)) },
|
|
57
|
+
}
|
|
58
|
+
function latestListener(type) {
|
|
59
|
+
const list = documentListeners.get(type) ?? []
|
|
60
|
+
return list[list.length - 1]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const windowListeners = new Map()
|
|
64
|
+
globalThis.window = {
|
|
65
|
+
__ModuleLoader__: { load: (def) => { definition = def } },
|
|
66
|
+
addEventListener: (type, fn) => { windowListeners.set(type, [...(windowListeners.get(type) ?? []), fn]) },
|
|
67
|
+
removeEventListener: (type, fn) => { windowListeners.set(type, (windowListeners.get(type) ?? []).filter((item) => item !== fn)) },
|
|
68
|
+
}
|
|
69
|
+
let definition = null
|
|
70
|
+
|
|
71
|
+
// --- fake react -----------------------------------------------------------
|
|
72
|
+
// Rects are looked up per element so that the anchoring code can be exercised.
|
|
73
|
+
let rects = {}
|
|
74
|
+
function rectFor(element) {
|
|
75
|
+
if (element.props?.role === 'dialog') return rects.panel
|
|
76
|
+
if (element.props?.role === 'tooltip') return rects.bubble
|
|
77
|
+
return rects.anchor
|
|
78
|
+
}
|
|
79
|
+
function walk(node, out = []) {
|
|
80
|
+
if (node === null || node === undefined) return out
|
|
81
|
+
out.push(node)
|
|
82
|
+
for (const child of node.children ?? []) walk(child, out)
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
function createElement(type, props, ...children) {
|
|
86
|
+
const element = { type, props: props ?? {}, children: children.flat(Infinity) }
|
|
87
|
+
element.getBoundingClientRect = () => rectFor(element)
|
|
88
|
+
element.contains = (target) => walk(element).includes(target)
|
|
89
|
+
if (element.props.ref && typeof element.props.ref === 'object') element.props.ref.current = element
|
|
90
|
+
return element
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let states = []
|
|
94
|
+
let refs = []
|
|
95
|
+
let stateCursor = 0
|
|
96
|
+
let refCursor = 0
|
|
97
|
+
let effectCursor = 0
|
|
98
|
+
let effects = []
|
|
99
|
+
let pendingEffects = []
|
|
100
|
+
|
|
101
|
+
const React = {
|
|
102
|
+
createElement,
|
|
103
|
+
memo: (component) => component,
|
|
104
|
+
useState: (initial) => {
|
|
105
|
+
const index = stateCursor++
|
|
106
|
+
if (!(index in states)) states[index] = initial
|
|
107
|
+
const set = (value) => { states[index] = typeof value === 'function' ? value(states[index]) : value }
|
|
108
|
+
return [states[index], set]
|
|
109
|
+
},
|
|
110
|
+
useRef: (initial) => {
|
|
111
|
+
const index = refCursor++
|
|
112
|
+
if (!(index in refs)) refs[index] = { current: initial }
|
|
113
|
+
return refs[index]
|
|
114
|
+
},
|
|
115
|
+
useEffect: (fn, deps) => {
|
|
116
|
+
const index = effectCursor++
|
|
117
|
+
const previous = effects[index]
|
|
118
|
+
const changed = previous === undefined
|
|
119
|
+
|| deps === undefined
|
|
120
|
+
|| previous.deps === null
|
|
121
|
+
|| deps.length !== previous.deps.length
|
|
122
|
+
|| deps.some((value, i) => !Object.is(value, previous.deps[i]))
|
|
123
|
+
if (!changed) return
|
|
124
|
+
if (previous?.cleanup) previous.cleanup()
|
|
125
|
+
effects[index] = { deps: deps === undefined ? null : deps.slice(), fn, cleanup: undefined }
|
|
126
|
+
pendingEffects.push(index)
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
const reactDom = { createPortal: (node, container) => Object.assign(node, { portalContainer: container }) }
|
|
130
|
+
|
|
131
|
+
function flushEffects() {
|
|
132
|
+
const queue = pendingEffects
|
|
133
|
+
pendingEffects = []
|
|
134
|
+
for (const index of queue) effects[index].cleanup = effects[index].fn() ?? undefined
|
|
135
|
+
}
|
|
136
|
+
async function settle() {
|
|
137
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
138
|
+
}
|
|
139
|
+
function resetHooks(payload, open = false, hovered = false, stale = false) {
|
|
140
|
+
for (const effect of effects) if (effect?.cleanup) effect.cleanup()
|
|
141
|
+
states = [payload, stale, hovered, open, null]
|
|
142
|
+
refs = []
|
|
143
|
+
effects = []
|
|
144
|
+
pendingEffects = []
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- fake loader ----------------------------------------------------------
|
|
148
|
+
await import(pathToFileURL(clientPath).href)
|
|
149
|
+
check('loader received a definition', definition !== null, true)
|
|
150
|
+
check('bundle id', definition.id, 'dsh-spendingframe')
|
|
151
|
+
|
|
152
|
+
const requireShim = (id) => {
|
|
153
|
+
if (id === 'react') return React
|
|
154
|
+
if (id === 'react-dom') return reactDom
|
|
155
|
+
throw new Error(`unexpected require(${id})`)
|
|
156
|
+
}
|
|
157
|
+
const mod = definition.factory(requireShim)
|
|
158
|
+
check('exports.name', mod.name, 'dsh-spendingframe')
|
|
159
|
+
check('exports.inject', mod.inject, ['slots'])
|
|
160
|
+
check('apply is a function', typeof mod.apply, 'function')
|
|
161
|
+
|
|
162
|
+
// --- fake slots context ---------------------------------------------------
|
|
163
|
+
const registrations = []
|
|
164
|
+
const ctx = {
|
|
165
|
+
slots: {
|
|
166
|
+
inject: (slot, register) => { check('injected slot name', slot, 'conversation.composer.dock'); register() },
|
|
167
|
+
register: (meta, component) => { registrations.push({ meta, component }); return () => {} },
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
mod.apply(ctx)
|
|
171
|
+
check('registration count', registrations.length, 1)
|
|
172
|
+
const { meta, component } = registrations[0]
|
|
173
|
+
check('slot meta', { name: meta.name, id: meta.id, order: meta.order }, { name: 'conversation.composer.dock', id: 'balance', order: 1 })
|
|
174
|
+
|
|
175
|
+
// --- render helpers -------------------------------------------------------
|
|
176
|
+
/** useState order: [data, stale, hovered, open, position]. */
|
|
177
|
+
function render(payload, sessionId = 'session-abc', reset = true) {
|
|
178
|
+
if (reset) resetHooks(payload)
|
|
179
|
+
stateCursor = 0
|
|
180
|
+
refCursor = 0
|
|
181
|
+
effectCursor = 0
|
|
182
|
+
pendingEffects = []
|
|
183
|
+
const tree = component({ sessionId })
|
|
184
|
+
flushEffects()
|
|
185
|
+
if (tree === null) return { tree: null }
|
|
186
|
+
const children = tree.children.filter((child) => child !== null && child !== undefined && child !== false)
|
|
187
|
+
const button = children[0]
|
|
188
|
+
return {
|
|
189
|
+
tree,
|
|
190
|
+
button,
|
|
191
|
+
texts: button.children.map((child) => child.children[0]),
|
|
192
|
+
label: button.props['aria-label'],
|
|
193
|
+
expanded: button.props['aria-expanded'],
|
|
194
|
+
triggerStyle: button.props.style,
|
|
195
|
+
rootStyle: tree.props.style,
|
|
196
|
+
bubble: children.find((child) => child.props?.role === 'tooltip') ?? null,
|
|
197
|
+
panel: children.find((child) => child.props?.role === 'dialog') ?? null,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Measuring the anchor happens in an effect (that is what the real hook does),
|
|
201
|
+
// so the render that flips visibility still carries the previous position.
|
|
202
|
+
// Two renders: the first computes the position, the second shows it.
|
|
203
|
+
function renderSettled(payload, sessionId = 'session-abc') {
|
|
204
|
+
render(payload, sessionId, false)
|
|
205
|
+
return render(payload, sessionId, false)
|
|
206
|
+
}
|
|
207
|
+
function textOf(node) {
|
|
208
|
+
if (node === null || node === undefined) return ''
|
|
209
|
+
if (typeof node === 'string') return node
|
|
210
|
+
return (node.children ?? []).map(textOf).join('')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- canned payloads ------------------------------------------------------
|
|
214
|
+
const peakPayload = {
|
|
215
|
+
ok: true, at: Date.parse('2026-10-12T02:00:00Z'), peak: true, window: '高峰时段', version: '0.2.2',
|
|
216
|
+
session: {
|
|
217
|
+
cost: 0.434705, calls: 60,
|
|
218
|
+
own: { cost: 0.3, calls: 57, tokens: { hit: 120000, miss: 4000, out: 900 } },
|
|
219
|
+
subagents: { cost: 0.134705, calls: 3, sessions: 2, tokens: { hit: 20000, miss: 900, out: 120 } },
|
|
220
|
+
},
|
|
221
|
+
today: { cost: 1.2084, calls: 91, sessions: 2 },
|
|
222
|
+
models: [{ id: 'deepseek-flash', cost: 0.434705, calls: 60 }],
|
|
223
|
+
assumedPricing: [],
|
|
224
|
+
}
|
|
225
|
+
rects = {
|
|
226
|
+
anchor: { left: 600, top: 400, width: 40, height: 20, right: 640, bottom: 420 },
|
|
227
|
+
bubble: { width: 200, height: 60 },
|
|
228
|
+
panel: { width: 300, height: 200 },
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- 1. base rendering ----------------------------------------------------
|
|
232
|
+
const base = render(peakPayload)
|
|
233
|
+
check('peak mark', base.texts[0], '峰')
|
|
234
|
+
check('session cost includes subagents', base.texts[1], '¥0.435')
|
|
235
|
+
check('today cost', base.texts[2], '¥1.21')
|
|
236
|
+
check('aria label mentions subagent scope', base.label, '高峰时段,会话消耗 ¥0.435,含 2 个子智能体会话,今日消耗 ¥1.21。点击查看明细')
|
|
237
|
+
check('trigger is a real button', base.button.type, 'button')
|
|
238
|
+
check('trigger declares the dialog', base.button.props['aria-haspopup'], 'dialog')
|
|
239
|
+
check('collapsed by default', base.expanded, false)
|
|
240
|
+
check('no bubble before hovering', base.bubble, null)
|
|
241
|
+
check('no panel before clicking', base.panel, null)
|
|
242
|
+
check('uses tertiary label token', base.triggerStyle.color, 'var(--dsw-alias-label-tertiary)')
|
|
243
|
+
check('base background is transparent', base.triggerStyle.background, 'transparent')
|
|
244
|
+
check('rounded rect like the native pill', base.triggerStyle.borderRadius, 'var(--dsw-radius-sm)')
|
|
245
|
+
check('follows app secondary font size', base.triggerStyle.fontSize, 'var(--dsh-content-font-size-secondary, 13px)')
|
|
246
|
+
check('tabular numerals', base.triggerStyle.fontVariantNumeric, 'tabular-nums')
|
|
247
|
+
check('no native title attribute (we own the bubble)', base.button.props.title, undefined)
|
|
248
|
+
check('sits after the context meter (flex order)', base.rootStyle.order, 1)
|
|
249
|
+
check('root is an inline-flex pill', [base.rootStyle.display, base.rootStyle.flex], ['inline-flex', 'none'])
|
|
250
|
+
|
|
251
|
+
// --- 2. hover: only the two costs plus the refresh time -------------------
|
|
252
|
+
timeoutDelays.length = 0
|
|
253
|
+
base.button.props.onMouseEnter()
|
|
254
|
+
check('hover waits the native Tooltip delay', timeoutDelays[timeoutDelays.length - 1], 200)
|
|
255
|
+
const hovered = renderSettled(peakPayload)
|
|
256
|
+
check('hover background uses the native token', hovered.triggerStyle.background, 'var(--dsw-alias-interactive-bg-hover)')
|
|
257
|
+
check('hover text uses the secondary token', hovered.triggerStyle.color, 'var(--dsw-alias-label-secondary)')
|
|
258
|
+
check('hover shows the bubble', hovered.bubble !== null, true)
|
|
259
|
+
check('bubble is a tooltip', hovered.bubble.props.role, 'tooltip')
|
|
260
|
+
check('bubble does not swallow the pointer', hovered.bubble.props.style.pointerEvents, 'none')
|
|
261
|
+
check('bubble keeps the newlines', hovered.bubble.props.style.whiteSpace, 'pre-line')
|
|
262
|
+
const bubbleText = textOf(hovered.bubble)
|
|
263
|
+
console.log('hover bubble:\n' + bubbleText.split('\n').map((line) => ' ' + line).join('\n'))
|
|
264
|
+
check('bubble has exactly three lines', bubbleText.split('\n').length, 3)
|
|
265
|
+
contains('line 1: own cost', bubbleText, '本会话消耗 ¥0.300')
|
|
266
|
+
contains('line 2: subagent cost + session count', bubbleText, '子智能体消耗 ¥0.135(2 个会话)')
|
|
267
|
+
contains('line 3: refresh time (Beijing)', bubbleText, '统计更新时间 2026-10-12 10:00')
|
|
268
|
+
check('bubble shows no full breakdown', bubbleText.includes('今日消耗'), false)
|
|
269
|
+
check('still no panel while hovering', hovered.panel, null)
|
|
270
|
+
// anchored above the pill: left = 600 + 40/2 - 200/2, top = 400 - 6 - 60
|
|
271
|
+
check('bubble anchored above the pill', [hovered.bubble.props.style.left, hovered.bubble.props.style.top], [520, 334])
|
|
272
|
+
|
|
273
|
+
hovered.button.props.onMouseLeave()
|
|
274
|
+
const unhovered = render(peakPayload, 'session-abc', false)
|
|
275
|
+
check('leaving restores the base appearance', [unhovered.triggerStyle.background, unhovered.triggerStyle.color], ['transparent', 'var(--dsw-alias-label-tertiary)'])
|
|
276
|
+
check('leaving removes the bubble', unhovered.bubble, null)
|
|
277
|
+
|
|
278
|
+
// --- 3. click: the full information panel ---------------------------------
|
|
279
|
+
hovered.button.props.onClick()
|
|
280
|
+
const opened = renderSettled(peakPayload)
|
|
281
|
+
check('aria-expanded flips to true', opened.expanded, true)
|
|
282
|
+
check('click opens the dialog', opened.panel?.props.role, 'dialog')
|
|
283
|
+
check('panel is portalled to the body', opened.panel.portalContainer, globalThis.document.body)
|
|
284
|
+
check('panel labelled by its headline', opened.panel.props['aria-label'], '高峰时段消耗明细')
|
|
285
|
+
check('panel uses the menu background', opened.panel.props.style.background, 'var(--dsw-specific-menu)')
|
|
286
|
+
check('panel uses the large radius', opened.panel.props.style.borderRadius, 'var(--dsw-radius-lg)')
|
|
287
|
+
// left = 600 + 40/2 - 300/2, top = 400 - 8 - 200
|
|
288
|
+
check('panel anchored above the pill', [opened.panel.props.style.left, opened.panel.props.style.top], [470, 192])
|
|
289
|
+
const panelText = textOf(opened.panel)
|
|
290
|
+
const [header, bar, rows, footer] = opened.panel.children
|
|
291
|
+
check('header headline + mark', [textOf(header.children[0]), textOf(header.children[1])], ['高峰时段', '峰'])
|
|
292
|
+
check('header figures', textOf(header.children[2]), '会话合计 ¥0.435')
|
|
293
|
+
const segments = bar.children
|
|
294
|
+
check('bar has one segment per source', segments.length, 2)
|
|
295
|
+
check('bar tints match the native meter', segments.map((segment) => segment.props.style.background), ['var(--dsw-static-neutral-bluish-400)', '#a78bfa'])
|
|
296
|
+
near('bar segments add up to 100%', segments.reduce((sum, segment) => sum + Number.parseFloat(segment.props.style.width), 0), 100)
|
|
297
|
+
const rowMap = Object.fromEntries(rows.children.map((row) => [textOf(row.children[0]), textOf(row.children[1])]))
|
|
298
|
+
check('panel rows', Object.keys(rowMap), ['本会话消耗', '子智能体消耗', '今日消耗', '本会话 Token', '子智能体 Token', '模型', '计费时段', '统计更新时间'])
|
|
299
|
+
check('own row', rowMap['本会话消耗'], '¥0.300(57 次调用)')
|
|
300
|
+
check('subagent row', rowMap['子智能体消耗'], '¥0.135(2 个会话 / 3 次调用)')
|
|
301
|
+
check('today row', rowMap['今日消耗'], '¥1.21(2 个会话 / 91 次调用)')
|
|
302
|
+
check('own token row', rowMap['本会话 Token'], '命中 120K / 未命中 4K / 输出 900')
|
|
303
|
+
check('model row', rowMap['模型'], 'deepseek-flash ¥0.435(60 次)')
|
|
304
|
+
contains('panel footer carries the version', textOf(footer), 'DSHBalance v0.2.2')
|
|
305
|
+
check('panel carries the full breakdown, not just the hover lines', [panelText.includes('今日消耗'), panelText.includes('计费时段'), panelText.includes('统计更新时间')], [true, true, true])
|
|
306
|
+
|
|
307
|
+
// --- 4. dismissal ---------------------------------------------------------
|
|
308
|
+
latestListener('keydown')({ key: 'Escape' })
|
|
309
|
+
const closed = render(peakPayload, 'session-abc', false)
|
|
310
|
+
check('escape closes the panel', [closed.expanded, closed.panel], [false, null])
|
|
311
|
+
check('escape keeps the bubble away too', closed.bubble, null)
|
|
312
|
+
|
|
313
|
+
closed.button.props.onClick()
|
|
314
|
+
const reopened = render(peakPayload, 'session-abc', false)
|
|
315
|
+
check('panel reopens', reopened.panel?.props.role, 'dialog')
|
|
316
|
+
latestListener('pointerdown')({ target: { tag: 'outside' } })
|
|
317
|
+
const dismissed = render(peakPayload, 'session-abc', false)
|
|
318
|
+
check('outside pointerdown closes the panel', dismissed.panel, null)
|
|
319
|
+
|
|
320
|
+
dismissed.button.props.onClick()
|
|
321
|
+
const inside = render(peakPayload, 'session-abc', false)
|
|
322
|
+
latestListener('pointerdown')({ target: inside.button })
|
|
323
|
+
check('pointerdown inside the pill keeps the panel open', render(peakPayload, 'session-abc', false).panel?.props.role, 'dialog')
|
|
324
|
+
|
|
325
|
+
// --- 5. off-peak / formatting / degradation -------------------------------
|
|
326
|
+
const offPeak = render({ ok: true, at: Date.now(), peak: false, session: { cost: 0, calls: 0, own: { cost: 0 }, subagents: { cost: 0, sessions: 0 } }, today: { cost: 1234.56, calls: 3, sessions: 1 } })
|
|
327
|
+
check('off-peak mark', offPeak.texts[0], '谷')
|
|
328
|
+
check('zero cost', offPeak.texts[1], '¥0.000')
|
|
329
|
+
check('large cost rounds', offPeak.texts[2], '¥1235')
|
|
330
|
+
check('no subagent scope without subagents', offPeak.label, '空闲时段,会话消耗 ¥0.000,今日消耗 ¥1235。点击查看明细')
|
|
331
|
+
offPeak.button.props.onClick()
|
|
332
|
+
const offPanel = render({ ok: true, at: Date.now(), peak: false, session: { cost: 0, calls: 0, own: { cost: 0 }, subagents: { cost: 0, sessions: 0 } }, today: { cost: 1234.56, calls: 3, sessions: 1 } }, 'session-abc', false)
|
|
333
|
+
check('empty split falls back to one neutral segment', offPanel.panel.children[1].children.map((segment) => segment.props.style.background), ['var(--dsw-alias-label-tertiary)'])
|
|
334
|
+
|
|
335
|
+
const mid = render({ ok: true, at: Date.now(), peak: false, session: { cost: 123.456, own: { cost: 123.456 } }, today: { cost: 250.5 } })
|
|
336
|
+
check('cost >= 100 uses 1 decimal', [mid.texts[1], mid.texts[2]], ['¥123.5', '¥250.5'])
|
|
337
|
+
const small = render({ ok: true, at: Date.now(), peak: false, session: { cost: 1.5, own: { cost: 1.5 } }, today: { cost: 2.5 } })
|
|
338
|
+
check('cost >= 1 uses 2 decimals', [small.texts[1], small.texts[2]], ['¥1.50', '¥2.50'])
|
|
339
|
+
|
|
340
|
+
// stale flag from a failed refresh shows up in the bubble
|
|
341
|
+
resetHooks(peakPayload, false, true, true)
|
|
342
|
+
const staleRender = render(peakPayload, 'session-abc', false)
|
|
343
|
+
contains('stale refresh is called out in the bubble', textOf(staleRender.bubble), '(刷新失败,显示上次结果)')
|
|
344
|
+
|
|
345
|
+
// no data yet -> renders nothing (never a placeholder flash)
|
|
346
|
+
resetHooks(null)
|
|
347
|
+
check('renders nothing without data', component({ sessionId: 'x' }), null)
|
|
348
|
+
|
|
349
|
+
// the fetch path: no seeded data, the host answers, the pill appears
|
|
350
|
+
resetHooks(null)
|
|
351
|
+
fetchImpl = async () => ({ ok: true, status: 200, json: async () => peakPayload })
|
|
352
|
+
const loading = render(null, 'session-abc', false)
|
|
353
|
+
check('still nothing while the first fetch is in flight', loading.tree, null)
|
|
354
|
+
await settle()
|
|
355
|
+
const loaded = render(peakPayload, 'session-abc', false)
|
|
356
|
+
check('fetched payload renders', [loaded.texts[0], loaded.texts[1]], ['峰', '¥0.435'])
|
|
357
|
+
check('a refresh timer is armed', intervals.length > 0, true)
|
|
358
|
+
|
|
359
|
+
// --- 6. pure helpers ------------------------------------------------------
|
|
360
|
+
const t = mod.__test
|
|
361
|
+
check('money tiers', [t.money(0), t.money(0.4), t.money(3.25), t.money(123.456), t.money(1234.56)], ['¥0.000', '¥0.400', '¥3.25', '¥123.5', '¥1235'])
|
|
362
|
+
check('compact tokens', [t.compactTokens(0), t.compactTokens(900), t.compactTokens(4000), t.compactTokens(120000), t.compactTokens(2400000)], ['0', '900', '4K', '120K', '2.4M'])
|
|
363
|
+
check('beijing clock', t.beijingClock(Date.parse('2026-10-12T02:00:00Z')), '10:00')
|
|
364
|
+
check('beijing stamp', t.beijingStamp(Date.parse('2026-10-12T02:00:00Z')), '2026-10-12 10:00')
|
|
365
|
+
const viewport = { width: 1200, height: 800 }
|
|
366
|
+
check('placement centres above the anchor', t.placement({ left: 600, top: 400, width: 40, height: 20, bottom: 420 }, { width: 300, height: 200 }, viewport), { left: 470, top: 192, width: 300 })
|
|
367
|
+
check('placement flips below when there is no room above', t.placement({ left: 600, top: 10, width: 40, height: 20, bottom: 30 }, { width: 300, height: 200 }, viewport), { left: 470, top: 38, width: 300 })
|
|
368
|
+
check('placement clamps to the left margin', t.placement({ left: 0, top: 400, width: 20, height: 20, bottom: 420 }, { width: 300, height: 200 }, viewport), { left: 12, top: 192, width: 300 })
|
|
369
|
+
check('placement clamps to the right margin', t.placement({ left: 1190, top: 400, width: 20, height: 20, bottom: 420 }, { width: 300, height: 200 }, viewport), { left: 888, top: 192, width: 300 })
|
|
370
|
+
check('placement shrinks a panel wider than the viewport', t.placement({ left: 600, top: 400, width: 40, height: 20, bottom: 420 }, { width: 2000, height: 200 }, viewport).width, 1176)
|
|
371
|
+
check('hover label without subagents', t.hoverLabel({ at: Date.parse('2026-10-12T02:00:00Z'), session: { own: { cost: 1 }, subagents: { cost: 0, sessions: 0 } } }).split('\n')[1], '子智能体消耗 ¥0.000')
|
|
372
|
+
check('panel rows skip the model line when there is none', t.panelRows({ session: { own: {}, subagents: {} }, today: {} }).map((row) => row.label), ['本会话消耗', '子智能体消耗', '今日消耗', '本会话 Token', '子智能体 Token', '计费时段', '统计更新时间'])
|
|
373
|
+
check('assumed pricing is flagged', t.panelRows({ session: { own: {}, subagents: {} }, today: {}, assumedPricing: ['unknown-model'] }).at(-1).label, '按 flash 价目估算')
|
|
374
|
+
// the two cost rows carry the bar colours as an 8px swatch (native .swatch)
|
|
375
|
+
check('cost rows carry the bar tints', t.panelRows({ session: { own: {}, subagents: {} }, today: {} }).slice(0, 3).map((row) => row.tint ?? null), ['var(--dsw-static-neutral-bluish-400)', '#a78bfa', null])
|
|
376
|
+
|
|
377
|
+
console.log(failures.length === 0 ? '\nALL CLIENT CHECKS PASSED' : `\n${failures.length} CLIENT CHECK(S) FAILED:\n- ${failures.join('\n- ')}`)
|
|
378
|
+
process.exitCode = failures.length === 0 ? 0 : 1
|