Files
traefik-llmgateway/webui/src/lib/format.spec.ts
T
lukaszraczylo d0b55a37e9 fix(webui): match the health detail row color to its badge, add tests
ProvidersView.vue's accordion detail row hardcoded text-destructive red
for any non-closed health state, while the trigger row's Badge
deliberately used grey (secondary) for half-open and red (destructive)
only for open. Add healthDetailClass so the detail row's color always
matches the badge's own severity, instead of overstating a half-open
probe as an error.

Add a describe block for formatUntil to format.spec.ts, covering the
unset sentinel, undefined, a future timestamp, and a past one. It had
no test coverage since being added in the previous round.
2026-08-22 23:37:55 +01:00

167 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import { formatCompactCount, formatContextWindow, formatModelCostHover, formatUntil, refreshDetailLabel, refreshLabel } from './format'
const ZERO_TIME = '0001-01-01T00:00:00Z'
describe('refreshLabel', () => {
it('reads "discovery off" when discovery is disabled, regardless of lastRefresh', () => {
expect(refreshLabel(false, ZERO_TIME)).toBe('discovery off')
expect(refreshLabel(false, new Date().toISOString())).toBe('discovery off')
expect(refreshLabel(false, undefined)).toBe('discovery off')
})
it('reads "pending" when discovery is enabled but lastRefresh is still the unset sentinel', () => {
expect(refreshLabel(true, ZERO_TIME)).toBe('pending')
})
it('reads "pending" when discovery is enabled and lastRefresh is undefined (edge case)', () => {
expect(refreshLabel(true, undefined)).toBe('pending')
})
it('reads the relative-time "refreshed (Ns ago)" text once discovery has completed a refresh', () => {
const oneMinuteAgo = new Date(Date.now() - 60_000).toISOString()
expect(refreshLabel(true, oneMinuteAgo)).toMatch(/^refreshed \(\d+s ago\)$/)
})
})
// TestAdminOverview_ProviderLastErrAfterFailedRefresh's webui counterpart:
// the accordion DETAIL row (folded review minor, v0.22 review round) must
// share refreshLabel's own three-state logic instead of calling
// formatTimestamp directly — the bug this closes was a discovery-off
// provider's expanded row still reading the raw "never" right next to a
// trigger row that correctly said "discovery off".
describe('refreshDetailLabel', () => {
it('reads "discovery off" when discovery is disabled, regardless of lastRefresh', () => {
expect(refreshDetailLabel(false, ZERO_TIME)).toBe('discovery off')
expect(refreshDetailLabel(false, undefined)).toBe('discovery off')
})
it('reads "pending" when discovery is enabled but lastRefresh is still the unset sentinel', () => {
expect(refreshDetailLabel(true, ZERO_TIME)).toBe('pending')
})
it('reads the full formatTimestamp date/time once discovery has completed a refresh, not a relative "(Ns ago)"', () => {
const iso = new Date(2026, 7, 21, 12, 0, 0).toISOString()
const got = refreshDetailLabel(true, iso)
expect(got).not.toMatch(/ago/)
expect(got).not.toBe('pending')
expect(got).not.toBe('discovery off')
})
})
// Feature v0.23: per-model metadata exposure (context window, per-token
// cost). formatContextWindow is table-driven over good/edge cases;
// formatModelCostHover covers the free/priced split ModelChip's
// hover-detail relies on.
//
// Review fix: binary-K/M (÷1024) applies ONLY when tokens is an exact
// power of two; a decimal, round-thousands count uses decimal-K/M
// (÷1000) instead — see formatContextWindow's own doc comment for why a
// plain "tokens % 1024 == 0" check cannot tell these two cases apart
// (128000 divides evenly by 1024 too, yet must render as decimal "128k",
// not binary "125k").
describe('formatContextWindow', () => {
it.each([
// Genuine binary sizes (exact powers of two) — binary-K/M.
{ tokens: 262144, want: '256k' }, // 2^18 — LM Studio's own worked example
{ tokens: 131072, want: '128k' }, // 2^17
{ tokens: 32768, want: '32k' }, // 2^15
{ tokens: 8192, want: '8k' }, // 2^13
{ tokens: 1024, want: '1k' }, // 2^10 — boundary
{ tokens: 1_048_576, want: '1M' }, // 2^20 — binary M-tier boundary
// Decimal, marketing-style round counts — NOT powers of two, even
// when they happen to be exact multiples of 1024 (128000 = 125 ×
// 1024) — decimal-K/M.
{ tokens: 128_000, want: '128k' }, // review regression: was "125k"
{ tokens: 1_000_000, want: '1M' }, // review regression: was "977k"
{ tokens: 2_000_000, want: '2M' }, // review regression: was "1953k"
// Plain digits under 1024, either path.
{ tokens: 512, want: '512' },
{ tokens: 0, want: '0' },
])('formatContextWindow($tokens) = $want', ({ tokens, want }) => {
expect(formatContextWindow(tokens)).toBe(want)
})
})
describe('formatModelCostHover', () => {
it('renders "free" when both sides are exactly 0', () => {
expect(formatModelCostHover(0, 0)).toBe('free')
})
it('renders "in $X / out $Y per MTok" for a priced model', () => {
expect(formatModelCostHover(1.25, 10)).toBe('in $1.25 / out $10.00 per MTok')
})
it('renders a sub-dollar price with two decimals', () => {
expect(formatModelCostHover(0.19, 0.51)).toBe('in $0.19 / out $0.51 per MTok')
})
})
// Discovery circuit breaker (feat/provider-health, round 3): openUntil's
// future-time counterpart to formatAgo's past-time "(Ns ago)". Only
// caller today is ProvidersView.vue's healthBadgeLabel, rendering the
// breaker's live "open (in Ns)" countdown.
describe('formatUntil', () => {
it('renders "" for the unset zero-time sentinel', () => {
expect(formatUntil(ZERO_TIME)).toBe('')
})
it('renders "" for undefined', () => {
expect(formatUntil(undefined)).toBe('')
})
it('renders "in Ns" for a future timestamp', () => {
const inFortyTwoSeconds = new Date(Date.now() + 42_000).toISOString()
expect(formatUntil(inFortyTwoSeconds)).toMatch(/^in \d+s$/)
})
it('renders "" for a timestamp already in the past — a stale poll must not show a negative countdown', () => {
const oneMinuteAgo = new Date(Date.now() - 60_000).toISOString()
expect(formatUntil(oneMinuteAgo)).toBe('')
})
})
// Usage-count compact display (v0.23 addendum): SI-style, 3 significant
// figures, floor-not-round (the same honesty rule formatRatePercent
// already applies to the success-rate badge — a count must never appear
// to have crossed a boundary it has not actually reached).
describe('formatCompactCount', () => {
it.each([
// Below 10,000: exact integer with thousands separators.
{ n: 0, want: '0' },
{ n: 999, want: '999' },
{ n: 1_000, want: '1,000' }, // the 999/1000 boundary
{ n: 9_999, want: '9,999' },
// k tier.
{ n: 10_000, want: '10k' },
{ n: 12_345, want: '12.3k' },
{ n: 456_789, want: '456k' },
{ n: 999_949, want: '999k' }, // floor, not round: stays 999k
{ n: 999_950, want: '999k' }, // the classic round-up edge — floor must NOT bump this to 1,000k
{ n: 999_999, want: '999k' },
// M tier.
{ n: 1_000_000, want: '1M' },
{ n: 1_234_567, want: '1.23M' },
{ n: 45_600_000, want: '45.6M' },
{ n: 999_999_999, want: '999M' }, // just under the 1e9 boundary
// Review fix: IEEE-754 precision regressions. 8_700_000 / 1_000_000
// is not exactly representable in binary floating point (the double
// closest to 8.7 sits a hair BELOW it); the old
// `Math.floor(scaled*factor)/factor` implementation floored that
// slightly-under value to 869 instead of 870, rendering "8.69M".
{ n: 8_700_000, want: '8.7M' },
{ n: 1_130_000, want: '1.13M' },
// B tier.
{ n: 1_000_000_000, want: '1B' }, // the 1e9 boundary itself
{ n: 1_230_000_000, want: '1.23B' },
{ n: 12_345_000_000, want: '12.3B' },
])('formatCompactCount($n) = $want', ({ n, want }) => {
expect(formatCompactCount(n)).toBe(want)
})
it('preserves the sign for a negative count (defensive — counts are never negative in practice)', () => {
expect(formatCompactCount(-15_000_000)).toBe('-15M')
})
})