Merge remote-tracking branch 'origin/master' into feat/game-works-management
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m33s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m18s
Project CI / Backend tests (pull_request) Successful in 6m35s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m50s
Project CI / Native shell tests (pull_request) Successful in 6m32s
Project CI / Repository checks (pull_request) Successful in 5m9s
Project CI / Frontend tests (pull_request) Successful in 1m53s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m33s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m18s
Project CI / Backend tests (pull_request) Successful in 6m35s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m50s
Project CI / Native shell tests (pull_request) Successful in 6m32s
Project CI / Repository checks (pull_request) Successful in 5m9s
Project CI / Frontend tests (pull_request) Successful in 1m53s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -71,6 +71,7 @@ import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
} from './features/project-summary/projectSummary';
|
||||
import { insertChatReferences } from './features/project-workspace/activeChatComposer';
|
||||
import { importDesignFiles } from './features/project-workspace/importDesignFiles';
|
||||
import {
|
||||
needsInitializedChatProject,
|
||||
@@ -78,10 +79,13 @@ import {
|
||||
} from './features/project-workspace/projectCommandPolicy';
|
||||
import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput';
|
||||
import {
|
||||
type ChatReference,
|
||||
directCodexContentToLegacyContentDto,
|
||||
hasMeaningfulDirectCodexContent,
|
||||
RESOURCE_REFERENCE_INSERT_EVENT,
|
||||
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
|
||||
type ResourceReferenceInsertEventDetail,
|
||||
type ResourceReferenceInsertManyEventDetail,
|
||||
} from './features/project-workspace/resourceReferences';
|
||||
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
|
||||
import {
|
||||
@@ -1257,22 +1261,54 @@ export function App({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
画布引用只有这一处消费者:单条「引用」按钮与拖拽批量引用都从这里落进草稿。
|
||||
落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、
|
||||
策划链路挂策划面,事件本身不携带这个判断。
|
||||
|
||||
插入失败(此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索,
|
||||
否则用户看到的又是一次「点了没反应」。空批次不是失败,它没有要插的东西,
|
||||
也就不能把原因指到输入区上。
|
||||
*/
|
||||
const insertReferences = (references: readonly ChatReference[]) => {
|
||||
if (references.length === 0) return;
|
||||
if (insertChatReferences(references)) return;
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[resource-reference] 引用没有落进草稿:没有可用的聊天输入区(未挂载或已卸载)',
|
||||
);
|
||||
}
|
||||
};
|
||||
const handleResourceReferenceInsert = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ResourceReferenceInsertEventDetail>)
|
||||
.detail;
|
||||
if (!detail?.reference) return;
|
||||
chatComposerRef.current?.insertReferences([detail.reference]);
|
||||
chatComposerRef.current?.focus();
|
||||
insertReferences([detail.reference]);
|
||||
};
|
||||
const handleResourceReferenceInsertMany = (event: Event) => {
|
||||
const detail = (
|
||||
event as CustomEvent<ResourceReferenceInsertManyEventDetail>
|
||||
).detail;
|
||||
insertReferences(detail?.references ?? []);
|
||||
};
|
||||
window.addEventListener(
|
||||
RESOURCE_REFERENCE_INSERT_EVENT,
|
||||
handleResourceReferenceInsert,
|
||||
);
|
||||
return () =>
|
||||
window.addEventListener(
|
||||
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
|
||||
handleResourceReferenceInsertMany,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
RESOURCE_REFERENCE_INSERT_EVENT,
|
||||
handleResourceReferenceInsert,
|
||||
);
|
||||
window.removeEventListener(
|
||||
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
|
||||
handleResourceReferenceInsertMany,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ChatReference } from './resourceReferences';
|
||||
|
||||
/**
|
||||
* 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。
|
||||
*
|
||||
* `insertReferences` 回答**这一批有没有真的落进草稿**(输入区自己那份句柄可能还没挂上、
|
||||
* 或者已经被卸载),注册表按它决定成功还是失败,不让「注册表里有句柄」冒充「用户看得见
|
||||
* 的结果」。输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板
|
||||
* 内部用,不进这里——注册表只负责跨面板的引用插入。
|
||||
*/
|
||||
export type ActiveChatComposerHandle = {
|
||||
insertReferences: (references: ChatReference[]) => boolean;
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 同一时刻只可能有一个聊天输入区挂载:普通项目走 DirectProject,立项策划走策划面,
|
||||
* 两条链路互斥渲染(见 `App.tsx` 的 `directProjectMode`)。
|
||||
*/
|
||||
let activeChatComposer: ActiveChatComposerHandle | null = null;
|
||||
|
||||
/**
|
||||
* 注册当前挂载的聊天输入区,返回注销函数。
|
||||
*
|
||||
* 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换),
|
||||
* 旧注销不能把新句柄一起清掉。
|
||||
*
|
||||
* 两个输入区同时挂载属于调用方接线错误(本应互斥,见 `App.tsx` 的 `directProjectMode`):
|
||||
* 后注册者会顶掉前者,引用会落进用户看不见的那份草稿。这里只留一条线索,不改运行时语义
|
||||
* ——注册表仍然按最后注册的那个工作,注销的身份校验也照旧兜住乱序卸载。
|
||||
*/
|
||||
export function registerActiveChatComposer(
|
||||
handle: ActiveChatComposerHandle,
|
||||
): () => void {
|
||||
if (activeChatComposer && activeChatComposer !== handle) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[resource-reference] 检测到第二个聊天输入区注册:引用会插进最后注册的那一个',
|
||||
);
|
||||
}
|
||||
}
|
||||
activeChatComposer = handle;
|
||||
return () => {
|
||||
if (activeChatComposer === handle) {
|
||||
activeChatComposer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。
|
||||
*
|
||||
* 三种情况都返回 `false`:空批次、此刻没有任何输入区挂载、注册表里的句柄已经插不进去
|
||||
* (它转发的那份输入区没挂上或已卸载)。这些都不能静默,由调用方(`App.tsx` 的事件监听)
|
||||
* 决定怎么留痕或提示,注册表本身不吞;只有真的插进去了才把焦点交给输入区。
|
||||
*/
|
||||
export function insertChatReferences(
|
||||
references: readonly ChatReference[],
|
||||
): boolean {
|
||||
if (references.length === 0) return false;
|
||||
const handle = activeChatComposer;
|
||||
if (!handle) return false;
|
||||
if (!handle.insertReferences([...references])) return false;
|
||||
handle.focus();
|
||||
return true;
|
||||
}
|
||||
+30
-1
@@ -4,17 +4,22 @@ import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { claimInitialTurnForPage } from '../../../app/initialTurnClaims';
|
||||
import type { PendingUiConfirmation } from '../../../app/types';
|
||||
import { projectNameFromPath } from '../../../features/agent-runtime';
|
||||
import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer';
|
||||
import { userItemFromContent } from '../../../features/project-workspace/resourceReferences';
|
||||
import { type ApprovalMode, approvalModeLabel } from '../approvalMode';
|
||||
import { ApprovalModeDialog } from '../ApprovalModeDialog';
|
||||
import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader';
|
||||
import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer';
|
||||
import {
|
||||
DirectProjectComposer,
|
||||
type DirectProjectComposerHandle,
|
||||
} from './components/DirectProjectComposer/DirectProjectComposer';
|
||||
import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation';
|
||||
import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog';
|
||||
import {
|
||||
@@ -103,6 +108,7 @@ export function DirectProjectChatView({
|
||||
}: DirectProjectChatViewProps) {
|
||||
const { assets, projectId, refresh, versions } =
|
||||
useDirectProjectManifest(projectPath);
|
||||
const composerHandleRef = useRef<DirectProjectComposerHandle | null>(null);
|
||||
const [runtimeNotice, setRuntimeNotice] = useState('');
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [approvalOpen, setApprovalOpen] = useState(false);
|
||||
@@ -212,6 +218,28 @@ export function DirectProjectChatView({
|
||||
},
|
||||
}));
|
||||
|
||||
/*
|
||||
画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里
|
||||
**当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路
|
||||
互斥,所以同一时刻注册表里只有一个句柄。
|
||||
|
||||
注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面
|
||||
在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences`
|
||||
返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。
|
||||
*/
|
||||
useEffect(
|
||||
() =>
|
||||
registerActiveChatComposer({
|
||||
insertReferences: (references) => {
|
||||
const handle = composerHandleRef.current;
|
||||
if (!handle) return false;
|
||||
return handle.insertReferences(references);
|
||||
},
|
||||
focus: () => composerHandleRef.current?.focus(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="project-chat-surface is-direct-codex"
|
||||
@@ -252,6 +280,7 @@ export function DirectProjectChatView({
|
||||
</div>
|
||||
) : null}
|
||||
<DirectProjectComposer
|
||||
ref={composerHandleRef}
|
||||
assets={assets}
|
||||
{...(versions ? { versions } : {})}
|
||||
projectPath={projectPath}
|
||||
|
||||
+43
-2
@@ -1,10 +1,18 @@
|
||||
import { ArrowUp, AtSign } from 'lucide-react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameIterationVersion,
|
||||
} from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { ActiveChatComposerHandle } from '../../../../../features/project-workspace/activeChatComposer';
|
||||
import {
|
||||
ConversationModelSelect,
|
||||
type ConversationModelSelectHandle,
|
||||
@@ -21,7 +29,10 @@ import {
|
||||
ResourceReferencePicker,
|
||||
type ResourceReferencePickerHandle,
|
||||
} from '../../../../../features/project-workspace/ResourceReferencePicker';
|
||||
import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences';
|
||||
import {
|
||||
assetsSignature,
|
||||
type ChatReference,
|
||||
} from '../../../../../features/project-workspace/resourceReferences';
|
||||
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
|
||||
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
|
||||
import type { UserContentPart } from '../../generated/UserContentPart';
|
||||
@@ -40,6 +51,12 @@ function draftAttachmentCount(draft: { content: UserContentPart[] }) {
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入区对外的命令式出口:就是「活跃聊天输入区」注册表要的那两件事
|
||||
* (见 `features/project-workspace/activeChatComposer.ts`)。
|
||||
*/
|
||||
export type DirectProjectComposerHandle = ActiveChatComposerHandle;
|
||||
|
||||
/**
|
||||
* DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。
|
||||
*
|
||||
@@ -62,6 +79,7 @@ export function DirectProjectComposer({
|
||||
onReferencePickerOpen,
|
||||
onSubmit,
|
||||
onUploadFiles,
|
||||
ref,
|
||||
}: {
|
||||
assets: GameCreationAppAssetManifestEntry[];
|
||||
versions?: GameIterationVersion[];
|
||||
@@ -107,6 +125,11 @@ export function DirectProjectComposer({
|
||||
files: readonly File[],
|
||||
draftAttachmentCount: number,
|
||||
) => Promise<DirectCodexTurnAttachment[]>;
|
||||
/**
|
||||
* 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」,
|
||||
* 画布的「引用」/拖拽批量引用事件才能落进这份草稿。
|
||||
*/
|
||||
ref?: Ref<DirectProjectComposerHandle>;
|
||||
}) {
|
||||
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
|
||||
const composerRootRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -166,6 +189,24 @@ export function DirectProjectComposer({
|
||||
},
|
||||
[onUploadFiles],
|
||||
);
|
||||
/*
|
||||
对外句柄只做一层转发:内部输入区句柄会随编辑器重挂载换对象,这里按 ref 读最新值,
|
||||
句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相;
|
||||
`insertReferences` 回答有没有真的递到输入区——注册表拿它区分「插进去了」和「这一步落空」。
|
||||
*/
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
insertReferences: (references: ChatReference[]) => {
|
||||
const handle = composerRef.current;
|
||||
if (!handle) return false;
|
||||
handle.insertReferences([...references]);
|
||||
return true;
|
||||
},
|
||||
focus: () => composerRef.current?.focus(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
// 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管),
|
||||
// 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。
|
||||
const submitButton = (
|
||||
|
||||
+19
-1
@@ -1,6 +1,6 @@
|
||||
import { ArrowUp, FileUp, Loader2 } from 'lucide-react';
|
||||
import type { FormEventHandler, RefObject, UIEventHandler } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent';
|
||||
import type {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
projectNameFromPath,
|
||||
projectRuntimeVisibleError,
|
||||
} from '../../../features/agent-runtime';
|
||||
import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer';
|
||||
import { ConversationModelSelect } from '../../../features/project-workspace/ConversationModelSelect';
|
||||
import { attachmentReferenceProvider } from '../../../features/project-workspace/reference-source/attachmentReferenceProvider';
|
||||
import { createResourceReferenceProvider } from '../../../features/project-workspace/reference-source/resourceReferenceProvider';
|
||||
@@ -133,6 +134,23 @@ export function PlanningChatView({
|
||||
onDesignRetry,
|
||||
}: PlanningChatViewProps) {
|
||||
const designFileInputRef = useRef<HTMLInputElement>(null);
|
||||
/*
|
||||
策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处,
|
||||
它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份
|
||||
可变句柄),注册的那个包装对象因此永远读到最新值,并由它回答「这一批有没有真的插进去」。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!composerRef) return;
|
||||
return registerActiveChatComposer({
|
||||
insertReferences: (references) => {
|
||||
const handle = composerRef.current;
|
||||
if (!handle) return false;
|
||||
handle.insertReferences([...references]);
|
||||
return true;
|
||||
},
|
||||
focus: () => composerRef.current?.focus(),
|
||||
});
|
||||
}, [composerRef]);
|
||||
/*
|
||||
策划输入盒的引用来源:**只注入资源与两个静默 provider**。
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { dispatchResourceReferenceInsert } from '../../src/features/project-workspace/resourceReferences';
|
||||
import {
|
||||
act,
|
||||
App,
|
||||
@@ -935,4 +936,35 @@ export function registerDesignAgentSurfaceTests() {
|
||||
expect(details[1]!.open).toBe(false);
|
||||
await waitFor(() => expect(summaries[0]!.textContent).toBe('思考过程'));
|
||||
});
|
||||
|
||||
it('画布派发的「引用」落进策划输入盒草稿', async () => {
|
||||
const harness = createProjectChatRuntimeHarness({
|
||||
initialSessionExists: false,
|
||||
});
|
||||
renderDesignAgent(harness);
|
||||
const editor = await screen.findByLabelText('项目需求');
|
||||
|
||||
// 资源画布唯一的生产入口就是这个 window 事件;消费者在 `App.tsx`,落到哪份输入盒由
|
||||
// 「活跃聊天输入区」注册表回答。策划链路与普通项目共用一个消费者,这条用例钉的是
|
||||
// 它在 `PlanningChatView` 这一侧也真的进了草稿。
|
||||
act(() => {
|
||||
dispatchResourceReferenceInsert({
|
||||
type: 'resource',
|
||||
resourceId: 'planning-hero',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
label: 'hero.png',
|
||||
category: 'character',
|
||||
tags: [],
|
||||
source: 'resource-card',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
editor.querySelectorAll('[data-resource-reference-id="planning-hero"]'),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
expect(editor.textContent).toContain('@hero.png');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
} from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
|
||||
import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog';
|
||||
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
|
||||
import {
|
||||
@@ -31,8 +30,10 @@ import {
|
||||
} from '../resourceGenerationPromptTestUtils';
|
||||
import {
|
||||
act,
|
||||
App,
|
||||
cleanup,
|
||||
createGameCreationAppManifest,
|
||||
createProjectChatRuntimeHarness,
|
||||
expect,
|
||||
findResourceSelectButton,
|
||||
fireEvent,
|
||||
@@ -53,6 +54,11 @@ import {
|
||||
within,
|
||||
} from './harness';
|
||||
|
||||
// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。
|
||||
const AppComponent = App as React.ComponentType<
|
||||
NonNullable<React.ComponentProps<typeof App>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 历史夹具沿用的旧 kind 字面量(如 `design-document` / `game-code`)已不在当前
|
||||
* `GameCreationAppAssetKind` 联合里;这些用例依赖未识别 kind 落「待归类」的运行时语义,
|
||||
@@ -3608,7 +3614,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
);
|
||||
});
|
||||
|
||||
it('工具条里的「引用」用键盘也能插进聊天输入框', async () => {
|
||||
it('工具条里的「引用」把素材 @ 进真实聊天草稿(键盘与鼠标两条通路)', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-resource-reference',
|
||||
'引用入口测试',
|
||||
@@ -3623,6 +3629,13 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
source: { kind: 'generated' },
|
||||
});
|
||||
let layoutRevision = 0;
|
||||
const projectPath = '/tmp/workbench-resource-reference';
|
||||
// 对话面用真实 DirectProject 链路(`App` → `DirectProjectChatView` → `DirectProjectComposer`):
|
||||
// 引用事件的消费者在 `App.tsx`,桩接不住它,链路断点正好在「谁把它落进草稿」。
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
@@ -3660,20 +3673,45 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return manifest;
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
projectName: manifest.name,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'get_local_game_preview_status') {
|
||||
return { status: 'stopped', url: null, port: null, root: null };
|
||||
}
|
||||
if (command === 'get_design_agent_runtime_mode') {
|
||||
return null;
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
installTauriRuntime({
|
||||
core: { invoke },
|
||||
event: { listen: chatHarness.listen },
|
||||
});
|
||||
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: '引用入口测试',
|
||||
projectPath: '/tmp/workbench-resource-reference',
|
||||
projectPath,
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
chat: React.createElement('div', null, '项目总控'),
|
||||
chat: React.createElement(AppComponent, {
|
||||
initialProjectPath: projectPath,
|
||||
}),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
}),
|
||||
@@ -3701,49 +3739,39 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
'引用资源 hero.png',
|
||||
);
|
||||
|
||||
const inserted: unknown[] = [];
|
||||
const onInsert = (event: Event) => {
|
||||
inserted.push(
|
||||
(event as CustomEvent<{ reference: unknown }>).detail.reference,
|
||||
);
|
||||
};
|
||||
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
|
||||
try {
|
||||
// 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip
|
||||
// (`App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。
|
||||
referenceButton.focus();
|
||||
expect(document.activeElement).toBe(referenceButton);
|
||||
await userEvent.setup().keyboard('{Enter}');
|
||||
expect(inserted).toHaveLength(1);
|
||||
// 端到端契约:交付结果是草稿里的引用芯片,不是 window 上的一次 dispatch。
|
||||
// 链路断点一直是「谁把引用落进草稿」,所以断言必须读真实输入盒的 DOM。
|
||||
const draftChipIds = () =>
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'form.project-chat-composer [data-resource-reference-id]',
|
||||
),
|
||||
).map((chip) => chip.getAttribute('data-resource-reference-id'));
|
||||
const composerEditor = () =>
|
||||
document.querySelector<HTMLElement>(
|
||||
'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]',
|
||||
)!;
|
||||
// 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。
|
||||
const firstChipLabel = () =>
|
||||
document.querySelector<HTMLElement>(
|
||||
'form.project-chat-composer [data-resource-reference-id] .resource-reference-chip-label',
|
||||
)?.textContent;
|
||||
|
||||
// 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。
|
||||
fireEvent.click(referenceButton);
|
||||
expect(inserted).toHaveLength(2);
|
||||
} finally {
|
||||
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
|
||||
}
|
||||
expect(inserted).toEqual([
|
||||
{
|
||||
type: 'resource',
|
||||
resourceId: 'scene-hero',
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
label: 'hero.png',
|
||||
category: 'scene',
|
||||
tags: ['主舞台'],
|
||||
source: 'resource-card',
|
||||
},
|
||||
{
|
||||
type: 'resource',
|
||||
resourceId: 'scene-hero',
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
label: 'hero.png',
|
||||
category: 'scene',
|
||||
tags: ['主舞台'],
|
||||
source: 'resource-card',
|
||||
},
|
||||
]);
|
||||
// 键盘通路:聚焦后回车确认。
|
||||
referenceButton.focus();
|
||||
expect(document.activeElement).toBe(referenceButton);
|
||||
await userEvent.setup().keyboard('{Enter}');
|
||||
await waitFor(() => expect(draftChipIds()).toEqual(['scene-hero']));
|
||||
expect(firstChipLabel()).toBe('hero');
|
||||
expect(composerEditor().textContent).toContain('@hero');
|
||||
|
||||
// 鼠标通路:再点一次,第二枚芯片接在第一枚之后——光标留在插入之后,不覆盖已插的引用。
|
||||
fireEvent.click(referenceButton);
|
||||
await waitFor(() =>
|
||||
expect(draftChipIds()).toEqual(['scene-hero', 'scene-hero']),
|
||||
);
|
||||
// 插入后焦点回到输入盒,用户可以接着打字。
|
||||
expect(document.activeElement).toBe(composerEditor());
|
||||
});
|
||||
|
||||
it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => {
|
||||
|
||||
@@ -15,8 +15,11 @@ import {
|
||||
type ResourceReferenceInsertManyEventDetail,
|
||||
} from '../src/features/project-workspace/resourceReferences';
|
||||
import ProjectDevelopmentView from '../src/view/project-development';
|
||||
import type { ProjectAttachmentResult } from '../src/view/project-development/resourceProjectionModel';
|
||||
import {
|
||||
App,
|
||||
createGameCreationAppManifest,
|
||||
createProjectChatRuntimeHarness,
|
||||
fireEvent,
|
||||
React,
|
||||
render,
|
||||
@@ -24,6 +27,11 @@ import {
|
||||
} from './appSurface/harness';
|
||||
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
|
||||
|
||||
// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。
|
||||
const AppComponent = App as React.ComponentType<
|
||||
NonNullable<React.ComponentProps<typeof App>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 「拖动素材到对话 = 批量 @ 引用」的行为级验收。
|
||||
*
|
||||
@@ -89,10 +97,21 @@ type LayoutWrite = {
|
||||
positions: ProjectResourceCanvasPosition[];
|
||||
};
|
||||
|
||||
function installTauri(): { layoutWrites: LayoutWrite[] } {
|
||||
/** 画布与真实 DirectProject 聊天面共用的项目身份。 */
|
||||
const CANVAS_PROJECT_PATH = '/tmp/chat-reference-drop';
|
||||
|
||||
function installTauri(manifest: GameCreationAppManifest): {
|
||||
layoutWrites: LayoutWrite[];
|
||||
} {
|
||||
const layoutWrites: LayoutWrite[] = [];
|
||||
const persisted = new Map<string, ProjectResourceCanvasPosition[]>();
|
||||
const revisions = new Map<string, number>();
|
||||
// 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答,
|
||||
// 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath: CANVAS_PROJECT_PATH,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
@@ -142,10 +161,33 @@ function installTauri(): { layoutWrites: LayoutWrite[] } {
|
||||
if (command === 'list_local_project_asset_generations') {
|
||||
return [];
|
||||
}
|
||||
return undefined;
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return manifest;
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath: CANVAS_PROJECT_PATH,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
projectName: manifest.name,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'get_local_game_preview_status') {
|
||||
return { status: 'stopped', url: null, port: null, root: null };
|
||||
}
|
||||
if (command === 'get_design_agent_runtime_mode') {
|
||||
return null;
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
installTauriRuntime({
|
||||
core: { invoke },
|
||||
event: { listen: chatHarness.listen },
|
||||
});
|
||||
return { layoutWrites };
|
||||
}
|
||||
|
||||
@@ -187,21 +229,34 @@ function collectReferenceInserts() {
|
||||
};
|
||||
}
|
||||
|
||||
async function mountCanvas() {
|
||||
const tauri = installTauri();
|
||||
async function mountCanvas(
|
||||
options: {
|
||||
/** 项目附件(无 manifest 登记的素材来源)。 */
|
||||
attachments?: ProjectAttachmentResult[];
|
||||
/** 打开哪个栏目找卡片;未登记素材统一落在「待归类」。 */
|
||||
categoryLabel?: string;
|
||||
} = {},
|
||||
) {
|
||||
const { attachments = [], categoryLabel = '角色与对象' } = options;
|
||||
const manifest = manifestFor('chat-reference-drop', [
|
||||
characterAsset('drop-a', 'a.png'),
|
||||
characterAsset('drop-b', 'b.png'),
|
||||
]);
|
||||
const tauri = installTauri(manifest);
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: '/tmp/chat-reference-drop',
|
||||
projectPath: CANVAS_PROJECT_PATH,
|
||||
manifest,
|
||||
attachments: [],
|
||||
attachments,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
chat: React.createElement('div', null, '项目总控'),
|
||||
// 对话面是真实链路:`App`(`directProjectMode`)→ `DirectProjectChatView` →
|
||||
// `DirectProjectComposer`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」,
|
||||
// 所以这里不能再拿 `<div>项目总控</div>` 顶替。
|
||||
chat: React.createElement(AppComponent, {
|
||||
initialProjectPath: CANVAS_PROJECT_PATH,
|
||||
}),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
}),
|
||||
@@ -222,7 +277,7 @@ async function mountCanvas() {
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
act(() => window.dispatchEvent(new Event('resize')));
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: `打开${categoryLabel}` }));
|
||||
await settle();
|
||||
const chat = document.querySelector<HTMLElement>('.game-workbench-chat')!;
|
||||
vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT);
|
||||
@@ -236,6 +291,20 @@ function cardIn(manager: HTMLElement, resourceId: string) {
|
||||
)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实 DirectProject 输入盒草稿里的资源引用芯片,按 DOM 顺序读出 `resourceId`。
|
||||
*
|
||||
* 事件派发本身不是交付结果:链路断点一直是「谁把它落进草稿」,所以断言必须读草稿,
|
||||
* 不能再读 window 上的一次 dispatch。
|
||||
*/
|
||||
function draftReferenceIds() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'form.project-chat-composer [data-resource-reference-id]',
|
||||
),
|
||||
).map((chip) => chip.getAttribute('data-resource-reference-id') ?? '');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetTauriRuntime();
|
||||
vi.restoreAllMocks();
|
||||
@@ -281,6 +350,8 @@ describe('拖动素材到对话:批量 @ 引用', () => {
|
||||
kind: 'character',
|
||||
source: 'resource-card',
|
||||
});
|
||||
// 端到端:这一批引用真的落进了真实 DirectProject 输入盒的草稿。
|
||||
await waitFor(() => expect(draftReferenceIds()).toEqual(['drop-a']));
|
||||
// 拖到对话不是排版:一条坐标都不写。
|
||||
expect(tauri.layoutWrites).toHaveLength(writesBefore);
|
||||
expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull();
|
||||
@@ -327,6 +398,10 @@ describe('拖动素材到对话:批量 @ 引用', () => {
|
||||
.map((reference) => reference.resourceId),
|
||||
),
|
||||
).toEqual(new Set(['drop-a', 'drop-b']));
|
||||
// 端到端:整批一次插进同一份草稿,顺序就是拖动集合(画布可见顺序)的顺序。
|
||||
await waitFor(() =>
|
||||
expect(draftReferenceIds()).toEqual(['drop-a', 'drop-b']),
|
||||
);
|
||||
expect(tauri.layoutWrites).toHaveLength(writesBefore);
|
||||
dispose();
|
||||
});
|
||||
@@ -404,4 +479,81 @@ describe('拖动素材到对话:批量 @ 引用', () => {
|
||||
expect(inserts).toHaveLength(0);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('未登记素材:不出现「引用」按钮,拖到对话栏只给原因', async () => {
|
||||
// 导入的附件没有 manifest 登记(`manifestAssetId: null`),画布把它归到「待归类」。
|
||||
const { manager, inserts, dispose } = await mountCanvas({
|
||||
categoryLabel: '待归类',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'raw-shot.png',
|
||||
mediaType: 'image/png',
|
||||
status: 'imported',
|
||||
localPath: 'assets/raw-shot.png',
|
||||
},
|
||||
],
|
||||
});
|
||||
const card = cardIn(manager, 'attachment:assets/raw-shot.png');
|
||||
expect(card).not.toBeNull();
|
||||
|
||||
fireEvent.click(card);
|
||||
// 引用入口与「编辑 / 删除」同一条 `manifestAssetId` 判据:没登记就没有这枚按钮。
|
||||
expect(screen.queryByRole('button', { name: /^引用资源/ })).toBeNull();
|
||||
|
||||
fireEvent.pointerDown(card, {
|
||||
pointerId: 74,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(card, {
|
||||
pointerId: 74,
|
||||
buttons: 1,
|
||||
clientX: 1000,
|
||||
clientY: 300,
|
||||
});
|
||||
// 落点提示也说清原因,不承诺一次插不进去的引用。
|
||||
expect(
|
||||
screen.getByText('这些素材还没登记为项目资源,不能 @ 引用'),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.pointerUp(card, {
|
||||
pointerId: 74,
|
||||
button: 0,
|
||||
clientX: 1000,
|
||||
clientY: 300,
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(
|
||||
screen.getByText('选中的素材都还没登记为项目资源,暂时不能 @ 引用'),
|
||||
).not.toBeNull();
|
||||
expect(inserts).toHaveLength(0);
|
||||
expect(draftReferenceIds()).toEqual([]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('空批次引用事件不报「没有可用的聊天输入区」', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { dispose } = await mountCanvas();
|
||||
// 派发器自己会挡掉空批次(`dispatchResourceReferenceInsertMany` 对空数组直接 return),
|
||||
// 这里直接造事件:钉的是 App 侧不把「没有要插的东西」说成「没有输入区」。
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(RESOURCE_REFERENCE_INSERT_MANY_EVENT, {
|
||||
detail: { references: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(
|
||||
warn.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.filter((message) => message.includes('没有可用的聊天输入区')),
|
||||
).toEqual([]);
|
||||
expect(draftReferenceIds()).toEqual([]);
|
||||
warn.mockRestore();
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user