收口拼消消运行态等待层与结算层
提取 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 () => {
|
test('指针拖拽到另一张卡片时提交后端交换动作', async () => {
|
||||||
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
const onSwapCards = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
@@ -1148,6 +1167,7 @@ test('关卡结算弹层提供下一关和失败重试动作', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: '本关完成' })).toBeTruthy();
|
||||||
fireEvent.click(screen.getByRole('button', { name: /下一关/u }));
|
fireEvent.click(screen.getByRole('button', { name: /下一关/u }));
|
||||||
expect(onNextLevel).toHaveBeenCalledTimes(1);
|
expect(onNextLevel).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -1162,6 +1182,7 @@ test('关卡结算弹层提供下一关和失败重试动作', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: '本关失败' })).toBeTruthy();
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: '重试' }).at(-1)!);
|
fireEvent.click(screen.getAllByRole('button', { name: '重试' }).at(-1)!);
|
||||||
expect(onRetryLevel).toHaveBeenCalledTimes(1);
|
expect(onRetryLevel).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ArrowLeft, ChevronRight, RotateCcw } from 'lucide-react';
|
import { ArrowLeft, ChevronRight, RotateCcw } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -122,6 +123,28 @@ type PuzzleClearMergeHighlightState = {
|
|||||||
|
|
||||||
type PuzzleClearLockedGroupViewModel = PuzzleClearDragGroupState;
|
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_CLEAR_TRANSITION_MS = 1120;
|
||||||
const PUZZLE_CLEAR_REFILL_DROP_DELAY_MS = 520;
|
const PUZZLE_CLEAR_REFILL_DROP_DELAY_MS = 520;
|
||||||
const PUZZLE_CLEAR_DROP_STAGGER_MAX_MS = 180;
|
const PUZZLE_CLEAR_DROP_STAGGER_MAX_MS = 180;
|
||||||
@@ -240,6 +263,100 @@ function getCardImageStyle(card: PuzzleClearCardAsset | null) {
|
|||||||
} as CSSProperties;
|
} 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>) {
|
function readPointerClientPoint(event: ReactPointerEvent<HTMLButtonElement>) {
|
||||||
return {
|
return {
|
||||||
x: Number.isFinite(event.clientX) ? event.clientX : 0,
|
x: Number.isFinite(event.clientX) ? event.clientX : 0,
|
||||||
@@ -568,6 +685,14 @@ export function PuzzleClearRuntimeShell({
|
|||||||
const hasWonLevel = activeRun?.status === 'level_cleared';
|
const hasWonLevel = activeRun?.status === 'level_cleared';
|
||||||
const hasFinished = activeRun?.status === 'finished';
|
const hasFinished = activeRun?.status === 'finished';
|
||||||
const hasFailed = activeRun?.status === 'level_failed';
|
const hasFailed = activeRun?.status === 'level_failed';
|
||||||
|
const settlementStatus: PuzzleClearRuntimeSettlementStatus | null =
|
||||||
|
hasFailed
|
||||||
|
? 'level_failed'
|
||||||
|
: hasFinished
|
||||||
|
? 'finished'
|
||||||
|
: hasWonLevel
|
||||||
|
? 'level_cleared'
|
||||||
|
: null;
|
||||||
const clearRatio = activeRun
|
const clearRatio = activeRun
|
||||||
? Math.min(1, activeRun.clearsDone / Math.max(1, activeRun.targetClears))
|
? Math.min(1, activeRun.clearsDone / Math.max(1, activeRun.targetClears))
|
||||||
: 0;
|
: 0;
|
||||||
@@ -1467,42 +1592,17 @@ export function PuzzleClearRuntimeShell({
|
|||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!activeRun ? (
|
{!activeRun ? <PuzzleClearRuntimePendingOverlay /> : null}
|
||||||
<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}
|
|
||||||
|
|
||||||
{hasWonLevel || hasFinished || hasFailed ? (
|
{settlementStatus ? (
|
||||||
<div className="absolute inset-0 z-50 grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
|
<PuzzleClearRuntimeSettlementDialog
|
||||||
<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)]">
|
status={settlementStatus}
|
||||||
<div className="text-2xl font-black">
|
isBusy={isBusy}
|
||||||
{hasFailed ? '本关失败' : hasFinished ? '已完成' : '本关完成'}
|
onRetryLevel={onRetryLevel}
|
||||||
</div>
|
onPrimaryAction={
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
settlementStatus === 'level_cleared' ? onNextLevel : onBack
|
||||||
<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>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user