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
App.tsx 的监听处对空批次直接返回:空批次没有要插的东西,不能报成「没有可用的聊天输入区」(原因指向错了方向) activeChatComposer.registerActiveChatComposer 增加重复注册检测:dev 下 console.warn 指出后注册者顶替了前者,不改运行时语义(仍然后注册者接管、身份校验照旧兜住乱序卸载) tests/activeChatComposer.test.ts 补两条:空批次不打扰已挂载输入区;重复注册出现告警且乱序注销清不掉更新的句柄 tests/resourceCanvasChatReferenceDrop.test.tsx 补一条:直接构造空批次引用事件时不产生「没有可用的聊天输入区」告警 decision-log 的「影响范围」补记 tests/activeChatComposer.test.ts,「验证」换成合并 master 后的实跑数字;功能说明的用例表补两行 反向证伪:去掉空批次短路、去掉重复注册告警后,上面两条新用例各红一处
138 lines
5.5 KiB
TypeScript
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();
|
|
});
|
|
});
|