收口拼消消运行态等待层与结算层
提取 PuzzleClearRuntimeShell 内部 overlay 薄壳并统一等待层与结算层结构 补充等待层与结算弹层语义断言测试
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user