dsh-llm-verifier 0.4.0 → 0.6.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 +133 -25
- package/lib/{auto-B5V49e-A.js → auto-DAMeKRkK.js} +775 -170
- package/lib/auto-DAMeKRkK.js.map +1 -0
- package/lib/{caller-DR_C7ueP.js → caller-BUK7OgUs.js} +168 -14
- package/lib/caller-BUK7OgUs.js.map +1 -0
- package/lib/caller.js +2 -2
- package/lib/client.js +1847 -846
- package/lib/client.js.map +1 -1
- package/lib/core.js +267 -11
- package/lib/core.js.map +1 -1
- package/lib/index.js +2985 -364
- package/lib/index.js.map +1 -1
- package/lib/replay.js +290 -3
- package/lib/replay.js.map +1 -1
- package/lib/types/auto.d.ts +69 -2
- package/lib/types/auto.d.ts.map +1 -1
- package/lib/types/auto.js +216 -23
- package/lib/types/auto.js.map +1 -1
- package/lib/types/cache.d.ts +9 -0
- package/lib/types/cache.d.ts.map +1 -1
- package/lib/types/cache.js +1 -1
- package/lib/types/cache.js.map +1 -1
- package/lib/types/caller.d.ts +23 -0
- package/lib/types/caller.d.ts.map +1 -1
- package/lib/types/caller.js +180 -14
- package/lib/types/caller.js.map +1 -1
- package/lib/types/client-fields.d.ts +174 -0
- package/lib/types/client-fields.d.ts.map +1 -0
- package/lib/types/client-fields.js +456 -0
- package/lib/types/client-fields.js.map +1 -0
- package/lib/types/client-i18n.d.ts +255 -1
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +180 -4
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +57 -44
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +286 -43
- package/lib/types/client.js.map +1 -1
- package/lib/types/config.d.ts +36 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +21 -0
- package/lib/types/config.js.map +1 -1
- package/lib/types/core.d.ts +155 -4
- package/lib/types/core.d.ts.map +1 -1
- package/lib/types/core.js +271 -10
- package/lib/types/core.js.map +1 -1
- package/lib/types/engine.d.ts +76 -2
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +364 -94
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +906 -126
- package/lib/types/index.js.map +1 -1
- package/lib/types/process-activity.d.ts +109 -0
- package/lib/types/process-activity.d.ts.map +1 -0
- package/lib/types/process-activity.js +135 -0
- package/lib/types/process-activity.js.map +1 -0
- package/lib/types/process-selection.d.ts +572 -0
- package/lib/types/process-selection.d.ts.map +1 -0
- package/lib/types/process-selection.js +1183 -0
- package/lib/types/process-selection.js.map +1 -0
- package/lib/types/replay.d.ts +188 -0
- package/lib/types/replay.d.ts.map +1 -1
- package/lib/types/replay.js +231 -1
- package/lib/types/replay.js.map +1 -1
- package/lib/types/router.d.ts +321 -5
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +600 -100
- package/lib/types/router.js.map +1 -1
- package/lib/types/session.d.ts.map +1 -1
- package/lib/types/session.js +5 -1
- package/lib/types/session.js.map +1 -1
- package/lib/types/statistics.d.ts +92 -0
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +116 -2
- package/lib/types/statistics.js.map +1 -1
- package/package.json +2 -2
- package/scripts/eval-replay.mjs +55 -5
- package/scripts/sync-installed-profiles.mjs +113 -0
- package/src/auto.test.ts +195 -4
- package/src/auto.ts +249 -28
- package/src/cache.ts +10 -1
- package/src/caller.test.ts +202 -3
- package/src/caller.ts +195 -13
- package/src/client-fields.test.ts +309 -0
- package/src/client-fields.ts +619 -0
- package/src/client-i18n.ts +212 -3
- package/src/client.test.ts +45 -2
- package/src/client.tsx +470 -162
- package/src/config.test.ts +37 -0
- package/src/config.ts +57 -0
- package/src/core.test.ts +210 -0
- package/src/core.ts +327 -10
- package/src/engine.test.ts +322 -2
- package/src/engine.ts +426 -105
- package/src/index.test.ts +1210 -3
- package/src/index.ts +880 -110
- package/src/parity.test.ts +50 -1
- package/src/process-activity.test.ts +91 -0
- package/src/process-activity.ts +177 -0
- package/src/process-selection.test.ts +911 -0
- package/src/process-selection.ts +1429 -0
- package/src/replay.test.ts +168 -1
- package/src/replay.ts +333 -1
- package/src/router.test.ts +725 -1
- package/src/router.ts +772 -89
- package/src/session.test.ts +34 -0
- package/src/session.ts +5 -1
- package/src/statistics.test.ts +157 -0
- package/src/statistics.ts +178 -2
- package/lib/auto-B5V49e-A.js.map +0 -1
- package/lib/caller-DR_C7ueP.js.map +0 -1
package/src/caller.ts
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
import { BlockAssembler, ReasoningEffortId, createUserMessage, type ContentBlock, type FinishReason, type LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
2
2
|
import * as LlmModule from '@deepseek-ai/dsh-llm'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Deep-freeze a call-options graph, leaving any live AbortSignal mutable.
|
|
6
|
+
*
|
|
7
|
+
* The host re-exports its own `deepFreeze` from `@deepseek-ai/dsh-llm` on the 0.1.1 line; the
|
|
8
|
+
* 0.1.5 line moved the helper to `@deepseek-ai/dsh-util-values` and no longer re-exports it, so
|
|
9
|
+
* the fallback below is what actually runs there. Both implementations must carry the same rule
|
|
10
|
+
* ("leaving live AbortSignal objects mutable" — the host's own wording), because freezing a
|
|
11
|
+
* signal breaks the transport twice over: Node >= 26.5 initializes the signal's internal event
|
|
12
|
+
* map lazily, so the first `addEventListener` throws "Cannot assign to read only property
|
|
13
|
+
* 'Symbol(kEvents)'", and on every Node version the attempt timeout's `controller.abort()` throws
|
|
14
|
+
* the same way on 'Symbol(kAborted)'. The signal belongs to this call's retry loop and is still
|
|
15
|
+
* unsubscribed when the options are frozen, which is what makes the ordering fatal.
|
|
16
|
+
*/
|
|
4
17
|
function deepFreeze<T>(value: T): T {
|
|
5
18
|
const mod = LlmModule as unknown as Record<string, unknown>
|
|
6
19
|
if (typeof mod.deepFreeze === 'function') {
|
|
7
20
|
return (mod.deepFreeze as Function)(value)
|
|
8
21
|
}
|
|
22
|
+
return fallbackDeepFreeze(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The graph walk used when the host exports no `deepFreeze`; exported for tests.
|
|
27
|
+
* @param value - value to freeze.
|
|
28
|
+
* @returns The same value, with every reachable enumerable child frozen except live AbortSignals.
|
|
29
|
+
*/
|
|
30
|
+
export function fallbackDeepFreeze<T>(value: T): T {
|
|
31
|
+
// See deepFreeze(): a frozen signal fails on the transport's first subscription.
|
|
32
|
+
if (value instanceof AbortSignal) return value
|
|
9
33
|
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
10
34
|
Object.freeze(value)
|
|
11
35
|
for (const key of Object.keys(value)) {
|
|
12
|
-
|
|
36
|
+
fallbackDeepFreeze((value as Record<string, unknown>)[key])
|
|
13
37
|
}
|
|
14
38
|
}
|
|
15
39
|
return value
|
|
@@ -50,13 +74,92 @@ export interface UsageStats {
|
|
|
50
74
|
cachedInputTokens: number
|
|
51
75
|
outputTokens: number
|
|
52
76
|
reasoningTokens: number
|
|
77
|
+
/**
|
|
78
|
+
* At least one attempt's usage is UNKNOWN (an earlier attempt failed and was retried, or a
|
|
79
|
+
* response came back unusable). The token counts are a floor, never a confident total.
|
|
80
|
+
*/
|
|
81
|
+
usageIncomplete?: boolean
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
export type ScoringMode = 'top-logprobs' | 'explicit-tag'
|
|
56
|
-
export interface VerifierCompletion extends CompletionLogprobs {
|
|
85
|
+
export interface VerifierCompletion extends CompletionLogprobs {
|
|
86
|
+
usage: UsageStats
|
|
87
|
+
scoringMode: ScoringMode
|
|
88
|
+
/**
|
|
89
|
+
* The direct logprob transport was attempted and the provider rejected it, so this
|
|
90
|
+
* answer came from the explicit-tag fallback. Diagnostics only: it never changes the
|
|
91
|
+
* score, and it is recorded so a silent downgrade is visible in the statistics.
|
|
92
|
+
*/
|
|
93
|
+
channelFallback?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where a finally-failed request records how many attempts it already spent.
|
|
98
|
+
*
|
|
99
|
+
* A failure that never returned usage leaves the tokens UNKNOWN, not zero. The attempt
|
|
100
|
+
* count is the one fact the transport does know, so it is carried on the error itself
|
|
101
|
+
* (the engine catches per-judge errors) instead of being discarded.
|
|
102
|
+
*/
|
|
103
|
+
const REQUEST_ATTEMPTS = Symbol('llm-verifier.requestAttempts')
|
|
104
|
+
|
|
105
|
+
/** Attempts one failed verifier request already spent; 0 when the error carries none. */
|
|
106
|
+
export function requestAttempts(error: unknown): number {
|
|
107
|
+
if (typeof error !== 'object' || error === null) return 0
|
|
108
|
+
const value = (error as { [REQUEST_ATTEMPTS]?: unknown })[REQUEST_ATTEMPTS]
|
|
109
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Usage the transport DID return for a response that then turned out unusable.
|
|
114
|
+
*
|
|
115
|
+
* A parse failure or a truncated judge answer happened after the request was billed, so the
|
|
116
|
+
* engine must not fold it into a zero-cost failure. The usage rides on the error next to the
|
|
117
|
+
* attempt count; one error can carry both (a retried call whose final answer also failed).
|
|
118
|
+
*/
|
|
119
|
+
const PARTIAL_USAGE = Symbol('llm-verifier.partialUsage')
|
|
120
|
+
|
|
121
|
+
/** Usage a completed-but-unusable response already cost; undefined when the error carries none. */
|
|
122
|
+
export function partialUsage<T = UsageStats>(error: unknown): T | undefined {
|
|
123
|
+
if (typeof error !== 'object' || error === null) return undefined
|
|
124
|
+
const value = (error as { [PARTIAL_USAGE]?: unknown })[PARTIAL_USAGE]
|
|
125
|
+
return typeof value === 'object' && value !== null ? value as T : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Attach the usage a completed-but-unusable response already cost to its error. */
|
|
129
|
+
export function attachUsage(error: unknown, usage: UsageStats): void {
|
|
130
|
+
if (typeof error === 'object' && error !== null) (error as { [PARTIAL_USAGE]?: UsageStats })[PARTIAL_USAGE] = usage
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* An error for a response that came back and was billed but produced no usable verdict.
|
|
135
|
+
*
|
|
136
|
+
* The usage is attached so the failure row reports known requests and tokens instead of zero.
|
|
137
|
+
*/
|
|
138
|
+
function unusable(reason: string, attempt: number, raw: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; reasoningTokens?: number } | undefined): Error {
|
|
139
|
+
const error = new Error('llm-verifier: ' + reason)
|
|
140
|
+
attachUsage(error, usage(attempt, raw ?? {}))
|
|
141
|
+
return error
|
|
142
|
+
}
|
|
57
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Describe why a finished stream is unusable.
|
|
146
|
+
*
|
|
147
|
+
* `FinishReason.failure` is the host's SERIALIZABLE facts record (`{message, code, status?,
|
|
148
|
+
* providerRetryAfterMs?, requestId?}`), not the error the adapter threw: `normalizeLlmFailure`
|
|
149
|
+
* detaches the facts at the adapter boundary, so the stack and the cause are already gone before
|
|
150
|
+
* this plugin sees them. The machine code and the provider status ARE carried, and dropping them
|
|
151
|
+
* left the board with one line and no way to tell an auth failure from a transport one.
|
|
152
|
+
*/
|
|
58
153
|
function failureMessage(finish: FinishReason): string | undefined {
|
|
59
|
-
if (finish.kind === 'error' || finish.kind === 'aborted')
|
|
154
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
155
|
+
const failure = finish.failure
|
|
156
|
+
const facts = [
|
|
157
|
+
failure.code,
|
|
158
|
+
failure.status === undefined ? undefined : 'HTTP ' + failure.status,
|
|
159
|
+
failure.requestId === undefined ? undefined : 'request ' + failure.requestId,
|
|
160
|
+
].filter((fact): fact is string => typeof fact === 'string' && fact.length > 0)
|
|
161
|
+
return facts.length > 0 ? failure.message + ' [' + facts.join(', ') + ']' : failure.message
|
|
162
|
+
}
|
|
60
163
|
if (finish.kind === 'max-tokens') return 'verifier response reached max tokens before completing its answer'
|
|
61
164
|
return undefined
|
|
62
165
|
}
|
|
@@ -88,10 +191,59 @@ const RETRYABLE_MESSAGE = /rate|quota|timeout|timed out|temporar|network|fetch|s
|
|
|
88
191
|
* @param run - one attempt; receives its own deadline signal and 1-based attempt number.
|
|
89
192
|
* @returns The first successful completion.
|
|
90
193
|
*/
|
|
194
|
+
function attachAttempts(error: unknown, attempt: number): void {
|
|
195
|
+
if (typeof error === 'object' && error !== null) (error as { [REQUEST_ATTEMPTS]?: number })[REQUEST_ATTEMPTS] = attempt
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A fresh error for one aborted call.
|
|
200
|
+
*
|
|
201
|
+
* Never annotate and rethrow the shared `signal.reason`: several concurrent requests share it,
|
|
202
|
+
* and the engine attaches its accumulator to the thrown error, so one object carries several
|
|
203
|
+
* accumulators and they get merged repeatedly. The wrapper preserves the message and name.
|
|
204
|
+
*/
|
|
205
|
+
function abortFailure(reason: unknown, attempt: number): Error {
|
|
206
|
+
const source = reason instanceof Error ? reason : new Error(reason === undefined ? 'llm-verifier: request aborted' : String(reason))
|
|
207
|
+
const error = new Error(source.message)
|
|
208
|
+
error.name = source.name
|
|
209
|
+
attachAttempts(error, attempt)
|
|
210
|
+
return error
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Token-only addition: call/attempt counters already describe the logical request. */
|
|
214
|
+
function addTokens(target: Pick<UsageStats, 'inputTokens' | 'cachedInputTokens' | 'outputTokens' | 'reasoningTokens'>, source: Pick<UsageStats, 'inputTokens' | 'cachedInputTokens' | 'outputTokens' | 'reasoningTokens'>): void {
|
|
215
|
+
target.inputTokens += source.inputTokens
|
|
216
|
+
target.cachedInputTokens += source.cachedInputTokens
|
|
217
|
+
target.outputTokens += source.outputTokens
|
|
218
|
+
target.reasoningTokens += source.reasoningTokens
|
|
219
|
+
}
|
|
220
|
+
|
|
91
221
|
async function retrying<T>(config: VerifierClientConfig, signal: AbortSignal | undefined, run: (signal: AbortSignal, attempt: number) => Promise<T>): Promise<T> {
|
|
92
222
|
let attempt = 0
|
|
223
|
+
// Tokens an earlier attempt already paid for but never delivered a usable result. They are
|
|
224
|
+
// real cost, so they follow the chain into whichever exit happens (success, final failure or
|
|
225
|
+
// cancellation) instead of being discarded when no attempt ultimately succeeds.
|
|
226
|
+
const carried = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0 }
|
|
227
|
+
let unknownFailure = false
|
|
228
|
+
// The usage the chain must be able to report: everything already spent plus this attempt's own
|
|
229
|
+
// billed response. `unknown` (some attempt's tokens never came back) is what makes it a floor.
|
|
230
|
+
const spent = (billed: UsageStats | undefined, unknown: boolean): UsageStats => ({
|
|
231
|
+
calls: billed === undefined ? 0 : billed.calls,
|
|
232
|
+
attempts: attempt,
|
|
233
|
+
retries: Math.max(0, attempt - 1),
|
|
234
|
+
inputTokens: carried.inputTokens + (billed?.inputTokens ?? 0),
|
|
235
|
+
cachedInputTokens: carried.cachedInputTokens + (billed?.cachedInputTokens ?? 0),
|
|
236
|
+
outputTokens: carried.outputTokens + (billed?.outputTokens ?? 0),
|
|
237
|
+
reasoningTokens: carried.reasoningTokens + (billed?.reasoningTokens ?? 0),
|
|
238
|
+
...(unknown ? { usageIncomplete: true } : {}),
|
|
239
|
+
})
|
|
240
|
+
const cancel = (reason: unknown, billed: UsageStats | undefined, unknown: boolean): never => {
|
|
241
|
+
const error = abortFailure(reason, attempt)
|
|
242
|
+
attachUsage(error, spent(billed, unknown))
|
|
243
|
+
throw error
|
|
244
|
+
}
|
|
93
245
|
while (true) {
|
|
94
|
-
if (signal?.aborted)
|
|
246
|
+
if (signal?.aborted) cancel(signal.reason, undefined, false)
|
|
95
247
|
attempt += 1
|
|
96
248
|
const controller = new AbortController()
|
|
97
249
|
let timedOut = false
|
|
@@ -99,14 +251,39 @@ async function retrying<T>(config: VerifierClientConfig, signal: AbortSignal | u
|
|
|
99
251
|
const abort = () => controller.abort(signal?.reason)
|
|
100
252
|
signal?.addEventListener('abort', abort, { once: true })
|
|
101
253
|
try {
|
|
102
|
-
|
|
254
|
+
const value = await run(controller.signal, attempt)
|
|
255
|
+
if (typeof value === 'object' && value !== null) {
|
|
256
|
+
const usage = (value as { usage?: UsageStats }).usage
|
|
257
|
+
if (usage !== undefined) {
|
|
258
|
+
addTokens(usage, carried)
|
|
259
|
+
// Only a failed attempt whose tokens never came back makes the total a floor.
|
|
260
|
+
if (unknownFailure) usage.usageIncomplete = true
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return value
|
|
103
264
|
} catch (error) {
|
|
104
|
-
|
|
265
|
+
const billed = partialUsage<UsageStats>(error)
|
|
266
|
+
if (signal?.aborted) cancel(signal.reason ?? error, billed, unknownFailure || billed === undefined)
|
|
105
267
|
// A deadline abort is retryable even when the adapter wraps the reason in
|
|
106
268
|
// its own error type with an unrelated message.
|
|
107
269
|
const retryable = timedOut || (error instanceof Error && RETRYABLE_MESSAGE.test(error.message))
|
|
108
|
-
if (attempt > config.maxRetries || !retryable)
|
|
109
|
-
|
|
270
|
+
if (attempt > config.maxRetries || !retryable) {
|
|
271
|
+
// Every attempt of the chain goes onto the thrown error: the last response alone would
|
|
272
|
+
// report a fraction of the requests and tokens the invocation really spent.
|
|
273
|
+
if (billed === undefined) unknownFailure = true
|
|
274
|
+
const failure = typeof error === 'object' && error !== null ? error : new Error(String(error))
|
|
275
|
+
attachAttempts(failure, attempt)
|
|
276
|
+
attachUsage(failure, spent(billed, unknownFailure || billed === undefined))
|
|
277
|
+
throw failure
|
|
278
|
+
}
|
|
279
|
+
if (billed === undefined) unknownFailure = true
|
|
280
|
+
else addTokens(carried, billed)
|
|
281
|
+
try {
|
|
282
|
+
await delay(Math.min(30000, config.retryBaseDelayMs * 2 ** (attempt - 1) * (0.8 + Math.random() * 0.4)), signal)
|
|
283
|
+
} catch (waitError) {
|
|
284
|
+
// Cancelled while waiting to retry: the attempts and the tokens already spent are real.
|
|
285
|
+
cancel(signal?.reason ?? waitError, undefined, unknownFailure)
|
|
286
|
+
}
|
|
110
287
|
} finally {
|
|
111
288
|
clearTimeout(timeout)
|
|
112
289
|
signal?.removeEventListener('abort', abort)
|
|
@@ -165,14 +342,14 @@ async function callTextCompletion(config: VerifierClientConfig, prompt: string,
|
|
|
165
342
|
for await (const chunk of config.llm.stream(options)) assembler.push(chunk)
|
|
166
343
|
let truncated = false
|
|
167
344
|
if (assembler.finish.kind === 'max-tokens') {
|
|
168
|
-
if (!tolerateTruncation) throw
|
|
345
|
+
if (!tolerateTruncation) throw unusable('model call failed: ' + failureMessage(assembler.finish), attempt, assembler.usage)
|
|
169
346
|
truncated = true
|
|
170
347
|
} else {
|
|
171
348
|
const failed = failureMessage(assembler.finish)
|
|
172
|
-
if (failed !== undefined) throw
|
|
349
|
+
if (failed !== undefined) throw unusable('model call failed: ' + failed, attempt, assembler.usage)
|
|
173
350
|
}
|
|
174
351
|
const text = assembler.blocks().filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text').map(block => block.text).join('')
|
|
175
|
-
if (!text.trim()) throw
|
|
352
|
+
if (!text.trim()) throw unusable('selected DSH model produced no text', attempt, assembler.usage)
|
|
176
353
|
// DSH adapters expose provider-neutral text/usage but not top-logprob candidates.
|
|
177
354
|
// extractScore() therefore uses the model's explicit final A–T tags.
|
|
178
355
|
return { text, tokens: [], positions: [], scoringMode: 'explicit-tag', usage: usage(attempt, assembler.usage), truncated }
|
|
@@ -211,6 +388,7 @@ export async function predictScoringChannel(config: VerifierClientConfig): Promi
|
|
|
211
388
|
|
|
212
389
|
async function callAutomatic(config: VerifierClientConfig, prompt: string, signal: AbortSignal | undefined, images: readonly VerifierImage[] | undefined, attempt: number): Promise<VerifierCompletion> {
|
|
213
390
|
await config.topLogprobCapabilities.ensureLoaded()
|
|
391
|
+
let fellBack = false
|
|
214
392
|
if (!config.topLogprobCapabilities.isUnsupported(config.provider, config.model)) {
|
|
215
393
|
const route = await resolveTopLogprobRoute(config.ctx, config.provider)
|
|
216
394
|
if (route !== undefined) {
|
|
@@ -221,10 +399,14 @@ async function callAutomatic(config: VerifierClientConfig, prompt: string, signa
|
|
|
221
399
|
// the whole verification.
|
|
222
400
|
if (!(error instanceof TopLogprobsUnsupportedError)) throw error
|
|
223
401
|
config.topLogprobCapabilities.markUnsupported(config.provider, config.model)
|
|
402
|
+
// A runtime downgrade AFTER a live attempt, unlike the capability probe that
|
|
403
|
+
// finds no route at all: only this one is a fallback worth counting.
|
|
404
|
+
fellBack = true
|
|
224
405
|
}
|
|
225
406
|
} else config.topLogprobCapabilities.markUnsupported(config.provider, config.model)
|
|
226
407
|
}
|
|
227
|
-
|
|
408
|
+
const completion = await callExplicitTag(config, prompt, signal, images, attempt)
|
|
409
|
+
return fellBack ? { ...completion, channelFallback: true } : completion
|
|
228
410
|
}
|
|
229
411
|
|
|
230
412
|
export async function callVerifier(config: VerifierClientConfig, prompt: string, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<VerifierCompletion> {
|
|
@@ -330,5 +512,5 @@ export async function callVerifierText(config: VerifierClientConfig, prompt: str
|
|
|
330
512
|
const invoke = () => retrying(config, signal, (attemptSignal, attempt) => callExplicitTag(config, prompt, attemptSignal, undefined, attempt))
|
|
331
513
|
return config.limiter === undefined ? invoke() : config.limiter.run(invoke, signal)
|
|
332
514
|
}
|
|
333
|
-
export function addUsage(target: UsageStats, source: UsageStats): void { for (const key of ['calls', 'attempts', 'retries', 'inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningTokens'] as const) target[key] += source[key] }
|
|
515
|
+
export function addUsage(target: UsageStats, source: UsageStats): void { for (const key of ['calls', 'attempts', 'retries', 'inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningTokens'] as const) target[key] += source[key] ?? 0 }
|
|
334
516
|
export function emptyUsage(): UsageStats { return { calls: 0, attempts: 0, retries: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0 } }
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { zh, en, computeJudgeCount, computeWorstCaseBudget } from './client-i18n.ts'
|
|
3
|
+
import {
|
|
4
|
+
CONFIG_DEFAULTS,
|
|
5
|
+
FIELDS,
|
|
6
|
+
PROFILES,
|
|
7
|
+
PROFILE_IDS,
|
|
8
|
+
SECTIONS,
|
|
9
|
+
acceptsNumber,
|
|
10
|
+
activeProfile,
|
|
11
|
+
applyProfile,
|
|
12
|
+
fieldsOfSection,
|
|
13
|
+
isFieldChanged,
|
|
14
|
+
isFieldVisible,
|
|
15
|
+
issueMap,
|
|
16
|
+
issueMessageKey,
|
|
17
|
+
recommendedBudgets,
|
|
18
|
+
resetField,
|
|
19
|
+
renderSections,
|
|
20
|
+
sectionSummary,
|
|
21
|
+
textIssue,
|
|
22
|
+
validateValues,
|
|
23
|
+
valuesFromView,
|
|
24
|
+
type FieldSpec,
|
|
25
|
+
type Values,
|
|
26
|
+
} from './client-fields.ts'
|
|
27
|
+
|
|
28
|
+
/** A base that passes every validation: the two identity fields have no default. */
|
|
29
|
+
const base: Values = { ...CONFIG_DEFAULTS, provider: 'deepseek-official', model: 'deepseek-flash' }
|
|
30
|
+
const translateFor = (dictionary: Record<string, string>) => (key: string): string | undefined => dictionary[key]
|
|
31
|
+
const zhT = translateFor(zh as unknown as Record<string, string>)
|
|
32
|
+
const enT = translateFor(en as unknown as Record<string, string>)
|
|
33
|
+
const format = (template: string, params?: Record<string, string | number>): string =>
|
|
34
|
+
(zhT(template) ?? template).replace(/\{(\w+)\}/gu, (_match, name: string) => String(params?.[name] ?? ''))
|
|
35
|
+
|
|
36
|
+
const field = (key: string): FieldSpec => {
|
|
37
|
+
const found = FIELDS.find(candidate => candidate.key === key)
|
|
38
|
+
if (!found) throw new Error('unknown field ' + key)
|
|
39
|
+
return found
|
|
40
|
+
}
|
|
41
|
+
const codesFor = (values: Values, key: string): string[] =>
|
|
42
|
+
validateValues(values)
|
|
43
|
+
.filter(issue => issue.key === key)
|
|
44
|
+
.map(issue => issue.code)
|
|
45
|
+
|
|
46
|
+
describe('settings field registry', () => {
|
|
47
|
+
it('covers every declared field exactly once and only known sections', () => {
|
|
48
|
+
const keys = FIELDS.map(entry => entry.key)
|
|
49
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
50
|
+
expect(keys.length).toBeGreaterThan(40)
|
|
51
|
+
const sections = new Set(SECTIONS.map(section => section.id))
|
|
52
|
+
for (const entry of FIELDS) expect(sections.has(entry.section)).toBe(true)
|
|
53
|
+
// every section is addressable and non-empty except none: each id must appear
|
|
54
|
+
for (const section of SECTIONS) expect(FIELDS.some(entry => entry.section === section.id)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('gives every field a default to fall back to', () => {
|
|
58
|
+
for (const entry of FIELDS) {
|
|
59
|
+
const optional = entry.key === 'reasoningEffort' || entry.key === 'label'
|
|
60
|
+
expect(optional || Object.prototype.hasOwnProperty.call(CONFIG_DEFAULTS, entry.key)).toBe(true)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('resolves every localized title and help string in both dictionaries', () => {
|
|
65
|
+
for (const entry of FIELDS) {
|
|
66
|
+
for (const dictionary of [zhT, enT]) {
|
|
67
|
+
expect(typeof dictionary(entry.titleKey)).toBe('string')
|
|
68
|
+
expect(typeof dictionary(entry.helpKey)).toBe('string')
|
|
69
|
+
if (entry.helpKeyOff) expect(typeof dictionary(entry.helpKeyOff)).toBe('string')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('resolves every section title, profile and validation message', () => {
|
|
75
|
+
const keys = [
|
|
76
|
+
...SECTIONS.map(section => section.titleKey),
|
|
77
|
+
...PROFILES.map(profile => profile.titleKey),
|
|
78
|
+
'settings.invalid.required',
|
|
79
|
+
'settings.invalid.range',
|
|
80
|
+
'settings.invalid.min',
|
|
81
|
+
'settings.invalid.max',
|
|
82
|
+
'settings.invalid.integer',
|
|
83
|
+
'settings.invalid.cacheDirRelative',
|
|
84
|
+
'settings.invalid.routeBudget',
|
|
85
|
+
'settings.summary.line',
|
|
86
|
+
'settings.summary.mode',
|
|
87
|
+
'settings.summary.routing',
|
|
88
|
+
'settings.summary.budgets',
|
|
89
|
+
'settings.summary.storage',
|
|
90
|
+
'settings.summary.execution',
|
|
91
|
+
'settings.summary.on',
|
|
92
|
+
'settings.summary.off',
|
|
93
|
+
'settings.summary.manual',
|
|
94
|
+
'settings.summary.manualShort',
|
|
95
|
+
'settings.summary.judges',
|
|
96
|
+
'settings.unit.ms',
|
|
97
|
+
'settings.unit.chars',
|
|
98
|
+
'settings.unit.calls',
|
|
99
|
+
'settings.unit.tokens',
|
|
100
|
+
'settings.fieldReset',
|
|
101
|
+
'settings.fieldChanged',
|
|
102
|
+
'settings.recommend',
|
|
103
|
+
'settings.profile.title',
|
|
104
|
+
'settings.profile.hint',
|
|
105
|
+
'settings.profile.custom',
|
|
106
|
+
'settings.advancedBadge',
|
|
107
|
+
'settings.expandAll',
|
|
108
|
+
'settings.collapseAll',
|
|
109
|
+
'settings.sectionExpand',
|
|
110
|
+
'settings.sectionCollapse',
|
|
111
|
+
'settings.jumpToIssue',
|
|
112
|
+
'settings.invalid.summary',
|
|
113
|
+
]
|
|
114
|
+
for (const key of keys) {
|
|
115
|
+
expect(typeof zhT(key), 'zh ' + key).toBe('string')
|
|
116
|
+
expect(typeof enT(key), 'en ' + key).toBe('string')
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('settings values', () => {
|
|
122
|
+
it('builds exactly the defaults from an empty namespace view', () => {
|
|
123
|
+
expect(valuesFromView({})).toEqual(CONFIG_DEFAULTS)
|
|
124
|
+
expect(valuesFromView(undefined)).toEqual(CONFIG_DEFAULTS)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('keeps the defaults self-consistent, so a fresh form can always be saved', () => {
|
|
128
|
+
expect(validateValues(base)).toEqual([])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('reads overrides and falls back to the safe member of every enum', () => {
|
|
132
|
+
const values = valuesFromView({
|
|
133
|
+
enabled: false,
|
|
134
|
+
autoVerifyMode: 'strict',
|
|
135
|
+
criteriaPreset: 'debug',
|
|
136
|
+
autoRouteSemantic: false,
|
|
137
|
+
maxRetries: 0,
|
|
138
|
+
cacheDir: ' nested/cache ',
|
|
139
|
+
label: ' judge A ',
|
|
140
|
+
reasoningEffort: 'high',
|
|
141
|
+
extraJudges: [{ provider: 'p', model: 'm' }],
|
|
142
|
+
})
|
|
143
|
+
expect(values.enabled).toBe(false)
|
|
144
|
+
expect(values.autoVerifyMode).toBe('strict')
|
|
145
|
+
expect(values.criteriaPreset).toBe('debug')
|
|
146
|
+
expect(values.autoRouteSemantic).toBe(false)
|
|
147
|
+
expect(values.maxRetries).toBe(0)
|
|
148
|
+
expect(values.cacheDir).toBe('nested/cache')
|
|
149
|
+
expect(values.label).toBe('judge A')
|
|
150
|
+
expect(values.reasoningEffort).toBe('high')
|
|
151
|
+
expect(values.extraJudges).toHaveLength(1)
|
|
152
|
+
expect(valuesFromView({ autoVerifyMode: 'nonsense', criteriaPreset: 'nonsense' })).toMatchObject({
|
|
153
|
+
autoVerifyMode: 'smart',
|
|
154
|
+
criteriaPreset: 'coding',
|
|
155
|
+
})
|
|
156
|
+
// a discarded enum member must not leak an invalid value into the draft
|
|
157
|
+
expect(validateValues(valuesFromView({ provider: 'p', model: 'm', autoVerifyMode: 'nonsense', criteriaPreset: 'nonsense' }))).toEqual([])
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('settings validation', () => {
|
|
162
|
+
it('mirrors the resolveConfig bounds that a text box can violate', () => {
|
|
163
|
+
expect(codesFor({ ...base, autoVerifyThreshold: 1.5 }, 'autoVerifyThreshold')).toEqual(['range'])
|
|
164
|
+
expect(codesFor({ ...base, autoRouteMinConfidence: -0.1 }, 'autoRouteMinConfidence')).toEqual(['range'])
|
|
165
|
+
expect(codesFor({ ...base, autoTrackCompletionThreshold: 2 }, 'autoTrackCompletionThreshold')).toEqual(['range'])
|
|
166
|
+
expect(codesFor({ ...base, temperature: 2.5 }, 'temperature')).toEqual(['range'])
|
|
167
|
+
expect(codesFor({ ...base, autoRouteMaxCandidates: 2 }, 'autoRouteMaxCandidates')).toEqual(['range'])
|
|
168
|
+
expect(codesFor({ ...base, autoRouteMaxCandidates: 17 }, 'autoRouteMaxCandidates')).toEqual(['range'])
|
|
169
|
+
expect(codesFor({ ...base, autoVerifyMaxChars: 999 }, 'autoVerifyMaxChars')).toEqual(['min'])
|
|
170
|
+
expect(codesFor({ ...base, maxConcurrency: 0 }, 'maxConcurrency')).toEqual(['min'])
|
|
171
|
+
expect(codesFor({ ...base, autoMaxModelCallsPerTask: 0 }, 'autoMaxModelCallsPerTask')).toEqual(['min'])
|
|
172
|
+
expect(codesFor({ ...base, maxRetries: 1.5 }, 'maxRetries')).toEqual(['integer'])
|
|
173
|
+
expect(codesFor({ ...base, estimatedOutputUsdPerMillion: -1 }, 'estimatedOutputUsdPerMillion')).toEqual(['min'])
|
|
174
|
+
expect(codesFor({ ...base, timeoutMs: Number.NaN }, 'timeoutMs')).toEqual(['required'])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('keeps the two route budgets ordered', () => {
|
|
178
|
+
expect(codesFor({ ...base, autoRouteMaxItemChars: 20000, autoRouteMaxInputChars: 30000 }, 'autoRouteMaxInputChars')).toEqual(['routeBudget'])
|
|
179
|
+
expect(codesFor({ ...base, autoRouteMaxItemChars: 20000, autoRouteMaxInputChars: 40000 }, 'autoRouteMaxInputChars')).toEqual([])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('rejects a cache directory outside the topic, and empty identity fields', () => {
|
|
183
|
+
expect(codesFor({ ...base, cacheDir: 'C:\\verifier' }, 'cacheDir')).toEqual(['cacheDirRelative'])
|
|
184
|
+
expect(codesFor({ ...base, cacheDir: 'a/../b' }, 'cacheDir')).toEqual(['cacheDirRelative'])
|
|
185
|
+
expect(codesFor({ ...base, cacheDir: '/etc/verifier' }, 'cacheDir')).toEqual(['cacheDirRelative'])
|
|
186
|
+
expect(codesFor({ ...base, cacheDir: ' ' }, 'cacheDir')).toEqual(['required'])
|
|
187
|
+
expect(codesFor({ ...base, cacheDir: 'nested/verifier' }, 'cacheDir')).toEqual([])
|
|
188
|
+
expect(codesFor({ ...base, provider: '' }, 'provider')).toEqual(['required'])
|
|
189
|
+
expect(codesFor({ ...base, model: ' ' }, 'model')).toEqual(['required'])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('never blocks a missing custom rubric file: the resolver falls back and reports why', () => {
|
|
193
|
+
const values = { ...base, criteriaPreset: 'custom' as const, criteriaFile: '' }
|
|
194
|
+
expect(validateValues(values)).toEqual([])
|
|
195
|
+
// ...and the field is only rendered while the custom preset is selected
|
|
196
|
+
expect(isFieldVisible(field('criteriaFile'), values)).toBe(true)
|
|
197
|
+
expect(isFieldVisible(field('criteriaFile'), base)).toBe(false)
|
|
198
|
+
expect(codesFor(base, 'criteriaFile')).toEqual([])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('reports parse-time feedback for a half-typed number', () => {
|
|
202
|
+
const threshold = field('autoVerifyThreshold')
|
|
203
|
+
expect(textIssue(threshold, '0.7')).toBeNull()
|
|
204
|
+
expect(textIssue(threshold, '')).toBeNull()
|
|
205
|
+
expect(textIssue(threshold, '1.5')?.code).toBe('range')
|
|
206
|
+
expect(textIssue(threshold, 'abc')?.code).toBe('required')
|
|
207
|
+
expect(textIssue(field('autoRouteMaxCandidates'), '3.5')?.code).toBe('integer')
|
|
208
|
+
expect(textIssue(field('maxRetries'), '0')).toBeNull()
|
|
209
|
+
expect(acceptsNumber(threshold, '0.65')).toBe(true)
|
|
210
|
+
expect(acceptsNumber(threshold, '1.5')).toBe(false)
|
|
211
|
+
expect(acceptsNumber(threshold, '')).toBe(false)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('maps issues to the localized message keys and keeps the first issue per field', () => {
|
|
215
|
+
const issues = validateValues({ ...base, autoVerifyThreshold: 5, temperature: 5 })
|
|
216
|
+
const map = issueMap(issues)
|
|
217
|
+
expect(map.get('autoVerifyThreshold')?.code).toBe('range')
|
|
218
|
+
expect(issueMessageKey({ key: 'autoVerifyThreshold', code: 'range' })).toBe('settings.invalid.range')
|
|
219
|
+
expect(issues.map(issue => issue.key)).toContain('temperature')
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('settings defaults and profiles', () => {
|
|
224
|
+
it('tracks a changed field and restores it', () => {
|
|
225
|
+
const threshold = field('autoVerifyThreshold')
|
|
226
|
+
expect(isFieldChanged(threshold, base)).toBe(false)
|
|
227
|
+
const changed = { ...base, autoVerifyThreshold: 0.8 }
|
|
228
|
+
expect(isFieldChanged(threshold, changed)).toBe(true)
|
|
229
|
+
expect(resetField(changed, 'autoVerifyThreshold').autoVerifyThreshold).toBe(CONFIG_DEFAULTS.autoVerifyThreshold)
|
|
230
|
+
// identity fields have no meaningful default and must not offer a reset
|
|
231
|
+
expect(isFieldChanged(field('provider'), { ...base, provider: 'other' })).toBe(false)
|
|
232
|
+
expect(isFieldChanged(field('model'), { ...base, model: 'other' })).toBe(false)
|
|
233
|
+
expect(isFieldChanged(field('extraJudges'), base)).toBe(false)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('writes a coherent, warning-free configuration for every profile', () => {
|
|
237
|
+
const lean = { ...base, provider: 'other', model: 'other-model', criteriaPreset: 'ops' as const, maxConcurrency: 3 }
|
|
238
|
+
for (const id of PROFILE_IDS) {
|
|
239
|
+
const next = applyProfile(lean, id)
|
|
240
|
+
expect(validateValues(next), id).toEqual([])
|
|
241
|
+
// identity/plumbing settings survive a profile switch
|
|
242
|
+
expect(next.provider, id).toBe('other')
|
|
243
|
+
expect(next.model, id).toBe('other-model')
|
|
244
|
+
expect(next.criteriaPreset, id).toBe('ops')
|
|
245
|
+
expect(next.maxConcurrency, id).toBe(3)
|
|
246
|
+
const floor = recommendedBudgets(computeJudgeCount(next.extraJudges.length))
|
|
247
|
+
expect(next.autoMaxModelCallsPerTask, id).toBeGreaterThanOrEqual(floor.autoMaxModelCallsPerTask)
|
|
248
|
+
expect(next.autoMaxModelCallsPerSession, id).toBeGreaterThanOrEqual(floor.autoMaxModelCallsPerSession)
|
|
249
|
+
const worst = computeWorstCaseBudget(computeJudgeCount(next.extraJudges.length))
|
|
250
|
+
expect(next.autoMaxModelCallsPerTask, id).toBeGreaterThanOrEqual(worst.worstCaseTask)
|
|
251
|
+
expect(next.autoMaxModelCallsPerSession, id).toBeGreaterThanOrEqual(worst.worstCaseSession)
|
|
252
|
+
}
|
|
253
|
+
expect(applyProfile(lean, 'strict').autoVerifyMode).toBe('strict')
|
|
254
|
+
expect(applyProfile(lean, 'toolsOnly').autoVerifyMode).toBe('manual')
|
|
255
|
+
expect(applyProfile(lean, 'frugal')).toMatchObject({ autoVerifyMode: 'smart', autoVerifyTeamTasks: false, autoRouteMaxCandidates: 3 })
|
|
256
|
+
// the recommendation itself still respects the judge count
|
|
257
|
+
const shape = (budget: { worstCaseTask: number; worstCaseSession: number }) => ({
|
|
258
|
+
autoMaxModelCallsPerTask: budget.worstCaseTask,
|
|
259
|
+
autoMaxModelCallsPerSession: budget.worstCaseSession,
|
|
260
|
+
})
|
|
261
|
+
expect(recommendedBudgets(2)).toEqual(shape(computeWorstCaseBudget(2)))
|
|
262
|
+
expect(recommendedBudgets(0)).toEqual(shape(computeWorstCaseBudget(1)))
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('active profile', () => {
|
|
267
|
+
it('reports the profile a draft currently matches, and custom otherwise', () => {
|
|
268
|
+
expect(activeProfile(base)).toBe('balanced')
|
|
269
|
+
for (const id of PROFILE_IDS) expect(activeProfile(applyProfile(base, id)), id).toBe(id)
|
|
270
|
+
expect(activeProfile({ ...base, autoVerifyThreshold: 0.5 })).toBe('custom')
|
|
271
|
+
expect(activeProfile({ ...base, autoRouteSemantic: false })).toBe('custom')
|
|
272
|
+
expect(activeProfile(applyProfile(base, 'toolsOnly'))).toBe('toolsOnly')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('ignores the derived budgets when deciding which profile is active', () => {
|
|
276
|
+
const tuned = { ...applyProfile(base, 'strict'), autoMaxModelCallsPerTask: 321, autoMaxModelCallsPerSession: 654 }
|
|
277
|
+
expect(activeProfile(tuned)).toBe('strict')
|
|
278
|
+
expect(validateValues(tuned)).toEqual([])
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe('settings navigation', () => {
|
|
283
|
+
const visibleKeys = (values: Values): string[] =>
|
|
284
|
+
renderSections(values).flatMap(entry => entry.fields.map(entry => String(entry.key)))
|
|
285
|
+
|
|
286
|
+
it('renders every section with the fields that are visible for the draft', () => {
|
|
287
|
+
const all = renderSections(base)
|
|
288
|
+
expect(all.map(entry => entry.section.id)).toEqual(SECTIONS.map(section => section.id))
|
|
289
|
+
for (const entry of all) expect(entry.fields.length, entry.section.id).toBeGreaterThan(0)
|
|
290
|
+
expect(visibleKeys(base)).not.toContain('criteriaFile')
|
|
291
|
+
const custom = visibleKeys({ ...base, criteriaPreset: 'custom' })
|
|
292
|
+
expect(custom).toContain('criteriaFile')
|
|
293
|
+
expect(custom).toHaveLength(visibleKeys(base).length + 1)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('summarizes a collapsed section from the live draft', () => {
|
|
297
|
+
expect(sectionSummary('tools', { ...base, enabled: false }, zhT, format)).toBe(zhT('settings.summary.off'))
|
|
298
|
+
expect(sectionSummary('tools', base, zhT, format)).toBe(zhT('settings.summary.on'))
|
|
299
|
+
expect(sectionSummary('autoVerify', { ...base, autoVerifyMode: 'manual' }, zhT, format)).toBe(zhT('settings.summary.manualShort'))
|
|
300
|
+
expect(sectionSummary('autoVerify', base, zhT, format)).toContain('0.65')
|
|
301
|
+
expect(sectionSummary('routing', base, zhT, format)).toContain('2/8')
|
|
302
|
+
expect(sectionSummary('budgets', base, zhT, format)).toContain('96')
|
|
303
|
+
expect(sectionSummary('model', base, zhT, format)).toBe('')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('keeps every section reachable through its own fields', () => {
|
|
307
|
+
for (const section of SECTIONS) expect(fieldsOfSection(section.id).length).toBeGreaterThan(0)
|
|
308
|
+
})
|
|
309
|
+
})
|