@mzzsfy/dsh-usage-dash 0.10.3 → 0.10.5
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 +8 -8
- package/package.json +1 -1
- package/src/client.js +134 -69
- package/src/routes.js +18 -13
- package/src/store.js +31 -7
- package/test/pricing-current.test.mjs +179 -0
- package/test/routes.test.mjs +2 -0
- package/test/stats-line-render.test.mjs +1 -1
- package/test/store-owner.test.mjs +45 -0
- package/test/store.test.mjs +163 -36
- package/test/trend-tooltip.test.mjs +182 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CLIENT_SOURCE } from './client-eval.mjs'
|
|
4
|
+
|
|
5
|
+
const flush = () => new Promise(resolve => setImmediate(resolve))
|
|
6
|
+
const nodes = node => !node || typeof node !== 'object' ? [] : Array.isArray(node) ? node.flatMap(nodes) : [node, ...(node.children ?? []).flatMap(nodes)]
|
|
7
|
+
const text = node => typeof node === 'string' ? node : Array.isArray(node) ? node.map(text).join('') : (node?.children ?? []).map(text).join('')
|
|
8
|
+
const rule = (amount = 1, currency = '¥') => ({ model: '*/*', unit: 'perMillion', currency, price: { input: amount, output: 0, cacheRead: 0, cacheWrite: 0 }, conditions: [] })
|
|
9
|
+
const price = (revision, amount = revision) => ({ revision, rules: [rule(amount)] })
|
|
10
|
+
const usage = { uncachedInputTokens: 1000 * 1000, inputTokens: 1000 * 1000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, routes: [{ model: 'p/m' }] }
|
|
11
|
+
|
|
12
|
+
// 执行真实loader和槽组件,Hook仅模拟状态/依赖/清理,网络为可控边界。
|
|
13
|
+
function fixture() {
|
|
14
|
+
let current, factory, now = Date.now()
|
|
15
|
+
const slots = new Map(), mounted = [], requests = [], gets = [], saves = [], timers = new Map()
|
|
16
|
+
let lateState = 0
|
|
17
|
+
const depsEqual = (a, b) => a && b && a.length === b.length && a.every((value, i) => Object.is(value, b[i]))
|
|
18
|
+
const hooks = {
|
|
19
|
+
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
|
|
20
|
+
memo: fn => fn, Fragment: 'fragment', useId: () => 'fixture',
|
|
21
|
+
useState(seed) {
|
|
22
|
+
const owner = current, i = owner.cursor++
|
|
23
|
+
if (!(i in owner.states)) owner.states[i] = typeof seed === 'function' ? seed() : seed
|
|
24
|
+
return [owner.states[i], value => {
|
|
25
|
+
if (!owner.alive) { lateState++; return }
|
|
26
|
+
owner.states[i] = typeof value === 'function' ? value(owner.states[i]) : value
|
|
27
|
+
owner.dirty = true
|
|
28
|
+
}]
|
|
29
|
+
},
|
|
30
|
+
useRef(seed) { const i = current.cursor++; return current.refs[i] ??= { current: seed } },
|
|
31
|
+
useMemo(fn, deps) {
|
|
32
|
+
const i = current.cursor++, old = current.memos[i]
|
|
33
|
+
if (!old || !depsEqual(old.deps, deps)) current.memos[i] = { value: fn(), deps }
|
|
34
|
+
return current.memos[i].value
|
|
35
|
+
},
|
|
36
|
+
useEffect(fn, deps) {
|
|
37
|
+
const owner = current, i = owner.cursor++, old = owner.effects[i]
|
|
38
|
+
if (!old || !depsEqual(old.deps, deps)) owner.pending.push(() => {
|
|
39
|
+
old?.cleanup?.(); owner.effects[i] = { deps, cleanup: fn() }
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
hooks.useCallback = (fn, deps) => hooks.useMemo(() => fn, deps)
|
|
44
|
+
hooks.useLayoutEffect = hooks.useEffect
|
|
45
|
+
const window = { __ModuleLoader__: { load: entry => { factory = entry.factory } }, addEventListener() {}, removeEventListener() {} }
|
|
46
|
+
const document = { querySelector: () => ({}), querySelectorAll: () => [], documentElement: {}, body: {} }
|
|
47
|
+
const schedule = (fn, ms) => { const id = {}; timers.set(id, { fn, ms }); return id }
|
|
48
|
+
const fetch = async (path, options = {}) => {
|
|
49
|
+
requests.push({ path, ...options })
|
|
50
|
+
const deferred = Promise.withResolvers()
|
|
51
|
+
if (path.endsWith('/pricing')) {
|
|
52
|
+
const list = options.method === 'POST' ? saves : gets
|
|
53
|
+
list.push({ ...deferred, body: options.body && JSON.parse(options.body) })
|
|
54
|
+
return deferred.promise
|
|
55
|
+
}
|
|
56
|
+
const value = path.endsWith('/status') ? { running: false } : { models: [], daily: [], total: 0, requests: 0, from: '2026-10-01', to: '2026-10-28' }
|
|
57
|
+
return { json: async () => ({ ok: true, value }) }
|
|
58
|
+
}
|
|
59
|
+
const Clock = class extends Date { static now() { return now } }
|
|
60
|
+
new Function('window', 'document', 'fetch', 'localStorage', 'MutationObserver', 'setTimeout', 'clearTimeout', 'Date', CLIENT_SOURCE)(window, document, fetch, null, class { observe() {} disconnect() {} takeRecords() { return [] } }, schedule, id => timers.delete(id), Clock)
|
|
61
|
+
const mod = factory(name => name === 'react' ? hooks : { createPortal: null })
|
|
62
|
+
mod.apply({ effect: fn => fn(), locale: { register() {} }, slots: { inject: (_name, fn) => fn(), register: (entry, component) => slots.set(entry.name, component) } })
|
|
63
|
+
const render = owner => {
|
|
64
|
+
current = owner; owner.cursor = 0; owner.dirty = false
|
|
65
|
+
owner.tree = owner.component(owner.props); current = null
|
|
66
|
+
owner.pending.splice(0).forEach(fn => fn())
|
|
67
|
+
return owner.tree
|
|
68
|
+
}
|
|
69
|
+
const mount = (component, props = {}) => {
|
|
70
|
+
const owner = { component, props, states: [], refs: [], memos: [], effects: [], pending: [], cursor: 0, alive: true, dirty: true }
|
|
71
|
+
mounted.push(owner); render(owner); return owner
|
|
72
|
+
}
|
|
73
|
+
const unmount = owner => { owner.alive = false; owner.effects.forEach(effect => effect?.cleanup?.()) }
|
|
74
|
+
const settle = async () => {
|
|
75
|
+
for (let i = 0; i < 12; i++) { await flush(); mounted.filter(owner => owner.alive && owner.dirty).forEach(render) }
|
|
76
|
+
}
|
|
77
|
+
const panel = () => mount(slots.get('settings.section'))
|
|
78
|
+
const editor = () => { const p = panel(); return mount(nodes(p.tree).find(n => n.type?.name === 'PricingEditor').type) }
|
|
79
|
+
const stats = () => mount(slots.get('conversation.composer.dock'), { useProjection: () => usage })
|
|
80
|
+
const chip = () => mount(slots.get('conversation.chat.assistant-actions'), { messageId: 'm1', useChat: select => select({ nodes: [{ kind: 'turn-tail', data: { closing: { finalNode: { messageId: 'm1' } }, tokenUsage: usage } }] }) })
|
|
81
|
+
const resolve = (item, value) => item.resolve({ json: async () => ({ ok: true, value }) })
|
|
82
|
+
const save = owner => {
|
|
83
|
+
const button = nodes(owner.tree).find(n => n.type === 'button' && text(n) === '保存')
|
|
84
|
+
assert.ok(button, '已载入编辑器保存按钮'); return button.props.onClick()
|
|
85
|
+
}
|
|
86
|
+
return { panel, editor, stats, chip, settle, unmount, requests, gets, saves, resolve, save, mount, nodes, text, timers,
|
|
87
|
+
get lateState() { return lateState }, advance: ms => { now += ms }, cleanup: () => mounted.filter(o => o.alive).forEach(unmount) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const withFixture = async (t, fn) => { const f = fixture(); t.after(f.cleanup); await fn(f) }
|
|
91
|
+
|
|
92
|
+
test('P1 Given concurrent consumers, When cold mounted, Then only one pricing GET is sent', t => withFixture(t, async f => {
|
|
93
|
+
f.stats(); f.stats(); f.panel(); f.editor()
|
|
94
|
+
assert.equal(f.gets.length, 1)
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
test('P2/P4 Given mounted consumers, When editor saves, Then cost changes and panel ranges refresh', t => withFixture(t, async f => {
|
|
98
|
+
const stats = f.stats(), chip = f.chip(), editor = f.editor()
|
|
99
|
+
f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
100
|
+
assert.match(text(stats.tree), /¥1\.00/)
|
|
101
|
+
assert.match(text(chip.tree), /¥1\.00/)
|
|
102
|
+
const before = f.requests.filter(r => r.path.endsWith('/range')).length
|
|
103
|
+
const pending = f.save(editor); f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
104
|
+
assert.match(text(stats.tree), /¥2\.00/)
|
|
105
|
+
assert.match(text(chip.tree), /¥2\.00/)
|
|
106
|
+
assert.equal(f.requests.filter(r => r.path.endsWith('/range')).length, before + 2)
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
test('P3/P8 Given initial load or same revision TTL refresh, When panel mounts, Then range is not duplicated', t => withFixture(t, async f => {
|
|
110
|
+
f.panel(); f.resolve(f.gets[0], price(1)); await f.settle()
|
|
111
|
+
assert.equal(f.requests.filter(r => r.path.endsWith('/range')).length, 2)
|
|
112
|
+
f.advance(5 * 60 * 1000 + 1); f.panel()
|
|
113
|
+
f.resolve(f.gets.at(-1), price(1)); await f.settle()
|
|
114
|
+
assert.equal(f.requests.filter(r => r.path.endsWith('/range')).length, 4)
|
|
115
|
+
}))
|
|
116
|
+
|
|
117
|
+
test('P6/P9 Given new save while old GET is pending, When old reply arrives, Then price never goes backward', t => withFixture(t, async f => {
|
|
118
|
+
const editor = f.editor()
|
|
119
|
+
f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
120
|
+
f.advance(5 * 60 * 1000 + 1)
|
|
121
|
+
const stats = f.stats(), old = f.gets.at(-1)
|
|
122
|
+
const pending = f.save(editor); f.resolve(f.saves[0], price(3)); await pending; await f.settle()
|
|
123
|
+
f.resolve(old, price(2)); await f.settle()
|
|
124
|
+
assert.match(text(stats.tree), /¥3\.00/)
|
|
125
|
+
const remount = f.stats(); await f.settle(); assert.match(text(remount.tree), /¥3\.00/)
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
test('P7 Given TTL refresh GET pending, When another editor saves first, Then panel refreshes for mutation', t => withFixture(t, async f => {
|
|
129
|
+
const editor = f.editor(); f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
130
|
+
f.advance(5 * 60 * 1000 + 1); f.panel()
|
|
131
|
+
const before = f.requests.filter(r => r.path.endsWith('/range')).length
|
|
132
|
+
const pending = f.save(editor); f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
133
|
+
assert.equal(f.requests.filter(r => r.path.endsWith('/range')).length, before + 4)
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
test('P10 Given editor unmount during save, When reply arrives, Then publish without stale state or timer', t => withFixture(t, async f => {
|
|
137
|
+
const stats = f.stats(), editor = f.editor()
|
|
138
|
+
f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
139
|
+
const before = f.timers.size, pending = f.save(editor); f.unmount(editor)
|
|
140
|
+
f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
141
|
+
assert.equal(f.lateState, 0)
|
|
142
|
+
assert.equal(f.timers.size, before)
|
|
143
|
+
assert.match(text(stats.tree), /¥2\.00/)
|
|
144
|
+
}))
|
|
145
|
+
|
|
146
|
+
test('P11 Given another editor has a draft, When save publishes, Then draft stays unchanged', t => withFixture(t, async f => {
|
|
147
|
+
const a = f.editor(), b = f.editor()
|
|
148
|
+
f.gets.forEach(item => f.resolve(item, price(1))); await f.settle()
|
|
149
|
+
const group = () => nodes(b.tree).find(n => n.type?.name === 'PricingModelGroup')
|
|
150
|
+
group().props.onModelChange('draft/model'); await f.settle()
|
|
151
|
+
const pending = f.save(a); f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
152
|
+
assert.equal(group().props.group.model, 'draft/model')
|
|
153
|
+
}))
|
|
154
|
+
|
|
155
|
+
for (const [tab, endpoint] of [['小时', 'hours'], ['分钟', 'minutes']]) {
|
|
156
|
+
test(`P5 Given ${endpoint} view, When save publishes, Then active points refetch once`, t => withFixture(t, async f => {
|
|
157
|
+
const panel = f.panel(), editor = f.editor()
|
|
158
|
+
f.resolve(f.gets[0], price(1)); await f.settle()
|
|
159
|
+
const button = nodes(panel.tree).find(n => n.type === 'button' && text(n) === tab)
|
|
160
|
+
assert.ok(button, tab + '视图按钮'); button.props.onClick(); await f.settle()
|
|
161
|
+
const count = () => f.requests.filter(r => r.path.endsWith('/' + endpoint)).length
|
|
162
|
+
assert.equal(count(), 1)
|
|
163
|
+
const pending = f.save(editor); f.resolve(f.saves[0], price(2)); await pending; await f.settle()
|
|
164
|
+
assert.equal(count(), 2)
|
|
165
|
+
}))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test('P10-GET Given consumers unmount with GET pending, When response arrives, Then no stale state is written', t => withFixture(t, async f => {
|
|
169
|
+
const stats = f.stats(), editor = f.editor()
|
|
170
|
+
f.unmount(stats); f.unmount(editor)
|
|
171
|
+
f.resolve(f.gets[0], price(2)); await f.settle()
|
|
172
|
+
assert.equal(f.lateState, 0)
|
|
173
|
+
}))
|
|
174
|
+
|
|
175
|
+
test('P12 Given failed pricing request, When next consumer mounts, Then failed flight is released and retry works', t => withFixture(t, async f => {
|
|
176
|
+
f.stats(); f.gets[0].reject(new Error('injected network')); await f.settle()
|
|
177
|
+
const stats = f.stats(); assert.equal(f.gets.length, 2)
|
|
178
|
+
f.resolve(f.gets[1], price(2)); await f.settle(); assert.match(text(stats.tree), /¥2\.00/)
|
|
179
|
+
}))
|
package/test/routes.test.mjs
CHANGED
|
@@ -453,6 +453,8 @@ test('pricing POST 非法规则拒绝 400 且不写入', async () => {
|
|
|
453
453
|
{ rules: [{ ...PRICING_RULE, conditions: [{ kind: 'unknown' }] }] },
|
|
454
454
|
{ rules: [{ ...PRICING_RULE, conditions: [{ kind: 'weekdays', days: [7] }] }] },
|
|
455
455
|
{ rules: [{ ...PRICING_RULE, currency: '€' }] },
|
|
456
|
+
// F15 表级单币:非空币种混用整表拒绝;null 与单币并存合法
|
|
457
|
+
{ rules: [{ ...PRICING_RULE, currency: '¥' }, { ...PRICING_RULE, model: 'openai/gpt-x', currency: '$' }] },
|
|
456
458
|
]
|
|
457
459
|
for (const payload of invalidPayloads) {
|
|
458
460
|
const res = await invokeJson(routes, PATH_PRICING, { payload })
|
|
@@ -17,7 +17,7 @@ function render(options = {}) {
|
|
|
17
17
|
const useState = (init) => [stateIndex++ === 1 ? pricing : typeof init === 'function' ? init() : init, () => {}]
|
|
18
18
|
const noop = () => {}
|
|
19
19
|
const names = Object.keys(core)
|
|
20
|
-
const component = new Function(...names, 'React', 'h', 'useState', 'useEffect', 'useLayoutEffect', 'useRef', 'statsLineState', 'defaultT', '
|
|
20
|
+
const component = new Function(...names, 'React', 'h', 'useState', 'useEffect', 'useLayoutEffect', 'useRef', 'statsLineState', 'defaultT', 'usePricing', 'useUsageDialogTarget', 'createPortal', `return (${source})`)(...Object.values(core), React, h, useState, noop, noop, () => ({ current: null }), { get: () => prefs }, core.createTranslator(core.MESSAGES_ZH), () => ({ rules: pricing }), () => target, (content, container) => ({ type: 'portal', props: { container }, children: [content].flat(Infinity) }))
|
|
21
21
|
return component({ useProjection: () => value, t: core.createTranslator(lang === 'en' ? core.MESSAGES_EN : core.MESSAGES_ZH) })
|
|
22
22
|
}
|
|
23
23
|
const nodes = (tree) => tree && typeof tree === 'object' ? [tree, ...tree.children.flatMap(nodes)] : []
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
|
4
|
+
import { UsageStore, sharedStore, __resetSharedStoreForTests } from '../src/store.js'
|
|
5
|
+
|
|
6
|
+
// 真实宿主域,只替换磁盘后端,不读取生产档案或调用回扫。
|
|
7
|
+
test('UO1 Given real DomainFacility with live pending owner, When a newcomer collides, Then old tokens remain writable exactly once', async () => {
|
|
8
|
+
const media = new Map(), closes = [], errors = []
|
|
9
|
+
const backend = { kv: { async open(spec) {
|
|
10
|
+
const data = media.get(spec.name) ?? { tables: { buckets: {} }, global: null }
|
|
11
|
+
media.set(spec.name, data)
|
|
12
|
+
return {
|
|
13
|
+
async loadAll() { return structuredClone(data) },
|
|
14
|
+
async setGlobal(value) { data.global = structuredClone(value) },
|
|
15
|
+
async putRecord(table, key, value) { data.tables[table][key] = structuredClone(value) },
|
|
16
|
+
async deleteRecord(table, key) { delete data.tables[table][key] },
|
|
17
|
+
async close() { closes.push(spec.name) },
|
|
18
|
+
}
|
|
19
|
+
} } }
|
|
20
|
+
const facility = new DomainFacility({ storage: { backend: { get: () => backend } }, emit() {}, logger: { warn() {} } }, { backend: 'memory' })
|
|
21
|
+
let old, next
|
|
22
|
+
try {
|
|
23
|
+
__resetSharedStoreForTests()
|
|
24
|
+
old = new UsageStore(facility, { onFlushError: error => errors.push(error.message) })
|
|
25
|
+
await old.readyPromise()
|
|
26
|
+
await old.markSeenSessions(['s1'])
|
|
27
|
+
await old.record({ time: Date.now(), model: 'probe/model', inputTokens: 17, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
|
|
28
|
+
next = sharedStore(facility)
|
|
29
|
+
await next.readyPromise()
|
|
30
|
+
assert.ok(next.degradation)
|
|
31
|
+
assert.deepEqual(closes, [])
|
|
32
|
+
assert.deepEqual(await old.seenSessions(), new Set(['s1']))
|
|
33
|
+
await old.flushNow()
|
|
34
|
+
await old.flushNow()
|
|
35
|
+
assert.deepEqual(errors, [])
|
|
36
|
+
const sums = Object.entries(media.get('usage_stats').tables.buckets).filter(([key]) => key.startsWith('D|')).reduce((sum, [, row]) => sum + row.inputTokens, 0)
|
|
37
|
+
assert.equal(sums, 17)
|
|
38
|
+
assert.equal(old.pending.size, 0)
|
|
39
|
+
await assert.rejects(next.record({ time: Date.now(), model: 'probe/model', inputTokens: 17 }), /usage store degraded/)
|
|
40
|
+
} finally {
|
|
41
|
+
clearTimeout(old?.flushTimer); clearTimeout(next?.flushTimer)
|
|
42
|
+
await facility.closeAll()
|
|
43
|
+
__resetSharedStoreForTests()
|
|
44
|
+
}
|
|
45
|
+
})
|