Files
Genarrative/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts
T
suzmii 3f18e3dc77
Project CI / AI game creator shell Rust crates (pull_request) Failing after 1m35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m20s
Project CI / Backend tests (pull_request) Failing after 4m39s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m18s
Project CI / Frontend tests (pull_request) Successful in 3m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m43s
Project CI / Native shell tests (pull_request) Successful in 7m28s
Project CI / Repository checks (pull_request) Failing after 4m32s
按独立评审的三条 P2 收口:空批次不误报、重复注册留线索、文档与实测对齐
App.tsx 的监听处对空批次直接返回:空批次没有要插的东西,不能报成「没有可用的聊天输入区」(原因指向错了方向)
activeChatComposer.registerActiveChatComposer 增加重复注册检测:dev 下 console.warn 指出后注册者顶替了前者,不改运行时语义(仍然后注册者接管、身份校验照旧兜住乱序卸载)
tests/activeChatComposer.test.ts 补两条:空批次不打扰已挂载输入区;重复注册出现告警且乱序注销清不掉更新的句柄
tests/resourceCanvasChatReferenceDrop.test.tsx 补一条:直接构造空批次引用事件时不产生「没有可用的聊天输入区」告警
decision-log 的「影响范围」补记 tests/activeChatComposer.test.ts,「验证」换成合并 master 后的实跑数字;功能说明的用例表补两行
反向证伪:去掉空批次短路、去掉重复注册告警后,上面两条新用例各红一处
2026-10-04 00:38:06 +08:00

138 lines
5.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
type ActiveChatComposerHandle,
insertChatReferences,
registerActiveChatComposer,
} from '../src/features/project-workspace/activeChatComposer';
import type { ChatReference } from '../src/features/project-workspace/resourceReferences';
function resourceReference(resourceId: string): ChatReference {
return {
type: 'resource',
resourceId,
kind: 'character',
mediaType: 'image/png',
label: resourceId,
category: 'character',
tags: [],
source: 'resource-card',
};
}
/** 一个「插入总是成功」的输入区句柄替身;`inserted` 为 false 时模拟输入区已不在位。 */
function composerHandle(inserted = true): {
handle: ActiveChatComposerHandle;
insertReferences: ReturnType<typeof vi.fn>;
focus: ReturnType<typeof vi.fn>;
} {
const insertReferences = vi.fn(() => inserted);
const focus = vi.fn();
return { handle: { insertReferences, focus }, insertReferences, focus };
}
/**
* 「活跃聊天输入区」注册表的合同。
*
* 这些语义就是 `App.tsx` 那处监听区分「引用落进草稿」与「这一步落空(要留痕)」的唯一依据,
* 所以在这里钉死;链路本身的端到端断言在 `resourceCanvasChatReferenceDrop.test.tsx` 与
* `appSurface/*.suite.ts`。
*/
describe('活跃聊天输入区注册表', () => {
// 注册表是模块级单例:用例之间靠对称注销回到空态,只在这里清 mock 记录。
afterEach(() => {
vi.restoreAllMocks();
});
it('没有任何输入区挂载时插入返回 false', () => {
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
});
it('空批次不算成功,也不打扰已挂载的输入区', () => {
const { handle, insertReferences, focus } = composerHandle();
const unregister = registerActiveChatComposer(handle);
expect(insertChatReferences([])).toBe(false);
expect(insertReferences).not.toHaveBeenCalled();
expect(focus).not.toHaveBeenCalled();
unregister();
});
it('挂载期间:整批一次交给输入区、插入成功后才聚焦,返回 true', () => {
const { handle, insertReferences, focus } = composerHandle();
const unregister = registerActiveChatComposer(handle);
expect(
insertChatReferences([
resourceReference('hero'),
resourceReference('npc'),
]),
).toBe(true);
expect(insertReferences).toHaveBeenCalledTimes(1);
expect(insertReferences).toHaveBeenCalledWith([
expect.objectContaining({ resourceId: 'hero' }),
expect.objectContaining({ resourceId: 'npc' }),
]);
expect(focus).toHaveBeenCalledTimes(1);
unregister();
});
it('句柄报「这一批没插进去」时返回 false,且不抢焦点', () => {
const { handle, focus } = composerHandle(false);
const unregister = registerActiveChatComposer(handle);
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
expect(focus).not.toHaveBeenCalled();
unregister();
});
it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => {
// 这条用例故意让两个句柄同时在册(重复注册的告警本身由下一条用例覆盖),先静音。
vi.spyOn(console, 'warn').mockImplementation(() => {});
const first = composerHandle();
const second = composerHandle();
const unregisterFirst = registerActiveChatComposer(first.handle);
const unregisterSecond = registerActiveChatComposer(second.handle);
// 旧链路后卸载(切换项目 / 两条链路互换时可能发生):新句柄必须留在注册表里。
unregisterFirst();
expect(insertChatReferences([resourceReference('hero')])).toBe(true);
expect(second.insertReferences).toHaveBeenCalledTimes(1);
expect(first.insertReferences).not.toHaveBeenCalled();
unregisterSecond();
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
});
it('重复注册:后注册者接管并留一条告警,乱序注销也清不掉更新的句柄', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const first = composerHandle();
const second = composerHandle();
const third = composerHandle();
const unregisterFirst = registerActiveChatComposer(first.handle);
expect(warn).not.toHaveBeenCalled();
// 第二个注册:接线错误(两个聊天面同时挂载),后注册者接管,只在 dev 留线索。
const unregisterSecond = registerActiveChatComposer(second.handle);
expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([
expect.stringContaining('第二个聊天输入区注册'),
]);
expect(insertChatReferences([resourceReference('hero')])).toBe(true);
expect(second.insertReferences).toHaveBeenCalledTimes(1);
// 再注册第三个(模拟又一条链路接管):同样留线索。
const unregisterThird = registerActiveChatComposer(third.handle);
expect(warn).toHaveBeenCalledTimes(2);
// 乱序注销:第一个、第二个先卸载,都不能清掉当前接管的第三个。
unregisterFirst();
unregisterSecond();
expect(insertChatReferences([resourceReference('npc')])).toBe(true);
expect(third.insertReferences).toHaveBeenCalledTimes(1);
expect(first.insertReferences).not.toHaveBeenCalled();
expect(second.insertReferences).toHaveBeenCalledTimes(1);
unregisterThird();
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
warn.mockRestore();
});
});