kimi-code/apps/kimi-web/test/chat-turn-rendering.test.ts
liruifengv b5139757e2
fix: align context usage display with 1024-based units and ceiled percents (#1771)
* fix(web): align context usage display with 1024-based units and ring-only meter

- simplify the composer context meter to the ring only; the full
  used/max/pct numbers live in the tooltip
- format token counts with 1024-based k/M units via a shared formatTokens
  helper (256k context reads "256k", not "262k"), applied to the composer
  tooltip, status panel, mobile settings sheet, model picker, goal strip,
  and turn rendering
- ceil the usage percent so sub-0.5% usage still shows a sliver instead
  of an empty meter

* fix(tui): render context usage with 1024-based units and ceil percent

- formatTokenCount is now 1024-based ("256k", not "262.1k"); the footer,
  /status and /usage panels, subagent cards, and goal stats all share it,
  replacing five local 1000-based copies
- the footer and panel percents use an integer ceil (new usagePercent
  helper) so any non-zero usage shows at least 1% instead of "0.0%"

* fix(web): clamp the status panel context percent to [0,100]

ctxUsed can momentarily exceed ctxMax (estimates), which could flash a
"101%" readout — the composer and mobile sheet already clamp the same
ConversationStatus data, so apply the same clamp around the ceiled
percentage here.

* chore: merge the context usage changesets into one

* chore: reword the context usage changeset in English
2026-07-16 16:41:40 +08:00

179 lines
6.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { ChatTurn, ToolCall, TurnBlock } from '../src/types';
import {
assistantRenderBlocks,
formatDuration,
formatTokens,
rendersToolCard,
renderBlockKey,
toolStackPosition,
turnBlocks,
turnFinalText,
turnToMarkdown,
} from '../src/components/chatTurnRendering';
function tool(id: string, over: Partial<ToolCall> = {}): ToolCall {
return { id, name: 'read', arg: `· ${id}.ts`, status: 'ok', ...over };
}
function toolBlock(id: string, over: Partial<ToolCall> = {}): Extract<TurnBlock, { kind: 'tool' }> {
return { kind: 'tool', tool: tool(id, over) };
}
function assistantTurn(blocks: TurnBlock[], over: Partial<ChatTurn> = {}): ChatTurn {
return { id: 't1', role: 'assistant', no: 1, text: '', blocks, ...over };
}
describe('formatTokens', () => {
it('keeps counts under 1024 verbatim and uses 1024-based k / M units', () => {
expect(formatTokens(0)).toBe('0');
expect(formatTokens(999)).toBe('999');
expect(formatTokens(1000)).toBe('1000');
expect(formatTokens(1500)).toBe('1.5k');
expect(formatTokens(1_000_000)).toBe('977k');
expect(formatTokens(2_500_000)).toBe('2.4M');
});
});
describe('formatDuration', () => {
it('switches units at the 1s and 1m boundaries', () => {
expect(formatDuration(999)).toBe('999ms');
expect(formatDuration(1000)).toBe('1.0s');
expect(formatDuration(59_999)).toBe('60.0s');
expect(formatDuration(60_000)).toBe('1m0.0s');
expect(formatDuration(90_500)).toBe('1m30.5s');
});
});
describe('turnBlocks', () => {
it('returns the ordered blocks as-is when present', () => {
const blocks: TurnBlock[] = [{ kind: 'text', text: 'hi' }];
expect(turnBlocks(assistantTurn(blocks))).toBe(blocks);
});
it('falls back to thinking -> text -> tools order when blocks are absent', () => {
const turn: ChatTurn = {
id: 't1',
role: 'assistant',
no: 1,
text: 'answer',
thinking: 'plan',
tools: [tool('a')],
};
expect(turnBlocks(turn)).toEqual([
{ kind: 'thinking', thinking: 'plan' },
{ kind: 'text', text: 'answer' },
{ kind: 'tool', tool: tool('a') },
]);
});
});
describe('rendersToolCard', () => {
it('hides the card only for a successful tool that carries inline media', () => {
expect(rendersToolCard(toolBlock('a'))).toBe(true);
expect(rendersToolCard(toolBlock('r', { status: 'running' }))).toBe(true);
expect(
rendersToolCard(toolBlock('m', { status: 'ok', media: { kind: 'image', url: 'x' } })),
).toBe(false);
// media but errored -> still rendered as a card
expect(
rendersToolCard(toolBlock('e', { status: 'error', media: { kind: 'image', url: 'x' } })),
).toBe(true);
});
});
describe('toolStackPosition', () => {
it('marks a lone tool single and otherwise reports first/middle/last', () => {
expect(toolStackPosition(0, 1)).toBe('single');
expect(toolStackPosition(0, 0)).toBe('single');
expect(toolStackPosition(0, 3)).toBe('first');
expect(toolStackPosition(1, 3)).toBe('middle');
expect(toolStackPosition(2, 3)).toBe('last');
});
});
describe('assistantRenderBlocks', () => {
it('groups consecutive renderable tools into one tool-stack', () => {
const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a'), toolBlock('b')]));
expect(rendered).toHaveLength(1);
expect(rendered[0]).toMatchObject({ kind: 'tool-stack' });
if (rendered[0]?.kind === 'tool-stack') {
expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']);
expect(rendered[0].tools.map((t) => t.sourceIndex)).toEqual([0, 1]);
}
});
it('renders a lone tool as a standalone tool, not a stack', () => {
const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a')]));
expect(rendered).toEqual([{ kind: 'tool', tool: tool('a'), sourceIndex: 0 }]);
});
it('breaks the stack when a non-tool block interrupts the run', () => {
const rendered = assistantRenderBlocks(
assistantTurn([toolBlock('a'), { kind: 'text', text: 'x' }, toolBlock('b')]),
);
expect(rendered.map((b) => b.kind)).toEqual(['tool', 'text', 'tool']);
});
it('breaks the stack when a media tool (no card) interrupts the run', () => {
const rendered = assistantRenderBlocks(
assistantTurn([
toolBlock('a'),
toolBlock('b'),
toolBlock('c', { status: 'ok', media: { kind: 'image', url: 'x' } }),
]),
);
expect(rendered.map((b) => b.kind)).toEqual(['tool-stack', 'tool']);
if (rendered[0]?.kind === 'tool-stack') {
expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']);
}
});
it('preserves thinking/text order with their source indexes', () => {
const rendered = assistantRenderBlocks(
assistantTurn([
{ kind: 'thinking', thinking: 'plan' },
{ kind: 'text', text: 'answer' },
]),
);
expect(rendered).toEqual([
{ kind: 'thinking', thinking: 'plan', sourceIndex: 0 },
{ kind: 'text', text: 'answer', sourceIndex: 1 },
]);
});
});
describe('turnFinalText', () => {
it('joins only the text blocks, dropping thinking and tools', () => {
const turn = assistantTurn([
{ kind: 'thinking', thinking: 'plan' },
{ kind: 'text', text: 'first' },
toolBlock('a'),
{ kind: 'text', text: 'second' },
]);
expect(turnFinalText(turn)).toBe('first\n\nsecond');
});
});
describe('turnToMarkdown', () => {
it('renders thinking as a quote, text verbatim, and tool output as a fenced block', () => {
const turn = assistantTurn([
{ kind: 'thinking', thinking: 'line1\nline2' },
{ kind: 'text', text: 'hello' },
toolBlock('a', { name: 'bash', output: ['out1', 'out2'] }),
]);
expect(turnToMarkdown(turn)).toBe(
['> **Thinking**\n> line1\n> line2', 'hello', '```\n[bash]\nout1\nout2\n```'].join('\n\n'),
);
});
});
describe('renderBlockKey', () => {
it('derives stable keys per block kind', () => {
expect(renderBlockKey({ kind: 'text', text: 'x', sourceIndex: 2 }, 0)).toBe('text-2');
expect(renderBlockKey({ kind: 'tool', tool: tool('a'), sourceIndex: 3 }, 0)).toBe('a');
expect(
renderBlockKey({ kind: 'tool-stack', tools: [{ tool: tool('a'), sourceIndex: 5 }] }, 0),
).toBe('tool-stack-5');
});
});