1
This commit is contained in:
315
src/components/match3d-result/Match3DModelPreview.tsx
Normal file
315
src/components/match3d-result/Match3DModelPreview.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { Box, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { readAssetBytes } from '../../services/assetReadUrlService';
|
||||
|
||||
type ThreeModule = typeof import('three');
|
||||
type GltfPayload = import('three/examples/jsm/loaders/GLTFLoader.js').GLTF;
|
||||
|
||||
type PreviewStatus = 'empty' | 'loading' | 'ready' | 'fallback';
|
||||
|
||||
type Match3DModelPreviewProps = {
|
||||
modelSrc?: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function hasWebGLSupport() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function disposeThreeObject(object: import('three').Object3D) {
|
||||
object.traverse((child) => {
|
||||
const mesh = child as import('three').Mesh;
|
||||
mesh.geometry?.dispose();
|
||||
const material = mesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyCanvasLayout(canvas: HTMLCanvasElement) {
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.inset = '0';
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.width = '100%';
|
||||
}
|
||||
|
||||
function centerAndScaleModel(three: ThreeModule, model: import('three').Object3D) {
|
||||
const bounds = new three.Box3().setFromObject(model);
|
||||
const size = bounds.getSize(new three.Vector3());
|
||||
const maxDimension = Math.max(size.x, size.y, size.z, 0.001);
|
||||
const scale = 1.45 / maxDimension;
|
||||
model.scale.setScalar(scale);
|
||||
|
||||
const centeredBounds = new three.Box3().setFromObject(model);
|
||||
const center = centeredBounds.getCenter(new three.Vector3());
|
||||
model.position.sub(center);
|
||||
}
|
||||
|
||||
export function Match3DModelPreview({
|
||||
modelSrc,
|
||||
className = '',
|
||||
}: Match3DModelPreviewProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const runtimeRef = useRef<{
|
||||
animationId: number | null;
|
||||
cleanup: (() => void) | null;
|
||||
renderer: import('three').WebGLRenderer;
|
||||
} | null>(null);
|
||||
const [status, setStatus] = useState<PreviewStatus>(
|
||||
modelSrc ? 'loading' : 'empty',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvasHost = canvasHostRef.current;
|
||||
if (!container || !canvasHost) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = modelSrc?.trim() ?? '';
|
||||
if (!source) {
|
||||
setStatus('empty');
|
||||
runtimeRef.current?.cleanup?.();
|
||||
runtimeRef.current = null;
|
||||
canvasHost.replaceChildren();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const teardown = () => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (runtime?.animationId != null) {
|
||||
window.cancelAnimationFrame(runtime.animationId);
|
||||
}
|
||||
runtime?.cleanup?.();
|
||||
runtime?.renderer.dispose();
|
||||
runtime?.renderer.domElement.remove();
|
||||
runtimeRef.current = null;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
}
|
||||
canvasHost.replaceChildren();
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
if (!hasWebGLSupport()) {
|
||||
setStatus('fallback');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('loading');
|
||||
try {
|
||||
const [three, loaderModule, response] = await Promise.all([
|
||||
import('three'),
|
||||
import('three/examples/jsm/loaders/GLTFLoader.js'),
|
||||
readAssetBytes(source, { expireSeconds: 600 }),
|
||||
]);
|
||||
if (cancelled || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength === 0) {
|
||||
throw new Error('empty model');
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'model/gltf-binary',
|
||||
});
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
const renderer = new three.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
|
||||
renderer.outputColorSpace = three.SRGBColorSpace;
|
||||
applyCanvasLayout(renderer.domElement);
|
||||
canvasHost.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new three.Scene();
|
||||
scene.background = null;
|
||||
|
||||
const camera = new three.PerspectiveCamera(35, 1, 0.1, 100);
|
||||
camera.position.set(0.06, 0.92, 2.8);
|
||||
camera.lookAt(0, 0.1, 0);
|
||||
|
||||
scene.add(new three.AmbientLight(0xffffff, 1.55));
|
||||
|
||||
const keyLight = new three.DirectionalLight(0xffffff, 2.8);
|
||||
keyLight.position.set(-3.6, 4.8, 3.5);
|
||||
scene.add(keyLight);
|
||||
|
||||
const fillLight = new three.DirectionalLight(0xfef3c7, 0.62);
|
||||
fillLight.position.set(2.5, 1.8, -3.2);
|
||||
scene.add(fillLight);
|
||||
|
||||
const rimLight = new three.DirectionalLight(0xffffff, 0.8);
|
||||
rimLight.position.set(1.8, 3.6, -4.4);
|
||||
scene.add(rimLight);
|
||||
|
||||
const modelRoot = new three.Group();
|
||||
modelRoot.rotation.set(0.2, 0.45, 0.02);
|
||||
scene.add(modelRoot);
|
||||
|
||||
const loader = new loaderModule.GLTFLoader();
|
||||
const gltf = await new Promise<GltfPayload>(
|
||||
(resolve, reject) => {
|
||||
loader.load(
|
||||
objectUrl as string,
|
||||
(loaded: GltfPayload) => resolve(loaded),
|
||||
undefined,
|
||||
(error) => reject(error),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (cancelled) {
|
||||
const cancelledModel = gltf.scene ?? gltf.scenes[0];
|
||||
if (cancelledModel) {
|
||||
disposeThreeObject(cancelledModel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const model = gltf.scene ?? gltf.scenes[0];
|
||||
if (!model) {
|
||||
throw new Error('missing model scene');
|
||||
}
|
||||
|
||||
modelRoot.add(model);
|
||||
centerAndScaleModel(three, model);
|
||||
|
||||
const resize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const width = Math.max(1, rect.width);
|
||||
const height = Math.max(1, rect.height);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
const resizeObserver = window.ResizeObserver
|
||||
? new window.ResizeObserver(resize)
|
||||
: null;
|
||||
resizeObserver?.observe(container);
|
||||
resize();
|
||||
|
||||
const pointerState = {
|
||||
dragging: false,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
pointerState.dragging = true;
|
||||
pointerState.lastX = event.clientX;
|
||||
pointerState.lastY = event.clientY;
|
||||
if (typeof container.setPointerCapture === 'function') {
|
||||
container.setPointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!pointerState.dragging) {
|
||||
return;
|
||||
}
|
||||
const deltaX = event.clientX - pointerState.lastX;
|
||||
const deltaY = event.clientY - pointerState.lastY;
|
||||
pointerState.lastX = event.clientX;
|
||||
pointerState.lastY = event.clientY;
|
||||
modelRoot.rotation.y += deltaX * 0.01;
|
||||
modelRoot.rotation.x = Math.max(
|
||||
-1.15,
|
||||
Math.min(1.15, modelRoot.rotation.x + deltaY * 0.01),
|
||||
);
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
const handlePointerEnd = (event: PointerEvent) => {
|
||||
pointerState.dragging = false;
|
||||
if (
|
||||
typeof container.hasPointerCapture === 'function' &&
|
||||
container.hasPointerCapture(event.pointerId)
|
||||
) {
|
||||
container.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('pointerdown', handlePointerDown);
|
||||
container.addEventListener('pointermove', handlePointerMove);
|
||||
container.addEventListener('pointerup', handlePointerEnd);
|
||||
container.addEventListener('pointercancel', handlePointerEnd);
|
||||
|
||||
const animate = () => {
|
||||
renderer.render(scene, camera);
|
||||
if (runtimeRef.current) {
|
||||
runtimeRef.current.animationId = window.requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
runtimeRef.current = {
|
||||
animationId: window.requestAnimationFrame(animate),
|
||||
cleanup: () => {
|
||||
resizeObserver?.disconnect();
|
||||
container.removeEventListener('pointerdown', handlePointerDown);
|
||||
container.removeEventListener('pointermove', handlePointerMove);
|
||||
container.removeEventListener('pointerup', handlePointerEnd);
|
||||
container.removeEventListener('pointercancel', handlePointerEnd);
|
||||
disposeThreeObject(modelRoot);
|
||||
},
|
||||
renderer,
|
||||
};
|
||||
|
||||
setStatus('ready');
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus('fallback');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
teardown();
|
||||
};
|
||||
}, [modelSrc]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-label="3D 模型预览"
|
||||
className={`relative overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/78 ${className}`}
|
||||
data-testid="match3d-model-preview"
|
||||
>
|
||||
<div ref={canvasHostRef} aria-hidden="true" className="absolute inset-0" />
|
||||
{status !== 'ready' ? (
|
||||
<div className="absolute inset-0 grid place-items-center text-[var(--platform-text-soft)]">
|
||||
{status === 'loading' ? (
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
) : (
|
||||
<Box className="h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-0 z-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,14 +21,14 @@ 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('./Match3DModelPreview', () => ({
|
||||
Match3DModelPreview: ({
|
||||
modelSrc,
|
||||
}: {
|
||||
modelSrc?: string | null;
|
||||
}) => (
|
||||
<div data-model-src={modelSrc ?? ''} data-testid="match3d-model-preview" />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/assetReadUrlService', () => ({
|
||||
@@ -45,6 +45,7 @@ vi.mock('../../services/assetReadUrlService', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-works', () => ({
|
||||
generateMatch3DWorkTags: vi.fn(),
|
||||
publishMatch3DWork: vi.fn(),
|
||||
updateMatch3DWork: vi.fn(),
|
||||
}));
|
||||
@@ -53,7 +54,6 @@ vi.mock('../../services/hyper3dModelGenerationService', () => ({
|
||||
getHyper3dDownloads: vi.fn(),
|
||||
getHyper3dTaskStatus: vi.fn(),
|
||||
submitHyper3dImageToModel: vi.fn(),
|
||||
submitHyper3dTextToModel: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
@@ -71,8 +71,8 @@ function createProfile(
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '水果主题的经典消除玩法。',
|
||||
tags: ['水果'],
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅', '经典消除'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 4,
|
||||
@@ -87,6 +87,68 @@ function createProfile(
|
||||
}
|
||||
|
||||
describe('Match3DResultView', () => {
|
||||
test('作品信息 Tab 字段命名对齐拼图草稿且描述可为空', async () => {
|
||||
const profile = createProfile();
|
||||
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
item: profile,
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('作品名称')).toHaveProperty(
|
||||
'value',
|
||||
'水果抓大鹅',
|
||||
);
|
||||
expect(screen.getByLabelText('作品描述')).toHaveProperty('value', '');
|
||||
expect(screen.getByText('作品标签')).toBeTruthy();
|
||||
expect(screen.getByText('水果')).toBeTruthy();
|
||||
expect(screen.getByText('抓大鹅')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
|
||||
'match3d-profile-1',
|
||||
expect.objectContaining({
|
||||
gameName: '水果抓大鹅',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅', '经典消除'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('作品标签支持 AI 生成并写回标签编辑区', async () => {
|
||||
vi.mocked(match3dWorksService.generateMatch3DWorkTags).mockResolvedValue({
|
||||
tags: ['果园', '抓大鹅', '经典消除', '轻量休闲'],
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({ tags: [] })}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dWorksService.generateMatch3DWorkTags).toHaveBeenCalledWith({
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
});
|
||||
expect(screen.getByText('果园')).toBeTruthy();
|
||||
expect(screen.getByText('轻量休闲')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => {
|
||||
const profile = createProfile();
|
||||
const onStartTestRun = vi.fn();
|
||||
@@ -121,7 +183,7 @@ describe('Match3DResultView', () => {
|
||||
test('发布仍要求封面和标签数量满足门槛', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile()}
|
||||
profile={createProfile({ tags: ['水果', '抓大鹅'] })}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
@@ -150,22 +212,15 @@ describe('Match3DResultView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /水果核心物件/u }));
|
||||
|
||||
expect(screen.getByText('素材名称')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '文生模型' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '图生模型' })).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-model-preview')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重新生成' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '文生模型' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '图生模型' })).toBeNull();
|
||||
expect(screen.queryByText('用途')).toBeNull();
|
||||
expect(screen.queryByText('提示词')).toBeNull();
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
test('重新生成缺少参考图时阻止提交', async () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({ themeText: '水果' })}
|
||||
@@ -176,42 +231,15 @@ describe('Match3DResultView', () => {
|
||||
|
||||
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: '生成' });
|
||||
const generateButton = screen.getByRole('button', { name: '重新生成' });
|
||||
expect(generateButton).toHaveProperty('disabled', true);
|
||||
expect(hyper3dService.submitHyper3dImageToModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('结果页优先预览生成出来的物品图片和模型文件', () => {
|
||||
test('结果页优先把生成出来的模型文件交给模型预览', () => {
|
||||
const modelSrc =
|
||||
'/generated-match3d-assets/session/profile/items/strawberry/model/model.glb';
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
@@ -223,7 +251,7 @@ describe('Match3DResultView', () => {
|
||||
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',
|
||||
modelSrc,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
@@ -244,13 +272,12 @@ describe('Match3DResultView', () => {
|
||||
|
||||
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',
|
||||
expect(screen.getByTestId('match3d-model-preview').getAttribute('data-model-src')).toBe(
|
||||
modelSrc,
|
||||
);
|
||||
});
|
||||
|
||||
test('草稿阶段仅有切割图片时展示图片已就绪,不要求模型文件', () => {
|
||||
test('草稿阶段仅有切割图片时模型预览为空', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
@@ -282,7 +309,7 @@ describe('Match3DResultView', () => {
|
||||
|
||||
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
|
||||
expect(screen.getAllByText('图片已就绪').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('0 文件')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-model-preview').getAttribute('data-model-src')).toBe('');
|
||||
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -348,6 +375,24 @@ describe('Match3DResultView', () => {
|
||||
message: 'submitted',
|
||||
tier: 'Gen-2',
|
||||
});
|
||||
vi.mocked(hyper3dService.getHyper3dTaskStatus).mockResolvedValue({
|
||||
ok: true,
|
||||
provider: 'hyper3d-rodin',
|
||||
status: 'done',
|
||||
jobs: [],
|
||||
raw: {},
|
||||
});
|
||||
vi.mocked(hyper3dService.getHyper3dDownloads).mockResolvedValue({
|
||||
ok: true,
|
||||
provider: 'hyper3d-rodin',
|
||||
files: [
|
||||
{
|
||||
name: 'strawberry.glb',
|
||||
url: 'https://cdn.example.com/strawberry.glb',
|
||||
},
|
||||
],
|
||||
raw: {},
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
|
||||
render(
|
||||
@@ -378,7 +423,7 @@ describe('Match3DResultView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assetReadUrlService.readAssetBytes).toHaveBeenCalledWith(
|
||||
@@ -392,6 +437,9 @@ describe('Match3DResultView', () => {
|
||||
prompt: expect.stringContaining('草莓'),
|
||||
}),
|
||||
);
|
||||
expect(hyper3dService.getHyper3dDownloads).toHaveBeenCalledWith({
|
||||
taskUuid: 'task-image',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user