kimi-code/apps/kimi-web/test/chat-turn-rendering.test.ts
qer ea1b33b674
refactor(web): extract pure turn-rendering helpers from ChatPane (#1001)
* refactor(web): extract pure turn-rendering helpers from ChatPane

* chore: add changeset for chat pane helper extraction

* test(kimi-web): cover chat turn-rendering helpers

Pure-logic tests for the helpers extracted from ChatPane, focused on
assistantRenderBlocks (tool-stack grouping, interrupt/media break, single
tool) plus the formatting/boundary helpers. Doubles as a safety net
confirming the extraction preserved behavior.
2026-06-23 15:20:00 +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 small counts verbatim and abbreviates at the k / M thresholds', () => {
expect(formatTokens(0)).toBe('0');
expect(formatTokens(999)).toBe('999');
expect(formatTokens(1000)).toBe('1.0k');
expect(formatTokens(1500)).toBe('1.5k');
expect(formatTokens(1_000_000)).toBe('1.0M');
expect(formatTokens(2_500_000)).toBe('2.5M');
});
});
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');
});
});