fix: stabilize match3d demo discovery

This commit is contained in:
2026-05-26 00:13:08 +08:00
parent 5d3e2ac111
commit f79a6ea81e
123 changed files with 1778 additions and 233 deletions

View File

@@ -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,移出');

View File

@@ -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>
);
}