1
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import { Match3DAgentWorkspace } from './Match3DAgentWorkspace';
|
||||
|
||||
const baseSession: Match3DAgentSessionSnapshot = {
|
||||
sessionId: 'match3d-session-1',
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
stage: 'collecting_config',
|
||||
anchorPack: {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '题材主题',
|
||||
value: '水果摊',
|
||||
status: 'confirmed',
|
||||
},
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '需要消除次数',
|
||||
value: '8',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
themeText: '水果摊',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 8,
|
||||
difficulty: 3,
|
||||
assetStyleId: 'low-poly',
|
||||
assetStyleLabel: '低多边形',
|
||||
assetStylePrompt:
|
||||
'块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
|
||||
},
|
||||
draft: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '旧会话固定追问不再作为主入口。',
|
||||
createdAt: '2026-05-10T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
lastAssistantReply: '旧会话固定追问不再作为主入口。',
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-05-10T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('match3d workspace submits derived entry form payload instead of agent chat', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
expect(screen.getByText('3D素材风格')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '黏土手作' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
|
||||
expect(screen.getByText('消耗20光点')).toBeTruthy();
|
||||
expect(screen.queryByText('参考图')).toBeNull();
|
||||
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();
|
||||
expect(screen.queryByLabelText('需要消除次数')).toBeNull();
|
||||
expect(screen.queryByLabelText('难度数值')).toBeNull();
|
||||
expect(screen.queryByText('物品')).toBeNull();
|
||||
expect(screen.queryByText('旧会话固定追问不再作为主入口。')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '赛博水果摊' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '进阶' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '赛博水果摊题材,消除16次,难度6',
|
||||
themeText: '赛博水果摊',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 16,
|
||||
difficulty: 6,
|
||||
assetStyleId: 'clay-toy',
|
||||
assetStyleLabel: '黏土手作',
|
||||
assetStylePrompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
|
||||
});
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('match3d workspace supports custom 3d asset style prompt', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '海底甜品店' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '自定义' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText('自定义3D素材风格描述'), {
|
||||
target: { value: '透明果冻材质,边缘有柔和蓝色荧光' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
seedText: '海底甜品店题材,消除12次,难度4',
|
||||
themeText: '海底甜品店',
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'custom',
|
||||
assetStyleLabel: '自定义风格',
|
||||
assetStylePrompt: '透明果冻材质,边缘有柔和蓝色荧光',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d workspace falls back to compile action when restored from the legacy route', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByLabelText('想做一个什么题材的抓大鹅?') as HTMLTextAreaElement)
|
||||
.value,
|
||||
).toBe('水果摊');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '低多边形' }).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'match3d_compile_draft',
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import * as assetReadUrlService from '../../services/assetReadUrlService';
|
||||
import * as hyper3dService from '../../services/hyper3dModelGenerationService';
|
||||
import * as match3dWorksService from '../../services/match3d-works';
|
||||
import { Match3DResultView } from './Match3DResultView';
|
||||
|
||||
@@ -19,13 +21,44 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: (src?: string | null) => ({
|
||||
resolvedUrl: src?.startsWith('/generated-')
|
||||
? `https://signed.example.com${src}`
|
||||
: (src ?? ''),
|
||||
isResolving: false,
|
||||
shouldResolve: Boolean(src?.startsWith('/generated-')),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/assetReadUrlService', () => ({
|
||||
readAssetBytes: vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-works', () => ({
|
||||
publishMatch3DWork: vi.fn(),
|
||||
updateMatch3DWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/hyper3dModelGenerationService', () => ({
|
||||
getHyper3dDownloads: vi.fn(),
|
||||
getHyper3dTaskStatus: vi.fn(),
|
||||
submitHyper3dImageToModel: vi.fn(),
|
||||
submitHyper3dTextToModel: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function createProfile(
|
||||
@@ -100,4 +133,265 @@ describe('Match3DResultView', () => {
|
||||
fireEvent.click(publishButton);
|
||||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('结果页提供多 Tab,并能进入 Rodin 3D 素材详情', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({ themeText: '水果' })}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '玩法配置' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /水果核心物件/u }));
|
||||
|
||||
expect(screen.getByText('素材名称')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '文生模型' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '图生模型' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Rodin 文生模型提交使用 Hyper3D 代理', async () => {
|
||||
vi.mocked(hyper3dService.submitHyper3dTextToModel).mockResolvedValue({
|
||||
ok: true,
|
||||
provider: 'hyper3d-rodin',
|
||||
mode: 'text-to-model',
|
||||
taskUuid: 'task-1',
|
||||
subscriptionKey: 'sub-1',
|
||||
jobUuids: ['job-1'],
|
||||
message: 'submitted',
|
||||
tier: 'Gen-2',
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({ themeText: '水果' })}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /水果核心物件/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hyper3dService.submitHyper3dTextToModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
geometryFileFormat: 'glb',
|
||||
material: 'PBR',
|
||||
meshMode: 'Quad',
|
||||
prompt: expect.stringContaining('水果核心物件'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('排队中').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Rodin 图生模型没有参考图时阻止提交', async () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({ themeText: '水果' })}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /水果核心物件/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '图生模型' }));
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: '生成' });
|
||||
expect(generateButton).toHaveProperty('disabled', true);
|
||||
expect(hyper3dService.submitHyper3dImageToModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('结果页优先预览生成出来的物品图片和模型文件', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
modelSrc: '/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||||
|
||||
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
|
||||
expect(screen.getAllByText('已完成').length).toBeGreaterThan(0);
|
||||
const modelLink = screen.getByRole('link', { name: /strawberry\.glb/u });
|
||||
expect(modelLink.getAttribute('href')).toBe(
|
||||
'https://signed.example.com/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
|
||||
);
|
||||
});
|
||||
|
||||
test('草稿阶段仅有切割图片时展示图片已就绪,不要求模型文件', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||||
|
||||
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
|
||||
expect(screen.getAllByText('图片已就绪').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('0 文件')).toBeTruthy();
|
||||
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('重进草稿页时从持久化 profile 素材恢复 3D 素材列表', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
itemId: 'match3d-item-2',
|
||||
itemName: '苹果',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
draft={null}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /草莓/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /苹果/u })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /水果核心物件/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('Rodin 图生模型提交前会把 generated 参考图转成 data URL', async () => {
|
||||
vi.mocked(hyper3dService.submitHyper3dImageToModel).mockResolvedValue({
|
||||
ok: true,
|
||||
provider: 'hyper3d-rodin',
|
||||
mode: 'image-to-model',
|
||||
taskUuid: 'task-image',
|
||||
subscriptionKey: 'sub-image',
|
||||
jobUuids: ['job-image'],
|
||||
message: 'submitted',
|
||||
tier: 'Gen-2',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assetReadUrlService.readAssetBytes).toHaveBeenCalledWith(
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
expect.objectContaining({ expireSeconds: 300 }),
|
||||
);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
expect(hyper3dService.submitHyper3dImageToModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
imageDataUrls: ['data:image/png;base64,aGVsbG8='],
|
||||
prompt: expect.stringContaining('草莓'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1219,6 +1219,17 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
export function applyMatch3DRendererCanvasLayout(
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
// 中文注释:WebGL 绘图缓冲区会乘设备 DPR,CSS 尺寸必须单独锁住,否则手机端画布会放大溢出。
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.inset = '0';
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.width = '100%';
|
||||
}
|
||||
|
||||
function positionTrayPreviewObject(
|
||||
runtime: TrayPreviewRuntime,
|
||||
object: ThreeObject3D,
|
||||
@@ -1280,11 +1291,7 @@ export function Match3DTrayPreviewBoard({
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
|
||||
renderer.outputColorSpace = three.SRGBColorSpace;
|
||||
renderer.domElement.style.display = 'block';
|
||||
renderer.domElement.style.height = '100%';
|
||||
renderer.domElement.style.inset = '0';
|
||||
renderer.domElement.style.position = 'absolute';
|
||||
renderer.domElement.style.width = '100%';
|
||||
applyMatch3DRendererCanvasLayout(renderer.domElement);
|
||||
container.appendChild(renderer.domElement);
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
@@ -1529,6 +1536,7 @@ export function Match3DPhysicsBoard({
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.outputColorSpace = three.SRGBColorSpace;
|
||||
applyMatch3DRendererCanvasLayout(renderer.domElement);
|
||||
container.appendChild(renderer.domElement);
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE,
|
||||
applyMatch3DRendererCanvasLayout,
|
||||
buildMatch3DPhysicsEntrySignature,
|
||||
createMatch3DCannonShape,
|
||||
createMatch3DThreeGeometry,
|
||||
@@ -190,6 +191,18 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋
|
||||
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('3D WebGL 画布锁定 CSS 尺寸,避免高 DPR 手机上溢出中心棋盘', () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
applyMatch3DRendererCanvasLayout(canvas);
|
||||
|
||||
expect(canvas.style.position).toBe('absolute');
|
||||
expect(canvas.style.inset).toBe('0');
|
||||
expect(canvas.style.width).toBe('100%');
|
||||
expect(canvas.style.height).toBe('100%');
|
||||
expect(canvas.style.display).toBe('block');
|
||||
});
|
||||
|
||||
test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用到新局', () => {
|
||||
const run = startLocalMatch3DRun(10);
|
||||
const item = run.items[0]!;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ export type SelectionStage =
|
||||
| 'big-fish-result'
|
||||
| 'big-fish-runtime'
|
||||
| 'match3d-agent-workspace'
|
||||
| 'match3d-generating'
|
||||
| 'match3d-result'
|
||||
| 'match3d-runtime'
|
||||
| 'square-hole-agent-workspace'
|
||||
@@ -32,6 +33,7 @@ export type SelectionStage =
|
||||
| 'square-hole-runtime'
|
||||
| 'creative-agent-workspace'
|
||||
| 'visual-novel-agent-workspace'
|
||||
| 'visual-novel-generating'
|
||||
| 'visual-novel-result'
|
||||
| 'visual-novel-gallery-detail'
|
||||
| 'visual-novel-runtime'
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
puzzleAssetClient: {
|
||||
listHistoryAssets: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const baseSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 3,
|
||||
@@ -70,6 +96,7 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function stubReferenceImageUpload(dataUrl: string, width = 512, height = 512) {
|
||||
@@ -221,6 +248,66 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle workspace selects a history image from the upload card', async () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||
{
|
||||
assetObjectId: 'asset-history-1',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
imageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
ownerUserId: 'user-1',
|
||||
ownerLabel: '账号 user-1',
|
||||
profileId: null,
|
||||
entityId: 'puzzle-session-1',
|
||||
createdAt: '2026-04-27T10:00:00.000Z',
|
||||
updatedAt: '2026-04-27T10:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
|
||||
expect(historyButton.closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(screen.getByText('历史').closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
fireEvent.click(historyButton);
|
||||
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
fireEvent.click(
|
||||
await within(picker).findByRole('button', { name: /账号 user-1/u }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
expect(screen.getByAltText('拼图图片')).toHaveProperty(
|
||||
'src',
|
||||
expect.stringContaining('/generated-puzzle-assets/history/image.png'),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
|
||||
target: { value: '保留历史图里的主体,改成晴天花园。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '保留历史图里的主体,改成晴天花园。',
|
||||
pictureDescription: '保留历史图里的主体,改成晴天花园。',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle upload card stays light in light theme', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const { container } = render(
|
||||
@@ -237,6 +324,9 @@ test('puzzle upload card stays light in light theme', () => {
|
||||
const uploadLabel = screen.getByText('点击上传拼图图片');
|
||||
expect(uploadLabel).toBeTruthy();
|
||||
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(uploadLabel.className).not.toContain('rounded-full');
|
||||
expect(uploadLabel.className).not.toContain('bg-white/94');
|
||||
expect(uploadLabel.className).not.toContain('border');
|
||||
expect(screen.queryByText('AI重绘')).toBeNull();
|
||||
expect(container.querySelector('.puzzle-image-upload-card')?.className).toContain(
|
||||
'bg-white/90',
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { ArrowLeft, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
History,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
@@ -20,6 +27,7 @@ import {
|
||||
isPuzzleReferenceImageSquare,
|
||||
readPuzzleReferenceImageForUpload,
|
||||
} from '../../services/puzzleReferenceImage';
|
||||
import PuzzleHistoryAssetPickerDialog from './PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -535,6 +543,7 @@ export function PuzzleAgentWorkspace({
|
||||
null,
|
||||
);
|
||||
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const previousSessionIdRef = useRef<string | null>(
|
||||
@@ -565,6 +574,7 @@ export function PuzzleAgentWorkspace({
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setReferenceImageError(null);
|
||||
setCropState(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
@@ -865,6 +875,17 @@ export function PuzzleAgentWorkspace({
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
className={`absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="选择历史图片"
|
||||
title="选择历史图片"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<span>历史</span>
|
||||
</button>
|
||||
{formState.referenceImageSrc ? (
|
||||
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
|
||||
<span>AI重绘</span>
|
||||
@@ -910,7 +931,7 @@ export function PuzzleAgentWorkspace({
|
||||
) : (
|
||||
<label
|
||||
htmlFor="puzzle-image-upload-input"
|
||||
className={`absolute bottom-3 left-1/2 z-10 inline-flex min-h-10 -translate-x-1/2 items-center justify-center whitespace-nowrap rounded-full border border-white/80 bg-white/94 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
|
||||
>
|
||||
点击上传拼图图片
|
||||
</label>
|
||||
@@ -1003,6 +1024,22 @@ export function PuzzleAgentWorkspace({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isHistoryPickerOpen ? (
|
||||
<PuzzleHistoryAssetPickerDialog
|
||||
isBusy={isBusy}
|
||||
onClose={() => setIsHistoryPickerOpen(false)}
|
||||
onSelect={(asset) => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: asset.imageSrc,
|
||||
referenceImageLabel: `历史素材 · ${asset.ownerLabel || '未记录账号'}`,
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isRemoveImageConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
puzzleAssetClient,
|
||||
type PuzzleHistoryAsset,
|
||||
} from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleHistoryAssetPickerDialogProps = {
|
||||
isBusy: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (asset: PuzzleHistoryAsset) => void;
|
||||
};
|
||||
|
||||
function formatHistoryAssetDate(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '未知时间';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function PuzzleHistoryAssetPickerDialog({
|
||||
isBusy,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: PuzzleHistoryAssetPickerDialogProps) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
puzzleAssetClient
|
||||
.listHistoryAssets({ limit: 120 })
|
||||
.then((nextAssets) => {
|
||||
if (!cancelled) {
|
||||
setAssets(nextAssets);
|
||||
}
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: '历史拼图素材读取失败。',
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="选择历史图片"
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,46rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
选择历史图片
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
className="platform-icon-button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
读取中...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && assets.length <= 0 ? (
|
||||
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
暂无历史拼图素材
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && assets.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{assets.map((asset) => (
|
||||
<button
|
||||
key={asset.assetObjectId}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onSelect(asset)}
|
||||
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
|
||||
>
|
||||
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={asset.imageSrc}
|
||||
alt={asset.ownerLabel || '历史拼图素材'}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 px-4 py-4">
|
||||
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{asset.ownerLabel || '未记录账号'}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{formatHistoryAssetDate(asset.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleHistoryAssetPickerDialog;
|
||||
@@ -249,6 +249,7 @@ describe('PuzzleResultView', () => {
|
||||
promptText: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
@@ -263,8 +264,15 @@ describe('PuzzleResultView', () => {
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '暖灯猫街',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
]);
|
||||
expect(within(dialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
|
||||
).toBeNull();
|
||||
const levelList = screen.getByLabelText('拼图关卡列表');
|
||||
expect(within(levelList).getAllByText('生成中').length).toBeGreaterThan(0);
|
||||
|
||||
const levelNameInput = within(dialog).getByLabelText('关卡名称');
|
||||
const formalImageTitle = within(dialog).getByText('画面图');
|
||||
@@ -314,7 +322,10 @@ describe('PuzzleResultView', () => {
|
||||
within(dialog).getByRole('button', { name: /生成画面/u }),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByText('消耗2光点')).toBeTruthy();
|
||||
expect(within(dialog).queryByText('画面图')).toBeNull();
|
||||
expect(
|
||||
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByText('画面图')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: /关卡测试/u }),
|
||||
).toBeNull();
|
||||
@@ -385,6 +396,7 @@ describe('PuzzleResultView', () => {
|
||||
promptText: '新关卡里有一座发光钟楼。',
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
@@ -400,10 +412,90 @@ describe('PuzzleResultView', () => {
|
||||
levelId: 'puzzle-level-1775000000000-2',
|
||||
levelName: '',
|
||||
pictureDescription: '新关卡里有一座发光钟楼。',
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps generation progress visible after closing and reopening level dialog', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
|
||||
expect(
|
||||
within(screen.getByLabelText('拼图关卡列表')).getAllByText('生成中')
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const reopenedDialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
|
||||
).toBeTruthy();
|
||||
expect(within(reopenedDialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('关卡名称'), {
|
||||
target: { value: '继续编辑的猫街' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /关卡测试/u }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelName: '继续编辑的猫街',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
|
||||
expect(
|
||||
within(publishDialog).getByRole('button', { name: '发布到广场' }),
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('publishes with work info and serialized levels', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -552,19 +644,26 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const uploadInput = within(dialog).getByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(uploadInput.closest('.platform-subpanel')).toBeTruthy();
|
||||
const historyButton = within(dialog).getByRole('button', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
expect(within(historyButton).getByText('历史')).toBeTruthy();
|
||||
fireEvent.click(historyButton);
|
||||
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: '选择历史拼图素材',
|
||||
name: '选择历史图片',
|
||||
});
|
||||
fireEvent.click(
|
||||
await within(picker).findByRole('button', { name: /账号 user-1/u }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '选择历史拼图素材' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
@@ -580,6 +679,7 @@ describe('PuzzleResultView', () => {
|
||||
promptText: '屋檐下的猫与暖灯街角。',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
@@ -624,8 +724,10 @@ describe('PuzzleResultView', () => {
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
aiRedraw: true,
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull();
|
||||
});
|
||||
|
||||
test('passes the selected image model when regenerating a level image', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -207,7 +207,8 @@ test('拼图界面在 mocap open_palm 时显示体感光标', () => {
|
||||
|
||||
const cursor = screen.getByTestId('puzzle-mocap-cursor');
|
||||
expect(cursor).toBeTruthy();
|
||||
expect(cursor).toHaveStyle({left: '42%', top: '58%'});
|
||||
expect(cursor.style.left).toBe('42%');
|
||||
expect(cursor.style.top).toBe('58%');
|
||||
mocapMock.state = 'grab';
|
||||
});
|
||||
|
||||
@@ -813,6 +814,7 @@ test('基础单块使用圆角裁剪图片', () => {
|
||||
) as HTMLElement | null;
|
||||
expect(basePiece?.className).toContain('overflow-hidden');
|
||||
expect(basePiece?.className).toContain('rounded-[0.85rem]');
|
||||
expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
test('移动端点击拼图片时立即触发一次震动反馈', () => {
|
||||
|
||||
@@ -1347,7 +1347,6 @@ export function PuzzleRuntimeShell({
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
|
||||
)}
|
||||
<div className="puzzle-runtime-piece-overlay absolute inset-0" />
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
@@ -2000,7 +1999,7 @@ export function PuzzleRuntimeShell({
|
||||
排行榜
|
||||
</div>
|
||||
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
|
||||
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] px-3 py-2 text-[11px] font-bold">
|
||||
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
|
||||
<span>名次</span>
|
||||
<span>昵称</span>
|
||||
<span className="text-right">通关时间</span>
|
||||
@@ -2010,7 +2009,7 @@ export function PuzzleRuntimeShell({
|
||||
leaderboardEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
|
||||
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
|
||||
entry.isCurrentPlayer
|
||||
? 'puzzle-runtime-leaderboard-row--active'
|
||||
: 'puzzle-runtime-leaderboard-row border-t'
|
||||
@@ -2019,8 +2018,22 @@ export function PuzzleRuntimeShell({
|
||||
<span className="font-mono font-black">
|
||||
#{entry.rank}
|
||||
</span>
|
||||
<span className="truncate font-semibold">
|
||||
{entry.nickname}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold leading-tight">
|
||||
{entry.nickname}
|
||||
</span>
|
||||
{entry.visibleTags?.length ? (
|
||||
<span className="puzzle-runtime-leaderboard-tags">
|
||||
{entry.visibleTags.map((tag) => (
|
||||
<span
|
||||
className="puzzle-runtime-leaderboard-tag"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-bold">
|
||||
{formatElapsedMs(entry.elapsedMs)}
|
||||
@@ -2112,7 +2125,7 @@ function PuzzleNextWorkCard({
|
||||
) : (
|
||||
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
|
||||
)}
|
||||
<div className="puzzle-runtime-piece-overlay absolute inset-0 transition group-hover:opacity-0" />
|
||||
<div className="puzzle-runtime-next-card-overlay absolute inset-0 transition group-hover:opacity-0" />
|
||||
</div>
|
||||
<div className="min-w-0 px-3 py-2.5">
|
||||
<div className="truncate text-sm font-black">
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
} from '../../services/match3d-works';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
executePuzzleAgentAction,
|
||||
getPuzzleAgentSession,
|
||||
} from '../../services/puzzle-agent';
|
||||
import {
|
||||
@@ -463,9 +464,17 @@ vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
|
||||
|
||||
vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
PuzzleResultView: ({
|
||||
isBusy,
|
||||
onExecuteAction,
|
||||
session,
|
||||
onBack,
|
||||
}: {
|
||||
isBusy?: boolean;
|
||||
onExecuteAction: (payload: {
|
||||
action: string;
|
||||
levelId?: string;
|
||||
promptText?: string;
|
||||
}) => void;
|
||||
session: { draft?: { levelName: string } | null };
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
@@ -475,6 +484,21 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
关卡名
|
||||
<input readOnly value={session.draft?.levelName ?? ''} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '重新生成猫街',
|
||||
});
|
||||
}}
|
||||
>
|
||||
重新生成画面
|
||||
</button>
|
||||
<button type="button" disabled={isBusy}>
|
||||
新增关卡
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
@@ -550,14 +574,36 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||||
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
Match3DAgentWorkspace: ({
|
||||
session,
|
||||
onCreateFromForm,
|
||||
}: {
|
||||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||||
onCreateFromForm?: (payload: {
|
||||
seedText: string;
|
||||
themeText: string;
|
||||
referenceImageSrc: string | null;
|
||||
clearCount: number;
|
||||
difficulty: number;
|
||||
}) => void;
|
||||
}) => (
|
||||
<div className="match3d-agent-workspace-mock">
|
||||
<div>抓大鹅工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||||
{session?.messages.map((message) => (
|
||||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCreateFromForm?.({
|
||||
seedText: '赛博水果摊题材,消除9次,难度6',
|
||||
themeText: '赛博水果摊',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 9,
|
||||
difficulty: 6,
|
||||
});
|
||||
}}
|
||||
>
|
||||
生成抓大鹅草稿
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -2355,6 +2401,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/airp.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/match3d.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
|
||||
).toBeTruthy();
|
||||
@@ -2364,12 +2413,29 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
|
||||
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
expect(screen.queryByRole('tab', { name: /抓大鹅/u })).toBeNull();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('create tab switches match3d into the embedded entry form', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
await screen.findByText('抓大鹅工作区:missing-session'),
|
||||
).toBeTruthy();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('embedded puzzle form routes through requireAuth while logged out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -2826,6 +2892,159 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn
|
||||
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
|
||||
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
|
||||
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
|
||||
operation: {
|
||||
operationId: 'puzzle-image-generation-1',
|
||||
type: 'generate_puzzle_images',
|
||||
status: 'running',
|
||||
phaseLabel: '生成中',
|
||||
phaseDetail: '正在生成拼图画面',
|
||||
progress: 0.3,
|
||||
},
|
||||
session: {
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 3,
|
||||
progressPercent: 88,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'theme_promise',
|
||||
label: '主题承诺',
|
||||
value: '雨夜猫街',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visual_subject',
|
||||
label: '视觉主体',
|
||||
value: '屋檐下的猫',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visual_mood',
|
||||
label: '视觉气质',
|
||||
value: '温暖',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'composition_hooks',
|
||||
label: '构图钩子',
|
||||
value: '雨滴与灯牌',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tags_and_forbidden',
|
||||
label: '标签与禁区',
|
||||
value: '猫咪、雨夜',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
draft: {
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'theme_promise',
|
||||
label: '主题承诺',
|
||||
value: '雨夜猫街',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visual_subject',
|
||||
label: '视觉主体',
|
||||
value: '屋檐下的猫',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visual_mood',
|
||||
label: '视觉气质',
|
||||
value: '温暖',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'composition_hooks',
|
||||
label: '构图钩子',
|
||||
value: '雨滴与灯牌',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tags_and_forbidden',
|
||||
label: '标签与禁区',
|
||||
value: '猫咪、雨夜',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/candidate-1.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫咪',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/candidate-1.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'generating',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/candidate-1.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫咪',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/candidate-1.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
metadata: null,
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。',
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: {
|
||||
draft: null,
|
||||
publishReady: false,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
},
|
||||
updatedAt: '2026-04-26T10:10:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重新生成画面' }));
|
||||
|
||||
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
|
||||
'puzzle-session-1',
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '新增关卡' })).toHaveProperty(
|
||||
'disabled',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('logged out public detail gates big fish start before local runtime', async () => {
|
||||
@@ -3027,7 +3246,6 @@ test('published puzzle works appear on home and mobile game category channel', a
|
||||
});
|
||||
|
||||
test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
@@ -3396,7 +3614,7 @@ test('embedded puzzle form timeout exits busy state and shows a readable error',
|
||||
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
|
||||
test('match3d creation tab stays usable even when public galleries fail', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
|
||||
@@ -3411,9 +3629,7 @@ test('hidden match3d creation card stays closed even when public galleries fail'
|
||||
await openCreateTemplateHub(user);
|
||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: /抓大鹅.*经典消除玩法/u }),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1312,6 +1312,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
vi.useFakeTimers();
|
||||
const onSelectNextRecommendEntry = vi.fn();
|
||||
const onSelectPreviousRecommendEntry = vi.fn();
|
||||
const onLikeRecommendEntry = vi.fn();
|
||||
const onRemixRecommendEntry = vi.fn();
|
||||
const firstEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-feed-1',
|
||||
@@ -1397,6 +1399,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1"
|
||||
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
|
||||
onLikeRecommendEntry={onLikeRecommendEntry}
|
||||
onRemixRecommendEntry={onRemixRecommendEntry}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
/>
|
||||
@@ -1412,8 +1416,38 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
).toHaveLength(3);
|
||||
expect(screen.getAllByText('下一拼图').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.queryByText('评论')).toBeNull();
|
||||
expect(screen.queryByLabelText(/游玩/u)).toBeNull();
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: clipboardWriteText },
|
||||
});
|
||||
|
||||
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
|
||||
const activeRecommendCard = within(meta);
|
||||
const likeButton = activeRecommendCard.getByRole('button', {
|
||||
name: '点赞 12',
|
||||
});
|
||||
expect(likeButton).toBeTruthy();
|
||||
expect(activeRecommendCard.getByLabelText('12 个赞')).toBeTruthy();
|
||||
const shareButton = activeRecommendCard.getByRole('button', { name: '分享' });
|
||||
const remixButton = activeRecommendCard.getByRole('button', {
|
||||
name: '改造 5',
|
||||
});
|
||||
expect(shareButton).toBeTruthy();
|
||||
expect(remixButton).toBeTruthy();
|
||||
|
||||
fireEvent.click(likeButton);
|
||||
fireEvent.click(shareButton);
|
||||
fireEvent.click(remixButton);
|
||||
|
||||
expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('作品号:PZ-FEED1'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
|
||||
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 210 });
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Coins,
|
||||
Compass,
|
||||
Copy,
|
||||
GitFork,
|
||||
Gamepad2,
|
||||
Heart,
|
||||
LogIn,
|
||||
@@ -16,10 +17,12 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Star,
|
||||
ThumbsUp,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
@@ -54,6 +57,7 @@ import type {
|
||||
RedeemProfileRewardCodeResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
getPublicAuthUserByCode,
|
||||
@@ -86,6 +90,7 @@ import {
|
||||
isVisualNovelGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
@@ -126,6 +131,8 @@ export interface RpgEntryHomeViewProps {
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectNextRecommendEntry?: () => void;
|
||||
onSelectPreviousRecommendEntry?: () => void;
|
||||
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
@@ -772,19 +779,27 @@ function RecommendSwipeCard({
|
||||
authorAvatarUrl,
|
||||
isActive,
|
||||
visual,
|
||||
shareState,
|
||||
onDragPointerDown,
|
||||
onDragPointerMove,
|
||||
onDragPointerUp,
|
||||
onDragPointerCancel,
|
||||
onLike,
|
||||
onShare,
|
||||
onRemix,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
authorAvatarUrl?: string | null;
|
||||
isActive: boolean;
|
||||
visual: ReactNode;
|
||||
shareState?: 'idle' | 'copied' | 'failed';
|
||||
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onLike?: () => void;
|
||||
onShare?: () => void;
|
||||
onRemix?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -797,10 +812,14 @@ function RecommendSwipeCard({
|
||||
entry={entry}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
isActive={isActive}
|
||||
shareState={shareState}
|
||||
onDragPointerDown={onDragPointerDown}
|
||||
onDragPointerMove={onDragPointerMove}
|
||||
onDragPointerUp={onDragPointerUp}
|
||||
onDragPointerCancel={onDragPointerCancel}
|
||||
onLike={onLike}
|
||||
onShare={onShare}
|
||||
onRemix={onRemix}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -814,6 +833,10 @@ function RecommendRuntimeMeta({
|
||||
onDragPointerMove,
|
||||
onDragPointerUp,
|
||||
onDragPointerCancel,
|
||||
shareState = 'idle',
|
||||
onLike,
|
||||
onShare,
|
||||
onRemix,
|
||||
isActive = true,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
@@ -822,20 +845,21 @@ function RecommendRuntimeMeta({
|
||||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||||
shareState?: 'idle' | 'copied' | 'failed';
|
||||
onLike?: () => void;
|
||||
onShare?: () => void;
|
||||
onRemix?: () => void;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const playCount = getPlatformWorldPlayCount(entry);
|
||||
const remixCount = getPlatformWorldRemixCount(entry);
|
||||
const likeCount = getPlatformWorldLikeCount(entry);
|
||||
const remixCount = getPlatformWorldRemixCount(entry);
|
||||
const authorName = entry.authorDisplayName.trim() || '玩家';
|
||||
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
|
||||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const statItems = [
|
||||
{ label: '游玩', value: playCount, icon: Gamepad2 },
|
||||
{ label: '点赞', value: likeCount, icon: Heart },
|
||||
{ label: '改造', value: remixCount, icon: MessageCircle },
|
||||
];
|
||||
const stopActionPointer = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -854,19 +878,6 @@ function RecommendRuntimeMeta({
|
||||
onPointerUp={onDragPointerUp}
|
||||
onPointerCancel={onDragPointerCancel}
|
||||
>
|
||||
<div className="platform-recommend-work-meta__stats">
|
||||
{statItems.map(({ label, value, icon: Icon }) => (
|
||||
<span
|
||||
key={label}
|
||||
className="platform-recommend-work-meta__stat"
|
||||
aria-label={`${label} ${formatCompactCount(value)}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{formatCompactCount(value)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="platform-recommend-work-meta__row">
|
||||
<div
|
||||
className="platform-recommend-work-meta__identity"
|
||||
@@ -894,6 +905,62 @@ function RecommendRuntimeMeta({
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="platform-recommend-work-meta__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--like"
|
||||
onPointerDown={stopActionPointer}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onLike?.();
|
||||
}}
|
||||
disabled={!isActive || !onLike}
|
||||
aria-label={`点赞 ${formatCompactCount(likeCount)}`}
|
||||
title="点赞"
|
||||
>
|
||||
<ThumbsUp className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
className="platform-recommend-work-meta__like-count"
|
||||
aria-label={`${formatCompactCount(likeCount)} 个赞`}
|
||||
>
|
||||
{formatCompactCount(likeCount)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon"
|
||||
onPointerDown={stopActionPointer}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onShare?.();
|
||||
}}
|
||||
disabled={!isActive || !onShare}
|
||||
aria-label={
|
||||
shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
title="分享"
|
||||
>
|
||||
<Share2 className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--remix"
|
||||
onPointerDown={stopActionPointer}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemix?.();
|
||||
}}
|
||||
disabled={!isActive || !onRemix}
|
||||
aria-label={`改造 ${formatCompactCount(remixCount)}`}
|
||||
title="改造"
|
||||
>
|
||||
<GitFork className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -2977,6 +3044,8 @@ export function RpgEntryHomeView({
|
||||
recommendRuntimeError = null,
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
onLikeRecommendEntry,
|
||||
onRemixRecommendEntry,
|
||||
onOpenLibraryDetail,
|
||||
onDeleteLibraryEntry,
|
||||
deletingLibraryEntryId = null,
|
||||
@@ -3863,6 +3932,10 @@ export function RpgEntryHomeView({
|
||||
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
|
||||
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
|
||||
useState<1 | -1 | null>(null);
|
||||
const [recommendShareState, setRecommendShareState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const recommendShareResetTimerRef = useRef<number | null>(null);
|
||||
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const recommendDragStartRef = useRef<{
|
||||
pointerId: number;
|
||||
@@ -4005,6 +4078,36 @@ export function RpgEntryHomeView({
|
||||
onSelectNextRecommendEntry,
|
||||
recommendedFeedEntries.length,
|
||||
]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
setRecommendShareState('idle');
|
||||
}, [activeRecommendEntryKey]);
|
||||
const shareRecommendEntry = useCallback((entry: PlatformPublicGalleryCard) => {
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
|
||||
if (!publicWorkCode) {
|
||||
setRecommendShareState('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setRecommendShareState(copied ? 'copied' : 'failed');
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
}
|
||||
recommendShareResetTimerRef.current = window.setTimeout(() => {
|
||||
recommendShareResetTimerRef.current = null;
|
||||
setRecommendShareState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
}, []);
|
||||
const openActiveRecommendEntry = useCallback(() => {
|
||||
if (!activeRecommendEntry) {
|
||||
return;
|
||||
@@ -4168,6 +4271,10 @@ export function RpgEntryHomeView({
|
||||
onDragPointerMove={moveRecommendDrag}
|
||||
onDragPointerUp={endRecommendDrag}
|
||||
onDragPointerCancel={cancelRecommendDrag}
|
||||
shareState={recommendShareState}
|
||||
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
|
||||
onShare={() => shareRecommendEntry(activeRecommendEntry)}
|
||||
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,123 +1,101 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
|
||||
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
|
||||
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
buildVisualNovelEntryGenerationAnchorEntries,
|
||||
buildVisualNovelEntryGenerationProgress,
|
||||
VisualNovelAgentWorkspace,
|
||||
} from './VisualNovelAgentWorkspace';
|
||||
|
||||
vi.mock('../../services/creation-agent/creationAgentDocumentInput', () => ({
|
||||
parseCreationAgentDocumentInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/visual-novel-creation', () => ({
|
||||
uploadVisualNovelAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedParseCreationAgentDocumentInput = vi.mocked(
|
||||
await import('../../services/creation-agent/creationAgentDocumentInput'),
|
||||
);
|
||||
const mockedVisualNovelAssetClient = vi.mocked(
|
||||
await import('../../services/visual-novel-creation'),
|
||||
);
|
||||
|
||||
function renderWorkspace(ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>) {
|
||||
function renderWorkspace(
|
||||
ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>,
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={
|
||||
{
|
||||
user: { id: 'user-1' },
|
||||
platformTheme: 'light',
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<VisualNovelAgentWorkspace
|
||||
session={mockVisualNovelSession}
|
||||
onBack={() => {}}
|
||||
{...ui}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
<VisualNovelAgentWorkspace onBack={() => {}} session={null} {...ui} />,
|
||||
);
|
||||
}
|
||||
|
||||
test('visual novel workspace renders mock creation shell without forbidden entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenResult = vi.fn();
|
||||
test('visual novel workspace only exposes one-line input and visual style entry', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
renderWorkspace({ onOpenResult });
|
||||
renderWorkspace({ onCreateFromForm });
|
||||
|
||||
expect(screen.getByRole('heading', { name: '视觉小说' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '一句话' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('一句话创作')).toBeTruthy();
|
||||
expect(screen.getByText('视觉画风')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '映画动画' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '水彩绘本' })).toBeTruthy();
|
||||
expect(screen.getByText('消耗20光点')).toBeTruthy();
|
||||
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '进入结果页' }));
|
||||
|
||||
expect(onOpenResult).toHaveBeenCalledWith(mockVisualNovelSession);
|
||||
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '空白' })).toBeNull();
|
||||
expect(screen.queryByLabelText('上传文档')).toBeNull();
|
||||
expect(screen.queryByText('进入结果页')).toBeNull();
|
||||
expect(screen.queryByText('Agent')).toBeNull();
|
||||
});
|
||||
|
||||
test('visual novel workspace opens editable blank draft from blank source', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenResult = vi.fn();
|
||||
test('visual novel workspace submits idea and selected visual style as seed text', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
renderWorkspace({ session: null, onOpenResult });
|
||||
renderWorkspace({ onCreateFromForm });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '空白' }));
|
||||
const openResultButtons = screen.getAllByRole('button', {
|
||||
name: '进入结果页',
|
||||
fireEvent.change(screen.getByLabelText('一句话创作'), {
|
||||
target: { value: '失忆画师在雨夜剧场寻找旧胶片。' },
|
||||
});
|
||||
await user.click(openResultButtons[0]!);
|
||||
fireEvent.click(screen.getByRole('button', { name: '像素霓虹' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /生成视觉小说草稿/u }),
|
||||
);
|
||||
|
||||
expect(onOpenResult).toHaveBeenCalledTimes(1);
|
||||
const session = onOpenResult.mock.calls[0]?.[0];
|
||||
expect(session?.sourceMode).toBe('blank');
|
||||
expect(session?.draft?.sourceMode).toBe('blank');
|
||||
expect(session?.draft?.runtimeConfig.textModeEnabled).toBe(true);
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
sourceMode: 'idea',
|
||||
sourceAssetIds: [],
|
||||
ideaText: '失忆画师在雨夜剧场寻找旧胶片。',
|
||||
visualStyleId: 'pixel-noir',
|
||||
visualStyleLabel: '像素霓虹',
|
||||
visualStylePrompt:
|
||||
'高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
|
||||
seedText:
|
||||
'失忆画师在雨夜剧场寻找旧胶片。\n视觉画风:像素霓虹\n画风要求:高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
|
||||
});
|
||||
});
|
||||
|
||||
test('visual novel workspace uploads document asset and passes asset id to session', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateSession = vi.fn();
|
||||
const parseMock = mockedParseCreationAgentDocumentInput.parseCreationAgentDocumentInput;
|
||||
const uploadMock = mockedVisualNovelAssetClient.uploadVisualNovelAsset;
|
||||
test('visual novel workspace restores idea text from existing session', () => {
|
||||
renderWorkspace({ session: mockVisualNovelSession });
|
||||
|
||||
parseMock.mockResolvedValue({
|
||||
document: {
|
||||
fileName: '世界设定.docx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
sizeBytes: 128,
|
||||
text: '第一章\n雨夜书店\n第二章\n失踪乘客',
|
||||
sourceAssetId: 'asset-doc-source',
|
||||
},
|
||||
});
|
||||
uploadMock.mockResolvedValue({
|
||||
assetObjectId: 'asset-doc-1',
|
||||
assetKind: 'visual_novel_document',
|
||||
objectKey: 'generated-character-drafts/visual-novel/draft/document/1.docx',
|
||||
imageSrc: '/generated-character-drafts/visual-novel/draft/document/1.docx',
|
||||
});
|
||||
|
||||
renderWorkspace({ session: null, onCreateSession });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '文档' }));
|
||||
const fileInput = screen.getByLabelText('上传文档') as HTMLInputElement;
|
||||
const file = new File(['first chapter'], '世界设定.docx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
});
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '生成底稿' }));
|
||||
|
||||
expect(parseMock).toHaveBeenCalledTimes(1);
|
||||
expect(uploadMock).toHaveBeenCalledTimes(1);
|
||||
expect(onCreateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceMode: 'document',
|
||||
sourceAssetIds: ['asset-doc-1'],
|
||||
seedText: expect.stringContaining('第一章'),
|
||||
}),
|
||||
expect((screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value).toBe(
|
||||
'想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
|
||||
);
|
||||
});
|
||||
|
||||
test('visual novel generation helpers build process page data', () => {
|
||||
const payload = {
|
||||
sourceMode: 'idea' as const,
|
||||
seedText:
|
||||
'雨夜书店\n视觉画风:水彩绘本\n画风要求:透明水彩与绘本质感。',
|
||||
sourceAssetIds: [],
|
||||
ideaText: '雨夜书店',
|
||||
visualStyleId: 'watercolor' as const,
|
||||
visualStyleLabel: '水彩绘本',
|
||||
visualStylePrompt: '透明水彩与绘本质感。',
|
||||
};
|
||||
|
||||
expect(buildVisualNovelEntryGenerationAnchorEntries(payload)).toEqual([
|
||||
{ id: 'visual-novel-idea', label: '一句话', value: '雨夜书店' },
|
||||
{ id: 'visual-novel-style', label: '视觉画风', value: '水彩绘本' },
|
||||
]);
|
||||
|
||||
const progress = buildVisualNovelEntryGenerationProgress(
|
||||
1_000,
|
||||
'generating',
|
||||
8_000,
|
||||
);
|
||||
|
||||
expect(progress.phaseId).toBe('generating');
|
||||
expect(progress.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress.steps.some((step) => step.status === 'active')).toBe(true);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+85
-13
@@ -541,7 +541,7 @@ body {
|
||||
--puzzle-runtime-piece-selected-fill: linear-gradient(135deg, #ff4f8b, #ff8a73);
|
||||
--puzzle-runtime-piece-selected-text: #fff7fb;
|
||||
--puzzle-runtime-piece-selected-border: rgba(255, 79, 139, 0.68);
|
||||
--puzzle-runtime-piece-overlay: rgba(61, 24, 38, 0.06);
|
||||
--puzzle-runtime-next-card-overlay: rgba(61, 24, 38, 0.06);
|
||||
--puzzle-runtime-control-fill: rgba(255, 255, 255, 0.72);
|
||||
--puzzle-runtime-control-hover-fill: rgba(255, 91, 132, 0.1);
|
||||
--puzzle-runtime-primary-fill: var(--platform-button-primary-fill);
|
||||
@@ -768,7 +768,7 @@ body {
|
||||
--puzzle-runtime-piece-selected-fill: rgba(251, 191, 36, 0.84);
|
||||
--puzzle-runtime-piece-selected-text: #020617;
|
||||
--puzzle-runtime-piece-selected-border: rgba(253, 230, 138, 1);
|
||||
--puzzle-runtime-piece-overlay: rgba(0, 0, 0, 0.1);
|
||||
--puzzle-runtime-next-card-overlay: rgba(0, 0, 0, 0.1);
|
||||
--puzzle-runtime-control-fill: rgba(0, 0, 0, 0.36);
|
||||
--puzzle-runtime-control-hover-fill: rgba(255, 255, 255, 0.1);
|
||||
--puzzle-runtime-primary-fill: #fde68a;
|
||||
@@ -2095,8 +2095,31 @@ body {
|
||||
color: var(--puzzle-runtime-piece-empty-text);
|
||||
}
|
||||
|
||||
.puzzle-runtime-piece-overlay {
|
||||
background: var(--puzzle-runtime-piece-overlay);
|
||||
.puzzle-runtime-next-card-overlay {
|
||||
background: var(--puzzle-runtime-next-card-overlay);
|
||||
}
|
||||
|
||||
.puzzle-runtime-leaderboard-tags {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding-top: 0.28rem;
|
||||
}
|
||||
|
||||
.puzzle-runtime-leaderboard-tag {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
border: 1px solid var(--puzzle-runtime-control-border);
|
||||
border-radius: 9999px;
|
||||
background: var(--puzzle-runtime-control-fill);
|
||||
color: var(--puzzle-runtime-accent-text);
|
||||
padding: 0.12rem 0.42rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.puzzle-runtime-primary-button {
|
||||
@@ -2603,32 +2626,81 @@ body {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__stats {
|
||||
.platform-recommend-work-meta__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 1.05rem;
|
||||
padding: 0 0.28rem;
|
||||
gap: 0.55rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__stat {
|
||||
.platform-recommend-work-meta__action {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: 2.4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(121, 82, 109, 0.12);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--platform-text-strong);
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
background 140ms ease,
|
||||
border-color 140ms ease;
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__action:not(:disabled):active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__action:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__action--icon {
|
||||
width: 2.4rem;
|
||||
flex: 0 0 2.4rem;
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__action--like {
|
||||
border-color: rgba(255, 85, 126, 0.3);
|
||||
background: rgba(255, 85, 126, 0.12);
|
||||
color: #ff4f7d;
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__action--remix {
|
||||
border-color: rgba(121, 82, 109, 0.14);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__like-count {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 850;
|
||||
gap: 0.22rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__like-count {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1rem;
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__row {
|
||||
margin-top: 0.36rem;
|
||||
margin-top: 0.12rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__identity {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
clearSignedAssetReadUrlCache,
|
||||
getSignedAssetReadUrl,
|
||||
readAssetBytes,
|
||||
resolveAssetReadUrl,
|
||||
} from './assetReadUrlService';
|
||||
|
||||
@@ -301,4 +302,30 @@ describe('assetReadUrlService', () => {
|
||||
expect(getStoredAccessToken()).toBe('test-access-token');
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('readAssetBytes reads generated resources through same-origin bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||||
import {
|
||||
ApiClientError,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
type ApiRequestOptions,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from './apiClient';
|
||||
|
||||
@@ -23,6 +25,11 @@ type AssetReadUrlResolveOptions = {
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
type AssetReadBytesOptions = {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
};
|
||||
|
||||
export type AssetReadUrlResponse = {
|
||||
read?: {
|
||||
objectKey?: string;
|
||||
@@ -44,6 +51,7 @@ type CachedReadUrlFailureEntry = {
|
||||
};
|
||||
|
||||
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
|
||||
const ASSET_READ_BYTES_API_PATH = '/api/assets/read-bytes';
|
||||
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
|
||||
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
|
||||
const ASSET_READ_URL_BACKGROUND_OPTIONS =
|
||||
@@ -72,6 +80,27 @@ function buildCacheKey(request: AssetReadUrlRequest) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildAssetReadSearchParams(request: AssetReadUrlRequest) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (request.objectKey?.trim()) {
|
||||
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
|
||||
}
|
||||
if (request.legacyPublicPath?.trim()) {
|
||||
searchParams.set(
|
||||
'legacyPublicPath',
|
||||
normalizeLegacyPublicPath(request.legacyPublicPath),
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof request.expireSeconds === 'number' &&
|
||||
Number.isFinite(request.expireSeconds) &&
|
||||
request.expireSeconds > 0
|
||||
) {
|
||||
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
function resolveSignedReadPayload(response: AssetReadUrlResponse) {
|
||||
const read = response.read ?? response;
|
||||
const signedUrl = typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
|
||||
@@ -146,23 +175,7 @@ export async function getSignedAssetReadUrl(
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (request.objectKey?.trim()) {
|
||||
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
|
||||
}
|
||||
if (request.legacyPublicPath?.trim()) {
|
||||
searchParams.set(
|
||||
'legacyPublicPath',
|
||||
normalizeLegacyPublicPath(request.legacyPublicPath),
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof request.expireSeconds === 'number' &&
|
||||
Number.isFinite(request.expireSeconds) &&
|
||||
request.expireSeconds > 0
|
||||
) {
|
||||
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
|
||||
}
|
||||
const searchParams = buildAssetReadSearchParams(request);
|
||||
|
||||
try {
|
||||
const response = await requestJson<AssetReadUrlResponse>(
|
||||
@@ -289,6 +302,49 @@ export async function resolveAssetReadUrl(
|
||||
return appendCacheBustParam(value, options.refreshKey);
|
||||
}
|
||||
|
||||
export async function readAssetBytes(
|
||||
source: string | null | undefined,
|
||||
options: AssetReadBytesOptions = {},
|
||||
) {
|
||||
const value = source?.trim() ?? '';
|
||||
if (!value) {
|
||||
throw new Error('资源路径不能为空');
|
||||
}
|
||||
|
||||
if (!isGeneratedLegacyPath(value)) {
|
||||
const response = await fetch(value, { signal: options.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error('读取资源内容失败');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
legacyPublicPath: value,
|
||||
expireSeconds: options.expireSeconds,
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
`${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
},
|
||||
{
|
||||
...ASSET_READ_URL_BACKGROUND_OPTIONS,
|
||||
omitEnvelopeHeader: true,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const message = await response
|
||||
.text()
|
||||
.then((text) => parseApiErrorMessage(text, '读取资源内容失败'))
|
||||
.catch(() => '');
|
||||
throw new Error(message || '读取资源内容失败');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function clearSignedAssetReadUrlCache() {
|
||||
signedReadUrlCache.clear();
|
||||
signedReadUrlFailureCache.clear();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
createMiniGameDraftGenerationState,
|
||||
type MiniGameDraftGenerationState,
|
||||
} from './miniGameDraftGenerationProgress';
|
||||
|
||||
@@ -150,6 +152,47 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d draft generation exposes item sheet and image asset steps', () => {
|
||||
const state = createMiniGameDraftGenerationState('match3d');
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 17_000,
|
||||
);
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'match3d-item-names',
|
||||
'match3d-material-sheet',
|
||||
'match3d-slice-images',
|
||||
'match3d-upload-images',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('match3d-material-sheet');
|
||||
expect(progress?.phaseLabel).toBe('生成素材图');
|
||||
expect(progress?.estimatedRemainingMs).toBe(103_000);
|
||||
});
|
||||
|
||||
test('match3d generation anchors show theme and fixed three items', () => {
|
||||
const entries = buildMatch3DGenerationAnchorEntries(null, {
|
||||
themeText: '水果',
|
||||
clearCount: 20,
|
||||
difficulty: 8,
|
||||
referenceImageSrc: null,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'match3d-theme',
|
||||
label: '题材',
|
||||
value: '水果',
|
||||
},
|
||||
{
|
||||
id: 'match3d-items',
|
||||
label: '物品数量',
|
||||
value: '3 件',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
|
||||
@@ -3,6 +3,10 @@ import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/match3dAgent';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldGenerationStep,
|
||||
@@ -10,7 +14,11 @@ import type {
|
||||
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
|
||||
|
||||
export type MiniGameDraftGenerationKind = 'puzzle' | 'big-fish' | 'square-hole';
|
||||
export type MiniGameDraftGenerationKind =
|
||||
| 'puzzle'
|
||||
| 'big-fish'
|
||||
| 'square-hole'
|
||||
| 'match3d';
|
||||
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
@@ -22,6 +30,11 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'square-hole-cover'
|
||||
| 'square-hole-shapes'
|
||||
| 'square-hole-ready'
|
||||
| 'match3d-item-names'
|
||||
| 'match3d-material-sheet'
|
||||
| 'match3d-slice-images'
|
||||
| 'match3d-upload-images'
|
||||
| 'match3d-ready'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-select-image'
|
||||
| 'ready'
|
||||
@@ -126,6 +139,33 @@ const SQUARE_HOLE_STEPS = [
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const MATCH3D_STEPS = [
|
||||
{
|
||||
id: 'match3d-item-names',
|
||||
label: '生成物品名称',
|
||||
detail: '根据题材生成本局的 3 个物品名称。',
|
||||
weight: 16,
|
||||
},
|
||||
{
|
||||
id: 'match3d-material-sheet',
|
||||
label: '生成素材图',
|
||||
detail: '生成一张 1:1 的网格素材图。',
|
||||
weight: 30,
|
||||
},
|
||||
{
|
||||
id: 'match3d-slice-images',
|
||||
label: '切割独立图片',
|
||||
detail: '把素材图切成独立物品参考图。',
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
id: 'match3d-upload-images',
|
||||
label: '上传图片资产',
|
||||
detail: '写入切割图片并准备进入草稿页。',
|
||||
weight: 40,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
@@ -137,6 +177,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'square-hole') {
|
||||
return SQUARE_HOLE_STEPS;
|
||||
}
|
||||
if (kind === 'match3d') {
|
||||
return MATCH3D_STEPS;
|
||||
}
|
||||
return BIG_FISH_STEPS;
|
||||
}
|
||||
|
||||
@@ -190,7 +233,9 @@ export function createMiniGameDraftGenerationState(
|
||||
? 'big-fish-draft'
|
||||
: kind === 'square-hole'
|
||||
? 'square-hole-draft'
|
||||
: 'compile',
|
||||
: kind === 'match3d'
|
||||
? 'match3d-item-names'
|
||||
: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
@@ -222,6 +267,21 @@ function resolveSquareHolePhaseByElapsedMs(
|
||||
return 'square-hole-draft';
|
||||
}
|
||||
|
||||
function resolveMatch3DPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 72_000) {
|
||||
return 'match3d-upload-images';
|
||||
}
|
||||
if (elapsedMs >= 58_000) {
|
||||
return 'match3d-slice-images';
|
||||
}
|
||||
if (elapsedMs >= 16_000) {
|
||||
return 'match3d-material-sheet';
|
||||
}
|
||||
return 'match3d-item-names';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
@@ -282,6 +342,13 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
...state,
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'match3d' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
@@ -307,6 +374,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 0.55
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? 0.42
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: 0;
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
@@ -334,7 +403,9 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
(normalizedState.phase === 'ready'
|
||||
? normalizedState.kind === 'big-fish'
|
||||
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: normalizedState.kind === 'match3d'
|
||||
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(cappedOverallProgress),
|
||||
@@ -350,7 +421,9 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: null,
|
||||
: normalizedState.kind === 'match3d'
|
||||
? Math.max(0, 120_000 - elapsedMs)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(
|
||||
steps,
|
||||
@@ -439,6 +512,43 @@ export function buildBigFishGenerationAnchorEntries(
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildMatch3DGenerationAnchorEntries(
|
||||
session: Match3DAgentSessionSnapshot | null | undefined,
|
||||
formPayload: CreateMatch3DSessionRequest | null | undefined = null,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
if (!session && !formPayload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const config = session?.config;
|
||||
const itemCount = 3;
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
{
|
||||
key: 'match3d-theme',
|
||||
label: '题材',
|
||||
value:
|
||||
formPayload?.themeText?.trim() ||
|
||||
config?.themeText?.trim() ||
|
||||
session?.anchorPack.theme.value ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'match3d-items',
|
||||
label: '物品数量',
|
||||
value: `${itemCount} 件`,
|
||||
},
|
||||
];
|
||||
|
||||
return entries
|
||||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||||
.map((entry) => ({
|
||||
id: entry.key,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
}))
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildSquareHoleGenerationAnchorEntries(
|
||||
session: SquareHoleSessionSnapshot | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user