Merge branch 'master' into codex/frontend-error-dialogs
# Conflicts: # .hermes/shared-memory/decision-log.md # server-rs/crates/api-server/src/generated_asset_sheets.rs
This commit is contained in:
@@ -174,7 +174,7 @@ test('baby object result blocks placeholder assets and exposes regeneration', as
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。'),
|
||||
screen.getByText('当前作品仍是占位资源,请重新生成素材后再试玩或发布。'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '试玩' }) as HTMLButtonElement)
|
||||
|
||||
@@ -158,7 +158,7 @@ export function BabyObjectMatchResultView({
|
||||
|
||||
{!hasGeneratedAssets ? (
|
||||
<div className="platform-banner mt-3 rounded-2xl text-sm leading-6">
|
||||
当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。
|
||||
当前作品仍是占位资源,请重新生成素材后再试玩或发布。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -235,35 +228,58 @@ test('顶部 HUD 对齐拼图样式展示关卡名和倒计时', () => {
|
||||
expect(screen.getByText('第 1 关')).toBeTruthy();
|
||||
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
|
||||
expect(screen.getByText('10:00')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '打开抓大鹅设置' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '重新开始' })).toBeNull();
|
||||
expect(screen.getByTestId('match3d-runtime-level-logo')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('水果抓大鹅').closest('.puzzle-runtime-level-title-card'),
|
||||
).toBeTruthy();
|
||||
const timerCard = screen.getByText('10:00').closest('.puzzle-runtime-timer-card');
|
||||
expect(timerCard).toBeTruthy();
|
||||
expect(timerCard?.className).toContain('puzzle-runtime-timer');
|
||||
expect(screen.queryByRole('button', { name: '打开抓大鹅设置' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '返回' })).toBeTruthy();
|
||||
expect(screen.queryByTestId('match3d-ui-sprite-settings')).toBeNull();
|
||||
});
|
||||
|
||||
test('抓大鹅右上角设置面板内置重新开始', () => {
|
||||
const run = startLocalMatch3DRun(4);
|
||||
const onRestart = vi.fn();
|
||||
test('抓大鹅运行态不再渲染设置入口', () => {
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
levelName="水果抓大鹅"
|
||||
run={startLocalMatch3DRun(4)}
|
||||
onBack={vi.fn()}
|
||||
onRestart={onRestart}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开抓大鹅设置' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '抓大鹅设置' });
|
||||
expect(within(dialog).getByText('水果抓大鹅')).toBeTruthy();
|
||||
expect(within(dialog).getByText('已清除 0/12')).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '重新开始' }));
|
||||
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('button', { name: '打开抓大鹅设置' })).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '抓大鹅设置' })).toBeNull();
|
||||
});
|
||||
|
||||
test('抓大鹅顶部和底部保留交互边界但不显示旧半透底', () => {
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={startLocalMatch3DRun(4)}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('match3d-board').className).toContain(
|
||||
'bg-transparent',
|
||||
);
|
||||
expect(screen.getAllByTestId('match3d-tray-slot')[0]!.className).toContain(
|
||||
'bg-transparent',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '移出' }).className,
|
||||
).toContain('bg-transparent');
|
||||
expect(screen.getByRole('button', { name: '返回' }).className).toContain(
|
||||
'bg-transparent',
|
||||
);
|
||||
});
|
||||
|
||||
test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => {
|
||||
const run: Match3DRunSnapshot = {
|
||||
...startLocalMatch3DRun(4),
|
||||
@@ -1548,9 +1564,7 @@ test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () =
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,返回');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,设置');
|
||||
expect(screen.queryByTestId('match3d-ui-sprite-settings')).toBeNull();
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,移出');
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Clock,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -17,6 +15,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import match3DRuntimeLevelLogo from '../../../media/logo.png';
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
@@ -71,19 +70,9 @@ import {
|
||||
} from './match3dRuntimePresentation';
|
||||
import {
|
||||
MATCH3D_RUNTIME_BOARD_BASE_CLASS,
|
||||
MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS,
|
||||
MATCH3D_RUNTIME_BOARD_WIDTH,
|
||||
MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS,
|
||||
MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS,
|
||||
MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS,
|
||||
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
|
||||
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
|
||||
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
|
||||
MATCH3D_RUNTIME_HEADER_CARD_CLASS,
|
||||
MATCH3D_RUNTIME_LEVEL_BADGE_CLASS,
|
||||
MATCH3D_RUNTIME_STAGE_CLASS,
|
||||
MATCH3D_RUNTIME_TIMER_CLASS,
|
||||
MATCH3D_RUNTIME_TIMER_URGENT_CLASS,
|
||||
} from './match3dRuntimeUiStyles';
|
||||
import { Match3DVisualIcon, resolveVisualSeed } from './match3dVisualAssets';
|
||||
|
||||
@@ -769,7 +758,7 @@ function Match3DTrayToken({
|
||||
}) {
|
||||
if (!slot.visualKey) {
|
||||
return (
|
||||
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
|
||||
<span className="h-full w-full rounded-none border border-dashed border-white/18 bg-transparent" />
|
||||
);
|
||||
}
|
||||
const visualSeed = resolveVisualSeed(slot.visualKey);
|
||||
@@ -1030,7 +1019,6 @@ export function Match3DRuntimeShell({
|
||||
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
||||
const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] =
|
||||
useState('');
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
|
||||
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
|
||||
const runtimeGeneratedItemAssets = useMemo(
|
||||
@@ -1366,10 +1354,6 @@ export function Match3DRuntimeShell({
|
||||
useState('');
|
||||
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] =
|
||||
useState('');
|
||||
const [isContainerImageLoaded, setIsContainerImageLoaded] = useState(false);
|
||||
const hasRenderedContainerAsset = Boolean(
|
||||
resolvedContainerImageSrc && isContainerImageLoaded,
|
||||
);
|
||||
const clickSoundByTypeId = useMemo(() => {
|
||||
if (!run) {
|
||||
return new Map<string, string>();
|
||||
@@ -1489,7 +1473,6 @@ export function Match3DRuntimeShell({
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setResolvedContainerImageSrc('');
|
||||
setIsContainerImageLoaded(false);
|
||||
if (!isGeneratedLegacyPath(containerAssetSrc)) {
|
||||
setResolvedContainerImageSrc(containerAssetSrc);
|
||||
return undefined;
|
||||
@@ -1501,7 +1484,6 @@ export function Match3DRuntimeShell({
|
||||
.then((resolvedSrc) => {
|
||||
if (!cancelled) {
|
||||
setResolvedContainerImageSrc(resolvedSrc);
|
||||
setIsContainerImageLoaded(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -1511,7 +1493,6 @@ export function Match3DRuntimeShell({
|
||||
? ''
|
||||
: MATCH3D_CONTAINER_REFERENCE_SRC,
|
||||
);
|
||||
setIsContainerImageLoaded(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1937,9 +1918,8 @@ export function Match3DRuntimeShell({
|
||||
const timerClassName =
|
||||
timeLeftMs <= levelAudioConfig.countdownWarningThresholdMs &&
|
||||
isRunState(run.status, 'running')
|
||||
? MATCH3D_RUNTIME_TIMER_URGENT_CLASS
|
||||
: MATCH3D_RUNTIME_TIMER_CLASS;
|
||||
const canRestartRun = Boolean(run?.runId) && !isBusy;
|
||||
? 'puzzle-runtime-timer--urgent'
|
||||
: 'puzzle-runtime-timer';
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -1978,7 +1958,7 @@ export function Match3DRuntimeShell({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-transparent bg-transparent text-white shadow-none transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 sm:h-11 sm:w-11"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
>
|
||||
@@ -1992,43 +1972,38 @@ export function Match3DRuntimeShell({
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
<div className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
|
||||
<div className="flex max-w-full items-center justify-center gap-1.5">
|
||||
<span className={MATCH3D_RUNTIME_LEVEL_BADGE_CLASS}>第 1 关</span>
|
||||
<span className="min-w-0 truncate text-sm font-black sm:text-base">
|
||||
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
|
||||
<div className="puzzle-runtime-level-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:px-4 sm:pr-5">
|
||||
<span aria-hidden="true" className="puzzle-runtime-level-logo">
|
||||
<img
|
||||
src={match3DRuntimeLevelLogo}
|
||||
alt=""
|
||||
data-testid="match3d-runtime-level-logo"
|
||||
className="puzzle-runtime-level-logo__image"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
<span className="puzzle-runtime-level-badge shrink-0 text-[0.92rem] font-black sm:text-base">
|
||||
第 1 关
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[0.92rem] font-black sm:text-base">
|
||||
{displayLevelName}
|
||||
</span>
|
||||
</div>
|
||||
<div className={timerClassName}>
|
||||
<Clock3 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<div
|
||||
className={`puzzle-runtime-timer-card -mt-px inline-flex items-center gap-1.5 px-3.5 py-1.5 font-mono text-lg font-black leading-none sm:text-xl ${timerClassName}`}
|
||||
>
|
||||
<Clock className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
{formatTimer(timeLeftMs)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
|
||||
onClick={() => setIsSettingsPanelOpen(true)}
|
||||
aria-label="打开抓大鹅设置"
|
||||
>
|
||||
<Match3DSpriteImage
|
||||
region={uiSpritesheetRegionByLabel.get('设置')}
|
||||
testId="match3d-ui-sprite-settings"
|
||||
className="h-7 w-7 object-contain"
|
||||
/>
|
||||
{!uiSpritesheetRegionByLabel.get('设置') ? (
|
||||
<Settings size={18} />
|
||||
) : null}
|
||||
</button>
|
||||
<div aria-hidden="true" />
|
||||
</header>
|
||||
|
||||
<section className={MATCH3D_RUNTIME_STAGE_CLASS}>
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} ${
|
||||
hasRenderedContainerAsset
|
||||
? MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS
|
||||
: MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS
|
||||
}`}
|
||||
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} overflow-hidden rounded-[50%] border border-white/14 bg-transparent shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]`}
|
||||
style={{
|
||||
width: MATCH3D_RUNTIME_BOARD_WIDTH,
|
||||
}}
|
||||
@@ -2043,22 +2018,11 @@ export function Match3DRuntimeShell({
|
||||
src={resolvedContainerImageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={`${MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS} ${
|
||||
isContainerImageLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
className={`${MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS} opacity-0`}
|
||||
data-testid="match3d-container-image"
|
||||
onLoad={() => setIsContainerImageLoaded(true)}
|
||||
onError={() => {
|
||||
setIsContainerImageLoaded(false);
|
||||
setResolvedContainerImageSrc((currentSrc) =>
|
||||
currentSrc && currentSrc !== MATCH3D_CONTAINER_REFERENCE_SRC
|
||||
? MATCH3D_CONTAINER_REFERENCE_SRC
|
||||
: '',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS} />
|
||||
<div className="pointer-events-none absolute inset-0 z-0 rounded-full border border-white/10 bg-transparent" />
|
||||
)}
|
||||
{run.items.map((item) =>
|
||||
hasPendingMatch3DGeneratedImageForItem(
|
||||
@@ -2091,7 +2055,7 @@ export function Match3DRuntimeShell({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={MATCH3D_RUNTIME_GLASS_TRAY_CLASS}>
|
||||
<section className="mt-3 w-full min-w-0">
|
||||
<div
|
||||
className="relative grid grid-cols-7 gap-1.5"
|
||||
data-testid="match3d-tray"
|
||||
@@ -2107,7 +2071,7 @@ export function Match3DRuntimeShell({
|
||||
return (
|
||||
<div
|
||||
key={slot.slotIndex}
|
||||
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
|
||||
className="relative z-0 h-14 min-w-0 rounded-none border border-transparent bg-transparent p-0 sm:h-16"
|
||||
data-testid="match3d-tray-slot"
|
||||
data-slot-index={slot.slotIndex}
|
||||
ref={(element) => {
|
||||
@@ -2170,7 +2134,7 @@ export function Match3DRuntimeShell({
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className="flex min-h-12 items-center justify-center overflow-hidden rounded-[1rem] border border-white/58 bg-white/50 px-2 py-2 text-sm font-black text-slate-800 shadow-[0_10px_24px_rgba(15,23,42,0.14)] backdrop-blur-md"
|
||||
className="flex min-h-12 items-center justify-center overflow-hidden rounded-none border border-transparent bg-transparent px-1 py-2 text-sm font-black text-white shadow-none transition hover:bg-white/10"
|
||||
aria-label={label}
|
||||
>
|
||||
<Match3DSpriteImage
|
||||
@@ -2219,84 +2183,6 @@ export function Match3DRuntimeShell({
|
||||
onBack={onBack}
|
||||
onRestart={onRestart}
|
||||
/>
|
||||
|
||||
{isSettingsPanelOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-[85] flex items-center justify-center bg-slate-950/42 px-4 py-6 backdrop-blur-sm"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="match3d-settings-title"
|
||||
className="w-full max-w-[20.5rem] overflow-hidden rounded-[1.35rem] border border-white/18 bg-white/95 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="border-b border-slate-200 px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
id="match3d-settings-title"
|
||||
className="text-base font-black"
|
||||
>
|
||||
抓大鹅设置
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭设置"
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
>
|
||||
<XCircle size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<div className="rounded-[1rem] border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">
|
||||
当前进度
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-black text-slate-900">
|
||||
{displayLevelName}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
已清除 {run.clearedItemCount}/{run.totalItemCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">
|
||||
本局时间
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-xl font-black text-slate-900">
|
||||
{formatTimer(timeLeftMs)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="grid gap-3 border-t border-slate-200 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-slate-950 px-4 py-3 text-sm font-black text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
disabled={!canRestartRun}
|
||||
onClick={() => {
|
||||
setIsSettingsPanelOpen(false);
|
||||
onRestart();
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
重新开始
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-12 items-center justify-center rounded-[1rem] border border-slate-200 bg-white px-4 py-3 text-sm font-bold text-slate-700 transition hover:bg-slate-50"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
>
|
||||
继续抓大鹅
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -411,7 +411,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle workspace switches the image model from the description box', () => {
|
||||
test('puzzle workspace switches image mode without exposing model names', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -427,9 +427,9 @@ test('puzzle workspace switches the image model from the description box', () =>
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
|
||||
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '图片生成模式' }));
|
||||
expect(screen.queryByText(/gpt|nanobanana|gemini/u)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: '创意模式' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
confirmPuzzlePointCost();
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ export function PuzzleImageModelPicker({
|
||||
className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-label="图片模型"
|
||||
title="图片模型"
|
||||
aria-label="图片生成模式"
|
||||
title="图片生成模式"
|
||||
>
|
||||
<span className="truncate">
|
||||
{getPuzzleImageModelLabel(normalizedValue)}
|
||||
|
||||
@@ -9,8 +9,8 @@ export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{
|
||||
id: PuzzleImageModelId;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' },
|
||||
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' },
|
||||
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: '标准模式' },
|
||||
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: '创意模式' },
|
||||
];
|
||||
|
||||
export function normalizePuzzleImageModel(
|
||||
@@ -25,6 +25,6 @@ export function normalizePuzzleImageModel(
|
||||
export function getPuzzleImageModelLabel(model: PuzzleImageModelId) {
|
||||
return (
|
||||
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ??
|
||||
'gpt-image-2'
|
||||
'标准模式'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1305,7 +1305,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull();
|
||||
});
|
||||
|
||||
test('passes the selected image model when regenerating a level image', () => {
|
||||
test('passes the selected image mode without exposing model names', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -1319,9 +1319,12 @@ describe('PuzzleResultView', () => {
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' }));
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }),
|
||||
within(dialog).getByRole('button', { name: '图片生成模式' }),
|
||||
);
|
||||
expect(within(dialog).queryByText(/gpt|nanobanana|gemini/u)).toBeNull();
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('menuitemradio', { name: '标准模式' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /重新生成画面/u }),
|
||||
|
||||
@@ -882,6 +882,7 @@ test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关
|
||||
expect(nextSprite).toBeTruthy();
|
||||
expect(nextSprite?.style.backgroundSize).toBe('320% 480%');
|
||||
expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%');
|
||||
expect(screen.getByRole('button', { name: '下一关' }).textContent).toBe('');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
@@ -971,6 +972,11 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
nextLevelProfileId: 'profile-1',
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
@@ -986,7 +992,9 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
const nextButton = screen.getByRole('button', { name: /下一关/u });
|
||||
@@ -1002,6 +1010,53 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('推荐页关闭通关弹窗后保留底部下一关入口且不叠加下一关素材图', async () => {
|
||||
vi.useFakeTimers();
|
||||
const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: 'profile-1',
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithoutRecommendedNextProfile}
|
||||
embedded
|
||||
hideBackButton
|
||||
hideExitControls
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
});
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
const nextButton = screen.getByRole('button', { name: /下一关/u });
|
||||
expect(nextButton).toBeTruthy();
|
||||
expect(
|
||||
nextButton.querySelector('[data-puzzle-ui-sprite="next"]'),
|
||||
).toBeTruthy();
|
||||
expect(nextButton.textContent?.trim()).toBe('');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('当前作品没有下一关时展示三个相似作品并可选择进入', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
|
||||
@@ -1933,6 +1933,7 @@ export function PuzzleRuntimeShell({
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
aria-label={hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||||
onClick={() => {
|
||||
if (hasSimilarWorkChoices) {
|
||||
setDismissedClearKey(null);
|
||||
@@ -1944,9 +1945,8 @@ export function PuzzleRuntimeShell({
|
||||
levelId: run.nextLevelId ?? null,
|
||||
});
|
||||
}}
|
||||
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
|
||||
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center justify-center rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
|
||||
>
|
||||
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="next"
|
||||
|
||||
@@ -83,7 +83,10 @@ import {
|
||||
saveBabyObjectMatchDraft,
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
||||
import {
|
||||
createLocalMatch3DRuntimeAdapter,
|
||||
createServerMatch3DRuntimeAdapter,
|
||||
} from '../../services/match3d-runtime';
|
||||
import {
|
||||
deleteMatch3DWork,
|
||||
getMatch3DWorkDetail,
|
||||
@@ -257,6 +260,13 @@ function queryCreationTypeButton(name: string | RegExp) {
|
||||
});
|
||||
}
|
||||
|
||||
async function openPuzzleFormFromCreateHub(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await screen.findByText(/拼图工作区:/u);
|
||||
}
|
||||
|
||||
async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '草稿');
|
||||
const panel = getPlatformTabPanel('saves');
|
||||
@@ -291,7 +301,9 @@ async function openProfilePlayedWorks(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
await clickFirstButtonByName(user, '我的');
|
||||
await user.click(await screen.findByRole('button', { name: /玩过/u }));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: /已玩游戏数量/u }),
|
||||
);
|
||||
expect(await screen.findByText('可继续')).toBeTruthy();
|
||||
}
|
||||
|
||||
@@ -324,6 +336,13 @@ function getPlatformTabPanel(tab: string) {
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function findPlatformTabPanel(tab: string) {
|
||||
await waitFor(() => {
|
||||
expect(document.getElementById(`platform-tab-panel-${tab}`)).toBeTruthy();
|
||||
});
|
||||
return getPlatformTabPanel(tab);
|
||||
}
|
||||
|
||||
const testCreationEntryConfig = {
|
||||
startCard: {
|
||||
title: '新建作品',
|
||||
@@ -655,6 +674,7 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({
|
||||
}));
|
||||
|
||||
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
|
||||
createLocalMatch3DRuntimeAdapter: vi.fn(),
|
||||
createServerMatch3DRuntimeAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -667,6 +687,15 @@ const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({
|
||||
stopRun: vi.fn(),
|
||||
}));
|
||||
|
||||
const match3dLocalRuntimeAdapterMock = vi.hoisted(() => ({
|
||||
clickItem: vi.fn(),
|
||||
finishTimeUp: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
restartRun: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/match3d-runtime')
|
||||
@@ -2376,6 +2405,9 @@ beforeEach(() => {
|
||||
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
|
||||
match3dServerRuntimeAdapterMock,
|
||||
);
|
||||
vi.mocked(createLocalMatch3DRuntimeAdapter).mockReturnValue(
|
||||
match3dLocalRuntimeAdapterMock,
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.startRun.mockRejectedValue(
|
||||
new Error('未启动抓大鹅运行态'),
|
||||
);
|
||||
@@ -2391,6 +2423,21 @@ beforeEach(() => {
|
||||
match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-stopped'),
|
||||
});
|
||||
match3dLocalRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-demo-20260525'),
|
||||
});
|
||||
match3dLocalRuntimeAdapterMock.clickItem.mockRejectedValue(
|
||||
new Error('未执行本地抓大鹅点击'),
|
||||
);
|
||||
match3dLocalRuntimeAdapterMock.restartRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-demo-20260525'),
|
||||
});
|
||||
match3dLocalRuntimeAdapterMock.finishTimeUp.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-demo-20260525'),
|
||||
});
|
||||
match3dLocalRuntimeAdapterMock.stopRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-demo-20260525'),
|
||||
});
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
@@ -3469,7 +3516,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('tablist', { name: '玩法模板分类' }).className,
|
||||
).toContain('scroll-px-3');
|
||||
).toContain('scroll-px-2');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
@@ -3511,7 +3558,7 @@ test('create tab opens puzzle entry form from the template card', async () => {
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await openPuzzleFormFromCreateHub(user);
|
||||
|
||||
expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
@@ -3663,7 +3710,11 @@ test('bark battle draft is visible in draft shelf while image assets are generat
|
||||
await user.click(await findCreationTypeButton('汪汪声浪'));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
|
||||
expect(await screen.findByText('自动生成素材')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '汪汪声浪素材生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回编辑' }));
|
||||
await openDraftHub(user);
|
||||
@@ -3728,7 +3779,7 @@ test('published bark battle stays visible when refresh temporarily returns only
|
||||
|
||||
await openDraftHub(user);
|
||||
const panel = getPlatformTabPanel('saves');
|
||||
await user.click(within(panel).getByRole('button', { name: /已发布/u }));
|
||||
await user.click(within(panel).getByRole('tab', { name: /已发布/u }));
|
||||
|
||||
expect(await within(panel).findByText('汪汪测试杯')).toBeTruthy();
|
||||
expect(
|
||||
@@ -3762,7 +3813,11 @@ test('running match3d form generation can return to draft tab and reopen progres
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '抓大鹅草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
|
||||
@@ -3772,7 +3827,11 @@ test('running match3d form generation can return to draft tab and reopen progres
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /继续创作《抓大鹅草稿》/u }),
|
||||
);
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '抓大鹅草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveCompile({ session: buildMockMatch3DAgentSession() });
|
||||
@@ -3845,7 +3904,11 @@ test('running match3d persisted draft reopens progress instead of unfinished res
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '抓大鹅草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findAllByText('素材生成仍在后台处理')).not.toHaveLength(
|
||||
0,
|
||||
);
|
||||
@@ -3859,7 +3922,11 @@ test('running match3d persisted draft reopens progress instead of unfinished res
|
||||
await screen.findByRole('button', { name: /继续创作《赛博水果摊》/u }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '抓大鹅草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
|
||||
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
|
||||
'match3d-running-persisted-session',
|
||||
@@ -4042,13 +4109,18 @@ test('running match3d form generation keeps other creation templates available',
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '抓大鹅草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
|
||||
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
|
||||
await openCreateTemplateHub(user);
|
||||
const puzzleCard = await findCreationTypeButton('拼图');
|
||||
expect((puzzleCard as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
await user.click(puzzleTab);
|
||||
await user.click(puzzleCard);
|
||||
const generatePuzzleButton = await screen.findByRole('button', {
|
||||
name: '生成草稿',
|
||||
});
|
||||
@@ -4111,12 +4183,17 @@ test('running match3d form generation keeps same template generation available',
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '抓大鹅草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
const match3dTab = await screen.findByRole('tab', { name: '抓大鹅' });
|
||||
expect((match3dTab as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(match3dTab);
|
||||
await openCreateTemplateHub(user);
|
||||
const match3dCard = await findCreationTypeButton('抓大鹅');
|
||||
expect((match3dCard as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(match3dCard);
|
||||
|
||||
const secondGenerateButton = await screen.findByRole('button', {
|
||||
name: '生成抓大鹅草稿',
|
||||
@@ -4143,7 +4220,11 @@ test('running match3d form generation keeps same template generation available',
|
||||
expect.objectContaining({ action: 'match3d_compile_draft' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '抓大鹅草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
await waitFor(() => {
|
||||
@@ -4213,15 +4294,23 @@ test('running puzzle form generation creates a new puzzle draft on same template
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成草稿' }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '拼图草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
|
||||
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(puzzleTab);
|
||||
await openCreateTemplateHub(user);
|
||||
const puzzleCard = await findCreationTypeButton('拼图');
|
||||
expect((puzzleCard as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(puzzleCard);
|
||||
|
||||
expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy();
|
||||
expect(await screen.findByText(/拼图工作区:/u)).toBeTruthy();
|
||||
expect(screen.getByTestId('puzzle-workspace-busy-state')).toHaveProperty(
|
||||
'textContent',
|
||||
'idle',
|
||||
@@ -4232,9 +4321,7 @@ test('running puzzle form generation creates a new puzzle draft on same template
|
||||
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(secondGenerateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
|
||||
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -4243,11 +4330,15 @@ test('running puzzle form generation creates a new puzzle draft on same template
|
||||
);
|
||||
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'puzzle-parallel-session-2',
|
||||
'puzzle-session-1',
|
||||
expect.objectContaining({ action: 'compile_puzzle_draft' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '拼图草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
await waitFor(() => {
|
||||
@@ -4319,8 +4410,15 @@ test('running puzzle draft opens generation progress from draft tab', async () =
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成草稿' }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '拼图草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
@@ -4330,7 +4428,11 @@ test('running puzzle draft opens generation progress from draft tab', async () =
|
||||
screen.getByRole('button', { name: /继续创作《拼图草稿》/u }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '拼图草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
@@ -4363,7 +4465,9 @@ test('puzzle form checks mud points before creating a draft', async () => {
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成草稿' }),
|
||||
);
|
||||
|
||||
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
|
||||
expect(
|
||||
@@ -4591,7 +4695,7 @@ test('match3d result trial passes generated 2D image views into first runtime mo
|
||||
});
|
||||
});
|
||||
|
||||
test('match3d result back returns to platform creation page', async () => {
|
||||
test('match3d result back returns to draft hub when opened from shelf', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dDraftWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-back-1',
|
||||
@@ -4645,9 +4749,15 @@ test('match3d result back returns to platform creation page', async () => {
|
||||
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
const draftPanel = await findPlatformTabPanel('saves');
|
||||
await waitFor(() => {
|
||||
expect(draftPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('tablist', { name: '玩法模板分类' }),
|
||||
within(draftPanel).getByRole('tablist', { name: '作品筛选' }),
|
||||
).toBeTruthy();
|
||||
expect(within(draftPanel).getByText('自动试玩抓大鹅')).toBeTruthy();
|
||||
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true');
|
||||
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -4994,7 +5104,11 @@ test('completed match3d draft notice first opens trial then reopens result', asy
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
|
||||
expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('progressbar', {
|
||||
name: '抓大鹅草稿生成进度',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -5036,14 +5150,7 @@ test('completed baby object match draft viewed immediately does not keep unread
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByRole('tab', { name: '宝贝识物' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
});
|
||||
await user.click(await findCreationTypeButton('宝贝识物'));
|
||||
await user.type(await screen.findByLabelText('物品 A'), '苹果');
|
||||
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
|
||||
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
|
||||
@@ -5075,8 +5182,10 @@ test('completed baby object match draft viewed immediately does not keep unread
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
|
||||
});
|
||||
await user.click(await screen.findByRole('button', { name: '返回' }));
|
||||
await openDraftHub(user);
|
||||
const reopenedDraftPanel = await findPlatformTabPanel('saves');
|
||||
await waitFor(() => {
|
||||
expect(reopenedDraftPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
});
|
||||
expect(screen.queryByLabelText('新生成完成')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -5094,12 +5203,16 @@ test('completed baby object match draft shows unread marker after leaving genera
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
|
||||
await user.click(await findCreationTypeButton('宝贝识物'));
|
||||
await user.type(await screen.findByLabelText('物品 A'), '苹果');
|
||||
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
|
||||
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
|
||||
|
||||
expect(await screen.findByText('宝贝识物草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '宝贝识物草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
|
||||
@@ -5123,8 +5236,10 @@ test('completed baby object match draft shows unread marker after leaving genera
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
|
||||
});
|
||||
await user.click(await screen.findByRole('button', { name: '返回' }));
|
||||
await openDraftHub(user);
|
||||
const draftPanel = await findPlatformTabPanel('saves');
|
||||
await waitFor(() => {
|
||||
expect(draftPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
});
|
||||
expect(screen.queryByLabelText('新生成完成')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -5193,7 +5308,9 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成草稿' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updatePuzzleWork).toHaveBeenCalledWith(
|
||||
@@ -5279,7 +5396,10 @@ test('embedded puzzle form recovers when compile request times out after backend
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('button', { name: '生成草稿' }));
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成草稿' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
|
||||
@@ -5316,12 +5436,10 @@ test('embedded puzzle form routes through requireAuth while logged out', async (
|
||||
);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
const generateButton = await screen.findByRole('button', {
|
||||
name: /生成草稿/u,
|
||||
});
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
|
||||
await user.click(generateButton);
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('拼图工作区:missing-session')).toBeNull();
|
||||
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
||||
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
@@ -6265,6 +6383,260 @@ test('home recommendation keeps logged-in puzzle start on default auth instead o
|
||||
);
|
||||
});
|
||||
|
||||
test('logged out home recommendation next starts the next puzzle work', async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstWork = {
|
||||
workId: 'puzzle-work-public-next-1',
|
||||
profileId: 'puzzle-profile-public-next-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-public-next-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '家常菜',
|
||||
summary: '酱猪蹄不是酱肘子。',
|
||||
themeTags: ['家常菜', '拼图'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
publishedAt: '2026-04-25T10:00:00.000Z',
|
||||
playCount: 47,
|
||||
likeCount: 1,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const secondWork = {
|
||||
...firstWork,
|
||||
workId: 'puzzle-work-public-next-2',
|
||||
profileId: 'puzzle-profile-public-next-2',
|
||||
ownerUserId: 'user-3',
|
||||
sourceSessionId: 'puzzle-session-public-next-2',
|
||||
authorDisplayName: '贝壳作者',
|
||||
levelName: '贝壳',
|
||||
summary: '第二个公开拼图。',
|
||||
themeTags: ['贝壳', '拼图'],
|
||||
playCount: 1,
|
||||
likeCount: 0,
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [firstWork, secondWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === secondWork.profileId ? secondWork : firstWork,
|
||||
}));
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const recommendNavButton = document.querySelector<HTMLButtonElement>(
|
||||
'.platform-bottom-nav [aria-label="推荐"]',
|
||||
);
|
||||
expect(recommendNavButton).toBeTruthy();
|
||||
await user.click(recommendNavButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: firstWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '下一个' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: secondWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation puzzle next level switches to similar work detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const entryWork = {
|
||||
workId: 'puzzle-work-public-guest-1',
|
||||
profileId: 'puzzle-profile-public-guest-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-public-guest-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '旋转碎片并接通星桥机关。',
|
||||
themeTags: ['机关', '星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫塔',
|
||||
pictureDescription: '首关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
{
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '同作品第二关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const similarWork = {
|
||||
...entryWork,
|
||||
workId: 'puzzle-work-similar-guest-1',
|
||||
profileId: 'puzzle-profile-similar-guest-1',
|
||||
levelName: '风塔试炼',
|
||||
summary: '另一套奇幻机关拼图。',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'similar-level-1',
|
||||
levelName: '风塔试炼',
|
||||
pictureDescription: '相似作品首关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const clearedRun = buildClearedPuzzleRun({
|
||||
runId: 'run-puzzle-profile-public-guest-1',
|
||||
entryProfileId: entryWork.profileId,
|
||||
profileId: entryWork.profileId,
|
||||
levelName: entryWork.levelName,
|
||||
levelIndex: 1,
|
||||
elapsedMs: 18_000,
|
||||
});
|
||||
const clearedRunWithSameWorkNext: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: entryWork.profileId,
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: entryWork.profileId,
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
};
|
||||
const startedRun = buildMockPuzzleRun(entryWork.profileId, entryWork.levelName);
|
||||
const similarRun = {
|
||||
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName),
|
||||
runId: clearedRun.runId,
|
||||
entryProfileId: entryWork.profileId,
|
||||
currentLevelIndex: 2,
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName)
|
||||
.currentLevel!,
|
||||
runId: clearedRun.runId,
|
||||
levelIndex: 2,
|
||||
levelId: 'similar-level-1',
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [entryWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === similarWork.profileId ? similarWork : entryWork,
|
||||
}));
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: {
|
||||
...startedRun,
|
||||
currentLevel: {
|
||||
...startedRun.currentLevel!,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: clearedRunWithSameWorkNext,
|
||||
});
|
||||
let resolveAdvancePuzzleNextLevel!: (value: { run: PuzzleRunSnapshot }) => void;
|
||||
vi.mocked(advancePuzzleNextLevel).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAdvancePuzzleNextLevel = resolve;
|
||||
}),
|
||||
);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedRun);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: entryWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
|
||||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
||||
|
||||
const dialog = await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: '通关完成' },
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||||
clearedRun.runId,
|
||||
{ preferSimilarWork: true },
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
|
||||
resolveAdvancePuzzleNextLevel({ run: similarRun });
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(similarWork.profileId);
|
||||
});
|
||||
expect(
|
||||
await screen.findByLabelText('风塔试炼 作品信息', undefined, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText('风塔试炼').length).toBeGreaterThan(0);
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
|
||||
const match3dCard: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-card-1',
|
||||
@@ -6886,7 +7258,9 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
const generateButton = screen.getByRole('button', { name: /生成草稿/u });
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await screen.findByText(/拼图工作区:/u);
|
||||
const generateButton = screen.getByRole('button', { name: '生成草稿' });
|
||||
|
||||
expect((generateButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(generateButton);
|
||||
@@ -6923,8 +7297,10 @@ test('embedded puzzle form timeout exits busy state and shows a readable error',
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await screen.findByText(/拼图工作区:/u);
|
||||
|
||||
const button = screen.getByRole('button', { name: /生成草稿/u });
|
||||
const button = screen.getByRole('button', { name: '生成草稿' });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -6954,11 +7330,11 @@ test('match3d creation tab stays usable even when public galleries fail', async
|
||||
await openCreateTemplateHub(user);
|
||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||
expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy();
|
||||
expect(await findCreationTypeButton('抓大鹅')).toBeTruthy();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('puzzle draft result back button returns to creation hub', async () => {
|
||||
test('puzzle draft result back button returns to draft hub when opened from shelf', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
@@ -6999,10 +7375,16 @@ test('puzzle draft result back button returns to creation hub', async () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
const draftPanel = await findPlatformTabPanel('saves');
|
||||
await waitFor(() => {
|
||||
expect(draftPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('tablist', { name: '玩法模板分类' }),
|
||||
within(draftPanel).getByRole('tablist', { name: '作品筛选' }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy();
|
||||
expect(within(draftPanel).getByText('雨夜猫塔')).toBeTruthy();
|
||||
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true');
|
||||
expect(screen.queryByText('拼图工作区:missing-session')).toBeNull();
|
||||
expect(
|
||||
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
||||
).toBeNull();
|
||||
@@ -7383,6 +7765,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelId: null,
|
||||
},
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
vi.mocked(listProfileSaveArchives).mockClear();
|
||||
vi.mocked(listProfileSaveArchives).mockRejectedValueOnce(
|
||||
@@ -7771,6 +8154,9 @@ test('recommend puzzle remix return restarts recommendation instead of stale loa
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByText('正在进入拼图关卡')).toBeNull();
|
||||
@@ -7857,6 +8243,7 @@ test('missing puzzle public detail returns to platform home', async () => {
|
||||
);
|
||||
|
||||
render(<TestWrapper />);
|
||||
vi.mocked(startPuzzleRun).mockClear();
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const workCards = await screen.findAllByRole('button', { name: /失效拼图/u });
|
||||
@@ -7868,7 +8255,6 @@ test('missing puzzle public detail returns to platform home', async () => {
|
||||
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false');
|
||||
expect(screen.queryByText('详情')).toBeNull();
|
||||
expect(screen.queryByText('资源不存在')).toBeNull();
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('direct missing public work detail alert returns to platform home', async () => {
|
||||
@@ -8004,6 +8390,38 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('public code search opens the local Match3D demo and starts local runtime', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [] });
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'M3-20260525');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
expect(screen.getByText('海底糖果集市')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dLocalRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-demo-20260525',
|
||||
{},
|
||||
);
|
||||
});
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).not.toHaveBeenCalled();
|
||||
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
|
||||
'match3d-demo-20260525',
|
||||
);
|
||||
expect(
|
||||
await screen.findByText('抓大鹅运行态:match3d-run-match3d-demo-20260525'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('published Match3D runtime receives persisted generated models', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dWork: Match3DWorkSummary = {
|
||||
@@ -8127,9 +8545,12 @@ test('starting draft generation leaves the agent workspace and shows the generat
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '世界草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('当前世界信息')).toBeTruthy();
|
||||
expect(screen.queryByText('回到工作区')).toBeNull();
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
@@ -8162,7 +8583,11 @@ test('running custom world draft generation can return to creation center with s
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '开始生成草稿' }));
|
||||
|
||||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '世界草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
|
||||
expect(
|
||||
@@ -8192,9 +8617,12 @@ test('refresh restores running draft generation progress instead of agent worksp
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '世界草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('failed draft work continues on generation progress view instead of agent workspace', async () => {
|
||||
@@ -8243,7 +8671,11 @@ test('failed draft work continues on generation progress view instead of agent w
|
||||
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||||
|
||||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '世界草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -9058,7 +9490,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
|
||||
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('agent draft result back button returns to creation hub without syncing result profile', async () => {
|
||||
test('agent draft result back button returns to draft hub without syncing result profile', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const resultSession = {
|
||||
@@ -9215,22 +9647,32 @@ test('agent draft result back button returns to creation hub without syncing res
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
const syncCallsBeforeBack = vi
|
||||
.mocked(executeRpgCreationAction)
|
||||
.mock.calls.filter(
|
||||
([sessionId, payload]) =>
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
).length;
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /返回创作/u }));
|
||||
|
||||
const draftPanel = await findPlatformTabPanel('saves');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy();
|
||||
expect(draftPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
});
|
||||
expect(within(draftPanel).getByRole('tablist', { name: '作品筛选' })).toBeTruthy();
|
||||
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true');
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(executeRpgCreationAction)
|
||||
.mock.calls.some(
|
||||
.mock.calls.filter(
|
||||
([sessionId, payload]) =>
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
),
|
||||
).toBe(false);
|
||||
).length,
|
||||
).toBe(syncCallsBeforeBack);
|
||||
expect(screen.queryByText('世界档案')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -9614,7 +10056,7 @@ test('save tab can resume a selected archive directly into the game', async () =
|
||||
});
|
||||
});
|
||||
|
||||
test('profile page exposes save archive picker as a direct entry', async () => {
|
||||
test('profile page keeps save archives inside played stats panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleContinueGame = vi.fn();
|
||||
|
||||
@@ -9656,20 +10098,11 @@ test('profile page exposes save archive picker as a direct entry', async () => {
|
||||
|
||||
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
|
||||
|
||||
await clickFirstButtonByName(user, '我的');
|
||||
const shortcutRegion = await screen.findByRole('region', {
|
||||
name: '常用功能',
|
||||
});
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /存档/u }),
|
||||
);
|
||||
await openProfilePlayedWorks(user);
|
||||
|
||||
const closeButton = await screen.findByLabelText('关闭存档');
|
||||
const modal = closeButton.closest('.fixed') as HTMLElement;
|
||||
expect(modal).toBeTruthy();
|
||||
expect(within(modal).getByText('SAVES')).toBeTruthy();
|
||||
|
||||
await user.click(within(modal).getByRole('button', { name: /潮雾列岛/u }));
|
||||
expect(screen.queryByLabelText('关闭存档')).toBeNull();
|
||||
expect(screen.queryByText('SAVES')).toBeNull();
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
|
||||
|
||||
@@ -76,6 +76,7 @@ import type {
|
||||
WechatMiniProgramPayParams,
|
||||
WechatNativePayment,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
@@ -192,8 +193,8 @@ export interface RpgEntryHomeViewProps {
|
||||
activeRecommendEntryKey?: string | null;
|
||||
isStartingRecommendEntry?: boolean;
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectNextRecommendEntry?: () => void;
|
||||
onSelectPreviousRecommendEntry?: () => void;
|
||||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
@@ -4068,6 +4069,7 @@ export function RpgEntryHomeView({
|
||||
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const hasManualCategoryTagSelectionRef = useRef(false);
|
||||
const pendingPublicAuthorKeysRef = useRef<Set<string>>(new Set());
|
||||
const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
|
||||
Record<string, PublicUserSummary | null>
|
||||
@@ -4290,16 +4292,33 @@ export function RpgEntryHomeView({
|
||||
useEffect(() => {
|
||||
if (categoryGroups.length === 0) {
|
||||
setSelectedCategoryTag(null);
|
||||
hasManualCategoryTagSelectionRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const firstCategoryGroup = categoryGroups[0];
|
||||
const firstCategoryGroup =
|
||||
categoryGroups.find((group) =>
|
||||
group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)),
|
||||
) ?? categoryGroups[0];
|
||||
const selectedCategoryGroup =
|
||||
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
|
||||
if (
|
||||
firstCategoryGroup &&
|
||||
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
|
||||
(!selectedCategoryGroup ||
|
||||
(!hasManualCategoryTagSelectionRef.current &&
|
||||
selectedCategoryGroup.entries.every((entry) =>
|
||||
isMatch3DDemoProfileId(entry.profileId),
|
||||
) &&
|
||||
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
|
||||
) {
|
||||
setSelectedCategoryTag(firstCategoryGroup.tag);
|
||||
}
|
||||
if (
|
||||
selectedCategoryTag &&
|
||||
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
|
||||
) {
|
||||
hasManualCategoryTagSelectionRef.current = false;
|
||||
}
|
||||
}, [categoryGroups, selectedCategoryTag]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -5226,6 +5245,9 @@ export function RpgEntryHomeView({
|
||||
const [recommendShareState, setRecommendShareState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const activeRecommendEntryKeyForSelection = activeRecommendEntry
|
||||
? buildPublicGalleryCardKey(activeRecommendEntry)
|
||||
: null;
|
||||
const recommendShareResetTimerRef = useRef<number | null>(null);
|
||||
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const recommendDragStartRef = useRef<{
|
||||
@@ -5248,15 +5270,16 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
window.setTimeout(() => {
|
||||
if (direction === 1) {
|
||||
onSelectNextRecommendEntry?.();
|
||||
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||
} else {
|
||||
onSelectPreviousRecommendEntry?.();
|
||||
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||
}
|
||||
setRecommendDragOffsetY(0);
|
||||
setRecommendDragCommitDirection(null);
|
||||
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
|
||||
},
|
||||
[
|
||||
activeRecommendEntryKeyForSelection,
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
recommendDragCommitDirection,
|
||||
@@ -5356,9 +5379,10 @@ export function RpgEntryHomeView({
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectNextRecommendEntry?.();
|
||||
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||
}, [
|
||||
activeRecommendEntry,
|
||||
activeRecommendEntryKeyForSelection,
|
||||
commitRecommendDrag,
|
||||
isAuthenticated,
|
||||
onSelectNextRecommendEntry,
|
||||
@@ -5643,7 +5667,10 @@ export function RpgEntryHomeView({
|
||||
<button
|
||||
key={group.tag}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
onClick={() => {
|
||||
hasManualCategoryTagSelectionRef.current = true;
|
||||
setSelectedCategoryTag(group.tag);
|
||||
}}
|
||||
className={`platform-category-chip ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
@@ -5841,7 +5868,10 @@ export function RpgEntryHomeView({
|
||||
<button
|
||||
key={`${group.tag}:desktop-discover-category`}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
onClick={() => {
|
||||
hasManualCategoryTagSelectionRef.current = true;
|
||||
setSelectedCategoryTag(group.tag);
|
||||
}}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
@@ -6610,7 +6640,10 @@ export function RpgEntryHomeView({
|
||||
<button
|
||||
key={`${group.tag}:desktop-category`}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
onClick={() => {
|
||||
hasManualCategoryTagSelectionRef.current = true;
|
||||
setSelectedCategoryTag(group.tag);
|
||||
}}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldFallbackCoverImage,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
import { buildMatch3DDemoGalleryCard } from '../../data/match3dDemoGalleryCard';
|
||||
|
||||
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
|
||||
expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25');
|
||||
@@ -78,6 +80,24 @@ test('platform public cards use play type reference images as cover fallback', (
|
||||
);
|
||||
});
|
||||
|
||||
test('builds local Match3D demo gallery card with generated runtime assets intact', () => {
|
||||
const card = buildMatch3DDemoGalleryCard();
|
||||
|
||||
expect(isMatch3DGalleryEntry(card)).toBe(true);
|
||||
expect(card.publicWorkCode).toBe('M3-20260525');
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('M3-20260525');
|
||||
expect(card.coverImageSrc).toBe(
|
||||
'/match3d-demo/undersea-candy-market/level-scene.png',
|
||||
);
|
||||
expect(card.generatedBackgroundAsset?.uiSpritesheetImageSrc).toBe(
|
||||
'/match3d-demo/undersea-candy-market/ui-spritesheet.png',
|
||||
);
|
||||
expect(card.generatedBackgroundAsset?.containerImageSrc).toBeNull();
|
||||
expect(card.generatedItemAssets?.[0]?.imageViews?.[0]?.imageSrc).toBe(
|
||||
'/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
|
||||
const slides = buildPuzzleWorkCoverSlides({
|
||||
workId: 'work-1',
|
||||
|
||||
141
src/data/match3dDemoGalleryCard.ts
Normal file
141
src/data/match3dDemoGalleryCard.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
} from '../../packages/shared/src/contracts/match3dWorks';
|
||||
import { buildMatch3DPublicWorkCode } from '../services/publicWorkCode';
|
||||
import type { PlatformMatch3DGalleryCard } from '../components/rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export const MATCH3D_DEMO_PROFILE_ID = 'match3d-demo-20260525';
|
||||
export const MATCH3D_DEMO_WORK_ID = 'match3d-demo-undersea-candy-market';
|
||||
export const MATCH3D_DEMO_PUBLIC_WORK_CODE =
|
||||
buildMatch3DPublicWorkCode(MATCH3D_DEMO_PROFILE_ID);
|
||||
|
||||
const MATCH3D_DEMO_ASSET_BASE = '/match3d-demo/undersea-candy-market';
|
||||
const MATCH3D_DEMO_PUBLISHED_AT = '2026-05-25T12:04:17.000+08:00';
|
||||
const MATCH3D_DEMO_ITEM_NAMES = [
|
||||
'海星糖',
|
||||
'贝壳糖',
|
||||
'珊瑚软糖',
|
||||
'珍珠泡泡糖',
|
||||
'海马棒棒糖',
|
||||
'鱼尾果冻',
|
||||
'水母棉花糖',
|
||||
'螺旋饼干',
|
||||
'海螺巧克力',
|
||||
'贝珠马卡龙',
|
||||
'珊瑚杯糕',
|
||||
'星砂软糖',
|
||||
'小鱼糖块',
|
||||
'海草曲奇',
|
||||
'泡泡杯',
|
||||
'蓝莓珊瑚糖',
|
||||
'迷你糖罐',
|
||||
'珍珠饼',
|
||||
'海浪甜甜圈',
|
||||
'贝壳蛋糕',
|
||||
] as const;
|
||||
|
||||
export const MATCH3D_DEMO_BACKGROUND_ASSET: Match3DGeneratedBackgroundAsset = {
|
||||
prompt: '海底糖果集市抓大鹅关卡背景',
|
||||
levelScenePrompt: '海底糖果集市完整关卡画面',
|
||||
levelSceneImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/level-scene.png`,
|
||||
imageSrc: `${MATCH3D_DEMO_ASSET_BASE}/background.png`,
|
||||
uiSpritesheetPrompt: '海底糖果集市 UI 透明 spritesheet',
|
||||
uiSpritesheetImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/ui-spritesheet.png`,
|
||||
itemSpritesheetPrompt: '海底糖果集市物品 10x10 透明 spritesheet',
|
||||
itemSpritesheetImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/item-spritesheet.png`,
|
||||
containerImageSrc: null,
|
||||
status: 'image_ready',
|
||||
};
|
||||
|
||||
export function buildMatch3DDemoGeneratedItemAssets() {
|
||||
return MATCH3D_DEMO_ITEM_NAMES.map<Match3DGeneratedItemAsset>(
|
||||
(itemName, itemIndex) => {
|
||||
const itemNumber = itemIndex + 1;
|
||||
const paddedItemNumber = String(itemNumber).padStart(2, '0');
|
||||
return {
|
||||
itemId: `match3d-item-${itemNumber}`,
|
||||
itemName,
|
||||
itemSize:
|
||||
itemIndex < 4 ? '大' : itemIndex < 14 ? '中' : '小',
|
||||
imageViews: Array.from({ length: 5 }, (_, viewIndex) => {
|
||||
const viewNumber = viewIndex + 1;
|
||||
return {
|
||||
viewId: `view-${String(viewNumber).padStart(2, '0')}`,
|
||||
viewIndex: viewNumber,
|
||||
imageSrc: `${MATCH3D_DEMO_ASSET_BASE}/item-slices/item-${paddedItemNumber}/view-${String(viewNumber).padStart(2, '0')}.png`,
|
||||
};
|
||||
}),
|
||||
backgroundAsset:
|
||||
itemIndex === 0 ? MATCH3D_DEMO_BACKGROUND_ASSET : null,
|
||||
status: 'image_ready',
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMatch3DDemoWorkProfile(): Match3DWorkProfile {
|
||||
return {
|
||||
workId: MATCH3D_DEMO_WORK_ID,
|
||||
profileId: MATCH3D_DEMO_PROFILE_ID,
|
||||
ownerUserId: 'official-match3d-demo',
|
||||
sourceSessionId: 'match3d-demo-session-20260525',
|
||||
gameName: '海底糖果集市',
|
||||
themeText: '海底糖果集市',
|
||||
summary: '在海底糖果集市里把同款甜点抓成三消。',
|
||||
tags: ['抓大鹅', '海底糖果', '官方示例'],
|
||||
coverImageSrc: `${MATCH3D_DEMO_ASSET_BASE}/level-scene.png`,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 21,
|
||||
difficulty: 8,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: MATCH3D_DEMO_PUBLISHED_AT,
|
||||
publishedAt: MATCH3D_DEMO_PUBLISHED_AT,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
backgroundPrompt: MATCH3D_DEMO_BACKGROUND_ASSET.prompt,
|
||||
backgroundImageSrc: MATCH3D_DEMO_BACKGROUND_ASSET.imageSrc,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: MATCH3D_DEMO_BACKGROUND_ASSET,
|
||||
generatedItemAssets: buildMatch3DDemoGeneratedItemAssets(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatch3DDemoGalleryCard(): PlatformMatch3DGalleryCard {
|
||||
const profile = buildMatch3DDemoWorkProfile();
|
||||
return {
|
||||
sourceType: 'match3d',
|
||||
workId: profile.workId,
|
||||
profileId: profile.profileId,
|
||||
sourceSessionId: profile.sourceSessionId,
|
||||
publicWorkCode: MATCH3D_DEMO_PUBLIC_WORK_CODE,
|
||||
ownerUserId: profile.ownerUserId,
|
||||
authorDisplayName: '官方示例',
|
||||
worldName: profile.gameName,
|
||||
subtitle: '抓大鹅 · 资源管线示例',
|
||||
summaryText: profile.summary,
|
||||
coverImageSrc: profile.coverImageSrc ?? null,
|
||||
themeTags: profile.tags,
|
||||
playCount: profile.playCount,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: profile.publishedAt ?? null,
|
||||
updatedAt: profile.updatedAt,
|
||||
backgroundPrompt: profile.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: profile.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: profile.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset: profile.generatedBackgroundAsset ?? null,
|
||||
generatedItemAssets: profile.generatedItemAssets ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export const MATCH3D_DEMO_WORK_PROFILE = buildMatch3DDemoWorkProfile();
|
||||
export const MATCH3D_DEMO_GALLERY_CARD = buildMatch3DDemoGalleryCard();
|
||||
|
||||
export function isMatch3DDemoProfileId(profileId: string | null | undefined) {
|
||||
return profileId?.trim() === MATCH3D_DEMO_PROFILE_ID;
|
||||
}
|
||||
@@ -279,7 +279,7 @@ async function generateBabyObjectMatchAssets(
|
||||
const assets = normalizeGeneratedAssets(response.assets, itemNames);
|
||||
const visualPackage = normalizeGeneratedVisualPackage(response.visualPackage);
|
||||
if (!assets || !visualPackage) {
|
||||
throw new Error('宝贝识物 image-2 资源生成结果不完整,请重试。');
|
||||
throw new Error('宝贝识物素材生成结果不完整,请重试。');
|
||||
}
|
||||
|
||||
return { assets, visualPackage };
|
||||
|
||||
@@ -447,7 +447,10 @@ function settleMatchedTrayItems(
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
export function startLocalMatch3DRun(
|
||||
clearCount = 12,
|
||||
profileId = 'local-match3d-profile',
|
||||
): Match3DRunSnapshot {
|
||||
const normalizedClearCount =
|
||||
normalizeLocalMatch3DRuntimeClearCount(clearCount);
|
||||
const selectedSeeds = selectVisualSeeds(normalizedClearCount);
|
||||
@@ -467,7 +470,7 @@ export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
const nowMs = Date.now();
|
||||
return {
|
||||
runId: `local-match3d-run-${nowMs}`,
|
||||
profileId: 'local-match3d-profile',
|
||||
profileId,
|
||||
status: 'Running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: nowMs,
|
||||
|
||||
@@ -95,7 +95,7 @@ test('local Match3D runtime adapter exposes the same runtime seam as the server
|
||||
const started = await adapter.startRun('ignored-local-profile');
|
||||
const clickableItem = started.run.items.find((item) => item.clickable);
|
||||
|
||||
expect(started.run.profileId).toBe('local-match3d-profile');
|
||||
expect(started.run.profileId).toBe('ignored-local-profile');
|
||||
expect(clickableItem).toBeTruthy();
|
||||
|
||||
const clickResult = await adapter.clickItem(started.run.runId, {
|
||||
@@ -117,6 +117,15 @@ test('local Match3D runtime adapter exposes the same runtime seam as the server
|
||||
expect(stopped.run.status).toBe('Stopped');
|
||||
});
|
||||
|
||||
test('local Match3D runtime adapter keeps the requested profile id on restart', async () => {
|
||||
const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 });
|
||||
const started = await adapter.startRun('match3d-demo-20260525');
|
||||
const restarted = await adapter.restartRun(started.run.runId);
|
||||
|
||||
expect(started.run.profileId).toBe('match3d-demo-20260525');
|
||||
expect(restarted.run.profileId).toBe('match3d-demo-20260525');
|
||||
});
|
||||
|
||||
test('local Match3D runtime adapter keeps authority run local to the adapter', async () => {
|
||||
const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) });
|
||||
const first = await adapter.getRun('unused-run-id');
|
||||
|
||||
@@ -36,6 +36,7 @@ export type Match3DRuntimeAdapter = {
|
||||
|
||||
export type LocalMatch3DRuntimeAdapterOptions = {
|
||||
clearCount?: number;
|
||||
profileId?: string;
|
||||
initialRun?: Match3DRunResponse['run'];
|
||||
};
|
||||
|
||||
@@ -74,11 +75,16 @@ export function createServerMatch3DRuntimeAdapter(
|
||||
export function createLocalMatch3DRuntimeAdapter(
|
||||
options: LocalMatch3DRuntimeAdapterOptions = {},
|
||||
): Match3DRuntimeAdapter {
|
||||
let authorityRun = options.initialRun ?? startLocalMatch3DRun(options.clearCount);
|
||||
let authorityRun =
|
||||
options.initialRun ??
|
||||
startLocalMatch3DRun(options.clearCount, options.profileId);
|
||||
|
||||
return {
|
||||
async startRun() {
|
||||
authorityRun = startLocalMatch3DRun(options.clearCount);
|
||||
async startRun(profileId) {
|
||||
authorityRun = startLocalMatch3DRun(
|
||||
options.clearCount,
|
||||
profileId || options.profileId,
|
||||
);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async getRun() {
|
||||
@@ -91,7 +97,10 @@ export function createLocalMatch3DRuntimeAdapter(
|
||||
return result;
|
||||
},
|
||||
async restartRun() {
|
||||
authorityRun = startLocalMatch3DRun(options.clearCount);
|
||||
authorityRun = startLocalMatch3DRun(
|
||||
options.clearCount,
|
||||
authorityRun.profileId || options.profileId,
|
||||
);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async stopRun() {
|
||||
|
||||
@@ -36,6 +36,42 @@ describe('match3dSpritesheetParser', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('同一行图标高度错位时仍按行内横向顺序映射标签', () => {
|
||||
const width = 36;
|
||||
const height = 24;
|
||||
const alpha = new Uint8ClampedArray(width * height);
|
||||
const paint = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
for (let y = y0; y <= y1; y += 1) {
|
||||
for (let x = x0; x <= x1; x += 1) {
|
||||
alpha[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
paint(2, 1, 5, 4);
|
||||
paint(12, 1, 15, 4);
|
||||
paint(22, 1, 25, 4);
|
||||
paint(2, 15, 5, 20);
|
||||
paint(12, 13, 15, 18);
|
||||
paint(22, 10, 25, 15);
|
||||
|
||||
const regions = detectMatch3DSpritesheetRegions({
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
labels: ['返回', '设置', '方格', '移出', '凑齐', '打乱'],
|
||||
});
|
||||
|
||||
expect(regions.map((region) => `${region.label}:${region.x}`)).toEqual([
|
||||
'返回:2',
|
||||
'设置:12',
|
||||
'方格:22',
|
||||
'移出:2',
|
||||
'凑齐:12',
|
||||
'打乱:22',
|
||||
]);
|
||||
});
|
||||
|
||||
test('忽略小噪点,只返回可用矩形素材', () => {
|
||||
const width = 8;
|
||||
const height = 8;
|
||||
|
||||
@@ -79,8 +79,7 @@ export function detectMatch3DSpritesheetRegions({
|
||||
}
|
||||
}
|
||||
|
||||
return components
|
||||
.sort((left, right) => left.y - right.y || left.x - right.x)
|
||||
return sortMatch3DSpritesheetComponentsByRows(components)
|
||||
.map((component, index) => ({
|
||||
label: labels[index] ?? `素材${index + 1}`,
|
||||
x: component.x,
|
||||
@@ -90,6 +89,67 @@ export function detectMatch3DSpritesheetRegions({
|
||||
}));
|
||||
}
|
||||
|
||||
function sortMatch3DSpritesheetComponentsByRows(
|
||||
components: Match3DDetectedComponent[],
|
||||
) {
|
||||
const rows: Array<{
|
||||
top: number;
|
||||
bottom: number;
|
||||
components: Match3DDetectedComponent[];
|
||||
}> = [];
|
||||
|
||||
[...components]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
resolveMatch3DSpritesheetComponentCenterY(left) -
|
||||
resolveMatch3DSpritesheetComponentCenterY(right) ||
|
||||
left.x - right.x,
|
||||
)
|
||||
.forEach((component) => {
|
||||
const row = rows.find((entry) =>
|
||||
isMatch3DSpritesheetComponentInRow(component, entry),
|
||||
);
|
||||
if (!row) {
|
||||
rows.push({
|
||||
top: component.y,
|
||||
bottom: component.y + component.height - 1,
|
||||
components: [component],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
row.top = Math.min(row.top, component.y);
|
||||
row.bottom = Math.max(row.bottom, component.y + component.height - 1);
|
||||
row.components.push(component);
|
||||
});
|
||||
|
||||
return rows
|
||||
.sort((left, right) => left.top - right.top)
|
||||
.flatMap((row) =>
|
||||
row.components.sort((left, right) => left.x - right.x || left.y - right.y),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DSpritesheetComponentCenterY(
|
||||
component: Match3DDetectedComponent,
|
||||
) {
|
||||
return component.y + component.height / 2;
|
||||
}
|
||||
|
||||
function isMatch3DSpritesheetComponentInRow(
|
||||
component: Match3DDetectedComponent,
|
||||
row: { top: number; bottom: number },
|
||||
) {
|
||||
const bottom = component.y + component.height - 1;
|
||||
if (component.y <= row.bottom && bottom >= row.top) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const gap =
|
||||
component.y > row.bottom ? component.y - row.bottom : row.top - bottom;
|
||||
return gap <= Math.max(2, component.height * 0.25);
|
||||
}
|
||||
|
||||
export function buildMatch3DItemSpritesheetViewRegions<
|
||||
Region extends Match3DSpritesheetRegion,
|
||||
>(
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
|
||||
);
|
||||
expect(progress?.steps[2]?.detail).toBe(
|
||||
'调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
|
||||
'生成 1:1 拼图首图,预计 4 分钟。',
|
||||
);
|
||||
expect(progress?.estimatedRemainingMs).toBe(446_500);
|
||||
expect(progress?.overallProgress).toBe(0);
|
||||
|
||||
@@ -167,7 +167,7 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
|
||||
steps.push({
|
||||
id: 'puzzle-cover-image',
|
||||
label: '生成拼图首图',
|
||||
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
|
||||
detail: '生成 1:1 拼图首图,预计 4 分钟。',
|
||||
durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS,
|
||||
});
|
||||
}
|
||||
@@ -177,15 +177,15 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
|
||||
id: 'puzzle-level-scene',
|
||||
label: '生成关卡画面',
|
||||
detail: shouldSkipPuzzleCoverGeneration(state)
|
||||
? '直接使用上传图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。'
|
||||
: '使用拼图首图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。',
|
||||
? '直接使用上传图作为参考,生成 9:16 完整关卡画面,预计 90 秒。'
|
||||
: '使用拼图首图作为参考,生成 9:16 完整关卡画面,预计 90 秒。',
|
||||
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-ui-assets',
|
||||
label: '生成UI与背景',
|
||||
detail:
|
||||
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景;两次 gpt-image-2 并发,预计 90 秒。',
|
||||
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景,预计 90 秒。',
|
||||
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
@@ -305,7 +305,7 @@ const MATCH3D_STEPS = [
|
||||
{
|
||||
id: 'match3d-level-scene',
|
||||
label: '生成关卡整图',
|
||||
detail: '调用 gpt-image-2 生成 9:16 完整抓大鹅关卡画面。',
|
||||
detail: '生成 9:16 完整抓大鹅关卡画面。',
|
||||
weight: 28,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -30,6 +30,9 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
const PUZZLE_RUNTIME_LEADERBOARD_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 0,
|
||||
};
|
||||
type PuzzleRuntimeRequestOptions = RuntimeGuestRequestOptions;
|
||||
|
||||
/**
|
||||
@@ -125,16 +128,22 @@ export async function advancePuzzleNextLevel(
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const targetProfileId = payload.targetProfileId?.trim() ?? '';
|
||||
const preferSimilarWork = payload.preferSimilarWork === true;
|
||||
const requestPayload = {
|
||||
...(targetProfileId ? { targetProfileId } : {}),
|
||||
...(preferSimilarWork ? { preferSimilarWork: true } : {}),
|
||||
};
|
||||
const hasRequestPayload = Object.keys(requestPayload).length > 0;
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
|
||||
{
|
||||
method: 'POST',
|
||||
...(targetProfileId
|
||||
...(hasRequestPayload
|
||||
? {
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ targetProfileId }),
|
||||
body: JSON.stringify(requestPayload),
|
||||
}
|
||||
: {
|
||||
headers: buildRuntimeGuestHeaders(options),
|
||||
@@ -156,20 +165,20 @@ export async function submitPuzzleLeaderboard(
|
||||
payload: SubmitPuzzleLeaderboardRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交拼图排行榜失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
|
||||
import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient';
|
||||
import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient';
|
||||
import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient';
|
||||
import { startPuzzleRun } from './puzzle-runtime/puzzleRuntimeClient';
|
||||
import {
|
||||
advancePuzzleNextLevel,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
} from './puzzle-runtime/puzzleRuntimeClient';
|
||||
import { startSquareHoleRun } from './square-hole-runtime/squareHoleRuntimeClient';
|
||||
import { startVisualNovelRun } from './visual-novel-runtime/visualNovelRuntimeClient';
|
||||
|
||||
@@ -87,6 +91,21 @@ describe('recommended runtime guest launch clients', () => {
|
||||
),
|
||||
expectedUrl: '/api/runtime/puzzle/runs',
|
||||
},
|
||||
{
|
||||
name: 'puzzle leaderboard',
|
||||
start: () =>
|
||||
submitPuzzleLeaderboard(
|
||||
'run-puzzle-1',
|
||||
{
|
||||
profileId: 'puzzle-profile-1',
|
||||
gridSize: 3,
|
||||
elapsedMs: 18_000,
|
||||
nickname: '玩家',
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/leaderboard',
|
||||
},
|
||||
])(
|
||||
'$name start request uses the runtime guest bearer token without touching login auth',
|
||||
async ({ start, expectedUrl }) => {
|
||||
@@ -110,4 +129,63 @@ describe('recommended runtime guest launch clients', () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('puzzle next level can carry preferSimilarWork through the runtime guest request', async () => {
|
||||
await advancePuzzleNextLevel(
|
||||
'run-puzzle-1',
|
||||
{ preferSimilarWork: true },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
|
||||
expect(url).toBe('/api/runtime/puzzle/runs/run-puzzle-1/next-level');
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ preferSimilarWork: true }),
|
||||
}),
|
||||
);
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('puzzle leaderboard submission does not retry unsafe writes', async () => {
|
||||
await submitPuzzleLeaderboard(
|
||||
'run-puzzle-1',
|
||||
{
|
||||
profileId: 'puzzle-profile-1',
|
||||
gridSize: 3,
|
||||
elapsedMs: 18_000,
|
||||
nickname: '玩家',
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
|
||||
expect(url).toBe('/api/runtime/puzzle/runs/run-puzzle-1/leaderboard');
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user