收口拼消消运行态等待层与结算层

提取 PuzzleClearRuntimeShell 内部 overlay 薄壳并统一等待层与结算层结构
补充等待层与结算弹层语义断言测试
This commit is contained in:
2026-06-11 22:35:54 +08:00
parent ed2c386603
commit f7404a07ef
2 changed files with 156 additions and 35 deletions

View File

@@ -296,6 +296,25 @@ test('翻牌只在开局阶段出现,交换后的新卡不会重新翻转', as
);
});
test('未拿到 active run 时显示本地等待层薄壳', () => {
render(
<PuzzleClearRuntimeShell
profile={createProfile()}
run={null}
onSwapCards={vi.fn()}
onRetryLevel={vi.fn()}
onNextLevel={vi.fn()}
onTimeUp={vi.fn()}
/>,
);
const pendingOverlay = screen.getByTestId(
'puzzle-clear-runtime-pending-overlay',
);
expect(pendingOverlay.getAttribute('role')).toBe('status');
expect(pendingOverlay.textContent).toContain('等待开局');
});
test('指针拖拽到另一张卡片时提交后端交换动作', async () => {
const onSwapCards = vi.fn().mockResolvedValue(undefined);
@@ -1148,6 +1167,7 @@ test('关卡结算弹层提供下一关和失败重试动作', () => {
/>,
);
expect(screen.getByRole('dialog', { name: '本关完成' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onNextLevel).toHaveBeenCalledTimes(1);
@@ -1162,6 +1182,7 @@ test('关卡结算弹层提供下一关和失败重试动作', () => {
/>,
);
expect(screen.getByRole('dialog', { name: '本关失败' })).toBeTruthy();
fireEvent.click(screen.getAllByRole('button', { name: '重试' }).at(-1)!);
expect(onRetryLevel).toHaveBeenCalledTimes(1);
});

View File

@@ -1,6 +1,7 @@
import { ArrowLeft, ChevronRight, RotateCcw } from 'lucide-react';
import {
type CSSProperties,
type ReactNode,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
@@ -122,6 +123,28 @@ type PuzzleClearMergeHighlightState = {
type PuzzleClearLockedGroupViewModel = PuzzleClearDragGroupState;
type PuzzleClearRuntimeOverlayShellProps = {
backdropClassName: string;
panelClassName: string;
children: ReactNode;
panelRole?: 'dialog' | 'status';
panelLabelledBy?: string;
panelLiveMode?: 'polite' | 'assertive';
panelTestId?: string;
};
type PuzzleClearRuntimeSettlementStatus = Extract<
PuzzleClearRuntimeSnapshotResponse['status'],
'level_cleared' | 'finished' | 'level_failed'
>;
type PuzzleClearRuntimeSettlementDialogProps = {
status: PuzzleClearRuntimeSettlementStatus;
isBusy: boolean;
onRetryLevel: () => void;
onPrimaryAction?: () => void;
};
const PUZZLE_CLEAR_CLEAR_TRANSITION_MS = 1120;
const PUZZLE_CLEAR_REFILL_DROP_DELAY_MS = 520;
const PUZZLE_CLEAR_DROP_STAGGER_MAX_MS = 180;
@@ -240,6 +263,100 @@ function getCardImageStyle(card: PuzzleClearCardAsset | null) {
} as CSSProperties;
}
function getPuzzleClearSettlementTitle(
status: PuzzleClearRuntimeSettlementStatus,
) {
if (status === 'level_failed') {
return '本关失败';
}
if (status === 'finished') {
return '已完成';
}
return '本关完成';
}
// 运行态 overlay 先在玩法目录内做薄壳收口,保留拼消消自己的视觉和交互语义。
function PuzzleClearRuntimeOverlayShell({
backdropClassName,
panelClassName,
children,
panelRole,
panelLabelledBy,
panelLiveMode,
panelTestId,
}: PuzzleClearRuntimeOverlayShellProps) {
return (
<div className={backdropClassName}>
<div
role={panelRole}
aria-modal={panelRole === 'dialog' ? true : undefined}
aria-labelledby={panelLabelledBy}
aria-live={panelLiveMode}
data-testid={panelTestId}
className={panelClassName}
>
{children}
</div>
</div>
);
}
function PuzzleClearRuntimePendingOverlay() {
return (
<PuzzleClearRuntimeOverlayShell
backdropClassName="absolute inset-0 z-40 grid place-items-center bg-white/42 px-6 backdrop-blur-sm"
panelClassName="rounded-[1.25rem] border border-white/80 bg-white/90 px-5 py-4 text-sm font-black shadow-lg"
panelRole="status"
panelLiveMode="polite"
panelTestId="puzzle-clear-runtime-pending-overlay"
>
</PuzzleClearRuntimeOverlayShell>
);
}
function PuzzleClearRuntimeSettlementDialog({
status,
isBusy,
onRetryLevel,
onPrimaryAction,
}: PuzzleClearRuntimeSettlementDialogProps) {
const isExitAction = status === 'finished' || status === 'level_failed';
const title = getPuzzleClearSettlementTitle(status);
return (
<PuzzleClearRuntimeOverlayShell
backdropClassName="absolute inset-0 z-50 grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]"
panelClassName="w-full max-w-[19rem] rounded-[1.25rem] border border-white/70 bg-white/92 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]"
panelRole="dialog"
panelLabelledBy="puzzle-clear-runtime-settlement-title"
panelTestId="puzzle-clear-runtime-settlement-dialog"
>
<div className="text-2xl font-black">
<span id="puzzle-clear-runtime-settlement-title">{title}</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<PlatformActionButton
onClick={onRetryLevel}
disabled={isBusy}
tone="ghost"
className="min-h-11 justify-center bg-white px-3 py-2 text-sm"
>
</PlatformActionButton>
<PlatformActionButton
onClick={onPrimaryAction}
disabled={isBusy}
className="min-h-11 justify-center gap-2 px-3 py-2 text-sm"
>
{isExitAction ? '返回' : '下一关'}
{!isExitAction ? <ChevronRight className="h-4 w-4" /> : null}
</PlatformActionButton>
</div>
</PuzzleClearRuntimeOverlayShell>
);
}
function readPointerClientPoint(event: ReactPointerEvent<HTMLButtonElement>) {
return {
x: Number.isFinite(event.clientX) ? event.clientX : 0,
@@ -568,6 +685,14 @@ export function PuzzleClearRuntimeShell({
const hasWonLevel = activeRun?.status === 'level_cleared';
const hasFinished = activeRun?.status === 'finished';
const hasFailed = activeRun?.status === 'level_failed';
const settlementStatus: PuzzleClearRuntimeSettlementStatus | null =
hasFailed
? 'level_failed'
: hasFinished
? 'finished'
: hasWonLevel
? 'level_cleared'
: null;
const clearRatio = activeRun
? Math.min(1, activeRun.clearsDone / Math.max(1, activeRun.targetClears))
: 0;
@@ -1467,42 +1592,17 @@ export function PuzzleClearRuntimeShell({
) : null}
</main>
{!activeRun ? (
<div className="absolute inset-0 z-40 grid place-items-center bg-white/42 px-6 backdrop-blur-sm">
<div className="rounded-[1.25rem] border border-white/80 bg-white/90 px-5 py-4 text-sm font-black shadow-lg">
</div>
</div>
) : null}
{!activeRun ? <PuzzleClearRuntimePendingOverlay /> : null}
{hasWonLevel || hasFinished || hasFailed ? (
<div className="absolute inset-0 z-50 grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
<div className="w-full max-w-[19rem] rounded-[1.25rem] border border-white/70 bg-white/92 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]">
<div className="text-2xl font-black">
{hasFailed ? '本关失败' : hasFinished ? '已完成' : '本关完成'}
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<PlatformActionButton
onClick={onRetryLevel}
disabled={isBusy}
tone="ghost"
className="min-h-11 justify-center bg-white px-3 py-2 text-sm"
>
</PlatformActionButton>
<PlatformActionButton
onClick={hasFinished || hasFailed ? onBack : onNextLevel}
disabled={isBusy}
className="min-h-11 justify-center gap-2 px-3 py-2 text-sm"
>
{hasFinished || hasFailed ? '返回' : '下一关'}
{!hasFinished && !hasFailed ? (
<ChevronRight className="h-4 w-4" />
) : null}
</PlatformActionButton>
</div>
</div>
</div>
{settlementStatus ? (
<PuzzleClearRuntimeSettlementDialog
status={settlementStatus}
isBusy={isBusy}
onRetryLevel={onRetryLevel}
onPrimaryAction={
settlementStatus === 'level_cleared' ? onNextLevel : onBack
}
/>
) : null}
</div>
);