mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-29 00:41:25 +08:00
feat(quota): enhance QuotaTimeline with empty state handling and initial zoom configuration
This commit is contained in:
@@ -85,6 +85,16 @@ $lane-width: 210px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty {
|
||||
min-height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: $spacing-lg;
|
||||
color: var(--text-quaternary);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.axis,
|
||||
.lane {
|
||||
display: grid;
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface QuotaTimelineProps {
|
||||
resolvedTheme: ResolvedTheme;
|
||||
/** Injectable for tests/screenshots; defaults to the real clock. */
|
||||
now?: number;
|
||||
/** Injectable initial zoom for tests/screenshots; defaults to the weekly view. */
|
||||
initialMode?: TimelineMode;
|
||||
}
|
||||
|
||||
export function QuotaTimeline({
|
||||
@@ -61,9 +63,10 @@ export function QuotaTimeline({
|
||||
displayNameFor,
|
||||
resolvedTheme,
|
||||
now: nowProp,
|
||||
initialMode = 'weekly',
|
||||
}: QuotaTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<TimelineMode>('weekly');
|
||||
const [mode, setMode] = useState<TimelineMode>(initialMode);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
// The clock is state, not a read during render: bars are classified
|
||||
@@ -81,29 +84,39 @@ export function QuotaTimeline({
|
||||
|
||||
const span = useMemo(() => timelineSpan(mode, offset, now), [mode, offset, now]);
|
||||
|
||||
// Lanes with nothing to draw are dropped, not rendered blank: an xAI account
|
||||
// on a monthly plan has no weekly window and never will, and a credential
|
||||
// whose quota hasn't been loaded has nothing to show yet. The chart grows as
|
||||
// data arrives rather than opening as a wall of empty rows.
|
||||
const laneInputs = useMemo(
|
||||
() =>
|
||||
entries.map((entry) => ({
|
||||
name: entry.file.name,
|
||||
displayName: displayNameFor(entry.file.name),
|
||||
provider: entry.type,
|
||||
quota: quotaFor(entry),
|
||||
})),
|
||||
[entries, quotaFor, displayNameFor]
|
||||
);
|
||||
|
||||
// Keep the timeline hidden until at least one loaded credential exposes a
|
||||
// real quota window. Once there is timeline data, however, changing zoom must
|
||||
// never remove the whole panel just because that mode has no matching lanes.
|
||||
const hasAnyLane = useMemo(
|
||||
() => laneInputs.some((input) => laneHasWindow(buildTimelineLane(input))),
|
||||
[laneInputs]
|
||||
);
|
||||
|
||||
const lanes = useMemo(
|
||||
() =>
|
||||
entries
|
||||
.map((entry) =>
|
||||
laneInputs
|
||||
.map((input) =>
|
||||
buildTimelineLane({
|
||||
name: entry.file.name,
|
||||
displayName: displayNameFor(entry.file.name),
|
||||
provider: entry.type,
|
||||
quota: quotaFor(entry),
|
||||
...input,
|
||||
// Weekly mode prefers the longest readable window. Session mode
|
||||
// asks specifically for a real 5-hour window; longer periods must
|
||||
// not be reinterpreted as 5-hour resets.
|
||||
maxPeriodHours: mode === 'session' ? 5 : span.days * 24,
|
||||
})
|
||||
)
|
||||
.filter(
|
||||
(lane) => laneHasWindow(lane) && (mode !== 'session' || lane.periodHours === 5)
|
||||
),
|
||||
[entries, quotaFor, displayNameFor, mode, span.days]
|
||||
.filter((lane) => laneHasWindow(lane) && (mode !== 'session' || lane.periodHours === 5)),
|
||||
[laneInputs, mode, span.days]
|
||||
);
|
||||
|
||||
/** Weekly: one cell per day. Session: one per 6 hours. */
|
||||
@@ -136,7 +149,7 @@ export function QuotaTimeline({
|
||||
? ((now - span.startMs) / (span.endMs - span.startMs)) * 100
|
||||
: null;
|
||||
|
||||
if (lanes.length === 0) return null;
|
||||
if (!hasAnyLane) return null;
|
||||
|
||||
return (
|
||||
<section className={styles.timeline}>
|
||||
@@ -151,7 +164,8 @@ export function QuotaTimeline({
|
||||
{mode === 'weekly'
|
||||
? t('quota_management.windows_span_weekly', { defaultValue: 'two weeks' })
|
||||
: t('quota_management.windows_span_session', { defaultValue: 'three days' })}
|
||||
{offset === 0 && ` · ${t('quota_management.windows_current', { defaultValue: 'current' })}`}
|
||||
{offset === 0 &&
|
||||
` · ${t('quota_management.windows_current', { defaultValue: 'current' })}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -197,65 +211,79 @@ export function QuotaTimeline({
|
||||
</header>
|
||||
|
||||
<div className={styles.chart}>
|
||||
<div className={styles.axis}>
|
||||
<div className={styles.axisLabel}>
|
||||
{t('quota_management.windows_credential', { defaultValue: 'Credential' })}
|
||||
{lanes.length === 0 ? (
|
||||
<div className={styles.empty} role="status">
|
||||
{t('quota_management.windows_empty_session', {
|
||||
defaultValue: 'No credentials on this page report a 5-hour quota window.',
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.axisCells}>
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.at}
|
||||
className={styles.axisCell}
|
||||
data-today={cell.isToday ? 1 : 0}
|
||||
data-weekend={cell.isWeekend ? 1 : 0}
|
||||
data-daystart={cell.isDayStart ? 1 : 0}
|
||||
>
|
||||
<span className={styles.axisWeekday}>{cell.isDayStart ? cell.weekday : ''}</span>
|
||||
<span className={styles.axisDate}>{cell.label}</span>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.axis}>
|
||||
<div className={styles.axisLabel}>
|
||||
{t('quota_management.windows_credential', { defaultValue: 'Credential' })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.axisCells}>
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.at}
|
||||
className={styles.axisCell}
|
||||
data-today={cell.isToday ? 1 : 0}
|
||||
data-weekend={cell.isWeekend ? 1 : 0}
|
||||
data-daystart={cell.isDayStart ? 1 : 0}
|
||||
>
|
||||
<span className={styles.axisWeekday}>
|
||||
{cell.isDayStart ? cell.weekday : ''}
|
||||
</span>
|
||||
<span className={styles.axisDate}>{cell.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lanes.map((lane) => (
|
||||
<Lane
|
||||
key={lane.name}
|
||||
lane={lane}
|
||||
span={span}
|
||||
now={now}
|
||||
mode={mode}
|
||||
cells={cells}
|
||||
nowPercent={nowPercent}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
))}
|
||||
{lanes.map((lane) => (
|
||||
<Lane
|
||||
key={lane.name}
|
||||
lane={lane}
|
||||
span={span}
|
||||
now={now}
|
||||
mode={mode}
|
||||
cells={cells}
|
||||
nowPercent={nowPercent}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className={styles.legend}>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.swatch} ${styles.swatchLive}`} />
|
||||
{t('quota_management.windows_legend_current', { defaultValue: 'current window' })}
|
||||
</span>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.swatch} ${styles.swatchNext}`} />
|
||||
{t('quota_management.windows_legend_upcoming', { defaultValue: 'upcoming' })}
|
||||
</span>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.swatch} ${styles.swatchPast}`} />
|
||||
{t('quota_management.windows_legend_elapsed', { defaultValue: 'elapsed' })}
|
||||
</span>
|
||||
<span className={styles.legendNote}>
|
||||
{mode === 'weekly'
|
||||
? t('quota_management.windows_note_weekly', {
|
||||
defaultValue:
|
||||
'Each bar is one full quota window, drawn from when it opened to when it resets. Lanes ending together compete for the same days.',
|
||||
})
|
||||
: t('quota_management.windows_note_session', {
|
||||
defaultValue:
|
||||
'Each bar is one 5-hour window. Only credentials with a window counting down can be projected; the rest stay empty rather than invented.',
|
||||
})}
|
||||
</span>
|
||||
</footer>
|
||||
{lanes.length > 0 && (
|
||||
<footer className={styles.legend}>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.swatch} ${styles.swatchLive}`} />
|
||||
{t('quota_management.windows_legend_current', { defaultValue: 'current window' })}
|
||||
</span>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.swatch} ${styles.swatchNext}`} />
|
||||
{t('quota_management.windows_legend_upcoming', { defaultValue: 'upcoming' })}
|
||||
</span>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.swatch} ${styles.swatchPast}`} />
|
||||
{t('quota_management.windows_legend_elapsed', { defaultValue: 'elapsed' })}
|
||||
</span>
|
||||
<span className={styles.legendNote}>
|
||||
{mode === 'weekly'
|
||||
? t('quota_management.windows_note_weekly', {
|
||||
defaultValue:
|
||||
'Each bar is one full quota window, drawn from when it opened to when it resets. Lanes ending together compete for the same days.',
|
||||
})
|
||||
: t('quota_management.windows_note_session', {
|
||||
defaultValue:
|
||||
'Each bar is one 5-hour window. Only credentials with a window counting down can be projected; the rest stay empty rather than invented.',
|
||||
})}
|
||||
</span>
|
||||
</footer>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1166,6 +1166,7 @@
|
||||
"windows_mode_session": "5-hour",
|
||||
"windows_credential": "Credential",
|
||||
"windows_idle": "no window counting down",
|
||||
"windows_empty_session": "No credentials on this page report a 5-hour quota window.",
|
||||
"windows_legend_current": "current window",
|
||||
"windows_legend_upcoming": "upcoming",
|
||||
"windows_legend_elapsed": "elapsed",
|
||||
|
||||
@@ -1153,6 +1153,7 @@
|
||||
"windows_mode_session": "5 часов",
|
||||
"windows_credential": "Учётные данные",
|
||||
"windows_idle": "нет активного окна",
|
||||
"windows_empty_session": "Ни одни учётные данные на этой странице не предоставляют 5-часовое окно квоты.",
|
||||
"windows_legend_current": "текущее окно",
|
||||
"windows_legend_upcoming": "предстоящее",
|
||||
"windows_legend_elapsed": "прошедшее",
|
||||
|
||||
@@ -1166,6 +1166,7 @@
|
||||
"windows_mode_session": "5 小时",
|
||||
"windows_credential": "凭证",
|
||||
"windows_idle": "没有正在计时的窗口",
|
||||
"windows_empty_session": "当前页面没有凭证提供 5 小时配额窗口。",
|
||||
"windows_legend_current": "当前窗口",
|
||||
"windows_legend_upcoming": "即将开始",
|
||||
"windows_legend_elapsed": "已结束",
|
||||
|
||||
@@ -1192,6 +1192,7 @@
|
||||
"windows_mode_session": "5 小時",
|
||||
"windows_credential": "憑證",
|
||||
"windows_idle": "沒有正在計時的視窗",
|
||||
"windows_empty_session": "目前頁面沒有憑證提供 5 小時配額視窗。",
|
||||
"windows_legend_current": "目前視窗",
|
||||
"windows_legend_upcoming": "即將開始",
|
||||
"windows_legend_elapsed": "已結束",
|
||||
|
||||
+15
-15
@@ -247,22 +247,22 @@ export interface CodexQuotaState {
|
||||
|
||||
// Kimi API payload types
|
||||
export interface KimiUsageDetail {
|
||||
used?: number;
|
||||
limit?: number;
|
||||
remaining?: number;
|
||||
used?: number | string;
|
||||
limit?: number | string;
|
||||
remaining?: number | string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
resetAt?: string;
|
||||
reset_at?: string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
resetIn?: number;
|
||||
reset_in?: number;
|
||||
ttl?: number;
|
||||
resetIn?: number | string;
|
||||
reset_in?: number | string;
|
||||
ttl?: number | string;
|
||||
}
|
||||
|
||||
export interface KimiLimitWindow {
|
||||
duration?: number;
|
||||
duration?: number | string;
|
||||
timeUnit?: string;
|
||||
}
|
||||
|
||||
@@ -272,16 +272,16 @@ export interface KimiLimitItem {
|
||||
scope?: string;
|
||||
detail?: KimiUsageDetail;
|
||||
window?: KimiLimitWindow;
|
||||
used?: number;
|
||||
limit?: number;
|
||||
remaining?: number;
|
||||
duration?: number;
|
||||
used?: number | string;
|
||||
limit?: number | string;
|
||||
remaining?: number | string;
|
||||
duration?: number | string;
|
||||
timeUnit?: string;
|
||||
resetAt?: string;
|
||||
reset_at?: string;
|
||||
resetIn?: number;
|
||||
reset_in?: number;
|
||||
ttl?: number;
|
||||
resetIn?: number | string;
|
||||
reset_in?: number | string;
|
||||
ttl?: number | string;
|
||||
}
|
||||
|
||||
export interface KimiUsagePayload {
|
||||
@@ -299,7 +299,7 @@ export interface KimiQuotaRow {
|
||||
resetHint?: string;
|
||||
/** Reset instant in epoch ms; null when only a relative hint was available. */
|
||||
resetAtMs?: number | null;
|
||||
/** Window length in hours, inferred from the limit's daily/weekly/monthly scope. */
|
||||
/** Window length in hours, derived from explicit duration metadata or the limit scope. */
|
||||
periodHours?: number | null;
|
||||
}
|
||||
|
||||
|
||||
+70
-22
@@ -177,14 +177,29 @@ function kimiResetHint(data: Record<string, unknown>): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type KimiTimeUnit = 'second' | 'minute' | 'hour' | 'day';
|
||||
|
||||
/** Kimi currently sends protobuf-style values such as TIME_UNIT_MINUTE. */
|
||||
function normalizeKimiTimeUnit(rawTimeUnit: unknown): KimiTimeUnit | null {
|
||||
const unit =
|
||||
typeof rawTimeUnit === 'string'
|
||||
? rawTimeUnit
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/^TIME_UNIT_/, '')
|
||||
: '';
|
||||
if (unit === 'SECONDS' || unit === 'SECOND') return 'second';
|
||||
if (!unit || unit === 'MINUTES' || unit === 'MINUTE') return 'minute';
|
||||
if (unit === 'HOURS' || unit === 'HOUR') return 'hour';
|
||||
if (unit === 'DAYS' || unit === 'DAY') return 'day';
|
||||
return null;
|
||||
}
|
||||
|
||||
function kimiDurationToken(duration: number, rawTimeUnit: unknown): string {
|
||||
const unit = typeof rawTimeUnit === 'string' ? rawTimeUnit.trim().toUpperCase() : '';
|
||||
if (unit === 'SECONDS' || unit === 'SECOND') return `${duration}s`;
|
||||
if (!unit || unit === 'MINUTES' || unit === 'MINUTE') {
|
||||
return duration % 60 === 0 ? `${duration / 60}h` : `${duration}m`;
|
||||
}
|
||||
if (unit === 'HOURS' || unit === 'HOUR') return `${duration}h`;
|
||||
if (unit === 'DAYS' || unit === 'DAY') return `${duration}d`;
|
||||
const unit = normalizeKimiTimeUnit(rawTimeUnit);
|
||||
if (unit === 'second') return `${duration}s`;
|
||||
if (unit === 'hour') return `${duration}h`;
|
||||
if (unit === 'day') return `${duration}d`;
|
||||
return duration % 60 === 0 ? `${duration / 60}h` : `${duration}m`;
|
||||
}
|
||||
|
||||
@@ -244,26 +259,43 @@ function kimiResetMs(data: Record<string, unknown>): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Window length in hours implied by a row's label/scope. */
|
||||
function kimiPeriodHours(label: string | undefined): number | null {
|
||||
/** Window length in hours from explicit duration metadata, then label/scope. */
|
||||
function kimiPeriodHours(
|
||||
label: string | undefined,
|
||||
duration: number | null = null,
|
||||
rawTimeUnit?: unknown
|
||||
): number | null {
|
||||
if (duration !== null && duration > 0) {
|
||||
const unit = normalizeKimiTimeUnit(rawTimeUnit);
|
||||
if (unit === 'second') return duration / 3600;
|
||||
if (unit === 'hour') return duration;
|
||||
if (unit === 'day') return duration * 24;
|
||||
// Match the card-label fallback: an absent or unknown unit is treated as minutes.
|
||||
return duration / 60;
|
||||
}
|
||||
|
||||
const text = (label ?? '').toLowerCase();
|
||||
if (text.includes('daily') || text.includes('day')) return 24;
|
||||
if (text.includes('weekly') || text.includes('week')) return 24 * 7;
|
||||
if (text.includes('monthly') || text.includes('month')) return 24 * 30;
|
||||
if (text.includes('hour')) return 5;
|
||||
if (text.includes('5h') || text.includes('hour')) return 5;
|
||||
return null;
|
||||
}
|
||||
|
||||
function toKimiUsageRow(
|
||||
data: Record<string, unknown>,
|
||||
fallbackLabel: KimiRowLabel
|
||||
): (KimiRowLabel & {
|
||||
used: number;
|
||||
limit: number;
|
||||
resetHint?: string;
|
||||
resetAtMs?: number | null;
|
||||
periodHours?: number | null;
|
||||
}) | null {
|
||||
fallbackLabel: KimiRowLabel,
|
||||
duration: number | null = null,
|
||||
timeUnit?: unknown
|
||||
):
|
||||
| (KimiRowLabel & {
|
||||
used: number;
|
||||
limit: number;
|
||||
resetHint?: string;
|
||||
resetAtMs?: number | null;
|
||||
periodHours?: number | null;
|
||||
})
|
||||
| null {
|
||||
const limit = toInt(data.limit);
|
||||
let used = toInt(data.used);
|
||||
if (used === null) {
|
||||
@@ -283,7 +315,11 @@ function toKimiUsageRow(
|
||||
limit: limit ?? 0,
|
||||
resetHint: kimiResetHint(data),
|
||||
resetAtMs: kimiResetMs(data),
|
||||
periodHours: kimiPeriodHours(explicitLabel || fallbackLabel.label || fallbackLabel.labelKey),
|
||||
periodHours: kimiPeriodHours(
|
||||
explicitLabel || fallbackLabel.label || fallbackLabel.labelKey,
|
||||
duration,
|
||||
timeUnit
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,13 +330,25 @@ export function buildKimiQuotaRows(payload: KimiUsagePayload): KimiQuotaRow[] {
|
||||
if (Array.isArray(limits)) {
|
||||
limits.forEach((item, idx) => {
|
||||
const detail = (item.detail && typeof item.detail === 'object' ? item.detail : item) as
|
||||
| KimiUsageDetail
|
||||
| KimiLimitItem;
|
||||
KimiUsageDetail | KimiLimitItem;
|
||||
const window = (
|
||||
item.window && typeof item.window === 'object' ? item.window : {}
|
||||
) as KimiLimitWindow;
|
||||
const fallbackLabel = kimiLimitLabel(item, detail, window, idx);
|
||||
const row = toKimiUsageRow(detail as Record<string, unknown>, fallbackLabel);
|
||||
const duration =
|
||||
toInt(window.duration) ??
|
||||
toInt((item as Record<string, unknown>).duration) ??
|
||||
toInt((detail as Record<string, unknown>).duration);
|
||||
const timeUnit =
|
||||
(window as Record<string, unknown>).timeUnit ??
|
||||
(item as Record<string, unknown>).timeUnit ??
|
||||
(detail as Record<string, unknown>).timeUnit;
|
||||
const row = toKimiUsageRow(
|
||||
detail as Record<string, unknown>,
|
||||
fallbackLabel,
|
||||
duration,
|
||||
timeUnit
|
||||
);
|
||||
if (row) {
|
||||
rows.push({ id: `limit-${idx}`, ...row });
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildTimelineLane } from '@/features/quota/quotaTimelineModel';
|
||||
import { buildKimiQuotaRows } from '@/utils/quota';
|
||||
|
||||
describe('Kimi quota ordering', () => {
|
||||
test('shows the 5-hour limit before the weekly limit', () => {
|
||||
test('shows the 5-hour limit before the weekly limit and exposes it to the timeline', () => {
|
||||
const rows = buildKimiQuotaRows({
|
||||
usage: {
|
||||
used: 200,
|
||||
limit: 1000,
|
||||
used: '1',
|
||||
limit: '100',
|
||||
remaining: '99',
|
||||
resetTime: '2099-08-06T13:59:23.136523Z',
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
detail: {
|
||||
used: 20,
|
||||
limit: 100,
|
||||
used: '2',
|
||||
limit: '100',
|
||||
remaining: '98',
|
||||
resetTime: '2099-07-31T06:59:23.136523Z',
|
||||
},
|
||||
window: {
|
||||
duration: 300,
|
||||
timeUnit: 'MINUTES',
|
||||
timeUnit: 'TIME_UNIT_MINUTE',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -25,7 +30,19 @@ describe('Kimi quota ordering', () => {
|
||||
expect(rows.map(({ id }) => id)).toEqual(['limit-0', 'summary']);
|
||||
expect(rows[0]?.labelKey).toBe('kimi_quota.limit_window');
|
||||
expect(rows[0]?.labelParams).toEqual({ duration: '5h' });
|
||||
expect(rows[0]?.periodHours).toBe(5);
|
||||
expect(rows[1]?.labelKey).toBe('kimi_quota.weekly_limit');
|
||||
expect(rows[1]?.periodHours).toBe(168);
|
||||
|
||||
const lane = buildTimelineLane({
|
||||
name: 'kimi.json',
|
||||
displayName: 'Kimi',
|
||||
provider: 'kimi',
|
||||
quota: { status: 'success', rows },
|
||||
maxPeriodHours: 5,
|
||||
});
|
||||
expect(lane.anchorMs).toBe(rows[0]?.resetAtMs);
|
||||
expect(lane.periodHours).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import '../src/i18n/index';
|
||||
import { QuotaTimeline } from '../src/features/quota/components/QuotaTimeline';
|
||||
import type { QuotaFileEntry } from '../src/features/quota/logic';
|
||||
import { buildKimiQuotaRows } from '../src/utils/quota';
|
||||
|
||||
const entries: QuotaFileEntry[] = [
|
||||
{
|
||||
file: { name: 'weekly-only.json', type: 'claude' },
|
||||
type: 'claude',
|
||||
},
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
entries,
|
||||
displayNameFor: (name: string) => name,
|
||||
resolvedTheme: 'light' as const,
|
||||
now: new Date(2026, 6, 29, 12).getTime(),
|
||||
};
|
||||
|
||||
describe('QuotaTimeline rendering', () => {
|
||||
test('keeps the panel and controls visible when 5-hour mode has no matching lanes', () => {
|
||||
const weeklyOnlyQuota = {
|
||||
status: 'success' as const,
|
||||
windows: [
|
||||
{
|
||||
label: '7-day',
|
||||
usedPercent: 25,
|
||||
resetAtMs: new Date(2026, 7, 1, 12).getTime(),
|
||||
periodHours: 168,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
...baseProps,
|
||||
initialMode: 'session',
|
||||
quotaFor: () => weeklyOnlyQuota,
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toContain('<section');
|
||||
expect(markup).toContain('aria-pressed="true"');
|
||||
expect(markup).toContain('role="status"');
|
||||
});
|
||||
|
||||
test('renders a Kimi 5-hour lane from the protobuf-style time unit', () => {
|
||||
const rows = buildKimiQuotaRows({
|
||||
usage: {
|
||||
used: '1',
|
||||
limit: '100',
|
||||
resetTime: '2099-08-06T13:59:23.136523Z',
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
window: { duration: 300, timeUnit: 'TIME_UNIT_MINUTE' },
|
||||
detail: {
|
||||
used: '2',
|
||||
limit: '100',
|
||||
resetTime: '2099-07-31T06:59:23.136523Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
entries: [
|
||||
{
|
||||
file: { name: 'kimi-real-response.json', type: 'kimi' },
|
||||
type: 'kimi',
|
||||
},
|
||||
],
|
||||
displayNameFor: (name: string) => name,
|
||||
resolvedTheme: 'light',
|
||||
now: new Date('2099-07-31T04:40:00Z').getTime(),
|
||||
initialMode: 'session',
|
||||
quotaFor: () => ({ status: 'success', rows }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toContain('kimi-real-response.json');
|
||||
expect(markup).not.toContain('role="status"');
|
||||
});
|
||||
|
||||
test('stays hidden before any credential exposes a usable quota window', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
...baseProps,
|
||||
quotaFor: () => undefined,
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user