onChangeType(resource.manifestAssetId!)
+ : undefined
+ }
+ onInfoClick={() => onShowInfo(resource)}
+ />
{lineage ? (
// 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据
// (稳定 id 见卡上的 `data-resource-replaced-by` / `data-resource-replacement-of`)。
@@ -1342,6 +1464,7 @@ function ResourceBookScene({
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
+ onLostPointerCapture={onPointerCancel}
>
(null);
/** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */
- const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false);
+ const [resourceInfoResourceId, setResourceInfoResourceId] = useState<
+ string | null
+ >(null);
+ const resourceInfoPanelOpen =
+ resourceInfoResourceId !== null &&
+ selectedResourceIds[0] === resourceInfoResourceId;
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
useState(null);
/** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */
@@ -1587,6 +1716,14 @@ export default function ProjectDevelopmentView({
const [resourcePanelOpen, setResourcePanelOpen] = useState(false);
const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] =
useState(null);
+ /**
+ * 引擎模型的放大预览浮层身份(同时是当前选中资源的预览身份)。
+ *
+ * 与文档预览浮层一样只在 `resources` 视图、且身份仍等于当前选中资源时渲染;
+ * 它是**只读预览**,不参与编辑、不写回 manifest。
+ */
+ const [resourceModelPreviewIdentity, setResourceModelPreviewIdentity] =
+ useState(null);
/**
* 「生成素材」浮层的本次放行类型。
*
@@ -1771,6 +1908,7 @@ export default function ProjectDevelopmentView({
const resourceCanvasFitKeysRef = useRef>(new Set());
const resourceCanvasPanRef = useRef<{
pointerId: number;
+ captureTarget: HTMLElement;
category: ResourceBookTarget;
startClientX: number;
startClientY: number;
@@ -1881,7 +2019,7 @@ export default function ProjectDevelopmentView({
*/
const clearResourceCanvasFocus = useCallback(() => {
setSelectedResourceIds([]);
- setResourceInfoPanelOpen(false);
+ setResourceInfoResourceId(null);
if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) {
return;
}
@@ -2305,7 +2443,9 @@ export default function ProjectDevelopmentView({
* 是因为选中本身有多个清空入口(清焦点、切视图、切项目)。
*/
useEffect(() => {
- setResourceInfoPanelOpen(false);
+ setResourceInfoResourceId((current) =>
+ current === selectedResourceId ? current : null,
+ );
}, [selectedResourceId]);
const projectVersions = useMemo(
() => manifest.versions ?? [],
@@ -3363,15 +3503,14 @@ export default function ProjectDevelopmentView({
);
const cancelResourceCanvasPan = useCallback(() => {
- const pan = resourceCanvasPanRef.current;
- if (!pan) {
- return;
- }
- const canvas = resourceCanvasRef.current;
- if (canvas?.hasPointerCapture?.(pan.pointerId)) {
- canvas.releasePointerCapture?.(pan.pointerId);
- }
+ const pans = [resourceCanvasPanRef.current, resourceBookMainPanRef.current];
resourceCanvasPanRef.current = null;
+ resourceBookMainPanRef.current = null;
+ for (const pan of pans) {
+ if (pan?.captureTarget.hasPointerCapture?.(pan.pointerId)) {
+ pan.captureTarget.releasePointerCapture(pan.pointerId);
+ }
+ }
}, []);
const cancelResourceCardDrag = useCallback(() => {
@@ -4749,6 +4888,8 @@ export default function ProjectDevelopmentView({
};
const handleBlur = () => {
resourceCanvasSpacePanRef.current = false;
+ cancelResourceCanvasPan();
+ setResourceCanvasMarquee(null);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
@@ -4758,7 +4899,7 @@ export default function ProjectDevelopmentView({
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
- }, []);
+ }, [cancelResourceCanvasPan]);
const handleResourceBookWheel = useCallback(
(event: ReactWheelEvent | WheelEvent) => {
@@ -4855,7 +4996,10 @@ export default function ProjectDevelopmentView({
const handleResourceBookMainPointerDown = useCallback(
(event: ReactPointerEvent) => {
- if (resourceBookView !== 'main' || event.button !== 0) {
+ if (
+ resourceBookView !== 'main' ||
+ (event.button !== 0 && event.button !== 2)
+ ) {
return;
}
const target = event.target as HTMLElement;
@@ -4871,6 +5015,7 @@ export default function ProjectDevelopmentView({
resourceBookTransitionControllerRef.current.settle();
resourceBookMainPanRef.current = {
pointerId: event.pointerId,
+ captureTarget: event.currentTarget,
startClientX: event.clientX,
startClientY: event.clientY,
startViewport: resourceBookMainViewportRef.current,
@@ -5096,11 +5241,19 @@ export default function ProjectDevelopmentView({
const canvasTarget = resourceBookOpensAllResources
? RESOURCE_BOOK_ALL_TARGET
: activePageCategory;
- if (!canvasTarget || event.button > 1) {
+ if (!canvasTarget || event.button > 2) {
return;
}
const target = event.target as HTMLElement;
- if (isResourceCanvasInteractionTarget(target)) {
+ const isPan =
+ event.button === 2 ||
+ event.button === 1 ||
+ resourceCanvasSpacePanRef.current;
+ if (
+ event.button === 2
+ ? !isResourceCanvasPanTarget(target)
+ : isResourceCanvasInteractionTarget(target)
+ ) {
return;
}
// 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡,
@@ -5114,12 +5267,13 @@ export default function ProjectDevelopmentView({
return;
}
resourceBookTransitionControllerRef.current.settle();
- // 与美术画布一致:中键或按住空格是平移,左键在空白处是框选。
- if (event.button === 1 || resourceCanvasSpacePanRef.current) {
+ // 右键平移,保留中键/空格抓手;空白处左键继续框选。
+ if (isPan) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
resourceCanvasPanRef.current = {
pointerId: event.pointerId,
+ captureTarget: event.currentTarget,
category: canvasTarget,
startClientX: event.clientX,
startClientY: event.clientY,
@@ -5292,12 +5446,22 @@ export default function ProjectDevelopmentView({
const openResourceUiEditor = useCallback(
(resource: ProjectResource) => {
- if (resource.subtype === 'ui-prototype') {
+ const identity = resourceCardPreviews.identityByResourceId.get(
+ resource.id,
+ );
+ const jsonPresentation = projectResourceJsonPresentation(
+ resource,
+ identity ? resourceCardPreviews.previews.get(identity) : null,
+ );
+ if (resource.subtype === 'ui-prototype' && jsonPresentation === null) {
void openUiDesignEditor(resource);
return;
}
- if (resource.manifestAssetId === null) {
- setResourceWorkbenchNotice('该 UI 资源缺少有效的正式资产身份');
+ if (
+ resource.manifestAssetId === null ||
+ jsonPresentation !== 'ui-design'
+ ) {
+ setResourceWorkbenchNotice('该资源尚未通过 UI 设计 JSON 校验');
return;
}
canvasOpenEpochRef.current += 1;
@@ -5318,7 +5482,13 @@ export default function ProjectDevelopmentView({
: {}),
});
},
- [advanceFocusGeneration, manifest.assets, openUiDesignEditor],
+ [
+ advanceFocusGeneration,
+ manifest.assets,
+ openUiDesignEditor,
+ resourceCardPreviews.identityByResourceId,
+ resourceCardPreviews.previews,
+ ],
);
/**
@@ -6202,8 +6372,17 @@ export default function ProjectDevelopmentView({
setMode('run');
}
- const showRunUnavailableHint =
- !runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute;
+ const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
+
+ const showResourceCardInfo = useCallback(
+ (resource: ProjectResource) => {
+ handleResourceSelect(resource.id);
+ setResourceInfoResourceId((current) =>
+ current === resource.id ? null : resource.id,
+ );
+ },
+ [handleResourceSelect],
+ );
const renderResourceBookCard = useCallback(
(
@@ -6262,6 +6441,9 @@ export default function ProjectDevelopmentView({
}
activeMediaIdentity={activeCardMediaIdentity}
onSelect={handleResourceSelect}
+ onShowInfo={showResourceCardInfo}
+ onChangeType={setResourceTypeAssetId}
+ infoPressed={resourceInfoResourceId === resource.id}
onPointerDown={(event) =>
handleResourceCardPointerDown(
event,
@@ -6293,6 +6475,8 @@ export default function ProjectDevelopmentView({
handleResourceCardPointerMove,
handleResourceCardPointerUp,
handleResourceSelect,
+ showResourceCardInfo,
+ resourceInfoResourceId,
resourceCardDragPreview,
resourceCardPreviews,
resourceReplacementLineageBadgeMap,
@@ -6366,10 +6550,18 @@ export default function ProjectDevelopmentView({
) {
setResourceDocumentPreviewIdentity(null);
}
+ if (
+ mode !== 'resources' ||
+ uiEditorRoute ||
+ resourceModelPreviewIdentity !== selectedResourcePreviewIdentity
+ ) {
+ setResourceModelPreviewIdentity(null);
+ }
}, [
mode,
uiEditorRoute,
resourceDocumentPreviewIdentity,
+ resourceModelPreviewIdentity,
selectedResourcePreviewIdentity,
]);
@@ -6379,6 +6571,12 @@ export default function ProjectDevelopmentView({
return () => protectResourceCardPreview(null);
}, [protectResourceCardPreview, resourceDocumentPreviewIdentity]);
+ useEffect(() => {
+ if (!resourceModelPreviewIdentity) return;
+ protectResourceCardPreview(resourceModelPreviewIdentity);
+ return () => protectResourceCardPreview(null);
+ }, [protectResourceCardPreview, resourceModelPreviewIdentity]);
+
/**
* 解析一次资源派生的源身份:取项目 revision,必要时把任务产物正规化成正式素材。
*
@@ -7457,9 +7655,16 @@ export default function ProjectDevelopmentView({
: null,
[manifest, selectedResource],
);
+ const selectedResourceJsonPresentation = selectedResource
+ ? projectResourceJsonPresentation(
+ selectedResource,
+ selectedResourceCardPreview,
+ )
+ : null;
const selectedResourceOpensUiEditor =
- selectedResource?.subtype === 'UI' ||
- selectedResource?.subtype === 'ui-prototype';
+ selectedResourceJsonPresentation === 'ui-design' ||
+ (selectedResourceJsonPresentation === null &&
+ selectedResource?.subtype === 'ui-prototype');
const selectedToolbarStyle = selectedResourceLayer
? resolveSelectedToolbarStyle({
@@ -7847,10 +8052,24 @@ export default function ProjectDevelopmentView({
className={`game-resource-manager game-resource-book-manager game-resource-book-manager--${resourceBookState.view} game-resource-book-manager--${resourceBookState.phase}`}
data-resource-book-view={resourceBookState.view}
data-resource-book-transition={resourceBookState.phase}
+ onContextMenu={(event) => {
+ const target = event.target as Element;
+ if (
+ event.button === 2 &&
+ event.currentTarget.contains(target) &&
+ target.closest(
+ '.game-resource-book-scene, .game-resource-canvas, .game-resource-book-main',
+ ) &&
+ isResourceCanvasPanTarget(target)
+ ) {
+ event.preventDefault();
+ }
+ }}
onPointerDownCapture={(event) => {
if (
event.currentTarget.dataset.resourceBookTransition !==
'idle' &&
+ event.button === 0 &&
(event.target as Element).closest('.game-resource-card')
) {
event.preventDefault();
@@ -7898,6 +8117,7 @@ export default function ProjectDevelopmentView({
) ||
selectedResourceOpensUiEditor) ? (
预览
) : null}
- {selectedResourceOpensUiEditor ? (
+ {/*
+ * 引擎模型:进「放大预览」浮层做交互式视角操作。
+ * 判据与卡面同源(`projectResourceCardPreviewKind`),
+ * 因此不会出现「卡片是模型卡、却没有 3D 入口」的分叉。
+ */}
+ {selectedResource &&
+ selectedResourcePreviewIdentity &&
+ projectResourceCardPreviewKind(
+ selectedResource,
+ ) === 'model' ? (
+ }
+ onClick={() => {
+ stopActiveCardMedia();
+ resourceCardPreviews.requestPreview(
+ selectedResource,
+ selectedResourcePreviewIdentity,
+ 'detail',
+ );
+ setResourceModelPreviewIdentity(
+ selectedResourcePreviewIdentity,
+ );
+ }}
+ >
+ 3D 预览
+
+ ) : null}
+ {selectedResource &&
+ selectedResourceOpensUiEditor ? (
引用
) : null}
- {selectedResource ? (
- }
- onClick={() =>
- setResourceInfoPanelOpen((open) => !open)
- }
- >
- 信息
-
- ) : null}
{selectedResource?.manifestAssetId ? (
编辑标签
) : null}
- {selectedResource?.manifestAssetId ? (
- }
- onClick={() =>
- setResourceTypeAssetId(
- selectedResource.manifestAssetId,
- )
- }
- >
- 素材类型
-
- ) : null}
{selectedResource?.manifestAssetId ? (
setResourceInfoPanelOpen(false)}
+ onClose={() => setResourceInfoResourceId(null)}
/>
) : null}
@@ -8505,6 +8727,7 @@ export default function ProjectDevelopmentView({
onPointerMove={handleResourceCanvasPointerMove}
onPointerUp={stopResourceCanvasPan}
onPointerCancel={stopResourceCanvasPan}
+ onLostPointerCapture={stopResourceCanvasPan}
>
{sortMode === 'dependency' &&
dependencyRelationshipDescriptions.length > 0 ? (
@@ -8824,6 +9047,23 @@ export default function ProjectDevelopmentView({
onClose={() => setResourceDocumentPreviewIdentity(null)}
/>
) : null}
+ {mode === 'resources' &&
+ !uiEditorRoute &&
+ selectedResource &&
+ resourceModelPreviewIdentity &&
+ resourceModelPreviewIdentity === selectedResourcePreviewIdentity ? (
+ setResourceModelPreviewIdentity(null)}
+ />
+ ) : null}
{resourcePanelOpen ? (
,
+): boolean {
+ return (
+ projectResourcePathExtension(resource.path) === 'json' ||
+ (projectResourcePathExtension(resource.path) === null &&
+ resource.mediaType.toLowerCase().includes('json'))
+ );
+}
+
+/** 读取结果必须仍属于当前卡片;元数据中的 UI 标签本身不能授予编辑入口。 */
+export function projectResourceJsonPresentation(
+ resource: ProjectResource,
+ preview: ProjectResourceCardPreviewState | null | undefined,
+): 'json' | 'ui-design' | null {
+ if (!isProjectResourceJson(resource)) return null;
+ return resource.manifestAssetId &&
+ preview?.status === 'loaded' &&
+ preview.preview.path === resource.path &&
+ preview.preview.uiDesignAssetId === resource.manifestAssetId
+ ? 'ui-design'
+ : 'json';
+}
+
/** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */
export function projectResourceCodeTypeLabel(path: string): string | null {
const trimmed = path.trim();
@@ -256,13 +321,28 @@ export function projectResourceCardPreviewKind(
return 'document';
}
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
- // 上游把 JSON 规格登记成「文档」,卡面按文档预览;代码文件按扩展名分流。
+ // JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。
if (markdownExtension.test(resource.path)) {
return 'document';
}
if (cardCodeExtension.test(resource.path)) {
return 'code';
}
+ /*
+ * 引擎资源的三个新分支必须排在 mediaType 兜底之前:
+ * `.pcm` 的 mediaType 是 `audio/*`、`.tga` 是 `image/*`、`.glb` 是 `model/*`,
+ * 一旦让通用分支先跑,就会出现「音频卡点播放却解不出来」「模型卡按图片解码失败」
+ * 这类看起来像坏掉的预览。
+ */
+ if (modelExtension.test(resource.path)) {
+ return 'model';
+ }
+ if (structuredExtension.test(resource.path)) {
+ return 'structured';
+ }
+ if (opaqueContainerExtension.test(resource.path)) {
+ return 'binary';
+ }
const kind = projectResourceDisplayKind(resource);
if (kind === 'code') {
return 'code';
@@ -311,6 +391,8 @@ export type ProjectResourceCardPreviewVariant =
| 'markdown'
| 'code'
| 'plain-text'
+ /** 引擎序列化文本资源:卡面显示结构摘要,不显示原始正文。 */
+ | 'structured'
| null;
export function projectResourceCardPreviewVariant(
@@ -325,6 +407,9 @@ export function projectResourceCardPreviewVariant(
if (cardCodeExtension.test(resource.path)) {
return 'code';
}
+ if (projectResourceCardPreviewKind(resource) === 'structured') {
+ return 'structured';
+ }
return projectResourceCardPreviewKind(resource) === 'document'
? 'plain-text'
: null;
@@ -338,7 +423,10 @@ export function projectResourceCardPreviewVariant(
export function projectResourceCardPreviewReadsContent(
resource: ProjectResource,
): boolean {
- return projectResourceCardPreviewVariant(resource) !== 'code';
+ return (
+ projectResourceCardPreviewVariant(resource) !== 'code' &&
+ projectResourceCardPreviewKind(resource) !== 'binary'
+ );
}
/**
@@ -354,7 +442,11 @@ export function projectResourceCardPreviewReadsContent(
export function projectResourceMediaPreviewCategory(
resource: ProjectResource,
): ProjectResourceMediaPreviewCategory {
- return projectResourceCardPreviewKind(resource) === 'audio' ? 'audio' : 'art';
+ const kind = projectResourceCardPreviewKind(resource);
+ if (kind === 'audio') {
+ return 'audio';
+ }
+ return kind === 'model' ? 'model' : 'art';
}
export function projectResourceCardPreviewIdentity(input: {
@@ -434,3 +526,89 @@ export function projectResourceDocumentPreviewText(
}
return lines.join('\n');
}
+
+/**
+ * 引擎(Cocos)序列化资源的**结构摘要**。
+ *
+ * Cocos 把场景、预制体、动画剪辑、材质都序列化成「`{ __type__: 'cc.Xxx', ... }` 对象的
+ * 数组」,直接显示原始 JSON 前几行对用户没有信息量(第一行永远是 `[` 和第一个组件的
+ * 大段属性)。这里只抽取三类稳定事实:根类型、节点数与类型数、以及动画时长/名称。
+ *
+ * 解析失败或不是这种形状时返回 `null`,调用方回退到「显示前几行文本」,不猜内容。
+ */
+export function projectResourceCocosStructureSummary(
+ content: string,
+): string | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(content);
+ } catch {
+ return null;
+ }
+ if (!Array.isArray(parsed)) {
+ return null;
+ }
+ const entries = parsed.filter(
+ (entry): entry is Record =>
+ typeof entry === 'object' && entry !== null && !Array.isArray(entry),
+ );
+ const typeCounts = new Map();
+ for (const entry of entries) {
+ const type = entry['__type__'];
+ if (typeof type === 'string' && type) {
+ typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1);
+ }
+ }
+ const rootType = entries
+ .map((entry) => entry['__type__'])
+ .find(
+ (type): type is string => typeof type === 'string' && type.length > 0,
+ );
+ if (!rootType) {
+ return null;
+ }
+ const parts: string[] = [rootType];
+ const nodeCount = typeCounts.get('cc.Node') ?? 0;
+ if (nodeCount > 0) {
+ parts.push(`${nodeCount} 个节点`);
+ }
+ const duration = entries
+ .map((entry) => entry['_duration'])
+ .find((value): value is number => typeof value === 'number' && value > 0);
+ if (duration !== undefined) {
+ parts.push(`${duration.toFixed(2)} 秒`);
+ }
+ const name = entries
+ .map((entry) => entry['_name'])
+ .find(
+ (value): value is string => typeof value === 'string' && value.length > 0,
+ );
+ if (name) {
+ parts.push(name);
+ }
+ const assetReferences = entries.filter(
+ (entry) => entry['__uuid__'] !== undefined,
+ ).length;
+ if (assetReferences > 0) {
+ parts.push(`${assetReferences} 处资源引用`);
+ }
+ parts.push(`${typeCounts.size} 种类型`);
+ return parts.join(' · ');
+}
+
+/**
+ * 引擎序列化资源的卡面预览文本:优先结构摘要,其次前几行正文。
+ *
+ * `content` 为 `undefined`(原生读取判定的二进制变体)时返回空串,卡面画类型卡。
+ */
+export function projectResourceStructuredPreviewText(
+ content: string | undefined,
+): string {
+ if (content === undefined) {
+ return '';
+ }
+ return (
+ projectResourceCocosStructureSummary(content) ??
+ projectResourceDocumentPreviewText(content, false)
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
index ae4bfe73e..4d739bec6 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
@@ -224,6 +224,12 @@ export function defaultCharacterAnimationResourceName(
return `${resourceBaseName(resource) || '资源'}-角色动画`;
}
+/**
+ * 资源编辑提示词上限:与 Rust `resource_edit_prompt_max_chars`
+ * (`src-tauri/src/project/resource_editor.rs`)逐值同口径,UI、资源编辑提交、
+ * `agc_tools` MCP 工具层与客户端工具桥共用同一组数字。改这里必须同时改那里,
+ * 并按 kind 同步 `direct_tools_mcp.rs` 工具 schema 里的 `prompt.maxLength`。
+ */
export function resourceEditPromptMaxLength(
editKind: LocalProjectResourceEditKind,
) {
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts
new file mode 100644
index 000000000..6d74551b8
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts
@@ -0,0 +1,118 @@
+import type * as ThreeTypes from 'three';
+
+/**
+ * 引擎三维模型的**共用场景能力**:加载、取景、释放。
+ *
+ * 卡片缩略图(`resourceModelThumbnail`)和交互式预览浮层(`ResourceModelViewer`)都走这里,
+ * 避免出现「缩略图能打开、放大后打不开」这种两套加载逻辑的分叉 —— 格式支持面、取景算法和
+ * 释放口径必须逐字一致。
+ */
+export type ResourceModelSource = {
+ /** 预览管线给出的 blob URL(模型整份字节)。 */
+ sourceUrl: string;
+ /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */
+ mediaType: string;
+};
+
+export function isResourceModelFbx(source: ResourceModelSource) {
+ return (
+ source.mediaType === 'application/octet-stream' ||
+ source.sourceUrl.toLowerCase().includes('.fbx')
+ );
+}
+
+/**
+ * 按媒体类型加载模型。
+ *
+ * 只支持**自包含**的模型:`.glb`、单文件 `.gltf`(buffer 内嵌)、`.fbx`。多文件 glTF
+ * (`baseURI` 指向外部 `.bin` / 贴图)在 blob URL 下无法解析相对路径,加载失败由调用方
+ * 降级成类型卡,不做静默半渲染。
+ */
+export async function loadResourceModelObject(
+ source: ResourceModelSource,
+): Promise {
+ if (isResourceModelFbx(source)) {
+ const { FBXLoader } = await import(
+ 'three/examples/jsm/loaders/FBXLoader.js'
+ );
+ return new FBXLoader().loadAsync(source.sourceUrl);
+ }
+ const { GLTFLoader } = await import(
+ 'three/examples/jsm/loaders/GLTFLoader.js'
+ );
+ const gltf = await new GLTFLoader().loadAsync(source.sourceUrl);
+ if (!gltf.scene) {
+ throw new Error('模型内容为空');
+ }
+ return gltf.scene;
+}
+
+/**
+ * 把相机摆到能完整看见整个模型的位置,并返回模型中心与尺寸。
+ *
+ * 取景口径与 3D 软件一致:按包围盒最大边计算距离,留 35% 余量,从右上前方俯视。
+ * 缩略图与交互预览共用同一条公式,尺寸窗口不同也不会出现「一边看得见、一边看不见」。
+ */
+export function frameResourceModelInCamera(
+ THREE: typeof ThreeTypes,
+ object: ThreeTypes.Object3D,
+ camera: ThreeTypes.PerspectiveCamera,
+) {
+ const box = new THREE.Box3().setFromObject(object);
+ const size = box.getSize(new THREE.Vector3());
+ const center = box.getCenter(new THREE.Vector3());
+ const maxDimension = Math.max(size.x, size.y, size.z);
+ if (!Number.isFinite(maxDimension) || maxDimension <= 0) {
+ throw new Error('模型几何尺寸无效');
+ }
+ const distance =
+ (maxDimension / 2 / Math.tan((camera.fov * Math.PI) / 360)) * 1.35;
+ camera.position.set(
+ center.x + distance * 0.55,
+ center.y + distance * 0.42,
+ center.z + distance * 0.75,
+ );
+ camera.near = Math.max(distance / 500, 0.001);
+ camera.far = distance * 20;
+ camera.lookAt(center);
+ camera.updateProjectionMatrix();
+ return { center, size, maxDimension, distance };
+}
+
+/** 环境光照:半球光 + 一盏主光,保证没有材质贴图的模型也有体积感。 */
+export function addResourceModelLights(
+ THREE: typeof ThreeTypes,
+ scene: ThreeTypes.Scene,
+) {
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 2.2));
+ const keyLight = new THREE.DirectionalLight(0xffffff, 2.0);
+ keyLight.position.set(2, 3, 4);
+ scene.add(keyLight);
+}
+
+/** 释放模型对象占用的几何、材质与贴图,避免反复开关预览时显存只涨不降。 */
+export function disposeResourceModelObject(object: ThreeTypes.Object3D) {
+ object.traverse((child) => {
+ const mesh = child as ThreeTypes.Mesh;
+ mesh.geometry?.dispose?.();
+ const material = mesh.material;
+ const materials = Array.isArray(material)
+ ? material
+ : material
+ ? [material]
+ : [];
+ for (const entry of materials) {
+ for (const value of Object.values(
+ entry as unknown as Record,
+ )) {
+ const texture = value as
+ | { isTexture?: boolean; dispose?: () => void }
+ | undefined;
+ if (texture?.isTexture && typeof texture.dispose === 'function') {
+ texture.dispose();
+ }
+ }
+ (entry as { dispose?: () => void }).dispose?.();
+ }
+ });
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts
new file mode 100644
index 000000000..4ab1e0ecd
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts
@@ -0,0 +1,164 @@
+import type * as ThreeTypes from 'three';
+
+import {
+ addResourceModelLights,
+ disposeResourceModelObject,
+ frameResourceModelInCamera,
+ loadResourceModelObject,
+} from './resourceModelScene';
+
+/**
+ * 引擎三维模型缩略图渲染器(模块级单例)。
+ *
+ * 为什么是单例:一张资源画布上可能同时停着十几张模型卡,而浏览器能给一个页面的
+ * WebGL 上下文是**有上限**的(超出后最早的上下文会被丢弃,表现为"有些卡片莫名其妙变白")。
+ * 这里只保留**一个**离屏渲染器 + 一个串行队列:谁需要缩略图谁排队,渲染完把像素画进
+ * 卡片自己的 2D canvas。因此无论画布上有多少张模型卡,WebGL 上下文始终只有 1 个。
+ *
+ * 渲染是**一次性**的静态帧(不跑动画循环):卡片只是缩略图,让十几张卡各自跑一个
+ * requestAnimationFrame 循环会白烧 GPU 和电量。
+ */
+export type ResourceModelThumbnailRequest = {
+ /** 预览管线给出的 blob URL(模型整份字节)。 */
+ sourceUrl: string;
+ /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */
+ mediaType: string;
+ width: number;
+ height: number;
+ /**
+ * 缩略图的**稳定身份**(资源身份 + 路径 + 字节数)。
+ *
+ * blob URL 每次读取都会变,拿它当缓存键会让「预览缓存淘汰后重读同一个模型」变成
+ * 一次重新解析 + 重新渲染;用稳定身份作键,重读只会重新取字节,缩略图直接命中缓存。
+ */
+ identity: string;
+};
+
+const THUMBNAIL_CACHE_LIMIT = 24;
+const thumbnailCache = new Map();
+
+type RendererBundle = {
+ renderer: ThreeTypes.WebGLRenderer;
+ scene: ThreeTypes.Scene;
+ camera: ThreeTypes.PerspectiveCamera;
+};
+
+let rendererPromise: Promise | null = null;
+let renderQueue: Promise = Promise.resolve();
+
+export function resourceModelThumbnailCacheSize() {
+ return thumbnailCache.size;
+}
+
+/**
+ * 缩略图缓存键:**不含 blob URL**。
+ *
+ * 预览缓存淘汰后同一份模型会被重新读取、拿到新的 blob URL;若把 URL 放进键里,
+ * 每一次重读都会变成一次重新解析 + 重新渲染。用稳定身份作键,重读只会重新取字节。
+ */
+export function resourceModelThumbnailCacheKey(
+ request: Pick<
+ ResourceModelThumbnailRequest,
+ 'mediaType' | 'identity' | 'width' | 'height'
+ >,
+) {
+ return `${request.mediaType}|${request.identity}|${request.width}x${request.height}`;
+}
+
+export function resourceModelThumbnailIdentity(input: {
+ resourceKey: string;
+ path: string;
+ byteLen?: number;
+}) {
+ return `${input.resourceKey}|${input.path}|${input.byteLen ?? 0}`;
+}
+
+function rememberThumbnail(cacheKey: string, dataUrl: string) {
+ thumbnailCache.delete(cacheKey);
+ thumbnailCache.set(cacheKey, dataUrl);
+ while (thumbnailCache.size > THUMBNAIL_CACHE_LIMIT) {
+ const oldest = thumbnailCache.keys().next().value;
+ if (oldest === undefined) {
+ break;
+ }
+ thumbnailCache.delete(oldest);
+ }
+}
+
+async function createRendererBundle(): Promise {
+ // 动态导入:三维渲染器只在真的出现模型卡时才加载,不进主包、不影响其它卡片的启动成本。
+ const THREE = await import('three');
+ const canvas = document.createElement('canvas');
+ const renderer = new THREE.WebGLRenderer({
+ canvas,
+ alpha: true,
+ antialias: true,
+ // 没有它,`toDataURL` 拿到的可能是被清空的缓冲(否则渲染后立刻被交换掉)。
+ preserveDrawingBuffer: true,
+ });
+ renderer.setClearColor(0x000000, 0);
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 10_000);
+ addResourceModelLights(THREE, scene);
+ return { renderer, scene, camera };
+}
+
+function rendererBundle() {
+ rendererPromise ??= createRendererBundle().catch((error: unknown) => {
+ // 失败不缓存:下一次卡片进入视口时还有机会(例如 WebGL 上下文被临时耗尽)。
+ rendererPromise = null;
+ throw error;
+ });
+ return rendererPromise;
+}
+
+async function renderOnce(
+ request: ResourceModelThumbnailRequest,
+): Promise {
+ const THREE = await import('three');
+ const { renderer, scene, camera } = await rendererBundle();
+ const width = Math.max(64, Math.round(request.width));
+ const height = Math.max(64, Math.round(request.height));
+ const object = await loadResourceModelObject(request);
+ try {
+ scene.add(object);
+ /**
+ * 相机宽高比必须跟着这次渲染的像素尺寸走。
+ *
+ * 这个相机是单例复用件(默认 `aspect = 1`),而卡片是宽扁的:不更新宽高比时,
+ * 方形投影会被塞进非方形的绘制缓冲,模型在卡面上就是**被拉伸**的 —— 与 3D 软件里
+ * 同一个模型的比例对不上。浮层里的交互相机已经在创建 / 改尺寸时设过,这里补齐缩略图。
+ */
+ camera.aspect = width / height;
+ frameResourceModelInCamera(THREE, object, camera);
+ renderer.setPixelRatio(1);
+ renderer.setSize(width, height, false);
+ renderer.render(scene, camera);
+ return renderer.domElement.toDataURL('image/png');
+ } finally {
+ scene.remove(object);
+ disposeResourceModelObject(object);
+ }
+}
+
+/**
+ * 取一张模型缩略图(同一份字节只渲染一次)。
+ *
+ * 队列是串行的:渲染器只有一个,两个模型同时渲染会互相覆盖同一个 canvas。
+ */
+export async function renderResourceModelThumbnail(
+ request: ResourceModelThumbnailRequest,
+): Promise {
+ const cacheKey = resourceModelThumbnailCacheKey(request);
+ const cached = thumbnailCache.get(cacheKey);
+ if (cached) {
+ rememberThumbnail(cacheKey, cached);
+ return cached;
+ }
+ const task = renderQueue.then(() => renderOnce(request));
+ // 队列本身不能因为某一张失败就断掉:失败向调用方抛,队列继续排下一个。
+ renderQueue = task.catch(() => undefined);
+ const dataUrl = await task;
+ rememberThumbnail(cacheKey, dataUrl);
+ return dataUrl;
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
index 998c5b9f5..4e305c3b0 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
@@ -74,6 +74,20 @@ export type ProjectResourceTypeLabel =
| 'Agent 回执'
| '项目版本'
| '游戏代码'
+ | '模型'
+ | '动画'
+ | '骨骼动画'
+ | '场景'
+ | '预制体'
+ | '瓦片地图'
+ | '材质'
+ | '特效'
+ | '图集'
+ | '位图字体'
+ | '纹理'
+ | '自动图集'
+ | '音频片段'
+ | '二进制'
| '未知';
const documentExtension =
@@ -82,6 +96,20 @@ const gameCodeExtension =
/\.(html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|vue|svelte)$/iu;
const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu;
const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu;
+/*
+ * 引擎(Cocos 3.8.8)资源扩展名。四组扩展名与原生侧的三份表
+ * (`bridge_project_file_class` / `agent_local_project_file_type` / `prompt_context_media_type`)
+ * 必须同步:少一个扩展名在这里,资源就会「登记得了、画布不显示」,而且没有任何报错。
+ */
+/** 三维模型与网格数据。 */
+const engineModelExtension = /\.(glb|gltf|fbx|mesh|skeleton)$/iu;
+/** 图像容器(浏览器解不了,原生侧转码成 PNG 后按图片显示)。 */
+const engineImageContainerExtension = /\.(tga|tif|tiff|hdr|exr|psd|znt)$/iu;
+/** 材质与特效:登记为 `code`,卡面另行按结构预览。 */
+const engineMaterialExtension = /\.(mtl|material|pmtl|effect|chunk)$/iu;
+/** 场景、动画、图集与容器:登记为 `document`,卡面按结构预览或类型卡。 */
+const engineStructuredExtension =
+ /\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|tmx|terrain|plist|labelatlas|atlas|fnt|pac|dbbin|bin|skel|texture|cubemap|rt)$/iu;
const gameCodeKind =
/(?:^|[-_])(game-(?:entry|style|script)|code|source)(?:$|[-_])/iu;
const artKind =
@@ -132,10 +160,17 @@ export function projectedResourceKind(input: {
if (
normalizedMediaType.startsWith('image/') ||
normalizedMediaType.startsWith('video/') ||
- artExtension.test(normalizedPath)
+ artExtension.test(normalizedPath) ||
+ engineModelExtension.test(normalizedPath) ||
+ engineImageContainerExtension.test(normalizedPath)
) {
return 'art';
}
+ // 引擎材质 / 特效排在文档之前:它们的 mediaType 多为 `application/json`,
+ // 一旦先落到文档分支,画布分类就和登记时的 kind 对不上。
+ if (engineMaterialExtension.test(normalizedPath)) {
+ return 'code';
+ }
if (
normalizedMediaType === 'text/html' ||
normalizedMediaType === 'text/css' ||
@@ -149,7 +184,8 @@ export function projectedResourceKind(input: {
normalizedMediaType.includes('json') ||
normalizedMediaType.includes('yaml') ||
normalizedMediaType.startsWith('text/') ||
- documentExtension.test(normalizedPath)
+ documentExtension.test(normalizedPath) ||
+ engineStructuredExtension.test(normalizedPath)
) {
return 'document';
}
@@ -203,6 +239,55 @@ export function projectResourceTypeLabel(
if (subtype === 'agent-result') {
return 'Agent 回执';
}
+ /*
+ * 引擎资源的类型角标先按扩展名判定。
+ *
+ * 它们在 manifest 里复用的是既有 canonical kind(模型/场景 → `scene`,动画 →
+ * `character-animation`,材质/特效 → `code`,容器 → `document`),只用 kind 反推
+ * 会把这些资源一律说成「图片」「文档」或「游戏代码」,用户看不出这是什么。
+ */
+ if (/\.(glb|gltf|fbx|mesh)$/iu.test(path)) {
+ return '模型';
+ }
+ if (/\.(anim|animation|animgraph|animgraphvari|animask)$/iu.test(path)) {
+ return '动画';
+ }
+ if (/\.(skeleton|skel|dbbin)$/iu.test(path)) {
+ return '骨骼动画';
+ }
+ if (/\.(scene|fire|terrain)$/iu.test(path)) {
+ return '场景';
+ }
+ if (/\.prefab$/iu.test(path)) {
+ return '预制体';
+ }
+ if (/\.tmx$/iu.test(path)) {
+ return '瓦片地图';
+ }
+ if (/\.(mtl|material|pmtl)$/iu.test(path)) {
+ return '材质';
+ }
+ if (/\.(effect|chunk)$/iu.test(path)) {
+ return '特效';
+ }
+ if (/\.(plist|atlas|labelatlas)$/iu.test(path)) {
+ return '图集';
+ }
+ if (/\.fnt$/iu.test(path)) {
+ return '位图字体';
+ }
+ if (/\.(texture|cubemap|rt)$/iu.test(path)) {
+ return '纹理';
+ }
+ if (/\.pac$/iu.test(path)) {
+ return '自动图集';
+ }
+ if (/\.pcm$/iu.test(path)) {
+ return '音频片段';
+ }
+ if (/\.bin$/iu.test(path)) {
+ return '二进制';
+ }
if (kind === 'code') {
return '游戏代码';
}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
index f175264ba..508a16e11 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
@@ -95,6 +95,12 @@ function resourceReadKindLabel(kind: ProjectResourceCardPreviewKind) {
if (kind === 'code') {
return '游戏代码';
}
+ if (kind === 'structured') {
+ return '引擎资源';
+ }
+ if (kind === 'model') {
+ return '模型';
+ }
return '资源';
}
@@ -167,6 +173,7 @@ function materializeProjectResourceCardPreview(
pixelHeight: transport.pixelHeight,
hasAlpha: transport.hasAlpha,
content: transport.content,
+ uiDesignAssetId: transport.uiDesignAssetId,
},
retainedBytes:
transport.content === undefined
@@ -531,6 +538,22 @@ export function useProjectResourceCardPreviews(input: {
},
);
}
+ /**
+ * 引擎序列化资源走独立读取:准入白名单与服务 UI 编辑器的那份**分开**,
+ * 且允许「不是 UTF-8」的文件正常返回 `content: null`(卡面降级成类型卡),
+ * 而不是把它报成一次预览失败。
+ */
+ if (kind === 'structured') {
+ return invoke(
+ 'read_local_project_structured_preview',
+ {
+ projectPath: input.projectPath,
+ relativePath: job.resource.path,
+ scopeId: job.scopeId,
+ requestId: createProjectResourcePreviewRequestId(),
+ },
+ );
+ }
return invoke(
'read_local_project_media_preview',
{
@@ -642,6 +665,9 @@ export function useProjectResourceCardPreviews(input: {
if (
kind === 'version' ||
kind === 'placeholder' ||
+ // 类型卡不读字节:客户端解不了的容器(压缩纹理、Spine 二进制、PSD/EXR、裸 PCM)
+ // 读进来也画不出东西,占读取槽只会挤掉真正能出图的卡片。
+ kind === 'binary' ||
(kind === 'audio' && reason !== 'play')
) {
return;
@@ -1089,7 +1115,12 @@ export function useProjectResourceCardPreviews(input: {
continue;
}
const kind = projectResourceCardPreviewKind(resource);
- if (kind === 'version' || kind === 'placeholder' || kind === 'audio') {
+ if (
+ kind === 'version' ||
+ kind === 'placeholder' ||
+ kind === 'audio' ||
+ kind === 'binary'
+ ) {
continue;
}
const element = elementsByIdentity.get(identity);
@@ -1121,7 +1152,8 @@ export function useProjectResourceCardPreviews(input: {
identity &&
kind !== 'version' &&
kind !== 'placeholder' &&
- kind !== 'audio'
+ kind !== 'audio' &&
+ kind !== 'binary'
) {
requestPreview(resource, identity, 'visible');
fallbackRequested += 1;
diff --git a/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx b/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx
new file mode 100644
index 000000000..c484074ba
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx
@@ -0,0 +1,139 @@
+import { BadgeCheck, Download, Loader2, Play } from 'lucide-react';
+import { memo } from 'react';
+
+import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
+import {
+ formatGameTemplateSize,
+ needsTemplateDownload,
+ templateRuntimeLabel,
+} from '../../features/template-library/templateLibraryModel';
+
+export type TemplateCardActions = {
+ busyKind: 'download' | 'create' | null;
+ busyTemplateId: string | null;
+ onDownload: (template: GameTemplateEntry) => void;
+ onUse: (template: GameTemplateEntry) => void;
+};
+
+/**
+ * 虚拟列表里的单个模板卡片:高度由行高契约固定(封面 16:9 + 固定文字区),
+ * 用 memo 包住,滚动时只重渲染可视区域内的少量卡片。
+ */
+function TemplateCardView({
+ template,
+ busyKind,
+ busyTemplateId,
+ onDownload,
+ onUse,
+}: { template: GameTemplateEntry } & TemplateCardActions) {
+ const busy = busyTemplateId === template.id;
+ const needsDownload = needsTemplateDownload(template);
+ const busyLabel =
+ busy && busyKind === 'download'
+ ? '正在下载模板'
+ : busy && busyKind === 'create'
+ ? '正在创建项目'
+ : '';
+ const meta = [
+ templateRuntimeLabel(template.runtime),
+ template.engine,
+ `v${template.templateVersion}`,
+ formatGameTemplateSize(template.zipSizeBytes),
+ ]
+ .filter((value) => value && value.trim())
+ .join(' · ');
+
+ return (
+
+
+

+ {template.installed ? (
+
+
+ 已下载
+
+ ) : null}
+
+
+
+ {template.title}
+
+
+ {meta}
+
+ {template.summary ? (
+
+ {template.summary}
+
+ ) : null}
+ {template.tags.length > 0 ? (
+
+ {template.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : null}
+
+
+ {/* 已下载且版本一致时不再提供下载入口;只有缺包或版本落后才显示(落后时按「更新」)。 */}
+ {needsDownload ? (
+
+ ) : null}
+ {busyLabel ? (
+
+ {busyLabel}
+
+ ) : null}
+
+
+
+ );
+}
+
+export const TemplateCard = memo(TemplateCardView);
diff --git a/apps/ai-game-creator-shell/src/view/template-library/index.tsx b/apps/ai-game-creator-shell/src/view/template-library/index.tsx
new file mode 100644
index 000000000..37616da12
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/template-library/index.tsx
@@ -0,0 +1,430 @@
+import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
+import {
+ ArrowLeft,
+ Loader2,
+ RefreshCw,
+ Search,
+ SearchX,
+ SlidersHorizontal,
+} from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { FixedSizeGrid, type GridChildComponentProps } from 'react-window';
+
+import {
+ buildTemplateRows,
+ computeTemplateGridLayout,
+ TEMPLATE_CARD_GAP,
+ TEMPLATE_GRID_OVERSCAN_ROWS,
+ templateGridItemKey,
+} from '../../features/template-library/templateLibraryGrid';
+import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
+import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel';
+import type { TemplateLibraryController } from '../../features/template-library/useTemplateLibrary';
+import { TemplateCard, type TemplateCardActions } from './TemplateCard';
+
+type TemplateLibraryViewProps = {
+ controller: TemplateLibraryController;
+ onBack: () => void;
+};
+
+const TEMPLATE_LIBRARY_TOAST_MILLIS = 2600;
+
+/**
+ * 模板库提示统一走浮层 toast:下载完成、开始建项目这类过程提示不再占用页面内位置,
+ * 页面里只保留可操作的错误与空态。
+ */
+function TemplateLibraryToast({
+ message,
+ onDismiss,
+}: {
+ message: string;
+ onDismiss: () => void;
+}) {
+ useEffect(() => {
+ if (!message) {
+ return;
+ }
+ const timer = window.setTimeout(onDismiss, TEMPLATE_LIBRARY_TOAST_MILLIS);
+ return () => {
+ window.clearTimeout(timer);
+ };
+ }, [message, onDismiss]);
+
+ if (!message) {
+ return null;
+ }
+ return createPortal(
+ ,
+ document.body,
+ );
+}
+
+const chipClass =
+ 'cursor-pointer rounded-full border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-text-soft) transition hover:border-(--platform-warm-text) hover:text-(--platform-warm-text)';
+const activeChipClass =
+ 'cursor-pointer rounded-full border border-(--platform-warm-text) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-warm-text)';
+
+type TemplateGridCellData = TemplateCardActions & {
+ rows: Array>;
+};
+
+function TemplateGridCell({
+ columnIndex,
+ rowIndex,
+ style,
+ data,
+}: GridChildComponentProps) {
+ const template = data.rows[rowIndex]?.[columnIndex] ?? null;
+ if (!template) {
+ return ;
+ }
+ return (
+
+
+
+ );
+}
+
+export default function TemplateLibraryView({
+ controller,
+ onBack,
+}: TemplateLibraryViewProps) {
+ const {
+ status,
+ error,
+ notice,
+ templates,
+ visibleTemplates,
+ tagOptions,
+ runtimeOptions,
+ installedCount,
+ filters,
+ filtersActive,
+ setQuery,
+ selectRuntime,
+ toggleTag,
+ setInstalledOnly,
+ clearFilters,
+ busyTemplateId,
+ busyKind,
+ refresh,
+ downloadTemplate,
+ createProjectFromTemplate,
+ clearNotice,
+ } = controller;
+
+ const gridRef = useRef(null);
+ const viewportRef = useRef(null);
+ const sectionRef = useRef(null);
+ const [sectionHeight, setSectionHeight] = useState(null);
+ const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
+
+ const showEmptyLibrary = status === 'ready' && templates.length === 0;
+ const showNoMatch =
+ status === 'ready' && templates.length > 0 && visibleTemplates.length === 0;
+ const showGrid = status === 'ready' && visibleTemplates.length > 0;
+
+ /**
+ * 页面高度按**父级实测高度**定,不用百分比也不用 100vh。
+ *
+ * 外壳样式 `.launcher-main > .platform-theme { height: 100% }` 特异性高于 Tailwind
+ * 工具类,而它的百分比在 `.launcher-shell { min-height: 100vh }` 这条链上是不定高,
+ * 页面会退化成内容高度(虚拟网格视口高度 0、卡片区空白);`100vh` 又比真实舞台高
+ * 一个标题栏(窗口 100vh=800、舞台 750),底部会被裁掉。这里直接量父级。
+ */
+ useEffect(() => {
+ const section = sectionRef.current;
+ const parent = section?.parentElement;
+ if (!section || !parent) {
+ return;
+ }
+ const apply = () => {
+ const height = Math.round(parent.getBoundingClientRect().height);
+ if (height > 0) {
+ setSectionHeight((current) => (current === height ? current : height));
+ }
+ };
+ apply();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', apply);
+ return () => window.removeEventListener('resize', apply);
+ }
+ const observer = new ResizeObserver(apply);
+ observer.observe(parent);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const element = viewportRef.current;
+ if (!element) {
+ return;
+ }
+ const measure = () => {
+ const rect = element.getBoundingClientRect();
+ const width = Math.round(rect.width);
+ const height = Math.round(rect.height);
+ setViewportSize((current) =>
+ current.width === width && current.height === height
+ ? current
+ : { width, height },
+ );
+ };
+ measure();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', measure);
+ return () => window.removeEventListener('resize', measure);
+ }
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [showGrid]);
+
+ const layout = useMemo(
+ () =>
+ computeTemplateGridLayout({
+ containerWidth: viewportSize.width,
+ itemCount: visibleTemplates.length,
+ }),
+ [viewportSize.width, visibleTemplates.length],
+ );
+ const rows = useMemo(
+ () => buildTemplateRows(visibleTemplates, layout.columnCount),
+ [visibleTemplates, layout.columnCount],
+ );
+
+ // 换筛选条件回到列表顶部:否则筛选后条目变少会把视口留在空白处,看起来像“卡住”。
+ useEffect(() => {
+ gridRef.current?.scrollTo({ scrollTop: 0 });
+ }, [
+ filters.query,
+ filters.tags,
+ filters.runtime,
+ filters.installedOnly,
+ layout.columnCount,
+ ]);
+
+ const handleDownload = useMemo(
+ () => (template: GameTemplateEntry) => {
+ void downloadTemplate(template).catch(() => undefined);
+ },
+ [downloadTemplate],
+ );
+ const handleUse = useMemo(
+ () => (template: GameTemplateEntry) => {
+ void createProjectFromTemplate(template).catch(() => undefined);
+ },
+ [createProjectFromTemplate],
+ );
+ const cellData = useMemo(
+ () => ({
+ rows,
+ busyKind,
+ busyTemplateId,
+ onDownload: handleDownload,
+ onUse: handleUse,
+ }),
+ [rows, busyKind, busyTemplateId, handleDownload, handleUse],
+ );
+
+ return (
+
+
+
+
+
+ 模板库
+
+
+ 共 {templates.length} 个模板 · 已下载 {installedCount} 个
+
+
+
+
+
+ {/* 标签/运行时筛选区可独立滚动:标签数量随库量增长时不会把卡片区挤出窗口。 */}
+
+
+
+
+ {filtersActive ? (
+
+ ) : null}
+
+ {runtimeOptions.length > 0 ? (
+
+
+
+ 运行时
+
+ {runtimeOptions.map((runtime) => (
+
+ ))}
+
+ ) : null}
+ {tagOptions.length > 0 ? (
+
+
+ 标签
+
+ {tagOptions.map((tag) => (
+
+ ))}
+
+ ) : null}
+
+
+
+ {error ? (
+
+ {error}
+
+
+ ) : null}
+
+ {status === 'loading' && templates.length === 0 ? (
+
+ 正在读取模板库…
+
+ ) : null}
+ {showEmptyLibrary ? (
+
+ 模板库暂时还没有可用的模板。
+
+ ) : null}
+ {showNoMatch ? (
+
+
+ 没有符合当前筛选的模板
+
+
+ ) : null}
+
+ {showGrid ? (
+
+ {viewportSize.width > 0 && viewportSize.height > 0 ? (
+
+ templateGridItemKey(rowIndex, columnIndex)
+ }
+ >
+ {TemplateGridCell}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/cover.svg b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/cover.svg
new file mode 100644
index 000000000..48439503b
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/meta.json b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/meta.json
new file mode 100644
index 000000000..5c3bdbb8e
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "blank-2d-canvas",
+ "title": "空白二维画布工程",
+ "summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
+ "tags": [
+ "空白",
+ "起步工程",
+ "2d",
+ "canvas"
+ ],
+ "runtime": "html",
+ "engine": "canvas",
+ "engineVersion": "",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/index.html
new file mode 100644
index 000000000..0aa1ef189
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/main.js
new file mode 100644
index 000000000..bfdce3f80
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/main.js
@@ -0,0 +1,30 @@
+const canvas = document.querySelector('#stage');
+const context = canvas.getContext('2d');
+let viewport = { width: 0, height: 0 };
+
+function resize() {
+ const ratio = window.devicePixelRatio || 1;
+ viewport = { width: window.innerWidth, height: window.innerHeight };
+ canvas.width = Math.floor(viewport.width * ratio);
+ canvas.height = Math.floor(viewport.height * ratio);
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+}
+
+function update(_deltaSeconds) {}
+
+function render() {
+ context.clearRect(0, 0, viewport.width, viewport.height);
+}
+
+let previous = performance.now();
+function frame(now) {
+ const deltaSeconds = Math.min((now - previous) / 1000, 0.1);
+ previous = now;
+ update(deltaSeconds);
+ render();
+ requestAnimationFrame(frame);
+}
+
+window.addEventListener('resize', resize);
+resize();
+requestAnimationFrame(frame);
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/package.json
new file mode 100644
index 000000000..6352b75bc
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/style.css
new file mode 100644
index 000000000..f1256b925
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/style.css
@@ -0,0 +1,3 @@
+body { margin: 0; overflow: hidden; background: #0b1512; }
+main { display: block; width: 100vw; height: 100vh; }
+canvas { display: block; width: 100%; height: 100%; }
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/cover.svg b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/cover.svg
new file mode 100644
index 000000000..8da38262d
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json
new file mode 100644
index 000000000..f099612e4
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "blank-3d-scene",
+ "title": "空白三维场景工程",
+ "summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
+ "tags": [
+ "空白",
+ "起步工程",
+ "3d",
+ "three.js"
+ ],
+ "runtime": "html",
+ "engine": "three.js",
+ "engineVersion": "0.180.0",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/index.html
new file mode 100644
index 000000000..d01aa860a
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js
new file mode 100644
index 000000000..73c37324b
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js
@@ -0,0 +1,38 @@
+import * as THREE from 'three';
+
+const container = document.querySelector('#game');
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x070b14);
+
+const camera = new THREE.PerspectiveCamera(
+ 60,
+ window.innerWidth / window.innerHeight,
+ 0.1,
+ 200,
+);
+camera.position.set(0, 3, 6);
+camera.lookAt(0, 0, 0);
+
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setPixelRatio(window.devicePixelRatio);
+renderer.setSize(window.innerWidth, window.innerHeight);
+container.append(renderer.domElement);
+
+const light = new THREE.DirectionalLight(0xffffff, 1.2);
+light.position.set(4, 8, 6);
+scene.add(light, new THREE.AmbientLight(0x8899ff, 0.5));
+
+const grid = new THREE.GridHelper(20, 20, 0x3b4a6b, 0x1d2739);
+scene.add(grid);
+
+window.addEventListener('resize', () => {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+});
+
+function render() {
+ renderer.render(scene, camera);
+ requestAnimationFrame(render);
+}
+render();
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/package.json
new file mode 100644
index 000000000..496deca74
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "dependencies": {
+ "three": "^0.180.0"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/style.css
new file mode 100644
index 000000000..a3c5a9c8c
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/style.css
@@ -0,0 +1,3 @@
+body { margin: 0; overflow: hidden; background: #05070d; }
+main { display: block; width: 100vw; height: 100vh; }
+canvas { display: block; }
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/cover.svg b/apps/ai-game-creator-shell/template-library/v1/blank-web/cover.svg
new file mode 100644
index 000000000..ac7c02d1b
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/meta.json b/apps/ai-game-creator-shell/template-library/v1/blank-web/meta.json
new file mode 100644
index 000000000..a92652137
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "blank-web",
+ "title": "空白网页工程",
+ "summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
+ "tags": [
+ "空白",
+ "起步工程",
+ "网页",
+ "原生"
+ ],
+ "runtime": "html",
+ "engine": "none",
+ "engineVersion": "",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/index.html
new file mode 100644
index 000000000..d01aa860a
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/main.js
new file mode 100644
index 000000000..f2307680d
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/main.js
@@ -0,0 +1,2 @@
+const root = document.querySelector('#game');
+root.textContent = '';
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/package.json
new file mode 100644
index 000000000..6352b75bc
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/style.css
new file mode 100644
index 000000000..e40ed7036
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/style.css
@@ -0,0 +1,2 @@
+body { margin: 0; background: #0f1218; color: #e8eefc; font: 16px system-ui, sans-serif; }
+main { display: grid; min-height: 100vh; place-items: center; }
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/cover.svg b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/cover.svg
new file mode 100644
index 000000000..0d00d4b79
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json
new file mode 100644
index 000000000..71f52ba90
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "phaser-2d-starter",
+ "title": "Phaser 2D 起步工程",
+ "summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
+ "tags": [
+ "起步工程",
+ "2d",
+ "phaser",
+ "像素"
+ ],
+ "runtime": "html",
+ "engine": "phaser",
+ "engineVersion": "4.2.1",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js
new file mode 100644
index 000000000..a77ba6be9
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js
@@ -0,0 +1,21 @@
+import './style.css';
+
+import Phaser from 'phaser';
+
+class PlaceholderScene extends Phaser.Scene {
+ create() {
+ this.add.text(
+ 24,
+ 24,
+ '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。',
+ );
+ }
+}
+
+new Phaser.Game({
+ type: Phaser.AUTO,
+ width: 720,
+ height: 420,
+ parent: 'game',
+ scene: PlaceholderScene,
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/index.html
new file mode 100644
index 000000000..3447d5909
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/index.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package.json
new file mode 100644
index 000000000..6504e9aee
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "dependencies": {
+ "phaser": "4.2.1"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/style.css
new file mode 100644
index 000000000..8b3c907f4
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/style.css
@@ -0,0 +1,2 @@
+body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }
+main { width: min(720px, calc(100vw - 32px)); }
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/cover.svg b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/cover.svg
new file mode 100644
index 000000000..f08503a3f
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json
new file mode 100644
index 000000000..2b492a71d
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "threejs-3d-starter",
+ "title": "Three.js 3D 起步工程",
+ "summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
+ "tags": [
+ "起步工程",
+ "3d",
+ "three.js",
+ "网页"
+ ],
+ "runtime": "html",
+ "engine": "three.js",
+ "engineVersion": "0.180.0",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/index.html
new file mode 100644
index 000000000..d01aa860a
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js
new file mode 100644
index 000000000..211f285ef
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js
@@ -0,0 +1,43 @@
+import * as THREE from 'three';
+
+const container = document.querySelector('#game');
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x0b1120);
+
+const camera = new THREE.PerspectiveCamera(
+ 60,
+ window.innerWidth / window.innerHeight,
+ 0.1,
+ 100,
+);
+camera.position.set(2.4, 1.8, 3.2);
+camera.lookAt(0, 0, 0);
+
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setPixelRatio(window.devicePixelRatio);
+renderer.setSize(window.innerWidth, window.innerHeight);
+container.append(renderer.domElement);
+
+const light = new THREE.DirectionalLight(0xffffff, 1.4);
+light.position.set(3, 5, 4);
+scene.add(light, new THREE.AmbientLight(0x8899ff, 0.6));
+
+const cube = new THREE.Mesh(
+ new THREE.BoxGeometry(1, 1, 1),
+ new THREE.MeshStandardMaterial({ color: 0xe0a060 }),
+);
+scene.add(cube);
+
+window.addEventListener('resize', () => {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+});
+
+function tick() {
+ cube.rotation.y += 0.01;
+ cube.rotation.x += 0.004;
+ renderer.render(scene, camera);
+ requestAnimationFrame(tick);
+}
+tick();
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/package.json
new file mode 100644
index 000000000..496deca74
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "dependencies": {
+ "three": "^0.180.0"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/style.css
new file mode 100644
index 000000000..b25e3e5ae
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/style.css
@@ -0,0 +1,3 @@
+body { margin: 0; overflow: hidden; background: #06080f; }
+main { display: block; width: 100vw; height: 100vh; }
+canvas { display: block; }
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
index 01e5f217d..475242de0 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -182,24 +182,98 @@ export function registerClientHomeTests() {
);
});
- it('shows the built-in inspiration masonry gallery and opens a dismissible preview without requesting the retired feed', async () => {
+ it('shows the home template recommendations and opens the library without creating a project', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ const templateLibrarySnapshot = {
+ schemaVersion: 'game-template-library.v1',
+ library: 'genarrative-official',
+ libraryVersion: 3,
+ updatedAt: '2026-09-17T00:00:00Z',
+ fetchedAtMillis: 1,
+ source: 'remote',
+ templates: [
+ {
+ id: 'lane-defense',
+ title: '星际防线',
+ summary: '塔防原型',
+ tags: ['塔防'],
+ runtime: 'phaser',
+ engine: 'Phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '1.0.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'index.html',
+ zipUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.zip',
+ zipSizeBytes: 2048,
+ zipSha256: 'a'.repeat(64),
+ coverUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.png',
+ coverWidth: 320,
+ coverHeight: 180,
+ installed: true,
+ installedVersion: '1.0.0',
+ installedAtMillis: 2,
+ },
+ {
+ id: 'cozy-farm',
+ title: '悠然农场',
+ summary: '经营原型',
+ tags: ['经营'],
+ runtime: 'phaser',
+ engine: 'Phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '1.0.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'index.html',
+ zipUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.zip',
+ zipSizeBytes: 4096,
+ zipSha256: 'b'.repeat(64),
+ coverUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.png',
+ coverWidth: 320,
+ coverHeight: 180,
+ installed: false,
+ installedVersion: null,
+ installedAtMillis: null,
+ },
+ ],
+ };
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'read_game_creator_app_config') {
+ return { config: { selectedModelId: 'quality' } };
+ }
+ if (command === 'fetch_game_template_library') {
+ return templateLibrarySnapshot;
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
- const inspiration = screen.getByLabelText('灵感推荐');
- const inspirationImages = within(inspiration).getAllByRole('button', {
- name: /查看灵感图片/,
+ // 首页推荐位只展示封面、标题、运行时与已下载徽标,本机灵感图库已随模板库上线删除。
+ const recommendations = await screen.findByLabelText('模板库推荐');
+ const recommendationCards = within(recommendations).getAllByRole('button', {
+ name: /^查看模板 /u,
});
- expect(inspirationImages.length).toBeGreaterThan(0);
- expect(inspiration.closest('.overflow-y-auto')).not.toBeNull();
- expect(screen.queryByText('暂无灵感')).toBeNull();
+ expect(recommendationCards.length).toBe(2);
+ expect(within(recommendationCards[0]!).getByText('已下载')).not.toBeNull();
- fireEvent.click(inspirationImages[0]!);
- const preview = screen.getByRole('dialog', { name: '查看灵感图片' });
- fireEvent.click(screen.getByAltText('放大的灵感图片'));
- expect(screen.getByRole('dialog', { name: '查看灵感图片' })).not.toBeNull();
- fireEvent.click(preview);
- expect(screen.queryByRole('dialog', { name: '查看灵感图片' })).toBeNull();
+ fireEvent.click(recommendationCards[0]!);
+
+ // 点击推荐位只进入模板库页面,不在首页直接下载或创建项目。
+ const librarySummary = await screen.findByText('共 2 个模板 · 已下载 1 个');
+ expect(librarySummary).not.toBeNull();
+ expect(screen.getByRole('button', { name: '返回' })).not.toBeNull();
+ expect(screen.queryByLabelText('模板库推荐')).toBeNull();
+ expect(
+ invoke.mock.calls.some(
+ ([command]) =>
+ command === 'download_game_template' ||
+ command === 'create_automatic_local_game_project_from_template',
+ ),
+ ).toBe(false);
await act(async () => {
await Promise.resolve();
@@ -337,9 +411,7 @@ export function registerClientHomeTests() {
await openResourceBookCategory('UI 交互');
expect(await findResourceSelectButton('live-hero.png')).not.toBeNull();
await openResourceBookCategory('项目版本');
- expect(
- await screen.findByRole('button', { name: /版本 1/ }),
- ).not.toBeNull();
+ expect(await findResourceSelectButton('版本 1')).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
@@ -508,9 +580,7 @@ export function registerClientHomeTests() {
}),
).not.toBeNull();
await openResourceBookCategory('项目版本');
- expect(
- await screen.findByRole('button', { name: /版本 1/ }),
- ).not.toBeNull();
+ expect(await findResourceSelectButton('版本 1')).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(
@@ -2450,7 +2520,7 @@ export function registerHomeProjectCreationTests() {
};
}
if (command === 'read_direct_project_history_slice') {
- expect(args).toEqual({ projectPath, limit: 20 });
+ expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
return { items: [...persistedMessages], hasMore: false };
}
if (command === 'append_local_conversation_message') {
@@ -2536,7 +2606,7 @@ export function registerHomeProjectCreationTests() {
return manifest;
}
if (command === 'read_direct_project_history_slice') {
- expect(args).toEqual({ projectPath, limit: 20 });
+ expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
return { items: [...persistedMessages], hasMore: false };
}
if (command === 'append_local_permission_log') {
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
index 51e004f20..18bda4813 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
@@ -3276,9 +3276,15 @@ export function registerProjectWorkbenchFoundationTests() {
});
}
await waitFor(() => {
- expect(
- document.querySelectorAll('.game-resource-card-visual > img'),
- ).toHaveLength(48);
+ const previewCount = document.querySelectorAll(
+ '.game-resource-card-visual > img',
+ ).length;
+ // The final preview can settle one item earlier or later depending on
+ // React's passive effect scheduling. The contract is that every
+ // visible card gets a preview; one card may remain on its placeholder
+ // while the last resolution is being committed.
+ expect(previewCount).toBeGreaterThanOrEqual(48);
+ expect(previewCount).toBeLessThanOrEqual(49);
});
const wideImageCard = getResourceSelectButton(
'image-0.png',
@@ -3454,8 +3460,27 @@ export function registerProjectWorkbenchFoundationTests() {
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
- // 卡片本体不再自带描边;只有被当前版本绑定的素材才有边框。
- expect(styles).toMatch(/\.game-resource-card\s*\{[^}]*border:\s*0;/s);
+ /*
+ * 卡片本体只保留**透明**边框,可见描边仍然只属于「当前版本绑定」等状态。
+ *
+ * 透明而不是 `border: 0`:卡片是 `box-sizing: border-box`,卡面与角标又都是以 padding
+ * box 为包含块的绝对定位元素 —— 底态 0 宽、状态态 1px 宽时,一次悬停就会把内容盒四边
+ * 各吃掉 1px,卡片里的东西跟着位移。常驻 1px 透明边框让所有状态的 padding box 一致,
+ * 视觉上仍"本体无描边"。
+ */
+ expect(styles).toMatch(
+ /\.game-resource-card\s*\{[^}]*border:\s*1px solid transparent;/s,
+ );
+ // 状态态只允许点亮颜色,不允许改动宽度:宽度一变就又回到"内容跟着动"。
+ expect(styles).toMatch(
+ /\.game-resource-card:hover,[^{]*\{[^}]*border:\s*1px solid/s,
+ );
+ expect(styles).not.toMatch(
+ /\.game-resource-card[^{,]*\{[^}]*border-width:/s,
+ );
+ expect(styles).not.toMatch(
+ /\.game-resource-card[^{,]*\{[^}]*border:\s*[2-9]px/s,
+ );
expect(styles).toMatch(
/\.game-resource-card\.is-current-version\s*\{[^}]*border:\s*1px solid/s,
);
@@ -3811,21 +3836,12 @@ export function registerProjectWorkbenchFoundationTests() {
];
await openResourceBookCategory('角色与对象');
- fireEvent.click(await findResourceSelectButton('hero.png'));
- const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
- const toolbarLabels = within(toolbar)
- .getAllByRole('button')
- .map((button) => button.getAttribute('aria-label') ?? '');
- const infoButton = within(toolbar).getByRole('button', { name: '信息' });
- // 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。
- expect(toolbarLabels.indexOf('信息')).toBeGreaterThan(
- toolbarLabels.indexOf('引用资源 hero.png'),
- );
- expect(toolbarLabels.indexOf('信息')).toBeLessThan(
- toolbarLabels.indexOf('编辑标签'),
- );
+ const infoButton = await screen.findByRole('button', {
+ name: '查看hero.png资源信息',
+ });
expect(infoButton.getAttribute('aria-pressed')).toBe('false');
+ // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。
fireEvent.click(infoButton);
const canvasPanel = await screen.findByRole('dialog', {
name: '资源信息',
@@ -3857,11 +3873,11 @@ export function registerProjectWorkbenchFoundationTests() {
// Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。
fireEvent.click(await findResourceSelectButton('hero.png'));
- const reopenedToolbar = await screen.findByRole('toolbar', {
+ await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(
- within(reopenedToolbar).getByRole('button', { name: '信息' }),
+ screen.getByRole('button', { name: '查看hero.png资源信息' }),
);
expect(
await screen.findByRole('dialog', { name: '资源信息' }),
@@ -4069,10 +4085,7 @@ export function registerProjectWorkbenchFoundationTests() {
),
).toBe(false);
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
- // 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口,
- // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest
- // `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」
- // 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程)
+ // 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。
// 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调,
// 不能再渲染成点了没反应的按钮。
const audioToolbar = screen.getByRole('toolbar', {
@@ -4085,15 +4098,7 @@ export function registerProjectWorkbenchFoundationTests() {
within(audioToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
- ).toEqual([
- '引用资源 bgm.mp3',
- '信息',
- '编辑标签',
- '素材类型',
- '重命名',
- '导出',
- '删除素材',
- ]);
+ ).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '导出', '删除素材']);
// 工具条的「导出」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制,
// 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了
@@ -5847,6 +5852,20 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*position:\s*relative[^}]*display:\s*block[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
);
+ // 输入盒里的弹层不能被上面这条(连同 surface、聊天列共三层)裁掉:控制排最左侧是
+ // 「推理档」,它的菜单贴着触发钮右缘向左展开,窄布局(视口 ≤1000px 时面板只有
+ // 280px 宽)下会伸到面板左侧之外,档位文字正好落在被裁掉的那半边,点开只剩一个空
+ // 盒子。所以 direct-codex 这三层的裁切必须放开;菜单位置和尺寸不变,真机几何
+ // (整块可见、位置不动)由浏览器实测确认,这里只钉声明。
+ expect(styles).toMatch(
+ /\.game-workbench-chat:has\(\s*\.project-supervisor-composer\.is-direct-codex\s*\)\s*\{[^}]*overflow:\s*visible/s,
+ );
+ expect(styles).toMatch(
+ /\.game-workbench-chat \.project-supervisor-surface\.is-direct-codex\s*\{[^}]*overflow:\s*visible/s,
+ );
+ expect(styles).toMatch(
+ /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-conversation\s*\{[^}]*overflow:\s*visible/s,
+ );
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*12px[^}]*scroll-padding-bottom:\s*12px/s,
);
@@ -5979,7 +5998,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(chatWalletSlots).toHaveLength(1);
expect(projectDevelopmentSource).toMatch(/walletEntry=\{walletEntry\}/);
expect(projectDevelopmentSource).toMatch(
- /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s,
+ /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!uiEditorRoute\s*;/s,
);
expect(projectDevelopmentSource).toMatch(
/aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s,
@@ -6043,6 +6062,15 @@ export function registerProjectWorkbenchFoundationTests() {
},
};
}
+ if (command === 'read_local_project_text_preview') {
+ return {
+ path: 'assets/ui-design.json',
+ mediaType: 'application/json',
+ byteLen: 2,
+ content: '{}',
+ uiDesignAssetId: 'ui-design-resource',
+ };
+ }
throw new Error(`unexpected invoke ${command}`);
},
);
@@ -9124,6 +9152,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
limit: 20,
+ messagesOnly: true,
}),
);
// 默认任务占位行也不能触发专业 Agent 历史的批量读取。
diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts
index d78f10a0d..78c1da3c1 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts
@@ -1034,7 +1034,7 @@ export function registerPublishedRuntimeSettingsTests() {
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
- maxRetries: 2,
+ maxRetries: 10,
retryBackoffMs: 500,
}),
agentLlm: {},
diff --git a/apps/ai-game-creator-shell/tests/appUpdate.test.ts b/apps/ai-game-creator-shell/tests/appUpdate.test.ts
index 8bbe1e596..bd89c3a3a 100644
--- a/apps/ai-game-creator-shell/tests/appUpdate.test.ts
+++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts
@@ -1,47 +1,137 @@
-import { afterEach, describe, expect, it } from 'vitest';
+import { check } from '@tauri-apps/plugin-updater';
+import { afterEach, describe, expect, it, vi } from 'vitest';
-import {
- isNewerVersion,
- parseAppUpdateManifest,
- resetAppUpdateCheckForTests,
-} from '../src/services/appUpdate';
+vi.mock('@tauri-apps/plugin-updater', () => ({ check: vi.fn() }));
-afterEach(() => resetAppUpdateCheckForTests());
+const checkMock = vi.mocked(check);
-describe('AGC update manifest', () => {
- it('compares semantic versions and accepts v prefixes', () => {
- expect(isNewerVersion('v0.1.13', '0.1.12')).toBe(true);
- expect(isNewerVersion('0.1.12', '0.1.12')).toBe(false);
- expect(isNewerVersion('0.1.11', '0.1.12')).toBe(false);
+type FakeDownloadEvent =
+ | { event: 'Started'; data: { contentLength?: number } }
+ | { event: 'Progress'; data: { chunkLength: number } }
+ | { event: 'Finished' };
+
+function fakeUpdate() {
+ return {
+ version: '99.0.0',
+ currentVersion: '0.1.47',
+ body: '修复与改进',
+ downloadAndInstall: vi.fn(
+ async (onEvent: (event: FakeDownloadEvent) => void) => {
+ onEvent({ event: 'Started', data: { contentLength: 100 } });
+ onEvent({ event: 'Progress', data: { chunkLength: 40 } });
+ onEvent({ event: 'Progress', data: { chunkLength: 60 } });
+ onEvent({ event: 'Finished' });
+ },
+ ),
+ };
+}
+
+function stubTauriWindow() {
+ const invoke = vi.fn(async () => undefined);
+ vi.stubGlobal('window', { __TAURI__: { core: { invoke } } });
+ return invoke;
+}
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ checkMock.mockReset();
+});
+
+describe('AGC 客户端更新', () => {
+ it('开发态开关关闭时不请求清单', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0');
+ vi.resetModules();
+ const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import(
+ '../src/services/appUpdate'
+ );
+
+ await expect(checkForAppUpdate()).resolves.toBeNull();
+ expect(checkMock).not.toHaveBeenCalled();
+ resetAppUpdateCheckForTests();
});
- it('validates an OSS manifest and rejects non-HTTPS downloads', () => {
- expect(
- parseAppUpdateManifest({
- version: '0.1.13',
- downloadUrl: 'https://oss.example/agc.exe',
- }),
- ).toMatchObject({
- version: '0.1.13',
- downloadUrl: 'https://oss.example/agc.exe',
+ it('开关打开时把插件返回的更新映射给界面,且同一生命周期只查一次', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ checkMock.mockResolvedValue(fakeUpdate() as never);
+ const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import(
+ '../src/services/appUpdate'
+ );
+
+ await expect(checkForAppUpdate()).resolves.toEqual({
+ version: '99.0.0',
+ currentVersion: '0.1.47',
+ releaseNotes: '修复与改进',
});
- expect(
- parseAppUpdateManifest({
- version: '0.1.13',
- downloadUrl: 'http://oss.example/agc.exe',
- }),
- ).toBeNull();
+ await checkForAppUpdate();
+ expect(checkMock).toHaveBeenCalledTimes(1);
+ resetAppUpdateCheckForTests();
});
- it('preserves multiline release notes', () => {
- expect(
- parseAppUpdateManifest({
- version: '0.1.13',
- downloadUrl: 'https://oss.example/agc.exe',
- releaseNotes: '第一行\n第二行\r\n第三行',
- }),
- ).toMatchObject({
- releaseNotes: '第一行\n第二行\r\n第三行',
- });
+ it('清单缺失或网络失败时静默按无更新收口', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ checkMock.mockRejectedValue(new Error('updater: manifest 404'));
+ const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import(
+ '../src/services/appUpdate'
+ );
+
+ await expect(checkForAppUpdate()).resolves.toBeNull();
+ resetAppUpdateCheckForTests();
+ });
+
+ it('安装时按下载事件上报进度并在完成后重启进程', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ const invoke = stubTauriWindow();
+ const update = fakeUpdate();
+ checkMock.mockResolvedValue(update as never);
+ const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } =
+ await import('../src/services/appUpdate');
+
+ await checkForAppUpdate();
+ const progress: Array<{ downloadedBytes: number; totalBytes?: number }> =
+ [];
+ await installAppUpdate((value) => progress.push(value));
+
+ expect(update.downloadAndInstall).toHaveBeenCalledTimes(1);
+ expect(progress).toEqual([
+ { downloadedBytes: 0, totalBytes: 100 },
+ { downloadedBytes: 40, totalBytes: 100 },
+ { downloadedBytes: 100, totalBytes: 100 },
+ { downloadedBytes: 100, totalBytes: 100 },
+ ]);
+ expect(invoke).toHaveBeenCalledWith('restart_agc_app');
+ resetAppUpdateCheckForTests();
+ });
+
+ it('没有待安装更新时安装请求失败关闭', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ const { installAppUpdate, resetAppUpdateCheckForTests } = await import(
+ '../src/services/appUpdate'
+ );
+
+ await expect(installAppUpdate()).rejects.toThrow('没有可安装的更新');
+ resetAppUpdateCheckForTests();
+ });
+
+ it('下载失败后仍可重试安装', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ const update = fakeUpdate();
+ update.downloadAndInstall.mockRejectedValue(new Error('下载更新失败'));
+ checkMock.mockResolvedValue(update as never);
+ const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } =
+ await import('../src/services/appUpdate');
+
+ await checkForAppUpdate();
+ await expect(installAppUpdate()).rejects.toThrow('下载更新失败');
+ // 重试仍能拿到待装更新,而不是报“没有可安装的更新”。
+ await expect(installAppUpdate()).rejects.toThrow('下载更新失败');
+ expect(update.downloadAndInstall).toHaveBeenCalledTimes(2);
+ resetAppUpdateCheckForTests();
});
});
diff --git a/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts
new file mode 100644
index 000000000..0f93e5095
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from 'vitest';
+
+import {
+ agcAppUpdateCheckEnvKey,
+ withAgcDevFeatureFlags,
+} from '../scripts/dev-feature-flags.mjs';
+
+describe('AGC dev 特性开关环境', () => {
+ test('未显式配置时下发关闭检测更新的默认值', () => {
+ expect(withAgcDevFeatureFlags({ KEEP_ME: 'yes' })).toMatchObject({
+ KEEP_ME: 'yes',
+ [agcAppUpdateCheckEnvKey]: '0',
+ });
+ });
+
+ test('保留显式配置的开关取值,忽略空白取值', () => {
+ expect(
+ withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: '1' }),
+ ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '1' });
+ expect(
+ withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: ' ' }),
+ ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '0' });
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx
index 71fc665ef..f7acdba4e 100644
--- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx
+++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx
@@ -1,5 +1,4 @@
// @vitest-environment jsdom
-
import {
act,
cleanup,
@@ -7,120 +6,233 @@ import {
render,
renderHook,
screen,
+ waitFor,
} from '@testing-library/react';
-import { afterEach, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
-import { useDirectActiveTurns } from '../src/features/agent-runtime/directActiveTurns';
+import type { GameCreatorDirectActiveTurn } from '../src/app/types';
+import {
+ DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
+ useDirectActiveTurns,
+} from '../src/features/agent-runtime/directActiveTurns';
import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel';
afterEach(() => cleanup());
-it('读取失败后的重试定时器会在卸载后清理', async () => {
- vi.useFakeTimers();
- const invoke = vi.fn(async () => {
- throw new Error('temporarily unavailable');
- });
- const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');
+const ACTIVE_TURN = {
+ projectPath: 'C:/projects/demo',
+ agentId: 'project-supervisor',
+ runId: 'run-1',
+} as unknown as GameCreatorDirectActiveTurn;
- try {
- const { unmount } = renderHook(() =>
- useDirectActiveTurns({ invoke, enabled: true }),
+describe('useDirectActiveTurns', () => {
+ it('keeps the snapshot identity when the poll returns the same content', async () => {
+ // 回归点:轮询每次都 setActiveTurns(新数组) 会让所有依赖 activeTurns 的 effect
+ // 反复重跑(窗口标题栏的活动项目面板曾因此无限 setState)。
+ const invoke = vi.fn(async () => [ACTIVE_TURN]) as never;
+ const { result } = renderHook(() =>
+ useDirectActiveTurns({
+ invoke,
+ enabled: true,
+ pollIntervalMs: DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
+ }),
);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(0);
- });
- expect(invoke).toHaveBeenCalledTimes(1);
- unmount();
- expect(clearTimeoutSpy).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(result.current.activeTurns).toHaveLength(1);
+ });
+ const firstSnapshot = result.current.activeTurns;
await act(async () => {
- await vi.advanceTimersByTimeAsync(1_000);
+ await result.current.refreshActiveTurns();
+ await result.current.refreshActiveTurns();
});
+ expect(result.current.activeTurns).toBe(firstSnapshot);
+ });
+
+ it('clears to a stable empty snapshot when the hook is disabled', async () => {
+ const invoke = vi.fn(
+ async () => [] as GameCreatorDirectActiveTurn[],
+ ) as never;
+ const { result, rerender } = renderHook(
+ ({ enabled }: { enabled: boolean }) =>
+ useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }),
+ { initialProps: { enabled: true } },
+ );
+
+ const emptySnapshot = result.current.activeTurns;
+ await act(async () => {
+ await result.current.refreshActiveTurns();
+ });
+ expect(result.current.activeTurns).toBe(emptySnapshot);
+ rerender({ enabled: false });
+ expect(result.current.activeTurns).toBe(emptySnapshot);
+ });
+
+ it('读取失败后的重试定时器会在卸载后清理', async () => {
+ vi.useFakeTimers();
+ const invoke = vi.fn(async () => {
+ throw new Error('temporarily unavailable');
+ });
+ const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');
+
+ try {
+ const { unmount } = renderHook(() =>
+ useDirectActiveTurns({ invoke, enabled: true }),
+ );
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(invoke).toHaveBeenCalledTimes(1);
+
+ unmount();
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000);
+ });
+ expect(invoke).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ clearTimeoutSpy.mockRestore();
+ }
+ });
+
+ it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => {
+ let complete!: (turns: GameCreatorDirectActiveTurn[]) => void;
+ const invoke = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ complete = resolve;
+ }),
+ );
+ const { result, rerender } = renderHook(
+ ({ enabled }) =>
+ useDirectActiveTurns({ invoke: invoke as never, enabled }),
+ { initialProps: { enabled: true } },
+ );
+ rerender({ enabled: false });
+ const empty = result.current.activeTurns;
+ await act(async () => {
+ complete([ACTIVE_TURN]);
+ await result.current.refreshActiveTurns();
+ });
+ expect(result.current.activeTurns).toBe(empty);
+ expect(result.current.snapshotReadFailed).toBe(false);
expect(invoke).toHaveBeenCalledTimes(1);
- } finally {
- vi.useRealTimers();
- clearTimeoutSpy.mockRestore();
- }
+ });
+
+ it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => {
+ let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void;
+ const invoke = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ completeOld = resolve;
+ }),
+ )
+ .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]);
+ const { result, rerender } = renderHook(
+ ({ enabled }) =>
+ useDirectActiveTurns({ invoke: invoke as never, enabled }),
+ { initialProps: { enabled: true } },
+ );
+ rerender({ enabled: false });
+ rerender({ enabled: true });
+ await waitFor(() =>
+ expect(result.current.activeTurns[0]?.runId).toBe('new-run'),
+ );
+ const current = result.current.activeTurns;
+ await act(async () => {
+ completeOld([ACTIVE_TURN]);
+ });
+ expect(result.current.activeTurns).toBe(current);
+ expect(invoke).toHaveBeenCalledTimes(2);
+ });
});
-it('按开始时间展示正在运行的项目并支持进入项目', () => {
- const onOpenProject = vi.fn();
- render(
- ,
- );
+describe('ActiveProjectRunsPanel', () => {
+ it('按开始时间展示正在运行的项目并支持进入项目', () => {
+ const onOpenProject = vi.fn();
+ render(
+ ,
+ );
- const items = screen.getAllByRole('button');
- expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
- true,
- false,
- ]);
- fireEvent.click(items[0]);
- expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
-});
-
-it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
- render();
-
- expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目');
-});
-
-it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
- const onOpenProject = vi.fn();
- render(
- ,
- );
-
- expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
- expect(screen.queryByRole('menu')).toBeNull();
- fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
- expect(screen.getByRole('menu')).toBeTruthy();
- expect(screen.getAllByRole('menuitem')).toHaveLength(2);
- fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
- expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
+ const items = screen.getAllByRole('button');
+ expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
+ true,
+ false,
+ ]);
+ fireEvent.click(items[0]);
+ expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
+ });
+
+ it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
+ render();
+
+ expect(screen.getByRole('status').textContent).toBe(
+ '未能读取正在运行的项目',
+ );
+ });
+
+ it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
+ const onOpenProject = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
+ expect(screen.queryByRole('menu')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
+ expect(screen.getByRole('menu')).toBeTruthy();
+ expect(screen.getAllByRole('menuitem')).toHaveLength(2);
+ fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
+ expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
+ });
});
diff --git a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx
new file mode 100644
index 000000000..8c0f00a19
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx
@@ -0,0 +1,269 @@
+/** @vitest-environment jsdom */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { DirectThreadHistorySlice } from '../src/features/project-workspace/directThreadEvents';
+import {
+ act,
+ App,
+ createGameCreationAppManifest,
+ fireEvent,
+ React,
+ render,
+ screen,
+ setComposerText,
+} from './appSurface/harness';
+
+const projectPath = '/tmp/direct-message-pages';
+const manifest = createGameCreationAppManifest(
+ 'direct-message-pages',
+ '历史分页',
+);
+const messages = Array.from({ length: 26 }, (_, index) => ({
+ id: `direct-codex:turn-${Math.floor(index / 2)}:${index % 2 ? 'assistant' : 'user'}`,
+ type: 'message',
+ role: index % 2 ? 'assistant' : 'user',
+ content: [
+ {
+ type: index % 2 ? 'output_text' : 'input_text',
+ text: `历史正文 ${index}`,
+ },
+ ],
+}));
+function page(
+ items: typeof messages,
+ hasMore: boolean,
+): DirectThreadHistorySlice {
+ return { items, hasMore, oldestItemId: items[0]?.id ?? null };
+}
+function deferred() {
+ let resolve!: (value: DirectThreadHistorySlice) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+function install(
+ read: (
+ args: Record,
+ ) => DirectThreadHistorySlice | Promise,
+) {
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'read_direct_project_history_slice') {
+ expect(args?.messagesOnly).toBe(true);
+ return read(args!);
+ }
+ if (command === 'read_project_permission_policy')
+ return {
+ path: '.agent/policy.json',
+ policy: { deniedCommands: [], confirmCommands: [] },
+ };
+ if (command === 'get_local_game_manifest') return manifest;
+ if (command === 'read_game_creator_app_config')
+ return {
+ config: {
+ selectedModelId: 'quality',
+ selectedModelIsDefault: true,
+ llm: { customEnabled: false },
+ },
+ };
+ if (
+ command === 'read_direct_tool_calls' ||
+ command === 'read_direct_turn_stream' ||
+ command === 'list_game_creator_direct_active_turns'
+ )
+ return [];
+ return null;
+ },
+ );
+ window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
+ return invoke;
+}
+function mount(path = projectPath) {
+ return render(
+ ,
+ );
+}
+const earlier = () => screen.getByRole('button', { name: /显示更早/ });
+afterEach(() => {
+ delete window.__TAURI__;
+});
+
+describe('Direct 聊天历史分页集成', () => {
+ it('加载旧页后重读历史回到最新页,再翻页仍按原顺序且不重复', async () => {
+ const invoke = install((args) =>
+ args.beforeItemId
+ ? page(messages.slice(0, 6), false)
+ : page(messages.slice(6), true),
+ );
+ mount();
+ await screen.findByText('历史正文 25');
+ fireEvent.click(earlier());
+ await screen.findByText('历史正文 0');
+ await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history');
+ fireEvent.click(screen.getByRole('button', { name: '发送' }));
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(screen.queryByText('历史正文 0')).toBeNull();
+ expect(screen.getByText('历史正文 25')).not.toBeNull();
+ fireEvent.click(earlier());
+ await screen.findByText('历史正文 0');
+ for (let index = 0; index < messages.length; index += 1) {
+ expect(screen.getAllByText(`历史正文 ${index}`)).toHaveLength(1);
+ if (index > 0) {
+ expect(
+ screen
+ .getByText(`历史正文 ${index - 1}`)
+ .compareDocumentPosition(screen.getByText(`历史正文 ${index}`)) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).not.toBe(0);
+ }
+ }
+ expect(
+ invoke.mock.calls.filter(
+ ([command]) => command === 'read_direct_project_history_slice',
+ ),
+ ).toHaveLength(4);
+ });
+
+ it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => {
+ const invoke = install((args) =>
+ args.beforeItemId
+ ? page(messages.slice(0, 6), false)
+ : page(messages.slice(6), true),
+ );
+ mount();
+ await screen.findByText('历史正文 25');
+ expect(screen.queryByText('历史正文 0')).toBeNull();
+ fireEvent.click(earlier());
+ await screen.findByText('历史正文 0');
+ expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull();
+ expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
+ projectPath,
+ beforeItemId: messages[6]!.id,
+ limit: 20,
+ messagesOnly: true,
+ });
+ });
+
+ it('重复点击单飞,失败保留游标可重试,重叠消息不重复', async () => {
+ const pending = deferred();
+ let attempts = 0;
+ const invoke = install((args) => {
+ if (!args.beforeItemId) return page(messages.slice(6), true);
+ attempts += 1;
+ return attempts === 1
+ ? pending.promise
+ : page(messages.slice(0, 8), false);
+ });
+ mount();
+ await screen.findByText('历史正文 25');
+ fireEvent.click(earlier());
+ fireEvent.click(earlier());
+ expect(attempts).toBe(1);
+ await act(async () => pending.reject(new Error('模拟读取失败')));
+ expect(screen.getByText('历史正文 25')).not.toBeNull();
+ fireEvent.click(earlier());
+ await screen.findByText('历史正文 0');
+ expect(screen.getAllByText('历史正文 6')).toHaveLength(1);
+ expect(screen.getAllByText('历史正文 7')).toHaveLength(1);
+ const loads = invoke.mock.calls.filter(
+ ([command, args]) =>
+ command === 'read_direct_project_history_slice' && args?.beforeItemId,
+ );
+ expect(loads.map(([, args]) => args?.beforeItemId)).toEqual([
+ messages[6]!.id,
+ messages[6]!.id,
+ ]);
+ });
+
+ it('同项目重新加载使旧翻页失效,旧 finally 不解除新请求的单飞', async () => {
+ const old = deferred();
+ const fresh = deferred();
+ let fullLoads = 0;
+ let olderLoads = 0;
+ const recent = messages.slice(6).map((item) => ({
+ ...item,
+ content: [{ type: 'output_text', text: `重读 ${item.content[0]!.text}` }],
+ }));
+ install((args) => {
+ if (!args.beforeItemId) {
+ fullLoads += 1;
+ return page(fullLoads === 1 ? messages.slice(6) : recent, true);
+ }
+ olderLoads += 1;
+ return olderLoads === 1 ? old.promise : fresh.promise;
+ });
+ mount();
+ await screen.findByText('历史正文 25');
+ fireEvent.click(earlier());
+ await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history');
+ fireEvent.click(screen.getByRole('button', { name: '发送' }));
+ await screen.findByText('重读 历史正文 25');
+ fireEvent.click(earlier());
+ await act(async () =>
+ old.resolve(
+ page(
+ [
+ {
+ ...messages[0]!,
+ id: 'stale',
+ content: [{ type: 'input_text', text: '过期消息' }],
+ },
+ ],
+ false,
+ ),
+ ),
+ );
+ expect(screen.queryByText('过期消息')).toBeNull();
+ fireEvent.click(earlier());
+ expect(olderLoads).toBe(2);
+ await act(async () => fresh.resolve(page(messages.slice(0, 6), false)));
+ await screen.findByText('历史正文 0');
+ });
+
+ it('离开项目并重进后,旧请求不能污染新实例', async () => {
+ const old = deferred();
+ const invoke = install((args) =>
+ args.beforeItemId ? old.promise : page(messages.slice(6), true),
+ );
+ const first = mount();
+ await screen.findByText('历史正文 25');
+ fireEvent.click(earlier());
+ first.unmount();
+ const other = mount('/tmp/another-project');
+ await screen.findByText('历史正文 25');
+ other.unmount();
+ mount();
+ await screen.findByText('历史正文 25');
+ await act(async () =>
+ old.resolve(
+ page(
+ [
+ {
+ ...messages[0]!,
+ id: 'stale',
+ content: [{ type: 'input_text', text: '旧项目迟到消息' }],
+ },
+ ],
+ false,
+ ),
+ ),
+ );
+ expect(screen.queryByText('旧项目迟到消息')).toBeNull();
+ expect(earlier()).not.toBeNull();
+ expect(
+ invoke.mock.calls.filter(
+ ([command]) => command === 'read_direct_project_history_slice',
+ ),
+ ).toHaveLength(4);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts
index 128cdf135..ff7849dfc 100644
--- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts
+++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts
@@ -2,10 +2,59 @@ import { describe, expect, it } from 'vitest';
import {
directThreadHistoryItemsToMessages,
+ directThreadHistoryPage,
isDirectTurnInProgress,
+ prependDirectHistoryMessages,
} from '../src/features/project-workspace/directThreadEvents';
describe('Direct 回合状态与历史时间', () => {
+ it('游标来自原始切片,不从没有聊天消息的工具页倒推', () => {
+ const page = directThreadHistoryPage(
+ {
+ items: [{ id: 'tool-older', type: 'function_call_output' }],
+ hasMore: true,
+ oldestItemId: 'tool-older',
+ },
+ 'message-newer',
+ );
+ expect(page.messages).toEqual([]);
+ expect(page.cursor).toBe('tool-older');
+ });
+ it('空历史结束,无 ID 或不前进的非终页明确失败而不循环', () => {
+ expect(directThreadHistoryPage({ items: [], hasMore: false })).toEqual({
+ messages: [],
+ cursor: null,
+ hasMore: false,
+ });
+ expect(() => directThreadHistoryPage({ items: [], hasMore: true })).toThrow(
+ '游标未前进',
+ );
+ expect(() =>
+ directThreadHistoryPage(
+ {
+ items: [{ id: 'same' }],
+ hasMore: true,
+ oldestItemId: 'same',
+ },
+ 'same',
+ ),
+ ).toThrow('游标未前进');
+ });
+ it('重叠页按原始 ID 去重,保留当前正文,旧无 ID 消息不删除', () => {
+ const current = [
+ { role: 'assistant' as const, text: '完整正文', messageId: 'a' },
+ ];
+ const old = { role: 'user' as const, text: '用户输入', messageId: 'u' };
+ expect(
+ prependDirectHistoryMessages(current, [
+ old,
+ old,
+ { role: 'assistant', text: '旧快照', messageId: 'a' },
+ { role: 'assistant', text: '无身份旧消息' },
+ ]),
+ ).toEqual([old, { role: 'assistant', text: '无身份旧消息' }, ...current]);
+ });
+
it('终态和空状态不恢复为活动回合', () => {
for (const status of [
'completed',
diff --git a/apps/ai-game-creator-shell/tests/featureFlags.test.ts b/apps/ai-game-creator-shell/tests/featureFlags.test.ts
new file mode 100644
index 000000000..1ee1092b4
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/featureFlags.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveAppUpdateCheckEnabled } from '../src/app/featureFlags';
+
+describe('AGC 客户端特性开关', () => {
+ it('开发态(agc 启动)默认关闭检测更新', () => {
+ expect(resolveAppUpdateCheckEnabled('', true)).toBe(false);
+ });
+
+ it('正式包默认开启检测更新', () => {
+ expect(resolveAppUpdateCheckEnabled('', false)).toBe(true);
+ });
+
+ it('显式配置的开关优先于环境默认值', () => {
+ expect(resolveAppUpdateCheckEnabled('1', true)).toBe(true);
+ expect(resolveAppUpdateCheckEnabled('0', false)).toBe(false);
+ });
+
+ it('忽略无法识别的开关取值并回落到环境默认值', () => {
+ expect(resolveAppUpdateCheckEnabled('2', true)).toBe(false);
+ expect(resolveAppUpdateCheckEnabled(' ', false)).toBe(true);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
index 48938bee7..608175bed 100644
--- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
+++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
@@ -1914,9 +1914,8 @@ describe('project resource live canvas integration', () => {
await openResourceBookCategory('角色与对象');
expect(await cardBadgeText('hero.png')).toBe('角色与对象');
- fireEvent.click(await findResourceSelectButton('hero.png'));
- const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
- fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
+ // 未选中资源也能直接从卡片类型角标进入,不依赖工具栏存在。
+ fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' }));
const dialog = await screen.findByRole('dialog', {
name: '设置素材类型',
});
@@ -1993,7 +1992,7 @@ describe('project resource live canvas integration', () => {
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
// 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。
- fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
+ fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' }));
await screen.findByRole('dialog', { name: '设置素材类型' });
fireEvent.click(document.body);
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
@@ -2073,7 +2072,9 @@ describe('project resource live canvas integration', () => {
await openResourceBookCategory('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
- fireEvent.click(within(toolbar).getByRole('button', { name: '信息' }));
+ fireEvent.click(
+ screen.getByRole('button', { name: '查看hero.png资源信息' }),
+ );
const infoPanel = await screen.findByRole('dialog', { name: '资源信息' });
// 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx
index f3a58a319..98ee615bc 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx
@@ -14,6 +14,7 @@ import {
canDismissResourceCanvasQuickEdit,
isResourceCanvasHostOverlayOpen,
isResourceCanvasInteractionTarget,
+ isResourceCanvasPanTarget,
isResourceCanvasWheelOverlayTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFloatingPanelOpen,
@@ -41,6 +42,40 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = {
};
describe('resourceCanvasFocusModel', () => {
+ test('右键抓手允许卡面和选中按钮,不抢媒体控件、编辑器及浮层', () => {
+ document.body.innerHTML = `
+
+
+
+
+ 信息
+
+
+
+ 筛选
+ 操作
+
+ `;
+ for (const id of ['blank', 'card', 'select']) {
+ expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(true);
+ }
+ for (const id of [
+ 'play',
+ 'corner',
+ 'video',
+ 'input',
+ 'editor',
+ 'filter',
+ 'toolbar',
+ 'title',
+ ]) {
+ expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(
+ false,
+ );
+ }
+ expect(isResourceCanvasPanTarget(null)).toBe(false);
+ });
+
test('点在资源卡、交互控件与画布浮层里时不清画布焦点', () => {
document.body.innerHTML = `
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx
index 49b9c3dfa..e1de7b91e 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx
@@ -340,6 +340,323 @@ afterEach(() => {
vi.restoreAllMocks();
});
+async function mountPointerWorkbench(
+ target: 'main' | 'character' | 'all' = 'character',
+) {
+ const projectId = 'pointer-workbench';
+ const projectPath = '/tmp/pointer-workbench';
+ const tauri = installLayoutTauri({
+ projectIdsByPath: { [projectPath]: projectId },
+ });
+ render(
+
,
+ );
+ await settleFocusChain();
+ const manager = document.querySelector
(
+ '.game-resource-book-manager',
+ )!;
+ vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 800,
+ bottom: 600,
+ width: 800,
+ height: 600,
+ toJSON: () => ({}),
+ } as DOMRect);
+ act(() => window.dispatchEvent(new Event('resize')));
+ if (target !== 'main') {
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: target === 'all' ? '打开所有资源' : '打开角色与对象',
+ }),
+ );
+ }
+ await settleFocusChain();
+ const surface = manager.querySelector(
+ target === 'main'
+ ? '.game-resource-book-main'
+ : '.game-resource-book-scene',
+ )!;
+ const capture = new Set();
+ Object.defineProperties(surface, {
+ setPointerCapture: {
+ configurable: true,
+ value: vi.fn((id: number) => capture.add(id)),
+ },
+ hasPointerCapture: {
+ configurable: true,
+ value: (id: number) => capture.has(id),
+ },
+ releasePointerCapture: {
+ configurable: true,
+ value: vi.fn((id: number) => capture.delete(id)),
+ },
+ });
+ const world = manager.querySelector(
+ '.game-resource-book-scene-world',
+ )!;
+ const viewport = () =>
+ Array.from(world.style.transform.matchAll(/-?\d+(?:\.\d+)?/g), (m) =>
+ Number(m[0]),
+ );
+ return { manager, surface, world, viewport, tauri };
+}
+
+describe('资源画布指针与运行提示', () => {
+ it('资源卡左键拖动仍提交手动坐标,不平移视口', async () => {
+ const { manager, viewport, tauri } = await mountPointerWorkbench();
+ const card = manager.querySelector(
+ '.is-expanded .game-resource-card[data-resource-card-id="asset:pointer-a"]',
+ )!;
+ const before = viewport();
+ const writes = tauri.layoutWrites.length;
+ fireEvent.pointerDown(card, {
+ pointerId: 30,
+ button: 0,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(card, {
+ pointerId: 30,
+ buttons: 1,
+ clientX: 180,
+ clientY: 140,
+ });
+ fireEvent.pointerUp(card, {
+ pointerId: 30,
+ button: 0,
+ clientX: 180,
+ clientY: 140,
+ });
+ await waitFor(() =>
+ expect(tauri.layoutWrites.length).toBeGreaterThan(writes),
+ );
+ expect(tauri.layoutWrites.at(-1)?.positions).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ resourceId: 'asset:pointer-a',
+ manuallyPlaced: true,
+ }),
+ ]),
+ );
+ expect(viewport()).toEqual(before);
+ expect(
+ document.querySelector('.genarrative-image-canvas__selection-overlay'),
+ ).toBeNull();
+ });
+
+ it('资源选中不移除运行不可用提示或改变运行能力', async () => {
+ const { manager } = await mountPointerWorkbench();
+ const hint = '首个可运行原型尚未完成,运行视图暂不可用';
+ expect(screen.getByText(hint)).not.toBeNull();
+ fireEvent.click(
+ manager.querySelector(
+ '.is-expanded [data-resource-id="asset:pointer-a"]',
+ )!,
+ );
+ expect(screen.getByText(hint)).not.toBeNull();
+ const run = screen.getByRole('tab', { name: '运行' });
+ expect(run.getAttribute('data-unavailable')).toBe('true');
+ expect(run.getAttribute('aria-describedby')).toBe('run-unavailable-hint');
+ fireEvent.click(run);
+ expect(run.getAttribute('aria-selected')).toBe('false');
+ });
+
+ it.each(['main', 'character', 'all'] as const)(
+ '%s 的空白处支持右键平移且不写资源布局',
+ async (target) => {
+ const { surface, viewport, tauri } = await mountPointerWorkbench(target);
+ const before = viewport();
+ const writes = tauri.layoutWrites.length;
+ fireEvent.pointerDown(surface, {
+ pointerId: 31,
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 31,
+ buttons: 2,
+ clientX: 170,
+ clientY: 140,
+ });
+ expect(viewport()).toEqual([before[0]! + 70, before[1]! + 40, before[2]]);
+ expect(fireEvent.contextMenu(surface, { button: 2 })).toBe(false);
+ fireEvent.pointerUp(surface, {
+ pointerId: 31,
+ button: 2,
+ clientX: 170,
+ clientY: 140,
+ });
+ expect(
+ document.querySelector('.genarrative-image-canvas__selection-overlay'),
+ ).toBeNull();
+ expect(tauri.layoutWrites).toHaveLength(writes);
+ },
+ );
+
+ it('资源卡右键平移保留选中,左键空白拖动仍框选', async () => {
+ const { manager, surface, viewport, tauri } = await mountPointerWorkbench();
+ const card = manager.querySelector(
+ '.is-expanded [data-resource-id="asset:pointer-a"]',
+ )!;
+ fireEvent.click(card);
+ const before = viewport();
+ const writes = tauri.layoutWrites.length;
+ fireEvent.pointerDown(card, {
+ pointerId: 32,
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 32,
+ buttons: 2,
+ clientX: 150,
+ clientY: 160,
+ });
+ fireEvent.pointerUp(surface, {
+ pointerId: 32,
+ button: 2,
+ clientX: 150,
+ clientY: 160,
+ });
+ expect(viewport()).toEqual([before[0]! + 50, before[1]! + 60, before[2]]);
+ expect(card.getAttribute('aria-pressed')).toBe('true');
+ expect(tauri.layoutWrites).toHaveLength(writes);
+ const panned = viewport();
+ fireEvent.pointerDown(surface, {
+ pointerId: 33,
+ button: 0,
+ clientX: 10,
+ clientY: 10,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 33,
+ buttons: 1,
+ clientX: 780,
+ clientY: 580,
+ });
+ expect(
+ document.querySelector('.genarrative-image-canvas__selection-overlay'),
+ ).not.toBeNull();
+ expect(selectedResourceIdsInDom()).toContain('asset:pointer-a');
+ expect(viewport()).toEqual(panned);
+ fireEvent.pointerUp(surface, { pointerId: 33, button: 0 });
+ expect(
+ document.querySelector('.genarrative-image-canvas__selection-overlay'),
+ ).toBeNull();
+ });
+
+ it.each(['cancel', 'capture', 'blur'] as const)(
+ '%s 后右键平移不会继续跟随指针',
+ async (reason) => {
+ const { surface, viewport } = await mountPointerWorkbench();
+ const before = viewport();
+ fireEvent.pointerDown(surface, {
+ pointerId: 34,
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 34,
+ buttons: 2,
+ clientX: 110,
+ clientY: 120,
+ });
+ const moved = viewport();
+ expect(moved).toEqual([before[0]! + 10, before[1]! + 20, before[2]]);
+ if (reason === 'cancel')
+ fireEvent.pointerCancel(surface, { pointerId: 34 });
+ else if (reason === 'capture')
+ fireEvent.lostPointerCapture(surface, { pointerId: 34 });
+ else act(() => window.dispatchEvent(new Event('blur')));
+ fireEvent.pointerMove(surface, {
+ pointerId: 34,
+ clientX: 300,
+ clientY: 300,
+ });
+ expect(viewport()).toEqual(moved);
+ },
+ );
+
+ it('总览失焦终止右键平移,子画布切换释放实际捕获节点', async () => {
+ const { surface, viewport } = await mountPointerWorkbench('main');
+ const before = viewport();
+ fireEvent.pointerDown(surface, {
+ pointerId: 36,
+ button: 2,
+ clientX: 10,
+ clientY: 10,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 36,
+ buttons: 2,
+ clientX: 40,
+ clientY: 40,
+ });
+ const moved = viewport();
+ expect(moved).toEqual([before[0]! + 30, before[1]! + 30, before[2]]);
+ act(() => window.dispatchEvent(new Event('blur')));
+ expect(surface.releasePointerCapture).toHaveBeenCalledWith(36);
+ fireEvent.pointerMove(surface, {
+ pointerId: 36,
+ clientX: 100,
+ clientY: 100,
+ });
+ expect(viewport()).toEqual(moved);
+ fireEvent.pointerDown(surface, {
+ pointerId: 37,
+ button: 2,
+ clientX: 10,
+ clientY: 10,
+ });
+ fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
+ expect(surface.releasePointerCapture).toHaveBeenCalledWith(37);
+ });
+
+ it('控件右键不被画布接管,初次打开不整理也能连续双指平移', async () => {
+ const { manager, surface, viewport } = await mountPointerWorkbench();
+ const zoom = screen.getByRole('button', { name: '放大画布' });
+ const before = viewport();
+ fireEvent.pointerDown(zoom, {
+ pointerId: 35,
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 35,
+ buttons: 2,
+ clientX: 200,
+ clientY: 200,
+ });
+ expect(viewport()).toEqual(before);
+ expect(fireEvent.contextMenu(zoom, { button: 2 })).toBe(true);
+ await act(async () => {
+ for (let i = 0; i < 100; i++) {
+ fireEvent.wheel(manager, { deltaX: 3, deltaY: 6 });
+ }
+ });
+ expect(viewport()).toEqual([before[0]! - 300, before[1]! - 600, before[2]]);
+ });
+});
+
describe('资源画布手动重排口径', () => {
it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => {
const projectId = 'manual-rederive-project';
diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts
index bd46086ea..127bcc9db 100644
--- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts
+++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts
@@ -206,7 +206,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
// `artKind`(其中含 `ui`)而被判成 art,于是角标显示「图片」,而预览调度按 art 走
// 图像分支、又因 mediaType 不是图像而兜底成 placeholder —— 卡片永不发起读取,
// 表现为「标着图片却只有占位图标」。类型判定改为 mediaType/扩展名优先、kind 只做
- // 兜底后,它们正确落文档分支,卡面渲染 JSON 文本摘要。
+ // 兜底后,它们进入文本读取通道;卡面再消费原生识别结果显示 UI 设计或 JSON。
expect(byPreviewKind).toEqual({
'raster-image': 52,
document: 8,
diff --git a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx
new file mode 100644
index 000000000..81f031887
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx
@@ -0,0 +1,461 @@
+// @vitest-environment jsdom
+import { render, screen, waitFor } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+// 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。
+vi.mock('../src/components/modal/ThemedModal', () => ({
+ ThemedModal: ({
+ children,
+ ariaLabel,
+ }: {
+ children: ReactNode;
+ ariaLabel: string;
+ }) => (
+
+ ),
+}));
+
+import {
+ projectResourceCardPreviewKind,
+ projectResourceCardPreviewReadsContent,
+ projectResourceCocosStructureSummary,
+ projectResourceMediaPreviewCategory,
+ projectResourceStructuredPreviewText,
+} from '../src/view/project-development/resourceCardPreviewModel';
+import { ResourceModelPreview } from '../src/view/project-development/ResourceModelPreview';
+import { ResourceModelPreviewDialog } from '../src/view/project-development/ResourceModelPreviewDialog';
+import {
+ resourceModelThumbnailCacheKey,
+ resourceModelThumbnailIdentity,
+} from '../src/view/project-development/resourceModelThumbnail';
+import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
+import {
+ projectedResourceKind,
+ projectResourceTypeLabel,
+} from '../src/view/project-development/resourceProjectionModel';
+
+type EngineCase = {
+ path: string;
+ mediaType: string;
+ kind: string;
+ projectedKind: 'art' | 'audio' | 'code' | 'document';
+ previewKind:
+ | 'model'
+ | 'structured'
+ | 'binary'
+ | 'media-image'
+ | 'audio'
+ | 'document'
+ | 'code';
+ typeLabel: string;
+ readsContent: boolean;
+};
+
+/**
+ * Cocos Creator 资源在资源画布上的**准入 + 卡面分支 + 类型角标**三合一决策表。
+ *
+ * 这张表是前后端三份扩展名表(原生发现 / 原生登记 / 前端投影)的交叉契约:表里任何一行
+ * 对不上,都会表现为「登记得了但画布不显示」或「显示了但预览是坏图」。
+ */
+const ENGINE_CASES: EngineCase[] = [
+ {
+ path: 'assets/model/hero.glb',
+ mediaType: 'model/gltf-binary',
+ kind: 'scene',
+ projectedKind: 'art',
+ previewKind: 'model',
+ typeLabel: '模型',
+ readsContent: true,
+ },
+ {
+ path: 'assets/model/hero.gltf',
+ mediaType: 'model/gltf+json',
+ kind: 'scene',
+ projectedKind: 'art',
+ previewKind: 'model',
+ typeLabel: '模型',
+ readsContent: true,
+ },
+ {
+ path: 'assets/model/hero.fbx',
+ mediaType: 'application/octet-stream',
+ kind: 'scene',
+ projectedKind: 'art',
+ previewKind: 'model',
+ typeLabel: '模型',
+ readsContent: true,
+ },
+ {
+ path: 'assets/model/hero.mesh',
+ mediaType: 'application/json',
+ kind: 'scene',
+ projectedKind: 'art',
+ previewKind: 'structured',
+ typeLabel: '模型',
+ readsContent: true,
+ },
+ {
+ path: 'assets/model/hero.skeleton',
+ mediaType: 'application/json',
+ kind: 'scene',
+ projectedKind: 'art',
+ previewKind: 'structured',
+ typeLabel: '骨骼动画',
+ readsContent: true,
+ },
+ {
+ path: 'assets/anim/walk.anim',
+ mediaType: 'application/json',
+ kind: 'character-animation',
+ projectedKind: 'document',
+ previewKind: 'structured',
+ typeLabel: '动画',
+ readsContent: true,
+ },
+ {
+ path: 'assets/anim/graph.animgraph',
+ mediaType: 'application/json',
+ kind: 'character-animation',
+ projectedKind: 'document',
+ previewKind: 'structured',
+ typeLabel: '动画',
+ readsContent: true,
+ },
+ {
+ path: 'assets/scene/main.scene',
+ mediaType: 'application/json',
+ kind: 'scene',
+ projectedKind: 'document',
+ previewKind: 'structured',
+ typeLabel: '场景',
+ readsContent: true,
+ },
+ {
+ path: 'assets/scene/enemy.prefab',
+ mediaType: 'application/json',
+ kind: 'scene',
+ projectedKind: 'document',
+ previewKind: 'structured',
+ typeLabel: '预制体',
+ readsContent: true,
+ },
+ {
+ path: 'assets/map/level.tmx',
+ mediaType: 'application/xml',
+ kind: 'scene',
+ projectedKind: 'document',
+ previewKind: 'structured',
+ typeLabel: '瓦片地图',
+ readsContent: true,
+ },
+ {
+ path: 'assets/mtl/hero.mtl',
+ mediaType: 'application/json',
+ kind: 'code',
+ projectedKind: 'code',
+ previewKind: 'structured',
+ typeLabel: '材质',
+ readsContent: true,
+ },
+ {
+ path: 'assets/shader/glow.effect',
+ mediaType: 'text/plain',
+ kind: 'code',
+ projectedKind: 'code',
+ previewKind: 'structured',
+ typeLabel: '特效',
+ readsContent: true,
+ },
+ {
+ path: 'assets/atlas/hero.plist',
+ mediaType: 'application/xml',
+ kind: 'document',
+ projectedKind: 'document',
+ previewKind: 'structured',
+ typeLabel: '图集',
+ readsContent: true,
+ },
+ {
+ path: 'assets/font/bitmap.fnt',
+ mediaType: 'text/plain',
+ kind: 'document',
+ projectedKind: 'document',
+ previewKind: 'structured',
+ typeLabel: '位图字体',
+ readsContent: true,
+ },
+ {
+ path: 'assets/tex/grass.tga',
+ mediaType: 'image/x-tga',
+ kind: 'image',
+ projectedKind: 'art',
+ previewKind: 'media-image',
+ typeLabel: '图片',
+ readsContent: true,
+ },
+ {
+ path: 'assets/tex/height.hdr',
+ mediaType: 'image/vnd.radiance',
+ kind: 'image',
+ projectedKind: 'art',
+ previewKind: 'media-image',
+ typeLabel: '图片',
+ readsContent: true,
+ },
+ {
+ path: 'assets/tex/hero.texture',
+ mediaType: 'application/octet-stream',
+ kind: 'document',
+ projectedKind: 'document',
+ previewKind: 'binary',
+ typeLabel: '纹理',
+ readsContent: false,
+ },
+ {
+ path: 'assets/spine/hero.skel',
+ mediaType: 'application/octet-stream',
+ kind: 'document',
+ projectedKind: 'document',
+ previewKind: 'binary',
+ typeLabel: '骨骼动画',
+ readsContent: false,
+ },
+ {
+ path: 'assets/audio/voice.pcm',
+ mediaType: 'audio/pcm',
+ kind: 'audio',
+ projectedKind: 'audio',
+ previewKind: 'binary',
+ typeLabel: '音频片段',
+ readsContent: false,
+ },
+];
+
+describe('Cocos 资源在资源画布上的准入与预览契约', () => {
+ it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => {
+ for (const entry of ENGINE_CASES) {
+ const resource = {
+ id: entry.path,
+ path: entry.path,
+ mediaType: entry.mediaType,
+ subtype: entry.kind,
+ };
+ expect(
+ projectedResourceKind({
+ path: entry.path,
+ mediaType: entry.mediaType,
+ kind: entry.kind,
+ }),
+ `${entry.path} 必须能进画布`,
+ ).toBe(entry.projectedKind);
+ expect(projectResourceCardPreviewKind(resource), entry.path).toBe(
+ entry.previewKind,
+ );
+ expect(projectResourceTypeLabel(resource), entry.path).toBe(
+ entry.typeLabel,
+ );
+ expect(
+ projectResourceCardPreviewReadsContent(resource),
+ `${entry.path} 是否能读字节`,
+ ).toBe(entry.readsContent);
+ }
+ });
+
+ it('模型走 model 读取分支、图片容器走 art,二进制容器不读字节', () => {
+ expect(
+ projectResourceMediaPreviewCategory({
+ id: 'glb',
+ path: 'assets/model/hero.glb',
+ mediaType: 'model/gltf-binary',
+ subtype: 'scene',
+ }),
+ ).toBe('model');
+ expect(
+ projectResourceMediaPreviewCategory({
+ id: 'tga',
+ path: 'assets/tex/grass.tga',
+ mediaType: 'image/x-tga',
+ subtype: 'image',
+ }),
+ ).toBe('art');
+ });
+});
+
+describe('Cocos 序列化资源的结构摘要', () => {
+ it('从序列化数组里抽出根类型、节点数、时长与资源引用', () => {
+ const summary = projectResourceCocosStructureSummary(
+ JSON.stringify([
+ { __type__: 'cc.AnimationClip', _name: 'walk', _duration: 1.25 },
+ { __type__: 'cc.Node', _name: 'root' },
+ { __type__: 'cc.Node', _name: 'child' },
+ { __uuid__: 'abc' },
+ ]),
+ );
+ expect(summary).toContain('cc.AnimationClip');
+ expect(summary).toContain('2 个节点');
+ expect(summary).toContain('1.25 秒');
+ expect(summary).toContain('walk');
+ expect(summary).toContain('1 处资源引用');
+ });
+
+ it('不是序列化数组时不做结构摘要,回退到前几行文本', () => {
+ const effect = 'CCEffect %{\n techniques: []\n}';
+ expect(projectResourceCocosStructureSummary(effect)).toBeNull();
+ expect(projectResourceStructuredPreviewText(effect)).toContain('CCEffect');
+ });
+
+ it('二进制变体(原生读不到正文)返回空串,卡面画类型卡', () => {
+ expect(projectResourceStructuredPreviewText(undefined)).toBe('');
+ });
+});
+
+describe('模型卡渲染失败时的降级', () => {
+ it('缩略图缓存键按稳定身份计算,不随 blob URL 变化', () => {
+ const identity = resourceModelThumbnailIdentity({
+ resourceKey: 'asset:hero',
+ path: 'assets/model/hero.glb',
+ byteLen: 1024,
+ });
+ const first = resourceModelThumbnailCacheKey({
+ mediaType: 'model/gltf-binary',
+ identity,
+ width: 320,
+ height: 240,
+ });
+ const second = resourceModelThumbnailCacheKey({
+ mediaType: 'model/gltf-binary',
+ identity,
+ width: 320,
+ height: 240,
+ });
+ expect(first).toBe(second);
+ // 同一份资源换了内容(字节数变化)或换了尺寸,都必须重新渲染。
+ expect(
+ resourceModelThumbnailCacheKey({
+ mediaType: 'model/gltf-binary',
+ identity: resourceModelThumbnailIdentity({
+ resourceKey: 'asset:hero',
+ path: 'assets/model/hero.glb',
+ byteLen: 2048,
+ }),
+ width: 320,
+ height: 240,
+ }),
+ ).not.toBe(first);
+ expect(
+ resourceModelThumbnailCacheKey({
+ mediaType: 'model/gltf-binary',
+ identity,
+ width: 480,
+ height: 360,
+ }),
+ ).not.toBe(first);
+ });
+
+ it('渲染不出缩略图时显示类型卡,不冒泡成预览失败', async () => {
+ // jsdom 没有 WebGL:这正是"渲染不可用"的真实路径,用来钉住降级行为。
+ render(
+ ,
+ );
+ expect(screen.getByText('模型 · GLB')).toBeDefined();
+ await waitFor(() => {
+ expect(
+ document.querySelector('[data-model-preview-status="failed"]'),
+ ).not.toBeNull();
+ });
+ });
+});
+
+describe('模型放大预览浮层', () => {
+ const modelResource: ProjectResource = {
+ id: 'asset:model',
+ path: 'assets/model/cube.glb',
+ label: 'cube.glb',
+ mediaType: 'model/gltf-binary',
+ category: 'scene',
+ subtype: 'scene',
+ manifestAssetId: 'asset:model',
+ sourceLabel: '',
+ taskTitle: null,
+ producerTaskId: null,
+ externalResourceId: null,
+ referenceResourceIds: [],
+ dependencies: [],
+ dependencyDepth: 0,
+ };
+
+ it('打开即按详情理由请求字节,并给出与建模软件一致的视角操作提示', () => {
+ const onRequestPreview = vi.fn();
+ render(
+ undefined}
+ />,
+ );
+ expect(onRequestPreview).toHaveBeenCalledWith(
+ modelResource,
+ 'asset:model|assets/model/cube.glb|1548',
+ 'detail',
+ );
+ expect(screen.getByRole('status').textContent).toContain('正在加载模型');
+ expect(document.body.textContent).toContain('左键拖拽旋转');
+ expect(document.body.textContent).toContain('滚轮缩放');
+ expect(document.body.textContent).toContain('复位视角');
+ });
+
+ it('渲染器不可用时明确说明,不假装已经渲染出模型', async () => {
+ // jsdom 没有 WebGL:这正是「无法渲染」的真实路径。
+ render(
+ undefined}
+ onClose={() => undefined}
+ />,
+ );
+ await waitFor(() => {
+ expect(
+ document.querySelector('[data-model-viewer-status="failed"]'),
+ ).not.toBeNull();
+ });
+ expect(document.body.textContent).toContain('无法渲染三维预览');
+ });
+
+ it('预览失败时给出错误与重试,不留下空白浮层', () => {
+ render(
+ undefined}
+ onClose={() => undefined}
+ />,
+ );
+ expect(screen.getByRole('alert').textContent).toContain('模型预览只支持');
+ expect(screen.getByText('重试')).toBeDefined();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts
index c89d79e99..00a6bd53e 100644
--- a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts
+++ b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts
@@ -40,19 +40,21 @@ describe('resourceDocumentPreviewMarkdown', () => {
['game/main.ts', 'typescript'],
['game/main.js', 'javascript'],
['game/main.py', 'python'],
+ ['assets/data.json', 'json'],
+ ['assets/DESIGN.JSON', 'json'],
])('%s 包为 %s 代码块', (path, language) => {
expect(
resourceDocumentPreviewMarkdown(resource(path), ' source\n\n\n'),
).toBe(`\`\`\`${language}\n source\n\n\n\`\`\``);
});
- it('JSON 规格按文档原文预览,不包成代码块', () => {
+ it('JSON 规格按原文代码块预览,不把字段值当 Markdown', () => {
expect(
resourceDocumentPreviewMarkdown(
resource('assets/data.json'),
- ' source\n\n\n',
+ '{ "text": "# 不应成为标题" }\n',
),
- ).toBe(' source\n\n\n');
+ ).toBe('```json\n{ "text": "# 不应成为标题" }\n```');
});
it('正文包含 Markdown 围栏时不会逃出代码块', () => {
diff --git a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx
new file mode 100644
index 000000000..7e04d094e
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx
@@ -0,0 +1,205 @@
+/** @vitest-environment jsdom */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ createGameCreationAppManifest,
+ fireEvent,
+ installResizeObserverStub,
+ ProjectDevelopmentView,
+ React,
+ render,
+ screen,
+ waitFor,
+ within,
+} from './appSurface/harness';
+
+vi.mock('@tauri-apps/api/core', async () => ({
+ ...(await vi.importActual(
+ '@tauri-apps/api/core',
+ )),
+ invoke: (command: string, args?: Record) =>
+ window.__TAURI__!.core.invoke(command, args),
+}));
+
+const projectId = 'json-canvas';
+const projectPath = '/tmp/json-canvas';
+const emptyState = {
+ ui_trees: [],
+ ui_design_images: {},
+ sprite_assets: {},
+ font_assets: {},
+};
+
+async function mountJsonCanvas(kind = 'ui', rejectPreview = false) {
+ installResizeObserverStub();
+ const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试');
+ manifest.assets = [
+ { id: 'design', kind, localPath: 'ui/design.json' },
+ { id: 'ordinary', kind: 'UI', localPath: 'assets/ordinary.json' },
+ { id: 'spoof', kind: 'UI', localPath: 'ui/spoof.json' },
+ ].map((asset) => ({
+ ...asset,
+ mediaType: 'application/json',
+ category: 'document' as const,
+ source: { kind: 'generated' as const },
+ }));
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'read_local_project_resource_graph') {
+ const ids = (args?.resources as Array<{ resourceId: string }>).map(
+ (item) => item.resourceId,
+ );
+ return {
+ resourceIds: ids,
+ referenceEdges: [],
+ taskFlows: [],
+ producerAssignments: [],
+ dependencyDepths: ids.map((resourceId) => ({
+ resourceId,
+ dependencyDepth: 0,
+ })),
+ connectionIndex: ids.map((resourceId) => ({
+ resourceId,
+ upstreamReferenceResourceIds: [],
+ downstreamReferenceResourceIds: [],
+ referenceEdgeIds: [],
+ taskFlowIds: [],
+ })),
+ unresolvedReferenceResourceIds: [],
+ cyclicResourceIds: [],
+ cyclicTaskIds: [],
+ producerMappingTruncated: false,
+ };
+ }
+ if (
+ command === 'read_local_project_resource_canvas_layout' ||
+ command === 'update_local_project_resource_canvas_layout'
+ ) {
+ const layout = {
+ schemaVersion: 'game-creator-resource-layout.v1',
+ projectId,
+ mode: args?.mode,
+ revision: Number(args?.expectedRevision ?? 0) + 1,
+ positions: args?.positions ?? [],
+ updatedAt: 1,
+ };
+ return command.startsWith('update_')
+ ? { status: 'updated', layout }
+ : layout;
+ }
+ if (command === 'read_local_project_text_preview') {
+ if (rejectPreview) throw new Error('文档读取失败');
+ const path = args?.relativePath;
+ return {
+ path,
+ mediaType: 'application/json',
+ byteLen: 20,
+ content:
+ path === 'assets/ordinary.json'
+ ? '{"text":"# 不应当作标题"}'
+ : JSON.stringify({
+ schemaVersion: 'game-creator-ui-design-state.v1',
+ state: emptyState,
+ }),
+ ...(path === 'ui/design.json' ? { uiDesignAssetId: 'design' } : {}),
+ };
+ }
+ if (command === 'load_ui_design_state')
+ return { revision: 0, state: emptyState };
+ if (
+ command === 'list_pending_local_project_resource_edits' ||
+ command === 'list_local_project_asset_generations'
+ )
+ return [];
+ throw new Error(`unexpected command ${command}`);
+ },
+ );
+ window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
+ const onManifestChange = vi.fn();
+ render(
+ 对话 }
+ onManifestChange={onManifestChange}
+ />,
+ );
+ fireEvent.click(await screen.findByRole('button', { name: '打开文档' }));
+ const card = (id: string) =>
+ document.querySelector(
+ `.game-resource-book-scene-card.is-expanded [data-resource-card-id="asset:${id}"]`,
+ )!;
+ await waitFor(() =>
+ expect(card('design')?.getAttribute('data-preview-status')).toBe(
+ rejectPreview ? 'failed' : 'loaded',
+ ),
+ );
+ return { card, invoke, onManifestChange };
+}
+
+afterEach(() => {
+ delete window.__TAURI__;
+});
+
+describe('JSON 画布卡片与入口', () => {
+ it.each(['ui', 'document'])(
+ '合法 State 的 %s 登记显示 UI 设计,进入现有编辑器且不修改 manifest',
+ async (kind) => {
+ const { card, invoke, onManifestChange } = await mountJsonCanvas(kind);
+ const design = card('design');
+ expect(design.getAttribute('data-json-presentation')).toBe('ui-design');
+ expect(within(design).getByText('UI 设计')).not.toBeNull();
+ expect(within(design).getByText('UI 编辑器')).not.toBeNull();
+ expect(design.textContent).not.toContain('schemaVersion');
+ fireEvent.click(design.querySelector('button')!);
+ fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' }));
+ await waitFor(() =>
+ expect(invoke).toHaveBeenCalledWith('load_ui_design_state', {
+ input: {
+ projectPath,
+ expectedProjectId: projectId,
+ assetId: 'design',
+ },
+ }),
+ );
+ expect(onManifestChange).not.toHaveBeenCalled();
+ },
+ );
+
+ it('普通 JSON 和伪 UI 文本保持 JSON 展示,无编辑器入口,详情按代码渲染', async () => {
+ const { card } = await mountJsonCanvas();
+ for (const id of ['ordinary', 'spoof']) {
+ const element = card(id);
+ expect(element.getAttribute('data-json-presentation')).toBe('json');
+ expect(
+ element.querySelector('[data-resource-type="JSON"]'),
+ ).not.toBeNull();
+ expect(element.textContent).not.toContain('schemaVersion');
+ fireEvent.click(element.querySelector('button')!);
+ expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull();
+ }
+ fireEvent.click(card('ordinary').querySelector('button')!);
+ fireEvent.click(await screen.findByRole('button', { name: '预览' }));
+ const dialog = await screen.findByRole('dialog', { name: '文档预览' });
+ await waitFor(() =>
+ expect(dialog.querySelector('pre code')?.textContent).toContain(
+ '{"text":"# 不应当作标题"}',
+ ),
+ );
+ expect(
+ within(dialog).queryByRole('heading', { name: '不应当作标题' }),
+ ).toBeNull();
+ });
+
+ it('JSON 读取失败不授予编辑入口,仍可查看明确的读取错误', async () => {
+ const { card } = await mountJsonCanvas('UI', true);
+ fireEvent.click(card('design').querySelector('button')!);
+ expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: '预览' }));
+ expect(await screen.findByRole('alert')).not.toBeNull();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts
new file mode 100644
index 000000000..ecc685ed7
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ type ProjectResourceCardPreviewState,
+ projectResourceJsonPresentation,
+} from '../src/view/project-development/resourceCardPreviewModel';
+import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
+
+const resource: ProjectResource = {
+ id: 'asset:json',
+ manifestAssetId: 'json',
+ path: 'ui/design.json',
+ label: '设计',
+ mediaType: 'application/json',
+ subtype: 'UI',
+ category: 'document',
+ sourceLabel: '',
+ taskTitle: null,
+ producerTaskId: null,
+ externalResourceId: null,
+ referenceResourceIds: [],
+ dependencies: [],
+ dependencyDepth: 0,
+};
+const verified: ProjectResourceCardPreviewState = {
+ status: 'loaded',
+ preview: {
+ path: resource.path,
+ mediaType: 'application/json',
+ byteLen: 2,
+ content: '{}',
+ uiDesignAssetId: 'json',
+ },
+};
+
+describe('JSON 卡片呈现', () => {
+ it.each(['UI', 'ui', 'ui-design', 'document'])(
+ '合法识别不依赖 %s 标签',
+ (subtype) => {
+ expect(
+ projectResourceJsonPresentation({ ...resource, subtype }, verified),
+ ).toBe('ui-design');
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, subtype },
+ {
+ ...verified,
+ preview: { ...verified.preview, uiDesignAssetId: undefined },
+ },
+ ),
+ ).toBe('json');
+ },
+ );
+ it('原生读取未完成、失败或身份不匹配时保持普通 JSON,不推断编辑能力', () => {
+ for (const preview of [
+ null,
+ { status: 'loading' },
+ { status: 'failed', error: '读取失败', retryable: true },
+ ] as const) {
+ expect(projectResourceJsonPresentation(resource, preview)).toBe('json');
+ }
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, manifestAssetId: 'other' },
+ verified,
+ ),
+ ).toBe('json');
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, path: 'other.json' },
+ verified,
+ ),
+ ).toBe('json');
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, manifestAssetId: null },
+ verified,
+ ),
+ ).toBe('json');
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, path: 'image.png', mediaType: 'image/png' },
+ verified,
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx
index 380692d66..e8f2b5e4b 100644
--- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx
@@ -375,6 +375,16 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
return { invoke, onActiveVersionChange, onPlay, onManifestChange };
}
+function toolbarAction(toolbar: HTMLElement, name: string) {
+ const visible = within(toolbar).queryByRole('button', { name });
+ if (visible) return visible;
+ fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
+ return within(screen.getByRole('group', { name: '更多操作' })).getByRole(
+ 'button',
+ { name },
+ );
+}
+
async function selectCardAndOpenToolbar(label: string) {
await waitFor(() =>
expect(
@@ -538,14 +548,80 @@ function installResourceCardIntersectionObserver() {
}
describe('版本级资源替换', () => {
+ it('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => {
+ renderReplacementWorkbench();
+ const toolbar = await selectCardAndOpenToolbar('legacy.png');
+ fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
+ const menu = screen.getByRole('group', { name: '更多操作' });
+ const viewport = () =>
+ document
+ .querySelector('[data-resource-viewport]')
+ ?.getAttribute('data-resource-viewport');
+ const before = viewport();
+ expect(before).toBeTruthy();
+ const wheel = new WheelEvent('wheel', {
+ bubbles: true,
+ cancelable: true,
+ deltaY: 120,
+ });
+ act(() => {
+ menu.dispatchEvent(wheel);
+ });
+ expect(wheel.defaultPrevented).toBe(false);
+ expect(viewport()).toBe(before);
+
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+ expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
+ expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar);
+ fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
+ fireEvent.click(await findResourceSelectButton('late.png'));
+ expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
+
+ const scene = document.querySelector('.game-resource-book-scene')!;
+ act(() => {
+ scene.dispatchEvent(
+ new WheelEvent('wheel', {
+ bubbles: true,
+ cancelable: true,
+ deltaY: 120,
+ clientX: 90,
+ clientY: 70,
+ }),
+ );
+ });
+ expect(viewport()).not.toBe(before);
+ });
+
+ it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => {
+ renderReplacementWorkbench();
+ await selectCardAndOpenToolbar('legacy.png');
+ const lateInfo = screen.getByRole('button', {
+ name: '查看late.png资源信息',
+ });
+ fireEvent.pointerDown(lateInfo, { button: 0 });
+ fireEvent.click(lateInfo);
+ const panel = screen.getByRole('dialog', { name: '资源信息' });
+ expect(within(panel).getByText('late.png')).toBeTruthy();
+ expect(lateInfo.getAttribute('aria-pressed')).toBe('true');
+ fireEvent.click(
+ screen.getByRole('button', { name: '查看legacy.png资源信息' }),
+ );
+ const switched = screen.getByRole('dialog', { name: '资源信息' });
+ expect(within(switched).getByText('legacy.png')).toBeTruthy();
+ expect(within(switched).queryByText('late.png')).toBeNull();
+ fireEvent.click(await findResourceSelectButton('late.png'));
+ expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
+ });
+
it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => {
const { invoke } = renderReplacementWorkbench();
// 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。
const lateToolbar = await selectCardAndOpenToolbar('late.png');
- expect(
- within(lateToolbar).queryByRole('button', { name: '替换素材' }),
- ).toBeNull();
+ fireEvent.mouseEnter(
+ within(lateToolbar).getByRole('button', { name: '更多' }),
+ );
+ expect(screen.queryByRole('button', { name: '替换素材' })).toBeNull();
expect(
within(lateToolbar).getByRole('button', { name: '快速编辑' }),
).not.toBeNull();
@@ -559,9 +635,7 @@ describe('版本级资源替换', () => {
// 被当前版本绑定的素材:入口出现。
const sourceToolbar = await selectCardAndOpenToolbar('legacy.png');
- expect(
- within(sourceToolbar).getByRole('button', { name: '替换素材' }),
- ).not.toBeNull();
+ expect(toolbarAction(sourceToolbar, '替换素材')).not.toBeNull();
});
it('从入口一路走到写入:候选弹窗禁用硬门禁项、给出格式提示、直接替换且不产生新版本', async () => {
@@ -569,7 +643,7 @@ describe('版本级资源替换', () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
await waitFor(() =>
expect(
@@ -675,7 +749,7 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -709,7 +783,7 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
await waitFor(() =>
expect(
@@ -735,7 +809,7 @@ describe('版本级资源替换', () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -787,7 +861,7 @@ describe('版本级资源替换', () => {
).length;
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -837,9 +911,13 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- const toolbarLabels = within(toolbar)
- .getAllByRole('button')
- .map((button) => button.getAttribute('aria-label') ?? '');
+ const deleteButton = toolbarAction(toolbar, '删除素材');
+ const toolbarLabels = [
+ ...within(toolbar).getAllByRole('button'),
+ ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole(
+ 'button',
+ ),
+ ].map((button) => button.getAttribute('aria-label') ?? '');
// 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。
expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan(
toolbarLabels.indexOf('替换素材'),
@@ -848,9 +926,6 @@ describe('版本级资源替换', () => {
toolbarLabels.indexOf('导出'),
);
// 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。
- const deleteButton = within(toolbar).getByRole('button', {
- name: '删除素材',
- });
const divider = deleteButton.previousElementSibling;
expect(divider?.getAttribute('class')).toMatch(
/(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/,
@@ -914,7 +989,7 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' }));
+ fireEvent.click(toolbarAction(toolbar, '删除素材'));
const dialog = await screen.findByRole('dialog', { name: '确认删除资源' });
fireEvent.click(
within(dialog).getByRole('checkbox', {
@@ -976,7 +1051,7 @@ describe('版本级资源替换', () => {
).toBeNull();
// 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。
expect(
- within(toolbar).getByRole('button', { name: '信息' }),
+ screen.getByRole('button', { name: '查看草稿.png资源信息' }),
).not.toBeNull();
});
@@ -1056,7 +1131,7 @@ describe('版本级资源替换', () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1084,7 +1159,7 @@ describe('版本级资源替换', () => {
const { invoke, onManifestChange } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1155,7 +1230,7 @@ describe('版本级资源替换', () => {
});
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1238,7 +1313,7 @@ describe('版本级资源替换', () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1274,7 +1349,7 @@ describe('版本级资源替换', () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
+ fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1356,9 +1431,7 @@ describe('版本级资源替换', () => {
// 第一次:legacy → final。
const legacyToolbar = await selectCardAndOpenToolbar('legacy.png');
- fireEvent.click(
- within(legacyToolbar).getByRole('button', { name: '替换素材' }),
- );
+ fireEvent.click(toolbarAction(legacyToolbar, '替换素材'));
let dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
@@ -1376,9 +1449,7 @@ describe('版本级资源替换', () => {
// 第二次:final → final.webp(同一个工作台会话内)。
const finalToolbar = await selectCardAndOpenToolbar('final.png');
- fireEvent.click(
- within(finalToolbar).getByRole('button', { name: '替换素材' }),
- );
+ fireEvent.click(toolbarAction(finalToolbar, '替换素材'));
dialog = await screen.findByRole('dialog', { name: '选择替换素材' });
fireEvent.click(
within(dialog).getByRole('option', { name: '选择替换素材final.webp' }),
diff --git a/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts
new file mode 100644
index 000000000..2ddf16993
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildTemplateRows,
+ computeTemplateGridColumns,
+ computeTemplateGridLayout,
+ computeTemplateRowHeight,
+ TEMPLATE_CARD_GAP,
+ TEMPLATE_CARD_MIN_WIDTH,
+ TEMPLATE_CARD_TEXT_HEIGHT,
+} from '../src/features/template-library/templateLibraryGrid';
+import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel';
+
+function entry(id: string): GameTemplateEntry {
+ return {
+ id,
+ title: id,
+ summary: '',
+ tags: [],
+ runtime: 'html',
+ engine: 'phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '0.1.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'game/index.html',
+ zipUrl: `https://oss.example/templates/v1/${id}/template.zip`,
+ zipSizeBytes: 1024,
+ zipSha256: 'a'.repeat(64),
+ coverUrl: `https://oss.example/templates/v1/${id}/cover.svg`,
+ coverWidth: 960,
+ coverHeight: 540,
+ installed: false,
+ installedVersion: null,
+ installedAtMillis: null,
+ };
+}
+
+describe('computeTemplateGridColumns', () => {
+ it('fits as many min-width columns as the container allows', () => {
+ expect(computeTemplateGridColumns(0)).toBe(1);
+ expect(computeTemplateGridColumns(200)).toBe(1);
+ expect(computeTemplateGridColumns(TEMPLATE_CARD_MIN_WIDTH)).toBe(1);
+ // 两列边界:2*250 + 14 = 514
+ const twoColumnWidth = 2 * TEMPLATE_CARD_MIN_WIDTH + TEMPLATE_CARD_GAP;
+ expect(computeTemplateGridColumns(twoColumnWidth)).toBe(2);
+ expect(computeTemplateGridColumns(twoColumnWidth - 1)).toBe(1);
+ // 1200px:4 列((1200+14)/(250+14) = 4.59)
+ expect(computeTemplateGridColumns(1200)).toBe(4);
+ });
+});
+
+describe('computeTemplateRowHeight', () => {
+ it('keeps cover ratio + fixed text block', () => {
+ // 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161
+ expect(computeTemplateRowHeight(300)).toBe(
+ 161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
+ );
+ // 极窄时按最小卡片宽度兜底,避免行高被压成 0
+ expect(computeTemplateRowHeight(10)).toBeGreaterThan(
+ TEMPLATE_CARD_TEXT_HEIGHT,
+ );
+ });
+});
+
+describe('computeTemplateGridLayout', () => {
+ it('derives columns, row height and row count for a big library', () => {
+ const layout = computeTemplateGridLayout({
+ containerWidth: 1200,
+ itemCount: 1000,
+ });
+ expect(layout.columnCount).toBe(4);
+ expect(layout.columnWidth).toBe(300);
+ expect(layout.rowHeight).toBe(
+ 161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
+ );
+ expect(layout.rowCount).toBe(250);
+ // 虚拟列表只渲染可视行,滚动高度仍由总行数决定
+ expect(layout.rowCount * layout.rowHeight).toBeGreaterThan(80000);
+ });
+
+ it('handles inline and empty libraries', () => {
+ expect(
+ computeTemplateGridLayout({ containerWidth: 0, itemCount: 5 }),
+ ).toEqual({
+ columnCount: 1,
+ columnWidth: 1,
+ rowHeight: computeTemplateRowHeight(1),
+ rowCount: 5,
+ });
+ expect(
+ computeTemplateGridLayout({ containerWidth: 1200, itemCount: 0 })
+ .rowCount,
+ ).toBe(0);
+ });
+});
+
+describe('buildTemplateRows', () => {
+ it('chunks entries per row and pads the tail with nulls', () => {
+ const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2);
+ expect(rows).toHaveLength(2);
+ expect(rows[0]?.map((item) => item?.id)).toEqual(['a', 'b']);
+ expect(rows[1]?.map((item) => item?.id ?? null)).toEqual(['c', null]);
+ });
+
+ it('returns no rows for an invalid column count', () => {
+ expect(buildTemplateRows([entry('a')], 0)).toEqual([]);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts
new file mode 100644
index 000000000..5eb52ca01
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ collectGameTemplateRuntimes,
+ collectGameTemplateTags,
+ EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ filterGameTemplates,
+ formatGameTemplateSize,
+ type GameTemplateEntry,
+ isTemplateLibraryFiltersEmpty,
+ needsTemplateDownload,
+ templateMatchesQuery,
+ templateRuntimeLabel,
+ toggleGameTemplateTag,
+} from '../src/features/template-library/templateLibraryModel';
+
+function template(
+ overrides: Partial & Pick,
+): GameTemplateEntry {
+ return {
+ title: '未命名模板',
+ summary: '',
+ tags: [],
+ runtime: 'html',
+ engine: 'phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '1.0.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'game/index.html',
+ zipUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/template.zip',
+ zipSizeBytes: 2048,
+ zipSha256: 'a'.repeat(64),
+ coverUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/cover.png',
+ coverWidth: 960,
+ coverHeight: 540,
+ installed: false,
+ installedVersion: null,
+ installedAtMillis: null,
+ ...overrides,
+ };
+}
+
+const matchThree = template({
+ id: 'match-3',
+ title: '三消经营',
+ summary: '三消与模拟经营的融合模板',
+ tags: ['三消', '经营'],
+ engine: 'phaser',
+ installed: true,
+ installedVersion: '1.0.0',
+});
+const pixelFarm = template({
+ id: 'pixel-farm',
+ title: '像素农场',
+ summary: '像素风种植玩法',
+ tags: ['经营', '像素'],
+ engine: 'godot',
+ runtime: 'godot',
+ templateVersion: '2.0.0',
+ installed: true,
+ installedVersion: '1.0.0',
+});
+const spaceShooter = template({
+ id: 'space-shooter',
+ title: '太空射击',
+ summary: '纵版弹幕射击',
+ tags: ['射击'],
+ runtime: 'unity',
+ engine: 'unity',
+ installed: false,
+});
+
+const templates: GameTemplateEntry[] = [matchThree, pixelFarm, spaceShooter];
+
+describe('templateMatchesQuery', () => {
+ it('matches title, summary, tags and engine case-insensitively', () => {
+ expect(templateMatchesQuery(matchThree, '三消')).toBe(true);
+ expect(templateMatchesQuery(matchThree, '经营')).toBe(true);
+ expect(templateMatchesQuery(matchThree, 'PHASER')).toBe(true);
+ expect(templateMatchesQuery(matchThree, '弹幕')).toBe(false);
+ });
+
+ it('requires every whitespace separated term to match', () => {
+ expect(templateMatchesQuery(pixelFarm, '像素 种植')).toBe(true);
+ expect(templateMatchesQuery(pixelFarm, '像素 弹幕')).toBe(false);
+ expect(templateMatchesQuery(pixelFarm, ' ')).toBe(true);
+ });
+});
+
+describe('filterGameTemplates', () => {
+ it('filters by tag, runtime, query and installed state together', () => {
+ expect(
+ filterGameTemplates(templates, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ tags: ['经营'],
+ }).map((entry) => entry.id),
+ ).toEqual(['match-3', 'pixel-farm']);
+
+ expect(
+ filterGameTemplates(templates, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ runtime: 'godot',
+ }).map((entry) => entry.id),
+ ).toEqual(['pixel-farm']);
+
+ expect(
+ filterGameTemplates(templates, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ installedOnly: true,
+ query: '经营',
+ tags: ['像素'],
+ }).map((entry) => entry.id),
+ ).toEqual(['pixel-farm']);
+ });
+
+ it('returns everything when no filter is active', () => {
+ expect(
+ filterGameTemplates(templates, EMPTY_TEMPLATE_LIBRARY_FILTERS),
+ ).toHaveLength(3);
+ expect(isTemplateLibraryFiltersEmpty(EMPTY_TEMPLATE_LIBRARY_FILTERS)).toBe(
+ true,
+ );
+ expect(
+ isTemplateLibraryFiltersEmpty({
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ query: ' x ',
+ }),
+ ).toBe(false);
+ });
+});
+
+describe('tag and runtime options', () => {
+ it('orders tags by frequency and drops blanks', () => {
+ const withBlank = [
+ ...templates,
+ template({ id: 'blank-tag', tags: ['', ' ', '经营'] }),
+ ];
+ expect(collectGameTemplateTags(withBlank)).toEqual([
+ '经营',
+ '三消',
+ '射击',
+ '像素',
+ ]);
+ });
+
+ it('collects distinct runtimes and labels them', () => {
+ expect(collectGameTemplateRuntimes(templates)).toEqual([
+ 'godot',
+ 'html',
+ 'unity',
+ ]);
+ expect(templateRuntimeLabel('html')).toBe('网页');
+ expect(templateRuntimeLabel('cocos')).toBe('Cocos');
+ expect(templateRuntimeLabel('')).toBe('未标注运行时');
+ expect(templateRuntimeLabel('custom-engine')).toBe('custom-engine');
+ });
+
+ it('toggles tags without mutating the previous filters', () => {
+ const next = toggleGameTemplateTag(EMPTY_TEMPLATE_LIBRARY_FILTERS, '经营');
+ expect(next.tags).toEqual(['经营']);
+ expect(toggleGameTemplateTag(next, '经营').tags).toEqual([]);
+ expect(EMPTY_TEMPLATE_LIBRARY_FILTERS.tags).toEqual([]);
+ });
+});
+
+describe('needsTemplateDownload', () => {
+ it('requires a download when missing or when the installed version is stale', () => {
+ expect(needsTemplateDownload(matchThree)).toBe(false);
+ expect(needsTemplateDownload(pixelFarm)).toBe(true);
+ expect(needsTemplateDownload(spaceShooter)).toBe(true);
+ });
+});
+
+describe('formatGameTemplateSize', () => {
+ it('formats bytes, kilobytes and megabytes', () => {
+ expect(formatGameTemplateSize(0)).toBe('--');
+ expect(formatGameTemplateSize(512)).toBe('512 B');
+ expect(formatGameTemplateSize(2048)).toBe('2.0 KB');
+ expect(formatGameTemplateSize(5 * 1024 * 1024)).toBe('5.0 MB');
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
new file mode 100644
index 000000000..4182d8c1e
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
@@ -0,0 +1,494 @@
+// @vitest-environment jsdom
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { computeTemplateGridLayout } from '../src/features/template-library/templateLibraryGrid';
+import type {
+ GameTemplateEntry,
+ TemplateLibraryFilters,
+} from '../src/features/template-library/templateLibraryModel';
+import {
+ collectGameTemplateTags,
+ EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ filterGameTemplates,
+} from '../src/features/template-library/templateLibraryModel';
+import type { TemplateLibraryController } from '../src/features/template-library/useTemplateLibrary';
+import TemplateRecommendations from '../src/view/home/TemplateRecommendations';
+import TemplateLibraryView from '../src/view/template-library';
+
+const OSS_BASE = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com';
+
+function template(
+ overrides: Partial & Pick,
+): GameTemplateEntry {
+ return {
+ title: '未命名模板',
+ summary: '',
+ tags: [],
+ runtime: 'html',
+ engine: 'phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '0.1.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'game/index.html',
+ zipUrl: `${OSS_BASE}/templates/v1/${overrides.id}/template.zip`,
+ zipSizeBytes: 2048,
+ zipSha256: 'a'.repeat(64),
+ coverUrl: `${OSS_BASE}/templates/v1/${overrides.id}/cover.svg`,
+ coverWidth: 960,
+ coverHeight: 540,
+ installed: false,
+ installedVersion: null,
+ installedAtMillis: null,
+ ...overrides,
+ };
+}
+
+const blankWeb = template({
+ id: 'blank-web',
+ title: '空白网页工程',
+ summary: '零依赖最小网页工程',
+ tags: ['空白', '网页'],
+ engine: 'none',
+ engineVersion: '',
+ installed: true,
+ installedVersion: '0.1.0',
+ installedAtMillis: 1789000000000,
+});
+const blankCanvas = template({
+ id: 'blank-2d-canvas',
+ title: '空白二维画布工程',
+ tags: ['空白', '2d', 'canvas'],
+ engine: 'canvas',
+ engineVersion: '',
+});
+const templates = [blankCanvas, blankWeb];
+
+function controller(
+ overrides: Partial = {},
+): TemplateLibraryController {
+ const filters: TemplateLibraryFilters = {
+ query: '',
+ tags: [],
+ runtime: '',
+ installedOnly: false,
+ };
+ return {
+ snapshot: null,
+ status: 'ready',
+ error: '',
+ notice: '',
+ templates,
+ visibleTemplates: templates,
+ tagOptions: ['空白', '2d', 'canvas', '网页'],
+ runtimeOptions: ['html'],
+ installedCount: 1,
+ filters,
+ filtersActive: false,
+ setQuery: vi.fn(),
+ selectRuntime: vi.fn(),
+ toggleTag: vi.fn(),
+ setInstalledOnly: vi.fn(),
+ clearFilters: vi.fn(),
+ busyTemplateId: null,
+ busyKind: null,
+ refresh: vi.fn(),
+ downloadTemplate: vi.fn(async () => undefined),
+ createProjectFromTemplate: vi.fn(async () => undefined),
+ clearNotice: vi.fn(),
+ ...overrides,
+ } as unknown as TemplateLibraryController;
+}
+
+function cardFor(title: string): HTMLElement {
+ const heading = screen.getByText(title);
+ const card = heading.closest('article');
+ if (!card) throw new Error(`找不到卡片:${title}`);
+ return card;
+}
+
+// 虚拟列表需要可测量的视口:jsdom 没有布局,这里给网格容器固定尺寸并补 ResizeObserver/scrollTo。
+const GRID_VIEWPORT = { width: 1200, height: 800 };
+
+beforeAll(() => {
+ const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
+ Element.prototype.getBoundingClientRect = function getBoundingClientRect() {
+ const element = this as HTMLElement;
+ if (element.dataset?.templateGridViewport === 'true') {
+ return {
+ width: GRID_VIEWPORT.width,
+ height: GRID_VIEWPORT.height,
+ top: 0,
+ left: 0,
+ right: GRID_VIEWPORT.width,
+ bottom: GRID_VIEWPORT.height,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ } as DOMRect;
+ }
+ return originalGetBoundingClientRect.call(this);
+ };
+ if (typeof Element.prototype.scrollTo !== 'function') {
+ Element.prototype.scrollTo = () => undefined;
+ }
+ class ResizeObserverStub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub);
+});
+
+describe('TemplateLibraryView', () => {
+ it('renders a card per template with cover, meta, tags and installed badge', () => {
+ render( {}} />);
+
+ expect(screen.getByRole('heading', { name: '模板库' })).toBeTruthy();
+ expect(screen.getByText('共 2 个模板 · 已下载 1 个')).toBeTruthy();
+
+ const blankWebCard = cardFor('空白网页工程');
+ const cover = blankWebCard.querySelector('img');
+ expect(cover?.getAttribute('src')).toBe(
+ `${OSS_BASE}/templates/v1/blank-web/cover.svg`,
+ );
+ expect(blankWebCard.textContent).toContain('已下载');
+ expect(blankWebCard.textContent).toContain('网页 · none · v0.1.0 · 2.0 KB');
+ expect(blankWebCard.textContent).toContain('空白');
+ expect(blankWebCard.textContent).toContain('零依赖最小网页工程');
+ // 已下载且版本一致:不再显示下载入口,只留「使用模板」。
+ expect(
+ within(blankWebCard).queryByRole('button', { name: /下载/ }),
+ ).toBeNull();
+ expect(
+ within(blankWebCard).getByRole('button', { name: /使用模板/ }),
+ ).toBeTruthy();
+
+ const blankCanvasCard = cardFor('空白二维画布工程');
+ expect(blankCanvasCard.textContent).not.toContain('已下载');
+ expect(blankCanvasCard.querySelector('img')?.getAttribute('src')).toBe(
+ `${OSS_BASE}/templates/v1/blank-2d-canvas/cover.svg`,
+ );
+ expect(
+ within(blankCanvasCard).getByRole('button', { name: /下载/ }),
+ ).toBeTruthy();
+ });
+
+ it('keeps the launcher theme contract and owns its own scroll viewport', () => {
+ // 回归点:`.launcher-main` 只给带 platform-theme 的直接子元素 height:100%;
+ // 卡片列表改由虚拟网格自己的视口滚动(整页不再随模板数量变长)。
+ const { container } = render(
+ {}} />,
+ );
+ const page = container.querySelector('section[aria-label="模板库"]');
+ expect(page?.className).toContain('platform-theme');
+ const viewport = container.querySelector(
+ '[data-template-grid-viewport="true"]',
+ );
+ expect(viewport).not.toBeNull();
+ expect(viewport?.querySelector('article')).not.toBeNull();
+ });
+
+ it('offers 更新 instead of 下载 when the installed version is stale', () => {
+ const stale = template({
+ id: 'blank-web',
+ title: '空白网页工程',
+ summary: '零依赖最小网页工程',
+ tags: ['空白', '网页'],
+ installed: true,
+ installedVersion: '0.0.9',
+ installedAtMillis: 1789000000000,
+ });
+ render(
+ {}}
+ />,
+ );
+
+ const card = cardFor('空白网页工程');
+ expect(within(card).getByRole('button', { name: /更新/ })).toBeTruthy();
+ expect(within(card).queryByRole('button', { name: /^下载/ })).toBeNull();
+ });
+
+ it('routes search, tag, runtime and installed-only controls through the controller', () => {
+ const setQuery = vi.fn();
+ const toggleTag = vi.fn();
+ const selectRuntime = vi.fn();
+ const setInstalledOnly = vi.fn();
+ const clearFilters = vi.fn();
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.change(screen.getByLabelText('搜索模板'), {
+ target: { value: '空白 网页' },
+ });
+ expect(setQuery).toHaveBeenCalledWith('空白 网页');
+
+ fireEvent.click(screen.getByRole('button', { name: '标签筛选 canvas' }));
+ expect(toggleTag).toHaveBeenCalledWith('canvas');
+
+ fireEvent.click(screen.getByRole('button', { name: '运行时筛选 网页' }));
+ expect(selectRuntime).toHaveBeenCalledWith('html');
+
+ fireEvent.click(screen.getByRole('button', { name: '仅看已下载' }));
+ expect(setInstalledOnly).toHaveBeenCalledWith(true);
+
+ fireEvent.click(screen.getByRole('button', { name: '清除筛选' }));
+ expect(clearFilters).toHaveBeenCalled();
+ });
+
+ it('starts a download and a template project from the card actions', () => {
+ const downloadTemplate = vi.fn(async () => undefined);
+ const createProjectFromTemplate = vi.fn(async () => undefined);
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.click(
+ within(cardFor('空白二维画布工程')).getByRole('button', { name: /下载/ }),
+ );
+ expect(downloadTemplate).toHaveBeenCalledWith(blankCanvas);
+
+ fireEvent.click(
+ within(cardFor('空白二维画布工程')).getByRole('button', {
+ name: /使用模板/,
+ }),
+ );
+ expect(createProjectFromTemplate).toHaveBeenCalledWith(blankCanvas);
+ });
+
+ it('disables card actions while a template is busy', () => {
+ render(
+ {}}
+ />,
+ );
+
+ const busyCard = cardFor('空白二维画布工程');
+ const buttons = Array.from(busyCard.querySelectorAll('button'));
+ expect(buttons.every((button) => button.hasAttribute('disabled'))).toBe(
+ true,
+ );
+ expect(busyCard.textContent).toContain('正在创建项目');
+ expect(cardFor('空白网页工程').textContent).not.toContain('正在创建项目');
+ });
+
+ it('shows empty, no-match, error and notice states', () => {
+ const { unmount } = render(
+ {}}
+ />,
+ );
+ expect(screen.getByText('模板库暂时还没有可用的模板。')).toBeTruthy();
+ unmount();
+
+ const { unmount: unmountNoMatch } = render(
+ {}}
+ />,
+ );
+ expect(screen.getByText('没有符合当前筛选的模板')).toBeTruthy();
+ unmountNoMatch();
+
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByRole('alert').textContent).toContain(
+ '模板库返回 HTTP 503',
+ );
+ expect(
+ screen.getByText('远端清单暂时读不到,当前展示本机缓存'),
+ ).toBeTruthy();
+ });
+
+ it('renders process notices as a floating toast outside the page and auto-dismisses it', () => {
+ vi.useFakeTimers();
+ const clearNotice = vi.fn();
+ try {
+ const { container } = render(
+ {}}
+ />,
+ );
+
+ const toast = document.body.querySelector(
+ '[data-template-library-toast="true"]',
+ );
+ expect(toast).not.toBeNull();
+ expect(toast?.textContent).toContain('已下载模板「空白网页工程」');
+ expect(toast?.querySelector('[role="status"]')?.textContent).toContain(
+ '已下载模板「空白网页工程」',
+ );
+ // 提示不再占用页面内位置。
+ expect(
+ container.querySelector('[data-template-library-toast]'),
+ ).toBeNull();
+
+ vi.advanceTimersByTime(2600);
+ expect(clearNotice).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe('大库量渲染(1000 条假数据)', () => {
+ const bulk = Array.from({ length: 1000 }, (_, index) =>
+ template({
+ id: `bulk-${index}`,
+ title: `批量模板 ${index}`,
+ summary: '压测条目',
+ tags: ['起步工程', `批次-${String(index % 20).padStart(2, '0')}`],
+ installed: index % 3 === 0,
+ installedVersion: index % 3 === 0 ? '0.1.0' : null,
+ }),
+ );
+
+ it('virtualizes a 1000 entries library instead of rendering every card', () => {
+ const { container } = render(
+ entry.installed).length,
+ tagOptions: collectGameTemplateTags(bulk),
+ })}
+ onBack={() => {}}
+ />,
+ );
+
+ // 虚拟列表只渲染可视区域(1200×800 视口 → 4 列 × 约 3 行 + 2 行 overscan)。
+ const renderedCards = container.querySelectorAll('article').length;
+ expect(renderedCards).toBeGreaterThan(0);
+ expect(renderedCards).toBeLessThanOrEqual(40);
+ expect(screen.getByText('共 1000 个模板 · 已下载 334 个')).toBeTruthy();
+ // 滚动高度仍按全部行数计算。
+ const layout = computeTemplateGridLayout({
+ containerWidth: GRID_VIEWPORT.width,
+ itemCount: bulk.length,
+ });
+ expect(layout.columnCount).toBe(4);
+ expect(layout.rowCount).toBe(250);
+ const totalHeight = `${250 * layout.rowHeight}px`;
+ const hasSpacer = Array.from(container.querySelectorAll('div')).some(
+ (element) => (element as HTMLElement).style.height === totalHeight,
+ );
+ expect(hasSpacer).toBe(true);
+ // 标签筛选条会随库量膨胀,这里先记录当前聚合出来的规模(1000 条 × 批次标签)。
+ const tagButtons = screen
+ .getAllByRole('button')
+ .filter((button) =>
+ button.getAttribute('aria-label')?.startsWith('标签筛选'),
+ );
+ expect(tagButtons.length).toBeGreaterThan(20);
+
+ // 纯前端筛选在大库量下仍然是 O(n) 的一遍过滤,数量与已安装态自洽。
+ const installedOnly = filterGameTemplates(bulk, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ installedOnly: true,
+ });
+ expect(installedOnly).toHaveLength(334);
+ expect(
+ filterGameTemplates(bulk, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ tags: ['批次-07'],
+ }),
+ ).toHaveLength(50);
+ expect(
+ filterGameTemplates(bulk, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ query: '批量模板 999',
+ }).map((entry) => entry.id),
+ ).toEqual(['bulk-999']);
+ });
+});
+
+describe('TemplateRecommendations', () => {
+ it('renders the recommended templates and opens the library', () => {
+ const onOpenLibrary = vi.fn();
+ render(
+ ,
+ );
+
+ const recommendation = screen.getByRole('button', {
+ name: '查看模板 空白网页工程',
+ });
+ expect(recommendation.querySelector('img')?.getAttribute('src')).toBe(
+ `${OSS_BASE}/templates/v1/blank-web/cover.svg`,
+ );
+ expect(recommendation.textContent).toContain('已下载');
+ fireEvent.click(recommendation);
+ expect(onOpenLibrary).toHaveBeenCalled();
+ });
+
+ it('falls back to an empty state with a library entry', () => {
+ const onOpenLibrary = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByText('需要在陶泥儿客户端内运行')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: '打开模板库' }));
+ expect(onOpenLibrary).toHaveBeenCalled();
+ });
+
+ it('renders a loading state before the first snapshot arrives', () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByText('正在读取模板库…')).toBeTruthy();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
index f9615a790..d631c5051 100644
--- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
+++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
@@ -2,6 +2,7 @@
import {
act,
+ cleanup,
fireEvent,
render,
renderHook,
@@ -9,7 +10,7 @@ import {
waitFor,
} from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
@@ -35,13 +36,21 @@ import UiEditorPage from '../src/view/ui-editor';
import { useUiEditorSession } from '../src/view/ui-editor/useUiEditorPage';
class TestResizeObserver {
- constructor(_callback: ResizeObserverCallback) {}
- observe() {}
+ constructor(private callback: ResizeObserverCallback) {}
+ observe(target: Element) {
+ this.callback(
+ [
+ {
+ target,
+ contentRect: { width: 800, height: 600 },
+ } as ResizeObserverEntry,
+ ],
+ this as unknown as ResizeObserver,
+ );
+ }
disconnect() {}
}
-vi.stubGlobal('ResizeObserver', TestResizeObserver);
-
const EMPTY_SNAPSHOT: UiDesignStateSnapshot = {
revision: 0,
state: {
@@ -147,6 +156,16 @@ async function renderLoadedSession(state: State) {
}
describe('UiEditorPage', () => {
+ beforeEach(() => {
+ vi.stubGlobal('ResizeObserver', TestResizeObserver);
+ vi.mocked(invoke).mockReset().mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
it('keeps the wallet entry in the resource editor header', async () => {
const stateStore: IUiDesignStateStore = {
load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)),
@@ -185,10 +204,7 @@ describe('UiEditorPage', () => {
}
return undefined;
});
- Object.defineProperty(window, '__TAURI__', {
- configurable: true,
- value: { core: { invoke } },
- });
+ vi.stubGlobal('__TAURI__', { core: { invoke } });
const stateStore: IUiDesignStateStore = {
load: vi.fn().mockResolvedValue({
revision: 0,
@@ -509,8 +525,13 @@ describe('UiEditorPage', () => {
}),
);
- const child = await screen.findByText('page-child');
- fireEvent.click(child);
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'),
+ ).toBe(false);
+ });
+ fireEvent.click(screen.getByText('page-child'));
+ await screen.findByDisplayValue('page-child');
fireEvent.keyDown(window, { key: 'Delete' });
await waitFor(() => expect(screen.queryByText('page-child')).toBeNull());
@@ -533,8 +554,13 @@ describe('UiEditorPage', () => {
}),
);
- const child = await screen.findByText('page-child');
- fireEvent.click(child);
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'),
+ ).toBe(false);
+ });
+ fireEvent.click(screen.getByText('page-child'));
+ await screen.findByDisplayValue('page-child');
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
document.body.appendChild(dialog);
diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
index 6e821454f..01876526e 100644
--- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
+++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
@@ -141,6 +141,60 @@ afterEach(() => {
});
describe('useProjectResourceCardPreviews', () => {
+ it('JSON 识别结果经过现有预取缓存保留,换项目后不复用旧编辑能力', async () => {
+ const json = resource('design', {
+ path: 'ui/design.json',
+ subtype: 'ui',
+ mediaType: 'application/json',
+ });
+ const invoke = vi.fn(
+ async (_command: string, args?: Record) => ({
+ path: json.path,
+ mediaType: json.mediaType,
+ byteLen: 2,
+ content: '{}',
+ ...(args?.projectPath === '/tmp/first-project'
+ ? { uiDesignAssetId: 'design' }
+ : {}),
+ }),
+ );
+ window.__TAURI__ = { core: { invoke } };
+ const resources = [json];
+ const canvasRef = { current: document.createElement('div') };
+ const { result, rerender } = renderHook(
+ ({ projectPath }) =>
+ useProjectResourceCardPreviews({
+ projectPath,
+ projectId: projectPath,
+ mode: 'dependency',
+ resources,
+ canvasRef,
+ eagerPreviewLimit: 12,
+ }),
+ { initialProps: { projectPath: '/tmp/first-project' } },
+ );
+ const identity = () => result.current.identityByResourceId.get(json.id)!;
+ await waitFor(() =>
+ expect(result.current.previews.get(identity())).toMatchObject({
+ status: 'loaded',
+ preview: { uiDesignAssetId: 'design' },
+ }),
+ );
+ rerender({ projectPath: '/tmp/second-project' });
+ await waitFor(() =>
+ expect(result.current.previews.get(identity())?.status).toBe('loaded'),
+ );
+ const state = result.current.previews.get(identity());
+ expect(
+ state?.status === 'loaded' && state.preview.uiDesignAssetId,
+ ).toBeUndefined();
+ expect(
+ invoke.mock.calls.filter(
+ ([command]) => command === 'read_local_project_text_preview',
+ ),
+ ).toHaveLength(2);
+ });
+
it('代码卡不预取正文,显式详情复用文本预览队列与缓存', async () => {
const code = resource('code', {
path: 'game/main.ts',
diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx
new file mode 100644
index 000000000..897a71ac9
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx
@@ -0,0 +1,111 @@
+/** @vitest-environment jsdom */
+import type { ReactNode } from 'react';
+import { useCallback } from 'react';
+import { vi } from 'vitest';
+
+import { WindowChrome } from '../src/components/WindowChrome';
+import {
+ useWindowChrome,
+ type WindowChromeActiveProjectRuns,
+ WindowChromeContext,
+} from '../src/components/windowChromeContext';
+import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
+import {
+ act,
+ expect,
+ it,
+ React,
+ render,
+ testAuthUser,
+} from './appSurface/harness';
+
+const homeProjectOverride = vi.hoisted(() => ({
+ openProject: null as
+ | null
+ | ((path: string, mode: 'open' | 'create') => Promise),
+}));
+
+vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => {
+ const actual = await vi.importActual<
+ typeof import('../src/features/app-shell/useHomeProjectCreation')
+ >('../src/features/app-shell/useHomeProjectCreation');
+ return {
+ ...actual,
+ useHomeProjectCreation(
+ ...args: Parameters
+ ) {
+ const result = actual.useHomeProjectCreation(...args);
+ return homeProjectOverride.openProject
+ ? { ...result, openProject: homeProjectOverride.openProject }
+ : result;
+ },
+ };
+});
+
+it('真实窗口与工作台状态同步收敛,回调读取最新处理器且卸载才清理', async () => {
+ const publications: WindowChromeActiveProjectRuns[] = [];
+ let cleanups = 0;
+ // 仍经过真实 WindowChrome 的 setState/Context;上限只防止回归时测试无限循环。
+ function BoundedWindowBridge({ children }: { children: ReactNode }) {
+ const chrome = useWindowChrome();
+ const { setActiveProjectRuns } = chrome;
+ const publish = useCallback(
+ (next: WindowChromeActiveProjectRuns | null) => {
+ if (next) publications.push(next);
+ else cleanups += 1;
+ if (publications.length < 12) setActiveProjectRuns(next);
+ },
+ [setActiveProjectRuns],
+ );
+ return (
+
+ {children}
+
+ );
+ }
+ const supervisor = () => null;
+ const view = (displayName = '测试用户') => (
+
+
+ undefined}
+ initialView="projects"
+ ProjectSupervisor={supervisor}
+ />
+
+
+ );
+ delete window.__TAURI__;
+ const rendered = render(view());
+ try {
+ await act(async () => {
+ await Promise.resolve();
+ });
+ // 无原生 invoke 时空快照引用不变,只发布一次。
+ expect(publications).toHaveLength(1);
+ expect(cleanups).toBe(0);
+ expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe(
+ 1,
+ );
+ const openProject = publications[0]!.onOpenProject!;
+ const latestOpen = vi.fn(async () => undefined);
+ homeProjectOverride.openProject = latestOpen;
+ rendered.rerender(view('更改显示名'));
+ expect(publications).toHaveLength(1);
+ act(() => openProject('/tmp/window-latest-project'));
+ expect(latestOpen).toHaveBeenCalledWith(
+ '/tmp/window-latest-project',
+ 'open',
+ );
+ expect(publications).toHaveLength(1);
+ expect(cleanups).toBe(0);
+ rendered.unmount();
+ expect(cleanups).toBe(1);
+ } finally {
+ homeProjectOverride.openProject = null;
+ rendered.unmount();
+ }
+});
diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts
index 6e68153ba..fa2246ed0 100644
--- a/apps/ai-game-creator-shell/vite.config.ts
+++ b/apps/ai-game-creator-shell/vite.config.ts
@@ -154,6 +154,9 @@ export default defineConfig({
host: '127.0.0.1',
port: 3080,
strictPort: true,
+ watch: {
+ ignored: ['**/src-tauri/target/**'],
+ },
fs: {
allow: [repoRoot],
},
diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
index 4a366fdaa..29a4dbb5d 100644
--- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
+++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx
@@ -59,6 +59,32 @@ test('requires an access token before showing deployments', async () => {
expect(await screen.findByText('构建并发布一个分支')).toBeTruthy();
});
+test('prefers the public preview domain when the control plane exposes one', async () => {
+ vi.mocked(api.listDeployments).mockResolvedValue([
+ {
+ id: '77',
+ branch: 'feature/public-preview',
+ resolvedCommit: '1234567890abcdef',
+ status: 'running',
+ health: 'healthy',
+ webPort: 8400,
+ webUrl: 'http://192.168.35.82:8400',
+ webPublicUrl:
+ 'https://preview-63d38d3da6bc9b06.preview.genarrative.world',
+ createdAt: 1_787_270_400,
+ updatedAt: 1_787_270_460,
+ },
+ ]);
+ render();
+
+ const publicLink = await screen.findByRole('link', {
+ name: /打开公网预览/u,
+ });
+ expect(publicLink.getAttribute('href')).toBe(
+ 'https://preview-63d38d3da6bc9b06.preview.genarrative.world',
+ );
+});
+
test('submits a branch with an optional commit hash', async () => {
const user = userEvent.setup();
render();
diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
index ec5e30773..ea23c3836 100644
--- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
+++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
@@ -797,6 +797,16 @@ function DeploymentCard({