mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
test(cache): add 26 integration tests + fix NaN edge case in computeCacheMetrics
TDD cycle results: - 26 new integration tests covering: • Cross-window percentage threshold consistency (200K/500K/1M) • Decision chain verification (pre-fold → normal → aggressive → force) • alreadyFoldedThisTurn double-fold prevention • Hysteresis oscillation prevention • Cache metrics invariant (ratio ∈ [0,1]) • CJK + emoji + mixed-language truncation edge cases - Bug fix: computeCacheMetrics returned NaN when cacheHit+cacheMiss==0 (all tokens were writes). Now returns 0 for valid range. - 58 cache optimization tests + 15 existing = 73 total. Zero failures. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
681868188c
commit
1efe3f63c2
@ -363,7 +363,7 @@ export function computeCacheMetrics(usage: {
|
||||
)
|
||||
const totalPromptTokens = cacheHitTokens + cacheMissTokens + cacheWriteTokens
|
||||
const cacheHitRatio =
|
||||
totalPromptTokens > 0
|
||||
totalPromptTokens > 0 && (cacheHitTokens + cacheMissTokens) > 0
|
||||
? cacheHitTokens / (cacheHitTokens + cacheMissTokens)
|
||||
: 0
|
||||
|
||||
|
||||
423
tests/cacheOptimization.integration.test.ts
Normal file
423
tests/cacheOptimization.integration.test.ts
Normal file
@ -0,0 +1,423 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import {
|
||||
COMPACT_NORMAL_FOLD_RATIO,
|
||||
COMPACT_AGGRESSIVE_FOLD_RATIO,
|
||||
COMPACT_FORCE_SUMMARY_RATIO,
|
||||
COMPACT_PRECHECK_FOLD_RATIO,
|
||||
COMPACT_NORMAL_FOLD_TAIL_RATIO,
|
||||
COMPACT_AGGRESSIVE_FOLD_TAIL_RATIO,
|
||||
COMPACT_PRECHECK_FOLD_HYSTERESIS,
|
||||
MIN_COMPACTION_SAVINGS_RATIO,
|
||||
type CompactionLevel,
|
||||
computeCacheMetrics,
|
||||
isCompactionWorthwhile,
|
||||
needsTurnStartPreFold,
|
||||
shouldPreFold,
|
||||
} from '../src/services/compact/autoCompact.js'
|
||||
import { truncateToolResultByTokens } from '../src/utils/toolResultStorage.js'
|
||||
import type { AutoCompactTrackingState } from '../src/services/compact/autoCompact.js'
|
||||
|
||||
// ===========================================================================
|
||||
// TDD Integration Suite — cache optimization decision chain + boundary tests
|
||||
// ===========================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WINDOWS = {
|
||||
SMALL: 200_000,
|
||||
MEDIUM: 500_000,
|
||||
LARGE: 1_000_000,
|
||||
} as const
|
||||
|
||||
function makeTracking(overrides: Partial<AutoCompactTrackingState> = {}): AutoCompactTrackingState {
|
||||
return {
|
||||
compacted: false,
|
||||
turnCounter: 1,
|
||||
turnId: 'tdd-test-turn',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function thresholdTokens(window: number, ratio: number): number {
|
||||
return Math.floor(window * ratio)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 1: Percentage threshold cross-window consistency
|
||||
//
|
||||
// The entire point of percentage thresholds is to work consistently across
|
||||
// all context window sizes. Verify that for every window size the ratios
|
||||
// produce the same percentile behavior.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: normal fold fires at same % regardless of window size', () => {
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
const tokens = thresholdTokens(w, COMPACT_NORMAL_FOLD_RATIO)
|
||||
const ratio = tokens / w
|
||||
expect(ratio).toBeGreaterThanOrEqual(COMPACT_NORMAL_FOLD_RATIO - 0.01)
|
||||
expect(ratio).toBeLessThanOrEqual(COMPACT_NORMAL_FOLD_RATIO + 0.01)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: aggressive fold fires at same % regardless of window size', () => {
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
const tokens = thresholdTokens(w, COMPACT_AGGRESSIVE_FOLD_RATIO)
|
||||
const ratio = tokens / w
|
||||
expect(ratio).toBeGreaterThanOrEqual(COMPACT_AGGRESSIVE_FOLD_RATIO - 0.01)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: force summary fires at same % regardless of window size', () => {
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
const tokens = thresholdTokens(w, COMPACT_FORCE_SUMMARY_RATIO)
|
||||
const ratio = tokens / w
|
||||
expect(ratio).toBeGreaterThanOrEqual(COMPACT_FORCE_SUMMARY_RATIO - 0.01)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: pre-check fires at same % regardless of window size', () => {
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
const tokens = thresholdTokens(w, COMPACT_PRECHECK_FOLD_RATIO)
|
||||
const ratio = tokens / w
|
||||
expect(ratio).toBeGreaterThanOrEqual(COMPACT_PRECHECK_FOLD_RATIO - 0.01)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 2: Tail budget ratio — aggressive < normal (cross-window)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: aggressive tail budget is half of normal across all window sizes', () => {
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
const normalTail = Math.floor(w * COMPACT_NORMAL_FOLD_TAIL_RATIO)
|
||||
const aggressiveTail = Math.floor(w * COMPACT_AGGRESSIVE_FOLD_TAIL_RATIO)
|
||||
expect(aggressiveTail).toBeLessThan(normalTail)
|
||||
// Aggressive tail should be exactly half of normal
|
||||
expect(aggressiveTail).toBe(Math.floor(normalTail / 2))
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 3: Decision chain — isCompactionWorthwhile gates normal vs emergency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: isCompactionWorthwhile gates at exact boundary across windows', () => {
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
const boundary = Math.floor(w * MIN_COMPACTION_SAVINGS_RATIO)
|
||||
// At exact boundary — worthwhile
|
||||
expect(isCompactionWorthwhile(boundary, w)).toBe(true)
|
||||
// Just below boundary — not worthwhile
|
||||
if (boundary > 1) {
|
||||
expect(isCompactionWorthwhile(boundary - 1, w)).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: isCompactionWorthwhile emergency gate consistent', () => {
|
||||
// When tokens exceed window, always worthwhile regardless of window size
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
expect(isCompactionWorthwhile(w + 1, w)).toBe(true)
|
||||
expect(isCompactionWorthwhile(w * 2, w)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 4: Pre-fold chain — estimate → needsPreFold → shouldPreFold
|
||||
// Simulates a full turn-start pre-estimation decision
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: pre-fold triggers at 90% regardless of window size', () => {
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
const atThreshold = thresholdTokens(w, COMPACT_PRECHECK_FOLD_RATIO)
|
||||
expect(needsTurnStartPreFold(atThreshold, w)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: pre-fold does NOT trigger at 89% across windows', () => {
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
const belowThreshold = thresholdTokens(w, COMPACT_PRECHECK_FOLD_RATIO * 0.99)
|
||||
expect(needsTurnStartPreFold(belowThreshold, w)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: hysteresis prevents oscillation at boundary', () => {
|
||||
// Simulate context hovering right at 90% boundary
|
||||
const w = WINDOWS.LARGE
|
||||
const threshold = thresholdTokens(w, COMPACT_PRECHECK_FOLD_RATIO)
|
||||
const hysteresisThreshold = thresholdTokens(
|
||||
w,
|
||||
COMPACT_PRECHECK_FOLD_RATIO * (1 + COMPACT_PRECHECK_FOLD_HYSTERESIS),
|
||||
)
|
||||
|
||||
// First pre-fold: at threshold → true
|
||||
const lastFoldAt = threshold + 1000
|
||||
expect(needsTurnStartPreFold(threshold, w)).toBe(true)
|
||||
|
||||
// Second check: context slightly above threshold but below hysteresis
|
||||
const afterFold = threshold + Math.floor((hysteresisThreshold - threshold) * 0.5)
|
||||
expect(needsTurnStartPreFold(afterFold, w, lastFoldAt)).toBe(false)
|
||||
|
||||
// Third check: context now well above hysteresis → re-trigger
|
||||
const aboveHysteresis = hysteresisThreshold + 100
|
||||
expect(needsTurnStartPreFold(aboveHysteresis, w, lastFoldAt)).toBe(true)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 5: alreadyFoldedThisTurn — prevents double-fold in the same turn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: alreadyFoldedThisTurn suppresses shouldPreFold', () => {
|
||||
const tracking = makeTracking({ alreadyFoldedThisTurn: true })
|
||||
// 95% context — would normally pre-fold, but already did this turn
|
||||
expect(shouldPreFold(tracking, thresholdTokens(200_000, 0.95), 200_000)).toBe(false)
|
||||
})
|
||||
|
||||
test('INTEGRATION: shouldPreFold returns true on fresh turn (not yet folded)', () => {
|
||||
const tracking = makeTracking({ alreadyFoldedThisTurn: false })
|
||||
expect(shouldPreFold(tracking, thresholdTokens(200_000, 0.95), 200_000)).toBe(true)
|
||||
})
|
||||
|
||||
test('INTEGRATION: shouldPreFold returns false below threshold even on fresh turn', () => {
|
||||
const tracking = makeTracking({ alreadyFoldedThisTurn: false })
|
||||
// 80% — below 90% pre-check threshold
|
||||
expect(shouldPreFold(tracking, thresholdTokens(200_000, 0.80), 200_000)).toBe(false)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 6: Compaction level ordering — verify severity hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: compaction level severity is monotonically ordered', () => {
|
||||
// The severity order of compaction levels, from least to most urgent:
|
||||
// none < normal_fold < aggressive_fold < force_summary
|
||||
// turn_start_prefold (90%) is a pre-check at a different point in the
|
||||
// turn lifecycle, so its ratio doesn't follow the post-response severity
|
||||
// chain. fixed_buffer depends on the window size.
|
||||
const postResponseLevels: CompactionLevel[] = [
|
||||
'none',
|
||||
'normal_fold',
|
||||
'aggressive_fold',
|
||||
'force_summary',
|
||||
]
|
||||
|
||||
const ratios: Record<CompactionLevel, number> = {
|
||||
none: 0,
|
||||
turn_start_prefold: COMPACT_PRECHECK_FOLD_RATIO,
|
||||
normal_fold: COMPACT_NORMAL_FOLD_RATIO,
|
||||
aggressive_fold: COMPACT_AGGRESSIVE_FOLD_RATIO,
|
||||
force_summary: COMPACT_FORCE_SUMMARY_RATIO,
|
||||
fixed_buffer: 0.935,
|
||||
}
|
||||
|
||||
for (let i = 1; i < postResponseLevels.length; i++) {
|
||||
const prev = postResponseLevels[i - 1]!
|
||||
const curr = postResponseLevels[i]!
|
||||
expect(ratios[curr]).toBeGreaterThanOrEqual(ratios[prev])
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 7: Cache metrics computation consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: cacheHitRatio consistent across different hit/miss splits', () => {
|
||||
const scenarios = [
|
||||
{ input: 100_000, hit: 99_000, write: 500, expectedRatio: 99_000 / (99_000 + 500) },
|
||||
{ input: 100_000, hit: 50_000, write: 0, expectedRatio: 0.5 },
|
||||
{ input: 100_000, hit: 0, write: 0, expectedRatio: 0 },
|
||||
{ input: 100_000, hit: 100_000, write: 0, expectedRatio: 1 },
|
||||
{ input: 1_000_000, hit: 750_000, write: 100_000, expectedRatio: 750_000 / (750_000 + 150_000) },
|
||||
]
|
||||
|
||||
for (const s of scenarios) {
|
||||
const m = computeCacheMetrics({
|
||||
input_tokens: s.input,
|
||||
cache_read_input_tokens: s.hit,
|
||||
cache_creation_input_tokens: s.write,
|
||||
})
|
||||
expect(m.cacheHitRatio).toBeCloseTo(s.expectedRatio, 4)
|
||||
expect(m.cacheHitTokens).toBe(s.hit)
|
||||
expect(m.cacheWriteTokens).toBe(s.write)
|
||||
// hit + miss + write ≈ input (miss may be adjusted if hit+write > input)
|
||||
expect(m.totalPromptTokens).toBeGreaterThanOrEqual(s.input - 10)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: cacheMetrics invariant: hitRatio ∈ [0, 1]', () => {
|
||||
// Random-ish sampling of plausible usage patterns
|
||||
const patterns = [
|
||||
{ input: 1, hit: 0, write: 0 },
|
||||
{ input: 1, hit: 1, write: 0 },
|
||||
{ input: 999_999, hit: 1, write: 0 },
|
||||
{ input: 500_000, hit: 500_000, write: 0 },
|
||||
{ input: 500_000, hit: 0, write: 500_000 },
|
||||
]
|
||||
for (const p of patterns) {
|
||||
const m = computeCacheMetrics({
|
||||
input_tokens: p.input,
|
||||
cache_read_input_tokens: p.hit,
|
||||
cache_creation_input_tokens: p.write,
|
||||
})
|
||||
expect(m.cacheHitRatio).toBeGreaterThanOrEqual(0)
|
||||
expect(m.cacheHitRatio).toBeLessThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 8: Token truncation — CJK and mixed-language edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: truncation preserves CJK character integrity', () => {
|
||||
// CJK: each character is 1-3 tokens. The truncation uses char/4 estimate
|
||||
// which is conservative for CJK (undercounts). Verify it still truncates
|
||||
// gracefully without corrupting characters.
|
||||
const content = 'これは日本語のテストです。'.repeat(1000)
|
||||
const result = truncateToolResultByTokens(content, 100)
|
||||
expect(result.wasTruncated).toBe(true)
|
||||
// Truncated content should be valid Unicode (no orphan surrogate pairs)
|
||||
expect(() => encodeURIComponent(result.truncated)).not.toThrow()
|
||||
// Should contain the truncation marker
|
||||
expect(result.truncated).toContain('Content truncated')
|
||||
})
|
||||
|
||||
test('INTEGRATION: truncation with data-like content is valid', () => {
|
||||
// When content is dense data (no natural line breaks), truncation at the
|
||||
// exact char boundary is acceptable — the function guarantees content
|
||||
// is a prefix of the original and that the marker is present.
|
||||
const lines: string[] = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
lines.push(`Line ${i.toString().padStart(4, '0')}: ${'data '.repeat(50)}`)
|
||||
}
|
||||
const content = lines.join('\n')
|
||||
const result = truncateToolResultByTokens(content, 100)
|
||||
|
||||
if (result.wasTruncated) {
|
||||
// Verify truncated is shorter than original
|
||||
expect(result.truncated.length).toBeLessThan(content.length)
|
||||
|
||||
// The truncated content should be a prefix of the original
|
||||
// (before the marker is appended)
|
||||
const markerIdx = result.truncated.lastIndexOf('[Content truncated')
|
||||
expect(markerIdx).toBeGreaterThan(0)
|
||||
|
||||
const beforeMarker = result.truncated.slice(0, markerIdx)
|
||||
// Content before the marker should be contained in the original
|
||||
// (may be truncated mid-word, which is acceptable)
|
||||
expect(content.includes(beforeMarker.trim())).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: truncation with mixed ASCII plus emoji content', () => {
|
||||
const content = 'Regular text with emoji 🚀🔥💻 mixed in. '.repeat(200)
|
||||
const result = truncateToolResultByTokens(content, 100)
|
||||
expect(result.wasTruncated).toBe(true)
|
||||
// Emoji are multi-byte; verify no orphan bytes
|
||||
expect(() => encodeURIComponent(result.truncated)).not.toThrow()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 9: Threshold cross-check — all ratios are in valid range
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: all compaction ratios are in (0, 1)', () => {
|
||||
const ratios = [
|
||||
COMPACT_NORMAL_FOLD_RATIO,
|
||||
COMPACT_AGGRESSIVE_FOLD_RATIO,
|
||||
COMPACT_FORCE_SUMMARY_RATIO,
|
||||
COMPACT_PRECHECK_FOLD_RATIO,
|
||||
COMPACT_NORMAL_FOLD_TAIL_RATIO,
|
||||
COMPACT_AGGRESSIVE_FOLD_TAIL_RATIO,
|
||||
MIN_COMPACTION_SAVINGS_RATIO,
|
||||
COMPACT_PRECHECK_FOLD_HYSTERESIS,
|
||||
]
|
||||
for (const r of ratios) {
|
||||
expect(r).toBeGreaterThan(0)
|
||||
expect(r).toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: normal fold threshold < aggressive < force_summary < precheck', () => {
|
||||
expect(COMPACT_NORMAL_FOLD_RATIO).toBeLessThan(COMPACT_AGGRESSIVE_FOLD_RATIO)
|
||||
expect(COMPACT_AGGRESSIVE_FOLD_RATIO).toBeLessThan(COMPACT_FORCE_SUMMARY_RATIO)
|
||||
expect(COMPACT_FORCE_SUMMARY_RATIO).toBeLessThan(COMPACT_PRECHECK_FOLD_RATIO)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 10: Savings check never blocks emergency compaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: savings check does not block when tokens exceed window', () => {
|
||||
// Emergency: tokens > window → always true regardless of savings ratio
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
expect(isCompactionWorthwhile(w + 1, w)).toBe(true)
|
||||
expect(isCompactionWorthwhile(w + 1000, w)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('INTEGRATION: savings check criteria consistent with min savings ratio', () => {
|
||||
const w = WINDOWS.MEDIUM
|
||||
const minFraction = MIN_COMPACTION_SAVINGS_RATIO
|
||||
|
||||
// At exact fraction → worthwhile
|
||||
expect(isCompactionWorthwhile(Math.floor(w * minFraction), w)).toBe(true)
|
||||
|
||||
// Slightly below → not worthwhile (but we test 1 token below for small ratios)
|
||||
const belowFraction = Math.floor(w * (minFraction - 0.01))
|
||||
if (belowFraction > 0) {
|
||||
expect(isCompactionWorthwhile(belowFraction, w)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 11: Pre-fold + post-fold never overlap (alreadyFoldedThisTurn invariant)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: pre-fold + post-fold coordination — alreadyFolded prevents second fold', () => {
|
||||
// Simulate a full turn:
|
||||
// 1. Turn starts
|
||||
// 2. Pre-estimation finds 92% → triggers pre-fold
|
||||
// 3. Pre-fold succeeds → alreadyFoldedThisTurn = true
|
||||
// 4. API call runs
|
||||
// 5. Post-response check → should NOT re-fold
|
||||
|
||||
const tracking = makeTracking({ alreadyFoldedThisTurn: false })
|
||||
const w = WINDOWS.SMALL
|
||||
|
||||
// Step 2: pre-estimation at 92%
|
||||
const tokens = thresholdTokens(w, 0.92)
|
||||
expect(shouldPreFold(tracking, tokens, w)).toBe(true)
|
||||
|
||||
// Step 3: after pre-fold, mark
|
||||
tracking.alreadyFoldedThisTurn = true
|
||||
|
||||
// Step 5: post-response check — suppressed
|
||||
expect(shouldPreFold(tracking, tokens, w)).toBe(false)
|
||||
})
|
||||
|
||||
test('INTEGRATION: next turn resets alreadyFoldedThisTurn (caller responsibility)', () => {
|
||||
// The tracking object is expected to be reset by the caller (query.ts)
|
||||
// at the start of each new turn. Verify the flag is not sticky.
|
||||
const tracking = makeTracking({ alreadyFoldedThisTurn: true })
|
||||
// Caller resets for new turn
|
||||
tracking.alreadyFoldedThisTurn = false
|
||||
expect(shouldPreFold(tracking, thresholdTokens(200_000, 0.95), 200_000)).toBe(true)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration 12: Hysteresis prevents thrashing across all window sizes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('INTEGRATION: hysteresis gap is at least 4% of threshold across windows', () => {
|
||||
for (const w of Object.values(WINDOWS)) {
|
||||
const threshold = thresholdTokens(w, COMPACT_PRECHECK_FOLD_RATIO)
|
||||
const hysteresisGap = thresholdTokens(
|
||||
w,
|
||||
COMPACT_PRECHECK_FOLD_RATIO * (1 + COMPACT_PRECHECK_FOLD_HYSTERESIS),
|
||||
) - threshold
|
||||
// Gap should be ~5% of the threshold value
|
||||
const expectedGap = Math.floor(threshold * COMPACT_PRECHECK_FOLD_HYSTERESIS)
|
||||
expect(hysteresisGap).toBeGreaterThanOrEqual(expectedGap - 1)
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user