Files
Genarrative/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
lhk229 bfc7eaa794
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m14s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m28s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m44s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m35s
Project CI / AI game creator shell Rust crates (push) Successful in 1m38s
Project CI / Frontend tests (push) Failing after 3m53s
Project CI / Repository checks (push) Failing after 4m11s
Project CI / Native shell tests (push) Successful in 6m4s
Project CI / Backend tests (push) Successful in 6m38s
Project CI / AI game creator shell web tests (push) Failing after 2m50s
修复 AGC CI 契约、JSON 预览与工具调用时间戳 (#380)
修复 AGC 的 Rust 编译、资源预览及工具调用时间戳问题,并包含当前分支已有的 Rust shard 4 修复。

变更:
- 补齐 Direct 回合事件测试构造中的 reasoning_text、stream_items 字段。
- JSON/JSONC 不再按代码扩展名匹配,JSON 规格恢复文档预览。
- 工具条分隔线测试对齐当前动作组条件,去掉旧 CSS 与变量名断言。
- 实时事件与历史回读的工具调用时间戳在模型入口归一化为毫秒,界面仍显示秒/分钟。
- 防止失效的精灵切片绕过校验重新进入通用资源路径;同步混合历史记录的失败关闭测试契约。

验证:
- 资源预览契约、真实 manifest、工具条测试:12 项通过。
- Direct 回合呈现测试:15 项通过。
- Rust direct_turn_update_payload_is_the_exact_camel_case_contract 通过,test target 编译成功。
- git diff --check 及提交钩子 ESLint、Prettier、Rustfmt 通过。

未运行完整 CI 或完整 appSurface 套件;其余对话区结构/文案断言未在此 PR 中批量调整。rehype-highlight 已在 master 的 package.json 与 lockfile 中声明,本 PR 未重复添加依赖。

Reviewed-on: #380
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-16 05:42:20 +08:00

237 lines
8.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type {
ChatMessage,
GameCreatorDirectToolCall,
TurnStreamItem,
} from '../src/app/types';
import {
buildDirectTurnPresentations,
directMessageTimestamp,
normalizeDirectTimestamp,
splitDirectTurnContent,
} from '../src/features/project-workspace/directTurnPresentation';
const user = (turn: string): ChatMessage => ({
role: 'user',
text: '只读检查',
messageId: `direct-codex:${turn}:user`,
});
const assistant = (id: string, text = '完整回复'): ChatMessage => ({
role: 'assistant',
text,
messageId: id,
});
const text = (turnId: string, id: string, seq = 1): TurnStreamItem => ({
schemaVersion: 'agc-turn-stream.v1',
kind: 'text',
turnId,
id: `text:${turnId}:${id}`,
text: '前缀',
seq,
at: 1_800_000_000_000,
updatedAt: 1_800_000_000_001,
});
const tool = (turnId: string): GameCreatorDirectToolCall => ({
schemaVersion: 'agc-tool-call.v1',
id: 'call',
turnId,
kind: 'mcp_tool',
title: '调用工具',
summary: '读取',
status: 'completed',
detail: { command: '{"path":"file"}', output: '内容', changes: [] },
startedAt: 1_800_000_000_000,
updatedAt: 1_800_000_000_001,
});
const build = (
messages: ChatMessage[],
items: TurnStreamItem[],
options: Partial<Parameters<typeof buildDirectTurnPresentations>[0]> = {},
) =>
buildDirectTurnPresentations({
messages,
visibleMessages: messages,
items,
calls: [],
transientReply: '',
...options,
});
describe('DirectProject 回合唯一呈现', () => {
it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => {
expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000);
expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000);
expect(directMessageTimestamp(1_800_000_000)).toBe(1_800_000_000_000);
});
it('历史仍有未加载切片时,不把那些回合的工具流追加到当前页末尾', () => {
const rows = build(
[user('one')],
[text('old', 'raw-old'), text('one', 'raw')],
{
hasUnloadedHistory: true,
},
);
expect(rows.map((row) => row.turnId)).toEqual(['one']);
const active = build([], [text('live', 'raw')], {
hasUnloadedHistory: true,
activeTurnId: 'live',
});
expect(active.map((row) => row.turnId)).toEqual(['live']);
});
it('尚未落盘用户的实时回合也只产生一个 owner,不另建 live 与 unmapped 出口', () => {
const rows = build([], [text('one', 'raw')], {
activeTurnId: 'one',
transientReply: '同一份累计回复',
calls: [tool('one')],
});
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('stream');
expect(rows[0].transientReply).toBe('');
expect(rows[0].calls).toHaveLength(1);
});
it('同一身份用户重复快照不增加回合,不丢用户正文', () => {
const rows = build([user('one'), user('one')], [text('one', 'raw')]);
expect(rows).toHaveLength(1);
expect(rows[0].messages).toHaveLength(1);
expect(rows[0].messages[0].role).toBe('user');
});
it('用原始 item 身份补齐旧前缀,最终合成消息不另占正文出口', () => {
const rows = build(
[user('one'), assistant('raw'), assistant('direct-codex:one:assistant')],
[text('one', 'raw')],
);
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('stream');
expect(rows[0].items).toHaveLength(1);
expect(rows[0].items[0].text).toBe('完整回复');
});
it('先关联完整历史再分页,首条可见 assistant 不会失去用户归属', () => {
const messages = [
user('old'),
assistant('old-raw'),
user('one'),
assistant('raw'),
];
const rows = build(messages, [text('old', 'old-raw'), text('one', 'raw')], {
visibleMessages: messages.slice(3),
});
expect(rows.map((row) => row.turnId)).toEqual(['one']);
expect(rows[0].messages[0].messageId).toBe('direct-codex:one:user');
});
it('纯文本旧回合不挤占之后有工具的回合身份', () => {
const rows = build(
[user('old'), assistant('plain'), user('one'), assistant('raw')],
[text('one', 'raw')],
{ calls: [tool('one')] },
);
expect(rows.map((row) => row.turnId)).toEqual(['old', 'one']);
expect(rows[0].source).toBe('messages');
expect(rows[0].calls).toEqual([]);
expect(rows[1].source).toBe('stream');
});
it('没有流的原始 assistant 与工具仍归属一个回合,输入输出保留', () => {
const rows = build([user('one'), assistant('raw')], [], {
calls: [tool('one')],
});
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('messages');
expect(rows[0].calls[0].detail.output).toBe('内容');
});
it('无法证明流覆盖历史时整轮回退,不能混画部分流与全文', () => {
const rows = build(
[user('one'), assistant('raw-a'), assistant('raw-b')],
[text('one', 'raw-b')],
);
expect(rows[0].source).toBe('messages');
expect(
rows[0].messages.filter((message) => message.role === 'assistant'),
).toHaveLength(2);
});
it('不同回合的相同文字不是重复消息,不做文本去重', () => {
const rows = build(
[user('one'), assistant('raw-one'), user('two'), assistant('raw-two')],
[text('one', 'raw-one'), text('two', 'raw-two')],
);
expect(rows).toHaveLength(2);
expect(rows.every((row) => row.source === 'stream')).toBe(true);
});
it('乱序重复快照按 seq 排列且不增加 item', () => {
const first = text('one', 'a');
const last = text('one', 'b', 3);
const marker: TurnStreamItem = {
...text('one', 'unused', 2),
kind: 'tool',
id: 'tool:one:call',
callId: 'call',
};
const rows = build([user('one')], [last, marker, first, first]);
expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]);
});
it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => {
const marker: TurnStreamItem = {
...text('one', 'unused', 2),
kind: 'tool',
id: 'tool:one:call',
callId: 'call',
};
const row = build(
[user('one')],
[text('one', 'start'), marker, text('one', 'final', 3)],
)[0];
const parts = splitDirectTurnContent(row);
expect(parts.processItems.map((item) => item.id)).toEqual([
'text:one:start',
'tool:one:call',
]);
expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']);
const active = splitDirectTurnContent({ ...row, active: true });
expect(active.processItems).toHaveLength(3);
expect(active.finalItems).toEqual([]);
});
it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => {
const row = build(
[user('one'), assistant('direct-codex:one:failure', '失败')],
[text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }],
)[0];
const parts = splitDirectTurnContent(row);
expect(parts.processItems.map((item) => item.id)).toEqual([
'text:one:progress',
]);
expect(parts.finalItems.map((item) => item.id)).toEqual([
'text:one:failure',
]);
});
it('无流历史只保留最后一条回复,发送时间不从工具推断', () => {
const row = build(
[user('one'), assistant('progress'), assistant('final')],
[],
)[0];
const parts = splitDirectTurnContent(row);
expect(parts.processMessages.map((message) => message.messageId)).toEqual([
'progress',
]);
expect(parts.finalMessages.map((message) => message.messageId)).toEqual([
'final',
]);
expect(directMessageTimestamp(undefined)).toBe(0);
expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0);
expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001);
});
it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => {
expect(
build([user('one')], [], {
activeTurnId: 'one',
transientReply: '回复',
})[0].transientReply,
).toBe('回复');
expect(
build([user('one'), assistant('direct-codex:one:assistant')], [], {
activeTurnId: 'one',
transientReply: '回复',
})[0].transientReply,
).toBe('');
});
});