合并 master 并保留外部生成 worker 模式
合入 master 的拼消消、微信能力、OpenSSL 3.2 和 SpacetimeDB 2.4.1 更新 保留外部内容生成 queue/inline、worker lease 与动态扩缩容口径 补齐拼图后台图片生成队列轮询和运行态返回恢复 同步容器、生产运维和 Hermes 共享记忆中的 worker 文档
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { type ReactNode } from 'react';
|
||||
|
||||
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
@@ -74,6 +74,12 @@ describe('CustomWorldGenerationView', () => {
|
||||
expect((container.firstChild as HTMLElement).className).toContain(
|
||||
'z-[1]',
|
||||
);
|
||||
expect((container.firstChild as HTMLElement).className).toContain(
|
||||
'overflow-hidden',
|
||||
);
|
||||
expect((container.firstChild as HTMLElement).className).not.toContain(
|
||||
'overflow-y-auto',
|
||||
);
|
||||
|
||||
const pageVideo = screen.getByTestId(
|
||||
'generation-page-background-video',
|
||||
@@ -114,6 +120,14 @@ describe('CustomWorldGenerationView', () => {
|
||||
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
|
||||
'bg-white/58',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-wait-card').parentElement
|
||||
?.className,
|
||||
).toContain('mt-3');
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-wait-card').parentElement
|
||||
?.className,
|
||||
).toContain('px-0');
|
||||
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('预计等待').parentElement?.className).toContain(
|
||||
@@ -231,6 +245,10 @@ describe('CustomWorldGenerationView', () => {
|
||||
expect(
|
||||
screen.getByTestId('generation-current-step-card').className,
|
||||
).toContain('bg-white/58');
|
||||
expect(
|
||||
screen.getByTestId('generation-current-step-card').parentElement
|
||||
?.className,
|
||||
).toContain('mt-5');
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: '编译草稿 进度' }),
|
||||
).toBeTruthy();
|
||||
@@ -238,10 +256,11 @@ describe('CustomWorldGenerationView', () => {
|
||||
expect(screen.queryByText('写回结果')).toBeNull();
|
||||
expect(screen.queryByText('当前批次')).toBeNull();
|
||||
expect(screen.queryByText('正在整理当前设定步骤')).toBeNull();
|
||||
expect(screen.queryByText('竖屏生成题材')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps the setting information panel as compact information cards', () => {
|
||||
test('does not render setting information cards on generation pages', () => {
|
||||
render(
|
||||
<CustomWorldGenerationView
|
||||
settingText="大鱼吃小鱼题材"
|
||||
@@ -258,19 +277,15 @@ describe('CustomWorldGenerationView', () => {
|
||||
backLabel="返回创作中心"
|
||||
settingDescription={null}
|
||||
settingActionLabel={null}
|
||||
settingTitle="当前大鱼吃小鱼信息"
|
||||
progressTitle="大鱼吃小鱼草稿生成进度"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('当前大鱼吃小鱼信息')).toBeTruthy();
|
||||
expect(screen.getByText('当前大鱼吃小鱼信息').className).toContain('text-[13px]');
|
||||
expect(screen.getByText('题材')).toBeTruthy();
|
||||
expect(screen.getByText('题材').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('火锅')).toBeTruthy();
|
||||
expect(screen.getByText('火锅').className).toContain('text-[13px]');
|
||||
expect(screen.getByText('素材数量')).toBeTruthy();
|
||||
expect(screen.getByText('20 种素材')).toBeTruthy();
|
||||
expect(screen.queryByText('当前大鱼吃小鱼信息')).toBeNull();
|
||||
expect(screen.queryByText('题材')).toBeNull();
|
||||
expect(screen.queryByText('火锅')).toBeNull();
|
||||
expect(screen.queryByText('素材数量')).toBeNull();
|
||||
expect(screen.queryByText('20 种素材')).toBeNull();
|
||||
expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull();
|
||||
expect(screen.getByTestId('generation-page-background-video')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -97,33 +97,18 @@ function resolveCurrentGenerationStep(
|
||||
);
|
||||
}
|
||||
|
||||
function buildFallbackRenderKey(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const normalizedValue = value?.trim();
|
||||
return normalizedValue ? normalizedValue : fallback;
|
||||
}
|
||||
|
||||
export function CustomWorldGenerationView({
|
||||
settingText,
|
||||
anchorEntries = [],
|
||||
progress,
|
||||
isGenerating,
|
||||
onBack,
|
||||
onEditSetting,
|
||||
onRetry,
|
||||
onInterrupt,
|
||||
backLabel = '返回',
|
||||
settingActionLabel = '修改设定',
|
||||
retryLabel = '重新开始生成',
|
||||
interruptLabel = '中断世界生成',
|
||||
settingTitle = '玩家设定',
|
||||
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
|
||||
progressTitle = '生成进度',
|
||||
activeBadgeLabel = '世界建设中',
|
||||
idleBadgeLabel = '等待操作',
|
||||
structuredEmptyText = '正在整理当前设定结构,请稍后。',
|
||||
hideBatchModule = false,
|
||||
}: CustomWorldGenerationViewProps) {
|
||||
void hideBatchModule;
|
||||
@@ -138,12 +123,6 @@ export function CustomWorldGenerationView({
|
||||
: isGenerating
|
||||
? '进行中'
|
||||
: '待处理';
|
||||
const hasStructuredAnchors = anchorEntries.length > 0;
|
||||
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
|
||||
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
|
||||
const normalizedSettingDescription = settingDescription?.trim() ?? '';
|
||||
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
|
||||
const hasSettingDescription = normalizedSettingDescription.length > 0;
|
||||
const estimatedWaitText =
|
||||
progress?.estimatedRemainingMs != null
|
||||
? formatDuration(progress.estimatedRemainingMs)
|
||||
@@ -153,11 +132,10 @@ export function CustomWorldGenerationView({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto overscroll-y-contain bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"
|
||||
>
|
||||
<GenerationPageBackdrop />
|
||||
<div className="relative z-10 mb-6 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-6">
|
||||
<div className="relative z-30 mb-4 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -171,7 +149,10 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex flex-none flex-col gap-4">
|
||||
<div
|
||||
className="relative z-10 flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<section className="overflow-hidden px-0 pb-2 pt-0 sm:px-0">
|
||||
<GenerationProgressHero
|
||||
title={progressTitle}
|
||||
@@ -181,7 +162,7 @@ export function CustomWorldGenerationView({
|
||||
elapsedText={elapsedText}
|
||||
/>
|
||||
|
||||
<div className="mt-[-0.15rem] px-0 sm:px-0">
|
||||
<div className="mt-5 px-0 sm:mt-[-0.15rem] sm:px-0">
|
||||
<GenerationCurrentStepCard
|
||||
label={currentStepLabel}
|
||||
statusLabel={currentStepStatusLabel}
|
||||
@@ -191,24 +172,13 @@ export function CustomWorldGenerationView({
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<>
|
||||
{hasSettingActionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{normalizedSettingActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="platform-button platform-button--primary w-full sm:w-auto"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="platform-button platform-button--primary w-full sm:w-auto"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
) : onInterrupt ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -220,55 +190,6 @@ export function CustomWorldGenerationView({
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-black tracking-[0.08em] text-[#111111]">
|
||||
{settingTitle}
|
||||
</div>
|
||||
{hasSettingDescription ? (
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#7e6656]">
|
||||
{normalizedSettingDescription}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{hasSettingActionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
{normalizedSettingActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{hasStructuredAnchors ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{anchorEntries.map((entry, index) => (
|
||||
<div
|
||||
key={buildFallbackRenderKey(
|
||||
entry.id,
|
||||
`anchor-entry-${index}`,
|
||||
)}
|
||||
className="rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4"
|
||||
>
|
||||
<div className="text-[9px] font-bold tracking-[0.12em] text-[#8e6f5d] sm:text-[10px]">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-line text-[13px] leading-7 text-[#111111]">
|
||||
{entry.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-line rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4 text-[13px] leading-7 text-[#111111] md:max-h-[16rem] md:overflow-y-auto">
|
||||
{settingText || structuredEmptyText}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -133,7 +133,7 @@ export function GenerationProgressHero({
|
||||
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
|
||||
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-0 pb-1 pt-1 sm:pt-4">
|
||||
<div className="sr-only">
|
||||
{title}
|
||||
{phaseLabel ? ` ${phaseLabel}` : ''}
|
||||
@@ -215,7 +215,7 @@ export function GenerationProgressHero({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-20 mt-[-0.3rem] grid w-full grid-cols-2 gap-2 px-0.5 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
|
||||
<div className="relative z-20 mt-3 grid w-full grid-cols-2 gap-2 px-0 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
|
||||
<div
|
||||
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:left-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
|
||||
data-testid="generation-hero-wait-card"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ImgHTMLAttributes, useEffect, useState } from 'react';
|
||||
import React, { type ImgHTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
import { RuntimeResourcePendingMarker } from './common/RuntimeResourcePendingMarker';
|
||||
|
||||
type ResolvedAssetImageProps = Omit<
|
||||
ImgHTMLAttributes<HTMLImageElement>,
|
||||
@@ -19,39 +20,50 @@ export function ResolvedAssetImage({
|
||||
onError,
|
||||
...rest
|
||||
}: ResolvedAssetImageProps) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(src, {
|
||||
refreshKey,
|
||||
});
|
||||
const normalizedSource = src?.trim() ?? '';
|
||||
const normalizedFallbackSrc = fallbackSrc?.trim() ?? '';
|
||||
const [useFallbackSrc, setUseFallbackSrc] = useState(false);
|
||||
const finalSrc =
|
||||
useFallbackSrc && normalizedFallbackSrc
|
||||
? normalizedFallbackSrc
|
||||
: resolvedUrl || normalizedFallbackSrc;
|
||||
const pendingMarker = (
|
||||
<RuntimeResourcePendingMarker
|
||||
source={normalizedSource}
|
||||
kind="image"
|
||||
isPending={shouldResolve && isResolving}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setUseFallbackSrc(false);
|
||||
}, [normalizedFallbackSrc, resolvedUrl]);
|
||||
|
||||
if (!finalSrc) {
|
||||
return null;
|
||||
return pendingMarker;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
src={finalSrc}
|
||||
alt={alt}
|
||||
onError={(event) => {
|
||||
if (
|
||||
normalizedFallbackSrc &&
|
||||
!useFallbackSrc &&
|
||||
finalSrc !== normalizedFallbackSrc
|
||||
) {
|
||||
setUseFallbackSrc(true);
|
||||
}
|
||||
onError?.(event);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{pendingMarker}
|
||||
<img
|
||||
{...rest}
|
||||
src={finalSrc}
|
||||
alt={alt}
|
||||
onError={(event) => {
|
||||
if (
|
||||
normalizedFallbackSrc &&
|
||||
!useFallbackSrc &&
|
||||
finalSrc !== normalizedFallbackSrc
|
||||
) {
|
||||
setUseFallbackSrc(true);
|
||||
}
|
||||
onError?.(event);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,13 @@ const baseUser: AuthUser = {
|
||||
displayName: '138****8000',
|
||||
avatarUrl: null,
|
||||
publicUserCode: 'user-tester',
|
||||
phoneNumber: '13800138000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
wechatDisplayName: '微信旅人甲',
|
||||
wechatAccount: 'wx-openid-bind-001',
|
||||
};
|
||||
|
||||
function renderAccountModal(overrides?: {
|
||||
@@ -112,6 +115,10 @@ test('settings header uses a generic title instead of the phone number', () => {
|
||||
expect(screen.queryByText('当前主题')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /主题设置/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /账号与安全/u })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /主题外观/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /账号信息/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('direct account entry does not render the settings shell as another dialog', () => {
|
||||
@@ -129,12 +136,70 @@ test('direct account entry does not render the settings shell as another dialog'
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('account panel uses compact binding cards and keeps logout actions at the bottom', () => {
|
||||
renderAccountModal({ entryMode: 'account' });
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(within(accountDialog).getByText('账号信息')).toBeTruthy();
|
||||
expect(within(accountDialog).queryByText('身份信息')).toBeNull();
|
||||
expect(
|
||||
within(accountDialog).queryByText(
|
||||
'统一查看身份、安全状态、登录设备与最近操作。',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(within(accountDialog).queryByText('登录方式')).toBeNull();
|
||||
expect(within(accountDialog).getByText('绑定手机号')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('13800138000')).toBeTruthy();
|
||||
expect(within(accountDialog).queryByText('138****8000')).toBeNull();
|
||||
expect(within(accountDialog).getByText('绑定微信')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('微信旅人甲')).toBeTruthy();
|
||||
expect(within(accountDialog).queryByText('wx-openid-bind-001')).toBeNull();
|
||||
|
||||
const compactCards = accountDialog.querySelectorAll(
|
||||
'[data-account-binding-card]',
|
||||
);
|
||||
expect(compactCards).toHaveLength(2);
|
||||
expect(
|
||||
within(compactCards[0] as HTMLElement).getByRole('button', {
|
||||
name: '更换手机号',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(compactCards[1] as HTMLElement).getByRole('button', {
|
||||
name: '更换微信号',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
const accountContent =
|
||||
accountDialog.querySelector('[data-account-content]') ?? accountDialog;
|
||||
expect(
|
||||
accountContent.lastElementChild?.getAttribute('data-account-actions'),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
test('account panel avoids bare bound label when wechat display name is missing', () => {
|
||||
renderAccountModal({
|
||||
entryMode: 'account',
|
||||
user: {
|
||||
...baseUser,
|
||||
wechatDisplayName: null,
|
||||
wechatAccount: 'openid_abcdef123456',
|
||||
},
|
||||
});
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(within(accountDialog).getByText('绑定微信')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('微信账号尾号 123456')).toBeTruthy();
|
||||
expect(within(accountDialog).queryByText('openid_abcdef123456')).toBeNull();
|
||||
expect(within(accountDialog).queryByText('已绑定')).toBeNull();
|
||||
});
|
||||
|
||||
test('account actions open in independent panels instead of inline expansion', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(accountDialog).toBeTruthy();
|
||||
@@ -162,7 +227,7 @@ test('nested settings panels keep back navigation without an extra close action'
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
|
||||
@@ -201,7 +266,7 @@ test('settings overlays move focus away from inert triggers and restore it on ba
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
|
||||
const accountTrigger = screen.getByRole('button', { name: /账号与安全/ });
|
||||
expect(document.activeElement).not.toBe(accountTrigger);
|
||||
|
||||
await user.click(accountTrigger);
|
||||
@@ -283,7 +348,7 @@ test('account panel includes merged security devices and audit sections', async
|
||||
],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
|
||||
@@ -324,7 +389,7 @@ test('current merged session group hides kick action and shows count', async ()
|
||||
],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
||||
@@ -348,7 +413,7 @@ test('remote merged session group can be revoked with loading state', async () =
|
||||
revokingSessionIds: ['usess_remote'],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const revokeButton = within(accountDialog).getByRole('button', {
|
||||
@@ -373,7 +438,7 @@ test('remote session revoke passes the grouped session payload', async () => {
|
||||
onRevokeSession,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
||||
await user.click(
|
||||
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
|
||||
'button',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -65,8 +66,8 @@ const SETTINGS_SECTIONS: Array<{
|
||||
label: string;
|
||||
detail: string;
|
||||
}> = [
|
||||
{ id: 'appearance', label: '主题外观', detail: '亮暗主题' },
|
||||
{ id: 'account', label: '账号信息', detail: '身份与安全' },
|
||||
{ id: 'appearance', label: '主题设置', detail: '亮暗主题' },
|
||||
{ id: 'account', label: '账号与安全', detail: '身份与设备' },
|
||||
];
|
||||
|
||||
const ACCOUNT_MODAL_MAX_HEIGHT =
|
||||
@@ -93,17 +94,6 @@ function normalizeSettingsSection(
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
|
||||
switch (loginMethod) {
|
||||
case 'wechat':
|
||||
return '微信登录';
|
||||
case 'phone':
|
||||
return '手机号登录';
|
||||
default:
|
||||
return '账号登录';
|
||||
}
|
||||
}
|
||||
|
||||
function formatSessionTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -119,6 +109,15 @@ function formatSessionTime(value: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatBoundWechatAccount(value: string | null | undefined) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `微信账号尾号 ${normalized.slice(-6)}`;
|
||||
}
|
||||
|
||||
function SettingsEntryCard({
|
||||
label,
|
||||
detail,
|
||||
@@ -166,7 +165,7 @@ function OverlayPanel({
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
@@ -184,12 +183,16 @@ function OverlayPanel({
|
||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
|
||||
{eyebrow ? (
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
|
||||
{eyebrow}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`${eyebrow ? 'mt-2 text-2xl' : 'text-xl sm:text-2xl'} font-semibold text-[var(--platform-text-strong)]`}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
@@ -204,9 +207,10 @@ function OverlayPanel({
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
||||
className="platform-button platform-button--ghost min-h-0 gap-1.5 rounded-full px-3 py-1.5 text-xs"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
) : (
|
||||
@@ -446,17 +450,18 @@ export function AccountModal({
|
||||
? '正在同步平台设置...'
|
||||
: '平台设置已同步';
|
||||
|
||||
const accountSummaryCards = [
|
||||
['登录方式', resolveLoginMethodLabel(user.loginMethod)],
|
||||
['手机号', user.phoneNumberMasked || '未绑定'],
|
||||
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
|
||||
] as const;
|
||||
const boundPhoneNumber =
|
||||
user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定';
|
||||
const boundWechatDisplayName =
|
||||
user.wechatDisplayName?.trim() ||
|
||||
formatBoundWechatAccount(user.wechatAccount) ||
|
||||
(user.wechatBound ? '微信账号已绑定' : '未绑定');
|
||||
|
||||
const sectionSummaries: Record<PrimarySettingsSection, string> = {
|
||||
appearance:
|
||||
platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
|
||||
account:
|
||||
user.phoneNumberMasked || user.wechatBound
|
||||
user.phoneNumber || user.phoneNumberMasked || user.wechatBound
|
||||
? '查看身份、安全状态、登录设备与操作记录。'
|
||||
: '查看账号绑定状态与安全记录。',
|
||||
};
|
||||
@@ -524,7 +529,7 @@ export function AccountModal({
|
||||
{activeSection === 'appearance' ? (
|
||||
<OverlayPanel
|
||||
eyebrow="平台偏好"
|
||||
title="主题外观"
|
||||
title="主题设置"
|
||||
description="切换平台亮色或暗色主题。"
|
||||
onBack={closeSectionPanel}
|
||||
onClose={onClose}
|
||||
@@ -568,70 +573,79 @@ export function AccountModal({
|
||||
|
||||
{activeSection === 'account' ? (
|
||||
<OverlayPanel
|
||||
eyebrow="身份信息"
|
||||
title="账号信息"
|
||||
description="统一查看身份、安全状态、登录设备与最近操作。"
|
||||
standalone={isDirectAccountMode}
|
||||
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<div data-account-content className="flex min-h-0 flex-col gap-3">
|
||||
{accountNotice ? (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{accountNotice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{accountSummaryCards.map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="platform-subpanel rounded-2xl px-4 py-3"
|
||||
>
|
||||
<div className="text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{value}
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
<div
|
||||
data-account-binding-card
|
||||
className="platform-subpanel rounded-2xl px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
绑定手机号
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
|
||||
onClick={(event) => {
|
||||
changePhoneTriggerRef.current = event.currentTarget;
|
||||
setAccountNotice('');
|
||||
resetChangePhoneDraft();
|
||||
setIsChangePhonePanelOpen(true);
|
||||
}}
|
||||
>
|
||||
更换手机号
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{boundPhoneNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-account-binding-card
|
||||
className="platform-subpanel rounded-2xl px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
绑定微信
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
|
||||
onClick={() => {
|
||||
setAccountNotice('更换微信号功能暂未接入。');
|
||||
}}
|
||||
>
|
||||
更换微信号
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{boundWechatDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost h-11 w-full text-sm"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--danger h-11 w-full text-sm"
|
||||
onClick={() => {
|
||||
void onLogoutAll();
|
||||
}}
|
||||
>
|
||||
退出全部设备
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
登录密码
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
在独立面板中设置或修改账号密码。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
|
||||
onClick={(event) => {
|
||||
passwordTriggerRef.current = event.currentTarget;
|
||||
setAccountNotice('');
|
||||
@@ -644,40 +658,12 @@ export function AccountModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
更换手机号
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
在独立面板中输入新的手机号与验证码。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
onClick={(event) => {
|
||||
changePhoneTriggerRef.current = event.currentTarget;
|
||||
setAccountNotice('');
|
||||
resetChangePhoneDraft();
|
||||
setIsChangePhonePanelOpen(true);
|
||||
}}
|
||||
>
|
||||
更换手机号
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
安全状态
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
查看当前生效中的账号保护与限制。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -690,7 +676,7 @@ export function AccountModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3">
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{loadingRiskBlocks ? (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
正在读取安全状态...
|
||||
@@ -734,15 +720,12 @@ export function AccountModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
登录设备
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
查看当前账号的设备会话与登录状态。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -755,7 +738,7 @@ export function AccountModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3">
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{loadingSessions ? (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
正在读取当前登录设备...
|
||||
@@ -818,15 +801,12 @@ export function AccountModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
操作记录
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
查看最近的账号登录与安全动作。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -839,7 +819,7 @@ export function AccountModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3">
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{loadingAuditLogs ? (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
正在读取账号操作记录...
|
||||
@@ -873,6 +853,30 @@ export function AccountModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-account-actions
|
||||
className="grid gap-2.5 pt-1 sm:grid-cols-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost h-10 w-full text-sm"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--danger h-10 w-full text-sm"
|
||||
onClick={() => {
|
||||
void onLogoutAll();
|
||||
}}
|
||||
>
|
||||
退出全部设备
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isChangePhonePanelOpen ? (
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { AuthGate, setAuthGateReloadForTest } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
@@ -107,6 +107,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/');
|
||||
setAuthGateReloadForTest(vi.fn());
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
authMocks.getStoredAccessToken.mockReturnValue('');
|
||||
@@ -158,6 +159,10 @@ beforeEach(() => {
|
||||
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setAuthGateReloadForTest(null);
|
||||
});
|
||||
|
||||
async function acceptLegalConsent(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
dialog: HTMLElement,
|
||||
@@ -382,6 +387,8 @@ test('auth gate keeps sms and password entries available when login options requ
|
||||
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAuthenticated = vi.fn();
|
||||
const reload = vi.fn();
|
||||
setAuthGateReloadForTest(reload);
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -411,6 +418,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
);
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
@@ -636,6 +644,8 @@ test('registration invite modal can skip when invite code is empty', async () =>
|
||||
|
||||
test('auth state refresh keeps mounted platform content and local tab state', async () => {
|
||||
const user = userEvent.setup();
|
||||
const reload = vi.fn();
|
||||
setAuthGateReloadForTest(reload);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -674,10 +684,13 @@ test('auth state refresh keeps mounted platform content and local tab state', as
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logout withdraws user context before backend request finishes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const reload = vi.fn();
|
||||
setAuthGateReloadForTest(reload);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -703,11 +716,14 @@ test('logout withdraws user context before backend request finishes', async () =
|
||||
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
|
||||
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveLogout();
|
||||
await logoutPromise;
|
||||
});
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
|
||||
@@ -65,6 +65,18 @@ type AuthStatus =
|
||||
|
||||
const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password'];
|
||||
|
||||
let reloadCurrentPageForAuthStateChange = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
export function setAuthGateReloadForTest(handler: (() => void) | null) {
|
||||
reloadCurrentPageForAuthStateChange =
|
||||
handler ??
|
||||
(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function readInviteCodeFromLocation(): string {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
return (params.get('inviteCode') || params.get('invite_code') || '')
|
||||
@@ -140,6 +152,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const autoOpenedInviteCodeRef = useRef<string | null>(null);
|
||||
const hasRenderedPlatformContentRef = useRef(false);
|
||||
const authHydrateVersionRef = useRef(0);
|
||||
const lastStableAuthPresenceRef = useRef<boolean | null>(null);
|
||||
const pendingAuthStateReloadRef = useRef(false);
|
||||
const canKeepPlatformContentMounted =
|
||||
hasRenderedPlatformContentRef.current &&
|
||||
(status === 'checking' || status === 'recovering');
|
||||
@@ -152,36 +166,64 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
hasRenderedPlatformContentRef.current = true;
|
||||
}
|
||||
|
||||
const markAuthStateReloadIfChanged = useCallback(
|
||||
(
|
||||
nextUser: AuthUser | null,
|
||||
options: { reloadOnChange?: boolean } = {},
|
||||
) => {
|
||||
const nextHasUser = Boolean(nextUser);
|
||||
const previousHasUser = lastStableAuthPresenceRef.current;
|
||||
if (previousHasUser === null) {
|
||||
lastStableAuthPresenceRef.current = nextHasUser;
|
||||
return;
|
||||
}
|
||||
|
||||
lastStableAuthPresenceRef.current = nextHasUser;
|
||||
if (
|
||||
previousHasUser !== nextHasUser &&
|
||||
options.reloadOnChange !== false
|
||||
) {
|
||||
pendingAuthStateReloadRef.current = true;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const activateReadyUser = useCallback((nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(nextUser);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
}, []);
|
||||
}, [markAuthStateReloadIfChanged]);
|
||||
|
||||
const clearLocalAuthenticatedState = useCallback(() => {
|
||||
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
|
||||
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
|
||||
authHydrateVersionRef.current += 1;
|
||||
pendingProtectedActionRef.current = null;
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
setShowLoginModal(false);
|
||||
setShowRegistrationInviteModal(false);
|
||||
setShowSettingsModal(false);
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setRevokingSessionIds([]);
|
||||
setAuditLogs([]);
|
||||
setRiskBlocks([]);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setBindCaptchaChallenge(null);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setPendingInviteCode('');
|
||||
setRegistrationInviteError('');
|
||||
setError('');
|
||||
}, []);
|
||||
const clearLocalAuthenticatedState = useCallback(
|
||||
(options: { reloadOnChange?: boolean } = {}) => {
|
||||
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
|
||||
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(null, options);
|
||||
pendingProtectedActionRef.current = null;
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
setShowLoginModal(false);
|
||||
setShowRegistrationInviteModal(false);
|
||||
setShowSettingsModal(false);
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setRevokingSessionIds([]);
|
||||
setAuditLogs([]);
|
||||
setRiskBlocks([]);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setBindCaptchaChallenge(null);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setPendingInviteCode('');
|
||||
setRegistrationInviteError('');
|
||||
setError('');
|
||||
},
|
||||
[markAuthStateReloadIfChanged],
|
||||
);
|
||||
|
||||
const restoreAuthSession = useCallback(async () => {
|
||||
const hadLocalAccessToken = Boolean(getStoredAccessToken());
|
||||
@@ -234,7 +276,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}, []);
|
||||
|
||||
const logoutCurrentSession = useCallback(async () => {
|
||||
clearLocalAuthenticatedState();
|
||||
clearLocalAuthenticatedState({ reloadOnChange: false });
|
||||
try {
|
||||
await logoutAuthUser();
|
||||
} catch (logoutError) {
|
||||
@@ -243,11 +285,13 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
? logoutError.message
|
||||
: '退出登录失败,请刷新页面确认状态。',
|
||||
);
|
||||
} finally {
|
||||
reloadCurrentPageForAuthStateChange();
|
||||
}
|
||||
}, [clearLocalAuthenticatedState]);
|
||||
|
||||
const logoutAllSessions = useCallback(async () => {
|
||||
clearLocalAuthenticatedState();
|
||||
clearLocalAuthenticatedState({ reloadOnChange: false });
|
||||
try {
|
||||
await logoutAllAuthSessions();
|
||||
} catch (logoutError) {
|
||||
@@ -256,6 +300,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
? logoutError.message
|
||||
: '退出全部设备失败,请刷新页面确认状态。',
|
||||
);
|
||||
} finally {
|
||||
reloadCurrentPageForAuthStateChange();
|
||||
}
|
||||
}, [clearLocalAuthenticatedState]);
|
||||
|
||||
@@ -386,6 +432,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
markAuthStateReloadIfChanged(null);
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
} catch (optionsError) {
|
||||
@@ -394,6 +441,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
setAvailableLoginMethods(REQUIRED_LOGIN_METHODS);
|
||||
markAuthStateReloadIfChanged(null);
|
||||
setUser(null);
|
||||
// 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口;
|
||||
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
|
||||
@@ -413,6 +461,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
if (restoredSession.kind === 'guest') {
|
||||
markAuthStateReloadIfChanged(null);
|
||||
setAvailableLoginMethods(
|
||||
normalizeAvailableLoginMethods(
|
||||
restoredSession.session?.availableLoginMethods,
|
||||
@@ -423,6 +472,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
const nextSession = restoredSession.session;
|
||||
markAuthStateReloadIfChanged(nextSession.user);
|
||||
setUser(nextSession.user);
|
||||
setAvailableLoginMethods(
|
||||
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
|
||||
@@ -470,19 +520,23 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.removeEventListener('hashchange', handleAuthHashChange);
|
||||
};
|
||||
}, [restoreAuthSession]);
|
||||
}, [markAuthStateReloadIfChanged, restoreAuthSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyUser) {
|
||||
setShowSettingsModal(false);
|
||||
return;
|
||||
} else {
|
||||
setShowLoginModal(false);
|
||||
|
||||
const pendingAction = pendingProtectedActionRef.current;
|
||||
pendingProtectedActionRef.current = null;
|
||||
pendingAction?.();
|
||||
}
|
||||
|
||||
setShowLoginModal(false);
|
||||
|
||||
const pendingAction = pendingProtectedActionRef.current;
|
||||
pendingProtectedActionRef.current = null;
|
||||
pendingAction?.();
|
||||
if (pendingAuthStateReloadRef.current) {
|
||||
pendingAuthStateReloadRef.current = false;
|
||||
reloadCurrentPageForAuthStateChange();
|
||||
}
|
||||
}, [readyUser]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,10 +15,6 @@ vi.mock('../../services/bark-battle-creation', () => ({
|
||||
updateBarkBattleDraftConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./BarkBattlePreviewCard', () => ({
|
||||
BarkBattlePreviewCard: () => <div>汪汪声浪预览</div>,
|
||||
}));
|
||||
|
||||
const draft = {
|
||||
draftId: 'bark-battle-draft-1',
|
||||
workId: 'BB-12345678',
|
||||
@@ -61,6 +57,12 @@ describe('BarkBattleGeneratingView', () => {
|
||||
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
expect((container.firstChild as HTMLElement).className).toContain('z-[1]');
|
||||
expect((container.firstChild as HTMLElement).className).toContain(
|
||||
'overflow-hidden',
|
||||
);
|
||||
expect((container.firstChild as HTMLElement).className).not.toContain(
|
||||
'overflow-y-auto',
|
||||
);
|
||||
expect(screen.getByText('总进度')).toBeTruthy();
|
||||
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
||||
const pageVideo = screen.getByTestId(
|
||||
@@ -100,6 +102,14 @@ describe('BarkBattleGeneratingView', () => {
|
||||
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
|
||||
'bg-white/58',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-wait-card').parentElement
|
||||
?.className,
|
||||
).toContain('mt-3');
|
||||
expect(
|
||||
screen.getByTestId('generation-hero-wait-card').parentElement
|
||||
?.className,
|
||||
).toContain('px-0');
|
||||
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('预计等待').parentElement?.className).toContain(
|
||||
@@ -218,7 +228,13 @@ describe('BarkBattleGeneratingView', () => {
|
||||
expect(
|
||||
screen.getByTestId('generation-current-step-card').className,
|
||||
).toContain('bg-white/58');
|
||||
expect(screen.getByText('预览信息').className).toContain('text-[13px]');
|
||||
expect(
|
||||
screen.getByTestId('generation-current-step-card').parentElement
|
||||
?.className,
|
||||
).toContain('mt-5');
|
||||
expect(screen.queryByText('预览信息')).toBeNull();
|
||||
expect(screen.queryByText('汪汪声浪预览')).toBeNull();
|
||||
expect(screen.queryByText('霓虹公园擂台')).toBeNull();
|
||||
expect(screen.queryByText('对手形象')).toBeNull();
|
||||
expect(screen.queryByText('竞技背景')).toBeNull();
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
GenerationPageBackdrop,
|
||||
GenerationProgressHero,
|
||||
} from '../GenerationProgressHero';
|
||||
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
|
||||
|
||||
type BarkBattleGeneratingViewProps = {
|
||||
draft: BarkBattleDraftConfig;
|
||||
@@ -355,54 +354,49 @@ export function BarkBattleGeneratingView({
|
||||
}, [draft, onComplete, onError]);
|
||||
|
||||
return (
|
||||
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
|
||||
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
|
||||
<GenerationPageBackdrop />
|
||||
<div className="relative z-10 mx-auto flex w-full max-w-[48rem] flex-col">
|
||||
<div className="mb-6 flex shrink-0 items-center justify-between gap-3 sm:mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
|
||||
<span className="break-keep">返回编辑</span>
|
||||
</button>
|
||||
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
|
||||
生成中
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-30 mx-auto mb-4 flex w-full max-w-[48rem] shrink-0 items-center justify-between gap-3 sm:mb-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
|
||||
<span className="break-keep">返回编辑</span>
|
||||
</button>
|
||||
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
|
||||
生成中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="grid content-start gap-3 overflow-hidden px-0 pb-0 pt-0">
|
||||
<GenerationProgressHero
|
||||
title="汪汪声浪素材生成进度"
|
||||
phaseLabel={draft.title || '未命名声浪竞技场'}
|
||||
progressValue={progressValue}
|
||||
estimatedWaitText="3 分钟"
|
||||
elapsedText={formatGenerationDuration(elapsedMs)}
|
||||
<div
|
||||
className="relative z-10 mx-auto flex min-h-0 w-full max-w-[48rem] flex-1 flex-col overflow-y-auto overscroll-y-contain"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<section className="grid content-start gap-3 overflow-hidden px-0 pb-2 pt-0">
|
||||
<GenerationProgressHero
|
||||
title="汪汪声浪素材生成进度"
|
||||
phaseLabel={draft.title || '未命名声浪竞技场'}
|
||||
progressValue={progressValue}
|
||||
estimatedWaitText="3 分钟"
|
||||
elapsedText={formatGenerationDuration(elapsedMs)}
|
||||
/>
|
||||
|
||||
<div className="mt-5 sm:mt-[-0.15rem]">
|
||||
<GenerationCurrentStepCard
|
||||
label={currentStepLabel}
|
||||
statusLabel={currentStepStatusLabel}
|
||||
progressValue={currentStepProgress}
|
||||
/>
|
||||
|
||||
<div className="mt-[-0.15rem]">
|
||||
<GenerationCurrentStepCard
|
||||
label={currentStepLabel}
|
||||
statusLabel={currentStepStatusLabel}
|
||||
progressValue={currentStepProgress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error || primaryFailureMessage ? (
|
||||
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
|
||||
{error ?? primaryFailureMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
|
||||
<div className="mb-4 text-[13px] font-black tracking-[0.08em] text-[#111111]">
|
||||
预览信息
|
||||
|
||||
{error || primaryFailureMessage ? (
|
||||
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
|
||||
{error ?? primaryFailureMessage}
|
||||
</div>
|
||||
<BarkBattlePreviewCard config={previewDraft} />
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
300
src/components/common/CreativeAudioInputPanel.test.tsx
Normal file
300
src/components/common/CreativeAudioInputPanel.test.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
CreativeAudioInputPanel,
|
||||
} from './CreativeAudioInputPanel';
|
||||
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
|
||||
|
||||
type TestAudioAsset = CreativeAudioAsset;
|
||||
|
||||
const originalMediaRecorder = globalThis.MediaRecorder;
|
||||
const originalMediaDevices = navigator.mediaDevices;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.MediaRecorder = originalMediaRecorder;
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: originalMediaDevices,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
|
||||
return {
|
||||
assetId: 'asset-test',
|
||||
audioSrc: 'blob:audio-preview',
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source: 'uploaded',
|
||||
prompt: 'hit.wav',
|
||||
durationMs: 800,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
overrides: Partial<ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>> = {},
|
||||
) {
|
||||
const onAssetChange = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const readFileAsAsset = vi.fn(async (file: File, source: 'uploaded' | 'recorded') =>
|
||||
buildAsset({
|
||||
audioSrc: `blob:${source}`,
|
||||
source,
|
||||
prompt: file.name,
|
||||
}),
|
||||
);
|
||||
|
||||
const rendered = render(
|
||||
<CreativeAudioInputPanel<TestAudioAsset>
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
asset={null}
|
||||
buildRecordedFileName={() => 'recorded-hit.webm'}
|
||||
onAssetChange={onAssetChange}
|
||||
onError={onError}
|
||||
readFileAsAsset={readFileAsAsset}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
return { ...rendered, onAssetChange, onError, readFileAsAsset };
|
||||
}
|
||||
|
||||
function getUploadInput() {
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
return input!;
|
||||
}
|
||||
|
||||
test('音频面板按需显示最长限制标签', () => {
|
||||
renderPanel({ limitLabel: '最长 1 秒' });
|
||||
|
||||
expect(screen.getByText('最长 1 秒')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('音频面板未传限制标签时不渲染限制提示', () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.queryByText('最长 1 秒')).toBeNull();
|
||||
});
|
||||
|
||||
test('音频面板无资产时显示默认音效文案', () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText('默认木鱼音')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('音频面板有预览地址时渲染 audio 控件', () => {
|
||||
const { container } = render(
|
||||
<CreativeAudioInputPanel<TestAudioAsset>
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
asset={buildAsset({ audioSrc: 'blob:preview' })}
|
||||
buildRecordedFileName={() => 'recorded-hit.webm'}
|
||||
onAssetChange={() => {}}
|
||||
onError={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('audio')?.getAttribute('src')).toBe(
|
||||
'blob:preview',
|
||||
);
|
||||
});
|
||||
|
||||
test('音频面板有资产但无预览地址时显示已选择状态', () => {
|
||||
renderPanel({ asset: buildAsset({ audioSrc: '' }) });
|
||||
|
||||
expect(screen.getByText('音效已选择')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('点击重置清空当前音频资产', () => {
|
||||
const onAssetChange = vi.fn();
|
||||
renderPanel({
|
||||
asset: buildAsset(),
|
||||
onAssetChange,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
|
||||
expect(onAssetChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test('取消上传选择时不读取音频', () => {
|
||||
const { readFileAsAsset, onAssetChange } = renderPanel();
|
||||
|
||||
fireEvent.change(getUploadInput(), { target: { files: [] } });
|
||||
|
||||
expect(readFileAsAsset).not.toHaveBeenCalled();
|
||||
expect(onAssetChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('上传音频成功后清空错误并写入资产', async () => {
|
||||
const audioFile = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
|
||||
const { readFileAsAsset, onAssetChange, onError } = renderPanel();
|
||||
|
||||
fireEvent.change(getUploadInput(), { target: { files: [audioFile] } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(readFileAsAsset).toHaveBeenCalledWith(audioFile, 'uploaded'),
|
||||
);
|
||||
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
|
||||
expect(onError).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test('上传音频失败时提示错误且不写入资产', async () => {
|
||||
const readFileAsAsset = vi.fn(async () => {
|
||||
throw new Error('音频最长 1 秒。');
|
||||
});
|
||||
const onAssetChange = vi.fn();
|
||||
const onError = vi.fn();
|
||||
renderPanel({ readFileAsAsset, onAssetChange, onError });
|
||||
|
||||
fireEvent.change(getUploadInput(), {
|
||||
target: {
|
||||
files: [new File(['audio'], 'hit.webm', { type: 'audio/webm' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith('音频最长 1 秒。'));
|
||||
expect(onAssetChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('浏览器不支持录音时提示错误', async () => {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
globalThis.MediaRecorder = undefined as unknown as typeof MediaRecorder;
|
||||
const { onError } = renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onError).toHaveBeenCalledWith('当前浏览器不支持录音。'),
|
||||
);
|
||||
});
|
||||
|
||||
test('录音启动失败时透传启动错误', async () => {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: vi.fn(async () => {
|
||||
throw new Error('麦克风拒绝授权。');
|
||||
}),
|
||||
},
|
||||
});
|
||||
globalThis.MediaRecorder = class {
|
||||
start = vi.fn();
|
||||
stop = vi.fn();
|
||||
} as unknown as typeof MediaRecorder;
|
||||
const { onError } = renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith('麦克风拒绝授权。'));
|
||||
});
|
||||
|
||||
test('录音停止后按 recorded 来源读取音频', async () => {
|
||||
const stopTrack = vi.fn();
|
||||
const recorderInstances: Array<{
|
||||
ondataavailable: ((event: BlobEvent) => void) | null;
|
||||
onstop: (() => void) | null;
|
||||
}> = [];
|
||||
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: vi.fn(async () => ({
|
||||
getTracks: () => [{ stop: stopTrack }],
|
||||
})),
|
||||
},
|
||||
});
|
||||
globalThis.MediaRecorder = class {
|
||||
mimeType = 'audio/webm';
|
||||
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
recorderInstances.push(this);
|
||||
}
|
||||
|
||||
start = vi.fn();
|
||||
|
||||
stop = vi.fn(() => {
|
||||
this.ondataavailable?.({
|
||||
data: new Blob(['recorded-audio'], { type: 'audio/webm' }),
|
||||
} as BlobEvent);
|
||||
this.onstop?.();
|
||||
});
|
||||
} as unknown as typeof MediaRecorder;
|
||||
|
||||
const { readFileAsAsset, onAssetChange } = renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止' }));
|
||||
|
||||
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
|
||||
const [recordedFile, source] = readFileAsAsset.mock.calls[0]!;
|
||||
expect(recordedFile).toBeInstanceOf(File);
|
||||
expect((recordedFile as File).name).toBe('recorded-hit.webm');
|
||||
expect(source).toBe('recorded');
|
||||
expect(stopTrack).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
|
||||
expect(recorderInstances).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('录音保存失败时提示错误', async () => {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: vi.fn(async () => ({
|
||||
getTracks: () => [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
globalThis.MediaRecorder = class {
|
||||
mimeType = 'audio/webm';
|
||||
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
start = vi.fn();
|
||||
stop = vi.fn(() => this.onstop?.());
|
||||
} as unknown as typeof MediaRecorder;
|
||||
const readFileAsAsset = vi.fn(async () => {
|
||||
throw new Error('音频声音过小,请重新录制或上传。');
|
||||
});
|
||||
const onError = vi.fn();
|
||||
renderPanel({ readFileAsAsset, onError });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onError).toHaveBeenCalledWith('音频声音过小,请重新录制或上传。'),
|
||||
);
|
||||
});
|
||||
|
||||
test('禁用状态不启动录音也不允许上传', () => {
|
||||
const getUserMedia = vi.fn();
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
});
|
||||
const { container } = renderPanel({ disabled: true });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
const input = container.querySelector('input[type="file"]');
|
||||
expect(input).not.toBeNull();
|
||||
expect((input as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
@@ -1,20 +1,16 @@
|
||||
import { Mic, Pause, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export type CreativeAudioAsset = {
|
||||
assetId: string;
|
||||
audioSrc: string;
|
||||
audioObjectKey: string;
|
||||
assetObjectId: string;
|
||||
source: string;
|
||||
prompt?: string | null;
|
||||
durationMs?: number | null;
|
||||
};
|
||||
import {
|
||||
type CreativeAudioAsset,
|
||||
readCreativeAudioFileAsAsset,
|
||||
} from './creativeAudioFileAsset';
|
||||
|
||||
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
defaultLabel: string;
|
||||
limitLabel?: string;
|
||||
asset: TAsset | null;
|
||||
buildRecordedFileName: () => string;
|
||||
onAssetChange: (asset: TAsset | null) => void;
|
||||
@@ -25,36 +21,11 @@ type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
|
||||
) => Promise<TAsset>;
|
||||
};
|
||||
|
||||
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
|
||||
file: File,
|
||||
source: 'uploaded' | 'recorded',
|
||||
) {
|
||||
return new Promise<TAsset>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('音频读取失败,请重试。'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
assetId: `local-${source}-${Date.now()}`,
|
||||
audioSrc: reader.result,
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs: null,
|
||||
} as TAsset);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
disabled = false,
|
||||
title,
|
||||
defaultLabel,
|
||||
limitLabel,
|
||||
asset,
|
||||
buildRecordedFileName,
|
||||
onAssetChange,
|
||||
@@ -124,8 +95,15 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
{limitLabel ? (
|
||||
<div className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-black text-[var(--platform-text-soft)]">
|
||||
{limitLabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{asset ? (
|
||||
<button
|
||||
|
||||
@@ -287,6 +287,118 @@ test('creative image input panel supports a preview-only main image mode', () =>
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel can preview the main image and keep upload on a corner button', () => {
|
||||
const onMainImageFileSelect = vi.fn();
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
mainImageClickMode="preview"
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
}}
|
||||
onMainImageFileSelect={onMainImageFileSelect}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
|
||||
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
|
||||
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(2);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭关卡图片预览' }),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '更换参考图' }));
|
||||
expect(inputClickSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
|
||||
target: {
|
||||
files: [new File(['a'], 'level-reference.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('creative image input panel can hide upload and history controls independently', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
canUploadMainImage={false}
|
||||
canUseImageHistory={false}
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
history: '选择历史图片',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onHistoryClick={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '查看关卡图片' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '更换参考图' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByLabelText('上传参考图', { selector: 'input' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
@@ -28,6 +28,8 @@ export type CreativeImageInputPanelLabels = {
|
||||
promptReferenceUpload: string;
|
||||
promptReferencePreviewAlt: string;
|
||||
closePromptReferencePreview: string;
|
||||
previewMainImage?: string;
|
||||
closeMainImagePreview?: string;
|
||||
history?: string;
|
||||
};
|
||||
|
||||
@@ -37,6 +39,9 @@ export type CreativeImageInputPanelProps = {
|
||||
disabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
mainImageMode?: 'edit' | 'preview';
|
||||
mainImageClickMode?: 'upload' | 'preview';
|
||||
canUploadMainImage?: boolean;
|
||||
canUseImageHistory?: boolean;
|
||||
canRemoveMainImage?: boolean;
|
||||
canToggleAiRedraw?: boolean;
|
||||
canUploadPromptReferences?: boolean;
|
||||
@@ -82,6 +87,9 @@ export function CreativeImageInputPanel({
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
mainImageMode = 'edit',
|
||||
mainImageClickMode = 'preview',
|
||||
canUploadMainImage = true,
|
||||
canUseImageHistory = true,
|
||||
canRemoveMainImage = true,
|
||||
canToggleAiRedraw = true,
|
||||
canUploadPromptReferences,
|
||||
@@ -117,8 +125,10 @@ export function CreativeImageInputPanel({
|
||||
onHistoryClick,
|
||||
onSubmit,
|
||||
}: CreativeImageInputPanelProps) {
|
||||
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [previewReferenceImage, setPreviewReferenceImage] =
|
||||
useState<CreativeImageInputReferenceImage | null>(null);
|
||||
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
|
||||
@@ -127,10 +137,19 @@ export function CreativeImageInputPanel({
|
||||
const promptReferenceUploadDisabled =
|
||||
disabled || promptReferenceImages.length >= promptReferenceLimit;
|
||||
const canEditMainImage = mainImageMode === 'edit';
|
||||
const isMainImageUploadEnabled = canEditMainImage && canUploadMainImage;
|
||||
const shouldShowHistoryButton =
|
||||
canEditMainImage && canUseImageHistory && Boolean(onHistoryClick);
|
||||
const shouldPreviewMainImage =
|
||||
mainImageClickMode === 'preview' && Boolean(uploadedImageSrc);
|
||||
const shouldShowMainImageUploadButton =
|
||||
isMainImageUploadEnabled && shouldPreviewMainImage;
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedImageSrc) {
|
||||
setPreviewReferenceImage(null);
|
||||
} else {
|
||||
setIsMainImagePreviewOpen(false);
|
||||
}
|
||||
}, [uploadedImageSrc]);
|
||||
|
||||
@@ -187,35 +206,48 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
<div className={imageFrameClassName}>
|
||||
<div className={imageCardClassName}>
|
||||
{canEditMainImage ? (
|
||||
<>
|
||||
<input
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
uploadedImageSrc ? labels.replaceImage : labels.uploadImage
|
||||
{isMainImageUploadEnabled ? (
|
||||
<input
|
||||
ref={mainImageInputRef}
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
) : null}
|
||||
{shouldPreviewMainImage ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-[2] cursor-zoom-in"
|
||||
aria-label={labels.previewMainImage ?? uploadedImageAlt}
|
||||
title={labels.previewMainImage ?? uploadedImageAlt}
|
||||
onClick={() => setIsMainImagePreviewOpen(true)}
|
||||
/>
|
||||
) : isMainImageUploadEnabled ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
uploadedImageSrc
|
||||
? labels.replaceImage
|
||||
: labels.uploadImage
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc
|
||||
? labels.replaceImage
|
||||
: labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -232,7 +264,19 @@ export function CreativeImageInputPanel({
|
||||
</span>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
|
||||
{canEditMainImage && onHistoryClick ? (
|
||||
{shouldShowMainImageUploadButton ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => mainImageInputRef.current?.click()}
|
||||
className="absolute bottom-3 right-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label={labels.replaceImage}
|
||||
title={labels.replaceImage}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{shouldShowHistoryButton ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -284,7 +328,7 @@ export function CreativeImageInputPanel({
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : canEditMainImage && !uploadedImageSrc ? (
|
||||
) : isMainImageUploadEnabled && !uploadedImageSrc ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] sm:bottom-10 ${
|
||||
@@ -477,6 +521,48 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isMainImagePreviewOpen && uploadedImageSrc ? (
|
||||
<div
|
||||
className="platform-modal-backdrop fixed inset-0 z-[82] flex items-center justify-center px-4 py-6"
|
||||
onClick={() => setIsMainImagePreviewOpen(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="creative-image-main-preview-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-4xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<div
|
||||
id="creative-image-main-preview-title"
|
||||
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{labels.previewMainImage ?? uploadedImageAlt}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
|
||||
}
|
||||
onClick={() => setIsMainImagePreviewOpen(false)}
|
||||
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
alt={uploadedImageAlt}
|
||||
className="h-full max-h-[82vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isRemoveImageConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
|
||||
29
src/components/common/RuntimeResourcePendingMarker.tsx
Normal file
29
src/components/common/RuntimeResourcePendingMarker.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
type RuntimeResourcePendingMarkerProps = {
|
||||
source?: string | null;
|
||||
isPending?: boolean;
|
||||
kind?: string;
|
||||
};
|
||||
|
||||
export const RUNTIME_RESOURCE_PENDING_SELECTOR =
|
||||
'[data-runtime-resource-pending="true"]';
|
||||
|
||||
export function RuntimeResourcePendingMarker({
|
||||
source,
|
||||
isPending = true,
|
||||
kind,
|
||||
}: RuntimeResourcePendingMarkerProps) {
|
||||
const normalizedSource = source?.trim() ?? '';
|
||||
if (!isPending || !normalizedSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
data-runtime-resource-pending="true"
|
||||
data-runtime-resource-src={normalizedSource}
|
||||
data-runtime-resource-kind={kind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
src/components/common/creativeAudioFileAsset.ts
Normal file
35
src/components/common/creativeAudioFileAsset.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type CreativeAudioAsset = {
|
||||
assetId: string;
|
||||
audioSrc: string;
|
||||
audioObjectKey: string;
|
||||
assetObjectId: string;
|
||||
source: string;
|
||||
prompt?: string | null;
|
||||
durationMs?: number | null;
|
||||
};
|
||||
|
||||
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
|
||||
file: File,
|
||||
source: 'uploaded' | 'recorded',
|
||||
) {
|
||||
return new Promise<TAsset>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('音频读取失败,请重试。'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
assetId: `local-${source}-${Date.now()}`,
|
||||
audioSrc: reader.result,
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs: null,
|
||||
} as TAsset);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
241
src/components/common/creativeAudioProcessing.test.ts
Normal file
241
src/components/common/creativeAudioProcessing.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
encodePcmChannelsToWavBlob,
|
||||
findAudibleFrameRange,
|
||||
normalizeAudioBufferSection,
|
||||
prepareCreativeAudioFileForLocalUse,
|
||||
} from './creativeAudioProcessing';
|
||||
|
||||
const originalAudioContext = globalThis.AudioContext;
|
||||
const originalCreateObjectUrl = URL.createObjectURL;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.AudioContext = originalAudioContext;
|
||||
URL.createObjectURL = originalCreateObjectUrl;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createAudioBufferStub(
|
||||
channels: number[][],
|
||||
sampleRate = 1000,
|
||||
): AudioBuffer {
|
||||
return {
|
||||
length: channels[0]?.length ?? 0,
|
||||
numberOfChannels: channels.length,
|
||||
sampleRate,
|
||||
duration: (channels[0]?.length ?? 0) / sampleRate,
|
||||
getChannelData: (channel: number) =>
|
||||
new Float32Array(channels[channel] ?? []),
|
||||
} as AudioBuffer;
|
||||
}
|
||||
|
||||
function installAudioContextMock(
|
||||
decodeAudioData: (bytes: ArrayBuffer) => Promise<AudioBuffer>,
|
||||
) {
|
||||
globalThis.AudioContext = class {
|
||||
decodeAudioData = decodeAudioData;
|
||||
close = vi.fn();
|
||||
} as unknown as typeof AudioContext;
|
||||
}
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects empty audio files', async () => {
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File([], 'empty.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('音频文件为空,请重新选择。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects non-audio files', async () => {
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['not-audio'], 'note.txt', { type: 'text/plain' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('请选择音频文件。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse reports decode failures', async () => {
|
||||
installAudioContextMock(async () => {
|
||||
throw new Error('decode failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'broken.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('音频解码失败,请重新选择。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects when AudioContext is unavailable', async () => {
|
||||
globalThis.AudioContext = undefined as unknown as typeof AudioContext;
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('当前浏览器不支持音频处理。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects all-silent audio', async () => {
|
||||
installAudioContextMock(async () => createAudioBufferStub([[0, 0.001, 0]], 1000));
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'silent.webm', { type: 'audio/webm' }),
|
||||
'recorded',
|
||||
),
|
||||
).rejects.toThrow('音频声音过小,请重新录制或上传。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse allows audio exactly at the visible limit', async () => {
|
||||
URL.createObjectURL = vi.fn(() => 'blob:one-second-audio');
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([Array.from({ length: 1000 }, () => 0.2)], 1000),
|
||||
);
|
||||
|
||||
const asset = await prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'one-second.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
);
|
||||
|
||||
expect(asset.durationMs).toBe(1000);
|
||||
expect(asset.audioSrc).toBe('blob:one-second-audio');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects audio longer than the visible limit after trimming', async () => {
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([[0, ...Array.from({ length: 1001 }, () => 0.2), 0]], 1000),
|
||||
);
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'long.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('音频最长 1 秒。');
|
||||
});
|
||||
|
||||
test('findAudibleFrameRange trims quiet leading and trailing frames', () => {
|
||||
const buffer = createAudioBufferStub([
|
||||
[0, 0.003, 0.02, 0.2, -0.03, 0.004],
|
||||
[0, 0, 0, 0.05, 0, 0],
|
||||
]);
|
||||
|
||||
expect(findAudibleFrameRange(buffer, 0.01)).toEqual({
|
||||
startFrame: 2,
|
||||
frameCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeAudioBufferSection pulls samples toward -15 LKFS approximation', () => {
|
||||
const buffer = createAudioBufferStub([[0.02, -0.02, 0.02, -0.02]], 1000);
|
||||
const normalized = normalizeAudioBufferSection(
|
||||
buffer,
|
||||
{ startFrame: 0, frameCount: 4 },
|
||||
{ targetLkfs: -15, peakCeiling: 0.98 },
|
||||
);
|
||||
const channel = normalized[0];
|
||||
expect(channel).toBeDefined();
|
||||
const rms = Math.sqrt(
|
||||
channel!.reduce((sum, sample) => sum + sample * sample, 0) /
|
||||
channel!.length,
|
||||
);
|
||||
|
||||
expect(rms).toBeCloseTo(Math.pow(10, -15 / 20), 3);
|
||||
});
|
||||
|
||||
test('normalizeAudioBufferSection avoids clipping when target gain is too high', () => {
|
||||
const buffer = createAudioBufferStub([[0.8, -0.8, 0.4, -0.4]], 1000);
|
||||
const normalized = normalizeAudioBufferSection(
|
||||
buffer,
|
||||
{ startFrame: 0, frameCount: 4 },
|
||||
{ targetLkfs: 0, peakCeiling: 0.5 },
|
||||
);
|
||||
const channel = normalized[0];
|
||||
expect(channel).toBeDefined();
|
||||
const peak = Math.max(...channel!.map((sample) => Math.abs(sample)));
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(0.5);
|
||||
});
|
||||
|
||||
test('normalizeAudioBufferSection rejects zero-energy sections', () => {
|
||||
const buffer = createAudioBufferStub([[0, 0, 0]], 1000);
|
||||
|
||||
expect(() =>
|
||||
normalizeAudioBufferSection(buffer, { startFrame: 0, frameCount: 3 }),
|
||||
).toThrow('音频声音过小,请重新录制或上传。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse writes trimmed normalized wav blob', async () => {
|
||||
URL.createObjectURL = vi.fn(() => 'blob:processed-audio');
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([[0, 0, 0.12, -0.12, 0]], 1000),
|
||||
);
|
||||
|
||||
const asset = await prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
);
|
||||
const bytes = await asset.blob.arrayBuffer();
|
||||
|
||||
expect(asset.fileName).toBe('hit.wav');
|
||||
expect(asset.mimeType).toBe('audio/wav');
|
||||
expect(asset.audioSrc).toBe('blob:processed-audio');
|
||||
expect(asset.durationMs).toBe(2);
|
||||
expect(String.fromCharCode(...new Uint8Array(bytes, 0, 4))).toBe('RIFF');
|
||||
expect(String.fromCharCode(...new Uint8Array(bytes, 8, 4))).toBe('WAVE');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse still succeeds without object URL support', async () => {
|
||||
URL.createObjectURL = undefined as unknown as typeof URL.createObjectURL;
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([[0.12, -0.12]], 1000),
|
||||
);
|
||||
|
||||
const asset = await prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
|
||||
'recorded',
|
||||
);
|
||||
|
||||
expect(asset.audioSrc).toBe('');
|
||||
expect(asset.previewUrl).toBe('');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse normalizes processed wav file names', async () => {
|
||||
URL.createObjectURL = vi.fn(() => 'blob:processed-audio');
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([[0.12, -0.12]], 1000),
|
||||
);
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'hit-sound', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).resolves.toMatchObject({ fileName: 'hit-sound.wav' });
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], ' ', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).resolves.toMatchObject({ fileName: 'creative-audio.wav' });
|
||||
});
|
||||
|
||||
test('encodePcmChannelsToWavBlob writes pcm16 wav bytes', async () => {
|
||||
const blob = encodePcmChannelsToWavBlob(
|
||||
[new Float32Array([0.25, -0.5])],
|
||||
1000,
|
||||
);
|
||||
const bytes = await blob.arrayBuffer();
|
||||
const view = new DataView(bytes);
|
||||
|
||||
expect(blob.type).toBe('audio/wav');
|
||||
expect(view.getUint32(40, true)).toBe(4);
|
||||
expect(view.getInt16(44, true)).toBeCloseTo(8191, -1);
|
||||
expect(view.getInt16(46, true)).toBeCloseTo(-16384, -1);
|
||||
});
|
||||
308
src/components/common/creativeAudioProcessing.ts
Normal file
308
src/components/common/creativeAudioProcessing.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
type CreativeAudioAsset,
|
||||
} from './creativeAudioFileAsset';
|
||||
|
||||
type BrowserAudioGlobal = typeof globalThis & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
export type CreativeAudioSource = 'uploaded' | 'recorded';
|
||||
|
||||
export type PendingCreativeAudioAsset = CreativeAudioAsset & {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
blob: Blob;
|
||||
source: CreativeAudioSource;
|
||||
previewUrl: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type CreativeAudioProcessingOptions = {
|
||||
maxDurationMs?: number;
|
||||
silenceThreshold?: number;
|
||||
targetLkfs?: number;
|
||||
peakCeiling?: number;
|
||||
};
|
||||
|
||||
export type AudibleFrameRange = {
|
||||
startFrame: number;
|
||||
frameCount: number;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_DURATION_MS = 1000;
|
||||
const DEFAULT_SILENCE_THRESHOLD = 0.01;
|
||||
const DEFAULT_TARGET_LKFS = -15;
|
||||
const DEFAULT_PEAK_CEILING = 0.98;
|
||||
const WAV_HEADER_BYTE_LENGTH = 44;
|
||||
const WAV_BITS_PER_SAMPLE = 16;
|
||||
const WAV_BYTES_PER_SAMPLE = WAV_BITS_PER_SAMPLE / 8;
|
||||
|
||||
export async function prepareCreativeAudioFileForLocalUse(
|
||||
file: File,
|
||||
source: CreativeAudioSource,
|
||||
options: CreativeAudioProcessingOptions = {},
|
||||
): Promise<PendingCreativeAudioAsset> {
|
||||
validateCreativeAudioFile(file);
|
||||
|
||||
const decodedBuffer = await decodeCreativeAudioFile(file);
|
||||
const range = findAudibleFrameRange(
|
||||
decodedBuffer,
|
||||
options.silenceThreshold ?? DEFAULT_SILENCE_THRESHOLD,
|
||||
);
|
||||
if (!range) {
|
||||
throw new Error('音频声音过小,请重新录制或上传。');
|
||||
}
|
||||
|
||||
const durationMs = Math.round(
|
||||
(range.frameCount / decodedBuffer.sampleRate) * 1000,
|
||||
);
|
||||
const maxDurationMs = options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS;
|
||||
if (durationMs > maxDurationMs) {
|
||||
throw new Error(`音频最长 ${formatDurationSeconds(maxDurationMs)} 秒。`);
|
||||
}
|
||||
|
||||
const normalized = normalizeAudioBufferSection(decodedBuffer, range, {
|
||||
targetLkfs: options.targetLkfs ?? DEFAULT_TARGET_LKFS,
|
||||
peakCeiling: options.peakCeiling ?? DEFAULT_PEAK_CEILING,
|
||||
});
|
||||
const blob = encodePcmChannelsToWavBlob(normalized, decodedBuffer.sampleRate);
|
||||
const fileName = buildProcessedAudioFileName(file.name);
|
||||
const previewUrl =
|
||||
typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
|
||||
? URL.createObjectURL(blob)
|
||||
: '';
|
||||
|
||||
return {
|
||||
assetId: `local-${source}-${Date.now()}`,
|
||||
audioSrc: previewUrl,
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs,
|
||||
fileName,
|
||||
mimeType: blob.type,
|
||||
blob,
|
||||
previewUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function findAudibleFrameRange(
|
||||
buffer: AudioBuffer,
|
||||
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
|
||||
): AudibleFrameRange | null {
|
||||
const threshold = Math.max(0, silenceThreshold);
|
||||
let startFrame: number | null = null;
|
||||
let endFrame: number | null = null;
|
||||
|
||||
for (let frameIndex = 0; frameIndex < buffer.length; frameIndex += 1) {
|
||||
if (isFrameAudible(buffer, frameIndex, threshold)) {
|
||||
startFrame = frameIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startFrame === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let frameIndex = buffer.length - 1; frameIndex >= startFrame; frameIndex -= 1) {
|
||||
if (isFrameAudible(buffer, frameIndex, threshold)) {
|
||||
endFrame = frameIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endFrame === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startFrame,
|
||||
frameCount: endFrame - startFrame + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAudioBufferSection(
|
||||
buffer: AudioBuffer,
|
||||
range: AudibleFrameRange,
|
||||
options: Pick<CreativeAudioProcessingOptions, 'targetLkfs' | 'peakCeiling'> = {},
|
||||
) {
|
||||
const channelCount = Math.max(1, buffer.numberOfChannels);
|
||||
const targetLkfs = options.targetLkfs ?? DEFAULT_TARGET_LKFS;
|
||||
const peakCeiling = Math.max(0.01, options.peakCeiling ?? DEFAULT_PEAK_CEILING);
|
||||
const channels = Array.from({ length: channelCount }, (_value, channelIndex) =>
|
||||
copyChannelSection(buffer, channelIndex, range),
|
||||
);
|
||||
const stats = measurePcmStats(channels);
|
||||
if (stats.rms <= 0 || stats.peak <= 0) {
|
||||
throw new Error('音频声音过小,请重新录制或上传。');
|
||||
}
|
||||
|
||||
// 浏览器端近似:用全通道 RMS 估算 LKFS,再按 GY/T 377-2023 目标值拉到 -15 LKFS。
|
||||
const targetLinear = Math.pow(10, targetLkfs / 20);
|
||||
const loudnessGain = targetLinear / stats.rms;
|
||||
const protectedGain = Math.min(loudnessGain, peakCeiling / stats.peak);
|
||||
|
||||
return channels.map((channel) =>
|
||||
Float32Array.from(channel, (sample) => clampSample(sample * protectedGain)),
|
||||
);
|
||||
}
|
||||
|
||||
export function encodePcmChannelsToWavBlob(
|
||||
channels: Float32Array[],
|
||||
sampleRate: number,
|
||||
) {
|
||||
const channelCount = Math.max(1, channels.length);
|
||||
const frameCount = channels[0]?.length ?? 0;
|
||||
const dataByteLength = frameCount * channelCount * WAV_BYTES_PER_SAMPLE;
|
||||
const output = new ArrayBuffer(WAV_HEADER_BYTE_LENGTH + dataByteLength);
|
||||
const view = new DataView(output);
|
||||
|
||||
writeAscii(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataByteLength, true);
|
||||
writeAscii(view, 8, 'WAVE');
|
||||
writeAscii(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, channelCount, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * channelCount * WAV_BYTES_PER_SAMPLE, true);
|
||||
view.setUint16(32, channelCount * WAV_BYTES_PER_SAMPLE, true);
|
||||
view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
|
||||
writeAscii(view, 36, 'data');
|
||||
view.setUint32(40, dataByteLength, true);
|
||||
|
||||
let outputOffset = WAV_HEADER_BYTE_LENGTH;
|
||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
|
||||
const sample = channels[channelIndex]?.[frameIndex] ?? 0;
|
||||
view.setInt16(outputOffset, toSignedPcm16(sample), true);
|
||||
outputOffset += WAV_BYTES_PER_SAMPLE;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([output], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
function validateCreativeAudioFile(file: File) {
|
||||
if (file.size <= 0) {
|
||||
throw new Error('音频文件为空,请重新选择。');
|
||||
}
|
||||
if (!resolveFileMimeType(file).startsWith('audio/')) {
|
||||
throw new Error('请选择音频文件。');
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeCreativeAudioFile(file: File) {
|
||||
const AudioContextConstructor = getAudioContextConstructor();
|
||||
if (!AudioContextConstructor) {
|
||||
throw new Error('当前浏览器不支持音频处理。');
|
||||
}
|
||||
|
||||
const context = new AudioContextConstructor();
|
||||
try {
|
||||
const bytes = await file.arrayBuffer();
|
||||
return await context.decodeAudioData(bytes.slice(0));
|
||||
} catch {
|
||||
throw new Error('音频解码失败,请重新选择。');
|
||||
} finally {
|
||||
void context.close();
|
||||
}
|
||||
}
|
||||
|
||||
function getAudioContextConstructor() {
|
||||
const audioGlobal = globalThis as BrowserAudioGlobal;
|
||||
return audioGlobal.AudioContext ?? audioGlobal.webkitAudioContext ?? null;
|
||||
}
|
||||
|
||||
function resolveFileMimeType(file: File) {
|
||||
if (file.type.trim()) {
|
||||
return file.type.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isFrameAudible(
|
||||
buffer: AudioBuffer,
|
||||
frameIndex: number,
|
||||
threshold: number,
|
||||
) {
|
||||
for (
|
||||
let channelIndex = 0;
|
||||
channelIndex < buffer.numberOfChannels;
|
||||
channelIndex += 1
|
||||
) {
|
||||
const channelData = buffer.getChannelData(channelIndex);
|
||||
if (Math.abs(channelData[frameIndex] ?? 0) > threshold) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function copyChannelSection(
|
||||
buffer: AudioBuffer,
|
||||
channelIndex: number,
|
||||
range: AudibleFrameRange,
|
||||
) {
|
||||
const source =
|
||||
channelIndex < buffer.numberOfChannels
|
||||
? buffer.getChannelData(channelIndex)
|
||||
: new Float32Array(buffer.length);
|
||||
const output = new Float32Array(range.frameCount);
|
||||
for (let frameOffset = 0; frameOffset < range.frameCount; frameOffset += 1) {
|
||||
output[frameOffset] = source[range.startFrame + frameOffset] ?? 0;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function measurePcmStats(channels: Float32Array[]) {
|
||||
let sumSquares = 0;
|
||||
let peak = 0;
|
||||
let sampleCount = 0;
|
||||
for (const channel of channels) {
|
||||
for (const sample of channel) {
|
||||
sumSquares += sample * sample;
|
||||
peak = Math.max(peak, Math.abs(sample));
|
||||
sampleCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
rms: sampleCount > 0 ? Math.sqrt(sumSquares / sampleCount) : 0,
|
||||
peak,
|
||||
};
|
||||
}
|
||||
|
||||
function clampSample(sample: number) {
|
||||
return Math.max(-1, Math.min(1, sample));
|
||||
}
|
||||
|
||||
function toSignedPcm16(sample: number) {
|
||||
const clamped = clampSample(sample);
|
||||
return clamped < 0
|
||||
? Math.round(clamped * 0x8000)
|
||||
: Math.round(clamped * 0x7fff);
|
||||
}
|
||||
|
||||
function writeAscii(view: DataView, offset: number, value: string) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
view.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
}
|
||||
|
||||
function buildProcessedAudioFileName(fileName: string) {
|
||||
const normalizedName = fileName.trim();
|
||||
if (!normalizedName) {
|
||||
return 'creative-audio.wav';
|
||||
}
|
||||
return /\.[^.]+$/u.test(normalizedName)
|
||||
? normalizedName.replace(/\.[^.]+$/u, '.wav')
|
||||
: `${normalizedName}.wav`;
|
||||
}
|
||||
|
||||
function formatDurationSeconds(durationMs: number) {
|
||||
return Number.isInteger(durationMs / 1000)
|
||||
? String(durationMs / 1000)
|
||||
: (durationMs / 1000).toFixed(1);
|
||||
}
|
||||
77
src/components/common/creativeAudioSilenceTrim.test.ts
Normal file
77
src/components/common/creativeAudioSilenceTrim.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildLeadingSilenceTrimmedWavBlob,
|
||||
findFirstAudibleFrame,
|
||||
} from './creativeAudioSilenceTrim';
|
||||
|
||||
function createAudioBufferStub(
|
||||
channels: number[][],
|
||||
sampleRate = 1000,
|
||||
): AudioBuffer {
|
||||
return {
|
||||
length: channels[0]?.length ?? 0,
|
||||
numberOfChannels: channels.length,
|
||||
sampleRate,
|
||||
duration: (channels[0]?.length ?? 0) / sampleRate,
|
||||
getChannelData: (channel: number) =>
|
||||
new Float32Array(channels[channel] ?? []),
|
||||
} as AudioBuffer;
|
||||
}
|
||||
|
||||
test('findFirstAudibleFrame skips leading frames that are silent across all channels', () => {
|
||||
const buffer = createAudioBufferStub([
|
||||
[0, 0.003, -0.006, 0.012, 0.02],
|
||||
[0, -0.004, 0.009, 0, 0],
|
||||
]);
|
||||
|
||||
expect(findFirstAudibleFrame(buffer, 0.01)).toBe(3);
|
||||
});
|
||||
|
||||
test('buildLeadingSilenceTrimmedWavBlob writes a wav that starts at the first audible frame', async () => {
|
||||
const buffer = createAudioBufferStub(
|
||||
[
|
||||
[0, 0, 0, 0.25, -0.5],
|
||||
[0, 0, 0, -0.25, 0.5],
|
||||
],
|
||||
1000,
|
||||
);
|
||||
|
||||
const blob = buildLeadingSilenceTrimmedWavBlob(buffer, {
|
||||
silenceThreshold: 0.01,
|
||||
minimumTrimDurationMs: 1,
|
||||
});
|
||||
|
||||
expect(blob).not.toBeNull();
|
||||
expect(blob?.type).toBe('audio/wav');
|
||||
|
||||
const bytes = await blob!.arrayBuffer();
|
||||
const view = new DataView(bytes);
|
||||
|
||||
expect(String.fromCharCode(...new Uint8Array(bytes, 0, 4))).toBe('RIFF');
|
||||
expect(String.fromCharCode(...new Uint8Array(bytes, 8, 4))).toBe('WAVE');
|
||||
expect(String.fromCharCode(...new Uint8Array(bytes, 36, 4))).toBe('data');
|
||||
expect(view.getUint32(40, true)).toBe(8);
|
||||
expect(view.getInt16(44, true)).toBeCloseTo(8191, -1);
|
||||
expect(view.getInt16(46, true)).toBeCloseTo(-8192, -1);
|
||||
expect(view.getInt16(48, true)).toBeCloseTo(-16384, -1);
|
||||
expect(view.getInt16(50, true)).toBeCloseTo(16383, -1);
|
||||
});
|
||||
|
||||
test('buildLeadingSilenceTrimmedWavBlob keeps the original recording when no leading silence is removable', () => {
|
||||
const startsImmediately = createAudioBufferStub([[0.2, 0.1, 0.05]], 1000);
|
||||
const allSilent = createAudioBufferStub([[0, 0.001, -0.001]], 1000);
|
||||
|
||||
expect(
|
||||
buildLeadingSilenceTrimmedWavBlob(startsImmediately, {
|
||||
silenceThreshold: 0.01,
|
||||
minimumTrimDurationMs: 1,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildLeadingSilenceTrimmedWavBlob(allSilent, {
|
||||
silenceThreshold: 0.01,
|
||||
minimumTrimDurationMs: 1,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
190
src/components/common/creativeAudioSilenceTrim.ts
Normal file
190
src/components/common/creativeAudioSilenceTrim.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
type BrowserAudioGlobal = typeof globalThis & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
export type LeadingSilenceTrimOptions = {
|
||||
silenceThreshold?: number;
|
||||
minimumTrimDurationMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SILENCE_THRESHOLD = 0.01;
|
||||
const DEFAULT_MINIMUM_TRIM_DURATION_MS = 20;
|
||||
const WAV_HEADER_BYTE_LENGTH = 44;
|
||||
const WAV_BITS_PER_SAMPLE = 16;
|
||||
const WAV_BYTES_PER_SAMPLE = WAV_BITS_PER_SAMPLE / 8;
|
||||
|
||||
export function findFirstAudibleFrame(
|
||||
buffer: AudioBuffer,
|
||||
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
|
||||
) {
|
||||
const threshold = Math.max(0, silenceThreshold);
|
||||
|
||||
for (let frameIndex = 0; frameIndex < buffer.length; frameIndex += 1) {
|
||||
for (
|
||||
let channelIndex = 0;
|
||||
channelIndex < buffer.numberOfChannels;
|
||||
channelIndex += 1
|
||||
) {
|
||||
const channelData = buffer.getChannelData(channelIndex);
|
||||
if (Math.abs(channelData[frameIndex] ?? 0) > threshold) {
|
||||
return frameIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildLeadingSilenceTrimmedWavBlob(
|
||||
buffer: AudioBuffer,
|
||||
options: LeadingSilenceTrimOptions = {},
|
||||
) {
|
||||
const silenceThreshold =
|
||||
options.silenceThreshold ?? DEFAULT_SILENCE_THRESHOLD;
|
||||
const minimumTrimDurationMs =
|
||||
options.minimumTrimDurationMs ?? DEFAULT_MINIMUM_TRIM_DURATION_MS;
|
||||
const firstAudibleFrame = findFirstAudibleFrame(buffer, silenceThreshold);
|
||||
|
||||
if (firstAudibleFrame === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minimumTrimFrames = Math.max(
|
||||
1,
|
||||
Math.round((buffer.sampleRate * minimumTrimDurationMs) / 1000),
|
||||
);
|
||||
if (firstAudibleFrame < minimumTrimFrames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frameCount = buffer.length - firstAudibleFrame;
|
||||
if (frameCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return encodeAudioBufferSectionToWavBlob(
|
||||
buffer,
|
||||
firstAudibleFrame,
|
||||
frameCount,
|
||||
);
|
||||
}
|
||||
|
||||
export async function trimLeadingSilenceFromRecordedAudioFile(
|
||||
file: File,
|
||||
options: LeadingSilenceTrimOptions = {},
|
||||
) {
|
||||
try {
|
||||
const decodedBuffer = await decodeRecordedAudioFile(file);
|
||||
if (!decodedBuffer) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const trimmedBlob = buildLeadingSilenceTrimmedWavBlob(
|
||||
decodedBuffer,
|
||||
options,
|
||||
);
|
||||
if (!trimmedBlob) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return new File([trimmedBlob], buildTrimmedAudioFileName(file.name), {
|
||||
type: trimmedBlob.type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// 录音裁剪只是体验优化,浏览器解码失败时必须保留用户刚录好的原始文件。
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
function getAudioContextConstructor() {
|
||||
const audioGlobal = globalThis as BrowserAudioGlobal;
|
||||
return audioGlobal.AudioContext ?? audioGlobal.webkitAudioContext ?? null;
|
||||
}
|
||||
|
||||
async function decodeRecordedAudioFile(file: File) {
|
||||
const AudioContextConstructor = getAudioContextConstructor();
|
||||
if (!AudioContextConstructor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const context = new AudioContextConstructor();
|
||||
try {
|
||||
const bytes = await file.arrayBuffer();
|
||||
return await context.decodeAudioData(bytes.slice(0));
|
||||
} finally {
|
||||
void context.close();
|
||||
}
|
||||
}
|
||||
|
||||
function encodeAudioBufferSectionToWavBlob(
|
||||
buffer: AudioBuffer,
|
||||
startFrame: number,
|
||||
frameCount: number,
|
||||
) {
|
||||
// MediaRecorder 输出格式不稳定;解码后统一写成 WAV,避免再依赖浏览器重新编码。
|
||||
const channelCount = Math.max(1, buffer.numberOfChannels);
|
||||
const dataByteLength =
|
||||
frameCount * channelCount * WAV_BYTES_PER_SAMPLE;
|
||||
const output = new ArrayBuffer(WAV_HEADER_BYTE_LENGTH + dataByteLength);
|
||||
const view = new DataView(output);
|
||||
const channelData = Array.from({ length: channelCount }, (_value, index) =>
|
||||
index < buffer.numberOfChannels
|
||||
? buffer.getChannelData(index)
|
||||
: new Float32Array(buffer.length),
|
||||
);
|
||||
|
||||
writeAscii(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataByteLength, true);
|
||||
writeAscii(view, 8, 'WAVE');
|
||||
writeAscii(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, channelCount, true);
|
||||
view.setUint32(24, buffer.sampleRate, true);
|
||||
view.setUint32(
|
||||
28,
|
||||
buffer.sampleRate * channelCount * WAV_BYTES_PER_SAMPLE,
|
||||
true,
|
||||
);
|
||||
view.setUint16(32, channelCount * WAV_BYTES_PER_SAMPLE, true);
|
||||
view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
|
||||
writeAscii(view, 36, 'data');
|
||||
view.setUint32(40, dataByteLength, true);
|
||||
|
||||
let outputOffset = WAV_HEADER_BYTE_LENGTH;
|
||||
for (let frameOffset = 0; frameOffset < frameCount; frameOffset += 1) {
|
||||
const sourceFrame = startFrame + frameOffset;
|
||||
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
|
||||
const sample = channelData[channelIndex]?.[sourceFrame] ?? 0;
|
||||
view.setInt16(outputOffset, toSignedPcm16(sample), true);
|
||||
outputOffset += WAV_BYTES_PER_SAMPLE;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([output], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
function toSignedPcm16(sample: number) {
|
||||
const clamped = Math.max(-1, Math.min(1, sample));
|
||||
return clamped < 0
|
||||
? Math.round(clamped * 0x8000)
|
||||
: Math.round(clamped * 0x7fff);
|
||||
}
|
||||
|
||||
function writeAscii(view: DataView, offset: number, value: string) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
view.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
}
|
||||
|
||||
function buildTrimmedAudioFileName(fileName: string) {
|
||||
const normalizedName = fileName.trim();
|
||||
if (!normalizedName) {
|
||||
return 'recorded-audio.wav';
|
||||
}
|
||||
|
||||
return /\.[^.]+$/u.test(normalizedName)
|
||||
? normalizedName.replace(/\.[^.]+$/u, '.wav')
|
||||
: `${normalizedName}.wav`;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
|
||||
@@ -1043,14 +1043,10 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
|
||||
expect(openedItems).toEqual([persistedDraft]);
|
||||
});
|
||||
|
||||
test('creation hub published share icon copies share text without opening the card', async () => {
|
||||
test('creation hub published share icon opens unified share payload without opening the card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
const onOpenPuzzleDetail = vi.fn();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const onShareWork = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
@@ -1081,6 +1077,7 @@ test('creation hub published share icon copies share text without opening the ca
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={onOpenPuzzleDetail}
|
||||
onShareWork={onShareWork}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
@@ -1092,19 +1089,12 @@ test('creation hub published share icon copies share text without opening the ca
|
||||
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('邀请你来玩《沉钟拼图》'),
|
||||
);
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('作品号:PZ-PROFILE1'),
|
||||
);
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'),
|
||||
);
|
||||
expect(onShareWork).toHaveBeenCalledWith({
|
||||
title: '沉钟拼图',
|
||||
publicWorkCode: 'PZ-PROFILE1',
|
||||
stage: 'puzzle-gallery-detail',
|
||||
});
|
||||
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '分享内容已复制' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation hub published share icon is shown directly on the card header', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect, test } from 'vitest';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import { buildCreationWorkShelfItems } from './creationWorkShelf';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -241,7 +241,7 @@ test('creation start card renders reference-aligned banner and template metadata
|
||||
expect(html).toContain('creation-template-card__body');
|
||||
expect(html).toContain('creation-template-card__cost-badge');
|
||||
expect(html).toContain('拼图关卡创作');
|
||||
expect(html).toContain('10-20泥点数');
|
||||
expect(html).toContain('10泥点数');
|
||||
expect(html).toContain('即将开放');
|
||||
expect(html).toContain('data-locked="true"');
|
||||
expect(html).toContain('暂未开放');
|
||||
@@ -292,7 +292,62 @@ test('locked creation template card replaces mud point cost with unavailable sta
|
||||
expect(html).toContain('data-locked="true"');
|
||||
expect(html).toContain('即将开放');
|
||||
expect(html).toContain('暂未开放');
|
||||
expect(html).not.toContain('10-20泥点数');
|
||||
expect(html).not.toContain('10泥点数');
|
||||
});
|
||||
|
||||
test('creation template card renders mud point cost from unified creation spec', () => {
|
||||
const config = {
|
||||
...testEntryConfig,
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '拼图关卡创作',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
unifiedCreationSpec: {
|
||||
playId: 'puzzle',
|
||||
title: '拼图',
|
||||
mudPointCost: 12,
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
resultStage: 'puzzle-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'pictureDescription',
|
||||
kind: 'text',
|
||||
label: '画面描述',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies CreationEntryConfig;
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={config}
|
||||
creationTypes={derivePlatformCreationTypes(config.creationTypes)}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('12泥点数');
|
||||
expect(html).not.toContain('10泥点数');
|
||||
});
|
||||
|
||||
test('creation start card falls back to legacy single banner when eventBanners is empty', () => {
|
||||
@@ -525,6 +580,7 @@ test('creation start card maps backend jump-hop draft to template card', () => {
|
||||
profileId: 'jump-hop-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-1',
|
||||
themeText: '跳一跳生成草稿',
|
||||
workTitle: '跳一跳生成草稿',
|
||||
workDescription: '后端仍在生成跳一跳玩法。',
|
||||
themeTags: ['跳一跳'],
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
type CreationWorkShelfItem,
|
||||
} from './creationWorkShelf';
|
||||
import {
|
||||
CustomWorldCreationHub as CustomWorldCreationHubView,
|
||||
} from './CustomWorldCreationHub';
|
||||
|
||||
type ShelfBuilderParams = Parameters<typeof buildCreationWorkShelfItems>[0];
|
||||
type HubViewProps = Parameters<typeof CustomWorldCreationHubView>[0];
|
||||
|
||||
type LegacyCustomWorldCreationHubProps = Omit<HubViewProps, 'shelfItems'> &
|
||||
Partial<
|
||||
Omit<ShelfBuilderParams, 'rpgItems' | 'bigFishItems' | 'puzzleItems'>
|
||||
> & {
|
||||
shelfItems?: CreationWorkShelfItem[];
|
||||
items?: ShelfBuilderParams['rpgItems'];
|
||||
bigFishItems?: ShelfBuilderParams['bigFishItems'];
|
||||
puzzleItems?: ShelfBuilderParams['puzzleItems'];
|
||||
onOpenDraft?: ShelfBuilderParams['onOpenRpgDraft'];
|
||||
onEnterPublished?: ShelfBuilderParams['onEnterRpgPublished'];
|
||||
onDeletePublished?: ShelfBuilderParams['onDeleteRpg'] | null;
|
||||
getWorkState?: ShelfBuilderParams['getItemState'];
|
||||
};
|
||||
|
||||
/** 测试用 Adapter:旧 fixture 先转成 shelfItems,生产 Hub Interface 保持窄面。 */
|
||||
export function CustomWorldCreationHub({
|
||||
shelfItems,
|
||||
items = [],
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems = [],
|
||||
match3dItems = [],
|
||||
squareHoleItems = [],
|
||||
jumpHopItems = [],
|
||||
woodenFishItems = [],
|
||||
puzzleItems = [],
|
||||
babyObjectMatchItems = [],
|
||||
barkBattleItems = [],
|
||||
visualNovelItems = [],
|
||||
onOpenDraft,
|
||||
onEnterPublished,
|
||||
onDeletePublished = null,
|
||||
onOpenBigFishDetail,
|
||||
onDeleteBigFish,
|
||||
onOpenMatch3DDetail,
|
||||
onDeleteMatch3D,
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole,
|
||||
onOpenJumpHopDetail,
|
||||
onDeleteJumpHop,
|
||||
onOpenWoodenFishDetail,
|
||||
onDeleteWoodenFish,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBabyObjectMatchDetail,
|
||||
onDeleteBabyObjectMatch,
|
||||
onOpenBarkBattleDetail,
|
||||
onDeleteBarkBattle,
|
||||
onOpenVisualNovelDetail,
|
||||
onDeleteVisualNovel,
|
||||
getItemState,
|
||||
getWorkState,
|
||||
creationTypes,
|
||||
...props
|
||||
}: LegacyCustomWorldCreationHubProps) {
|
||||
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
|
||||
creationTypes,
|
||||
'square-hole',
|
||||
);
|
||||
const resolvedShelfItems =
|
||||
shelfItems ??
|
||||
buildCreationWorkShelfItems({
|
||||
rpgItems: items,
|
||||
rpgLibraryEntries,
|
||||
bigFishItems,
|
||||
match3dItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||
jumpHopItems,
|
||||
woodenFishItems,
|
||||
puzzleItems,
|
||||
babyObjectMatchItems,
|
||||
barkBattleItems,
|
||||
visualNovelItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
canDeleteBigFish: Boolean(onDeleteBigFish),
|
||||
canDeleteMatch3D: Boolean(onDeleteMatch3D),
|
||||
canDeleteSquareHole: Boolean(onDeleteSquareHole),
|
||||
canDeleteJumpHop: Boolean(onDeleteJumpHop),
|
||||
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
||||
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
||||
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
|
||||
onOpenRpgDraft: onOpenDraft,
|
||||
onEnterRpgPublished: onEnterPublished,
|
||||
onDeleteRpg: onDeletePublished ?? undefined,
|
||||
onOpenBigFishDetail,
|
||||
onDeleteBigFish,
|
||||
onOpenMatch3DDetail,
|
||||
onDeleteMatch3D,
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole,
|
||||
onOpenJumpHopDetail,
|
||||
onDeleteJumpHop,
|
||||
onOpenWoodenFishDetail,
|
||||
onDeleteWoodenFish,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBabyObjectMatchDetail,
|
||||
onDeleteBabyObjectMatch,
|
||||
onOpenBarkBattleDetail,
|
||||
onDeleteBarkBattle,
|
||||
onOpenVisualNovelDetail,
|
||||
onDeleteVisualNovel,
|
||||
getItemState: getItemState ?? getWorkState,
|
||||
});
|
||||
|
||||
return (
|
||||
<CustomWorldCreationHubView
|
||||
{...props}
|
||||
creationTypes={creationTypes}
|
||||
shelfItems={resolvedShelfItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { resolveSelectionStageFromPath } from '../../routing/appPageRoutes';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
|
||||
import type {
|
||||
PlatformCreationTypeCard,
|
||||
PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
getCreationWorkShelfItemTime,
|
||||
type CreationWorkShelfItem,
|
||||
type CreationWorkShelfMetricId,
|
||||
type CreationWorkShelfRuntimeState,
|
||||
getCreationWorkShelfItemTime,
|
||||
} from './creationWorkShelf';
|
||||
import {
|
||||
CustomWorldCreationStartCard,
|
||||
@@ -47,7 +34,7 @@ type WorkMetricSnapshot = Record<
|
||||
>;
|
||||
|
||||
type CustomWorldCreationHubProps = {
|
||||
items: CustomWorldWorkSummary[];
|
||||
shelfItems: CreationWorkShelfItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
@@ -55,46 +42,10 @@ type CustomWorldCreationHubProps = {
|
||||
entryConfig: CreationEntryConfig;
|
||||
creationTypes: readonly PlatformCreationTypeCard[];
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
onOpenDraft: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterPublished: (profileId: string) => void;
|
||||
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
deletingWorkId?: string | null;
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
bigFishItems?: BigFishWorkSummary[];
|
||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
match3dItems?: Match3DWorkSummary[];
|
||||
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
|
||||
onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null;
|
||||
squareHoleItems?: SquareHoleWorkSummary[];
|
||||
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
|
||||
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
|
||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
|
||||
woodenFishItems?: WoodenFishWorkSummaryResponse[];
|
||||
onOpenWoodenFishDetail?:
|
||||
| ((item: WoodenFishWorkSummaryResponse) => void)
|
||||
| null;
|
||||
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
claimingPuzzleProfileId?: string | null;
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
|
||||
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
|
||||
barkBattleItems?: BarkBattleWorkSummary[];
|
||||
onOpenBarkBattleDetail?: ((item: BarkBattleWorkSummary) => void) | null;
|
||||
onDeleteBarkBattle?: ((item: BarkBattleWorkSummary) => void) | null;
|
||||
visualNovelItems?: VisualNovelWorkSummary[];
|
||||
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
getWorkState?: (
|
||||
item: CreationWorkShelfItem,
|
||||
) => CreationWorkShelfRuntimeState | null;
|
||||
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
|
||||
onShareWork?: ((payload: PublishShareModalPayload) => void) | null;
|
||||
// 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。
|
||||
recentWorkItems?: CreationWorkShelfItem[];
|
||||
mode?: 'full' | 'start-only' | 'works-only';
|
||||
@@ -163,9 +114,44 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveShelfShareStage(
|
||||
sharePath: string,
|
||||
): PublishShareModalPayload['stage'] | null {
|
||||
let pathname = '';
|
||||
try {
|
||||
pathname = new URL(sharePath, 'https://genarrative.local').pathname;
|
||||
} catch {
|
||||
pathname = sharePath.split(/[?#]/u)[0] ?? '';
|
||||
}
|
||||
|
||||
const stage = resolveSelectionStageFromPath(pathname);
|
||||
return stage === 'platform' ? null : stage;
|
||||
}
|
||||
|
||||
function buildCreationWorkShelfSharePayload(
|
||||
item: CreationWorkShelfItem,
|
||||
): PublishShareModalPayload | null {
|
||||
const publicWorkCode = item.publicWorkCode?.trim();
|
||||
const sharePath = item.sharePath?.trim();
|
||||
if (!publicWorkCode || !sharePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stage = resolveShelfShareStage(sharePath);
|
||||
if (!stage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title,
|
||||
publicWorkCode,
|
||||
stage,
|
||||
};
|
||||
}
|
||||
|
||||
/** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */
|
||||
export function CustomWorldCreationHub({
|
||||
items,
|
||||
shelfItems,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
@@ -173,137 +159,15 @@ export function CustomWorldCreationHub({
|
||||
entryConfig,
|
||||
creationTypes,
|
||||
onCreateType,
|
||||
onOpenDraft,
|
||||
onEnterPublished,
|
||||
onDeletePublished = null,
|
||||
deletingWorkId = null,
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems = [],
|
||||
onOpenBigFishDetail,
|
||||
onDeleteBigFish = null,
|
||||
match3dItems = [],
|
||||
onOpenMatch3DDetail,
|
||||
onDeleteMatch3D = null,
|
||||
squareHoleItems = [],
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole = null,
|
||||
jumpHopItems = [],
|
||||
onOpenJumpHopDetail,
|
||||
onDeleteJumpHop = null,
|
||||
woodenFishItems = [],
|
||||
onOpenWoodenFishDetail = null,
|
||||
onDeleteWoodenFish = null,
|
||||
puzzleItems = [],
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle = null,
|
||||
onClaimPuzzlePointIncentive = null,
|
||||
claimingPuzzleProfileId = null,
|
||||
babyObjectMatchItems = [],
|
||||
onOpenBabyObjectMatchDetail = null,
|
||||
onDeleteBabyObjectMatch = null,
|
||||
barkBattleItems = [],
|
||||
onOpenBarkBattleDetail = null,
|
||||
onDeleteBarkBattle = null,
|
||||
visualNovelItems = [],
|
||||
onOpenVisualNovelDetail = null,
|
||||
onDeleteVisualNovel = null,
|
||||
getWorkState,
|
||||
onOpenShelfItem,
|
||||
onShareWork = null,
|
||||
recentWorkItems: recentWorkSourceItems,
|
||||
mode = 'full',
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
useState<CustomWorldWorkFilter>('all');
|
||||
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
|
||||
creationTypes,
|
||||
'square-hole',
|
||||
);
|
||||
const shelfItems = useMemo(
|
||||
() =>
|
||||
buildCreationWorkShelfItems({
|
||||
rpgItems: items,
|
||||
rpgLibraryEntries,
|
||||
bigFishItems,
|
||||
match3dItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||
jumpHopItems,
|
||||
woodenFishItems,
|
||||
puzzleItems,
|
||||
babyObjectMatchItems,
|
||||
barkBattleItems,
|
||||
visualNovelItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
canDeleteBigFish: Boolean(onDeleteBigFish),
|
||||
canDeleteMatch3D: Boolean(onDeleteMatch3D),
|
||||
canDeleteSquareHole:
|
||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||
canDeleteJumpHop: Boolean(onDeleteJumpHop),
|
||||
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
||||
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
||||
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
|
||||
onOpenRpgDraft: onOpenDraft,
|
||||
onEnterRpgPublished: onEnterPublished,
|
||||
onDeleteRpg: onDeletePublished ?? undefined,
|
||||
onOpenBigFishDetail,
|
||||
onDeleteBigFish: onDeleteBigFish ?? undefined,
|
||||
onOpenMatch3DDetail,
|
||||
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
|
||||
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
|
||||
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
|
||||
onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined,
|
||||
onDeleteWoodenFish: onDeleteWoodenFish ?? undefined,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
||||
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
||||
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
|
||||
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
|
||||
onOpenBarkBattleDetail: onOpenBarkBattleDetail ?? undefined,
|
||||
onDeleteBarkBattle: onDeleteBarkBattle ?? undefined,
|
||||
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
|
||||
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
|
||||
getItemState: getWorkState,
|
||||
}),
|
||||
[
|
||||
bigFishItems,
|
||||
isSquareHoleCreationVisible,
|
||||
babyObjectMatchItems,
|
||||
barkBattleItems,
|
||||
items,
|
||||
match3dItems,
|
||||
onDeleteBigFish,
|
||||
onDeleteMatch3D,
|
||||
onDeleteSquareHole,
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
onDeleteBabyObjectMatch,
|
||||
onDeleteBarkBattle,
|
||||
onDeleteVisualNovel,
|
||||
onDeleteJumpHop,
|
||||
onDeleteWoodenFish,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBigFishDetail,
|
||||
onOpenDraft,
|
||||
onOpenMatch3DDetail,
|
||||
onOpenBabyObjectMatchDetail,
|
||||
onOpenBarkBattleDetail,
|
||||
onOpenPuzzleDetail,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenVisualNovelDetail,
|
||||
onOpenWoodenFishDetail,
|
||||
onEnterPublished,
|
||||
getWorkState,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
onOpenJumpHopDetail,
|
||||
jumpHopItems,
|
||||
woodenFishItems,
|
||||
visualNovelItems,
|
||||
],
|
||||
);
|
||||
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
|
||||
readWorkMetricSnapshot(),
|
||||
);
|
||||
@@ -341,44 +205,8 @@ export function CustomWorldCreationHub({
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
onOpenShelfItem?.(item);
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle':
|
||||
onOpenPuzzleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'baby-object-match':
|
||||
onOpenBabyObjectMatchDetail?.(item.source.item);
|
||||
return;
|
||||
case 'visual-novel':
|
||||
onOpenVisualNovelDetail?.(item.source.item);
|
||||
return;
|
||||
case 'bark-battle':
|
||||
onOpenBarkBattleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'big-fish':
|
||||
onOpenBigFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'match3d':
|
||||
onOpenMatch3DDetail?.(item.source.item);
|
||||
return;
|
||||
case 'square-hole':
|
||||
onOpenSquareHoleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'jump-hop':
|
||||
onOpenJumpHopDetail?.(item.source.item);
|
||||
return;
|
||||
case 'wooden-fish':
|
||||
onOpenWoodenFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'rpg':
|
||||
if (item.status === 'draft') {
|
||||
onOpenDraft(item.source.item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.source.item.profileId) {
|
||||
onEnterPublished(item.source.item.profileId);
|
||||
}
|
||||
}
|
||||
// 中文注释:玩法差异由 Work Shelf Adapter 承载,Hub 只负责响应卡片点击。
|
||||
item.actions.open();
|
||||
}
|
||||
|
||||
function buildDeleteAction(item: CreationWorkShelfItem) {
|
||||
@@ -389,6 +217,17 @@ export function CustomWorldCreationHub({
|
||||
return item.actions.delete ?? null;
|
||||
}
|
||||
|
||||
function buildShareAction(item: CreationWorkShelfItem) {
|
||||
const payload = buildCreationWorkShelfSharePayload(item);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
onShareWork?.(payload);
|
||||
};
|
||||
}
|
||||
|
||||
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
|
||||
return item.actions.claimPointIncentive ?? null;
|
||||
}
|
||||
@@ -464,6 +303,7 @@ export function CustomWorldCreationHub({
|
||||
}}
|
||||
onDelete={buildDeleteAction(item)}
|
||||
deleteBusy={deletingWorkId === item.id}
|
||||
onShare={buildShareAction(item)}
|
||||
onClaimPointIncentive={buildPointIncentiveAction(item)}
|
||||
pointIncentiveBusy={
|
||||
item.source.kind === 'puzzle' &&
|
||||
|
||||
@@ -33,7 +33,7 @@ function shouldShowCreationBadge(badge: string) {
|
||||
}
|
||||
|
||||
/** 从后端入口配置中解析创作入口公告位,保留旧单条字段兜底。 */
|
||||
export function resolveCreationEntryEventBanners(
|
||||
function resolveCreationEntryEventBanners(
|
||||
entryConfig: CreationEntryConfig,
|
||||
): CreationEventBannerCard[] {
|
||||
const configuredBanners = Array.isArray(entryConfig.eventBanners)
|
||||
@@ -379,7 +379,7 @@ export function CustomWorldCreationStartCard({
|
||||
<Coins className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{item.locked ? '暂未开放' : '10-20泥点数'}
|
||||
{item.locked ? '暂未开放' : item.mudPointCostLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
default as React,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import {
|
||||
formatPlatformWorkDisplayName,
|
||||
@@ -39,6 +39,7 @@ type CustomWorldWorkCardProps = {
|
||||
onOpen: () => void;
|
||||
onDelete?: (() => void) | null;
|
||||
deleteBusy?: boolean;
|
||||
onShare?: (() => void) | null;
|
||||
onClaimPointIncentive?: (() => void) | null;
|
||||
pointIncentiveBusy?: boolean;
|
||||
};
|
||||
@@ -62,6 +63,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
|
||||
'square-hole': '/creation-type-references/square-hole.webp',
|
||||
'jump-hop': '/creation-type-references/jump-hop.webp',
|
||||
'wooden-fish': '/wooden-fish/default-hit-object.png',
|
||||
'puzzle-clear': '/creation-type-references/puzzle.webp',
|
||||
puzzle: '/creation-type-references/puzzle.webp',
|
||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||
'bark-battle': '/creation-type-references/bark-battle.webp',
|
||||
@@ -229,13 +231,10 @@ export function CustomWorldWorkCard({
|
||||
onOpen,
|
||||
onDelete = null,
|
||||
deleteBusy = false,
|
||||
onShare = null,
|
||||
onClaimPointIncentive = null,
|
||||
pointIncentiveBusy = false,
|
||||
}: CustomWorldWorkCardProps) {
|
||||
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const shareResetTimerRef = useRef<number | null>(null);
|
||||
const suppressOpenResetTimerRef = useRef<number | null>(null);
|
||||
const suppressOpenRef = useRef(false);
|
||||
const swipeGestureRef = useRef<{
|
||||
@@ -251,7 +250,7 @@ export function CustomWorldWorkCard({
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const isPublished = item.status === 'published';
|
||||
const canUseShareAction =
|
||||
isPublished && item.canShare && Boolean(item.sharePath);
|
||||
isPublished && item.canShare && Boolean(item.sharePath) && Boolean(onShare);
|
||||
const swipeActionCount = onDelete ? 1 : 0;
|
||||
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
|
||||
const canClaimPointIncentive =
|
||||
@@ -287,34 +286,8 @@ export function CustomWorldWorkCard({
|
||||
}`,
|
||||
} as CSSProperties;
|
||||
|
||||
const copyShareText = () => {
|
||||
const publicWorkCode = item.publicWorkCode?.trim();
|
||||
const sharePath = item.sharePath?.trim();
|
||||
if (!publicWorkCode || !sharePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shareUrl =
|
||||
typeof window === 'undefined'
|
||||
? sharePath
|
||||
: new URL(sharePath, window.location.origin).href;
|
||||
const shareText = `邀请你来玩《${item.title}》\n作品号:${publicWorkCode}\n${shareUrl}`;
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setShareState(copied ? 'copied' : 'failed');
|
||||
if (shareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(shareResetTimerRef.current);
|
||||
}
|
||||
shareResetTimerRef.current = window.setTimeout(() => {
|
||||
shareResetTimerRef.current = null;
|
||||
setShareState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (shareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(shareResetTimerRef.current);
|
||||
}
|
||||
if (suppressOpenResetTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressOpenResetTimerRef.current);
|
||||
}
|
||||
@@ -675,7 +648,7 @@ export function CustomWorldWorkCard({
|
||||
event.stopPropagation();
|
||||
suppressOpenRef.current = false;
|
||||
closeSwipeActions();
|
||||
copyShareText();
|
||||
onShare?.();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -686,20 +659,8 @@ export function CustomWorldWorkCard({
|
||||
onTouchStart={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
title={
|
||||
shareState === 'copied'
|
||||
? '已复制'
|
||||
: shareState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享作品'
|
||||
}
|
||||
aria-label={
|
||||
shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
title="分享作品"
|
||||
aria-label="分享"
|
||||
className="creation-work-card__quick-action-button"
|
||||
>
|
||||
<Share2 aria-hidden="true" className="h-4 w-4" />
|
||||
|
||||
@@ -5,10 +5,11 @@ import { expect, test, vi } from 'vitest';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
buildCreationWorkShelfItemsFromSources,
|
||||
type CreationWorkShelfItem,
|
||||
getCreationWorkShelfItemTime,
|
||||
hasBarkBattleRequiredImages,
|
||||
isPersistedBarkBattleDraftGenerating,
|
||||
type CreationWorkShelfItem,
|
||||
} from './creationWorkShelf';
|
||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||
|
||||
@@ -56,6 +57,86 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
|
||||
expect(items[1]?.publicWorkCode).toBeNull();
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItemsFromSources flattens source adapters and applies runtime state', () => {
|
||||
const [staleRpgItem] = buildCreationWorkShelfItems({
|
||||
rpgItems: [
|
||||
{
|
||||
workId: 'draft:rpg-source-adapter',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '旧 RPG 草稿',
|
||||
subtitle: '待完善',
|
||||
summary: '通过 source adapter 输入。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
stage: 'clarifying',
|
||||
stageLabel: '待完善',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'rpg-source-adapter',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
});
|
||||
const [freshPuzzleItem] = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [
|
||||
{
|
||||
workId: 'puzzle:source-adapter',
|
||||
profileId: 'puzzle-source-adapter',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '新拼图',
|
||||
summary: '新近拼图。',
|
||||
themeTags: ['灯塔'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-03T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const items = buildCreationWorkShelfItemsFromSources({
|
||||
sources: [
|
||||
{
|
||||
kind: 'rpg',
|
||||
buildItems: () => (staleRpgItem ? [staleRpgItem] : []),
|
||||
},
|
||||
{
|
||||
kind: 'puzzle',
|
||||
buildItems: () => (freshPuzzleItem ? [freshPuzzleItem] : []),
|
||||
},
|
||||
],
|
||||
getItemState: (item) =>
|
||||
item.id === staleRpgItem?.id
|
||||
? {
|
||||
isGenerating: true,
|
||||
hasUnreadUpdate: true,
|
||||
titleOverride: '生成中 RPG 草稿',
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual([
|
||||
'puzzle:source-adapter',
|
||||
'draft:rpg-source-adapter',
|
||||
]);
|
||||
expect(items[1]?.title).toBe('生成中 RPG 草稿');
|
||||
expect(items[1]?.isGenerating).toBe(true);
|
||||
expect(items[1]?.hasUnreadUpdate).toBe(true);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => {
|
||||
const onOpenWoodenFishDetail = vi.fn();
|
||||
const woodenFishWork = {
|
||||
@@ -99,6 +180,47 @@ test('buildCreationWorkShelfItems maps wooden fish items with WF public code', (
|
||||
expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps puzzle clear items with PC public code', () => {
|
||||
const onOpenPuzzleClearDetail = vi.fn();
|
||||
const puzzleClearWork = {
|
||||
runtimeKind: 'puzzle-clear' as const,
|
||||
workId: 'puzzle-clear-work-1',
|
||||
profileId: 'puzzle-clear-profile-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-clear-session-1',
|
||||
workTitle: '星港拼消消',
|
||||
workDescription: '霓虹星港主题。',
|
||||
themePrompt: '霓虹星港',
|
||||
coverImageSrc: '/generated-puzzle-clear-assets/profile/atlas.png',
|
||||
publicationStatus: 'published',
|
||||
playCount: 6,
|
||||
updatedAt: '2026-05-30T00:00:00.000Z',
|
||||
publishedAt: '2026-05-30T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
puzzleClearItems: [puzzleClearWork],
|
||||
onOpenPuzzleClearDetail,
|
||||
});
|
||||
|
||||
items[0]?.actions.open();
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.kind).toBe('puzzle-clear');
|
||||
expect(items[0]?.status).toBe('published');
|
||||
expect(items[0]?.publicWorkCode).toBe('PC-12345678');
|
||||
expect(items[0]?.sharePath).toContain('/works/detail?work=PC-12345678');
|
||||
expect(items[0]?.openActionLabel).toBe('查看详情');
|
||||
expect(items[0]?.badges.some((badge) => badge.label === '拼消消')).toBe(true);
|
||||
expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(6);
|
||||
expect(onOpenPuzzleClearDetail).toHaveBeenCalledWith(puzzleClearWork);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
|
||||
@@ -2,21 +2,23 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contrac
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildCustomWorldPublicWorkCode,
|
||||
buildBarkBattlePublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildCustomWorldPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzleClearPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
buildVisualNovelPublicWorkCode,
|
||||
@@ -37,6 +39,7 @@ export type CreationWorkShelfKind =
|
||||
| 'square-hole'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish'
|
||||
| 'puzzle-clear'
|
||||
| 'puzzle'
|
||||
| 'baby-object-match'
|
||||
| 'bark-battle'
|
||||
@@ -97,6 +100,10 @@ export type CreationWorkShelfSource =
|
||||
kind: 'wooden-fish';
|
||||
item: WoodenFishWorkSummaryResponse;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle-clear';
|
||||
item: PuzzleClearWorkSummaryResponse;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
item: PuzzleWorkSummary;
|
||||
@@ -157,6 +164,11 @@ export type CreationWorkShelfRuntimeState = {
|
||||
summaryOverride?: string;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfSourceAdapter = {
|
||||
kind: CreationWorkShelfKind;
|
||||
buildItems: () => readonly CreationWorkShelfItem[];
|
||||
};
|
||||
|
||||
export function buildCreationWorkShelfItems(params: {
|
||||
rpgItems: CustomWorldWorkSummary[];
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
@@ -165,6 +177,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
squareHoleItems?: SquareHoleWorkSummary[];
|
||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||
woodenFishItems?: WoodenFishWorkSummaryResponse[];
|
||||
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
barkBattleItems?: BarkBattleWorkSummary[];
|
||||
@@ -175,6 +188,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole?: boolean;
|
||||
canDeleteJumpHop?: boolean;
|
||||
canDeleteWoodenFish?: boolean;
|
||||
canDeletePuzzleClear?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
canDeleteBabyObjectMatch?: boolean;
|
||||
canDeleteBarkBattle?: boolean;
|
||||
@@ -192,6 +206,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onOpenWoodenFishDetail?: (item: WoodenFishWorkSummaryResponse) => void;
|
||||
onDeleteWoodenFish?: (item: WoodenFishWorkSummaryResponse) => void;
|
||||
onOpenPuzzleClearDetail?: (item: PuzzleClearWorkSummaryResponse) => void;
|
||||
onDeletePuzzleClear?: (item: PuzzleClearWorkSummaryResponse) => void;
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
@@ -213,6 +229,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
squareHoleItems = [],
|
||||
jumpHopItems = [],
|
||||
woodenFishItems = [],
|
||||
puzzleClearItems = [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems = [],
|
||||
barkBattleItems = [],
|
||||
@@ -223,6 +240,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole = false,
|
||||
canDeleteJumpHop = false,
|
||||
canDeleteWoodenFish = false,
|
||||
canDeletePuzzleClear = false,
|
||||
canDeletePuzzle = false,
|
||||
canDeleteBabyObjectMatch = false,
|
||||
canDeleteBarkBattle = false,
|
||||
@@ -240,6 +258,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDeleteJumpHop,
|
||||
onOpenWoodenFishDetail,
|
||||
onDeleteWoodenFish,
|
||||
onOpenPuzzleClearDetail,
|
||||
onDeletePuzzleClear,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
@@ -252,70 +272,145 @@ export function buildCreationWorkShelfItems(params: {
|
||||
getItemState,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
...rpgItems.map((item) =>
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
|
||||
onOpenDraft: onOpenRpgDraft,
|
||||
onEnterPublished: onEnterRpgPublished,
|
||||
onDelete: onDeleteRpg,
|
||||
}),
|
||||
),
|
||||
...bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
|
||||
onOpen: onOpenBigFishDetail,
|
||||
onDelete: onDeleteBigFish,
|
||||
}),
|
||||
),
|
||||
...match3dItems.map((item) =>
|
||||
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
|
||||
onOpen: onOpenMatch3DDetail,
|
||||
onDelete: onDeleteMatch3D,
|
||||
}),
|
||||
),
|
||||
...squareHoleItems.map((item) =>
|
||||
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
|
||||
onOpen: onOpenSquareHoleDetail,
|
||||
onDelete: onDeleteSquareHole,
|
||||
}),
|
||||
),
|
||||
...jumpHopItems.map((item) =>
|
||||
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
|
||||
onOpen: onOpenJumpHopDetail,
|
||||
onDelete: onDeleteJumpHop,
|
||||
}),
|
||||
),
|
||||
...woodenFishItems.map((item) =>
|
||||
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
|
||||
onOpen: onOpenWoodenFishDetail,
|
||||
onDelete: onDeleteWoodenFish,
|
||||
}),
|
||||
),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||
onOpen: onOpenPuzzleDetail,
|
||||
onDelete: onDeletePuzzle,
|
||||
onClaimPointIncentive: onClaimPuzzlePointIncentive,
|
||||
}),
|
||||
),
|
||||
...babyObjectMatchItems.map((item) =>
|
||||
mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, {
|
||||
onOpen: onOpenBabyObjectMatchDetail,
|
||||
onDelete: onDeleteBabyObjectMatch,
|
||||
}),
|
||||
),
|
||||
...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
|
||||
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
|
||||
onOpen: onOpenBarkBattleDetail,
|
||||
onDelete: onDeleteBarkBattle,
|
||||
}),
|
||||
),
|
||||
...visualNovelItems.map((item) =>
|
||||
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
|
||||
onOpen: onOpenVisualNovelDetail,
|
||||
onDelete: onDeleteVisualNovel,
|
||||
}),
|
||||
),
|
||||
]
|
||||
return buildCreationWorkShelfItemsFromSources({
|
||||
sources: [
|
||||
{
|
||||
kind: 'rpg',
|
||||
buildItems: () =>
|
||||
rpgItems.map((item) =>
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
|
||||
onOpenDraft: onOpenRpgDraft,
|
||||
onEnterPublished: onEnterRpgPublished,
|
||||
onDelete: onDeleteRpg,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'big-fish',
|
||||
buildItems: () =>
|
||||
bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
|
||||
onOpen: onOpenBigFishDetail,
|
||||
onDelete: onDeleteBigFish,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'match3d',
|
||||
buildItems: () =>
|
||||
match3dItems.map((item) =>
|
||||
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
|
||||
onOpen: onOpenMatch3DDetail,
|
||||
onDelete: onDeleteMatch3D,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'square-hole',
|
||||
buildItems: () =>
|
||||
squareHoleItems.map((item) =>
|
||||
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
|
||||
onOpen: onOpenSquareHoleDetail,
|
||||
onDelete: onDeleteSquareHole,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'jump-hop',
|
||||
buildItems: () =>
|
||||
jumpHopItems.map((item) =>
|
||||
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
|
||||
onOpen: onOpenJumpHopDetail,
|
||||
onDelete: onDeleteJumpHop,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'wooden-fish',
|
||||
buildItems: () =>
|
||||
woodenFishItems.map((item) =>
|
||||
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
|
||||
onOpen: onOpenWoodenFishDetail,
|
||||
onDelete: onDeleteWoodenFish,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'puzzle-clear',
|
||||
buildItems: () =>
|
||||
puzzleClearItems.map((item) =>
|
||||
mapPuzzleClearWorkToShelfItem(item, canDeletePuzzleClear, {
|
||||
onOpen: onOpenPuzzleClearDetail,
|
||||
onDelete: onDeletePuzzleClear,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'puzzle',
|
||||
buildItems: () =>
|
||||
puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||
onOpen: onOpenPuzzleDetail,
|
||||
onDelete: onDeletePuzzle,
|
||||
onClaimPointIncentive: onClaimPuzzlePointIncentive,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'baby-object-match',
|
||||
buildItems: () =>
|
||||
babyObjectMatchItems.map((item) =>
|
||||
mapBabyObjectMatchDraftToShelfItem(
|
||||
item,
|
||||
canDeleteBabyObjectMatch,
|
||||
{
|
||||
onOpen: onOpenBabyObjectMatchDetail,
|
||||
onDelete: onDeleteBabyObjectMatch,
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'bark-battle',
|
||||
buildItems: () =>
|
||||
mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
|
||||
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
|
||||
onOpen: onOpenBarkBattleDetail,
|
||||
onDelete: onDeleteBarkBattle,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'visual-novel',
|
||||
buildItems: () =>
|
||||
visualNovelItems.map((item) =>
|
||||
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
|
||||
onOpen: onOpenVisualNovelDetail,
|
||||
onDelete: onDeleteVisualNovel,
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
getItemState,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCreationWorkShelfItemsFromSources(params: {
|
||||
sources: readonly CreationWorkShelfSourceAdapter[];
|
||||
getItemState?: (
|
||||
item: CreationWorkShelfItem,
|
||||
) => CreationWorkShelfRuntimeState | null;
|
||||
}) {
|
||||
const { sources, getItemState } = params;
|
||||
const sourceItems = sources.reduce<CreationWorkShelfItem[]>(
|
||||
(items, source) => {
|
||||
items.push(...source.buildItems());
|
||||
return items;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return sourceItems
|
||||
.map((item) => {
|
||||
const state = getItemState?.(item);
|
||||
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
|
||||
@@ -903,6 +998,56 @@ function mapWoodenFishWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function mapPuzzleClearWorkToShelfItem(
|
||||
item: PuzzleClearWorkSummaryResponse,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<PuzzleClearWorkSummaryResponse>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
status === 'published'
|
||||
? buildPuzzleClearPublicWorkCode(item.profileId)
|
||||
: null;
|
||||
const title = item.workTitle.trim() || '拼消消';
|
||||
const summary =
|
||||
item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : '');
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'puzzle-clear',
|
||||
status,
|
||||
title,
|
||||
summary,
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: normalizeCoverImageSrc(item.coverImageSrc),
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && status === 'published'
|
||||
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '拼消消', tone: 'neutral' },
|
||||
],
|
||||
metrics:
|
||||
status === 'published'
|
||||
? buildPublishedMetrics({
|
||||
playCount: item.playCount,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'puzzle-clear', item },
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAuthorDisplayName(...sources: Array<unknown>) {
|
||||
for (const source of sources) {
|
||||
const authorDisplayName =
|
||||
@@ -1119,6 +1264,8 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
||||
return isPersistedPuzzleDraftGenerating(item.source.item);
|
||||
case 'wooden-fish':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'puzzle-clear':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'bark-battle':
|
||||
return isPersistedBarkBattleDraftGenerating(item.source.item);
|
||||
default:
|
||||
|
||||
@@ -1,144 +1,227 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { JumpHopWorkProfileResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
||||
import { JumpHopResultView } from './JumpHopResultView';
|
||||
|
||||
const draft: JumpHopDraftResponse = {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'profile-1',
|
||||
workTitle: '云端跳台',
|
||||
workDescription: '一路跳到星星。',
|
||||
themeTags: ['云朵', '星空'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '纸片小兔',
|
||||
tilePrompt: '柔软云朵平台',
|
||||
endMoodPrompt: '星光门',
|
||||
characterAsset: {
|
||||
assetId: 'character-1',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
imageObjectKey: 'jump-hop/character.png',
|
||||
assetObjectId: 'asset-character',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '角色图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'tiles-1',
|
||||
imageSrc: 'data:image/png;base64,tiles',
|
||||
imageObjectKey: 'jump-hop/tiles.png',
|
||||
assetObjectId: 'asset-tiles',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '地块图',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
tileAssets: [
|
||||
{
|
||||
tileType: 'start',
|
||||
imageSrc: 'data:image/png;base64,tile-start',
|
||||
imageObjectKey: 'jump-hop/tile-start.png',
|
||||
assetObjectId: 'asset-tile-start',
|
||||
sourceAtlasCell: 'A1',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
{
|
||||
tileType: 'finish',
|
||||
imageSrc: 'data:image/png;base64,tile-finish',
|
||||
imageObjectKey: 'jump-hop/tile-finish.png',
|
||||
assetObjectId: 'asset-tile-finish',
|
||||
sourceAtlasCell: 'A2',
|
||||
visualWidth: 128,
|
||||
visualHeight: 96,
|
||||
topSurfaceRadius: 24,
|
||||
landingRadius: 28,
|
||||
},
|
||||
],
|
||||
path: {
|
||||
seed: 'jump-hop-seed',
|
||||
difficulty: 'standard',
|
||||
platforms: [
|
||||
{
|
||||
platformId: 'platform-1',
|
||||
tileType: 'start',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 48,
|
||||
height: 36,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 1,
|
||||
},
|
||||
{
|
||||
platformId: 'platform-2',
|
||||
tileType: 'finish',
|
||||
x: 16,
|
||||
y: 18,
|
||||
width: 60,
|
||||
height: 42,
|
||||
landingRadius: 22,
|
||||
perfectRadius: 12,
|
||||
scoreValue: 2,
|
||||
},
|
||||
],
|
||||
finishIndex: 1,
|
||||
cameraPreset: 'default',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 1.2,
|
||||
maxChargeMs: 1800,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 50,
|
||||
},
|
||||
},
|
||||
coverComposite: 'data:image/png;base64,cover',
|
||||
generationStatus: 'ready',
|
||||
};
|
||||
vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
|
||||
useJumpHopLeaderboard: vi.fn(),
|
||||
}));
|
||||
|
||||
test('jump hop result view exposes test run and publish actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
const onEdit = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
const onPublish = vi.fn();
|
||||
const onRegenerateCharacter = vi.fn();
|
||||
const onRegenerateTiles = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
|
||||
leaderboard: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('跳一跳结果页展示排行榜列表', () => {
|
||||
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
|
||||
leaderboard: {
|
||||
profileId: 'jump-hop-profile-test',
|
||||
items: [
|
||||
{
|
||||
rank: 1,
|
||||
playerId: 'user-secret-1',
|
||||
displayName: '陶泥儿玩家',
|
||||
successfulJumpCount: 12,
|
||||
durationMs: 40123,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
playerId: 'user-secret-2',
|
||||
displayName: '森林玩家',
|
||||
successfulJumpCount: 10,
|
||||
durationMs: 38210,
|
||||
updatedAt: '2026-05-26T00:00:00Z',
|
||||
},
|
||||
],
|
||||
viewerBest: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<JumpHopResultView
|
||||
profile={draft}
|
||||
onBack={onBack}
|
||||
onEdit={onEdit}
|
||||
onStartTestRun={onStartTestRun}
|
||||
onPublish={onPublish}
|
||||
onRegenerateCharacter={onRegenerateCharacter}
|
||||
onRegenerateTiles={onRegenerateTiles}
|
||||
profile={buildProfile({ publicationStatus: 'published' })}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
onRegenerateTiles={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('云端跳台')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
await user.click(screen.getByRole('button', { name: '发布' }));
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
await user.click(screen.getByRole('button', { name: '返回编辑' }));
|
||||
await user.click(screen.getByRole('button', { name: '角色' }));
|
||||
await user.click(screen.getByRole('button', { name: '地块' }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledTimes(1);
|
||||
expect(onPublish).toHaveBeenCalledTimes(1);
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(onRegenerateCharacter).toHaveBeenCalledTimes(1);
|
||||
expect(onRegenerateTiles).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('排行榜')).toBeTruthy();
|
||||
expect(screen.getByText('陶泥儿玩家')).toBeTruthy();
|
||||
expect(screen.queryByText('user-secret-1')).toBeNull();
|
||||
expect(screen.getByText('12 跳')).toBeTruthy();
|
||||
expect(screen.getByText('00:40')).toBeTruthy();
|
||||
expect(screen.getByText('森林玩家')).toBeTruthy();
|
||||
expect(screen.queryByText('user-secret-2')).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
|
||||
render(
|
||||
<JumpHopResultView
|
||||
profile={buildProfile()}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
onRegenerateTiles={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('jump-hop-result-character-logo').getAttribute('src')).toBe(
|
||||
'/branding/jump-hop-taonier-character.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳结果页根容器允许移动端向下滚动到操作按钮', () => {
|
||||
const { container } = render(
|
||||
<JumpHopResultView
|
||||
profile={buildProfile()}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
onRegenerateTiles={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.className).toContain('overflow-y-auto');
|
||||
expect(root.className).toContain('overscroll-contain');
|
||||
expect(root.className).toContain('safe-area-inset-bottom');
|
||||
});
|
||||
|
||||
test('跳一跳草稿结果页不请求公开排行榜', () => {
|
||||
render(
|
||||
<JumpHopResultView
|
||||
profile={buildProfile({ publicationStatus: 'draft' })}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
onRegenerateTiles={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('排行榜')).toBeNull();
|
||||
});
|
||||
|
||||
function buildProfile(
|
||||
options: {
|
||||
publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus'];
|
||||
} = {},
|
||||
): JumpHopWorkProfileResponse {
|
||||
return {
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-profile-test',
|
||||
profileId: 'jump-hop-profile-test',
|
||||
ownerUserId: 'user-test',
|
||||
sourceSessionId: 'jump-hop-session-test',
|
||||
themeText: '测试',
|
||||
workTitle: '测试',
|
||||
workDescription: '测试',
|
||||
themeTags: ['测试'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: options.publicationStatus ?? 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-test',
|
||||
themeText: '测试',
|
||||
workTitle: '测试',
|
||||
workDescription: '测试',
|
||||
themeTags: ['测试'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
defaultCharacter: {
|
||||
characterId: 'jump-hop-default-runner',
|
||||
displayName: '默认角色',
|
||||
modelKind: 'builtin-three',
|
||||
bodyColor: '#f59e0b',
|
||||
accentColor: '#2563eb',
|
||||
},
|
||||
characterPrompt: '默认角色',
|
||||
tilePrompt: '地块',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: {
|
||||
assetId: 'builtin',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'builtin',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'builtin',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'builtin',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: null,
|
||||
backButtonAsset: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
path: null as never,
|
||||
defaultCharacter: {
|
||||
characterId: 'jump-hop-default-runner',
|
||||
displayName: '默认角色',
|
||||
modelKind: 'builtin-three',
|
||||
bodyColor: '#f59e0b',
|
||||
accentColor: '#2563eb',
|
||||
},
|
||||
characterAsset: {
|
||||
assetId: 'builtin',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'builtin',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
tileAtlasAsset: {
|
||||
assetId: 'builtin',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'builtin',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
tileAssets: [],
|
||||
backButtonAsset: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,18 +2,22 @@ import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Send,
|
||||
Shuffle,
|
||||
} from 'lucide-react';
|
||||
import { type CSSProperties, useMemo, useState } from 'react';
|
||||
import { type CSSProperties, useState } from 'react';
|
||||
|
||||
import type {
|
||||
JumpHopDraftResponse,
|
||||
JumpHopPath,
|
||||
JumpHopPlatform,
|
||||
JumpHopTileAsset,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import {
|
||||
formatJumpHopDurationLabel,
|
||||
selectJumpHopTileAsset,
|
||||
} from '../../services/jump-hop/jumpHopRuntimeModel';
|
||||
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type JumpHopResultViewProps = {
|
||||
@@ -34,7 +38,6 @@ type JumpHopResultViewProps = {
|
||||
onEdit: () => void;
|
||||
onStartTestRun: () => void;
|
||||
onPublish: () => void;
|
||||
onRegenerateCharacter: () => void;
|
||||
onRegenerateTiles: () => void;
|
||||
};
|
||||
|
||||
@@ -44,43 +47,6 @@ function isJumpHopWorkProfile(
|
||||
return 'summary' in profile;
|
||||
}
|
||||
|
||||
type MiniMapPlatform = {
|
||||
platform: JumpHopPlatform;
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isStart: boolean;
|
||||
isFinish: boolean;
|
||||
};
|
||||
|
||||
const difficultyToneByValue: Record<
|
||||
JumpHopPath['difficulty'],
|
||||
{ accent: string; soft: string; label: string }
|
||||
> = {
|
||||
advanced: {
|
||||
accent: '#df7f40',
|
||||
soft: 'rgba(249, 115, 22, 0.16)',
|
||||
label: '进阶',
|
||||
},
|
||||
challenge: {
|
||||
accent: '#b64a35',
|
||||
soft: 'rgba(182, 98, 63, 0.16)',
|
||||
label: '挑战',
|
||||
},
|
||||
easy: {
|
||||
accent: '#14b8a6',
|
||||
soft: 'rgba(20, 184, 166, 0.16)',
|
||||
label: '轻松',
|
||||
},
|
||||
standard: {
|
||||
accent: '#2563eb',
|
||||
soft: 'rgba(37, 99, 235, 0.16)',
|
||||
label: '标准',
|
||||
},
|
||||
};
|
||||
|
||||
const tileToneByType: Record<string, string> = {
|
||||
accent: '#c4b5fd',
|
||||
bonus: '#fde68a',
|
||||
@@ -90,155 +56,193 @@ const tileToneByType: Record<string, string> = {
|
||||
target: '#fecdd3',
|
||||
};
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC =
|
||||
'/branding/jump-hop-taonier-character.png';
|
||||
|
||||
function JumpHopDefaultCharacterPreview() {
|
||||
return (
|
||||
<div className="relative grid aspect-[1/1] place-items-center overflow-hidden bg-[linear-gradient(180deg,#eff6ff_0%,#fff7ed_100%)]">
|
||||
<div className="absolute inset-x-[18%] bottom-[14%] h-[14%] rounded-full bg-slate-900/12 blur-[2px]" />
|
||||
<img
|
||||
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="relative z-10 h-[78%] w-[78%] object-contain drop-shadow-[0_12px_18px_rgba(146,64,14,0.2)]"
|
||||
data-testid="jump-hop-result-character-logo"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePathPlatforms(path: JumpHopPath | null | undefined) {
|
||||
const platforms = path?.platforms ?? [];
|
||||
if (platforms.length === 0) {
|
||||
return [];
|
||||
function JumpHopTilePoolPreview({
|
||||
tileAssets,
|
||||
tileAtlasAsset,
|
||||
tileAtlasFallbackSrc,
|
||||
}: {
|
||||
tileAssets: JumpHopTileAsset[];
|
||||
tileAtlasAsset?: JumpHopDraftResponse['tileAtlasAsset'] | null;
|
||||
tileAtlasFallbackSrc?: string | null;
|
||||
}) {
|
||||
const visibleTiles = tileAssets.slice(0, 25);
|
||||
const atlasSrc =
|
||||
tileAtlasAsset?.imageSrc?.trim() || tileAtlasFallbackSrc?.trim() || '';
|
||||
const atlasRefreshKey = tileAtlasAsset?.assetObjectId || atlasSrc;
|
||||
if (visibleTiles.length > 0) {
|
||||
return (
|
||||
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
|
||||
{visibleTiles.map((tile, index) => (
|
||||
<div
|
||||
key={tile.tileId ?? `${tile.sourceAtlasCell}-${index}`}
|
||||
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.45rem] border border-white/80 bg-slate-50"
|
||||
>
|
||||
{tile.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={tile.imageSrc}
|
||||
refreshKey={tile.assetObjectId}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
tileToneByType[tile.tileType] ?? tileToneByType.normal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const coordinatePlatforms = platforms.filter(
|
||||
(platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y),
|
||||
);
|
||||
const shouldUseCoordinates = coordinatePlatforms.length >= 2;
|
||||
const xValues = shouldUseCoordinates
|
||||
? coordinatePlatforms.map((platform) => platform.x)
|
||||
: [];
|
||||
const yValues = shouldUseCoordinates
|
||||
? coordinatePlatforms.map((platform) => platform.y)
|
||||
: [];
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
const minY = Math.min(...yValues);
|
||||
const maxY = Math.max(...yValues);
|
||||
const xRange = Math.max(maxX - minX, 1);
|
||||
const yRange = Math.max(maxY - minY, 1);
|
||||
const denominator = Math.max(platforms.length - 1, 1);
|
||||
|
||||
return platforms.map((platform, index): MiniMapPlatform => {
|
||||
const sequenceRatio = index / denominator;
|
||||
const hasCoordinates =
|
||||
shouldUseCoordinates &&
|
||||
isFiniteNumber(platform.x) &&
|
||||
isFiniteNumber(platform.y);
|
||||
const x = hasCoordinates
|
||||
? 12 + ((platform.x - minX) / xRange) * 76
|
||||
: 12 + sequenceRatio * 76;
|
||||
const y = hasCoordinates
|
||||
? 14 + ((platform.y - minY) / yRange) * 72
|
||||
: 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18;
|
||||
|
||||
return {
|
||||
platform,
|
||||
index,
|
||||
x,
|
||||
y,
|
||||
width: Math.min(Math.max(platform.width || 54, 42), 82),
|
||||
height: Math.min(Math.max(platform.height || 42, 34), 68),
|
||||
isStart: index === 0 || platform.tileType === 'start',
|
||||
isFinish:
|
||||
index === path?.finishIndex ||
|
||||
platform.tileType === 'finish' ||
|
||||
platform.tileType === 'target',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) {
|
||||
const platforms = useMemo(() => normalizePathPlatforms(path), [path]);
|
||||
const tone =
|
||||
difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard;
|
||||
const pathPoints = platforms
|
||||
.map((platform) => `${platform.x},${platform.y}`)
|
||||
.join(' ');
|
||||
|
||||
if (platforms.length === 0) {
|
||||
return null;
|
||||
if (atlasSrc) {
|
||||
return (
|
||||
<ResolvedAssetImage
|
||||
src={atlasSrc}
|
||||
refreshKey={atlasRefreshKey}
|
||||
alt=""
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-[1/1] w-full overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]"
|
||||
style={
|
||||
{
|
||||
'--jump-hop-path-accent': tone.accent,
|
||||
'--jump-hop-path-soft': tone.soft,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.92),transparent_28%),radial-gradient(circle_at_75%_78%,rgba(125,211,252,0.24),transparent_32%)]" />
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
points={pathPoints}
|
||||
fill="none"
|
||||
stroke="var(--jump-hop-path-soft)"
|
||||
strokeWidth="11"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
|
||||
{Array.from({ length: 25 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="rounded-[0.45rem] border border-white/80"
|
||||
style={{
|
||||
background:
|
||||
Object.values(tileToneByType)[index % Object.values(tileToneByType).length],
|
||||
}}
|
||||
/>
|
||||
<polyline
|
||||
points={pathPoints}
|
||||
fill="none"
|
||||
stroke="var(--jump-hop-path-accent)"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
</svg>
|
||||
{platforms.map((item) => {
|
||||
const tileTone =
|
||||
tileToneByType[item.platform.tileType] ?? tileToneByType.normal;
|
||||
const scoreBoost =
|
||||
isFiniteNumber(item.platform.scoreValue) &&
|
||||
item.platform.scoreValue > 1;
|
||||
const style = {
|
||||
left: `${item.x}%`,
|
||||
top: `${item.y}%`,
|
||||
width: `${item.width}%`,
|
||||
height: `${item.height}%`,
|
||||
background: tileTone,
|
||||
borderColor: item.isFinish ? tone.accent : 'rgba(255,255,255,0.92)',
|
||||
zIndex: 10 + item.index,
|
||||
} as CSSProperties;
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JumpHopFirstPlatformsPreview({
|
||||
path,
|
||||
tileAssets,
|
||||
}: {
|
||||
path: JumpHopPath | null | undefined;
|
||||
tileAssets: JumpHopTileAsset[];
|
||||
}) {
|
||||
const platforms = (path?.platforms ?? []).slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-[1/1] overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgba(255,255,255,0.92),transparent_34%)]" />
|
||||
{platforms.map((platform, index) => {
|
||||
const asset = selectJumpHopTileAsset(
|
||||
tileAssets,
|
||||
path?.seed,
|
||||
index,
|
||||
platform.platformId,
|
||||
);
|
||||
const style = {
|
||||
left: `${50 + (index - 1) * 24}%`,
|
||||
top: `${68 - index * 22}%`,
|
||||
width: `${34 - index * 3}%`,
|
||||
zIndex: 10 + index,
|
||||
} as CSSProperties;
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
item.platform.platformId ||
|
||||
`${item.index}-${item.platform.tileType}`
|
||||
}
|
||||
className="absolute grid max-h-9 max-w-11 min-h-6 min-w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-[0.72rem] border-2 shadow-[0_8px_18px_rgba(15,23,42,0.13)]"
|
||||
key={platform.platformId || index}
|
||||
className="absolute aspect-[1.16/1] -translate-x-1/2 -translate-y-1/2"
|
||||
style={style}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
item.isStart || item.isFinish ? tone.accent : '#ffffff',
|
||||
boxShadow: scoreBoost ? `0 0 0 4px ${tone.soft}` : undefined,
|
||||
}}
|
||||
/>
|
||||
{item.isStart || item.isFinish ? (
|
||||
<span className="absolute -top-2.5 rounded-full bg-slate-950/78 px-1.5 py-0.5 text-[0.58rem] font-black leading-none text-white">
|
||||
{item.isStart ? '起' : '终'}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="absolute inset-x-[12%] bottom-[-6%] h-[22%] rounded-full bg-slate-900/14 blur-[3px]" />
|
||||
{asset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={asset.imageSrc}
|
||||
refreshKey={asset.assetObjectId}
|
||||
alt=""
|
||||
className="relative h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="relative h-full w-full rounded-[18%] border-2 border-white/90 shadow-[0_10px_22px_rgba(15,23,42,0.14)]"
|
||||
style={{
|
||||
background:
|
||||
tileToneByType[platform.tileType] ?? tileToneByType.normal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute left-2 top-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-strong)] shadow-sm">
|
||||
{tone.label}
|
||||
{platforms.length === 0 ? (
|
||||
<div className="absolute inset-0 grid place-items-center text-sm font-bold text-[var(--platform-text-soft)]">
|
||||
路径
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JumpHopResultLeaderboard({
|
||||
profileId,
|
||||
}: {
|
||||
profileId?: string | null;
|
||||
}) {
|
||||
const { leaderboard, isLoading, error } = useJumpHopLeaderboard(profileId);
|
||||
const items = leaderboard?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
排行榜
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-base)] shadow-sm">
|
||||
{platforms.length}
|
||||
<div className="mt-3 grid gap-2">
|
||||
{items.slice(0, 5).map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}-${entry.playerId}`}
|
||||
className="grid grid-cols-[1.8rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-[0.75rem] bg-white/70 px-2 py-2 text-xs font-bold text-[var(--platform-text-base)]"
|
||||
>
|
||||
<span className="text-[var(--platform-text-soft)]">
|
||||
{entry.rank}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{entry.displayName?.trim() || '玩家'}
|
||||
</span>
|
||||
<span>{entry.successfulJumpCount} 跳</span>
|
||||
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-[0.75rem] bg-white/60 px-2 py-2 text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
{error ?? '暂无成绩'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -252,7 +256,6 @@ export function JumpHopResultView({
|
||||
onEdit,
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
onRegenerateCharacter,
|
||||
onRegenerateTiles,
|
||||
}: JumpHopResultViewProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
@@ -264,12 +267,15 @@ export function JumpHopResultView({
|
||||
path: NonNullable<JumpHopDraftResponse['path']>;
|
||||
};
|
||||
const path = isWorkProfile ? profile.path : safeDraft.path;
|
||||
const characterAsset = isWorkProfile
|
||||
? profile.characterAsset
|
||||
: safeDraft.characterAsset;
|
||||
const tileAtlasAsset = isWorkProfile
|
||||
? profile.tileAtlasAsset
|
||||
: safeDraft.tileAtlasAsset;
|
||||
const tileAssets = isWorkProfile ? profile.tileAssets : safeDraft.tileAssets;
|
||||
const profileId = isWorkProfile
|
||||
? profile.summary.profileId
|
||||
: safeDraft.profileId;
|
||||
const canShowLeaderboard =
|
||||
isWorkProfile && profile.summary.publicationStatus === 'published';
|
||||
const titleSource = isWorkProfile
|
||||
? profile.summary.workTitle
|
||||
: profile.workTitle;
|
||||
@@ -278,15 +284,12 @@ export function JumpHopResultView({
|
||||
: profile.workDescription;
|
||||
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
|
||||
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
|
||||
const pathPlatforms = normalizePathPlatforms(path);
|
||||
const canRenderPathMiniMap = pathPlatforms.length > 0;
|
||||
const hasAssets = Boolean(
|
||||
profile.characterImageSrc?.trim() ||
|
||||
profile.tileAtlasImageSrc?.trim() ||
|
||||
profile.tileAtlasImageSrc?.trim() ||
|
||||
profile.pathPreviewImageSrc?.trim() ||
|
||||
characterAsset?.imageSrc?.trim() ||
|
||||
tileAtlasAsset?.imageSrc?.trim() ||
|
||||
canRenderPathMiniMap,
|
||||
tileAssets.length > 0 ||
|
||||
path?.platforms.length,
|
||||
);
|
||||
|
||||
const handlePublish = async () => {
|
||||
@@ -299,7 +302,7 @@ export function JumpHopResultView({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overscroll-contain px-3 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -310,15 +313,6 @@ export function JumpHopResultView({
|
||||
返回
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateCharacter}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
角色
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateTiles}
|
||||
@@ -343,69 +337,25 @@ export function JumpHopResultView({
|
||||
) : null}
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{profile.characterImageSrc || characterAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
('characterImageSrc' in profile
|
||||
? profile.characterImageSrc
|
||||
: null) ??
|
||||
characterAsset?.imageSrc ??
|
||||
''
|
||||
}
|
||||
alt="角色图"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
角色
|
||||
</div>
|
||||
)}
|
||||
<JumpHopDefaultCharacterPreview />
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{profile.tileAtlasImageSrc || tileAtlasAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
('tileAtlasImageSrc' in profile
|
||||
? profile.tileAtlasImageSrc
|
||||
: null) ??
|
||||
tileAtlasAsset?.imageSrc ??
|
||||
''
|
||||
}
|
||||
alt="地块图"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
地块
|
||||
</div>
|
||||
)}
|
||||
<JumpHopTilePoolPreview
|
||||
tileAssets={tileAssets}
|
||||
tileAtlasAsset={tileAtlasAsset}
|
||||
tileAtlasFallbackSrc={
|
||||
('tileAtlasImageSrc' in profile
|
||||
? profile.tileAtlasImageSrc
|
||||
: null) ??
|
||||
null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{path && canRenderPathMiniMap ? (
|
||||
<JumpHopPathMiniMap path={path} />
|
||||
) : 'pathPreviewImageSrc' in profile &&
|
||||
profile.pathPreviewImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={profile.pathPreviewImageSrc}
|
||||
alt="路径预览"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : path ? (
|
||||
<div className="grid aspect-[1/1] place-items-center px-3 text-center">
|
||||
<div>
|
||||
<div className="text-3xl font-black text-[var(--platform-text-strong)]">
|
||||
{path.platforms.length}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{path.difficulty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
路径
|
||||
</div>
|
||||
)}
|
||||
<JumpHopFirstPlatformsPreview
|
||||
path={path}
|
||||
tileAssets={tileAssets}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!hasAssets ? (
|
||||
@@ -419,6 +369,9 @@ export function JumpHopResultView({
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
结果操作
|
||||
</div>
|
||||
{canShowLeaderboard ? (
|
||||
<JumpHopResultLeaderboard profileId={profileId} />
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,11 @@ import {
|
||||
readAssetBytes,
|
||||
resolveAssetReadUrl,
|
||||
} from '../../services/assetReadUrlService';
|
||||
import {
|
||||
buildMatch3DTrayInsertionPlan,
|
||||
resolveMatch3DTrayItemIdToSlotIndexMap,
|
||||
syncMatch3DItemTraySlotIndexes,
|
||||
} from '../../services/match3d-runtime/match3dTrayLayout';
|
||||
import {
|
||||
getMatch3DGeneratedImageViewSources,
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
||||
@@ -41,11 +46,6 @@ import {
|
||||
loadMatch3DSpritesheetAssetRegions,
|
||||
type Match3DDecodedSpritesheetRegion,
|
||||
} from '../../services/match3dSpritesheetParser';
|
||||
import {
|
||||
buildMatch3DTrayInsertionPlan,
|
||||
resolveMatch3DTrayItemIdToSlotIndexMap,
|
||||
syncMatch3DItemTraySlotIndexes,
|
||||
} from '../../services/match3d-runtime/match3dTrayLayout';
|
||||
import {
|
||||
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
|
||||
playRuntimeClickSound,
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
resolveRuntimeCountdownSecondBucket,
|
||||
} from '../../services/runtimeAudioFeedback';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
|
||||
import {
|
||||
findMatch3DHitItem,
|
||||
type Match3DAlphaHitMask,
|
||||
@@ -398,12 +399,6 @@ function indexMatch3DUiSpritesheetRegions(
|
||||
return new Map(regions.map((region) => [region.label, region]));
|
||||
}
|
||||
|
||||
function resolveMatch3DImageReadUrlCacheKey(
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
) {
|
||||
return resolveMatch3DImageReadUrlSources(imageSourcesByType).join('|');
|
||||
}
|
||||
|
||||
function resolveMatch3DImageReadUrlSources(
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
) {
|
||||
@@ -1047,6 +1042,10 @@ export function Match3DRuntimeShell({
|
||||
const [itemSpritesheetViewGroups, setItemSpritesheetViewGroups] = useState<
|
||||
Match3DItemSpritesheetViewGroup[]
|
||||
>([]);
|
||||
const [isUiSpritesheetResolving, setIsUiSpritesheetResolving] =
|
||||
useState(false);
|
||||
const [isItemSpritesheetResolving, setIsItemSpritesheetResolving] =
|
||||
useState(false);
|
||||
const uiSpritesheetRegionByLabel = useMemo(
|
||||
() => indexMatch3DUiSpritesheetRegions(uiSpritesheetRegions),
|
||||
[uiSpritesheetRegions],
|
||||
@@ -1077,11 +1076,13 @@ export function Match3DRuntimeShell({
|
||||
setUiSpritesheetRegions((current) =>
|
||||
current.length > 0 ? [] : current,
|
||||
);
|
||||
setIsUiSpritesheetResolving(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setIsUiSpritesheetResolving(true);
|
||||
void loadMatch3DSpritesheetAssetRegions({
|
||||
source: uiSpritesheetSource,
|
||||
labels: MATCH3D_UI_SPRITESHEET_LABELS,
|
||||
@@ -1093,11 +1094,13 @@ export function Match3DRuntimeShell({
|
||||
.then((regions) => {
|
||||
if (!cancelled) {
|
||||
setUiSpritesheetRegions(regions);
|
||||
setIsUiSpritesheetResolving(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setUiSpritesheetRegions([]);
|
||||
setIsUiSpritesheetResolving(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1112,11 +1115,13 @@ export function Match3DRuntimeShell({
|
||||
setItemSpritesheetViewGroups((current) =>
|
||||
current.length > 0 ? [] : current,
|
||||
);
|
||||
setIsItemSpritesheetResolving(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setIsItemSpritesheetResolving(true);
|
||||
void loadMatch3DSpritesheetAssetRegions({
|
||||
source: itemSpritesheetSource,
|
||||
maxRegions: 100,
|
||||
@@ -1132,11 +1137,13 @@ export function Match3DRuntimeShell({
|
||||
runtimeGeneratedItemAssets.map((asset) => asset.itemName),
|
||||
),
|
||||
);
|
||||
setIsItemSpritesheetResolving(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setItemSpritesheetViewGroups([]);
|
||||
setIsItemSpritesheetResolving(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1315,13 +1322,17 @@ export function Match3DRuntimeShell({
|
||||
),
|
||||
[itemSpritesheetViewGroups, runtimeGeneratedItemAssets, run],
|
||||
);
|
||||
const imageReadUrlSources = useMemo(
|
||||
() => resolveMatch3DImageReadUrlSources(imageSourcesByType),
|
||||
[imageSourcesByType],
|
||||
);
|
||||
const itemSizeByType = useMemo(
|
||||
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
|
||||
[runtimeGeneratedItemAssets, run],
|
||||
);
|
||||
const imageReadUrlCacheKey = useMemo(
|
||||
() => resolveMatch3DImageReadUrlCacheKey(imageSourcesByType),
|
||||
[imageSourcesByType],
|
||||
() => imageReadUrlSources.join('|'),
|
||||
[imageReadUrlSources],
|
||||
);
|
||||
const [resolvedImageSources, setResolvedImageSources] = useState<
|
||||
Map<string, string>
|
||||
@@ -1329,6 +1340,8 @@ export function Match3DRuntimeShell({
|
||||
const [failedImageSources, setFailedImageSources] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [isImageSourcesResolving, setIsImageSourcesResolving] =
|
||||
useState(false);
|
||||
const resolvedImageSourceEntriesByType = useMemo(
|
||||
() =>
|
||||
buildResolvedMatch3DImageSourceEntriesByType(
|
||||
@@ -1354,6 +1367,12 @@ export function Match3DRuntimeShell({
|
||||
useState('');
|
||||
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] =
|
||||
useState('');
|
||||
const [isBackgroundMusicResolving, setIsBackgroundMusicResolving] =
|
||||
useState(false);
|
||||
const [isBackgroundImageResolving, setIsBackgroundImageResolving] =
|
||||
useState(false);
|
||||
const [isContainerImageResolving, setIsContainerImageResolving] =
|
||||
useState(false);
|
||||
const clickSoundByTypeId = useMemo(() => {
|
||||
if (!run) {
|
||||
return new Map<string, string>();
|
||||
@@ -1397,16 +1416,19 @@ export function Match3DRuntimeShell({
|
||||
const source = backgroundMusicSrc?.trim() ?? '';
|
||||
if (!source) {
|
||||
setResolvedBackgroundMusicSrc('');
|
||||
setIsBackgroundMusicResolving(false);
|
||||
return undefined;
|
||||
}
|
||||
if (!isGeneratedLegacyPath(source)) {
|
||||
setResolvedBackgroundMusicSrc(source);
|
||||
setIsBackgroundMusicResolving(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setResolvedBackgroundMusicSrc('');
|
||||
setIsBackgroundMusicResolving(true);
|
||||
void resolveAssetReadUrl(source, {
|
||||
signal: controller.signal,
|
||||
expireSeconds: 300,
|
||||
@@ -1420,6 +1442,11 @@ export function Match3DRuntimeShell({
|
||||
if (!cancelled) {
|
||||
setResolvedBackgroundMusicSrc('');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsBackgroundMusicResolving(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -1439,15 +1466,19 @@ export function Match3DRuntimeShell({
|
||||
useEffect(() => {
|
||||
if (!backgroundAssetSrc) {
|
||||
setResolvedBackgroundImageSrc('');
|
||||
setIsBackgroundImageResolving(false);
|
||||
return undefined;
|
||||
}
|
||||
if (!isGeneratedLegacyPath(backgroundAssetSrc)) {
|
||||
setResolvedBackgroundImageSrc(backgroundAssetSrc);
|
||||
setIsBackgroundImageResolving(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setResolvedBackgroundImageSrc('');
|
||||
setIsBackgroundImageResolving(true);
|
||||
void resolveAssetReadUrl(backgroundAssetSrc, {
|
||||
signal: controller.signal,
|
||||
expireSeconds: 300,
|
||||
@@ -1461,6 +1492,11 @@ export function Match3DRuntimeShell({
|
||||
if (!cancelled) {
|
||||
setResolvedBackgroundImageSrc('');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsBackgroundImageResolving(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -1475,8 +1511,10 @@ export function Match3DRuntimeShell({
|
||||
setResolvedContainerImageSrc('');
|
||||
if (!isGeneratedLegacyPath(containerAssetSrc)) {
|
||||
setResolvedContainerImageSrc(containerAssetSrc);
|
||||
setIsContainerImageResolving(false);
|
||||
return undefined;
|
||||
}
|
||||
setIsContainerImageResolving(true);
|
||||
void resolveAssetReadUrl(containerAssetSrc, {
|
||||
signal: controller.signal,
|
||||
expireSeconds: 300,
|
||||
@@ -1494,6 +1532,11 @@ export function Match3DRuntimeShell({
|
||||
: MATCH3D_CONTAINER_REFERENCE_SRC,
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsContainerImageResolving(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -1503,7 +1546,7 @@ export function Match3DRuntimeShell({
|
||||
}, [containerAssetSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
const rawSources = resolveMatch3DImageReadUrlSources(imageSourcesByType);
|
||||
const rawSources = imageReadUrlSources;
|
||||
if (rawSources.length <= 0) {
|
||||
setResolvedImageSources((current) =>
|
||||
current.size > 0 ? new Map() : current,
|
||||
@@ -1511,6 +1554,7 @@ export function Match3DRuntimeShell({
|
||||
setFailedImageSources((current) =>
|
||||
current.size > 0 ? new Set() : current,
|
||||
);
|
||||
setIsImageSourcesResolving(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1519,6 +1563,7 @@ export function Match3DRuntimeShell({
|
||||
setFailedImageSources((current) =>
|
||||
current.size > 0 ? new Set() : current,
|
||||
);
|
||||
setIsImageSourcesResolving(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1535,6 +1580,7 @@ export function Match3DRuntimeShell({
|
||||
return retained;
|
||||
});
|
||||
setFailedImageSources(new Set());
|
||||
setIsImageSourcesResolving(true);
|
||||
void Promise.all(
|
||||
rawSources.map(async (source) => {
|
||||
if (nextSources.has(source)) {
|
||||
@@ -1565,13 +1611,18 @@ export function Match3DRuntimeShell({
|
||||
if (!cancelled) {
|
||||
setFailedImageSources(new Set(rawSources));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsImageSourcesResolving(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [imageReadUrlCacheKey, imageSourcesByType]);
|
||||
}, [imageReadUrlCacheKey, imageReadUrlSources]);
|
||||
|
||||
useEffect(() => {
|
||||
const rawSources = alphaHitMaskCacheKey
|
||||
@@ -1925,6 +1976,44 @@ export function Match3DRuntimeShell({
|
||||
<main
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
|
||||
>
|
||||
<RuntimeResourcePendingMarker
|
||||
source={backgroundMusicSrc}
|
||||
kind="audio"
|
||||
isPending={isBackgroundMusicResolving}
|
||||
/>
|
||||
<RuntimeResourcePendingMarker
|
||||
source={backgroundAssetSrc}
|
||||
kind="image"
|
||||
isPending={isBackgroundImageResolving}
|
||||
/>
|
||||
<RuntimeResourcePendingMarker
|
||||
source={containerAssetSrc}
|
||||
kind="image"
|
||||
isPending={isContainerImageResolving}
|
||||
/>
|
||||
<RuntimeResourcePendingMarker
|
||||
source={uiSpritesheetSource}
|
||||
kind="image"
|
||||
isPending={isUiSpritesheetResolving}
|
||||
/>
|
||||
<RuntimeResourcePendingMarker
|
||||
source={itemSpritesheetSource}
|
||||
kind="image"
|
||||
isPending={isItemSpritesheetResolving}
|
||||
/>
|
||||
{imageReadUrlSources.map((source) => (
|
||||
<RuntimeResourcePendingMarker
|
||||
key={`match3d-runtime-resource:${source}`}
|
||||
source={source}
|
||||
kind="image"
|
||||
isPending={
|
||||
isImageSourcesResolving &&
|
||||
isGeneratedLegacyPath(source) &&
|
||||
!resolvedImageSources.has(source) &&
|
||||
!failedImageSources.has(source)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
||||
{resolvedBackgroundImageSrc ? (
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveMiniGameGenerationViewBusy,
|
||||
resolveMiniGameGenerationProgressTickState,
|
||||
} from './PlatformEntryFlowShellImpl';
|
||||
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
|
||||
import { resolveFinishedMiniGameDraftGenerationState } from './platformMiniGameDraftGenerationStateModel';
|
||||
|
||||
describe('resolveMiniGameGenerationProgressTickState', () => {
|
||||
test('returns jump hop and wooden fish generation states for progress ticking', () => {
|
||||
const jumpHopState = createMiniGameDraftGenerationState('jump-hop');
|
||||
const woodenFishState = createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
expect(
|
||||
resolveMiniGameGenerationProgressTickState('jump-hop-generating', {
|
||||
'jump-hop': jumpHopState,
|
||||
}),
|
||||
).toBe(jumpHopState);
|
||||
expect(
|
||||
resolveMiniGameGenerationProgressTickState('wooden-fish-generating', {
|
||||
'wooden-fish': woodenFishState,
|
||||
}),
|
||||
).toBe(woodenFishState);
|
||||
});
|
||||
|
||||
test('returns null when the stage does not need generation ticking', () => {
|
||||
expect(
|
||||
resolveMiniGameGenerationProgressTickState('platform', {
|
||||
'jump-hop': createMiniGameDraftGenerationState('jump-hop'),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMiniGameGenerationViewBusy', () => {
|
||||
test('敲木鱼恢复生成中草稿时继续隐藏重新生成按钮', () => {
|
||||
const woodenFishGeneratingState =
|
||||
createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
expect(
|
||||
resolveMiniGameGenerationViewBusy(false, woodenFishGeneratingState),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('生成态结束后只保留真实 busy', () => {
|
||||
const woodenFishReadyState = resolveFinishedMiniGameDraftGenerationState(
|
||||
createMiniGameDraftGenerationState('wooden-fish'),
|
||||
'ready',
|
||||
);
|
||||
|
||||
expect(resolveMiniGameGenerationViewBusy(false, woodenFishReadyState)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(resolveMiniGameGenerationViewBusy(true, woodenFishReadyState)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,11 @@ import {
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isCustomWorldGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isPuzzleClearGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkCode,
|
||||
@@ -57,9 +61,18 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
|
||||
return '拼图';
|
||||
}
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return '拼消消';
|
||||
}
|
||||
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
||||
return '大鱼吃小鱼';
|
||||
}
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return '跳一跳';
|
||||
}
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return '敲木鱼';
|
||||
}
|
||||
if ('sourceType' in entry && entry.sourceType === 'match3d') {
|
||||
return '抓大鹅';
|
||||
}
|
||||
@@ -75,7 +88,11 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.templateName;
|
||||
}
|
||||
return 'RPG';
|
||||
if (isCustomWorldGalleryEntry(entry)) {
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
throw new Error('未知公开作品类型。');
|
||||
}
|
||||
|
||||
function getAuthorAvatarLabel(authorDisplayName: string) {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type {
|
||||
BarkBattleDraftConfig,
|
||||
BarkBattlePublishedConfig,
|
||||
BarkBattleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import {
|
||||
buildBarkBattleDraftConfigFromWorkSummary,
|
||||
buildBarkBattlePublishedConfigFromDraft,
|
||||
buildBarkBattlePublishedConfigFromWork,
|
||||
buildBarkBattlePublishSnapshot,
|
||||
mergeBarkBattlePublishedConfigAssets,
|
||||
mergeBarkBattleWorksByWorkId,
|
||||
mergeBarkBattleWorkSummary,
|
||||
resolveBarkBattleDraftGenerationStatus,
|
||||
shouldPreserveLocalBarkBattleWorkOnRefresh,
|
||||
} from './barkBattleWorkCache';
|
||||
|
||||
@@ -20,6 +30,7 @@ function buildBarkBattleWork(
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
onomatopoeia: ['汪', '破阵'],
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
@@ -34,6 +45,29 @@ function buildBarkBattleWork(
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarkBattleDraft(
|
||||
overrides: Partial<BarkBattleDraftConfig> = {},
|
||||
): BarkBattleDraftConfig {
|
||||
return {
|
||||
draftId: 'bark-battle-draft-1',
|
||||
workId: 'BB-cache-race-12345678',
|
||||
configVersion: 2,
|
||||
rulesetVersion: 'bark-battle-ruleset-v2',
|
||||
title: '汪汪测试杯',
|
||||
description: '测试声浪赛',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
onomatopoeia: ['汪', '破阵'],
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
difficultyPreset: 'normal',
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('preserves local published bark battle when refresh only returns same work draft', () => {
|
||||
const published = buildBarkBattleWork({
|
||||
status: 'published',
|
||||
@@ -106,3 +140,124 @@ test('preserves local ready bark battle draft when refresh has not returned it y
|
||||
expect(merged[0]?.generationStatus).toBe('ready');
|
||||
});
|
||||
|
||||
test('resolves bark battle draft generation status from required images', () => {
|
||||
expect(
|
||||
resolveBarkBattleDraftGenerationStatus(
|
||||
buildBarkBattleDraft({ uiBackgroundImageSrc: undefined }),
|
||||
false,
|
||||
),
|
||||
).toBe('pending_assets');
|
||||
expect(
|
||||
resolveBarkBattleDraftGenerationStatus(
|
||||
buildBarkBattleDraft({ opponentCharacterImageSrc: '' }),
|
||||
true,
|
||||
),
|
||||
).toBe('partial_failed');
|
||||
expect(resolveBarkBattleDraftGenerationStatus(buildBarkBattleDraft(), true)).toBe(
|
||||
'ready',
|
||||
);
|
||||
});
|
||||
|
||||
test('builds draft runtime config with stable defaults', () => {
|
||||
const config = buildBarkBattlePublishedConfigFromDraft(
|
||||
buildBarkBattleDraft({
|
||||
workId: undefined,
|
||||
configVersion: undefined,
|
||||
rulesetVersion: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.workId).toBe('bark-battle-draft-1');
|
||||
expect(config.draftId).toBe('bark-battle-draft-1');
|
||||
expect(config.configVersion).toBe(1);
|
||||
expect(config.rulesetVersion).toBe('bark-battle-ruleset-v1');
|
||||
expect(config.playTypeId).toBe('bark-battle');
|
||||
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
|
||||
});
|
||||
|
||||
test('builds work runtime config with publishedAt fallback', () => {
|
||||
const config = buildBarkBattlePublishedConfigFromWork(
|
||||
buildBarkBattleWork({ publishedAt: null }),
|
||||
);
|
||||
|
||||
expect(config.workId).toBe('BB-cache-race-12345678');
|
||||
expect(config.description).toBe('测试声浪赛');
|
||||
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
|
||||
expect(config.playerCharacterImageSrc).toBe('/generated-bark-battle/player.png');
|
||||
});
|
||||
|
||||
test('builds draft config from work summary with stable defaults', () => {
|
||||
const config = buildBarkBattleDraftConfigFromWorkSummary(
|
||||
buildBarkBattleWork({
|
||||
draftId: null,
|
||||
playerCharacterImageSrc: null,
|
||||
opponentCharacterImageSrc: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config).toMatchObject({
|
||||
draftId: 'BB-cache-race-12345678',
|
||||
workId: 'BB-cache-race-12345678',
|
||||
title: '汪汪测试杯',
|
||||
description: '测试声浪赛',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
onomatopoeia: ['汪', '破阵'],
|
||||
difficultyPreset: 'normal',
|
||||
configVersion: 1,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
});
|
||||
expect(config.playerCharacterImageSrc).toBeUndefined();
|
||||
expect(config.opponentCharacterImageSrc).toBeUndefined();
|
||||
expect(config.uiBackgroundImageSrc).toBeUndefined();
|
||||
});
|
||||
|
||||
test('builds publish snapshot without empty asset fields', () => {
|
||||
const snapshot = buildBarkBattlePublishSnapshot(
|
||||
buildBarkBattleDraft({
|
||||
playerCharacterImageSrc: '',
|
||||
opponentCharacterImageSrc: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshot).not.toHaveProperty('playerCharacterImageSrc');
|
||||
expect(snapshot).not.toHaveProperty('opponentCharacterImageSrc');
|
||||
expect(snapshot.uiBackgroundImageSrc).toBe(
|
||||
'/generated-bark-battle/background.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('merges draft assets into published config when publish response omits them', () => {
|
||||
const draft = buildBarkBattleDraft();
|
||||
const published: BarkBattlePublishedConfig = {
|
||||
workId: 'BB-cache-race-12345678',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
configVersion: 2,
|
||||
rulesetVersion: 'bark-battle-ruleset-v2',
|
||||
playTypeId: 'bark-battle',
|
||||
title: '汪汪测试杯',
|
||||
description: '测试声浪赛',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
onomatopoeia: ['汪', '破阵'],
|
||||
difficultyPreset: 'normal',
|
||||
updatedAt: '2026-05-21T10:01:00.000Z',
|
||||
publishedAt: '2026-05-21T10:01:00.000Z',
|
||||
};
|
||||
|
||||
const merged = mergeBarkBattlePublishedConfigAssets(published, draft);
|
||||
|
||||
expect(merged.playerCharacterImageSrc).toBe(
|
||||
'/generated-bark-battle/player.png',
|
||||
);
|
||||
expect(merged.opponentCharacterImageSrc).toBe(
|
||||
'/generated-bark-battle/opponent.png',
|
||||
);
|
||||
expect(merged.uiBackgroundImageSrc).toBe(
|
||||
'/generated-bark-battle/background.png',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
BarkBattleConfigEditorPayload,
|
||||
BarkBattleDraftConfig,
|
||||
BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus,
|
||||
BarkBattlePublishedConfig,
|
||||
BarkBattleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||
|
||||
@@ -36,6 +38,132 @@ export function hasBarkBattleSummaryRequiredImages(item: BarkBattleWorkSummary)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasBarkBattleDraftRequiredImages(draft: BarkBattleDraftConfig) {
|
||||
return Boolean(
|
||||
draft.playerCharacterImageSrc?.trim() &&
|
||||
draft.opponentCharacterImageSrc?.trim() &&
|
||||
draft.uiBackgroundImageSrc?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveBarkBattleDraftGenerationStatus(
|
||||
draft: BarkBattleDraftConfig,
|
||||
partialFailed: boolean,
|
||||
): BarkBattleGenerationStatus {
|
||||
if (hasBarkBattleDraftRequiredImages(draft)) {
|
||||
return 'ready';
|
||||
}
|
||||
return partialFailed ? 'partial_failed' : 'pending_assets';
|
||||
}
|
||||
|
||||
export function buildBarkBattlePublishedConfigFromDraft(
|
||||
draft: BarkBattleDraftConfig,
|
||||
): BarkBattlePublishedConfig {
|
||||
return {
|
||||
workId: draft.workId ?? draft.draftId,
|
||||
draftId: draft.draftId,
|
||||
configVersion: draft.configVersion ?? 1,
|
||||
rulesetVersion: draft.rulesetVersion ?? 'bark-battle-ruleset-v1',
|
||||
playTypeId: 'bark-battle',
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
themeDescription: draft.themeDescription,
|
||||
playerImageDescription: draft.playerImageDescription,
|
||||
opponentImageDescription: draft.opponentImageDescription,
|
||||
onomatopoeia: draft.onomatopoeia,
|
||||
playerCharacterImageSrc: draft.playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc: draft.opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc: draft.uiBackgroundImageSrc,
|
||||
difficultyPreset: draft.difficultyPreset,
|
||||
updatedAt: draft.updatedAt,
|
||||
publishedAt: draft.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBarkBattlePublishSnapshot(
|
||||
draft: BarkBattleDraftConfig,
|
||||
): BarkBattleConfigEditorPayload {
|
||||
return {
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
themeDescription: draft.themeDescription,
|
||||
playerImageDescription: draft.playerImageDescription,
|
||||
opponentImageDescription: draft.opponentImageDescription,
|
||||
onomatopoeia: draft.onomatopoeia,
|
||||
...(draft.playerCharacterImageSrc
|
||||
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
|
||||
: {}),
|
||||
...(draft.opponentCharacterImageSrc
|
||||
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
|
||||
: {}),
|
||||
...(draft.uiBackgroundImageSrc
|
||||
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
|
||||
: {}),
|
||||
difficultyPreset: draft.difficultyPreset,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeBarkBattlePublishedConfigAssets(
|
||||
published: BarkBattlePublishedConfig,
|
||||
draft: BarkBattleDraftConfig,
|
||||
): BarkBattlePublishedConfig {
|
||||
return {
|
||||
...published,
|
||||
playerCharacterImageSrc:
|
||||
published.playerCharacterImageSrc ?? draft.playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc:
|
||||
published.opponentCharacterImageSrc ?? draft.opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc:
|
||||
published.uiBackgroundImageSrc ?? draft.uiBackgroundImageSrc,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBarkBattlePublishedConfigFromWork(
|
||||
work: BarkBattleWorkSummary,
|
||||
): BarkBattlePublishedConfig {
|
||||
return {
|
||||
workId: work.workId,
|
||||
draftId: work.draftId ?? null,
|
||||
configVersion: 1,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
playTypeId: 'bark-battle',
|
||||
title: work.title,
|
||||
description: work.summary,
|
||||
themeDescription: work.themeDescription,
|
||||
playerImageDescription: work.playerImageDescription,
|
||||
opponentImageDescription: work.opponentImageDescription,
|
||||
onomatopoeia: work.onomatopoeia,
|
||||
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
|
||||
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
|
||||
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
|
||||
difficultyPreset: work.difficultyPreset,
|
||||
updatedAt: work.updatedAt,
|
||||
publishedAt: work.publishedAt ?? work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBarkBattleDraftConfigFromWorkSummary(
|
||||
work: BarkBattleWorkSummary,
|
||||
): BarkBattleDraftConfig {
|
||||
return {
|
||||
draftId: work.draftId ?? work.workId,
|
||||
workId: work.workId,
|
||||
title: work.title,
|
||||
description: work.summary,
|
||||
themeDescription: work.themeDescription,
|
||||
playerImageDescription: work.playerImageDescription,
|
||||
opponentImageDescription: work.opponentImageDescription,
|
||||
onomatopoeia: work.onomatopoeia,
|
||||
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
|
||||
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
|
||||
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
|
||||
difficultyPreset: work.difficultyPreset,
|
||||
configVersion: 1,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
updatedAt: work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPreserveLocalBarkBattleWorkOnRefresh(
|
||||
item: BarkBattleWorkSummary,
|
||||
refreshed: readonly BarkBattleWorkSummary[],
|
||||
@@ -85,11 +213,7 @@ export function buildBarkBattleWorkSummaryFromDraft(
|
||||
difficultyPreset: draft.difficultyPreset,
|
||||
status: 'draft',
|
||||
generationStatus,
|
||||
publishReady: Boolean(
|
||||
draft.playerCharacterImageSrc?.trim() &&
|
||||
draft.opponentCharacterImageSrc?.trim() &&
|
||||
draft.uiBackgroundImageSrc?.trim(),
|
||||
),
|
||||
publishReady: hasBarkBattleDraftRequiredImages(draft),
|
||||
playCount: 0,
|
||||
updatedAt: draft.updatedAt,
|
||||
publishedAt: null,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
type PlatformCreationLaunchTarget,
|
||||
resolvePlatformCreationLaunchIntent,
|
||||
} from './platformCreationLaunchModel';
|
||||
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
|
||||
|
||||
describe('platformCreationLaunchModel', () => {
|
||||
test('keeps airp as a placeholder noop before prepare', () => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: 'airp',
|
||||
isBabyObjectMatchVisible: true,
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
shouldPrepare: false,
|
||||
reason: 'placeholder',
|
||||
});
|
||||
});
|
||||
|
||||
test('blocks hidden baby object match after prepare', () => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: 'baby-object-match',
|
||||
isBabyObjectMatchVisible: false,
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'blocked',
|
||||
shouldPrepare: true,
|
||||
message: EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves known creation launch targets', () => {
|
||||
const targets: PlatformCreationLaunchTarget[] = [
|
||||
'rpg',
|
||||
'big-fish',
|
||||
'match3d',
|
||||
'square-hole',
|
||||
'jump-hop',
|
||||
'wooden-fish',
|
||||
'puzzle',
|
||||
'bark-battle',
|
||||
'visual-novel',
|
||||
'baby-object-match',
|
||||
];
|
||||
|
||||
targets.forEach((target) => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: target,
|
||||
isBabyObjectMatchVisible: true,
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'launch',
|
||||
shouldPrepare: true,
|
||||
target,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unknown creation type as a prepared noop', () => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: 'unknown-template' as never,
|
||||
isBabyObjectMatchVisible: true,
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
shouldPrepare: true,
|
||||
reason: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
87
src/components/platform-entry/platformCreationLaunchModel.ts
Normal file
87
src/components/platform-entry/platformCreationLaunchModel.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
|
||||
export type PlatformCreationLaunchTarget =
|
||||
| 'rpg'
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish'
|
||||
| 'puzzle'
|
||||
| 'bark-battle'
|
||||
| 'visual-novel'
|
||||
| 'baby-object-match';
|
||||
|
||||
export type PlatformCreationLaunchIntent =
|
||||
| {
|
||||
type: 'noop';
|
||||
shouldPrepare: false;
|
||||
reason: 'placeholder';
|
||||
}
|
||||
| {
|
||||
type: 'noop';
|
||||
shouldPrepare: true;
|
||||
reason: 'unknown';
|
||||
}
|
||||
| {
|
||||
type: 'blocked';
|
||||
shouldPrepare: true;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'launch';
|
||||
shouldPrepare: true;
|
||||
target: PlatformCreationLaunchTarget;
|
||||
};
|
||||
|
||||
const PLATFORM_CREATION_LAUNCH_TARGETS = new Set<PlatformCreationTypeId>([
|
||||
'rpg',
|
||||
'big-fish',
|
||||
'match3d',
|
||||
'square-hole',
|
||||
'jump-hop',
|
||||
'wooden-fish',
|
||||
'puzzle',
|
||||
'bark-battle',
|
||||
'visual-novel',
|
||||
'baby-object-match',
|
||||
]);
|
||||
|
||||
export function resolvePlatformCreationLaunchIntent(params: {
|
||||
type: PlatformCreationTypeId;
|
||||
isBabyObjectMatchVisible: boolean;
|
||||
}): PlatformCreationLaunchIntent {
|
||||
if (params.type === 'airp') {
|
||||
return {
|
||||
type: 'noop',
|
||||
shouldPrepare: false,
|
||||
reason: 'placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
params.type === 'baby-object-match' &&
|
||||
!params.isBabyObjectMatchVisible
|
||||
) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
shouldPrepare: true,
|
||||
message: EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
if (!PLATFORM_CREATION_LAUNCH_TARGETS.has(params.type)) {
|
||||
return {
|
||||
type: 'noop',
|
||||
shouldPrepare: true,
|
||||
reason: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'launch',
|
||||
shouldPrepare: true,
|
||||
target: params.type as PlatformCreationLaunchTarget,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../services/jump-hop/jumpHopClient';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import {
|
||||
buildBabyObjectMatchCreationUrlState,
|
||||
buildBarkBattleCreationUrlState,
|
||||
buildBigFishCreationUrlState,
|
||||
buildJumpHopCreationUrlState,
|
||||
buildMatch3DCreationUrlState,
|
||||
buildPuzzleCreationUrlState,
|
||||
buildPuzzleDraftRuntimeUrlState,
|
||||
buildPuzzlePublishedRuntimeUrlState,
|
||||
buildPuzzleRuntimeUrlStateKey,
|
||||
buildSquareHoleCreationUrlState,
|
||||
buildVisualNovelCreationUrlState,
|
||||
buildWoodenFishCreationUrlState,
|
||||
hasCreationUrlStateValue,
|
||||
hasPuzzleRuntimeUrlStateValue,
|
||||
matchesBabyObjectMatchCreationUrlRestoreTarget,
|
||||
matchesBarkBattleCreationUrlRestoreTarget,
|
||||
matchesBigFishCreationUrlRestoreTarget,
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget,
|
||||
matchesVisualNovelCreationUrlRestoreTarget,
|
||||
normalizeCreationUrlValue,
|
||||
resolveCreationUrlRestoreTarget,
|
||||
resolveInitialCreationUrlRestoreDecision,
|
||||
resolveJumpHopCreationUrlRestoreStage,
|
||||
resolveWoodenFishCreationUrlRestoreStage,
|
||||
} from './platformCreationUrlStateModel';
|
||||
|
||||
describe('platformCreationUrlStateModel', () => {
|
||||
test('normalizes private creation url state values', () => {
|
||||
expect(normalizeCreationUrlValue(' session-1 ')).toBe('session-1');
|
||||
expect(normalizeCreationUrlValue(' ')).toBeNull();
|
||||
expect(
|
||||
hasCreationUrlStateValue({
|
||||
sessionId: ' ',
|
||||
profileId: null,
|
||||
draftId: undefined,
|
||||
workId: 'work-1',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(hasCreationUrlStateValue({})).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves initial creation url restore readiness', () => {
|
||||
const readyParams = {
|
||||
handled: false,
|
||||
pathname: '/creation/puzzle/result',
|
||||
state: { sessionId: 'puzzle-session-1' },
|
||||
isLoadingPlatform: false,
|
||||
canReadProtectedData: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
handled: true,
|
||||
}),
|
||||
).toEqual({ type: 'skip' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
pathname: '/works/detail',
|
||||
}),
|
||||
).toEqual({ type: 'mark-handled' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
state: {},
|
||||
}),
|
||||
).toEqual({ type: 'mark-handled' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
isLoadingPlatform: true,
|
||||
}),
|
||||
).toEqual({ type: 'wait' });
|
||||
expect(
|
||||
resolveInitialCreationUrlRestoreDecision({
|
||||
...readyParams,
|
||||
canReadProtectedData: false,
|
||||
}),
|
||||
).toEqual({ type: 'wait' });
|
||||
expect(resolveInitialCreationUrlRestoreDecision(readyParams)).toEqual({
|
||||
type: 'restore',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves supported creation url restore targets from paths', () => {
|
||||
const state = {
|
||||
sessionId: ' session-1 ',
|
||||
profileId: ' profile-1 ',
|
||||
draftId: ' draft-1 ',
|
||||
workId: ' work-1 ',
|
||||
};
|
||||
const cases = [
|
||||
['/creation/big-fish/result', 'big-fish'],
|
||||
['/creation/match3d/result', 'match3d'],
|
||||
['/creation/square-hole/result', 'square-hole'],
|
||||
['/creation/puzzle/result', 'puzzle'],
|
||||
['/creation/visual-novel/result', 'visual-novel'],
|
||||
['/creation/bark-battle/result', 'bark-battle'],
|
||||
['/creation/baby-object-match/result', 'baby-object-match'],
|
||||
['/creation/jump-hop/result', 'jump-hop'],
|
||||
['/creation/wooden-fish/result', 'wooden-fish'],
|
||||
] as const;
|
||||
|
||||
cases.forEach(([pathname, kind]) => {
|
||||
expect(resolveCreationUrlRestoreTarget(pathname, state)).toMatchObject({
|
||||
kind,
|
||||
sessionId: 'session-1',
|
||||
profileId: 'profile-1',
|
||||
draftId: 'draft-1',
|
||||
workId: 'work-1',
|
||||
isGeneratingPath: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizes creation url restore target values and generating paths', () => {
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/jump-hop/generating', {
|
||||
sessionId: ' ',
|
||||
profileId: ' jump-profile-1 ',
|
||||
draftId: undefined,
|
||||
workId: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'jump-hop',
|
||||
sessionId: null,
|
||||
profileId: 'jump-profile-1',
|
||||
draftId: null,
|
||||
workId: null,
|
||||
isGeneratingPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('derives big fish restore session from work id when needed', () => {
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
|
||||
workId: 'big-fish-work-river',
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'big-fish',
|
||||
sessionId: null,
|
||||
profileId: null,
|
||||
draftId: null,
|
||||
workId: 'big-fish-work-river',
|
||||
isGeneratingPath: false,
|
||||
bigFishSessionId: 'river',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
|
||||
sessionId: 'big-fish-session-carp',
|
||||
workId: 'big-fish-work-river',
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'big-fish',
|
||||
bigFishSessionId: 'big-fish-session-carp',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unsupported creation paths without a concrete restore target', () => {
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/rpg/result', {
|
||||
sessionId: 'rpg-session-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/unknown/result', {
|
||||
sessionId: 'unknown-session-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/creation/big-fishery/result', {
|
||||
sessionId: 'big-fish-session-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCreationUrlRestoreTarget('/works/detail', {
|
||||
workId: 'work-1',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('matches restore targets against work and draft identities', () => {
|
||||
const bigFishTarget = resolveCreationUrlRestoreTarget(
|
||||
'/creation/big-fish/result',
|
||||
{
|
||||
workId: 'big-fish-work-river',
|
||||
},
|
||||
);
|
||||
expect(bigFishTarget?.kind).toBe('big-fish');
|
||||
if (bigFishTarget?.kind !== 'big-fish') {
|
||||
throw new Error('big fish target expected');
|
||||
}
|
||||
expect(
|
||||
matchesBigFishCreationUrlRestoreTarget(
|
||||
{ sourceSessionId: 'river' },
|
||||
bigFishTarget,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBigFishCreationUrlRestoreTarget(
|
||||
{ workId: 'big-fish-work-river' },
|
||||
bigFishTarget,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const target = {
|
||||
sessionId: 'session-1',
|
||||
profileId: 'profile-1',
|
||||
draftId: 'draft-1',
|
||||
workId: 'work-1',
|
||||
};
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ sourceSessionId: 'session-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ profileId: 'profile-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ workId: 'work-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesVisualNovelCreationUrlRestoreTarget(
|
||||
{ profileId: 'profile-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBarkBattleCreationUrlRestoreTarget(
|
||||
{ draftId: 'draft-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBabyObjectMatchCreationUrlRestoreTarget(
|
||||
{ profileId: 'work-1' },
|
||||
target,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
{ sourceSessionId: null, profileId: null, workId: null },
|
||||
{ sessionId: null, profileId: null, workId: null },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesBarkBattleCreationUrlRestoreTarget(
|
||||
{ workId: null, draftId: null },
|
||||
{ workId: null, draftId: null },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves work backed restore stages', () => {
|
||||
expect(
|
||||
resolveJumpHopCreationUrlRestoreStage({
|
||||
isGeneratingPath: true,
|
||||
hasRestoredDraft: false,
|
||||
hasRestoredWork: true,
|
||||
}),
|
||||
).toBe('jump-hop-generating');
|
||||
expect(
|
||||
resolveJumpHopCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: false,
|
||||
hasRestoredWork: true,
|
||||
}),
|
||||
).toBe('jump-hop-result');
|
||||
expect(
|
||||
resolveJumpHopCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: false,
|
||||
hasRestoredWork: false,
|
||||
}),
|
||||
).toBe('jump-hop-workspace');
|
||||
|
||||
expect(
|
||||
resolveWoodenFishCreationUrlRestoreStage({
|
||||
isGeneratingPath: true,
|
||||
hasRestoredDraft: true,
|
||||
}),
|
||||
).toBe('wooden-fish-generating');
|
||||
expect(
|
||||
resolveWoodenFishCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: true,
|
||||
}),
|
||||
).toBe('wooden-fish-result');
|
||||
expect(
|
||||
resolveWoodenFishCreationUrlRestoreStage({
|
||||
isGeneratingPath: false,
|
||||
hasRestoredDraft: false,
|
||||
}),
|
||||
).toBe('wooden-fish-workspace');
|
||||
});
|
||||
|
||||
test('builds creation restore state for core session based plays', () => {
|
||||
expect(
|
||||
buildBigFishCreationUrlState({
|
||||
sessionId: ' big-fish-session-1 ',
|
||||
} as BigFishSessionSnapshotResponse),
|
||||
).toEqual({
|
||||
sessionId: 'big-fish-session-1',
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildMatch3DCreationUrlState({
|
||||
sessionId: 'match3d-session-1',
|
||||
draft: { profileId: 'match3d-profile-draft' },
|
||||
} as Match3DAgentSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'match3d-session-1',
|
||||
profileId: 'match3d-profile-draft',
|
||||
workId: 'match3d-profile-draft',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildSquareHoleCreationUrlState({
|
||||
sessionId: 'square-session-1',
|
||||
publishedProfileId: 'square-profile-published',
|
||||
} as SquareHoleSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'square-session-1',
|
||||
profileId: 'square-profile-published',
|
||||
workId: 'square-profile-published',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildVisualNovelCreationUrlState({
|
||||
sessionId: 'visual-session-1',
|
||||
draft: { profileId: 'visual-profile-1' },
|
||||
} as VisualNovelAgentSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'visual-session-1',
|
||||
profileId: 'visual-profile-1',
|
||||
workId: 'visual-profile-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds puzzle creation and runtime query state', () => {
|
||||
expect(
|
||||
buildPuzzleCreationUrlState({
|
||||
sessionId: 'puzzle-session-ocean',
|
||||
} as PuzzleAgentSessionSnapshot),
|
||||
).toEqual({
|
||||
sessionId: 'puzzle-session-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
workId: 'puzzle-work-ocean',
|
||||
});
|
||||
|
||||
const draftRuntime = buildPuzzleDraftRuntimeUrlState(
|
||||
buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
sourceSessionId: null,
|
||||
}),
|
||||
'level-2',
|
||||
);
|
||||
expect(draftRuntime).toEqual({
|
||||
mode: 'draft',
|
||||
runtimeSessionId: 'puzzle-session-ocean',
|
||||
runtimeProfileId: 'puzzle-profile-ocean',
|
||||
runtimeLevelId: 'level-2',
|
||||
});
|
||||
expect(hasPuzzleRuntimeUrlStateValue(draftRuntime)).toBe(true);
|
||||
expect(buildPuzzleRuntimeUrlStateKey(draftRuntime)).toBe(
|
||||
'draft|puzzle-session-ocean|puzzle-profile-ocean|level-2|',
|
||||
);
|
||||
|
||||
const publishedRuntime = buildPuzzlePublishedRuntimeUrlState(
|
||||
buildPuzzleWork({ profileId: 'puzzle-profile-ocean' }),
|
||||
);
|
||||
expect(publishedRuntime.mode).toBe('published');
|
||||
expect(publishedRuntime.runtimeProfileId).toBe('puzzle-profile-ocean');
|
||||
expect(publishedRuntime.publicWorkCode).toMatch(/^PZ-/u);
|
||||
});
|
||||
|
||||
test('builds creation state for work backed plays with work id priority', () => {
|
||||
expect(
|
||||
buildJumpHopCreationUrlState({
|
||||
session: {
|
||||
sessionId: 'jump-session-1',
|
||||
draft: { profileId: 'jump-profile-draft' },
|
||||
} as JumpHopSessionSnapshotResponse,
|
||||
work: {
|
||||
summary: {
|
||||
profileId: 'jump-profile-work',
|
||||
workId: 'jump-work-1',
|
||||
},
|
||||
} as JumpHopWorkProfileResponse,
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: 'jump-session-1',
|
||||
profileId: 'jump-profile-work',
|
||||
workId: 'jump-work-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishCreationUrlState({
|
||||
session: {
|
||||
sessionId: 'wood-session-1',
|
||||
draft: { profileId: 'wood-profile-draft' },
|
||||
} as WoodenFishSessionSnapshotResponse,
|
||||
work: {
|
||||
summary: {
|
||||
profileId: 'wood-profile-work',
|
||||
workId: 'wood-work-1',
|
||||
},
|
||||
} as WoodenFishWorkProfileResponse,
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: 'wood-session-1',
|
||||
profileId: 'wood-profile-work',
|
||||
draftId: 'wood-profile-work',
|
||||
workId: 'wood-work-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds creation state for draft backed local plays', () => {
|
||||
expect(
|
||||
buildBarkBattleCreationUrlState({
|
||||
draftId: 'bark-draft-1',
|
||||
workId: 'bark-work-1',
|
||||
} as BarkBattleDraftConfig),
|
||||
).toEqual({
|
||||
draftId: 'bark-draft-1',
|
||||
workId: 'bark-work-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildBabyObjectMatchCreationUrlState({
|
||||
draftId: 'baby-draft-1',
|
||||
profileId: 'baby-profile-1',
|
||||
} as BabyObjectMatchDraft),
|
||||
).toEqual({
|
||||
profileId: 'baby-profile-1',
|
||||
draftId: 'baby-draft-1',
|
||||
workId: 'baby-profile-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-base',
|
||||
profileId: 'puzzle-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-base',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图',
|
||||
workDescription: '潮雾港口拼图。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '潮雾港口拼图。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
450
src/components/platform-entry/platformCreationUrlStateModel.ts
Normal file
450
src/components/platform-entry/platformCreationUrlStateModel.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
PuzzleClearSessionSnapshotResponse,
|
||||
PuzzleClearWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
type CreationUrlState,
|
||||
isCreationRestorePath,
|
||||
} from '../../services/creationUrlState';
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../services/jump-hop/jumpHopClient';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import type { PuzzleRuntimeUrlState } from '../../services/puzzleRuntimeUrlState';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
buildPuzzleSessionIdFromProfileId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
|
||||
/** 平台创作恢复 URL 私有 query 的纯模型,调用方只需传入玩法快照。 */
|
||||
export function normalizeCreationUrlValue(value: string | null | undefined) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
export function hasCreationUrlStateValue(state: CreationUrlState) {
|
||||
return Boolean(
|
||||
normalizeCreationUrlValue(state.sessionId) ||
|
||||
normalizeCreationUrlValue(state.profileId) ||
|
||||
normalizeCreationUrlValue(state.draftId) ||
|
||||
normalizeCreationUrlValue(state.workId),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) {
|
||||
return Boolean(
|
||||
normalizeCreationUrlValue(state.runtimeSessionId) ||
|
||||
normalizeCreationUrlValue(state.runtimeProfileId) ||
|
||||
normalizeCreationUrlValue(state.runtimeLevelId) ||
|
||||
normalizeCreationUrlValue(state.publicWorkCode) ||
|
||||
normalizeCreationUrlValue(state.mode),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) {
|
||||
return [
|
||||
normalizeCreationUrlValue(state.mode),
|
||||
normalizeCreationUrlValue(state.runtimeSessionId),
|
||||
normalizeCreationUrlValue(state.runtimeProfileId),
|
||||
normalizeCreationUrlValue(state.runtimeLevelId),
|
||||
normalizeCreationUrlValue(state.publicWorkCode),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export type CreationUrlRestoreTargetKind =
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'puzzle'
|
||||
| 'visual-novel'
|
||||
| 'bark-battle'
|
||||
| 'baby-object-match'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish';
|
||||
|
||||
type CreationUrlRestoreTargetBase = {
|
||||
kind: CreationUrlRestoreTargetKind;
|
||||
sessionId: string | null;
|
||||
profileId: string | null;
|
||||
draftId: string | null;
|
||||
workId: string | null;
|
||||
isGeneratingPath: boolean;
|
||||
};
|
||||
|
||||
export type BigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
|
||||
kind: 'big-fish';
|
||||
bigFishSessionId: string | null;
|
||||
};
|
||||
|
||||
type NonBigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
|
||||
kind: Exclude<CreationUrlRestoreTargetKind, 'big-fish'>;
|
||||
};
|
||||
|
||||
export type CreationUrlRestoreTarget =
|
||||
| BigFishCreationUrlRestoreTarget
|
||||
| NonBigFishCreationUrlRestoreTarget;
|
||||
|
||||
export type BigFishRestoreWorkIdentity = {
|
||||
sourceSessionId?: string | null;
|
||||
workId?: string | null;
|
||||
};
|
||||
|
||||
export type SessionProfileWorkRestoreIdentity = {
|
||||
sourceSessionId?: string | null;
|
||||
profileId?: string | null;
|
||||
workId?: string | null;
|
||||
};
|
||||
|
||||
export type ProfileRestoreWorkIdentity = {
|
||||
profileId?: string | null;
|
||||
};
|
||||
|
||||
export type BarkBattleRestoreWorkIdentity = {
|
||||
workId?: string | null;
|
||||
draftId?: string | null;
|
||||
};
|
||||
|
||||
export type BabyObjectMatchRestoreDraftIdentity = {
|
||||
profileId?: string | null;
|
||||
draftId?: string | null;
|
||||
};
|
||||
|
||||
const CREATION_URL_RESTORE_TARGET_ROUTES = [
|
||||
['/creation/big-fish', 'big-fish'],
|
||||
['/creation/match3d', 'match3d'],
|
||||
['/creation/square-hole', 'square-hole'],
|
||||
['/creation/puzzle', 'puzzle'],
|
||||
['/creation/visual-novel', 'visual-novel'],
|
||||
['/creation/bark-battle', 'bark-battle'],
|
||||
['/creation/baby-object-match', 'baby-object-match'],
|
||||
['/creation/jump-hop', 'jump-hop'],
|
||||
['/creation/wooden-fish', 'wooden-fish'],
|
||||
] as const satisfies readonly (readonly [
|
||||
string,
|
||||
CreationUrlRestoreTargetKind,
|
||||
])[];
|
||||
|
||||
export function resolveCreationUrlRestoreTarget(
|
||||
pathname: string | undefined,
|
||||
state: CreationUrlState,
|
||||
): CreationUrlRestoreTarget | null {
|
||||
const path = pathname?.trim() ?? '';
|
||||
const route = CREATION_URL_RESTORE_TARGET_ROUTES.find(([prefix]) =>
|
||||
path === prefix || path.startsWith(`${prefix}/`),
|
||||
);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = route[1];
|
||||
const sessionId = normalizeCreationUrlValue(state.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(state.profileId);
|
||||
const draftId = normalizeCreationUrlValue(state.draftId);
|
||||
const workId = normalizeCreationUrlValue(state.workId);
|
||||
const base = {
|
||||
kind,
|
||||
sessionId,
|
||||
profileId,
|
||||
draftId,
|
||||
workId,
|
||||
isGeneratingPath: path.includes('/generating'),
|
||||
};
|
||||
|
||||
if (kind === 'big-fish') {
|
||||
return {
|
||||
...base,
|
||||
kind,
|
||||
bigFishSessionId:
|
||||
sessionId ?? workId?.replace(/^big-fish-work-/u, '') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return base as NonBigFishCreationUrlRestoreTarget;
|
||||
}
|
||||
|
||||
function matchesRestoreValue(
|
||||
itemValue: string | null | undefined,
|
||||
targetValue: string | null,
|
||||
) {
|
||||
return Boolean(targetValue && itemValue === targetValue);
|
||||
}
|
||||
|
||||
export function matchesBigFishCreationUrlRestoreTarget(
|
||||
item: BigFishRestoreWorkIdentity,
|
||||
target: BigFishCreationUrlRestoreTarget,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.sourceSessionId, target.bigFishSessionId) ||
|
||||
matchesRestoreValue(item.workId, target.workId)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesSessionProfileWorkCreationUrlRestoreTarget(
|
||||
item: SessionProfileWorkRestoreIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'sessionId' | 'profileId' | 'workId'>,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.sourceSessionId, target.sessionId) ||
|
||||
matchesRestoreValue(item.profileId, target.profileId) ||
|
||||
matchesRestoreValue(item.workId, target.workId)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesVisualNovelCreationUrlRestoreTarget(
|
||||
item: ProfileRestoreWorkIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'profileId'>,
|
||||
) {
|
||||
return matchesRestoreValue(item.profileId, target.profileId);
|
||||
}
|
||||
|
||||
export function matchesBarkBattleCreationUrlRestoreTarget(
|
||||
item: BarkBattleRestoreWorkIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'workId' | 'draftId'>,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.workId, target.workId) ||
|
||||
matchesRestoreValue(item.draftId, target.draftId)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesBabyObjectMatchCreationUrlRestoreTarget(
|
||||
item: BabyObjectMatchRestoreDraftIdentity,
|
||||
target: Pick<CreationUrlRestoreTarget, 'profileId' | 'draftId' | 'workId'>,
|
||||
) {
|
||||
return (
|
||||
matchesRestoreValue(item.profileId, target.profileId) ||
|
||||
matchesRestoreValue(item.draftId, target.draftId) ||
|
||||
matchesRestoreValue(item.profileId, target.workId)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveJumpHopCreationUrlRestoreStage(params: {
|
||||
isGeneratingPath: boolean;
|
||||
hasRestoredDraft: boolean;
|
||||
hasRestoredWork: boolean;
|
||||
}): SelectionStage {
|
||||
if (params.isGeneratingPath) {
|
||||
return 'jump-hop-generating';
|
||||
}
|
||||
|
||||
return params.hasRestoredDraft || params.hasRestoredWork
|
||||
? 'jump-hop-result'
|
||||
: 'jump-hop-workspace';
|
||||
}
|
||||
|
||||
export function resolveWoodenFishCreationUrlRestoreStage(params: {
|
||||
isGeneratingPath: boolean;
|
||||
hasRestoredDraft: boolean;
|
||||
}): SelectionStage {
|
||||
if (params.isGeneratingPath) {
|
||||
return 'wooden-fish-generating';
|
||||
}
|
||||
|
||||
return params.hasRestoredDraft
|
||||
? 'wooden-fish-result'
|
||||
: 'wooden-fish-workspace';
|
||||
}
|
||||
|
||||
export type InitialCreationUrlRestoreDecision =
|
||||
| { type: 'skip' }
|
||||
| { type: 'mark-handled' }
|
||||
| { type: 'wait' }
|
||||
| { type: 'restore' };
|
||||
|
||||
export function resolveInitialCreationUrlRestoreDecision(params: {
|
||||
handled: boolean;
|
||||
pathname: string | undefined;
|
||||
state: CreationUrlState;
|
||||
isLoadingPlatform: boolean;
|
||||
canReadProtectedData: boolean;
|
||||
}): InitialCreationUrlRestoreDecision {
|
||||
if (params.handled) {
|
||||
return { type: 'skip' };
|
||||
}
|
||||
|
||||
if (
|
||||
!isCreationRestorePath(params.pathname) ||
|
||||
!hasCreationUrlStateValue(params.state)
|
||||
) {
|
||||
return { type: 'mark-handled' };
|
||||
}
|
||||
|
||||
if (params.isLoadingPlatform || !params.canReadProtectedData) {
|
||||
return { type: 'wait' };
|
||||
}
|
||||
|
||||
return { type: 'restore' };
|
||||
}
|
||||
|
||||
export function buildBigFishCreationUrlState(
|
||||
session: BigFishSessionSnapshotResponse | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
return {
|
||||
sessionId,
|
||||
workId: sessionId ? `big-fish-work-${sessionId}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatch3DCreationUrlState(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
session?.draft?.profileId ?? session?.publishedProfileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: profileId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSquareHoleCreationUrlState(
|
||||
session: SquareHoleSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
session?.draft?.profileId ?? session?.publishedProfileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: profileId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleCreationUrlState(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId),
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleDraftRuntimeUrlState(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
): PuzzleRuntimeUrlState {
|
||||
const runtimeSessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ??
|
||||
buildPuzzleSessionIdFromProfileId(item.profileId);
|
||||
|
||||
return {
|
||||
mode: 'draft',
|
||||
runtimeSessionId,
|
||||
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
|
||||
runtimeLevelId: normalizeCreationUrlValue(levelId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzlePublishedRuntimeUrlState(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
): PuzzleRuntimeUrlState {
|
||||
return {
|
||||
mode: 'published',
|
||||
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
|
||||
runtimeLevelId: normalizeCreationUrlValue(levelId),
|
||||
publicWorkCode: buildPuzzlePublicWorkCode(item.profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVisualNovelCreationUrlState(
|
||||
session: VisualNovelAgentSessionSnapshot | null,
|
||||
): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(session?.draft?.profileId);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: profileId ?? sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopCreationUrlState(params: {
|
||||
session?: JumpHopSessionSnapshotResponse | null;
|
||||
work?: JumpHopWorkProfileResponse | null;
|
||||
}): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
params.work?.summary.profileId ?? params.session?.draft?.profileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleClearCreationUrlState(params: {
|
||||
session?: PuzzleClearSessionSnapshotResponse | null;
|
||||
work?: PuzzleClearWorkProfileResponse | null;
|
||||
}): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
params.work?.summary.profileId ?? params.session?.draft?.profileId,
|
||||
);
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishCreationUrlState(params: {
|
||||
session?: WoodenFishSessionSnapshotResponse | null;
|
||||
work?: WoodenFishWorkProfileResponse | null;
|
||||
}): CreationUrlState {
|
||||
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
|
||||
const profileId = normalizeCreationUrlValue(
|
||||
params.work?.summary.profileId ?? params.session?.draft?.profileId,
|
||||
);
|
||||
const draftId = profileId ?? sessionId;
|
||||
return {
|
||||
sessionId,
|
||||
profileId,
|
||||
draftId,
|
||||
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBarkBattleCreationUrlState(
|
||||
draft: BarkBattleDraftConfig | null,
|
||||
): CreationUrlState {
|
||||
return {
|
||||
draftId: normalizeCreationUrlValue(draft?.draftId),
|
||||
workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchCreationUrlState(
|
||||
draft: BabyObjectMatchDraft | null,
|
||||
): CreationUrlState {
|
||||
const profileId = normalizeCreationUrlValue(draft?.profileId);
|
||||
return {
|
||||
profileId,
|
||||
draftId: normalizeCreationUrlValue(draft?.draftId),
|
||||
workId: profileId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow';
|
||||
|
||||
describe('platformCreationWorkDeleteFlow', () => {
|
||||
test('resolves RPG library delete confirmation without draft notice keys', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg-library',
|
||||
entry: {
|
||||
profileId: 'rpg-profile',
|
||||
worldName: '潮雾列岛',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'rpg-profile',
|
||||
title: '潮雾列岛',
|
||||
detail: '删除后会从你的作品列表和公开广场中移除。',
|
||||
noticeKeys: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves RPG work delete detail and notice keys by work status', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg',
|
||||
work: buildRpgWork(),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'rpg-work',
|
||||
title: 'RPG 草稿',
|
||||
detail: '删除后会从你的作品列表中移除。',
|
||||
noticeKeys: ['rpg:rpg-work', 'rpg:rpg-session', 'rpg:rpg-profile'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'rpg',
|
||||
work: buildRpgWork({ status: 'published' }),
|
||||
}).detail,
|
||||
).toBe('删除后会从你的作品列表和公开广场中移除。');
|
||||
});
|
||||
|
||||
test('resolves mini game delete models with shared public and private detail copy', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'big-fish',
|
||||
work: buildBigFishWork({ status: 'published' }),
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: 'big-fish-work',
|
||||
title: '大鱼作品',
|
||||
detail: '删除后会从你的作品列表和公开广场中移除。',
|
||||
noticeKeys: ['big-fish:big-fish-work', 'big-fish:big-fish-session'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'match3d',
|
||||
work: buildMatch3DWork(),
|
||||
}).detail,
|
||||
).toBe('删除后会从你的作品列表中移除。');
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'square-hole',
|
||||
work: buildSquareHoleWork({ publicationStatus: 'published' }),
|
||||
}).noticeKeys,
|
||||
).toEqual([
|
||||
'square-hole:square-hole-work',
|
||||
'square-hole:square-hole-profile',
|
||||
'square-hole:square-hole-session',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolves puzzle title fallback and stable result notice keys', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'puzzle',
|
||||
work: buildPuzzleWork({
|
||||
workTitle: ' ',
|
||||
levelName: ' 雾港第一关 ',
|
||||
sourceSessionId: 'puzzle-session-ocean',
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'puzzle-work',
|
||||
title: '雾港第一关',
|
||||
detail: '删除后会从你的作品列表中移除。',
|
||||
noticeKeys: [
|
||||
'puzzle:puzzle-work',
|
||||
'puzzle:puzzle-profile',
|
||||
'puzzle:puzzle-session-ocean',
|
||||
'puzzle:puzzle-work-ocean',
|
||||
'puzzle:puzzle-profile-ocean',
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'puzzle',
|
||||
work: buildPuzzleWork({ workTitle: '', levelName: ' ' }),
|
||||
}).title,
|
||||
).toBe('未命名拼图');
|
||||
});
|
||||
|
||||
test('resolves visual novel and baby object match special delete copy', () => {
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'visual-novel',
|
||||
work: buildVisualNovelWork({ title: '', publishStatus: 'published' }),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'visual-novel-profile',
|
||||
title: '未命名视觉小说',
|
||||
detail: '删除后会从你的作品列表和公开广场中移除。',
|
||||
noticeKeys: ['visual-novel:visual-novel-profile'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformCreationWorkDeleteConfirmationModel({
|
||||
kind: 'baby-object-match',
|
||||
work: buildBabyObjectMatchDraft({
|
||||
workTitle: ' ',
|
||||
publicationStatus: 'published',
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'baby-profile',
|
||||
title: '宝贝识物',
|
||||
detail: '删除后会从你的作品列表和寓教于乐板块中移除。',
|
||||
noticeKeys: [
|
||||
'baby-object-match:baby-profile',
|
||||
'baby-object-match:baby-draft',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildRpgWork(
|
||||
overrides: Partial<CustomWorldWorkSummary> = {},
|
||||
): CustomWorldWorkSummary {
|
||||
return {
|
||||
workId: 'rpg-work',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: 'RPG 草稿',
|
||||
subtitle: '待完善',
|
||||
summary: 'RPG 摘要。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
stage: 'draft',
|
||||
stageLabel: '草稿',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
sessionId: 'rpg-session',
|
||||
profileId: 'rpg-profile',
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work',
|
||||
sourceSessionId: 'big-fish-session',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '大鱼作品',
|
||||
subtitle: '大鱼吃小鱼',
|
||||
summary: '大鱼摘要。',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work',
|
||||
profileId: 'puzzle-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session',
|
||||
authorDisplayName: '玩家',
|
||||
workTitle: '拼图作品',
|
||||
workDescription: '拼图摘要。',
|
||||
levelName: '拼图第一关',
|
||||
summary: '拼图摘要。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work',
|
||||
profileId: 'match3d-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session',
|
||||
gameName: '抓大鹅作品',
|
||||
themeText: '糖果厨房',
|
||||
summary: '抓大鹅摘要。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleWork(
|
||||
overrides: Partial<SquareHoleWorkSummary> = {},
|
||||
): SquareHoleWorkSummary {
|
||||
return {
|
||||
workId: 'square-hole-work',
|
||||
profileId: 'square-hole-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'square-hole-session',
|
||||
gameName: '方洞作品',
|
||||
themeText: '图形',
|
||||
twistRule: '反直觉',
|
||||
summary: '方洞摘要。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
backgroundPrompt: '背景',
|
||||
backgroundImageSrc: null,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
shapeCount: 8,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWork(
|
||||
overrides: Partial<VisualNovelWorkSummary> = {},
|
||||
): VisualNovelWorkSummary {
|
||||
return {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile',
|
||||
ownerUserId: 'user-1',
|
||||
title: '视觉小说作品',
|
||||
description: '视觉小说摘要。',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'draft',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchDraft(
|
||||
overrides: Partial<BabyObjectMatchDraft> = {},
|
||||
): BabyObjectMatchDraft {
|
||||
return {
|
||||
draftId: 'baby-draft',
|
||||
profileId: 'baby-profile',
|
||||
templateId: 'baby-object-match',
|
||||
templateName: '宝贝识物',
|
||||
workTitle: '宝贝识物作品',
|
||||
workDescription: '宝贝识物摘要。',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'apple',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'banana',
|
||||
itemName: '香蕉',
|
||||
imageSrc: '/banana.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: [],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
288
src/components/platform-entry/platformCreationWorkDeleteFlow.ts
Normal file
288
src/components/platform-entry/platformCreationWorkDeleteFlow.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
collectDraftNoticeKeys,
|
||||
} from './platformDraftGenerationShelfModel';
|
||||
|
||||
const PRIVATE_WORK_DELETE_DETAIL = '删除后会从你的作品列表中移除。';
|
||||
const PUBLIC_GALLERY_DELETE_DETAIL = '删除后会从你的作品列表和公开广场中移除。';
|
||||
const EDUTAINMENT_PUBLIC_DELETE_DETAIL =
|
||||
'删除后会从你的作品列表和寓教于乐板块中移除。';
|
||||
|
||||
export type PlatformCreationWorkDeleteConfirmationModel = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
noticeKeys: string[];
|
||||
};
|
||||
|
||||
export type PlatformCreationWorkDeleteInput =
|
||||
| {
|
||||
kind: 'rpg-library';
|
||||
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>;
|
||||
}
|
||||
| {
|
||||
kind: 'rpg';
|
||||
work: Pick<
|
||||
CustomWorldWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'big-fish';
|
||||
work: Pick<
|
||||
BigFishWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sourceSessionId'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
work: Pick<
|
||||
PuzzleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'workTitle'
|
||||
| 'levelName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'match3d';
|
||||
work: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'square-hole';
|
||||
work: Pick<
|
||||
SquareHoleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'visual-novel';
|
||||
work: Pick<
|
||||
VisualNovelWorkSummary,
|
||||
'profileId' | 'title' | 'publishStatus'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'baby-object-match';
|
||||
work: Pick<
|
||||
BabyObjectMatchDraft,
|
||||
| 'profileId'
|
||||
| 'draftId'
|
||||
| 'workTitle'
|
||||
| 'templateName'
|
||||
| 'publicationStatus'
|
||||
>;
|
||||
};
|
||||
|
||||
export function resolvePlatformCreationWorkDeleteConfirmationModel(
|
||||
input: PlatformCreationWorkDeleteInput,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
switch (input.kind) {
|
||||
case 'rpg-library':
|
||||
return resolveRpgLibraryDeleteConfirmationModel(input.entry);
|
||||
case 'rpg':
|
||||
return resolveRpgWorkDeleteConfirmationModel(input.work);
|
||||
case 'big-fish':
|
||||
return resolveBigFishWorkDeleteConfirmationModel(input.work);
|
||||
case 'puzzle':
|
||||
return resolvePuzzleWorkDeleteConfirmationModel(input.work);
|
||||
case 'match3d':
|
||||
return resolveMatch3DWorkDeleteConfirmationModel(input.work);
|
||||
case 'square-hole':
|
||||
return resolveSquareHoleWorkDeleteConfirmationModel(input.work);
|
||||
case 'visual-novel':
|
||||
return resolveVisualNovelWorkDeleteConfirmationModel(input.work);
|
||||
case 'baby-object-match':
|
||||
return resolveBabyObjectMatchDeleteConfirmationModel(input.work);
|
||||
default: {
|
||||
const exhaustive: never = input;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusDeleteDetail(
|
||||
status: string,
|
||||
publishedDetail = PUBLIC_GALLERY_DELETE_DETAIL,
|
||||
) {
|
||||
return status === 'published' ? publishedDetail : PRIVATE_WORK_DELETE_DETAIL;
|
||||
}
|
||||
|
||||
function resolveTrimmedTitle(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const trimmedValue = value?.trim();
|
||||
return trimmedValue || fallback;
|
||||
}
|
||||
|
||||
function resolveRpgLibraryDeleteConfirmationModel(
|
||||
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: entry.profileId,
|
||||
title: entry.worldName,
|
||||
detail: PUBLIC_GALLERY_DELETE_DETAIL,
|
||||
noticeKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRpgWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
CustomWorldWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.title,
|
||||
detail: resolveStatusDeleteDetail(work.status),
|
||||
noticeKeys: collectDraftNoticeKeys('rpg', [
|
||||
work.workId,
|
||||
work.sessionId,
|
||||
work.profileId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBigFishWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
BigFishWorkSummary,
|
||||
'workId' | 'title' | 'status' | 'sourceSessionId'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.title,
|
||||
detail: resolveStatusDeleteDetail(work.status),
|
||||
noticeKeys: collectDraftNoticeKeys('big-fish', [
|
||||
work.workId,
|
||||
work.sourceSessionId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePuzzleWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
PuzzleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'workTitle'
|
||||
| 'levelName'
|
||||
| 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: resolveTrimmedTitle(
|
||||
work.workTitle,
|
||||
resolveTrimmedTitle(work.levelName, '未命名拼图'),
|
||||
),
|
||||
detail: resolveStatusDeleteDetail(work.publicationStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('puzzle', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
buildPuzzleResultWorkId(work.sourceSessionId),
|
||||
buildPuzzleResultProfileId(work.sourceSessionId),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatch3DWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.gameName,
|
||||
detail: resolveStatusDeleteDetail(work.publicationStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('match3d', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSquareHoleWorkDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
SquareHoleWorkSummary,
|
||||
| 'workId'
|
||||
| 'profileId'
|
||||
| 'sourceSessionId'
|
||||
| 'gameName'
|
||||
| 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.workId,
|
||||
title: work.gameName,
|
||||
detail: resolveStatusDeleteDetail(work.publicationStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('square-hole', [
|
||||
work.workId,
|
||||
work.profileId,
|
||||
work.sourceSessionId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveVisualNovelWorkDeleteConfirmationModel(
|
||||
work: Pick<VisualNovelWorkSummary, 'profileId' | 'title' | 'publishStatus'>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.profileId,
|
||||
title: work.title || '未命名视觉小说',
|
||||
detail: resolveStatusDeleteDetail(work.publishStatus),
|
||||
noticeKeys: collectDraftNoticeKeys('visual-novel', [work.profileId]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBabyObjectMatchDeleteConfirmationModel(
|
||||
work: Pick<
|
||||
BabyObjectMatchDraft,
|
||||
'profileId' | 'draftId' | 'workTitle' | 'templateName' | 'publicationStatus'
|
||||
>,
|
||||
): PlatformCreationWorkDeleteConfirmationModel {
|
||||
return {
|
||||
id: work.profileId,
|
||||
title: resolveTrimmedTitle(work.workTitle, work.templateName),
|
||||
detail: resolveStatusDeleteDetail(
|
||||
work.publicationStatus,
|
||||
EDUTAINMENT_PUBLIC_DELETE_DETAIL,
|
||||
),
|
||||
noticeKeys: collectDraftNoticeKeys('baby-object-match', [
|
||||
work.profileId,
|
||||
work.draftId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
113
src/components/platform-entry/platformDialogStateModel.test.ts
Normal file
113
src/components/platform-entry/platformDialogStateModel.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
buildPlatformTaskCompletionDialogDismissKey,
|
||||
formatPlatformDialogSource,
|
||||
isBackgroundGenerationStillRunningMessage,
|
||||
normalizePlatformDialogMessage,
|
||||
PLATFORM_TASK_COMPLETION_MESSAGE,
|
||||
resolveActivePlatformDialog,
|
||||
resolvePlatformErrorDialog,
|
||||
} from './platformDialogStateModel';
|
||||
|
||||
describe('platformDialogStateModel', () => {
|
||||
test('normalizes platform dialog messages', () => {
|
||||
expect(normalizePlatformDialogMessage(' 图片失败 ')).toBe('图片失败');
|
||||
expect(normalizePlatformDialogMessage(' ')).toBeNull();
|
||||
expect(normalizePlatformDialogMessage(null)).toBeNull();
|
||||
});
|
||||
|
||||
test('formats dialog source with optional identity', () => {
|
||||
expect(formatPlatformDialogSource('拼图草稿', ' puzzle-session-1 ')).toBe(
|
||||
'拼图草稿 puzzle-session-1',
|
||||
);
|
||||
expect(formatPlatformDialogSource('拼图草稿', ' ')).toBe('拼图草稿');
|
||||
});
|
||||
|
||||
test('detects background generation still running messages', () => {
|
||||
expect(
|
||||
isBackgroundGenerationStillRunningMessage('后台仍在处理,请稍后查看。'),
|
||||
).toBe(true);
|
||||
expect(isBackgroundGenerationStillRunningMessage('素材生成失败。')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves the first non-empty error candidate', () => {
|
||||
expect(
|
||||
resolvePlatformErrorDialog([
|
||||
{
|
||||
key: 'empty',
|
||||
source: '空来源',
|
||||
message: ' ',
|
||||
},
|
||||
{
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: ' 素材生成失败。 ',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: '素材生成失败。',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformErrorDialog([
|
||||
{
|
||||
key: 'empty',
|
||||
source: '空来源',
|
||||
message: null,
|
||||
},
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds stable dismiss keys for error and completion dialogs', () => {
|
||||
expect(
|
||||
buildPlatformErrorDialogDismissKey({
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: '素材生成失败。',
|
||||
}),
|
||||
).toBe('puzzle:拼图草稿 puzzle-session-1:素材生成失败。');
|
||||
expect(buildPlatformErrorDialogDismissKey(null)).toBeNull();
|
||||
|
||||
expect(
|
||||
buildPlatformTaskCompletionDialogDismissKey({
|
||||
key: 'match3d',
|
||||
source: '抓大鹅草稿 match3d-session-1',
|
||||
message: PLATFORM_TASK_COMPLETION_MESSAGE,
|
||||
completedAtMs: null,
|
||||
}),
|
||||
).toBe(
|
||||
`match3d:抓大鹅草稿 match3d-session-1:${PLATFORM_TASK_COMPLETION_MESSAGE}:0`,
|
||||
);
|
||||
});
|
||||
|
||||
test('hides active dialog when the dismiss key has already been recorded', () => {
|
||||
const dialog = {
|
||||
key: 'puzzle',
|
||||
source: '拼图草稿 puzzle-session-1',
|
||||
message: '素材生成失败。',
|
||||
};
|
||||
const dismissKey = buildPlatformErrorDialogDismissKey(dialog);
|
||||
|
||||
expect(
|
||||
resolveActivePlatformDialog(
|
||||
dialog,
|
||||
dismissKey,
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveActivePlatformDialog(
|
||||
dialog,
|
||||
'other-dismiss-key',
|
||||
buildPlatformErrorDialogDismissKey,
|
||||
),
|
||||
).toBe(dialog);
|
||||
});
|
||||
});
|
||||
85
src/components/platform-entry/platformDialogStateModel.ts
Normal file
85
src/components/platform-entry/platformDialogStateModel.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { PlatformErrorDialogPayload } from './PlatformErrorDialog';
|
||||
import type { PlatformTaskCompletionDialogPayload } from './PlatformTaskCompletionDialog';
|
||||
|
||||
export type PlatformErrorDialogState = PlatformErrorDialogPayload & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type PlatformTaskFailureDialogState = PlatformErrorDialogState & {
|
||||
failedAtMs: number;
|
||||
};
|
||||
|
||||
export type PlatformTaskCompletionDialogState =
|
||||
PlatformTaskCompletionDialogPayload & {
|
||||
key: string;
|
||||
completedAtMs: number | null;
|
||||
};
|
||||
|
||||
export type PlatformDialogCandidate = {
|
||||
key: string;
|
||||
source: string;
|
||||
message: string | null | undefined;
|
||||
};
|
||||
|
||||
export const PLATFORM_TASK_COMPLETION_MESSAGE =
|
||||
'生成任务已完成,可以继续查看草稿。';
|
||||
|
||||
/** 收口平台弹窗候选的纯状态规则,壳层只负责副作用清理。 */
|
||||
export function normalizePlatformDialogMessage(
|
||||
message: string | null | undefined,
|
||||
) {
|
||||
const normalized = message?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export function formatPlatformDialogSource(label: string, id?: string | null) {
|
||||
const normalizedId = id?.trim();
|
||||
return normalizedId ? `${label} ${normalizedId}` : label;
|
||||
}
|
||||
|
||||
export function isBackgroundGenerationStillRunningMessage(message: string) {
|
||||
return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message);
|
||||
}
|
||||
|
||||
export function resolvePlatformErrorDialog(
|
||||
candidates: readonly PlatformDialogCandidate[],
|
||||
): PlatformErrorDialogState | null {
|
||||
for (const candidate of candidates) {
|
||||
const message = normalizePlatformDialogMessage(candidate.message);
|
||||
if (message) {
|
||||
return {
|
||||
key: candidate.key,
|
||||
source: candidate.source,
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPlatformErrorDialogDismissKey(
|
||||
error: PlatformErrorDialogState | null,
|
||||
) {
|
||||
return error ? `${error.key}:${error.source}:${error.message}` : null;
|
||||
}
|
||||
|
||||
export function buildPlatformTaskCompletionDialogDismissKey(
|
||||
completion: PlatformTaskCompletionDialogState | null,
|
||||
) {
|
||||
return completion
|
||||
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}`
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveActivePlatformDialog<TDialog>(
|
||||
currentDialog: TDialog | null,
|
||||
dismissedDialogKey: string | null,
|
||||
buildDismissKey: (dialog: TDialog | null) => string | null,
|
||||
): TDialog | null {
|
||||
const currentDialogDismissKey = buildDismissKey(currentDialog);
|
||||
return currentDialogDismissKey &&
|
||||
currentDialogDismissKey === dismissedDialogKey
|
||||
? null
|
||||
: currentDialog;
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
buildCreationWorkShelfRuntimeState,
|
||||
buildPendingPuzzleWorks,
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
collectVisibleDraftNoticeKeys,
|
||||
createPendingDraftShelfState,
|
||||
type DraftGenerationNoticeMap,
|
||||
getGenerationNoticeShelfKeys,
|
||||
hasUnreadDraftGenerationUpdates,
|
||||
mergeBigFishWorkSummary,
|
||||
mergePuzzleWorkSummary,
|
||||
resolveBigFishDraftOpenIntent,
|
||||
resolveJumpHopDraftOpenIntent,
|
||||
resolveMatch3DDraftOpenIntent,
|
||||
resolvePuzzleDraftOpenIntent,
|
||||
resolveSquareHoleDraftOpenIntent,
|
||||
resolveVisualNovelDraftOpenIntent,
|
||||
resolveWoodenFishDraftOpenIntent,
|
||||
} from './platformDraftGenerationShelfModel';
|
||||
|
||||
describe('platformDraftGenerationShelfModel', () => {
|
||||
test('resolvePuzzleDraftOpenIntent sends published puzzle without session to detail', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork({
|
||||
sourceSessionId: null,
|
||||
publicationStatus: 'published',
|
||||
}),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent restores failed puzzle generation with notice copy', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork(),
|
||||
notices: {
|
||||
'puzzle:puzzle-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
message: '首图生成失败。',
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'failed-generation',
|
||||
source: 'restored',
|
||||
errorMessage: '首图生成失败。',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent prefers active generation before restoring draft', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork(),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: 'puzzle-session-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePuzzleDraftOpenIntent does not lock a puzzle draft that already has a cover', () => {
|
||||
expect(
|
||||
resolvePuzzleDraftOpenIntent({
|
||||
item: buildPuzzleWork({
|
||||
coverImageSrc: '/media/puzzle-cover.png',
|
||||
}),
|
||||
notices: {
|
||||
'puzzle:puzzle-session-base': {
|
||||
status: 'generating',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => {
|
||||
const item = buildMatch3DWork({
|
||||
publicationStatus: 'published',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item,
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item,
|
||||
notices: {},
|
||||
forceDraft: true,
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent starts ready unread draft before failure fallback', () => {
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item: buildMatch3DWork(),
|
||||
notices: {
|
||||
'match3d:match3d-session-base': {
|
||||
status: 'ready',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
hasBackgroundGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'ready-unread',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMatch3DDraftOpenIntent restores persisted generating draft', () => {
|
||||
expect(
|
||||
resolveMatch3DDraftOpenIntent({
|
||||
item: buildMatch3DWork({
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveBigFishDraftOpenIntent reopens active generating session before restoring draft', () => {
|
||||
expect(
|
||||
resolveBigFishDraftOpenIntent({
|
||||
item: buildBigFishWork(),
|
||||
activeSessionId: 'big-fish-session-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
sourceSessionId: 'big-fish-session-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveBigFishDraftOpenIntent({
|
||||
item: buildBigFishWork(),
|
||||
activeSessionId: 'other-session',
|
||||
hasActiveGenerationRunning: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
sourceSessionId: 'big-fish-session-base',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveSquareHoleDraftOpenIntent handles published, missing, active and restore states', () => {
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork({ publicationStatus: 'published' }),
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationRunning: false,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork({ sourceSessionId: null }),
|
||||
forceDraft: true,
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationRunning: false,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'missing-session',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork(),
|
||||
activeSessionId: 'square-hole-session-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
sourceSessionId: 'square-hole-session-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSquareHoleDraftOpenIntent({
|
||||
item: buildSquareHoleWork(),
|
||||
activeSessionId: 'other-session',
|
||||
hasActiveGenerationRunning: false,
|
||||
isGenerationReady: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-draft',
|
||||
shouldClearGenerationState: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveVisualNovelDraftOpenIntent handles published, active, current result and load detail states', () => {
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork({ publishStatus: 'published' }),
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationRunning: false,
|
||||
hasActiveSessionDraft: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork(),
|
||||
forceDraft: true,
|
||||
activeSessionId: 'visual-novel-profile-base',
|
||||
hasActiveGenerationRunning: true,
|
||||
hasActiveSessionDraft: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-generation',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork(),
|
||||
forceDraft: true,
|
||||
activeSessionId: 'visual-novel-profile-base',
|
||||
hasActiveGenerationRunning: false,
|
||||
hasActiveSessionDraft: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'current-result',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveVisualNovelDraftOpenIntent({
|
||||
item: buildVisualNovelWork(),
|
||||
forceDraft: true,
|
||||
activeSessionId: 'other-profile',
|
||||
hasActiveGenerationRunning: false,
|
||||
hasActiveSessionDraft: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveJumpHopDraftOpenIntent handles published, failed current generation, generating and detail states', () => {
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ publicationStatus: 'published' }),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'open-published-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork(),
|
||||
notices: {
|
||||
'jump-hop:jump-hop-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: 'jump-hop-session-base',
|
||||
hasActiveGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-failed-generation',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ generationStatus: 'generating' }),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ generationStatus: 'generating' }),
|
||||
notices: {
|
||||
'jump-hop:jump-hop-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveJumpHopDraftOpenIntent({
|
||||
item: buildJumpHopWork({ sourceSessionId: null }),
|
||||
notices: {
|
||||
'jump-hop:jump-hop-work-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveWoodenFishDraftOpenIntent uses profile fallback and failure fallback stage', () => {
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork({
|
||||
sourceSessionId: null,
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
notices: {
|
||||
'wooden-fish:wooden-fish-profile-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts({
|
||||
activeSessionId: 'wooden-fish-profile-base',
|
||||
hasActiveGenerationFailure: true,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'active-failed-generation',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork({ generationStatus: 'generating' }),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'restore-generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork(),
|
||||
notices: {
|
||||
'wooden-fish:wooden-fish-session-base': {
|
||||
status: 'failed',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
failureFallbackStage: 'wooden-fish-workspace',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveWoodenFishDraftOpenIntent({
|
||||
item: buildWoodenFishWork(),
|
||||
notices: {},
|
||||
generation: emptyGenerationFacts(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'load-detail',
|
||||
failureFallbackStage: 'wooden-fish-generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => {
|
||||
const pending = buildPendingPuzzleWorks(
|
||||
{
|
||||
'puzzle-session-ocean': createPendingDraftShelfState(
|
||||
'failed',
|
||||
false,
|
||||
'2026-06-03T08:00:00.000Z',
|
||||
),
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]).toMatchObject({
|
||||
workId: 'puzzle-work-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
sourceSessionId: 'puzzle-session-ocean',
|
||||
workTitle: '拼图草稿',
|
||||
summary: '拼图草稿生成失败,可重新打开处理。',
|
||||
generationStatus: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildPendingPuzzleWorks skips pending item when backend shelf already has the session', () => {
|
||||
const pending = buildPendingPuzzleWorks(
|
||||
{
|
||||
'puzzle-session-ocean': createPendingDraftShelfState(
|
||||
'generating',
|
||||
false,
|
||||
'2026-06-03T08:00:00.000Z',
|
||||
),
|
||||
},
|
||||
[buildPuzzleWork({ sourceSessionId: 'puzzle-session-ocean' })],
|
||||
);
|
||||
|
||||
expect(pending).toEqual([]);
|
||||
});
|
||||
|
||||
test('mergePuzzleWorkSummary only replaces the matching profile', () => {
|
||||
const current = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-1',
|
||||
workTitle: '旧拼图',
|
||||
});
|
||||
const updated = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-1',
|
||||
workTitle: '新拼图',
|
||||
});
|
||||
const other = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile-2',
|
||||
workTitle: '别的拼图',
|
||||
});
|
||||
|
||||
expect(mergePuzzleWorkSummary(current, updated)).toBe(updated);
|
||||
expect(mergePuzzleWorkSummary(current, other)).toBe(current);
|
||||
});
|
||||
|
||||
test('mergeBigFishWorkSummary only replaces the matching source session', () => {
|
||||
const current = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
title: '旧大鱼',
|
||||
});
|
||||
const updated = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
title: '新大鱼',
|
||||
});
|
||||
const other = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-2',
|
||||
title: '别的大鱼',
|
||||
});
|
||||
|
||||
expect(mergeBigFishWorkSummary(current, updated)).toBe(updated);
|
||||
expect(mergeBigFishWorkSummary(current, other)).toBe(current);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfRuntimeState lets failure notice override persisted generating puzzle copy', () => {
|
||||
const [item] = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [
|
||||
buildPuzzleWork({
|
||||
workId: 'puzzle-work-empty',
|
||||
profileId: 'puzzle-profile-empty',
|
||||
sourceSessionId: 'puzzle-session-empty',
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
levelName: '',
|
||||
summary: '正在生成拼图草稿。',
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(item).toBeTruthy();
|
||||
|
||||
const noticeKeys = getGenerationNoticeShelfKeys(item!);
|
||||
const notices = Object.fromEntries(
|
||||
noticeKeys.map((key) => [
|
||||
key,
|
||||
{ status: 'failed', seen: false },
|
||||
]),
|
||||
) as DraftGenerationNoticeMap;
|
||||
|
||||
const state = buildCreationWorkShelfRuntimeState({
|
||||
item: item!,
|
||||
notices,
|
||||
pendingShelfItems: {
|
||||
puzzle: {
|
||||
'puzzle-session-empty': createPendingDraftShelfState(
|
||||
'failed',
|
||||
false,
|
||||
'2026-06-03T08:00:00.000Z',
|
||||
{ summary: '图片生成超时,可重新打开处理。' },
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
isGenerating: false,
|
||||
hasGenerationFailure: true,
|
||||
generationFailureSummary: '拼图草稿生成失败,可重新打开处理。',
|
||||
hasUnreadUpdate: false,
|
||||
suppressPersistedGenerating: true,
|
||||
titleOverride: '拼图草稿',
|
||||
summaryOverride: '图片生成超时,可重新打开处理。',
|
||||
});
|
||||
});
|
||||
|
||||
test('collectVisibleDraftNoticeKeys and hasUnreadDraftGenerationUpdates share unread dot rule', () => {
|
||||
const puzzle = buildPuzzleWork({
|
||||
workId: 'puzzle-work-ocean',
|
||||
profileId: 'puzzle-profile-ocean',
|
||||
sourceSessionId: 'puzzle-session-ocean',
|
||||
});
|
||||
const visibleKeys = collectVisibleDraftNoticeKeys({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
jumpHopItems: [],
|
||||
woodenFishItems: [],
|
||||
match3dItems: [],
|
||||
squareHoleItems: [],
|
||||
puzzleItems: [puzzle],
|
||||
visualNovelItems: [],
|
||||
barkBattleItems: [],
|
||||
babyObjectMatchItems: [],
|
||||
});
|
||||
|
||||
expect(visibleKeys).toContain('puzzle:puzzle-work-ocean');
|
||||
expect(visibleKeys).toContain('puzzle:puzzle-profile-ocean');
|
||||
expect(visibleKeys).toContain('puzzle:puzzle-session-ocean');
|
||||
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
|
||||
'puzzle-work-ocean',
|
||||
);
|
||||
expect(buildPuzzleResultProfileId('puzzle-session-ocean')).toBe(
|
||||
'puzzle-profile-ocean',
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnreadDraftGenerationUpdates(
|
||||
{
|
||||
'puzzle:puzzle-profile-ocean': {
|
||||
status: 'ready',
|
||||
seen: false,
|
||||
},
|
||||
},
|
||||
visibleKeys,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasUnreadDraftGenerationUpdates(
|
||||
{
|
||||
'puzzle:puzzle-profile-ocean': {
|
||||
status: 'ready',
|
||||
seen: true,
|
||||
},
|
||||
},
|
||||
visibleKeys,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function emptyGenerationFacts(
|
||||
overrides: Partial<Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation']> = {},
|
||||
): Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation'] {
|
||||
return {
|
||||
activeSessionId: null,
|
||||
hasActiveGenerationFailure: false,
|
||||
hasActiveGenerationRunning: false,
|
||||
hasBackgroundGenerationFailure: false,
|
||||
hasBackgroundGenerationRunning: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-base',
|
||||
profileId: 'puzzle-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-base',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图',
|
||||
workDescription: '潮雾港口拼图。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '潮雾港口拼图。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work-base',
|
||||
profileId: 'match3d-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-base',
|
||||
gameName: '潮雾抓大鹅',
|
||||
themeText: '潮雾港口',
|
||||
summary: '潮雾港口抓大鹅。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 0,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work-base',
|
||||
sourceSessionId: 'big-fish-session-base',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
title: '潮雾大鱼',
|
||||
subtitle: '潮雾港口',
|
||||
summary: '潮雾港口大鱼吃小鱼。',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleWork(
|
||||
overrides: Partial<SquareHoleWorkSummary> = {},
|
||||
): SquareHoleWorkSummary {
|
||||
return {
|
||||
workId: 'square-hole-work-base',
|
||||
profileId: 'square-hole-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'square-hole-session-base',
|
||||
gameName: '潮雾方洞',
|
||||
themeText: '潮雾港口',
|
||||
twistRule: '避开雾门',
|
||||
summary: '潮雾港口方洞挑战。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
backgroundPrompt: '潮雾港口',
|
||||
backgroundImageSrc: null,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
shapeCount: 1,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWork(
|
||||
overrides: Partial<VisualNovelWorkSummary> = {},
|
||||
): VisualNovelWorkSummary {
|
||||
return {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
title: '潮雾视觉小说',
|
||||
description: '潮雾港口视觉小说。',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'draft',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopWork(
|
||||
overrides: Partial<JumpHopWorkSummaryResponse> = {},
|
||||
): JumpHopWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-base',
|
||||
profileId: 'jump-hop-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-base',
|
||||
themeText: '潮雾港口',
|
||||
workTitle: '潮雾跳一跳',
|
||||
workDescription: '潮雾港口跳一跳。',
|
||||
themeTags: [],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishWork(
|
||||
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
|
||||
): WoodenFishWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-base',
|
||||
profileId: 'wooden-fish-profile-base',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-base',
|
||||
workTitle: '潮雾敲木鱼',
|
||||
workDescription: '潮雾港口敲木鱼。',
|
||||
themeTags: ['敲木鱼'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
1534
src/components/platform-entry/platformDraftGenerationShelfModel.ts
Normal file
1534
src/components/platform-entry/platformDraftGenerationShelfModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
derivePlatformCreationTypes,
|
||||
groupVisiblePlatformCreationTypes,
|
||||
getVisiblePlatformCreationTypes,
|
||||
groupVisiblePlatformCreationTypes,
|
||||
isPlatformCreationTypeOpen,
|
||||
isPlatformCreationTypeVisible,
|
||||
} from './platformEntryCreationTypes';
|
||||
@@ -41,6 +42,22 @@ test('database entry config controls visibility open state and display order', (
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
unifiedCreationSpec: {
|
||||
playId: 'match3d',
|
||||
title: '抓大鹅',
|
||||
mudPointCost: 8,
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
generationStage: 'match3d-generating',
|
||||
resultStage: 'match3d-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'themeText',
|
||||
kind: 'text',
|
||||
label: '题材',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'square-hole',
|
||||
@@ -63,6 +80,7 @@ test('database entry config controls visibility open state and display order', (
|
||||
id: 'match3d',
|
||||
locked: false,
|
||||
hidden: false,
|
||||
mudPointCostLabel: '8泥点数',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'square-hole',
|
||||
@@ -74,6 +92,7 @@ test('database entry config controls visibility open state and display order', (
|
||||
title: '数据库拼图',
|
||||
locked: true,
|
||||
hidden: false,
|
||||
mudPointCostLabel: '10泥点数',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -81,7 +100,7 @@ test('database entry config controls visibility open state and display order', (
|
||||
test('visible platform creation types hide invisible cards and put locked cards last', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'hidden',
|
||||
id: 'airp',
|
||||
title: '隐藏',
|
||||
subtitle: '隐藏',
|
||||
badge: '隐藏',
|
||||
@@ -95,7 +114,7 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'locked',
|
||||
id: 'visual-novel',
|
||||
title: '锁定',
|
||||
subtitle: '锁定',
|
||||
badge: '即将开放',
|
||||
@@ -109,7 +128,7 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
id: 'rpg',
|
||||
title: '开放',
|
||||
subtitle: '开放',
|
||||
badge: '可创建',
|
||||
@@ -125,13 +144,13 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
]);
|
||||
|
||||
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
|
||||
['open', 'locked'],
|
||||
['rpg', 'visual-novel'],
|
||||
);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'airp')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'rpg')).toBe(true);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'airp')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'visual-novel')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'rpg')).toBe(true);
|
||||
expect(
|
||||
cards.every((item) =>
|
||||
item.imageSrc.startsWith('/creation-type-references/'),
|
||||
@@ -288,7 +307,7 @@ test('groups visible platform creation types by backend category metadata', () =
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
id: 'airp',
|
||||
title: '隐藏入口',
|
||||
subtitle: '隐藏',
|
||||
badge: '隐藏',
|
||||
@@ -319,7 +338,7 @@ test('groups visible platform creation types by backend category metadata', () =
|
||||
test('falls back when backend creation type category metadata is missing', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'legacy-entry',
|
||||
id: 'creative-agent',
|
||||
title: '历史入口',
|
||||
subtitle: '旧数据缺少分类字段',
|
||||
badge: '可创建',
|
||||
@@ -336,7 +355,7 @@ test('falls back when backend creation type category metadata is missing', () =>
|
||||
|
||||
expect(cards[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'legacy-entry',
|
||||
id: 'creative-agent',
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
}),
|
||||
@@ -348,3 +367,24 @@ test('falls back when backend creation type category metadata is missing', () =>
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('throws when backend sends an unknown creation type id', () => {
|
||||
const unknownEntry = {
|
||||
id: 'unknown-play',
|
||||
title: '未知玩法',
|
||||
subtitle: '未知',
|
||||
badge: '未知',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
} as unknown as CreationEntryTypeConfig;
|
||||
|
||||
expect(() => derivePlatformCreationTypes([unknownEntry])).toThrow(
|
||||
'未知创作类型:unknown-play',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
assertPlatformCreationTypeId,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../../../packages/shared/src/contracts/playTypes';
|
||||
import {
|
||||
type CreationEntryTypeConfig,
|
||||
DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
|
||||
|
||||
export type PlatformCreationTypeId = string;
|
||||
export type { PlatformCreationTypeId };
|
||||
|
||||
export type PlatformCreationTypeCard = {
|
||||
id: PlatformCreationTypeId;
|
||||
@@ -9,6 +16,8 @@ export type PlatformCreationTypeCard = {
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
imageSrc: string;
|
||||
mudPointCost: number;
|
||||
mudPointCostLabel: string;
|
||||
locked: boolean;
|
||||
categoryId: string;
|
||||
categoryLabel: string;
|
||||
@@ -28,6 +37,16 @@ const RECENT_CREATION_CATEGORY_ID = 'recent';
|
||||
const FALLBACK_CREATION_CATEGORY_ID = 'recommended';
|
||||
const FALLBACK_CREATION_CATEGORY_LABEL = '热门推荐';
|
||||
|
||||
function normalizeMudPointCost(value: number | null | undefined) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.trunc(value)
|
||||
: DEFAULT_UNIFIED_CREATION_MUD_POINT_COST;
|
||||
}
|
||||
|
||||
function formatMudPointCostText(value: number | null | undefined) {
|
||||
return `${normalizeMudPointCost(value)}泥点数`;
|
||||
}
|
||||
|
||||
export function getVisiblePlatformCreationTypes(
|
||||
creationTypes: readonly PlatformCreationTypeCard[],
|
||||
) {
|
||||
@@ -117,21 +136,31 @@ export function derivePlatformCreationTypes(
|
||||
): PlatformCreationTypeCard[] {
|
||||
const orderedCards = [...creationTypes]
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
locked: !item.open,
|
||||
categoryId: normalizeCategoryId(item.categoryId),
|
||||
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
|
||||
categorySortOrder: item.categorySortOrder,
|
||||
sortOrder: item.sortOrder,
|
||||
hidden:
|
||||
!item.visible ||
|
||||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
||||
}));
|
||||
.map((item) => {
|
||||
const id = assertPlatformCreationTypeId(item.id);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
mudPointCost: normalizeMudPointCost(
|
||||
item.unifiedCreationSpec?.mudPointCost,
|
||||
),
|
||||
mudPointCostLabel: formatMudPointCostText(
|
||||
item.unifiedCreationSpec?.mudPointCost,
|
||||
),
|
||||
locked: !item.open,
|
||||
categoryId: normalizeCategoryId(item.categoryId),
|
||||
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
|
||||
categorySortOrder: item.categorySortOrder,
|
||||
sortOrder: item.sortOrder,
|
||||
hidden:
|
||||
!item.visible ||
|
||||
(id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...orderedCards.filter((item) => !item.hidden && !item.locked),
|
||||
|
||||
@@ -36,6 +36,10 @@ export type SelectionStage =
|
||||
| 'jump-hop-result'
|
||||
| 'jump-hop-runtime'
|
||||
| 'jump-hop-gallery-detail'
|
||||
| 'puzzle-clear-workspace'
|
||||
| 'puzzle-clear-generating'
|
||||
| 'puzzle-clear-result'
|
||||
| 'puzzle-clear-runtime'
|
||||
| 'bark-battle-workspace'
|
||||
| 'bark-battle-generating'
|
||||
| 'bark-battle-result'
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
|
||||
import { shouldTickPlatformGenerationProgressClock } from './platformGenerationProgressClock';
|
||||
|
||||
describe('platformGenerationProgressClock', () => {
|
||||
test('ticks while puzzle clear generation is still running', () => {
|
||||
expect(
|
||||
shouldTickPlatformGenerationProgressClock({
|
||||
selectionStage: 'puzzle-clear-generating',
|
||||
generationState: createMiniGameDraftGenerationState('puzzle-clear'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('stops ticking after puzzle clear generation is ready or failed', () => {
|
||||
const runningState = createMiniGameDraftGenerationState('puzzle-clear');
|
||||
|
||||
expect(
|
||||
shouldTickPlatformGenerationProgressClock({
|
||||
selectionStage: 'puzzle-clear-generating',
|
||||
generationState: { ...runningState, phase: 'ready' },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldTickPlatformGenerationProgressClock({
|
||||
selectionStage: 'puzzle-clear-generating',
|
||||
generationState: { ...runningState, phase: 'failed' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('ticks for other shared mini game generation stages', () => {
|
||||
expect(
|
||||
shouldTickPlatformGenerationProgressClock({
|
||||
selectionStage: 'jump-hop-generating',
|
||||
generationState: createMiniGameDraftGenerationState('jump-hop'),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldTickPlatformGenerationProgressClock({
|
||||
selectionStage: 'wooden-fish-generating',
|
||||
generationState: createMiniGameDraftGenerationState('wooden-fish'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('ticks visual novel generation from its phase source', () => {
|
||||
expect(
|
||||
shouldTickPlatformGenerationProgressClock({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
visualNovelGenerationStartedAtMs: 1000,
|
||||
visualNovelGenerationPhase: 'generating',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldTickPlatformGenerationProgressClock({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
visualNovelGenerationStartedAtMs: 1000,
|
||||
visualNovelGenerationPhase: 'ready',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('does not tick when no generating stage is active', () => {
|
||||
expect(
|
||||
shouldTickPlatformGenerationProgressClock({
|
||||
selectionStage: 'platform',
|
||||
generationState: createMiniGameDraftGenerationState('puzzle-clear'),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed';
|
||||
|
||||
type PlatformGenerationProgressClockInput = {
|
||||
selectionStage: SelectionStage;
|
||||
generationState?: MiniGameDraftGenerationState | null;
|
||||
visualNovelGenerationStartedAtMs?: number | null;
|
||||
visualNovelGenerationPhase?: VisualNovelEntryGenerationPhase;
|
||||
};
|
||||
|
||||
export function shouldTickPlatformGenerationProgressClock({
|
||||
selectionStage,
|
||||
generationState,
|
||||
visualNovelGenerationStartedAtMs,
|
||||
visualNovelGenerationPhase,
|
||||
}: PlatformGenerationProgressClockInput) {
|
||||
if (selectionStage === 'visual-novel-generating') {
|
||||
return (
|
||||
visualNovelGenerationStartedAtMs != null &&
|
||||
visualNovelGenerationPhase !== 'ready' &&
|
||||
visualNovelGenerationPhase !== 'failed'
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectionStage.endsWith('-generating')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
generationState &&
|
||||
generationState.phase !== 'ready' &&
|
||||
generationState.phase !== 'failed',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
MiniGameDraftGenerationKind,
|
||||
MiniGameDraftGenerationPhase,
|
||||
MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
|
||||
|
||||
function buildGenerationState(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
phase: MiniGameDraftGenerationPhase = 'compile',
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind,
|
||||
phase,
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 1,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformGenerationProgressTickModel', () => {
|
||||
test('ticks while a mini-game generation stage has a running state', () => {
|
||||
const cases: Array<
|
||||
[stage: SelectionStage, kind: MiniGameDraftGenerationKind]
|
||||
> = [
|
||||
['puzzle-generating', 'puzzle'],
|
||||
['match3d-generating', 'match3d'],
|
||||
['big-fish-generating', 'big-fish'],
|
||||
['square-hole-generating', 'square-hole'],
|
||||
['jump-hop-generating', 'jump-hop'],
|
||||
['wooden-fish-generating', 'wooden-fish'],
|
||||
['baby-object-match-generating', 'baby-object-match'],
|
||||
];
|
||||
|
||||
for (const [selectionStage, kind] of cases) {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage,
|
||||
miniGameStates: {
|
||||
[kind]: buildGenerationState(kind),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: kind,
|
||||
shouldTick: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not tick mini-game generation when state is missing or terminal', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'puzzle-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'puzzle',
|
||||
shouldTick: false,
|
||||
});
|
||||
|
||||
for (const phase of ['ready', 'failed'] as const) {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'puzzle-generating',
|
||||
miniGameStates: {
|
||||
puzzle: buildGenerationState('puzzle', phase),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'puzzle',
|
||||
shouldTick: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not tick when stage and mini-game state do not match', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'puzzle-generating',
|
||||
miniGameStates: {
|
||||
match3d: buildGenerationState('match3d'),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'puzzle',
|
||||
shouldTick: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('ticks visual novel generation only after it has started and before terminal phases', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: 1000,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: null,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick: false,
|
||||
});
|
||||
|
||||
for (const phase of ['ready', 'failed'] as const) {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'visual-novel-generating',
|
||||
miniGameStates: {},
|
||||
visualNovel: {
|
||||
startedAtMs: 1000,
|
||||
phase,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not tick non-generation stages even when states are present', () => {
|
||||
expect(
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage: 'platform',
|
||||
miniGameStates: {
|
||||
puzzle: buildGenerationState('puzzle'),
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: 1000,
|
||||
phase: 'generating',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
activeKind: null,
|
||||
shouldTick: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
MiniGameDraftGenerationKind,
|
||||
MiniGameDraftGenerationState,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
export type PlatformVisualNovelGenerationPhase =
|
||||
| 'generating'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
|
||||
export type PlatformGenerationProgressTickKind =
|
||||
| MiniGameDraftGenerationKind
|
||||
| 'visual-novel';
|
||||
|
||||
export type PlatformGenerationProgressTickInput = {
|
||||
selectionStage: SelectionStage;
|
||||
miniGameStates: Partial<
|
||||
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
|
||||
>;
|
||||
visualNovel: {
|
||||
startedAtMs: number | null;
|
||||
phase: PlatformVisualNovelGenerationPhase;
|
||||
};
|
||||
};
|
||||
|
||||
export type PlatformGenerationProgressTickDecision = {
|
||||
activeKind: PlatformGenerationProgressTickKind | null;
|
||||
shouldTick: boolean;
|
||||
};
|
||||
|
||||
const MINI_GAME_GENERATION_STAGE_TO_KIND: Partial<
|
||||
Record<SelectionStage, MiniGameDraftGenerationKind>
|
||||
> = {
|
||||
'puzzle-generating': 'puzzle',
|
||||
'match3d-generating': 'match3d',
|
||||
'big-fish-generating': 'big-fish',
|
||||
'square-hole-generating': 'square-hole',
|
||||
'jump-hop-generating': 'jump-hop',
|
||||
'wooden-fish-generating': 'wooden-fish',
|
||||
'baby-object-match-generating': 'baby-object-match',
|
||||
};
|
||||
|
||||
function shouldTickMiniGameGenerationState(
|
||||
state: MiniGameDraftGenerationState | null | undefined,
|
||||
) {
|
||||
return state != null && state.phase !== 'ready' && state.phase !== 'failed';
|
||||
}
|
||||
|
||||
/** 收口生成页进度 tick 判定,壳层只保留 interval 副作用。 */
|
||||
export function resolvePlatformGenerationProgressTickDecision(
|
||||
input: PlatformGenerationProgressTickInput,
|
||||
): PlatformGenerationProgressTickDecision {
|
||||
if (input.selectionStage === 'visual-novel-generating') {
|
||||
return {
|
||||
activeKind: 'visual-novel',
|
||||
shouldTick:
|
||||
input.visualNovel.startedAtMs != null &&
|
||||
input.visualNovel.phase !== 'ready' &&
|
||||
input.visualNovel.phase !== 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
const activeKind =
|
||||
MINI_GAME_GENERATION_STAGE_TO_KIND[input.selectionStage] ?? null;
|
||||
if (!activeKind) {
|
||||
return {
|
||||
activeKind: null,
|
||||
shouldTick: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
activeKind,
|
||||
shouldTick: shouldTickMiniGameGenerationState(
|
||||
input.miniGameStates[activeKind],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
buildMatch3DProfileFromSession,
|
||||
mapMatch3DWorkToPublicWorkDetail,
|
||||
mapPublicWorkDetailToMatch3DWork,
|
||||
resolveActiveMatch3DRuntimeProfile,
|
||||
resolveMatch3DRuntimeBackgroundImageSrc,
|
||||
resolveMatch3DRuntimeGeneratedBackgroundAsset,
|
||||
resolveMatch3DRuntimeGeneratedItemAssets,
|
||||
} from './platformMatch3DRuntimeProfile';
|
||||
|
||||
function buildBackgroundAsset(
|
||||
overrides: Partial<Match3DGeneratedBackgroundAsset> = {},
|
||||
): Match3DGeneratedBackgroundAsset {
|
||||
return {
|
||||
prompt: '森林棋盘',
|
||||
imageSrc: '/generated/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
status: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildItemAsset(
|
||||
overrides: Partial<Match3DGeneratedItemAsset> = {},
|
||||
): Match3DGeneratedItemAsset {
|
||||
return {
|
||||
itemId: 'item-1',
|
||||
itemName: '蘑菇',
|
||||
imageSrc: '/generated/match3d/item.png',
|
||||
imageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProfile(
|
||||
overrides: Partial<Match3DWorkProfile> = {},
|
||||
): Match3DWorkProfile {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
gameName: '森林抓鹅',
|
||||
themeText: '森林',
|
||||
summary: '找出蘑菇。',
|
||||
tags: ['森林', '蘑菇'],
|
||||
coverImageSrc: '/cover.png',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: 1,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
backgroundPrompt: null,
|
||||
backgroundImageSrc: null,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets: [buildItemAsset()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRun(overrides: Partial<Match3DRunSnapshot> = {}): Match3DRunSnapshot {
|
||||
return {
|
||||
runId: 'match3d-run-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
status: 'running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1000,
|
||||
durationLimitMs: 60000,
|
||||
remainingMs: 55000,
|
||||
clearCount: 12,
|
||||
totalItemCount: 12,
|
||||
clearedItemCount: 0,
|
||||
items: [],
|
||||
traySlots: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublicWork(
|
||||
overrides: Partial<PlatformMatch3DGalleryCard> = {},
|
||||
): PlatformMatch3DGalleryCard {
|
||||
return {
|
||||
sourceType: 'match3d',
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
publicWorkCode: 'M3D-00000001',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: '森林抓鹅',
|
||||
subtitle: '抓大鹅',
|
||||
summaryText: '找出蘑菇。',
|
||||
coverImageSrc: '/cover.png',
|
||||
backgroundPrompt: null,
|
||||
backgroundImageSrc: null,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets: [buildItemAsset()],
|
||||
themeTags: ['森林', '蘑菇'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('Match3D runtime profile maps public detail and promotes item background asset', () => {
|
||||
const backgroundAsset = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/background-from-item.png',
|
||||
imageObjectKey: 'oss/background-from-item.png',
|
||||
});
|
||||
const work = mapPublicWorkDetailToMatch3DWork(
|
||||
buildPublicWork({
|
||||
generatedBackgroundAsset: null,
|
||||
backgroundImageSrc: null,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
backgroundAsset,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(work?.generatedBackgroundAsset).toEqual(backgroundAsset);
|
||||
expect(work?.backgroundImageSrc).toBe(
|
||||
'/generated/match3d/background-from-item.png',
|
||||
);
|
||||
expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png');
|
||||
});
|
||||
|
||||
test('Match3D runtime profile maps work summary to public detail with promoted background asset', () => {
|
||||
const backgroundAsset = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/detail-background.png',
|
||||
});
|
||||
const detail = mapMatch3DWorkToPublicWorkDetail(
|
||||
buildProfile({
|
||||
generatedBackgroundAsset: null,
|
||||
backgroundImageSrc: null,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
backgroundAsset,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
sourceType: 'match3d',
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
backgroundImageSrc: '/generated/match3d/detail-background.png',
|
||||
generatedBackgroundAsset: backgroundAsset,
|
||||
});
|
||||
});
|
||||
|
||||
test('Match3D runtime profile builds draft profile from session snapshot', () => {
|
||||
const backgroundAsset = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/draft-background.png',
|
||||
});
|
||||
const session: Match3DAgentSessionSnapshot = {
|
||||
sessionId: 'match3d-session-draft',
|
||||
currentTurn: 2,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_compiled',
|
||||
anchorPack: {
|
||||
theme: { key: 'theme', label: '主题', value: '森林', status: 'confirmed' },
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '消除数',
|
||||
value: '12',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '4',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
draft: {
|
||||
profileId: 'match3d-draft-profile',
|
||||
gameName: '草稿抓鹅',
|
||||
themeText: '森林',
|
||||
summaryText: '草稿摘要',
|
||||
tags: ['森林'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: '/reference.png',
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publishReady: true,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
backgroundAsset,
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const profile = buildMatch3DProfileFromSession(session);
|
||||
|
||||
expect(profile?.profileId).toBe('match3d-draft-profile');
|
||||
expect(profile?.sourceSessionId).toBe('match3d-session-draft');
|
||||
expect(profile?.publicationStatus).toBe('draft');
|
||||
expect(profile?.coverImageSrc).toBe('/reference.png');
|
||||
expect(profile?.generatedBackgroundAsset).toEqual(backgroundAsset);
|
||||
expect(profile?.backgroundImageSrc).toBe(
|
||||
'/generated/match3d/draft-background.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('Match3D runtime profile selects active profile by run profile id', () => {
|
||||
const runtimeProfile = buildProfile({
|
||||
profileId: 'runtime-profile',
|
||||
gameName: '运行态抓鹅',
|
||||
});
|
||||
const draftProfile = buildProfile({
|
||||
profileId: 'draft-profile',
|
||||
gameName: '旧草稿抓鹅',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveActiveMatch3DRuntimeProfile(
|
||||
buildRun({ profileId: 'runtime-profile' }),
|
||||
runtimeProfile,
|
||||
draftProfile,
|
||||
),
|
||||
).toBe(runtimeProfile);
|
||||
expect(
|
||||
resolveActiveMatch3DRuntimeProfile(
|
||||
buildRun({ profileId: 'draft-profile' }),
|
||||
runtimeProfile,
|
||||
draftProfile,
|
||||
),
|
||||
).toBe(draftProfile);
|
||||
});
|
||||
|
||||
test('Match3D runtime profile resolves generated assets from matching public detail', () => {
|
||||
const staleProfile = buildProfile({
|
||||
profileId: 'stale-profile',
|
||||
generatedBackgroundAsset: buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/stale-background.png',
|
||||
}),
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
itemId: 'stale-item',
|
||||
imageSrc: '/generated/match3d/stale-item.png',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const publicBackground = buildBackgroundAsset({
|
||||
imageSrc: '/generated/match3d/public-background.png',
|
||||
});
|
||||
const publicWork = buildPublicWork({
|
||||
profileId: 'public-profile',
|
||||
generatedBackgroundAsset: publicBackground,
|
||||
generatedItemAssets: [
|
||||
buildItemAsset({
|
||||
itemId: 'public-item',
|
||||
imageSrc: '/generated/match3d/public-item.png',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const run = buildRun({ profileId: 'public-profile' });
|
||||
|
||||
expect(
|
||||
resolveMatch3DRuntimeGeneratedItemAssets(run, staleProfile, publicWork).some(
|
||||
(asset) => asset.imageSrc === '/generated/match3d/public-item.png',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveMatch3DRuntimeGeneratedBackgroundAsset(run, staleProfile, publicWork),
|
||||
).toEqual(publicBackground);
|
||||
expect(resolveMatch3DRuntimeBackgroundImageSrc(run, staleProfile, publicWork)).toBe(
|
||||
'/generated/match3d/public-background.png',
|
||||
);
|
||||
});
|
||||
335
src/components/platform-entry/platformMatch3DRuntimeProfile.ts
Normal file
335
src/components/platform-entry/platformMatch3DRuntimeProfile.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
Match3DWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import {
|
||||
hasMatch3DGeneratedImageAsset,
|
||||
mergeMatch3DGeneratedItemAssetsForRuntime,
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
||||
} from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
isMatch3DGalleryEntry,
|
||||
mapMatch3DWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export function mapMatch3DWorkToPublicWorkDetail(
|
||||
item: Match3DWorkSummary,
|
||||
): PlatformPublicGalleryCard {
|
||||
return mapMatch3DWorkToPlatformGalleryCard(
|
||||
normalizeMatch3DWorkForRuntimeUi(item),
|
||||
);
|
||||
}
|
||||
|
||||
export function mapPublicWorkDetailToMatch3DWork(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): Match3DWorkSummary | null {
|
||||
if (!isMatch3DGalleryEntry(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
workId: entry.workId,
|
||||
profileId: entry.profileId,
|
||||
ownerUserId: entry.ownerUserId,
|
||||
sourceSessionId:
|
||||
'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string'
|
||||
? entry.sourceSessionId
|
||||
: null,
|
||||
gameName: entry.worldName,
|
||||
themeText: entry.themeTags[0] ?? '经典消除',
|
||||
summary: entry.summaryText,
|
||||
tags: entry.themeTags,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: entry.playCount ?? 0,
|
||||
updatedAt: entry.updatedAt,
|
||||
publishedAt: entry.publishedAt,
|
||||
publishReady: true,
|
||||
backgroundPrompt: entry.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: entry.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset:
|
||||
entry.generatedBackgroundAsset ??
|
||||
findMatch3DGeneratedBackgroundAsset(entry.generatedItemAssets) ??
|
||||
null,
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
entry.generatedItemAssets ?? [],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function findMatch3DGeneratedBackgroundAsset(
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
): Match3DGeneratedBackgroundAsset | null {
|
||||
return (
|
||||
generatedItemAssets
|
||||
?.map((asset) => asset.backgroundAsset ?? null)
|
||||
.find(Boolean) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function promoteMatch3DGeneratedBackgroundAsset<
|
||||
T extends Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'backgroundPrompt'
|
||||
| 'backgroundImageSrc'
|
||||
| 'backgroundImageObjectKey'
|
||||
| 'generatedBackgroundAsset'
|
||||
| 'generatedItemAssets'
|
||||
>,
|
||||
>(profile: T): T {
|
||||
const backgroundAsset =
|
||||
profile.generatedBackgroundAsset ??
|
||||
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets);
|
||||
if (!backgroundAsset) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
backgroundPrompt:
|
||||
profile.backgroundPrompt ?? backgroundAsset.prompt ?? null,
|
||||
backgroundImageSrc:
|
||||
profile.backgroundImageSrc ??
|
||||
backgroundAsset.imageSrc ??
|
||||
backgroundAsset.imageObjectKey ??
|
||||
null,
|
||||
backgroundImageObjectKey:
|
||||
profile.backgroundImageObjectKey ??
|
||||
backgroundAsset.imageObjectKey ??
|
||||
backgroundAsset.imageSrc ??
|
||||
null,
|
||||
generatedBackgroundAsset:
|
||||
profile.generatedBackgroundAsset ?? backgroundAsset,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMatch3DWorkForRuntimeUi<T extends Match3DWorkSummary>(
|
||||
profile: T,
|
||||
): T {
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
...profile,
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
profile.generatedItemAssets,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function mapMatch3DWorksForRuntimeUi<T extends Match3DWorkSummary>(
|
||||
profiles: readonly T[],
|
||||
): T[] {
|
||||
return profiles.map(normalizeMatch3DWorkForRuntimeUi);
|
||||
}
|
||||
|
||||
export function buildMatch3DProfileFromSession(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
): Match3DWorkProfile | null {
|
||||
const draft = session?.draft;
|
||||
if (!session || !draft?.profileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = session.updatedAt || new Date().toISOString();
|
||||
const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
draft.generatedItemAssets,
|
||||
);
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
gameName: draft.gameName,
|
||||
themeText: draft.themeText,
|
||||
summary: draft.summary ?? draft.summaryText ?? '',
|
||||
tags: draft.tags,
|
||||
coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null,
|
||||
referenceImageSrc: draft.referenceImageSrc ?? null,
|
||||
clearCount: draft.clearCount,
|
||||
difficulty: draft.difficulty,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
backgroundPrompt: draft.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: draft.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
|
||||
generatedItemAssets,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasMatch3DRuntimeAsset(
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
) {
|
||||
return hasMatch3DGeneratedImageAsset(assets);
|
||||
}
|
||||
|
||||
export function hasMatch3DRuntimeBackgroundAsset(
|
||||
profile: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'backgroundImageSrc'
|
||||
| 'backgroundImageObjectKey'
|
||||
| 'generatedBackgroundAsset'
|
||||
| 'generatedItemAssets'
|
||||
>,
|
||||
) {
|
||||
return Boolean(
|
||||
profile.backgroundImageSrc?.trim() ||
|
||||
profile.backgroundImageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
profile.generatedItemAssets?.some(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.imageSrc?.trim() ||
|
||||
asset.backgroundAsset?.imageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.containerImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileAssets = profile?.generatedItemAssets ?? [];
|
||||
const publicDetailAssets =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? (publicWorkDetail.generatedItemAssets ?? [])
|
||||
: [];
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
if (hasMatch3DRuntimeAsset(profileAssets)) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
if (
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return hasMatch3DRuntimeAsset(publicDetailAssets)
|
||||
? mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
publicDetailAssets,
|
||||
profileAssets,
|
||||
)
|
||||
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets);
|
||||
}
|
||||
|
||||
if (hasMatch3DRuntimeAsset(profileAssets)) {
|
||||
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
return publicDetailAssets.length > 0
|
||||
? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets)
|
||||
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
export function resolveMatch3DRuntimeGeneratedBackgroundAsset(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileBackground = profile
|
||||
? (promoteMatch3DGeneratedBackgroundAsset(profile)
|
||||
.generatedBackgroundAsset ?? null)
|
||||
: null;
|
||||
const publicBackground =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
|
||||
.generatedBackgroundAsset ?? null)
|
||||
: null;
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground ?? publicBackground;
|
||||
}
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicBackground ?? profileBackground;
|
||||
}
|
||||
return profileBackground ?? publicBackground;
|
||||
}
|
||||
|
||||
export function resolveActiveMatch3DRuntimeProfile(
|
||||
run: Match3DRunSnapshot | null,
|
||||
runtimeProfile: Match3DWorkProfile | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
if (runProfileId && runtimeProfile?.profileId === runProfileId) {
|
||||
return runtimeProfile;
|
||||
}
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profile;
|
||||
}
|
||||
return runtimeProfile ?? profile;
|
||||
}
|
||||
|
||||
export function resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const resolvedProfile = profile
|
||||
? promoteMatch3DGeneratedBackgroundAsset(profile)
|
||||
: null;
|
||||
const resolvedPublicWork =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
|
||||
: null;
|
||||
const profileBackground =
|
||||
resolvedProfile?.backgroundImageSrc?.trim() ||
|
||||
resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
resolvedProfile?.backgroundImageObjectKey?.trim() ||
|
||||
resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
const publicBackground =
|
||||
resolvedPublicWork?.backgroundImageSrc?.trim() ||
|
||||
resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
resolvedPublicWork?.backgroundImageObjectKey?.trim() ||
|
||||
resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicBackground || profileBackground || null;
|
||||
}
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleAnchorPack } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
|
||||
import {
|
||||
createFailedMiniGameDraftGenerationStateForRestoredDraft,
|
||||
createMiniGameDraftGenerationStateForRestoredDraft,
|
||||
createPuzzleDraftGenerationStateFromPayload,
|
||||
isMiniGameDraftGenerating,
|
||||
isMiniGameDraftReady,
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState,
|
||||
mergePuzzleSessionProgressIntoGenerationState,
|
||||
rebaseMiniGameDraftBackgroundCompileTaskForDisplay,
|
||||
rebaseMiniGameDraftGenerationStateForDisplay,
|
||||
resolveFinishedMiniGameDraftGenerationState,
|
||||
resolvePuzzlePhaseFromSessionProgress,
|
||||
} from './platformMiniGameDraftGenerationStateModel';
|
||||
|
||||
const NOW = Date.parse('2026-06-04T03:00:00.000Z');
|
||||
const SESSION_UPDATED_AT = '2026-06-01T10:00:00.000Z';
|
||||
const SESSION_UPDATED_AT_MS = Date.parse(SESSION_UPDATED_AT);
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 90,
|
||||
stage: 'draft_ready',
|
||||
anchorPack,
|
||||
draft: {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把星桥碎片拼回原位。',
|
||||
themeTags: ['星桥'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
levels: [],
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: SESSION_UPDATED_AT,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildState(
|
||||
overrides: Partial<MiniGameDraftGenerationState> = {},
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: 100,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
metadata: {
|
||||
puzzleAiRedraw: true,
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: 200,
|
||||
puzzleProgressPercent: 20,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DAsset(
|
||||
overrides: Partial<Match3DGeneratedItemAsset> = {},
|
||||
): Match3DGeneratedItemAsset {
|
||||
return {
|
||||
itemId: 'item-1',
|
||||
itemName: '红宝石',
|
||||
status: 'pending',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('platformMiniGameDraftGenerationStateModel', () => {
|
||||
test('creates restored generation state with metadata and explicit start time', () => {
|
||||
expect(
|
||||
createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
'match3d',
|
||||
{ puzzleAiRedraw: false },
|
||||
123,
|
||||
),
|
||||
).toMatchObject({
|
||||
kind: 'match3d',
|
||||
phase: 'match3d-work-title',
|
||||
startedAtMs: 123,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('creates failed restored state from backend updated time', () => {
|
||||
expect(
|
||||
createFailedMiniGameDraftGenerationStateForRestoredDraft(
|
||||
'puzzle',
|
||||
SESSION_UPDATED_AT,
|
||||
'生成失败',
|
||||
{ puzzleAiRedraw: true },
|
||||
),
|
||||
).toMatchObject({
|
||||
kind: 'puzzle',
|
||||
phase: 'failed',
|
||||
startedAtMs: SESSION_UPDATED_AT_MS,
|
||||
finishedAtMs: NOW,
|
||||
error: '生成失败',
|
||||
metadata: {
|
||||
puzzleAiRedraw: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('rebases finished state for display without changing other fields', () => {
|
||||
const state = buildState({
|
||||
phase: 'ready',
|
||||
finishedAtMs: 300,
|
||||
completedAssetCount: 2,
|
||||
totalAssetCount: 3,
|
||||
});
|
||||
|
||||
expect(rebaseMiniGameDraftGenerationStateForDisplay(state)).toEqual({
|
||||
...state,
|
||||
finishedAtMs: undefined,
|
||||
});
|
||||
expect(
|
||||
rebaseMiniGameDraftBackgroundCompileTaskForDisplay({
|
||||
sessionId: 'task-1',
|
||||
generationState: state,
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: 'task-1',
|
||||
generationState: {
|
||||
...state,
|
||||
finishedAtMs: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('creates puzzle generation state from payload and compiled session', () => {
|
||||
const payload: CreatePuzzleAgentSessionRequest = {
|
||||
seedText: '星桥',
|
||||
aiRedraw: false,
|
||||
};
|
||||
|
||||
expect(createPuzzleDraftGenerationStateFromPayload(payload)).toMatchObject({
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: NOW,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: undefined,
|
||||
puzzleActiveStepStartedAtMs: undefined,
|
||||
puzzleProgressPercent: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
createPuzzleDraftGenerationStateFromPayload(payload, buildPuzzleSession()),
|
||||
).toMatchObject({
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: SESSION_UPDATED_AT_MS,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: NOW,
|
||||
puzzleProgressPercent: 90,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves puzzle phase from backend progress thresholds', () => {
|
||||
const state = buildState();
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 96 }),
|
||||
),
|
||||
).toBe('puzzle-select-image');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 94 }),
|
||||
),
|
||||
).toBe('puzzle-ui-assets');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
buildState({ metadata: { puzzleAiRedraw: false } }),
|
||||
buildPuzzleSession({ progressPercent: 88 }),
|
||||
),
|
||||
).toBe('puzzle-level-scene');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 88 }),
|
||||
),
|
||||
).toBe('puzzle-cover-image');
|
||||
expect(
|
||||
resolvePuzzlePhaseFromSessionProgress(
|
||||
state,
|
||||
buildPuzzleSession({ progressPercent: 20 }),
|
||||
),
|
||||
).toBe('compile');
|
||||
});
|
||||
|
||||
test('merges compiled puzzle session progress into generation state', () => {
|
||||
expect(
|
||||
mergePuzzleSessionProgressIntoGenerationState(
|
||||
buildState({
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: 200,
|
||||
puzzleProgressPercent: 20,
|
||||
},
|
||||
}),
|
||||
buildPuzzleSession({ progressPercent: 90 }),
|
||||
),
|
||||
).toMatchObject({
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
puzzleActivePhaseId: 'puzzle-level-scene',
|
||||
puzzleActiveStepStartedAtMs: SESSION_UPDATED_AT_MS,
|
||||
puzzleProgressPercent: 90,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mergePuzzleSessionProgressIntoGenerationState(
|
||||
buildState(),
|
||||
buildPuzzleSession({
|
||||
draft: {
|
||||
...buildPuzzleSession().draft!,
|
||||
formDraft: {
|
||||
pictureDescription: '星桥',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).metadata,
|
||||
).toMatchObject({
|
||||
puzzleActivePhaseId: 'compile',
|
||||
puzzleActiveStepStartedAtMs: 200,
|
||||
puzzleProgressPercent: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test('merges match3d generated assets into active generation state', () => {
|
||||
const state = buildState({
|
||||
kind: 'match3d',
|
||||
phase: 'match3d-material-sheet',
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: '旧错误',
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(state, [
|
||||
buildMatch3DAsset({
|
||||
itemId: 'item-with-view',
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'front',
|
||||
viewIndex: 0,
|
||||
imageObjectKey: 'objects/front.png',
|
||||
},
|
||||
],
|
||||
}),
|
||||
buildMatch3DAsset({
|
||||
itemId: 'item-with-src',
|
||||
imageSrc: '/generated/item.png',
|
||||
}),
|
||||
buildMatch3DAsset({
|
||||
itemId: 'item-with-error',
|
||||
error: '切图失败',
|
||||
}),
|
||||
]),
|
||||
).toMatchObject({
|
||||
phase: 'match3d-generate-views',
|
||||
completedAssetCount: 2,
|
||||
totalAssetCount: 5,
|
||||
error: '切图失败',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps match3d generated asset merge away from finished states', () => {
|
||||
const readyState = buildState({
|
||||
kind: 'match3d',
|
||||
phase: 'ready',
|
||||
completedAssetCount: 5,
|
||||
totalAssetCount: 5,
|
||||
});
|
||||
const failedState = buildState({
|
||||
kind: 'match3d',
|
||||
phase: 'failed',
|
||||
error: '已失败',
|
||||
});
|
||||
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(readyState, [
|
||||
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
|
||||
]),
|
||||
).toBe(readyState);
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(failedState, [
|
||||
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
|
||||
]),
|
||||
).toBe(failedState);
|
||||
expect(
|
||||
mergeMatch3DGeneratedAssetsIntoGenerationState(null, [
|
||||
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('finishes generation state and resolves ready/generating flags', () => {
|
||||
const failedState = resolveFinishedMiniGameDraftGenerationState(
|
||||
buildState({ error: '旧错误' }),
|
||||
'failed',
|
||||
{
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 2,
|
||||
},
|
||||
);
|
||||
|
||||
expect(failedState).toMatchObject({
|
||||
phase: 'failed',
|
||||
finishedAtMs: NOW,
|
||||
error: '旧错误',
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 2,
|
||||
});
|
||||
expect(isMiniGameDraftReady(failedState)).toBe(false);
|
||||
expect(isMiniGameDraftGenerating(failedState)).toBe(false);
|
||||
expect(isMiniGameDraftReady({ ...failedState, phase: 'ready' })).toBe(true);
|
||||
expect(isMiniGameDraftGenerating(buildState())).toBe(true);
|
||||
expect(isMiniGameDraftGenerating(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
createMiniGameDraftGenerationState,
|
||||
type MiniGameDraftGenerationKind,
|
||||
type MiniGameDraftGenerationPhase,
|
||||
type MiniGameDraftGenerationState,
|
||||
resolveMiniGameDraftGenerationStartedAtMs,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
|
||||
export function createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
metadata?: MiniGameDraftGenerationState['metadata'],
|
||||
startedAtMs = Date.now(),
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...createMiniGameDraftGenerationState(kind, startedAtMs),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFailedMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
updatedAt: string | null | undefined,
|
||||
error: string,
|
||||
metadata?: MiniGameDraftGenerationState['metadata'],
|
||||
): MiniGameDraftGenerationState {
|
||||
return resolveFinishedMiniGameDraftGenerationState(
|
||||
createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind,
|
||||
metadata,
|
||||
resolveMiniGameDraftGenerationStartedAtMs(updatedAt),
|
||||
),
|
||||
'failed',
|
||||
{ error },
|
||||
);
|
||||
}
|
||||
|
||||
/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */
|
||||
export function rebaseMiniGameDraftGenerationStateForDisplay(
|
||||
state: MiniGameDraftGenerationState,
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...state,
|
||||
finishedAtMs: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function rebaseMiniGameDraftBackgroundCompileTaskForDisplay<
|
||||
T extends { generationState: MiniGameDraftGenerationState },
|
||||
>(task: T): T {
|
||||
return {
|
||||
...task,
|
||||
generationState: rebaseMiniGameDraftGenerationStateForDisplay(
|
||||
task.generationState,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPuzzleDraftGenerationStateFromPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null | undefined,
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined = null,
|
||||
): MiniGameDraftGenerationState {
|
||||
const puzzleProgressPercent =
|
||||
session?.draft && !session.draft.formDraft
|
||||
? session.progressPercent
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...createMiniGameDraftGenerationState(
|
||||
'puzzle',
|
||||
resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt),
|
||||
),
|
||||
metadata: {
|
||||
puzzleAiRedraw: payload?.aiRedraw ?? true,
|
||||
puzzleActivePhaseId:
|
||||
typeof puzzleProgressPercent === 'number' ? 'compile' : undefined,
|
||||
puzzleActiveStepStartedAtMs:
|
||||
typeof puzzleProgressPercent === 'number' ? Date.now() : undefined,
|
||||
puzzleProgressPercent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePuzzlePhaseFromSessionProgress(
|
||||
state: MiniGameDraftGenerationState,
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (session.progressPercent >= 96) {
|
||||
return 'puzzle-select-image';
|
||||
}
|
||||
if (session.progressPercent >= 94) {
|
||||
return 'puzzle-ui-assets';
|
||||
}
|
||||
if (session.progressPercent >= 88) {
|
||||
return state.metadata?.puzzleAiRedraw === false
|
||||
? 'puzzle-level-scene'
|
||||
: 'puzzle-cover-image';
|
||||
}
|
||||
|
||||
return 'compile';
|
||||
}
|
||||
|
||||
export function mergePuzzleSessionProgressIntoGenerationState(
|
||||
state: MiniGameDraftGenerationState,
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): MiniGameDraftGenerationState {
|
||||
const isCompiledGenerationSession = Boolean(
|
||||
session.draft && !session.draft.formDraft,
|
||||
);
|
||||
|
||||
const nextPhaseId = isCompiledGenerationSession
|
||||
? resolvePuzzlePhaseFromSessionProgress(state, session)
|
||||
: state.metadata?.puzzleActivePhaseId;
|
||||
const shouldResetActiveStepStart =
|
||||
isCompiledGenerationSession &&
|
||||
nextPhaseId != null &&
|
||||
nextPhaseId !== state.metadata?.puzzleActivePhaseId;
|
||||
|
||||
return {
|
||||
...state,
|
||||
metadata: {
|
||||
...state.metadata,
|
||||
puzzleActivePhaseId: nextPhaseId,
|
||||
puzzleActiveStepStartedAtMs: shouldResetActiveStepStart
|
||||
? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt)
|
||||
: state.metadata?.puzzleActiveStepStartedAtMs,
|
||||
puzzleProgressPercent: isCompiledGenerationSession
|
||||
? session.progressPercent
|
||||
: state.metadata?.puzzleProgressPercent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeMatch3DGeneratedAssetsIntoGenerationState(
|
||||
current: MiniGameDraftGenerationState | null,
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
): MiniGameDraftGenerationState | null {
|
||||
if (!current || current.phase === 'ready' || current.phase === 'failed') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const assetList = assets ?? [];
|
||||
const imageReadyCount = assetList.filter(
|
||||
(asset) =>
|
||||
asset.imageViews?.some(
|
||||
(view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(),
|
||||
) ||
|
||||
asset.imageObjectKey?.trim() ||
|
||||
asset.imageSrc?.trim(),
|
||||
).length;
|
||||
const totalAssetCount = Math.max(5, assetList.length);
|
||||
const failedAsset = assetList.find((asset) => asset.error?.trim());
|
||||
|
||||
return {
|
||||
...current,
|
||||
phase: imageReadyCount > 0 ? 'match3d-generate-views' : current.phase,
|
||||
completedAssetCount: imageReadyCount,
|
||||
totalAssetCount,
|
||||
error: failedAsset?.error?.trim() || current.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveFinishedMiniGameDraftGenerationState(
|
||||
state: MiniGameDraftGenerationState,
|
||||
phase: 'ready' | 'failed',
|
||||
options: {
|
||||
error?: string | null;
|
||||
completedAssetCount?: number;
|
||||
totalAssetCount?: number;
|
||||
} = {},
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...state,
|
||||
phase,
|
||||
finishedAtMs: Date.now(),
|
||||
error: options.error ?? state.error,
|
||||
completedAssetCount:
|
||||
options.completedAssetCount ?? state.completedAssetCount,
|
||||
totalAssetCount: options.totalAssetCount ?? state.totalAssetCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function isMiniGameDraftReady(
|
||||
state: MiniGameDraftGenerationState | null,
|
||||
) {
|
||||
return state?.phase === 'ready';
|
||||
}
|
||||
|
||||
export function isMiniGameDraftGenerating(
|
||||
state: MiniGameDraftGenerationState | null,
|
||||
) {
|
||||
return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed');
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DAnchorPackResponse,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleDraftLevel,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
buildJumpHopDraftActionPayload,
|
||||
buildMatch3DFormPayloadFromSession,
|
||||
buildMatch3DFormPayloadFromWork,
|
||||
buildPendingMatch3DDraftMetadata,
|
||||
buildPendingPuzzleDraftMetadata,
|
||||
buildPuzzleCompileActionFromFormPayload,
|
||||
buildPuzzleFormPayloadFromAction,
|
||||
buildPuzzleFormPayloadFromSession,
|
||||
buildPuzzleFormPayloadFromWork,
|
||||
buildPuzzleWorkUpdatePayloadFromDraft,
|
||||
buildWoodenFishDraftActionPayload,
|
||||
isEmptyPuzzleFormOnlyDraft,
|
||||
isPuzzleFormOnlyDraft,
|
||||
} from './platformMiniGameDraftPayloadModel';
|
||||
|
||||
function buildPuzzleAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleLevel(
|
||||
overrides: Partial<PuzzleDraftLevel> = {},
|
||||
): PuzzleDraftLevel {
|
||||
return {
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '关卡画面描述',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
workTitle: ' 星桥拼图 ',
|
||||
workDescription: ' 修复星桥机关。 ',
|
||||
levelName: '星桥机关',
|
||||
summary: '把碎片拼回原位。',
|
||||
themeTags: ['星桥'],
|
||||
coverImageSrc: '/cover.png',
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
levels: [buildPuzzleLevel()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildPuzzleAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '种子描述',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack,
|
||||
draft: {
|
||||
workTitle: '会话标题',
|
||||
workDescription: '会话描述',
|
||||
levelName: '星桥机关',
|
||||
summary: '会话摘要',
|
||||
themeTags: ['星桥'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle',
|
||||
levels: [buildPuzzleLevel()],
|
||||
formDraft: {
|
||||
workTitle: '表单标题',
|
||||
workDescription: '表单描述',
|
||||
pictureDescription: '表单画面',
|
||||
},
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DAnchorPack(
|
||||
overrides: Partial<Match3DAnchorPackResponse> = {},
|
||||
): Match3DAnchorPackResponse {
|
||||
return {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '海岛玩具',
|
||||
status: 'confirmed',
|
||||
},
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '消除次数',
|
||||
value: '12',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DSession(
|
||||
overrides: Partial<Match3DAgentSessionSnapshot> = {},
|
||||
): Match3DAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'match3d-session-1',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
stage: 'collecting',
|
||||
anchorPack: buildMatch3DAnchorPack(),
|
||||
config: null,
|
||||
draft: null,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
gameName: '海岛抓大鹅',
|
||||
themeText: ' 海岛玩具 ',
|
||||
summary: '收集海岛玩具。',
|
||||
tags: ['海岛'],
|
||||
coverImageSrc: '/match3d-cover.png',
|
||||
referenceImageSrc: '/match3d-reference.png',
|
||||
clearCount: 12,
|
||||
difficulty: 3,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopDraft(
|
||||
overrides: Partial<NonNullable<JumpHopSessionSnapshotResponse['draft']>> = {},
|
||||
): NonNullable<JumpHopSessionSnapshotResponse['draft']> {
|
||||
return {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
themeText: '草稿主题',
|
||||
workTitle: '草稿跳一跳',
|
||||
workDescription: '从草稿恢复。',
|
||||
themeTags: ['草稿'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '草稿角色',
|
||||
tilePrompt: '草稿平台',
|
||||
endMoodPrompt: '草稿终点',
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: null,
|
||||
generationStatus: 'draft',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopPayload(
|
||||
overrides: Partial<JumpHopWorkspaceCreateRequest> = {},
|
||||
): JumpHopWorkspaceCreateRequest {
|
||||
return {
|
||||
templateId: 'jump-hop',
|
||||
themeText: '表单主题',
|
||||
workTitle: '表单跳一跳',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
difficulty: 'advanced',
|
||||
stylePreset: 'neon-glass',
|
||||
characterPrompt: '表单角色',
|
||||
tilePrompt: '表单平台',
|
||||
endMoodPrompt: '表单终点',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishDraft(
|
||||
overrides: Partial<
|
||||
NonNullable<WoodenFishSessionSnapshotResponse['draft']>
|
||||
> = {},
|
||||
): NonNullable<WoodenFishSessionSnapshotResponse['draft']> {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
workTitle: '草稿木鱼',
|
||||
workDescription: '从草稿恢复。',
|
||||
themeTags: ['草稿'],
|
||||
hitObjectPrompt: '草稿敲击物',
|
||||
hitObjectReferenceImageSrc: '/draft-hit-ref.png',
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['草稿 +1'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: null,
|
||||
generationStatus: 'draft',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishPayload(
|
||||
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
|
||||
): WoodenFishWorkspaceCreateRequest {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '表单木鱼',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
hitObjectPrompt: '表单敲击物',
|
||||
hitObjectReferenceImageSrc: '/form-hit-ref.png',
|
||||
hitSoundPrompt: null,
|
||||
hitSoundAsset: null,
|
||||
floatingWords: ['表单 +1'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformMiniGameDraftPayloadModel', () => {
|
||||
test('builds puzzle form payload from work with fallback description priority', () => {
|
||||
expect(
|
||||
buildPuzzleFormPayloadFromWork(
|
||||
buildPuzzleWork({
|
||||
workDescription: ' ',
|
||||
summary: ' 摘要描述 ',
|
||||
levelName: ' 关卡标题 ',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
seedText: '摘要描述',
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '摘要描述',
|
||||
pictureDescription: '摘要描述',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('builds puzzle work update payload from result draft', () => {
|
||||
const draft = buildPuzzleSession().draft!;
|
||||
|
||||
expect(buildPuzzleWorkUpdatePayloadFromDraft(draft)).toEqual({
|
||||
workTitle: '会话标题',
|
||||
workDescription: '会话描述',
|
||||
levelName: '星桥机关',
|
||||
summary: '会话摘要',
|
||||
themeTags: ['星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
levels: [buildPuzzleLevel()],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPuzzleWorkUpdatePayloadFromDraft({
|
||||
...draft,
|
||||
levels: undefined,
|
||||
}).levels,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('builds jump hop draft action payload from payload or draft', () => {
|
||||
expect(
|
||||
buildJumpHopDraftActionPayload('compile-draft', {
|
||||
payload: buildJumpHopPayload(),
|
||||
draft: buildJumpHopDraft(),
|
||||
}),
|
||||
).toEqual({
|
||||
actionType: 'compile-draft',
|
||||
workTitle: '表单跳一跳',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
difficulty: 'advanced',
|
||||
stylePreset: 'neon-glass',
|
||||
characterPrompt: '表单角色',
|
||||
tilePrompt: '表单平台',
|
||||
endMoodPrompt: '表单终点',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildJumpHopDraftActionPayload('regenerate-tiles', {
|
||||
draft: buildJumpHopDraft(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
actionType: 'regenerate-tiles',
|
||||
workTitle: '草稿跳一跳',
|
||||
tilePrompt: '草稿平台',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wooden fish draft action payload from payload or draft', () => {
|
||||
expect(
|
||||
buildWoodenFishDraftActionPayload('compile-draft', {
|
||||
payload: buildWoodenFishPayload(),
|
||||
draft: buildWoodenFishDraft(),
|
||||
}),
|
||||
).toEqual({
|
||||
actionType: 'compile-draft',
|
||||
workTitle: '表单木鱼',
|
||||
workDescription: '从表单提交。',
|
||||
themeTags: ['表单'],
|
||||
hitObjectPrompt: '表单敲击物',
|
||||
hitObjectReferenceImageSrc: '/form-hit-ref.png',
|
||||
hitSoundAsset: null,
|
||||
floatingWords: ['表单 +1'],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishDraftActionPayload('regenerate-hit-object', {
|
||||
draft: buildWoodenFishDraft(),
|
||||
}),
|
||||
).toMatchObject({
|
||||
actionType: 'regenerate-hit-object',
|
||||
workTitle: '草稿木鱼',
|
||||
hitObjectPrompt: '草稿敲击物',
|
||||
floatingWords: ['草稿 +1'],
|
||||
});
|
||||
});
|
||||
|
||||
test('builds puzzle form payload from session form draft and fallbacks', () => {
|
||||
expect(buildPuzzleFormPayloadFromSession(buildPuzzleSession())).toEqual({
|
||||
seedText: '表单画面',
|
||||
workTitle: '表单标题',
|
||||
workDescription: '表单描述',
|
||||
pictureDescription: '表单画面',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPuzzleFormPayloadFromSession(
|
||||
buildPuzzleSession({
|
||||
draft: {
|
||||
...buildPuzzleSession().draft!,
|
||||
formDraft: null,
|
||||
levels: [buildPuzzleLevel({ pictureDescription: '关卡优先' })],
|
||||
},
|
||||
}),
|
||||
).pictureDescription,
|
||||
).toBe('关卡优先');
|
||||
});
|
||||
|
||||
test('resolves puzzle form-only draft state for empty and filled forms', () => {
|
||||
const baseDraft = buildPuzzleSession().draft!;
|
||||
const emptySession = buildPuzzleSession({
|
||||
seedText: ' ',
|
||||
draft: {
|
||||
...baseDraft,
|
||||
formDraft: {
|
||||
workTitle: ' ',
|
||||
workDescription: ' ',
|
||||
pictureDescription: ' ',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(isPuzzleFormOnlyDraft(emptySession)).toBe(true);
|
||||
expect(isEmptyPuzzleFormOnlyDraft(emptySession)).toBe(true);
|
||||
expect(isPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(true);
|
||||
expect(isEmptyPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(false);
|
||||
expect(
|
||||
isPuzzleFormOnlyDraft(buildPuzzleSession({ stage: 'ready_to_publish' })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('builds puzzle compile action and restores form payload from action', () => {
|
||||
const payload: CreatePuzzleAgentSessionRequest = {
|
||||
seedText: '种子',
|
||||
workTitle: ' 标题 ',
|
||||
workDescription: '',
|
||||
pictureDescription: ' 画面 ',
|
||||
referenceImageSrc: '/ref.png',
|
||||
referenceImageSrcs: ['/ref-a.png'],
|
||||
referenceImageAssetObjectId: 'asset-ref',
|
||||
referenceImageAssetObjectIds: ['asset-ref-a'],
|
||||
imageModel: 'image-model',
|
||||
aiRedraw: false,
|
||||
};
|
||||
const action = buildPuzzleCompileActionFromFormPayload(payload);
|
||||
|
||||
expect(action).toEqual({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: '画面',
|
||||
workTitle: '标题',
|
||||
workDescription: '画面',
|
||||
pictureDescription: '画面',
|
||||
referenceImageSrc: '/ref.png',
|
||||
referenceImageSrcs: ['/ref-a.png'],
|
||||
referenceImageAssetObjectId: 'asset-ref',
|
||||
referenceImageAssetObjectIds: ['asset-ref-a'],
|
||||
imageModel: 'image-model',
|
||||
aiRedraw: false,
|
||||
candidateCount: 1,
|
||||
});
|
||||
expect(buildPuzzleFormPayloadFromAction(action)).toEqual({
|
||||
seedText: '画面',
|
||||
workTitle: '标题',
|
||||
workDescription: '画面',
|
||||
pictureDescription: '画面',
|
||||
referenceImageSrc: '/ref.png',
|
||||
referenceImageSrcs: ['/ref-a.png'],
|
||||
referenceImageAssetObjectId: 'asset-ref',
|
||||
referenceImageAssetObjectIds: ['asset-ref-a'],
|
||||
imageModel: 'image-model',
|
||||
aiRedraw: false,
|
||||
});
|
||||
expect(
|
||||
buildPuzzleFormPayloadFromAction({
|
||||
action: 'publish_puzzle_work',
|
||||
} as PuzzleAgentActionRequest),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds pending puzzle metadata from non-empty payload fields', () => {
|
||||
expect(
|
||||
buildPendingPuzzleDraftMetadata({
|
||||
workTitle: ' 标题 ',
|
||||
workDescription: ' ',
|
||||
pictureDescription: ' 画面 ',
|
||||
seedText: '种子',
|
||||
}),
|
||||
).toEqual({
|
||||
title: '标题',
|
||||
summary: '画面',
|
||||
});
|
||||
expect(buildPendingPuzzleDraftMetadata(null)).toEqual({});
|
||||
});
|
||||
|
||||
test('builds match3d form payload from session config, draft and anchors', () => {
|
||||
expect(
|
||||
buildMatch3DFormPayloadFromSession(
|
||||
buildMatch3DSession({
|
||||
config: {
|
||||
themeText: ' 配置主题 ',
|
||||
referenceImageSrc: '/config-ref.png',
|
||||
clearCount: 9,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'style-1',
|
||||
assetStyleLabel: '手办',
|
||||
assetStylePrompt: '软陶手办',
|
||||
generateClickSound: true,
|
||||
},
|
||||
draft: {
|
||||
profileId: 'profile-1',
|
||||
gameName: '草稿标题',
|
||||
themeText: '草稿主题',
|
||||
tags: [],
|
||||
referenceImageSrc: '/draft-ref.png',
|
||||
clearCount: 6,
|
||||
difficulty: 2,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
seedText: '配置主题',
|
||||
themeText: '配置主题',
|
||||
referenceImageSrc: '/config-ref.png',
|
||||
clearCount: 9,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'style-1',
|
||||
assetStyleLabel: '手办',
|
||||
assetStylePrompt: '软陶手办',
|
||||
generateClickSound: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildMatch3DFormPayloadFromSession(
|
||||
buildMatch3DSession({
|
||||
anchorPack: buildMatch3DAnchorPack({
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '消除次数',
|
||||
value: 'not-number',
|
||||
status: 'confirmed',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
seedText: '海岛玩具',
|
||||
clearCount: undefined,
|
||||
difficulty: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('builds match3d form payload from work and pending metadata', () => {
|
||||
expect(
|
||||
buildMatch3DFormPayloadFromWork(
|
||||
buildMatch3DWork({
|
||||
themeText: ' ',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
seedText: '海岛抓大鹅',
|
||||
themeText: '海岛抓大鹅',
|
||||
referenceImageSrc: '/match3d-reference.png',
|
||||
clearCount: 12,
|
||||
difficulty: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPendingMatch3DDraftMetadata({
|
||||
themeText: ' ',
|
||||
seedText: ' 海岛抓大鹅 ',
|
||||
}),
|
||||
).toEqual({
|
||||
title: '海岛抓大鹅',
|
||||
summary: '海岛抓大鹅',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import type {
|
||||
JumpHopActionRequest,
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleDraftLevel,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
WoodenFishActionRequest,
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
|
||||
export type PuzzleWorkUpdatePayload = {
|
||||
workTitle?: string;
|
||||
workDescription?: string;
|
||||
levelName: string;
|
||||
summary: string;
|
||||
themeTags: string[];
|
||||
coverImageSrc?: string | null;
|
||||
coverAssetId?: string | null;
|
||||
levels: PuzzleDraftLevel[];
|
||||
};
|
||||
|
||||
export function buildPuzzleFormPayloadFromWork(
|
||||
item: PuzzleWorkSummary,
|
||||
): CreatePuzzleAgentSessionRequest {
|
||||
const pictureDescription =
|
||||
item.workDescription?.trim() ||
|
||||
item.summary?.trim() ||
|
||||
item.levels?.[0]?.pictureDescription?.trim() ||
|
||||
item.levelName?.trim() ||
|
||||
item.workTitle?.trim() ||
|
||||
'';
|
||||
|
||||
return {
|
||||
seedText: pictureDescription,
|
||||
workTitle: item.workTitle?.trim() || item.levelName?.trim() || undefined,
|
||||
workDescription: item.workDescription?.trim() || item.summary?.trim(),
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleWorkUpdatePayloadFromDraft(
|
||||
draft: PuzzleResultDraft,
|
||||
): PuzzleWorkUpdatePayload {
|
||||
return {
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
levels: draft.levels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopDraftActionPayload(
|
||||
actionType: 'compile-draft' | 'regenerate-tiles',
|
||||
input: {
|
||||
payload?: JumpHopWorkspaceCreateRequest | null;
|
||||
draft?: JumpHopSessionSnapshotResponse['draft'] | null;
|
||||
},
|
||||
): JumpHopActionRequest {
|
||||
const { payload, draft } = input;
|
||||
return {
|
||||
actionType,
|
||||
workTitle: payload?.workTitle ?? draft?.workTitle,
|
||||
workDescription: payload?.workDescription ?? draft?.workDescription,
|
||||
themeTags: payload?.themeTags ?? draft?.themeTags,
|
||||
difficulty: payload?.difficulty ?? draft?.difficulty,
|
||||
stylePreset: payload?.stylePreset ?? draft?.stylePreset,
|
||||
characterPrompt: payload?.characterPrompt ?? draft?.characterPrompt,
|
||||
tilePrompt: payload?.tilePrompt ?? draft?.tilePrompt,
|
||||
endMoodPrompt: payload?.endMoodPrompt ?? draft?.endMoodPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishDraftActionPayload(
|
||||
actionType: 'compile-draft' | 'regenerate-hit-object',
|
||||
input: {
|
||||
payload?: WoodenFishWorkspaceCreateRequest | null;
|
||||
draft?: WoodenFishSessionSnapshotResponse['draft'] | null;
|
||||
},
|
||||
): WoodenFishActionRequest {
|
||||
const { payload, draft } = input;
|
||||
return {
|
||||
actionType,
|
||||
workTitle: payload?.workTitle ?? draft?.workTitle,
|
||||
workDescription: payload?.workDescription ?? draft?.workDescription,
|
||||
themeTags: payload?.themeTags ?? draft?.themeTags,
|
||||
hitObjectPrompt: payload?.hitObjectPrompt ?? draft?.hitObjectPrompt,
|
||||
hitObjectReferenceImageSrc:
|
||||
payload?.hitObjectReferenceImageSrc ??
|
||||
draft?.hitObjectReferenceImageSrc,
|
||||
hitSoundAsset: payload?.hitSoundAsset ?? draft?.hitSoundAsset,
|
||||
floatingWords: payload?.floatingWords ?? draft?.floatingWords,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalFiniteNumber(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
const normalizedValue = value?.trim();
|
||||
if (!normalizedValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsedValue = Number(normalizedValue);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : undefined;
|
||||
}
|
||||
|
||||
export function buildMatch3DFormPayloadFromSession(
|
||||
session: Match3DAgentSessionSnapshot,
|
||||
): CreateMatch3DSessionRequest {
|
||||
const themeText =
|
||||
session.config?.themeText?.trim() ||
|
||||
session.draft?.themeText?.trim() ||
|
||||
session.anchorPack.theme.value.trim() ||
|
||||
'';
|
||||
|
||||
return {
|
||||
seedText: themeText,
|
||||
themeText,
|
||||
referenceImageSrc:
|
||||
session.config?.referenceImageSrc ??
|
||||
session.draft?.referenceImageSrc ??
|
||||
null,
|
||||
clearCount:
|
||||
session.config?.clearCount ??
|
||||
session.draft?.clearCount ??
|
||||
parseOptionalFiniteNumber(session.anchorPack.clearCount.value) ??
|
||||
undefined,
|
||||
difficulty:
|
||||
session.config?.difficulty ??
|
||||
session.draft?.difficulty ??
|
||||
parseOptionalFiniteNumber(session.anchorPack.difficulty.value) ??
|
||||
undefined,
|
||||
assetStyleId: session.config?.assetStyleId ?? null,
|
||||
assetStyleLabel: session.config?.assetStyleLabel ?? null,
|
||||
assetStylePrompt: session.config?.assetStylePrompt ?? null,
|
||||
generateClickSound: session.config?.generateClickSound,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatch3DFormPayloadFromWork(
|
||||
item: Match3DWorkSummary,
|
||||
): CreateMatch3DSessionRequest {
|
||||
const themeText = item.themeText?.trim() || item.gameName?.trim() || '';
|
||||
return {
|
||||
seedText: themeText,
|
||||
themeText,
|
||||
referenceImageSrc: item.referenceImageSrc ?? null,
|
||||
clearCount: item.clearCount,
|
||||
difficulty: item.difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleCompileActionFromFormPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null,
|
||||
): PuzzleAgentActionRequest {
|
||||
const pictureDescription =
|
||||
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
|
||||
const workTitle = payload?.workTitle?.trim();
|
||||
const workDescription = payload?.workDescription?.trim() || pictureDescription;
|
||||
|
||||
return {
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
...(pictureDescription ? { pictureDescription } : {}),
|
||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||
referenceImageSrcs: payload?.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [],
|
||||
imageModel: payload?.imageModel ?? null,
|
||||
aiRedraw: payload?.aiRedraw ?? true,
|
||||
candidateCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleFormPayloadFromSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): CreatePuzzleAgentSessionRequest {
|
||||
const formDraft = session.draft?.formDraft;
|
||||
const pictureDescription =
|
||||
formDraft?.pictureDescription?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
|
||||
session.anchorPack.visualSubject.value.trim() ||
|
||||
session.seedText?.trim() ||
|
||||
'';
|
||||
const workTitle =
|
||||
formDraft?.workTitle?.trim() || session.draft?.workTitle?.trim();
|
||||
const workDescription =
|
||||
formDraft?.workDescription?.trim() ||
|
||||
session.draft?.workDescription?.trim() ||
|
||||
session.draft?.summary?.trim() ||
|
||||
pictureDescription;
|
||||
|
||||
return {
|
||||
seedText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPuzzleFormOnlyDraft(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
return Boolean(
|
||||
session?.stage === 'collecting_anchors' && session.draft?.formDraft,
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmptyPuzzleFormOnlyDraft(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
if (!isPuzzleFormOnlyDraft(session)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const formDraft = session?.draft?.formDraft;
|
||||
return !(
|
||||
session?.seedText?.trim() ||
|
||||
formDraft?.workTitle?.trim() ||
|
||||
formDraft?.workDescription?.trim() ||
|
||||
formDraft?.pictureDescription?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPendingPuzzleDraftMetadata(
|
||||
payload: CreatePuzzleAgentSessionRequest | null | undefined,
|
||||
) {
|
||||
const title = payload?.workTitle?.trim();
|
||||
const summary =
|
||||
payload?.workDescription?.trim() ||
|
||||
payload?.pictureDescription?.trim() ||
|
||||
payload?.seedText?.trim();
|
||||
return {
|
||||
...(title ? { title } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPendingMatch3DDraftMetadata(
|
||||
payload: CreateMatch3DSessionRequest | null | undefined,
|
||||
) {
|
||||
const themeText = payload?.themeText?.trim() || payload?.seedText?.trim();
|
||||
return {
|
||||
...(themeText ? { title: themeText, summary: themeText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleFormPayloadFromAction(
|
||||
payload: PuzzleAgentActionRequest,
|
||||
): CreatePuzzleAgentSessionRequest | null {
|
||||
if (
|
||||
payload.action !== 'compile_puzzle_draft' &&
|
||||
payload.action !== 'save_puzzle_form_draft'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workTitle = payload.workTitle?.trim() ?? '';
|
||||
const workDescription = payload.workDescription?.trim() ?? '';
|
||||
const pictureDescription =
|
||||
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
|
||||
|
||||
return {
|
||||
seedText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
pictureDescription,
|
||||
referenceImageSrc:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.referenceImageSrc ?? null)
|
||||
: (payload.referenceImageSrc ?? null),
|
||||
referenceImageSrcs: payload.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [],
|
||||
imageModel:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.imageModel ?? null)
|
||||
: (payload.imageModel ?? null),
|
||||
aiRedraw:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.aiRedraw ?? true)
|
||||
: (payload.aiRedraw ?? true),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
SquareHoleResultDraft,
|
||||
SquareHoleSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type {
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelWorkDetail,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
WoodenFishAudioAsset,
|
||||
WoodenFishImageAsset,
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
buildJumpHopPendingSession,
|
||||
buildPuzzleRuntimeWorkFromSession,
|
||||
buildSquareHoleProfileFromSession,
|
||||
buildVisualNovelSessionFromWorkDetail,
|
||||
buildWoodenFishGeneratingWorkSummary,
|
||||
buildWoodenFishPendingSession,
|
||||
buildWoodenFishSessionFromWorkDetail,
|
||||
} from './platformMiniGameSessionMappingModel';
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleDraft(
|
||||
overrides: Partial<PuzzleResultDraft> = {},
|
||||
): PuzzleResultDraft {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把星桥碎片拼回原位。',
|
||||
themeTags: ['星桥'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/puzzle-cover.png',
|
||||
coverAssetId: 'asset-cover',
|
||||
generationStatus: 'ready',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '星桥',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/puzzle-level-cover.png',
|
||||
coverAssetId: 'asset-level-cover',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const draft = buildPuzzleDraft();
|
||||
return {
|
||||
sessionId: 'puzzle-session-12345678',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack: draft.anchorPack,
|
||||
draft,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: {
|
||||
draft,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
publishReady: true,
|
||||
},
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopSummary(
|
||||
overrides: Partial<JumpHopWorkSummaryResponse> = {},
|
||||
): JumpHopWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-1',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: ' jump-hop-session-1 ',
|
||||
themeText: '云阶机关',
|
||||
workTitle: '云阶跳跃',
|
||||
workDescription: '越过云阶。',
|
||||
themeTags: ['云阶'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: '/jump-hop-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleDraft(
|
||||
overrides: Partial<SquareHoleResultDraft> = {},
|
||||
): SquareHoleResultDraft {
|
||||
return {
|
||||
profileId: 'square-hole-profile-1',
|
||||
gameName: '星桥方洞',
|
||||
themeText: '星桥机关',
|
||||
twistRule: '只允许相同颜色形状入洞',
|
||||
summary: '把星桥机关里的形状送入正确孔洞。',
|
||||
tags: ['星桥', '机关'],
|
||||
coverImageSrc: '/square-hole-cover.png',
|
||||
backgroundPrompt: '星桥机关背景',
|
||||
backgroundImageSrc: '/square-hole-background.png',
|
||||
shapeOptions: [
|
||||
{
|
||||
optionId: 'shape-1',
|
||||
shapeKind: 'star',
|
||||
label: '星形',
|
||||
targetHoleId: 'hole-1',
|
||||
imagePrompt: '星形积木',
|
||||
imageSrc: '/shape-star.png',
|
||||
},
|
||||
],
|
||||
holeOptions: [
|
||||
{
|
||||
holeId: 'hole-1',
|
||||
holeKind: 'star-hole',
|
||||
label: '星形洞',
|
||||
imagePrompt: '星形洞口',
|
||||
imageSrc: '/hole-star.png',
|
||||
},
|
||||
],
|
||||
shapeCount: 6,
|
||||
difficulty: 3,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleSession(
|
||||
overrides: Partial<SquareHoleSessionSnapshot> = {},
|
||||
): SquareHoleSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'square-hole-session-1',
|
||||
currentTurn: 2,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_ready',
|
||||
anchorPack: {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed',
|
||||
},
|
||||
twistRule: {
|
||||
key: 'twistRule',
|
||||
label: '扭转规则',
|
||||
value: '只允许相同颜色形状入洞',
|
||||
status: 'confirmed',
|
||||
},
|
||||
shapeCount: {
|
||||
key: 'shapeCount',
|
||||
label: '形状数量',
|
||||
value: '6',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
themeText: '星桥机关',
|
||||
twistRule: '只允许相同颜色形状入洞',
|
||||
shapeCount: 6,
|
||||
difficulty: 3,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
backgroundPrompt: '星桥机关背景',
|
||||
coverImageSrc: null,
|
||||
backgroundImageSrc: null,
|
||||
},
|
||||
draft: buildSquareHoleDraft(),
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-06-01T12:30:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelDraft(
|
||||
overrides: Partial<VisualNovelResultDraft> = {},
|
||||
): VisualNovelResultDraft {
|
||||
return {
|
||||
profileId: 'visual-novel-profile-1',
|
||||
workTitle: '雪线电台',
|
||||
workDescription: '旧电台牵出雪夜列车谜案。',
|
||||
workTags: ['雪夜', '电台'],
|
||||
coverImageSrc: '/visual-novel-cover.png',
|
||||
sourceMode: 'idea',
|
||||
sourceAssetIds: ['asset-source-1'],
|
||||
world: {
|
||||
title: '北境终点线',
|
||||
summary: '边境小城与旧电台。',
|
||||
background: '十二年前的雪崩留下夜间广播。',
|
||||
premise: '玩家需要在日出前找出列车停摆的原因。',
|
||||
literaryStyle: '克制冷光感。',
|
||||
playerRole: '临时广播员',
|
||||
defaultTone: '安静紧张',
|
||||
},
|
||||
characters: [
|
||||
{
|
||||
characterId: 'vn-char-1',
|
||||
name: '林遥',
|
||||
gender: '女',
|
||||
role: 'main',
|
||||
appearance: '灰色长外套。',
|
||||
personality: '谨慎敏锐。',
|
||||
tone: '短句多。',
|
||||
background: '旧电台夜班实习生。',
|
||||
relationshipToPlayer: '临时搭档',
|
||||
imageAssets: [],
|
||||
defaultExpression: 'calm',
|
||||
isPlayerVisible: false,
|
||||
},
|
||||
],
|
||||
scenes: [
|
||||
{
|
||||
sceneId: 'vn-scene-1',
|
||||
name: '风雪站台',
|
||||
description: '站灯忽明忽暗。',
|
||||
backgroundImageSrc: null,
|
||||
musicSrc: null,
|
||||
ambientSoundSrc: null,
|
||||
availability: 'opening',
|
||||
phaseIds: ['vn-phase-1'],
|
||||
},
|
||||
],
|
||||
storyPhases: [
|
||||
{
|
||||
phaseId: 'vn-phase-1',
|
||||
title: '重启站台',
|
||||
goal: '确认列车为何停在废弃站台。',
|
||||
summary: '玩家抵达风雪站台。',
|
||||
entryCondition: '开场进入',
|
||||
exitCondition: '找到车长日志',
|
||||
sceneIds: ['vn-scene-1'],
|
||||
characterIds: ['vn-char-1'],
|
||||
suggestedChoices: ['检查广播柜'],
|
||||
},
|
||||
],
|
||||
opening: {
|
||||
sceneId: 'vn-scene-1',
|
||||
narration: '雪落得很慢。',
|
||||
speakerCharacterId: 'vn-char-1',
|
||||
firstDialogue: '你听见了吗?',
|
||||
initialChoices: [
|
||||
{
|
||||
choiceId: 'vn-choice-1',
|
||||
text: '靠近广播柜。',
|
||||
actionHint: 'inspect_radio',
|
||||
},
|
||||
],
|
||||
},
|
||||
runtimeConfig: {
|
||||
textModeEnabled: true,
|
||||
defaultTextMode: false,
|
||||
maxHistoryEntries: 80,
|
||||
maxAssistantStepCountPerTurn: 8,
|
||||
allowFreeTextAction: true,
|
||||
allowHistoryRegeneration: true,
|
||||
attributePanelMode: 'template_config',
|
||||
saveArchiveEnabled: true,
|
||||
},
|
||||
publishReady: true,
|
||||
validationIssues: [],
|
||||
updatedAt: '2026-06-01T13:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWorkDetail(
|
||||
overrides: Partial<VisualNovelWorkDetail> = {},
|
||||
): VisualNovelWorkDetail {
|
||||
const draft = buildVisualNovelDraft();
|
||||
return {
|
||||
workId: 'visual-novel-work-1',
|
||||
summary: {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile-1',
|
||||
ownerUserId: 'user-visual-novel-1',
|
||||
title: draft.workTitle,
|
||||
description: draft.workDescription,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
tags: draft.workTags,
|
||||
publishStatus: 'draft',
|
||||
publishReady: draft.publishReady,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T13:30:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
sourceSessionId: ' visual-novel-session-1 ',
|
||||
authorDisplayName: '视觉小说作者',
|
||||
sourceAssetIds: draft.sourceAssetIds,
|
||||
draft,
|
||||
createdAt: '2026-06-01T12:50:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const woodenFishImageAsset: WoodenFishImageAsset = {
|
||||
assetId: 'asset-hit',
|
||||
imageSrc: '/hit.png',
|
||||
imageObjectKey: 'hit.png',
|
||||
assetObjectId: 'asset-object-hit',
|
||||
generationProvider: 'test',
|
||||
prompt: '木鱼',
|
||||
width: 512,
|
||||
height: 512,
|
||||
};
|
||||
|
||||
const woodenFishAudioAsset: WoodenFishAudioAsset = {
|
||||
assetId: 'asset-sound',
|
||||
audioSrc: '/hit.mp3',
|
||||
audioObjectKey: 'hit.mp3',
|
||||
assetObjectId: 'asset-object-sound',
|
||||
source: 'test',
|
||||
};
|
||||
|
||||
function buildWoodenFishSummary(
|
||||
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
|
||||
): WoodenFishWorkSummaryResponse {
|
||||
return {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: ' wooden-fish-session-1 ',
|
||||
workTitle: '星灯木鱼',
|
||||
workDescription: '敲亮星灯。',
|
||||
themeTags: ['星灯'],
|
||||
coverImageSrc: '/wooden-fish-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishWorkProfile(
|
||||
overrides: Partial<WoodenFishWorkProfileResponse> = {},
|
||||
): WoodenFishWorkProfileResponse {
|
||||
const summary = buildWoodenFishSummary();
|
||||
const draft = {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: summary.profileId,
|
||||
workTitle: summary.workTitle,
|
||||
workDescription: summary.workDescription,
|
||||
themeTags: summary.themeTags,
|
||||
hitObjectPrompt: '星灯',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
hitObjectAsset: woodenFishImageAsset,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: woodenFishAudioAsset,
|
||||
coverImageSrc: summary.coverImageSrc,
|
||||
generationStatus: summary.generationStatus,
|
||||
};
|
||||
return {
|
||||
summary,
|
||||
draft,
|
||||
hitObjectAsset: woodenFishImageAsset,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: woodenFishAudioAsset,
|
||||
floatingWords: ['功德 +1'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishSession(
|
||||
overrides: Partial<WoodenFishSessionSnapshotResponse> = {},
|
||||
): WoodenFishSessionSnapshotResponse {
|
||||
const summary = buildWoodenFishSummary();
|
||||
return {
|
||||
sessionId: 'wooden-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: buildWoodenFishWorkProfile({ summary }).draft,
|
||||
createdAt: '2026-06-01T11:59:00.000Z',
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishCreatePayload(
|
||||
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
|
||||
): WoodenFishWorkspaceCreateRequest {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '表单星灯木鱼',
|
||||
workDescription: '表单里敲亮星灯。',
|
||||
themeTags: ['表单星灯'],
|
||||
hitObjectPrompt: '星灯',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformMiniGameSessionMappingModel', () => {
|
||||
test('builds a draft puzzle runtime work from a session', () => {
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(buildPuzzleSession(), {
|
||||
userId: 'user-1',
|
||||
displayName: '玩家一号',
|
||||
}),
|
||||
).toMatchObject({
|
||||
workId: 'puzzle-work-12345678',
|
||||
profileId: 'puzzle-profile-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-12345678',
|
||||
authorDisplayName: '玩家一号',
|
||||
workTitle: '星桥拼图',
|
||||
coverImageSrc: '/puzzle-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('prefers published puzzle profile id when present', () => {
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(
|
||||
buildPuzzleSession({
|
||||
publishedProfileId: 'published-puzzle-profile',
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
profileId: 'published-puzzle-profile',
|
||||
workId: 'puzzle-work-12345678',
|
||||
ownerUserId: 'current-user',
|
||||
authorDisplayName: '玩家',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for puzzle runtime work without draft or cover', () => {
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(
|
||||
buildPuzzleSession({
|
||||
draft: null,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildPuzzleRuntimeWorkFromSession(
|
||||
buildPuzzleSession({
|
||||
draft: buildPuzzleDraft({ coverImageSrc: ' ' }),
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds jump hop pending session from work summary', () => {
|
||||
expect(buildJumpHopPendingSession(buildJumpHopSummary())).toEqual({
|
||||
sessionId: 'jump-hop-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
themeText: '云阶机关',
|
||||
workTitle: '云阶跳跃',
|
||||
workDescription: '越过云阶。',
|
||||
themeTags: ['云阶'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: '/jump-hop-cover.png',
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
createdAt: '2026-06-01T11:00:00.000Z',
|
||||
updatedAt: '2026-06-01T11:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds square hole draft profile from session', () => {
|
||||
expect(buildSquareHoleProfileFromSession(buildSquareHoleSession())).toEqual({
|
||||
workId: 'square-hole-profile-1',
|
||||
profileId: 'square-hole-profile-1',
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: 'square-hole-session-1',
|
||||
gameName: '星桥方洞',
|
||||
themeText: '星桥机关',
|
||||
twistRule: '只允许相同颜色形状入洞',
|
||||
summary: '把星桥机关里的形状送入正确孔洞。',
|
||||
tags: ['星桥', '机关'],
|
||||
coverImageSrc: '/square-hole-cover.png',
|
||||
backgroundPrompt: '星桥机关背景',
|
||||
backgroundImageSrc: '/square-hole-background.png',
|
||||
shapeOptions: buildSquareHoleDraft().shapeOptions,
|
||||
holeOptions: buildSquareHoleDraft().holeOptions,
|
||||
shapeCount: 6,
|
||||
difficulty: 3,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T12:30:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for square hole profile without session draft or profile id', () => {
|
||||
expect(buildSquareHoleProfileFromSession(null)).toBeNull();
|
||||
expect(
|
||||
buildSquareHoleProfileFromSession(
|
||||
buildSquareHoleSession({
|
||||
draft: null,
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildSquareHoleProfileFromSession(
|
||||
buildSquareHoleSession({
|
||||
draft: buildSquareHoleDraft({ profileId: '' }),
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('builds visual novel recovered session from work detail', () => {
|
||||
const work = buildVisualNovelWorkDetail();
|
||||
|
||||
expect(buildVisualNovelSessionFromWorkDetail(work)).toEqual({
|
||||
sessionId: 'visual-novel-session-1',
|
||||
ownerUserId: 'user-visual-novel-1',
|
||||
sourceMode: 'idea',
|
||||
status: 'ready',
|
||||
messages: [],
|
||||
draft: work.draft,
|
||||
pendingAction: null,
|
||||
createdAt: '2026-06-01T12:50:00.000Z',
|
||||
updatedAt: '2026-06-01T13:30:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back visual novel recovered session id to work id', () => {
|
||||
expect(
|
||||
buildVisualNovelSessionFromWorkDetail(
|
||||
buildVisualNovelWorkDetail({
|
||||
sourceSessionId: ' ',
|
||||
workId: 'visual-novel-work-fallback',
|
||||
}),
|
||||
).sessionId,
|
||||
).toBe('visual-novel-work-fallback');
|
||||
});
|
||||
|
||||
test('builds wooden fish pending session from work summary', () => {
|
||||
expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({
|
||||
sessionId: 'wooden-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
workTitle: '星灯木鱼',
|
||||
workDescription: '敲亮星灯。',
|
||||
themeTags: ['星灯'],
|
||||
hitObjectPrompt: '',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: '/wooden-fish-cover.png',
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
createdAt: '2026-06-01T12:00:00.000Z',
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wooden fish generating work summary from session and payload', () => {
|
||||
expect(
|
||||
buildWoodenFishGeneratingWorkSummary(
|
||||
buildWoodenFishSession(),
|
||||
buildWoodenFishCreatePayload(),
|
||||
),
|
||||
).toEqual({
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-session-1',
|
||||
profileId: 'wooden-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-1',
|
||||
workTitle: '表单星灯木鱼',
|
||||
workDescription: '表单里敲亮星灯。',
|
||||
themeTags: ['表单星灯'],
|
||||
coverImageSrc: '/wooden-fish-cover.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishGeneratingWorkSummary(
|
||||
buildWoodenFishSession({
|
||||
draft: null,
|
||||
createdAt: '2026-06-01T11:59:00.000Z',
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toMatchObject({
|
||||
workTitle: '敲木鱼',
|
||||
workDescription: '',
|
||||
themeTags: ['敲木鱼'],
|
||||
coverImageSrc: null,
|
||||
updatedAt: '2026-06-01T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds wooden fish recovered session with summary, fallback and profile id priority', () => {
|
||||
expect(
|
||||
buildWoodenFishSessionFromWorkDetail(
|
||||
buildWoodenFishWorkProfile({
|
||||
summary: buildWoodenFishSummary({
|
||||
sourceSessionId: null,
|
||||
}),
|
||||
}),
|
||||
buildWoodenFishSummary({
|
||||
sourceSessionId: ' fallback-session ',
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
sessionId: 'fallback-session',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWoodenFishSessionFromWorkDetail(
|
||||
buildWoodenFishWorkProfile({
|
||||
summary: buildWoodenFishSummary({
|
||||
sourceSessionId: null,
|
||||
}),
|
||||
}),
|
||||
null,
|
||||
).sessionId,
|
||||
).toBe('wooden-fish-profile-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import type {
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
PuzzleClearSessionSnapshotResponse,
|
||||
PuzzleClearWorkProfileResponse,
|
||||
PuzzleClearWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type {
|
||||
VisualNovelAgentSessionSnapshot,
|
||||
VisualNovelWorkDetail,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { normalizeCreationUrlValue } from './platformCreationUrlStateModel';
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
|
||||
export type PlatformMiniGameSessionOwner = {
|
||||
userId?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
export function buildPuzzleRuntimeWorkFromSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
owner: PlatformMiniGameSessionOwner,
|
||||
): PuzzleWorkSummary | null {
|
||||
const draft = session.draft;
|
||||
const profileId =
|
||||
session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId);
|
||||
if (!draft || !profileId || !draft.coverImageSrc?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId,
|
||||
profileId,
|
||||
ownerUserId: owner.userId ?? 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
authorDisplayName: owner.displayName ?? '玩家',
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: session.updatedAt,
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: Boolean(session.resultPreview?.publishReady),
|
||||
levels: draft.levels,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleClearSessionFromWorkDetail(
|
||||
work: PuzzleClearWorkProfileResponse,
|
||||
fallbackItem?: PuzzleClearWorkSummaryResponse | null,
|
||||
): PuzzleClearSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(work.summary.sourceSessionId) ??
|
||||
normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ??
|
||||
work.summary.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: work.summary.ownerUserId,
|
||||
status: work.summary.generationStatus,
|
||||
draft: work.draft,
|
||||
createdAt: work.summary.updatedAt,
|
||||
updatedAt: work.summary.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleClearPendingSession(
|
||||
item: PuzzleClearWorkSummaryResponse,
|
||||
): PuzzleClearSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: item.ownerUserId,
|
||||
status: item.generationStatus,
|
||||
draft: {
|
||||
templateId: 'puzzle-clear',
|
||||
templateName: '拼消消',
|
||||
profileId: item.profileId,
|
||||
workTitle: item.workTitle,
|
||||
workDescription: item.workDescription,
|
||||
themePrompt: item.themePrompt,
|
||||
boardBackgroundPrompt: item.themePrompt,
|
||||
generateBoardBackground: true,
|
||||
boardBackgroundAsset: null,
|
||||
cardBackImageSrc: null,
|
||||
atlasAsset: null,
|
||||
patternGroups: [],
|
||||
cardAssets: [],
|
||||
generationStatus: item.generationStatus,
|
||||
},
|
||||
createdAt: item.updatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSquareHoleProfileFromSession(
|
||||
session: SquareHoleSessionSnapshot | null,
|
||||
): SquareHoleWorkProfile | null {
|
||||
const draft = session?.draft;
|
||||
if (!session || !draft?.profileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = session.updatedAt || new Date().toISOString();
|
||||
return {
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
gameName: draft.gameName,
|
||||
themeText: draft.themeText,
|
||||
twistRule: draft.twistRule,
|
||||
summary: draft.summary,
|
||||
tags: draft.tags,
|
||||
coverImageSrc: draft.coverImageSrc ?? null,
|
||||
backgroundPrompt: draft.backgroundPrompt,
|
||||
backgroundImageSrc: draft.backgroundImageSrc ?? null,
|
||||
shapeOptions: draft.shapeOptions,
|
||||
holeOptions: draft.holeOptions,
|
||||
shapeCount: draft.shapeCount,
|
||||
difficulty: draft.difficulty,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVisualNovelSessionFromWorkDetail(
|
||||
work: VisualNovelWorkDetail,
|
||||
): VisualNovelAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: normalizeCreationUrlValue(work.sourceSessionId) ?? work.workId,
|
||||
ownerUserId: work.summary.ownerUserId,
|
||||
sourceMode: work.draft.sourceMode,
|
||||
status: 'ready',
|
||||
messages: [],
|
||||
draft: work.draft,
|
||||
pendingAction: null,
|
||||
createdAt: work.createdAt,
|
||||
updatedAt: work.summary.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopPendingSession(
|
||||
item: JumpHopWorkSummaryResponse,
|
||||
): JumpHopSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: item.ownerUserId,
|
||||
status: item.generationStatus,
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: item.profileId,
|
||||
themeText: item.themeText,
|
||||
workTitle: item.workTitle,
|
||||
workDescription: item.workDescription,
|
||||
themeTags: item.themeTags,
|
||||
difficulty: item.difficulty,
|
||||
stylePreset: item.stylePreset,
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: item.coverImageSrc,
|
||||
generationStatus: item.generationStatus,
|
||||
},
|
||||
createdAt: item.updatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishSessionFromWorkDetail(
|
||||
work: WoodenFishWorkProfileResponse,
|
||||
fallbackItem?: WoodenFishWorkSummaryResponse | null,
|
||||
): WoodenFishSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(work.summary.sourceSessionId) ??
|
||||
normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ??
|
||||
work.summary.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: work.summary.ownerUserId,
|
||||
status: work.summary.generationStatus,
|
||||
draft: work.draft,
|
||||
createdAt: work.summary.updatedAt,
|
||||
updatedAt: work.summary.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishGeneratingWorkSummary(
|
||||
session: WoodenFishSessionSnapshotResponse,
|
||||
payload?: WoodenFishWorkspaceCreateRequest | null,
|
||||
): WoodenFishWorkSummaryResponse {
|
||||
const updatedAt = session.updatedAt ?? session.createdAt;
|
||||
return {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: session.sessionId,
|
||||
profileId: session.sessionId,
|
||||
ownerUserId: session.ownerUserId,
|
||||
sourceSessionId: session.sessionId,
|
||||
workTitle: payload?.workTitle ?? session.draft?.workTitle ?? '敲木鱼',
|
||||
workDescription:
|
||||
payload?.workDescription ?? session.draft?.workDescription ?? '',
|
||||
themeTags: payload?.themeTags ?? session.draft?.themeTags ?? ['敲木鱼'],
|
||||
coverImageSrc: session.draft?.coverImageSrc ?? null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt,
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWoodenFishPendingSession(
|
||||
item: WoodenFishWorkSummaryResponse,
|
||||
): WoodenFishSessionSnapshotResponse {
|
||||
const sessionId =
|
||||
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
|
||||
return {
|
||||
sessionId,
|
||||
ownerUserId: item.ownerUserId,
|
||||
status: item.generationStatus,
|
||||
draft: {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: item.profileId,
|
||||
workTitle: item.workTitle,
|
||||
workDescription: item.workDescription,
|
||||
themeTags: item.themeTags,
|
||||
hitObjectPrompt: '',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['功德 +1'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: item.coverImageSrc,
|
||||
generationStatus: item.generationStatus,
|
||||
},
|
||||
createdAt: item.updatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { ProfilePlayedWorkSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
|
||||
|
||||
function buildPlayedWork(
|
||||
overrides: Partial<ProfilePlayedWorkSummary> = {},
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: 'custom:world-1',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-1',
|
||||
worldType: 'CUSTOM',
|
||||
worldTitle: '潮雾列岛',
|
||||
worldSubtitle: '旧灯塔与失控航路',
|
||||
firstPlayedAt: '2026-04-18T12:00:00.000Z',
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
lastObservedPlayTimeMs: 12_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformPlayedWorkOpenModel', () => {
|
||||
test('opens puzzle played works with profile tab context', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType: 'PUZZLE',
|
||||
profileId: 'puzzle-profile-1',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-puzzle',
|
||||
profileId: 'puzzle-profile-1',
|
||||
tab: 'profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to worldKey prefixes when profile id is absent', () => {
|
||||
const cases = [
|
||||
['puzzle:profile-1', 'open-puzzle', 'profile-1'],
|
||||
['match3d:profile-2', 'open-match3d', 'profile-2'],
|
||||
['square-hole:profile-3', 'open-square-hole', 'profile-3'],
|
||||
['jump-hop:profile-4', 'open-jump-hop', 'profile-4'],
|
||||
['wooden-fish:profile-5', 'open-wooden-fish', 'profile-5'],
|
||||
] as const;
|
||||
|
||||
for (const [worldKey, type, profileId] of cases) {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey,
|
||||
profileId: null,
|
||||
worldType: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ type, profileId });
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps explicit profile id ahead of worldKey fallback', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'jump-hop:key-profile',
|
||||
profileId: 'explicit-profile',
|
||||
worldType: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
type: 'open-jump-hop',
|
||||
profileId: 'explicit-profile',
|
||||
});
|
||||
});
|
||||
|
||||
test('supports played work type aliases for mini-games', () => {
|
||||
const cases = [
|
||||
['match_3d', 'open-match3d'],
|
||||
['square_hole', 'open-square-hole'],
|
||||
['jump_hop', 'open-jump-hop'],
|
||||
['wooden_fish', 'open-wooden-fish'],
|
||||
] as const;
|
||||
|
||||
for (const [worldType, type] of cases) {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType,
|
||||
profileId: `${worldType}-profile`,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
type,
|
||||
profileId: `${worldType}-profile`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('returns noop when a mini-game target is empty', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'puzzle:key-profile',
|
||||
profileId: '',
|
||||
worldType: 'puzzle',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
});
|
||||
|
||||
test('builds big fish intent and fallback work for gallery misses', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: 'big-fish:big-fish-session-1',
|
||||
ownerUserId: null,
|
||||
profileId: null,
|
||||
worldType: 'big_fish',
|
||||
worldTitle: '机械深海',
|
||||
worldSubtitle: '',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-big-fish',
|
||||
sessionId: 'big-fish-session-1',
|
||||
fallbackWork: {
|
||||
workId: 'big-fish:big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: '',
|
||||
authorDisplayName: '玩家',
|
||||
title: '机械深海',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('opens unknown played work types as RPG detail when identity is complete', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldType: 'CUSTOM',
|
||||
profileId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'open-rpg',
|
||||
detail: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'custom:world-1',
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-18T12:00:00.000Z',
|
||||
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||
authorDisplayName: '旧灯塔与失控航路',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('returns noop for RPG fallback when owner or profile is missing', () => {
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
ownerUserId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPlayedWorkOpenIntent(
|
||||
buildPlayedWork({
|
||||
worldKey: '',
|
||||
profileId: null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
});
|
||||
});
|
||||
});
|
||||
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
212
src/components/platform-entry/platformPlayedWorkOpenModel.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
ProfilePlayedWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
export type PlatformPlayedWorkOpenIntent =
|
||||
| {
|
||||
type: 'noop';
|
||||
reason: 'missing-target';
|
||||
}
|
||||
| {
|
||||
type: 'open-puzzle';
|
||||
profileId: string;
|
||||
tab: 'profile';
|
||||
}
|
||||
| {
|
||||
type: 'open-match3d';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-square-hole';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-jump-hop';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-wooden-fish';
|
||||
profileId: string;
|
||||
}
|
||||
| {
|
||||
type: 'open-big-fish';
|
||||
sessionId: string;
|
||||
fallbackWork: BigFishWorkSummary;
|
||||
}
|
||||
| {
|
||||
type: 'open-rpg';
|
||||
detail: CustomWorldGalleryCard;
|
||||
};
|
||||
|
||||
function normalizePlayedWorkWorldType(worldType: string | null) {
|
||||
return (worldType ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function resolvePlayedWorkTargetId(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
worldKeyPrefix: string,
|
||||
) {
|
||||
const prefixedWorldKey = `${worldKeyPrefix}:`;
|
||||
return (
|
||||
work.profileId ??
|
||||
(work.worldKey.startsWith(prefixedWorldKey)
|
||||
? work.worldKey.slice(prefixedWorldKey.length)
|
||||
: work.worldKey)
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePlayedWorkProfileIntent<TIntent extends PlatformPlayedWorkOpenIntent>(
|
||||
profileId: string,
|
||||
intent: (profileId: string) => TIntent,
|
||||
) {
|
||||
return profileId ? intent(profileId) : buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
function buildMissingPlayedWorkTargetIntent(): PlatformPlayedWorkOpenIntent {
|
||||
return {
|
||||
type: 'noop',
|
||||
reason: 'missing-target',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayedBigFishFallbackWork(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
sessionId: string,
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: `big-fish:${sessionId}`,
|
||||
sourceSessionId: sessionId,
|
||||
ownerUserId: work.ownerUserId ?? '',
|
||||
authorDisplayName: work.worldSubtitle || '玩家',
|
||||
title: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summary: work.worldSubtitle,
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: work.lastPlayedAt,
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayedRpgDetail(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
profileId: string,
|
||||
ownerUserId: string,
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: work.firstPlayedAt,
|
||||
updatedAt: work.lastPlayedAt,
|
||||
authorDisplayName: work.worldSubtitle,
|
||||
worldName: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 收口个人“玩过作品”点击后的玩法打开意图,壳层只执行副作用。 */
|
||||
export function resolvePlatformPlayedWorkOpenIntent(
|
||||
work: ProfilePlayedWorkSummary,
|
||||
): PlatformPlayedWorkOpenIntent {
|
||||
const worldType = normalizePlayedWorkWorldType(work.worldType);
|
||||
|
||||
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'puzzle');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-puzzle',
|
||||
profileId: resolvedProfileId,
|
||||
tab: 'profile',
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'match3d' ||
|
||||
worldType === 'match_3d' ||
|
||||
work.worldKey.startsWith('match3d:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'match3d');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-match3d',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'square-hole' ||
|
||||
worldType === 'square_hole' ||
|
||||
work.worldKey.startsWith('square-hole:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'square-hole');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-square-hole',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'jump-hop' ||
|
||||
worldType === 'jump_hop' ||
|
||||
work.worldKey.startsWith('jump-hop:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'jump-hop');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-jump-hop',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'wooden-fish' ||
|
||||
worldType === 'wooden_fish' ||
|
||||
work.worldKey.startsWith('wooden-fish:')
|
||||
) {
|
||||
const profileId = resolvePlayedWorkTargetId(work, 'wooden-fish');
|
||||
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
|
||||
type: 'open-wooden-fish',
|
||||
profileId: resolvedProfileId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'big_fish' ||
|
||||
worldType === 'big-fish' ||
|
||||
work.worldKey.startsWith('big-fish:')
|
||||
) {
|
||||
const sessionId = resolvePlayedWorkTargetId(work, 'big-fish');
|
||||
return sessionId
|
||||
? {
|
||||
type: 'open-big-fish',
|
||||
sessionId,
|
||||
fallbackWork: buildPlayedBigFishFallbackWork(work, sessionId),
|
||||
}
|
||||
: buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
const profileId = work.profileId ?? work.worldKey;
|
||||
const ownerUserId = work.ownerUserId;
|
||||
if (!ownerUserId || !profileId) {
|
||||
return buildMissingPlayedWorkTargetIntent();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'open-rpg',
|
||||
detail: buildPlayedRpgDetail(work, profileId, ownerUserId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
adjustProfileDashboardWalletBalance,
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard,
|
||||
resolveProfileWalletBalance,
|
||||
} from './platformProfileWalletDeltaModel';
|
||||
|
||||
const NOW = Date.parse('2026-06-04T04:30:00.000Z');
|
||||
|
||||
function buildDashboard(
|
||||
overrides: Partial<ProfileDashboardSummary> = {},
|
||||
): ProfileDashboardSummary {
|
||||
return {
|
||||
walletBalance: 100,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('platformProfileWalletDeltaModel', () => {
|
||||
test('normalizes wallet balance to a non-negative integer', () => {
|
||||
expect(resolveProfileWalletBalance(buildDashboard({ walletBalance: 12.8 }))).toBe(
|
||||
12,
|
||||
);
|
||||
expect(
|
||||
resolveProfileWalletBalance(buildDashboard({ walletBalance: -4 })),
|
||||
).toBe(0);
|
||||
expect(resolveProfileWalletBalance({ walletBalance: Number.NaN })).toBe(0);
|
||||
expect(resolveProfileWalletBalance(null)).toBe(0);
|
||||
});
|
||||
|
||||
test('applies local delta and refreshes dashboard timestamp', () => {
|
||||
expect(
|
||||
adjustProfileDashboardWalletBalance(buildDashboard(), -3.8),
|
||||
).toMatchObject({
|
||||
walletBalance: 97,
|
||||
updatedAt: '2026-06-04T04:30:00.000Z',
|
||||
});
|
||||
expect(
|
||||
adjustProfileDashboardWalletBalance(buildDashboard({ walletBalance: 2 }), -10),
|
||||
).toMatchObject({
|
||||
walletBalance: 0,
|
||||
});
|
||||
expect(adjustProfileDashboardWalletBalance(null, 5)).toBeNull();
|
||||
const dashboard = buildDashboard();
|
||||
expect(adjustProfileDashboardWalletBalance(dashboard, Number.POSITIVE_INFINITY)).toBe(
|
||||
dashboard,
|
||||
);
|
||||
});
|
||||
|
||||
test('reconciles debit delta already reflected by latest server dashboard', () => {
|
||||
const previous = buildDashboard({ walletBalance: 100 });
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 98 }),
|
||||
-5,
|
||||
),
|
||||
).toBe(-3);
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 92 }),
|
||||
-5,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('reconciles credit delta already reflected by latest server dashboard', () => {
|
||||
const previous = buildDashboard({ walletBalance: 100 });
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 103 }),
|
||||
8,
|
||||
),
|
||||
).toBe(5);
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 120 }),
|
||||
8,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('does not reconcile when server balance moves against local delta', () => {
|
||||
const previous = buildDashboard({ walletBalance: 100 });
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 104 }),
|
||||
-5,
|
||||
),
|
||||
).toBe(-5);
|
||||
expect(
|
||||
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previous,
|
||||
buildDashboard({ walletBalance: 96 }),
|
||||
8,
|
||||
),
|
||||
).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
type ProfileWalletBalanceSource =
|
||||
| Pick<ProfileDashboardSummary, 'walletBalance'>
|
||||
| { walletBalance?: number | null }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export function resolveProfileWalletBalance(
|
||||
dashboard: ProfileWalletBalanceSource,
|
||||
) {
|
||||
const walletBalance = dashboard?.walletBalance;
|
||||
return typeof walletBalance === 'number' && Number.isFinite(walletBalance)
|
||||
? Math.max(0, Math.floor(walletBalance))
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function adjustProfileDashboardWalletBalance(
|
||||
dashboard: ProfileDashboardSummary | null,
|
||||
delta: number,
|
||||
): ProfileDashboardSummary | null {
|
||||
if (!dashboard || !Number.isFinite(delta) || delta === 0) {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
return {
|
||||
...dashboard,
|
||||
walletBalance: Math.max(
|
||||
0,
|
||||
resolveProfileWalletBalance(dashboard) + Math.trunc(delta),
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||
previousDashboard: ProfileDashboardSummary | null,
|
||||
latestDashboard: ProfileDashboardSummary | null,
|
||||
localDelta: number,
|
||||
) {
|
||||
if (
|
||||
!previousDashboard ||
|
||||
!latestDashboard ||
|
||||
!Number.isFinite(localDelta) ||
|
||||
localDelta === 0
|
||||
) {
|
||||
return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0;
|
||||
}
|
||||
|
||||
const previousBalance = resolveProfileWalletBalance(previousDashboard);
|
||||
const latestBalance = resolveProfileWalletBalance(latestDashboard);
|
||||
const normalizedDelta = Math.trunc(localDelta);
|
||||
|
||||
if (normalizedDelta < 0) {
|
||||
const reflectedDebit = Math.max(0, previousBalance - latestBalance);
|
||||
return Math.min(0, normalizedDelta + reflectedDebit);
|
||||
}
|
||||
|
||||
const reflectedCredit = Math.max(0, latestBalance - previousBalance);
|
||||
return Math.max(0, normalizedDelta - reflectedCredit);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
buildBarkBattlePublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
buildVisualNovelPublicWorkCode,
|
||||
buildWoodenFishPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
mapRpgPublicCodeSearchDetailToGalleryCard,
|
||||
type PlatformPublicCodeSearchStep,
|
||||
resolveBabyObjectMatchPublicCodeSearchMatch,
|
||||
resolveBarkBattlePublicCodeSearchMatch,
|
||||
resolveBigFishPublicCodeSearchMatch,
|
||||
resolveJumpHopPublicCodeSearchMatch,
|
||||
resolveMatch3DPublicCodeSearchMatch,
|
||||
resolvePlatformPublicCodeSearchPlan,
|
||||
resolvePuzzlePublicCodeSearchMatch,
|
||||
resolveSquareHolePublicCodeSearchMatch,
|
||||
resolveVisualNovelPublicCodeSearchMatch,
|
||||
resolveWoodenFishPublicCodeSearchMatch,
|
||||
} from './platformPublicCodeSearchModel';
|
||||
|
||||
function expectSearchSteps(
|
||||
keyword: string,
|
||||
steps: readonly PlatformPublicCodeSearchStep[],
|
||||
) {
|
||||
expect(resolvePlatformPublicCodeSearchPlan(keyword)?.steps).toEqual(steps);
|
||||
}
|
||||
|
||||
describe('platformPublicCodeSearchModel', () => {
|
||||
test('ignores empty public code search input', () => {
|
||||
expect(resolvePlatformPublicCodeSearchPlan(' ')).toBeNull();
|
||||
});
|
||||
|
||||
test('normalizes public code search keyword before planning', () => {
|
||||
expect(resolvePlatformPublicCodeSearchPlan(' PZ-00000001 ')).toEqual({
|
||||
normalizedKeyword: 'PZ-00000001',
|
||||
steps: ['puzzle-work'],
|
||||
});
|
||||
});
|
||||
|
||||
test('searches internal user ids directly without work fallback', () => {
|
||||
expectSearchSteps('user_00000001', ['user-id']);
|
||||
expectSearchSteps('USER-profile-1', ['user-id']);
|
||||
});
|
||||
|
||||
test('routes known public work prefixes to their play-specific lookup', () => {
|
||||
const cases: Array<
|
||||
[keyword: string, step: PlatformPublicCodeSearchStep]
|
||||
> = [
|
||||
['PZ-EPUBLIC1', 'puzzle-work'],
|
||||
['BF-NPUBLIC1', 'big-fish-work'],
|
||||
['JH-EPUBLIC1', 'jump-hop-work'],
|
||||
['WF-EPUBLIC1', 'wooden-fish-work'],
|
||||
['BO-EPUBLIC1', 'baby-object-match-work'],
|
||||
['M3-EPUBLIC1', 'match3d-work'],
|
||||
['M3D-LEGACY1', 'match3d-work'],
|
||||
['SH-EPUBLIC1', 'square-hole-work'],
|
||||
['VN-EPUBLIC1', 'visual-novel-work'],
|
||||
['BB-EPUBLIC1', 'bark-battle-work'],
|
||||
];
|
||||
|
||||
for (const [keyword, step] of cases) {
|
||||
expectSearchSteps(keyword, [step]);
|
||||
}
|
||||
});
|
||||
|
||||
test('searches RPG public works before public user codes for CW and numeric codes', () => {
|
||||
expectSearchSteps('CW-00000001', ['rpg-work', 'public-user-code']);
|
||||
expectSearchSteps('12345678', ['rpg-work', 'public-user-code']);
|
||||
});
|
||||
|
||||
test('keeps legacy user-code-first fallback for SY and ordinary keywords', () => {
|
||||
const legacyFallbackSteps = [
|
||||
'public-user-code',
|
||||
'rpg-work',
|
||||
'bark-battle-work',
|
||||
'public-user-code',
|
||||
] as const;
|
||||
|
||||
expectSearchSteps('SY-00000001', legacyFallbackSteps);
|
||||
expectSearchSteps('月井守望', legacyFallbackSteps);
|
||||
});
|
||||
|
||||
test('maps RPG detail responses to gallery cards with count defaults', () => {
|
||||
expect(
|
||||
mapRpgPublicCodeSearchDetailToGalleryCard(
|
||||
buildRpgDetailEntry({
|
||||
playCount: undefined,
|
||||
remixCount: undefined,
|
||||
likeCount: undefined,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
profileId: 'rpg-profile-1',
|
||||
visibility: 'published',
|
||||
worldName: '潮雾世界',
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves public code matches for every play-specific gallery type', () => {
|
||||
const puzzle = buildPuzzleWork({ profileId: 'puzzle-profile-12345678' });
|
||||
const bigFish = buildBigFishWork({
|
||||
sourceSessionId: 'big-fish-session-12345678',
|
||||
});
|
||||
const jumpHop = buildJumpHopCard({ profileId: 'jump-hop-profile-12345678' });
|
||||
const woodenFish = buildWoodenFishCard({
|
||||
profileId: 'wooden-fish-profile-12345678',
|
||||
});
|
||||
const babyObjectMatch = buildBabyObjectMatchDraft({
|
||||
profileId: 'baby-object-profile-12345678',
|
||||
});
|
||||
const match3d = buildMatch3DWork({ profileId: 'match3d-profile-12345678' });
|
||||
const squareHole = buildSquareHoleWork({
|
||||
profileId: 'square-hole-profile-12345678',
|
||||
});
|
||||
const visualNovel = buildVisualNovelWork({
|
||||
profileId: 'visual-novel-profile-12345678',
|
||||
});
|
||||
const barkBattle = buildBarkBattleWork({
|
||||
workId: 'bark-battle-work-12345678',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePuzzlePublicCodeSearchMatch(
|
||||
[puzzle],
|
||||
buildPuzzlePublicWorkCode(puzzle.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'puzzle' });
|
||||
expect(
|
||||
resolveBigFishPublicCodeSearchMatch(
|
||||
[bigFish],
|
||||
buildBigFishPublicWorkCode(bigFish.sourceSessionId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'big-fish' });
|
||||
expect(
|
||||
resolveJumpHopPublicCodeSearchMatch(
|
||||
[jumpHop],
|
||||
buildJumpHopPublicWorkCode(jumpHop.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'jump-hop' });
|
||||
expect(
|
||||
resolveWoodenFishPublicCodeSearchMatch(
|
||||
[woodenFish],
|
||||
buildWoodenFishPublicWorkCode(woodenFish.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'wooden-fish' });
|
||||
expect(
|
||||
resolveBabyObjectMatchPublicCodeSearchMatch(
|
||||
[babyObjectMatch],
|
||||
`BO-${babyObjectMatch.profileId.slice(-8)}`,
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'edutainment' });
|
||||
expect(
|
||||
resolveMatch3DPublicCodeSearchMatch(
|
||||
[match3d],
|
||||
buildMatch3DPublicWorkCode(match3d.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'match3d' });
|
||||
expect(
|
||||
resolveSquareHolePublicCodeSearchMatch(
|
||||
[squareHole],
|
||||
buildSquareHolePublicWorkCode(squareHole.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'square-hole' });
|
||||
expect(
|
||||
resolveVisualNovelPublicCodeSearchMatch(
|
||||
[visualNovel],
|
||||
buildVisualNovelPublicWorkCode(visualNovel.profileId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'visual-novel' });
|
||||
expect(
|
||||
resolveBarkBattlePublicCodeSearchMatch(
|
||||
[barkBattle],
|
||||
buildBarkBattlePublicWorkCode(barkBattle.workId),
|
||||
)?.detail,
|
||||
).toMatchObject({ sourceType: 'bark-battle' });
|
||||
});
|
||||
|
||||
test('public code search matchers skip entries hidden by visibility policy', () => {
|
||||
const hiddenPuzzle = buildPuzzleWork({
|
||||
profileId: 'hidden-profile-12345678',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePuzzlePublicCodeSearchMatch(
|
||||
[hiddenPuzzle],
|
||||
buildPuzzlePublicWorkCode(hiddenPuzzle.profileId),
|
||||
() => false,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function buildRpgDetailEntry(
|
||||
overrides: Partial<CustomWorldLibraryEntry<CustomWorldProfile>> = {},
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return {
|
||||
ownerUserId: 'rpg-owner-1',
|
||||
profileId: 'rpg-profile-1',
|
||||
publicWorkCode: 'CW-00000001',
|
||||
authorPublicUserCode: 'SY-00000001',
|
||||
profile: {} as CustomWorldProfile,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
authorDisplayName: '测试作者',
|
||||
worldName: '潮雾世界',
|
||||
subtitle: '潮雾港',
|
||||
summaryText: '潮雾世界说明。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
playCount: 1,
|
||||
remixCount: 1,
|
||||
likeCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图',
|
||||
workDescription: '潮雾拼图说明。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '潮雾拼图说明。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
title: '潮雾大鱼',
|
||||
subtitle: '潮雾港',
|
||||
summary: '潮雾大鱼说明。',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 1,
|
||||
levelMotionReadyCount: 1,
|
||||
backgroundReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopCard(
|
||||
overrides: Partial<JumpHopGalleryCardResponse> = {},
|
||||
): JumpHopGalleryCardResponse {
|
||||
const profileId = overrides.profileId ?? 'jump-hop-profile-1';
|
||||
return {
|
||||
publicWorkCode: buildJumpHopPublicWorkCode(profileId),
|
||||
workId: 'jump-hop-work-1',
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
themeText: '潮雾港',
|
||||
workTitle: '潮雾跳一跳',
|
||||
workDescription: '潮雾跳一跳说明。',
|
||||
coverImageSrc: null,
|
||||
themeTags: [],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWoodenFishCard(
|
||||
overrides: Partial<WoodenFishGalleryCardResponse> = {},
|
||||
): WoodenFishGalleryCardResponse {
|
||||
const profileId = overrides.profileId ?? 'wooden-fish-profile-1';
|
||||
return {
|
||||
publicWorkCode: buildWoodenFishPublicWorkCode(profileId),
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾木鱼',
|
||||
workDescription: '潮雾木鱼说明。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['敲木鱼'],
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchDraft(
|
||||
overrides: Partial<BabyObjectMatchDraft> = {},
|
||||
): BabyObjectMatchDraft {
|
||||
return {
|
||||
draftId: 'baby-draft-1',
|
||||
profileId: 'baby-object-profile-1',
|
||||
templateId: 'baby-object-match',
|
||||
templateName: '宝贝识物',
|
||||
workTitle: '潮雾识物',
|
||||
workDescription: '潮雾识物说明。',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
buildBabyObjectMatchItemAsset('item-a', '苹果'),
|
||||
buildBabyObjectMatchItemAsset('item-b', '香蕉'),
|
||||
],
|
||||
visualPackage: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchItemAsset(itemId: string, itemName: string) {
|
||||
return {
|
||||
itemId,
|
||||
itemName,
|
||||
imageSrc: `/media/${itemId}.png`,
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder' as const,
|
||||
prompt: itemName,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
gameName: '潮雾抓大鹅',
|
||||
themeText: '潮雾港',
|
||||
summary: '潮雾抓大鹅说明。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 0,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSquareHoleWork(
|
||||
overrides: Partial<SquareHoleWorkSummary> = {},
|
||||
): SquareHoleWorkSummary {
|
||||
return {
|
||||
workId: 'square-hole-work-1',
|
||||
profileId: 'square-hole-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'square-hole-session-1',
|
||||
gameName: '潮雾方洞',
|
||||
themeText: '潮雾港',
|
||||
twistRule: '避开雾门',
|
||||
summary: '潮雾方洞说明。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
backgroundPrompt: '潮雾港',
|
||||
backgroundImageSrc: null,
|
||||
shapeOptions: [],
|
||||
holeOptions: [],
|
||||
shapeCount: 1,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelWork(
|
||||
overrides: Partial<VisualNovelWorkSummary> = {},
|
||||
): VisualNovelWorkSummary {
|
||||
return {
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'visual-novel-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '潮雾视觉小说',
|
||||
description: '潮雾视觉小说说明。',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'published',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
return {
|
||||
workId: 'bark-battle-work-1',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
title: '潮雾声浪',
|
||||
summary: '潮雾声浪说明。',
|
||||
themeDescription: '潮雾港',
|
||||
playerImageDescription: '小狗',
|
||||
opponentImageDescription: '对手',
|
||||
onomatopoeia: ['汪'],
|
||||
playerCharacterImageSrc: null,
|
||||
opponentCharacterImageSrc: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
313
src/components/platform-entry/platformPublicCodeSearchModel.ts
Normal file
313
src/components/platform-entry/platformPublicCodeSearchModel.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import {
|
||||
isSameBabyObjectMatchPublicWorkCode,
|
||||
isSameBarkBattlePublicWorkCode,
|
||||
isSameBigFishPublicWorkCode,
|
||||
isSameJumpHopPublicWorkCode,
|
||||
isSameMatch3DPublicWorkCode,
|
||||
isSamePuzzlePublicWorkCode,
|
||||
isSameSquareHolePublicWorkCode,
|
||||
isSameVisualNovelPublicWorkCode,
|
||||
isSameWoodenFishPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { mapBabyObjectMatchDraftToPlatformGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { canExposePublicWork } from './platformEdutainmentVisibility';
|
||||
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
|
||||
import {
|
||||
mapBarkBattleWorkToPublicWorkDetail,
|
||||
mapBigFishWorkToPublicWorkDetail,
|
||||
mapJumpHopWorkToPublicWorkDetail,
|
||||
mapPuzzleWorkToPublicWorkDetail,
|
||||
mapSquareHoleWorkToPublicWorkDetail,
|
||||
mapVisualNovelWorkToPublicWorkDetail,
|
||||
mapWoodenFishWorkToPublicWorkDetail,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
|
||||
export type PlatformPublicCodeSearchStep =
|
||||
| 'user-id'
|
||||
| 'public-user-code'
|
||||
| 'rpg-work'
|
||||
| 'puzzle-work'
|
||||
| 'big-fish-work'
|
||||
| 'jump-hop-work'
|
||||
| 'wooden-fish-work'
|
||||
| 'baby-object-match-work'
|
||||
| 'match3d-work'
|
||||
| 'square-hole-work'
|
||||
| 'visual-novel-work'
|
||||
| 'bark-battle-work';
|
||||
|
||||
export type PlatformPublicCodeSearchPlan = {
|
||||
normalizedKeyword: string;
|
||||
steps: readonly PlatformPublicCodeSearchStep[];
|
||||
};
|
||||
|
||||
export type PlatformPublicCodeSearchMatch<TItem> = {
|
||||
item: TItem;
|
||||
detail: PlatformPublicGalleryCard;
|
||||
};
|
||||
|
||||
type PlatformPublicCodeSearchMatcherInput<TItem> = {
|
||||
keyword: string;
|
||||
entries: readonly TItem[];
|
||||
mapEntry: (item: TItem) => PlatformPublicGalleryCard;
|
||||
matchesEntry: (keyword: string, item: TItem) => boolean;
|
||||
canExposeEntry?: (entry: PlatformPublicGalleryCard) => boolean;
|
||||
};
|
||||
|
||||
const PLATFORM_PUBLIC_USER_ID_PATTERN = /^user[_-][a-z0-9_-]+$/iu;
|
||||
const PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN = /^\d{1,8}$/u;
|
||||
|
||||
const DIRECT_WORK_PREFIX_STEPS: ReadonlyArray<
|
||||
readonly [prefix: string, step: PlatformPublicCodeSearchStep]
|
||||
> = [
|
||||
['PZ', 'puzzle-work'],
|
||||
['BF', 'big-fish-work'],
|
||||
['JH', 'jump-hop-work'],
|
||||
['WF', 'wooden-fish-work'],
|
||||
['BO', 'baby-object-match-work'],
|
||||
['M3', 'match3d-work'],
|
||||
['SH', 'square-hole-work'],
|
||||
['VN', 'visual-novel-work'],
|
||||
['BB', 'bark-battle-work'],
|
||||
];
|
||||
|
||||
/** 收口公开码搜索顺序,壳层只按步骤执行网络读取与打开副作用。 */
|
||||
export function resolvePlatformPublicCodeSearchPlan(
|
||||
keyword: string,
|
||||
): PlatformPublicCodeSearchPlan | null {
|
||||
const normalizedKeyword = keyword.trim();
|
||||
if (!normalizedKeyword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PLATFORM_PUBLIC_USER_ID_PATTERN.test(normalizedKeyword)) {
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: ['user-id'],
|
||||
};
|
||||
}
|
||||
|
||||
const upperKeyword = normalizedKeyword.toUpperCase();
|
||||
const directWorkStep = DIRECT_WORK_PREFIX_STEPS.find(([prefix]) =>
|
||||
upperKeyword.startsWith(prefix),
|
||||
)?.[1];
|
||||
if (directWorkStep) {
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: [directWorkStep],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
upperKeyword.startsWith('CW') ||
|
||||
PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN.test(normalizedKeyword)
|
||||
) {
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: ['rpg-work', 'public-user-code'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
normalizedKeyword,
|
||||
steps: [
|
||||
'public-user-code',
|
||||
'rpg-work',
|
||||
'bark-battle-work',
|
||||
'public-user-code',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapRpgPublicCodeSearchDetailToGalleryCard(
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: entry.publicWorkCode,
|
||||
authorPublicUserCode: entry.authorPublicUserCode,
|
||||
visibility: 'published',
|
||||
publishedAt: entry.publishedAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
playCount: entry.playCount ?? 0,
|
||||
remixCount: entry.remixCount ?? 0,
|
||||
likeCount: entry.likeCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePuzzlePublicCodeSearchMatch(
|
||||
entries: readonly PuzzleWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapPuzzleWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSamePuzzlePublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBigFishPublicCodeSearchMatch(
|
||||
entries: readonly BigFishWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapBigFishWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameBigFishPublicWorkCode(searchKeyword, item.sourceSessionId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveJumpHopPublicCodeSearchMatch(
|
||||
entries: readonly JumpHopGalleryCardResponse[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapJumpHopWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameJumpHopPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveWoodenFishPublicCodeSearchMatch(
|
||||
entries: readonly WoodenFishGalleryCardResponse[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapWoodenFishWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameWoodenFishPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBabyObjectMatchPublicCodeSearchMatch(
|
||||
entries: readonly BabyObjectMatchDraft[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameBabyObjectMatchPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatch3DPublicCodeSearchMatch(
|
||||
entries: readonly Match3DWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapMatch3DWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameMatch3DPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSquareHolePublicCodeSearchMatch(
|
||||
entries: readonly SquareHoleWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapSquareHoleWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameSquareHolePublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveVisualNovelPublicCodeSearchMatch(
|
||||
entries: readonly VisualNovelWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapVisualNovelWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameVisualNovelPublicWorkCode(searchKeyword, item.profileId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBarkBattlePublicCodeSearchMatch(
|
||||
entries: readonly BarkBattleWorkSummary[],
|
||||
keyword: string,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
) {
|
||||
return resolveMappedPublicCodeSearchMatch({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry: mapBarkBattleWorkToPublicWorkDetail,
|
||||
matchesEntry: (searchKeyword, item) =>
|
||||
isSameBarkBattlePublicWorkCode(searchKeyword, item.workId),
|
||||
canExposeEntry,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMappedPublicCodeSearchMatch<TItem>({
|
||||
keyword,
|
||||
entries,
|
||||
mapEntry,
|
||||
matchesEntry,
|
||||
canExposeEntry = canExposePublicWork,
|
||||
}: PlatformPublicCodeSearchMatcherInput<TItem>):
|
||||
| PlatformPublicCodeSearchMatch<TItem>
|
||||
| null {
|
||||
for (const item of entries) {
|
||||
const detail = mapEntry(item);
|
||||
if (canExposeEntry(detail) && matchesEntry(keyword, item)) {
|
||||
return { item, detail };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
911
src/components/platform-entry/platformPublicGalleryFlow.test.ts
Normal file
911
src/components/platform-entry/platformPublicGalleryFlow.test.ts
Normal file
@@ -0,0 +1,911 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
buildPlatformPublicGalleryFeeds,
|
||||
getPlatformPublicGalleryEntryKey,
|
||||
getPlatformPublicGalleryEntryTime,
|
||||
getPlatformRecommendRuntimeKind,
|
||||
isPlatformRecommendRuntimeReadyForEntry,
|
||||
isSamePlatformPublicGalleryEntry,
|
||||
mergePlatformPublicGalleryEntries,
|
||||
type PlatformRecommendRuntimeStartIntentDeps,
|
||||
type RecommendRuntimeKind,
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision,
|
||||
resolvePlatformRecommendRuntimeStartIntent,
|
||||
} from './platformPublicGalleryFlow';
|
||||
import {
|
||||
mapBarkBattlePublicDetailToWorkSummary,
|
||||
mapPublicWorkDetailToBigFishWork,
|
||||
mapPublicWorkDetailToPuzzleWork,
|
||||
mapPublicWorkDetailToSquareHoleWork,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
|
||||
type TypedPlatformPublicGalleryCard = Extract<
|
||||
PlatformPublicGalleryCard,
|
||||
{ sourceType: string }
|
||||
>;
|
||||
type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType'];
|
||||
type TypedPlatformPublicGalleryCardOverrides = Partial<
|
||||
Omit<TypedPlatformPublicGalleryCard, 'sourceType'>
|
||||
>;
|
||||
|
||||
function buildRpgEntry(
|
||||
overrides: Partial<CustomWorldGalleryCard> = {},
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'rpg-profile',
|
||||
publicWorkCode: 'CW-RPG',
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: 'RPG 世界',
|
||||
subtitle: '公开作品',
|
||||
summaryText: '公开作品摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBigFishWork(
|
||||
overrides: Partial<BigFishWorkSummary> = {},
|
||||
): BigFishWorkSummary {
|
||||
return {
|
||||
workId: 'big-fish-work',
|
||||
sourceSessionId: 'big-fish-session',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '大鱼吃小鱼',
|
||||
subtitle: '海湾',
|
||||
summary: '一路长大。',
|
||||
coverImageSrc: '/big-fish-cover.png',
|
||||
status: 'published',
|
||||
updatedAt: '2026-06-01T02:00:00.000Z',
|
||||
publishedAt: '2026-06-01T02:00:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 1,
|
||||
levelMainImageReadyCount: 1,
|
||||
levelMotionReadyCount: 1,
|
||||
backgroundReady: true,
|
||||
playCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchDraft(
|
||||
overrides: Partial<BabyObjectMatchDraft> = {},
|
||||
): BabyObjectMatchDraft {
|
||||
const itemAsset = {
|
||||
itemId: 'item-a',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder' as const,
|
||||
prompt: '苹果',
|
||||
};
|
||||
return {
|
||||
draftId: 'baby-draft',
|
||||
profileId: 'baby-profile',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '认识水果。',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [itemAsset, { ...itemAsset, itemId: 'item-b', itemName: '香蕉' }],
|
||||
visualPackage: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T03:00:00.000Z',
|
||||
publishedAt: '2026-06-01T03:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJumpHopEntry(
|
||||
overrides: Partial<JumpHopGalleryCardResponse> = {},
|
||||
): JumpHopGalleryCardResponse {
|
||||
return {
|
||||
publicWorkCode: 'JH-JUMP',
|
||||
workId: 'jump-hop-work',
|
||||
profileId: 'jump-hop-profile',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
themeText: '一路向前',
|
||||
workTitle: '跳一跳',
|
||||
workDescription: '一路向前。',
|
||||
coverImageSrc: '/jump-hop-cover.png',
|
||||
themeTags: ['跳一跳'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
publicationStatus: 'published',
|
||||
playCount: 1,
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypedEntry(
|
||||
sourceType: PlatformGallerySourceType,
|
||||
overrides: TypedPlatformPublicGalleryCardOverrides = {},
|
||||
): PlatformPublicGalleryCard {
|
||||
const common = {
|
||||
workId: `${sourceType}-work`,
|
||||
profileId: `${sourceType}-profile`,
|
||||
publicWorkCode: `${sourceType}-code`,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: `${sourceType} 作品`,
|
||||
subtitle: '公开作品',
|
||||
summaryText: '公开作品摘要',
|
||||
coverImageSrc: null,
|
||||
themeTags: [sourceType],
|
||||
visibility: 'published' as const,
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
};
|
||||
|
||||
switch (sourceType) {
|
||||
case 'puzzle':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'puzzle-clear':
|
||||
return {
|
||||
...common,
|
||||
...overrides,
|
||||
sourceType,
|
||||
themePrompt: '拼消消主题',
|
||||
};
|
||||
case 'big-fish':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'match3d':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'square-hole':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'visual-novel':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'jump-hop':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'wooden-fish':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'edutainment':
|
||||
return {
|
||||
...common,
|
||||
...overrides,
|
||||
sourceType,
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
};
|
||||
case 'bark-battle':
|
||||
return {
|
||||
...common,
|
||||
...overrides,
|
||||
sourceType,
|
||||
authorPublicUserCode: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = sourceType;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPuzzleWork(
|
||||
overrides: Partial<PuzzleWorkSummary> = {},
|
||||
): PuzzleWorkSummary {
|
||||
return {
|
||||
workId: 'puzzle-work',
|
||||
profileId: 'puzzle-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session',
|
||||
authorDisplayName: '玩家',
|
||||
levelName: '拼图作品',
|
||||
summary: '拼图摘要',
|
||||
themeTags: ['拼图'],
|
||||
coverImageSrc: '/puzzle-cover.png',
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
playCount: 3,
|
||||
remixCount: 2,
|
||||
likeCount: 1,
|
||||
pointIncentiveTotalHalfPoints: 0,
|
||||
pointIncentiveClaimedPoints: 0,
|
||||
pointIncentiveTotalPoints: 0,
|
||||
pointIncentiveClaimablePoints: 0,
|
||||
publishReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DWork(
|
||||
overrides: Partial<Match3DWorkSummary> = {},
|
||||
): Match3DWorkSummary {
|
||||
return {
|
||||
workId: 'match3d-work',
|
||||
profileId: 'match3d-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session',
|
||||
gameName: '抓大鹅作品',
|
||||
themeText: '经典消除',
|
||||
summary: '抓大鹅摘要',
|
||||
tags: ['抓大鹅'],
|
||||
coverImageSrc: '/match3d-cover.png',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: 10,
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
generatedItemAssets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
return {
|
||||
workId: 'bark-battle-work',
|
||||
draftId: 'bark-battle-draft',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '汪汪声浪作品',
|
||||
summary: '汪汪摘要',
|
||||
themeDescription: '森林擂台',
|
||||
playerImageDescription: '小狗',
|
||||
opponentImageDescription: '对手',
|
||||
playerCharacterImageSrc: '/player.png',
|
||||
opponentCharacterImageSrc: '/opponent.png',
|
||||
uiBackgroundImageSrc: '/bark-bg.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 9,
|
||||
recentPlayCount7d: 2,
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecommendRuntimeStartDeps(
|
||||
overrides: Partial<PlatformRecommendRuntimeStartIntentDeps> = {},
|
||||
): PlatformRecommendRuntimeStartIntentDeps {
|
||||
return {
|
||||
selectedPuzzleDetail: null,
|
||||
barkBattleGalleryEntries: [],
|
||||
mapMatch3DWork: () => buildMatch3DWork(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => {
|
||||
const cases: Array<
|
||||
[sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind]
|
||||
> = [
|
||||
['big-fish', 'big-fish', 'big-fish'],
|
||||
['puzzle', 'puzzle', 'puzzle'],
|
||||
['jump-hop', 'jump-hop', 'jump-hop'],
|
||||
['wooden-fish', 'wooden-fish', 'wooden-fish'],
|
||||
['match3d', 'match3d', 'match3d'],
|
||||
['square-hole', 'square-hole', 'square-hole'],
|
||||
['visual-novel', 'visual-novel', 'visual-novel'],
|
||||
['bark-battle', 'bark-battle', 'bark-battle'],
|
||||
[
|
||||
'edutainment',
|
||||
`edutainment:${EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID}`,
|
||||
'edutainment',
|
||||
],
|
||||
];
|
||||
|
||||
cases.forEach(([sourceType, keyKind, kind]) => {
|
||||
const entry = buildTypedEntry(sourceType);
|
||||
|
||||
expect(getPlatformPublicGalleryEntryKey(entry)).toBe(
|
||||
`${keyKind}:user-1:${sourceType}-profile`,
|
||||
);
|
||||
expect(getPlatformRecommendRuntimeKind(entry)).toBe(kind);
|
||||
});
|
||||
|
||||
const rpgEntry = buildRpgEntry();
|
||||
|
||||
expect(getPlatformPublicGalleryEntryKey(rpgEntry)).toBe(
|
||||
'rpg:user-1:rpg-profile',
|
||||
);
|
||||
expect(getPlatformRecommendRuntimeKind(rpgEntry)).toBe('rpg');
|
||||
});
|
||||
|
||||
test('platform public gallery flow compares entries by resolved identity', () => {
|
||||
const left = buildTypedEntry('puzzle');
|
||||
const sameIdentity = buildTypedEntry('puzzle', {
|
||||
workId: 'other-work',
|
||||
worldName: '新标题',
|
||||
});
|
||||
const otherKind = buildTypedEntry('match3d', {
|
||||
ownerUserId: left.ownerUserId,
|
||||
profileId: left.profileId,
|
||||
});
|
||||
|
||||
expect(isSamePlatformPublicGalleryEntry(left, sameIdentity)).toBe(true);
|
||||
expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false);
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime start intent', () => {
|
||||
const bigFishEntry = buildTypedEntry('big-fish');
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
bigFishEntry,
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-big-fish',
|
||||
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
|
||||
const selectedPuzzleDetail = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('puzzle'),
|
||||
buildRecommendRuntimeStartDeps({ selectedPuzzleDetail }),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-puzzle',
|
||||
work: selectedPuzzleDetail,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
|
||||
const puzzleEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'fallback-puzzle-profile',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
puzzleEntry,
|
||||
buildRecommendRuntimeStartDeps({
|
||||
selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }),
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-puzzle',
|
||||
work: mapPublicWorkDetailToPuzzleWork(puzzleEntry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('jump-hop'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-jump-hop',
|
||||
profileId: 'jump-hop-profile',
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('wooden-fish'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-wooden-fish',
|
||||
profileId: 'wooden-fish-profile',
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('visual-novel'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-visual-novel',
|
||||
profileId: 'visual-novel-profile',
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildTypedEntry('edutainment'),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-edutainment',
|
||||
entry: buildTypedEntry('edutainment'),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
buildRpgEntry(),
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'mark-ready',
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime mapper-backed start intent', () => {
|
||||
const match3DEntry = buildTypedEntry('match3d');
|
||||
const match3DWork = buildMatch3DWork({ workId: 'mapped-match3d-work' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
match3DEntry,
|
||||
buildRecommendRuntimeStartDeps({
|
||||
mapMatch3DWork: (entry) =>
|
||||
entry === match3DEntry ? match3DWork : null,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-match3d',
|
||||
work: match3DWork,
|
||||
returnStage: 'work-detail',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
match3DEntry,
|
||||
buildRecommendRuntimeStartDeps({ mapMatch3DWork: () => null }),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'blocked',
|
||||
errorTarget: 'match3d',
|
||||
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
|
||||
});
|
||||
|
||||
const squareHoleEntry = buildTypedEntry('square-hole');
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
squareHoleEntry,
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-square-hole',
|
||||
work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime bark battle priority', () => {
|
||||
const entry = buildTypedEntry('bark-battle');
|
||||
const galleryWork = buildBarkBattleWork({
|
||||
workId: 'bark-battle-work',
|
||||
title: '推荐缓存',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
entry,
|
||||
buildRecommendRuntimeStartDeps({
|
||||
barkBattleGalleryEntries: [galleryWork],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-bark-battle',
|
||||
work: galleryWork,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeStartIntent(
|
||||
entry,
|
||||
buildRecommendRuntimeStartDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'start-bark-battle',
|
||||
work: mapBarkBattlePublicDetailToWorkSummary(entry),
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime readiness', () => {
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
|
||||
activeKind: 'puzzle',
|
||||
hasBigFishRun: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
|
||||
activeKind: 'big-fish',
|
||||
hasBigFishRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('jump-hop'), {
|
||||
activeKind: 'jump-hop',
|
||||
hasJumpHopRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('wooden-fish'), {
|
||||
activeKind: 'wooden-fish',
|
||||
hasWoodenFishRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('match3d'), {
|
||||
activeKind: 'match3d',
|
||||
hasMatch3DRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('square-hole'), {
|
||||
activeKind: 'square-hole',
|
||||
hasSquareHoleRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('visual-novel'), {
|
||||
activeKind: 'visual-novel',
|
||||
hasVisualNovelRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('bark-battle'), {
|
||||
activeKind: 'bark-battle',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildRpgEntry(), {
|
||||
activeKind: 'rpg',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves puzzle and edutainment readiness details', () => {
|
||||
const puzzleEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'puzzle-profile',
|
||||
});
|
||||
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
|
||||
activeKind: 'puzzle',
|
||||
puzzleRunEntryProfileId: 'other-profile',
|
||||
puzzleRunCurrentLevelProfileId: 'puzzle-profile',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
|
||||
activeKind: 'puzzle',
|
||||
puzzleRunEntryProfileId: 'other-profile',
|
||||
puzzleRunCurrentLevelProfileId: 'another-profile',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
|
||||
activeKind: 'edutainment',
|
||||
hasBabyObjectMatchDraft: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
|
||||
activeKind: 'edutainment',
|
||||
hasBabyObjectMatchDraft: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime auto-start gates', () => {
|
||||
const entry = buildTypedEntry('big-fish');
|
||||
const baseInput: Parameters<
|
||||
typeof resolvePlatformRecommendRuntimeAutoStartDecision
|
||||
>[0] = {
|
||||
isDesktopLayout: false,
|
||||
selectionStage: 'platform',
|
||||
platformTab: 'home',
|
||||
isLoadingPlatform: false,
|
||||
entries: [entry],
|
||||
activeEntryKey: null,
|
||||
isStarting: false,
|
||||
readyState: { activeKind: null },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
isDesktopLayout: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
platformTab: 'discover',
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
entries: [],
|
||||
}),
|
||||
).toEqual({ type: 'clear' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
isStarting: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
activeEntryKey: getPlatformPublicGalleryEntryKey(entry),
|
||||
hasStartError: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime auto-start target', () => {
|
||||
const firstEntry = buildTypedEntry('big-fish', {
|
||||
profileId: 'big-fish-first',
|
||||
});
|
||||
const activeEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'puzzle-active',
|
||||
});
|
||||
const activeEntryKey = getPlatformPublicGalleryEntryKey(activeEntry);
|
||||
const baseInput: Parameters<
|
||||
typeof resolvePlatformRecommendRuntimeAutoStartDecision
|
||||
>[0] = {
|
||||
isDesktopLayout: false,
|
||||
selectionStage: 'platform',
|
||||
platformTab: 'home',
|
||||
isLoadingPlatform: false,
|
||||
entries: [firstEntry, activeEntry],
|
||||
activeEntryKey,
|
||||
isStarting: false,
|
||||
readyState: { activeKind: 'puzzle' },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
readyState: {
|
||||
activeKind: 'puzzle',
|
||||
puzzleRunEntryProfileId: 'puzzle-active',
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(resolvePlatformRecommendRuntimeAutoStartDecision(baseInput)).toEqual({
|
||||
type: 'start',
|
||||
entry: activeEntry,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
readyState: { activeKind: null },
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'start',
|
||||
entry: activeEntry,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
activeEntryKey: 'missing-entry',
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'start',
|
||||
entry: firstEntry,
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public gallery flow merges duplicate identities and sorts newest first', () => {
|
||||
const staleRpgEntry = buildRpgEntry({
|
||||
profileId: 'shared-rpg',
|
||||
worldName: '旧版 RPG',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
});
|
||||
const freshRpgEntry = buildRpgEntry({
|
||||
profileId: 'shared-rpg',
|
||||
worldName: '新版 RPG',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
});
|
||||
const middleRpgEntry = buildRpgEntry({
|
||||
profileId: 'middle-rpg',
|
||||
worldName: '中间 RPG',
|
||||
publishedAt: '2026-06-02T00:00:00.000Z',
|
||||
});
|
||||
const updatedOnlyEntry = buildTypedEntry('big-fish', {
|
||||
profileId: 'updated-only',
|
||||
publishedAt: null,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
});
|
||||
const invalidTimeEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'invalid-time',
|
||||
publishedAt: 'not-a-date',
|
||||
updatedAt: 'still-not-a-date',
|
||||
});
|
||||
|
||||
const merged = mergePlatformPublicGalleryEntries(
|
||||
[staleRpgEntry, middleRpgEntry],
|
||||
[invalidTimeEntry, updatedOnlyEntry, freshRpgEntry],
|
||||
);
|
||||
|
||||
expect(merged).toHaveLength(4);
|
||||
expect(merged.map((entry) => entry.profileId)).toEqual([
|
||||
'shared-rpg',
|
||||
'updated-only',
|
||||
'middle-rpg',
|
||||
'invalid-time',
|
||||
]);
|
||||
expect(merged[0]?.worldName).toBe('新版 RPG');
|
||||
expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0);
|
||||
});
|
||||
|
||||
test('platform public gallery flow builds feeds with visibility gates and bark battle fallback', () => {
|
||||
const hiddenBigFish = buildBigFishWork({
|
||||
workId: 'hidden-big-fish',
|
||||
sourceSessionId: 'hidden-big-fish-session',
|
||||
});
|
||||
const hiddenBabyDraft = buildBabyObjectMatchDraft({
|
||||
profileId: 'hidden-baby',
|
||||
});
|
||||
const publishedBarkFallback = buildBarkBattleWork({
|
||||
workId: 'fallback-bark',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
});
|
||||
const draftBarkFallback = buildBarkBattleWork({
|
||||
workId: 'draft-bark',
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
const hiddenFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: [
|
||||
buildRpgEntry({
|
||||
profileId: 'rpg-visible',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
bigFishEntries: [hiddenBigFish],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
puzzleClearEntries: [],
|
||||
barkBattleGalleryEntries: [],
|
||||
barkBattleWorks: [draftBarkFallback, publishedBarkFallback],
|
||||
jumpHopEntries: [],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [hiddenBabyDraft],
|
||||
isBigFishCreationVisible: false,
|
||||
isBabyObjectMatchVisible: false,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
hiddenFeeds.latestEntries.map((entry) =>
|
||||
'sourceType' in entry ? entry.sourceType : 'rpg',
|
||||
),
|
||||
).toEqual(['bark-battle', 'rpg']);
|
||||
expect(hiddenFeeds.latestEntries[0]?.profileId).toBe('fallback-bark');
|
||||
|
||||
const visibleFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: [],
|
||||
bigFishEntries: [hiddenBigFish],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
puzzleClearEntries: [],
|
||||
barkBattleGalleryEntries: [
|
||||
buildBarkBattleWork({
|
||||
workId: 'gallery-bark',
|
||||
publishedAt: '2026-06-05T00:00:00.000Z',
|
||||
updatedAt: '2026-06-05T00:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
barkBattleWorks: [publishedBarkFallback],
|
||||
jumpHopEntries: [],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [hiddenBabyDraft],
|
||||
isBigFishCreationVisible: true,
|
||||
isBabyObjectMatchVisible: true,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
|
||||
expect(visibleFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
|
||||
'gallery-bark',
|
||||
'hidden-baby',
|
||||
'hidden-big-fish-session',
|
||||
]);
|
||||
expect(visibleFeeds.featuredEntries).toEqual(
|
||||
visibleFeeds.latestEntries.slice(0, 6),
|
||||
);
|
||||
});
|
||||
|
||||
test('platform public gallery flow preserves feed tie order and featured slice', () => {
|
||||
const sameTime = '2026-06-04T00:00:00.000Z';
|
||||
const tieFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: [],
|
||||
bigFishEntries: [],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
puzzleClearEntries: [],
|
||||
barkBattleGalleryEntries: [],
|
||||
barkBattleWorks: [
|
||||
buildBarkBattleWork({
|
||||
workId: 'fallback-bark',
|
||||
publishedAt: sameTime,
|
||||
updatedAt: sameTime,
|
||||
}),
|
||||
],
|
||||
jumpHopEntries: [
|
||||
buildJumpHopEntry({
|
||||
profileId: 'jump-hop',
|
||||
publishedAt: sameTime,
|
||||
updatedAt: sameTime,
|
||||
}),
|
||||
],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [],
|
||||
isBigFishCreationVisible: false,
|
||||
isBabyObjectMatchVisible: false,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
|
||||
expect(tieFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
|
||||
'jump-hop',
|
||||
'fallback-bark',
|
||||
]);
|
||||
|
||||
const sliceFeeds = buildPlatformPublicGalleryFeeds({
|
||||
rpgEntries: Array.from({ length: 7 }, (_, index) =>
|
||||
buildRpgEntry({
|
||||
profileId: `rpg-${index}`,
|
||||
publishedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
|
||||
updatedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
|
||||
}),
|
||||
),
|
||||
bigFishEntries: [],
|
||||
match3dEntries: [],
|
||||
puzzleEntries: [],
|
||||
puzzleClearEntries: [],
|
||||
barkBattleGalleryEntries: [],
|
||||
barkBattleWorks: [],
|
||||
jumpHopEntries: [],
|
||||
woodenFishEntries: [],
|
||||
squareHoleEntries: [],
|
||||
visualNovelEntries: [],
|
||||
babyObjectMatchDrafts: [],
|
||||
isBigFishCreationVisible: false,
|
||||
isBabyObjectMatchVisible: false,
|
||||
isVisualNovelCreationOpen: false,
|
||||
});
|
||||
expect(sliceFeeds.featuredEntries).toHaveLength(6);
|
||||
});
|
||||
594
src/components/platform-entry/platformPublicGalleryFlow.ts
Normal file
594
src/components/platform-entry/platformPublicGalleryFlow.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import type { PuzzleClearGalleryCardResponse } from '../../services/puzzle-clear/puzzleClearClient';
|
||||
import {
|
||||
isBarkBattleGalleryEntry,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleClearGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
mapBarkBattleWorkToPlatformGalleryCard,
|
||||
mapBigFishWorkToPlatformGalleryCard,
|
||||
mapJumpHopWorkToPlatformGalleryCard,
|
||||
mapPuzzleClearWorkToPlatformGalleryCard,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
mapSquareHoleWorkToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
mapWoodenFishWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
|
||||
import {
|
||||
mapBarkBattlePublicDetailToWorkSummary,
|
||||
mapPublicWorkDetailToBigFishWork,
|
||||
mapPublicWorkDetailToPuzzleWork,
|
||||
mapPublicWorkDetailToSquareHoleWork,
|
||||
} from './platformPublicWorkDetailFlow';
|
||||
|
||||
export type RecommendRuntimeKind =
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'edutainment'
|
||||
| 'jump-hop'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'puzzle-clear'
|
||||
| 'square-hole'
|
||||
| 'wooden-fish'
|
||||
| 'visual-novel'
|
||||
| 'rpg';
|
||||
|
||||
export type PlatformRecommendRuntimeStartErrorTarget =
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'puzzle-clear'
|
||||
| 'square-hole';
|
||||
|
||||
export type PlatformRecommendRuntimeStartIntent =
|
||||
| {
|
||||
type: 'blocked';
|
||||
errorTarget: PlatformRecommendRuntimeStartErrorTarget;
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
type: 'start-big-fish';
|
||||
work: BigFishWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-puzzle';
|
||||
work: PuzzleWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-puzzle-clear';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-jump-hop';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-wooden-fish';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-match3d';
|
||||
work: Match3DWorkSummary;
|
||||
returnStage: 'work-detail';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-square-hole';
|
||||
work: SquareHoleWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-visual-novel';
|
||||
profileId: string;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-bark-battle';
|
||||
work: BarkBattleWorkSummary;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'start-edutainment';
|
||||
entry: PlatformPublicGalleryCard;
|
||||
returnStage: 'platform';
|
||||
embedded: true;
|
||||
}
|
||||
| {
|
||||
type: 'mark-ready';
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeStartIntentDeps = {
|
||||
selectedPuzzleDetail?: PuzzleWorkSummary | null;
|
||||
barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[];
|
||||
mapMatch3DWork: (
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) => Match3DWorkSummary | null;
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeReadyState = {
|
||||
activeKind: RecommendRuntimeKind | null;
|
||||
hasBabyObjectMatchDraft?: boolean;
|
||||
hasBigFishRun?: boolean;
|
||||
hasJumpHopRun?: boolean;
|
||||
hasMatch3DRun?: boolean;
|
||||
hasPuzzleClearRun?: boolean;
|
||||
hasSquareHoleRun?: boolean;
|
||||
hasVisualNovelRun?: boolean;
|
||||
hasWoodenFishRun?: boolean;
|
||||
puzzleRunEntryProfileId?: string | null;
|
||||
puzzleRunCurrentLevelProfileId?: string | null;
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeAutoStartDecision =
|
||||
| { type: 'noop' }
|
||||
| { type: 'clear' }
|
||||
| { type: 'start'; entry: PlatformPublicGalleryCard };
|
||||
|
||||
export type PlatformRecommendRuntimeAutoStartInput = {
|
||||
isDesktopLayout: boolean;
|
||||
selectionStage: string;
|
||||
platformTab: string;
|
||||
isLoadingPlatform: boolean;
|
||||
entries: readonly PlatformPublicGalleryCard[];
|
||||
activeEntryKey: string | null;
|
||||
isStarting: boolean;
|
||||
hasStartError?: boolean;
|
||||
readyState: PlatformRecommendRuntimeReadyState;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryFeedsInput = {
|
||||
rpgEntries: readonly CustomWorldGalleryCard[];
|
||||
bigFishEntries: readonly BigFishWorkSummary[];
|
||||
match3dEntries: readonly Match3DWorkSummary[];
|
||||
puzzleEntries: readonly PuzzleWorkSummary[];
|
||||
puzzleClearEntries: readonly PuzzleClearGalleryCardResponse[];
|
||||
barkBattleGalleryEntries: readonly BarkBattleWorkSummary[];
|
||||
barkBattleWorks: readonly BarkBattleWorkSummary[];
|
||||
jumpHopEntries: readonly JumpHopGalleryCardResponse[];
|
||||
woodenFishEntries: readonly WoodenFishGalleryCardResponse[];
|
||||
squareHoleEntries: readonly SquareHoleWorkSummary[];
|
||||
visualNovelEntries: readonly VisualNovelWorkSummary[];
|
||||
babyObjectMatchDrafts: readonly BabyObjectMatchDraft[];
|
||||
isBigFishCreationVisible: boolean;
|
||||
isBabyObjectMatchVisible: boolean;
|
||||
isVisualNovelCreationOpen: boolean;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryFeeds = {
|
||||
featuredEntries: PlatformPublicGalleryCard[];
|
||||
latestEntries: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
export function getPlatformPublicGalleryEntryTime(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||
const timestamp = new Date(rawTime).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
export function getPlatformPublicGalleryEntryKey(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
// 同一作品身份由玩法、作者与 profile 共同确定,避免不同玩法共享 profileId 时误合并。
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isPuzzleClearGalleryEntry(entry)
|
||||
? 'puzzle-clear'
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? 'jump-hop'
|
||||
: isWoodenFishGalleryEntry(entry)
|
||||
? 'wooden-fish'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
export function getPlatformRecommendRuntimeKind(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): RecommendRuntimeKind {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish';
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle';
|
||||
}
|
||||
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return 'puzzle-clear';
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return 'jump-hop';
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return 'wooden-fish';
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return 'match3d';
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return 'square-hole';
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return 'visual-novel';
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return 'bark-battle';
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return 'edutainment';
|
||||
}
|
||||
|
||||
return 'rpg';
|
||||
}
|
||||
|
||||
export function resolvePlatformRecommendRuntimeStartIntent(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
deps: PlatformRecommendRuntimeStartIntentDeps,
|
||||
): PlatformRecommendRuntimeStartIntent {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
const work = mapPublicWorkDetailToBigFishWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'big-fish',
|
||||
errorMessage: '当前作品缺少会话信息,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-big-fish',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
const work =
|
||||
deps.selectedPuzzleDetail?.profileId === entry.profileId
|
||||
? deps.selectedPuzzleDetail
|
||||
: mapPublicWorkDetailToPuzzleWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'puzzle',
|
||||
errorMessage: '当前拼图作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-puzzle',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-puzzle-clear',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-jump-hop',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-wooden-fish',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
// 中文注释:抓大鹅推荐 runtime 仍接 Match3D Module 的 Adapter,避免复制素材归一规则。
|
||||
const work = deps.mapMatch3DWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'match3d',
|
||||
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-match3d',
|
||||
work,
|
||||
returnStage: 'work-detail',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
const work = mapPublicWorkDetailToSquareHoleWork(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'square-hole',
|
||||
errorMessage: '当前方洞挑战作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-square-hole',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-visual-novel',
|
||||
profileId: entry.profileId,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
const work =
|
||||
deps.barkBattleGalleryEntries?.find(
|
||||
(item) => item.workId === entry.workId,
|
||||
) ?? mapBarkBattlePublicDetailToWorkSummary(entry);
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
errorTarget: 'bark-battle',
|
||||
errorMessage: '当前汪汪声浪作品信息不完整,暂时无法进入玩法。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'start-bark-battle',
|
||||
work,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'start-edutainment',
|
||||
entry,
|
||||
returnStage: 'platform',
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'mark-ready',
|
||||
};
|
||||
}
|
||||
|
||||
export function isPlatformRecommendRuntimeReadyForEntry(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
state: PlatformRecommendRuntimeReadyState,
|
||||
) {
|
||||
const expectedKind = getPlatformRecommendRuntimeKind(entry);
|
||||
if (state.activeKind !== expectedKind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedKind === 'big-fish') {
|
||||
return Boolean(state.hasBigFishRun);
|
||||
}
|
||||
if (expectedKind === 'jump-hop') {
|
||||
return Boolean(state.hasJumpHopRun);
|
||||
}
|
||||
if (expectedKind === 'wooden-fish') {
|
||||
return Boolean(state.hasWoodenFishRun);
|
||||
}
|
||||
if (expectedKind === 'match3d') {
|
||||
return Boolean(state.hasMatch3DRun);
|
||||
}
|
||||
if (expectedKind === 'puzzle') {
|
||||
return (
|
||||
state.puzzleRunEntryProfileId === entry.profileId ||
|
||||
state.puzzleRunCurrentLevelProfileId === entry.profileId
|
||||
);
|
||||
}
|
||||
if (expectedKind === 'puzzle-clear') {
|
||||
return Boolean(state.hasPuzzleClearRun);
|
||||
}
|
||||
if (expectedKind === 'square-hole') {
|
||||
return Boolean(state.hasSquareHoleRun);
|
||||
}
|
||||
if (expectedKind === 'visual-novel') {
|
||||
return Boolean(state.hasVisualNovelRun);
|
||||
}
|
||||
if (expectedKind === 'bark-battle') {
|
||||
return true;
|
||||
}
|
||||
if (expectedKind === 'edutainment') {
|
||||
return Boolean(state.hasBabyObjectMatchDraft);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolvePlatformRecommendRuntimeAutoStartDecision(
|
||||
input: PlatformRecommendRuntimeAutoStartInput,
|
||||
): PlatformRecommendRuntimeAutoStartDecision {
|
||||
if (
|
||||
input.isDesktopLayout ||
|
||||
input.selectionStage !== 'platform' ||
|
||||
input.platformTab !== 'home' ||
|
||||
input.isLoadingPlatform
|
||||
) {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
if (input.entries.length === 0) {
|
||||
return { type: 'clear' };
|
||||
}
|
||||
|
||||
const activeEntry = input.activeEntryKey
|
||||
? (input.entries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) === input.activeEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRuntimeReady =
|
||||
activeEntry !== null &&
|
||||
isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState);
|
||||
|
||||
if (
|
||||
(activeEntry !== null && isActiveRuntimeReady) ||
|
||||
input.isStarting ||
|
||||
(activeEntry !== null && input.hasStartError)
|
||||
) {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
const nextEntry = activeEntry ?? input.entries[0];
|
||||
return nextEntry ? { type: 'start', entry: nextEntry } : { type: 'clear' };
|
||||
}
|
||||
|
||||
export function isSamePlatformPublicGalleryEntry(
|
||||
left: PlatformPublicGalleryCard,
|
||||
right: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return (
|
||||
getPlatformPublicGalleryEntryKey(left) ===
|
||||
getPlatformPublicGalleryEntryKey(right)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergePlatformPublicGalleryEntries(
|
||||
rpgEntries: readonly CustomWorldGalleryCard[],
|
||||
puzzleEntries: readonly PlatformPublicGalleryCard[],
|
||||
) {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
|
||||
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
|
||||
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values()).sort(
|
||||
(left, right) =>
|
||||
getPlatformPublicGalleryEntryTime(right) -
|
||||
getPlatformPublicGalleryEntryTime(left),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPlatformPublicGalleryFeeds(
|
||||
input: PlatformPublicGalleryFeedsInput,
|
||||
): PlatformPublicGalleryFeeds {
|
||||
const bigFishEntries = input.isBigFishCreationVisible
|
||||
? input.bigFishEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const babyObjectMatchEntries = input.isBabyObjectMatchVisible
|
||||
? input.babyObjectMatchDrafts
|
||||
.filter((draft) => draft.publicationStatus === 'published')
|
||||
.map(mapBabyObjectMatchDraftToPlatformGalleryCard)
|
||||
: [];
|
||||
const barkBattleGalleryEntries = input.barkBattleGalleryEntries.map(
|
||||
mapBarkBattleWorkToPlatformGalleryCard,
|
||||
);
|
||||
const barkBattleFallbackEntries =
|
||||
input.barkBattleGalleryEntries.length === 0
|
||||
? input.barkBattleWorks
|
||||
.filter((work) => work.status === 'published')
|
||||
.map(mapBarkBattleWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const visualNovelEntries = input.isVisualNovelCreationOpen
|
||||
? input.visualNovelEntries.map(mapVisualNovelWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const latestEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
|
||||
...bigFishEntries,
|
||||
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
|
||||
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
...input.puzzleClearEntries.map(mapPuzzleClearWorkToPlatformGalleryCard),
|
||||
...barkBattleGalleryEntries,
|
||||
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
|
||||
...barkBattleFallbackEntries,
|
||||
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
|
||||
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
|
||||
...visualNovelEntries,
|
||||
...babyObjectMatchEntries,
|
||||
]);
|
||||
const featuredEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
|
||||
...bigFishEntries,
|
||||
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
|
||||
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
...input.puzzleClearEntries.map(mapPuzzleClearWorkToPlatformGalleryCard),
|
||||
...(barkBattleGalleryEntries.length > 0
|
||||
? barkBattleGalleryEntries
|
||||
: barkBattleFallbackEntries),
|
||||
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
|
||||
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
|
||||
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
|
||||
...visualNovelEntries,
|
||||
...babyObjectMatchEntries,
|
||||
]).slice(0, 6);
|
||||
|
||||
return {
|
||||
featuredEntries,
|
||||
latestEntries,
|
||||
};
|
||||
}
|
||||
1464
src/components/platform-entry/platformPublicWorkDetailFlow.test.ts
Normal file
1464
src/components/platform-entry/platformPublicWorkDetailFlow.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1201
src/components/platform-entry/platformPublicWorkDetailFlow.ts
Normal file
1201
src/components/platform-entry/platformPublicWorkDetailFlow.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleDraftLevel,
|
||||
PuzzleGeneratedImageCandidate,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
hasRecoverableGeneratedPuzzleDraft,
|
||||
normalizeRecoveredPuzzleDraftSession,
|
||||
} from './platformPuzzleDraftRecoveryModel';
|
||||
|
||||
function buildAnchorPack(): PuzzleAnchorPack {
|
||||
const item = {
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
value: '星桥机关',
|
||||
status: 'confirmed' as const,
|
||||
};
|
||||
return {
|
||||
themePromise: item,
|
||||
visualSubject: item,
|
||||
visualMood: item,
|
||||
compositionHooks: item,
|
||||
tagsAndForbidden: item,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCandidate(
|
||||
overrides: Partial<PuzzleGeneratedImageCandidate> = {},
|
||||
): PuzzleGeneratedImageCandidate {
|
||||
return {
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/candidate-cover.png',
|
||||
assetId: 'asset-candidate-cover',
|
||||
prompt: '星桥机关',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLevel(overrides: Partial<PuzzleDraftLevel> = {}): PuzzleDraftLevel {
|
||||
return {
|
||||
levelId: 'level-1',
|
||||
levelName: '星桥机关',
|
||||
pictureDescription: '星桥机关画面',
|
||||
candidates: [buildCandidate()],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDraft(overrides: Partial<PuzzleResultDraft> = {}): PuzzleResultDraft {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
workTitle: '星桥拼图',
|
||||
workDescription: '修复星桥机关。',
|
||||
levelName: '星桥机关',
|
||||
summary: '把碎片拼回原位。',
|
||||
themeTags: ['星桥', '机关', '修复'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
levels: [buildLevel()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const anchorPack = buildAnchorPack();
|
||||
return {
|
||||
sessionId: 'puzzle-session-1',
|
||||
seedText: '星桥',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'draft_ready',
|
||||
anchorPack,
|
||||
draft: buildDraft(),
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withCompleteLevelAssets(
|
||||
overrides: Partial<PuzzleDraftLevel> = {},
|
||||
): PuzzleDraftLevel {
|
||||
return buildLevel({
|
||||
levelSceneImageSrc: '/level-scene.png',
|
||||
uiSpritesheetImageSrc: '/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc: '/level-background.png',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('platformPuzzleDraftRecoveryModel', () => {
|
||||
test('normalizes and marks recovered puzzle draft ready when asset pack is complete', () => {
|
||||
const normalized = normalizeRecoveredPuzzleDraftSession(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
levels: [withCompleteLevelAssets()],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(true);
|
||||
expect(normalized.draft).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
coverAssetId: 'asset-candidate-cover',
|
||||
selectedCandidateId: 'candidate-1',
|
||||
generationStatus: 'ready',
|
||||
});
|
||||
expect(normalized.draft?.levels?.[0]).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
coverAssetId: 'asset-candidate-cover',
|
||||
selectedCandidateId: 'candidate-1',
|
||||
generationStatus: 'ready',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps half-finished draft generating when only cover candidate exists', () => {
|
||||
const normalized = normalizeRecoveredPuzzleDraftSession(buildSession());
|
||||
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(false);
|
||||
expect(normalized.draft).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
expect(normalized.draft?.levels?.[0]).toMatchObject({
|
||||
coverImageSrc: '/candidate-cover.png',
|
||||
generationStatus: 'generating',
|
||||
});
|
||||
});
|
||||
|
||||
test('requires level scene, ui spritesheet and level background assets together', () => {
|
||||
expect(
|
||||
hasRecoverableGeneratedPuzzleDraft(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
coverImageSrc: '/draft-cover.png',
|
||||
levels: [
|
||||
withCompleteLevelAssets({
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts object keys as recovered asset references', () => {
|
||||
expect(
|
||||
hasRecoverableGeneratedPuzzleDraft(
|
||||
buildSession({
|
||||
draft: buildDraft({
|
||||
coverImageSrc: '/draft-cover.png',
|
||||
levels: [
|
||||
buildLevel({
|
||||
levelSceneImageObjectKey: 'level-scene.png',
|
||||
uiSpritesheetImageObjectKey: 'ui-spritesheet.png',
|
||||
levelBackgroundImageObjectKey: 'level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('leaves sessions without draft unchanged and unrecoverable', () => {
|
||||
const session = buildSession({ draft: null });
|
||||
|
||||
expect(normalizeRecoveredPuzzleDraftSession(session)).toBe(session);
|
||||
expect(hasRecoverableGeneratedPuzzleDraft(session)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
|
||||
function normalizeRecoveryText(value: string | null | undefined) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function hasPuzzleAssetReference(
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
return Boolean(normalizeRecoveryText(imageSrc) || normalizeRecoveryText(objectKey));
|
||||
}
|
||||
|
||||
function resolvePrimaryPuzzleLevel(session: PuzzleAgentSessionSnapshot) {
|
||||
return session.draft?.levels?.[0] ?? null;
|
||||
}
|
||||
|
||||
function resolvePuzzleRecoveryCandidate(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
primaryLevel: PuzzleDraftLevel | null,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
primaryLevel?.candidates.find((candidate) => candidate.selected) ??
|
||||
primaryLevel?.candidates[0] ??
|
||||
draft.candidates.find((candidate) => candidate.selected) ??
|
||||
draft.candidates[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleRecoveryCoverFields(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
const primaryLevel = resolvePrimaryPuzzleLevel(session);
|
||||
const selectedCandidate = resolvePuzzleRecoveryCandidate(
|
||||
session,
|
||||
primaryLevel,
|
||||
);
|
||||
|
||||
return {
|
||||
coverImageSrc:
|
||||
normalizeRecoveryText(draft?.coverImageSrc) ??
|
||||
normalizeRecoveryText(primaryLevel?.coverImageSrc) ??
|
||||
normalizeRecoveryText(selectedCandidate?.imageSrc),
|
||||
coverAssetId:
|
||||
normalizeRecoveryText(draft?.coverAssetId) ??
|
||||
normalizeRecoveryText(primaryLevel?.coverAssetId) ??
|
||||
normalizeRecoveryText(selectedCandidate?.assetId),
|
||||
selectedCandidateId:
|
||||
draft?.selectedCandidateId ??
|
||||
primaryLevel?.selectedCandidateId ??
|
||||
selectedCandidate?.candidateId ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasCompleteGeneratedPuzzleLevelAssets(
|
||||
level: PuzzleDraftLevel | null,
|
||||
coverImageSrc: string | null,
|
||||
) {
|
||||
return Boolean(
|
||||
normalizeRecoveryText(coverImageSrc) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.levelSceneImageSrc,
|
||||
level?.levelSceneImageObjectKey,
|
||||
) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.uiSpritesheetImageSrc,
|
||||
level?.uiSpritesheetImageObjectKey,
|
||||
) &&
|
||||
hasPuzzleAssetReference(
|
||||
level?.levelBackgroundImageSrc,
|
||||
level?.levelBackgroundImageObjectKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRecoverableGeneratedPuzzleDraft(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const primaryLevel = resolvePrimaryPuzzleLevel(session);
|
||||
const { coverImageSrc } = resolvePuzzleRecoveryCoverFields(session);
|
||||
return hasCompleteGeneratedPuzzleLevelAssets(primaryLevel, coverImageSrc);
|
||||
}
|
||||
|
||||
export function normalizeRecoveredPuzzleDraftSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const { coverImageSrc, coverAssetId, selectedCandidateId } =
|
||||
resolvePuzzleRecoveryCoverFields(session);
|
||||
const nextLevels = draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
coverImageSrc: normalizeRecoveryText(level.coverImageSrc)
|
||||
? level.coverImageSrc
|
||||
: coverImageSrc,
|
||||
coverAssetId: normalizeRecoveryText(level.coverAssetId)
|
||||
? level.coverAssetId
|
||||
: coverAssetId,
|
||||
selectedCandidateId:
|
||||
level.selectedCandidateId ?? selectedCandidateId,
|
||||
}
|
||||
: level,
|
||||
);
|
||||
const nextSession = {
|
||||
...session,
|
||||
draft: {
|
||||
...draft,
|
||||
coverImageSrc,
|
||||
coverAssetId,
|
||||
selectedCandidateId,
|
||||
levels: nextLevels,
|
||||
},
|
||||
} satisfies PuzzleAgentSessionSnapshot;
|
||||
const isRecoverable = hasRecoverableGeneratedPuzzleDraft(nextSession);
|
||||
|
||||
if (!isRecoverable) {
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextSession,
|
||||
draft: {
|
||||
...nextSession.draft,
|
||||
generationStatus: 'ready',
|
||||
levels: nextSession.draft.levels?.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
generationStatus: 'ready',
|
||||
}
|
||||
: level,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPuzzleResultProfileId,
|
||||
buildPuzzleResultWorkId,
|
||||
buildPuzzleSessionIdFromProfileId,
|
||||
} from './platformPuzzleIdentityModel';
|
||||
|
||||
describe('platformPuzzleIdentityModel', () => {
|
||||
test('builds stable puzzle result identities from a session id', () => {
|
||||
expect(buildPuzzleResultProfileId(' puzzle-session-ocean ')).toBe(
|
||||
'puzzle-profile-ocean',
|
||||
);
|
||||
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
|
||||
'puzzle-work-ocean',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps legacy suffix inputs usable', () => {
|
||||
expect(buildPuzzleResultProfileId('ocean')).toBe('puzzle-profile-ocean');
|
||||
expect(buildPuzzleResultWorkId('ocean')).toBe('puzzle-work-ocean');
|
||||
});
|
||||
|
||||
test('builds draft runtime session ids from profile ids', () => {
|
||||
expect(buildPuzzleSessionIdFromProfileId(' puzzle-profile-ocean ')).toBe(
|
||||
'puzzle-session-ocean',
|
||||
);
|
||||
expect(buildPuzzleSessionIdFromProfileId('puzzle-work-ocean')).toBeNull();
|
||||
expect(buildPuzzleSessionIdFromProfileId('puzzle-profile-')).toBeNull();
|
||||
});
|
||||
});
|
||||
36
src/components/platform-entry/platformPuzzleIdentityModel.ts
Normal file
36
src/components/platform-entry/platformPuzzleIdentityModel.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** 收口拼图草稿在 session/profile/work 之间的稳定身份互推规则。 */
|
||||
export function buildPuzzleResultProfileId(
|
||||
sessionId: string | null | undefined,
|
||||
) {
|
||||
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
|
||||
return stableSuffix ? `puzzle-profile-${stableSuffix}` : null;
|
||||
}
|
||||
|
||||
export function buildPuzzleResultWorkId(sessionId: string | null | undefined) {
|
||||
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
|
||||
return stableSuffix ? `puzzle-work-${stableSuffix}` : null;
|
||||
}
|
||||
|
||||
export function buildPuzzleSessionIdFromProfileId(
|
||||
profileId: string | null | undefined,
|
||||
) {
|
||||
const normalizedProfileId = profileId?.trim();
|
||||
if (!normalizedProfileId?.startsWith('puzzle-profile-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length);
|
||||
return stableSuffix ? `puzzle-session-${stableSuffix}` : null;
|
||||
}
|
||||
|
||||
function resolvePuzzleSessionStableSuffix(
|
||||
sessionId: string | null | undefined,
|
||||
) {
|
||||
const normalizedSessionId = sessionId?.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
return normalizedSessionId.startsWith('puzzle-session-')
|
||||
? normalizedSessionId.slice('puzzle-session-'.length)
|
||||
: normalizedSessionId;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
PuzzleLeaderboardEntry,
|
||||
PuzzleRunSnapshot,
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel';
|
||||
|
||||
const currentLeaderboard: PuzzleLeaderboardEntry[] = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '本地玩家',
|
||||
elapsedMs: 12000,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
];
|
||||
|
||||
const serviceLevelLeaderboard: PuzzleLeaderboardEntry[] = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '服务端玩家',
|
||||
elapsedMs: 9000,
|
||||
},
|
||||
];
|
||||
|
||||
const serviceRunLeaderboard: PuzzleLeaderboardEntry[] = [
|
||||
{
|
||||
rank: 2,
|
||||
nickname: '全局玩家',
|
||||
elapsedMs: 15000,
|
||||
},
|
||||
];
|
||||
|
||||
function buildPuzzleLevel(
|
||||
overrides: Partial<PuzzleRuntimeLevelSnapshot> = {},
|
||||
): PuzzleRuntimeLevelSnapshot {
|
||||
return {
|
||||
runId: 'run-current',
|
||||
levelIndex: 0,
|
||||
levelId: 'level-1',
|
||||
gridSize: 3,
|
||||
profileId: 'puzzle-profile-current',
|
||||
levelName: '星桥机关',
|
||||
authorDisplayName: '玩家',
|
||||
themeTags: ['星桥'],
|
||||
coverImageSrc: '/cover.png',
|
||||
board: {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
pieces: [],
|
||||
mergedGroups: [],
|
||||
selectedPieceId: null,
|
||||
allTilesResolved: true,
|
||||
},
|
||||
status: 'cleared',
|
||||
startedAtMs: 1000,
|
||||
clearedAtMs: 13000,
|
||||
elapsedMs: 12000,
|
||||
timeLimitMs: 120000,
|
||||
remainingMs: 108000,
|
||||
pausedAccumulatedMs: 0,
|
||||
pauseStartedAtMs: null,
|
||||
freezeAccumulatedMs: 0,
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
leaderboardEntries: currentLeaderboard,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleRun(
|
||||
overrides: Partial<PuzzleRunSnapshot> = {},
|
||||
): PuzzleRunSnapshot {
|
||||
return {
|
||||
runId: 'run-current',
|
||||
entryProfileId: 'puzzle-profile-current',
|
||||
clearedLevelCount: 1,
|
||||
currentLevelIndex: 0,
|
||||
currentGridSize: 3,
|
||||
playedProfileIds: ['puzzle-profile-current'],
|
||||
previousLevelTags: ['星桥'],
|
||||
currentLevel: buildPuzzleLevel(),
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: null,
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [],
|
||||
leaderboardEntries: currentLeaderboard,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('platformPuzzleRuntimeStateModel', () => {
|
||||
test('keeps current run when either current level is missing', () => {
|
||||
const currentRun = buildPuzzleRun({ currentLevel: null });
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(currentRun, buildPuzzleRun()),
|
||||
).toBe(currentRun);
|
||||
|
||||
const serviceRun = buildPuzzleRun({ currentLevel: null });
|
||||
const playableCurrentRun = buildPuzzleRun();
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(playableCurrentRun, serviceRun),
|
||||
).toBe(playableCurrentRun);
|
||||
});
|
||||
|
||||
test('merges service leaderboard and next-level handoff without replacing local level state', () => {
|
||||
const currentRun = buildPuzzleRun({
|
||||
clearedLevelCount: 2,
|
||||
currentLevel: buildPuzzleLevel({
|
||||
runId: 'run-current',
|
||||
status: 'cleared',
|
||||
board: {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
pieces: [
|
||||
{
|
||||
pieceId: 'piece-local',
|
||||
correctRow: 0,
|
||||
correctCol: 0,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
mergedGroupId: null,
|
||||
},
|
||||
],
|
||||
mergedGroups: [],
|
||||
selectedPieceId: 'piece-local',
|
||||
allTilesResolved: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const serviceRun = buildPuzzleRun({
|
||||
runId: 'run-service',
|
||||
entryProfileId: 'puzzle-profile-service',
|
||||
clearedLevelCount: 1,
|
||||
recommendedNextProfileId: 'next-recommended',
|
||||
nextLevelMode: 'similarWorks',
|
||||
nextLevelProfileId: 'next-profile',
|
||||
nextLevelId: 'next-level',
|
||||
recommendedNextWorks: [
|
||||
{
|
||||
profileId: 'next-profile',
|
||||
levelName: '月桥机关',
|
||||
authorDisplayName: '推荐作者',
|
||||
themeTags: ['月桥'],
|
||||
coverImageSrc: '/next-cover.png',
|
||||
similarityScore: 0.91,
|
||||
},
|
||||
],
|
||||
currentLevel: buildPuzzleLevel({
|
||||
runId: 'run-service-level',
|
||||
status: 'playing',
|
||||
leaderboardEntries: serviceLevelLeaderboard,
|
||||
}),
|
||||
});
|
||||
|
||||
const merged = mergePuzzleServiceRuntimeState(currentRun, serviceRun);
|
||||
|
||||
expect(merged.runId).toBe('run-service');
|
||||
expect(merged.entryProfileId).toBe('puzzle-profile-service');
|
||||
expect(merged.clearedLevelCount).toBe(2);
|
||||
expect(merged.recommendedNextProfileId).toBe('next-recommended');
|
||||
expect(merged.nextLevelMode).toBe('similarWorks');
|
||||
expect(merged.nextLevelProfileId).toBe('next-profile');
|
||||
expect(merged.nextLevelId).toBe('next-level');
|
||||
expect(merged.recommendedNextWorks).toEqual(serviceRun.recommendedNextWorks);
|
||||
expect(merged.leaderboardEntries).toEqual(serviceLevelLeaderboard);
|
||||
expect(merged.currentLevel?.status).toBe('cleared');
|
||||
expect(merged.currentLevel?.board.pieces).toEqual(
|
||||
currentRun.currentLevel?.board.pieces,
|
||||
);
|
||||
expect(merged.currentLevel?.leaderboardEntries).toEqual(
|
||||
serviceLevelLeaderboard,
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to service run leaderboard, then current level leaderboard', () => {
|
||||
const currentRun = buildPuzzleRun();
|
||||
const serviceRun = buildPuzzleRun({
|
||||
currentLevel: buildPuzzleLevel({ leaderboardEntries: [] }),
|
||||
leaderboardEntries: serviceRunLeaderboard,
|
||||
});
|
||||
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(currentRun, serviceRun).currentLevel
|
||||
?.leaderboardEntries,
|
||||
).toEqual(serviceRunLeaderboard);
|
||||
|
||||
expect(
|
||||
mergePuzzleServiceRuntimeState(currentRun, {
|
||||
...serviceRun,
|
||||
leaderboardEntries: [],
|
||||
}).currentLevel?.leaderboardEntries,
|
||||
).toEqual(currentLeaderboard);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
|
||||
export function mergePuzzleServiceRuntimeState(
|
||||
currentRun: PuzzleRunSnapshot,
|
||||
serviceRun: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
if (!currentRun.currentLevel || !serviceRun.currentLevel) {
|
||||
return currentRun;
|
||||
}
|
||||
|
||||
const serviceLevel = serviceRun.currentLevel;
|
||||
const leaderboardEntries =
|
||||
serviceLevel.leaderboardEntries.length > 0
|
||||
? serviceLevel.leaderboardEntries
|
||||
: serviceRun.leaderboardEntries;
|
||||
|
||||
// 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。
|
||||
return {
|
||||
...currentRun,
|
||||
runId: serviceRun.runId,
|
||||
entryProfileId: serviceRun.entryProfileId,
|
||||
clearedLevelCount: Math.max(
|
||||
currentRun.clearedLevelCount,
|
||||
serviceRun.clearedLevelCount,
|
||||
),
|
||||
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
|
||||
nextLevelMode: serviceRun.nextLevelMode,
|
||||
nextLevelProfileId: serviceRun.nextLevelProfileId,
|
||||
nextLevelId: serviceRun.nextLevelId,
|
||||
recommendedNextWorks: serviceRun.recommendedNextWorks,
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...currentRun.currentLevel,
|
||||
leaderboardEntries:
|
||||
leaderboardEntries.length > 0
|
||||
? leaderboardEntries
|
||||
: currentRun.currentLevel.leaderboardEntries,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
resolvePlatformRecommendRuntimeAuthPlan,
|
||||
shouldUsePlatformRecommendRuntimeGuestAuth,
|
||||
} from './platformRecommendRuntimeAuthModel';
|
||||
|
||||
test('uses runtime guest auth for anonymous embedded recommendation runtime', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: true,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'runtime-guest',
|
||||
puzzleRuntimeAuthMode: 'isolated',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses background auth for signed-in embedded recommendation runtime', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: true,
|
||||
authUserId: 'user-1',
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'background',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses background auth when embedded runtime has only a stored access token', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: true,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: true,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'background',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not alter auth for non-embedded runtime launches by default', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: false,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'none',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses isolated guest auth for anonymous puzzle isolated launch', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: false,
|
||||
allowRuntimeGuestAuth: true,
|
||||
authUserId: null,
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'runtime-guest',
|
||||
puzzleRuntimeAuthMode: 'isolated',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to default puzzle auth when isolated launch has account auth', () => {
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAuthPlan({
|
||||
embedded: false,
|
||||
allowRuntimeGuestAuth: true,
|
||||
authUserId: 'user-1',
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toEqual({
|
||||
requestKind: 'none',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
test('guest auth decision trims user id before treating account as signed in', () => {
|
||||
expect(
|
||||
shouldUsePlatformRecommendRuntimeGuestAuth({
|
||||
allowRuntimeGuestAuth: true,
|
||||
authUserId: ' ',
|
||||
hasStoredAccessToken: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export type PlatformRecommendRuntimeRequestKind =
|
||||
| 'none'
|
||||
| 'background'
|
||||
| 'runtime-guest';
|
||||
|
||||
export type PlatformPuzzleRuntimeAuthMode = 'default' | 'isolated';
|
||||
|
||||
export type PlatformRecommendRuntimeAuthPlan = {
|
||||
requestKind: PlatformRecommendRuntimeRequestKind;
|
||||
puzzleRuntimeAuthMode: PlatformPuzzleRuntimeAuthMode;
|
||||
};
|
||||
|
||||
export type PlatformRecommendRuntimeAuthInput = {
|
||||
embedded?: boolean;
|
||||
allowRuntimeGuestAuth?: boolean;
|
||||
authUserId?: string | null;
|
||||
hasStoredAccessToken?: boolean;
|
||||
};
|
||||
|
||||
function hasAccountAuth(input: {
|
||||
authUserId?: string | null;
|
||||
hasStoredAccessToken?: boolean;
|
||||
}) {
|
||||
return Boolean(input.authUserId?.trim() || input.hasStoredAccessToken);
|
||||
}
|
||||
|
||||
export function shouldUsePlatformRecommendRuntimeGuestAuth(
|
||||
input: Pick<
|
||||
PlatformRecommendRuntimeAuthInput,
|
||||
'allowRuntimeGuestAuth' | 'authUserId' | 'hasStoredAccessToken'
|
||||
>,
|
||||
) {
|
||||
return Boolean(input.allowRuntimeGuestAuth) && !hasAccountAuth(input);
|
||||
}
|
||||
|
||||
export function resolvePlatformRecommendRuntimeAuthPlan(
|
||||
input: PlatformRecommendRuntimeAuthInput,
|
||||
): PlatformRecommendRuntimeAuthPlan {
|
||||
const embedded = Boolean(input.embedded);
|
||||
const allowRuntimeGuestAuth = input.allowRuntimeGuestAuth ?? embedded;
|
||||
const useRuntimeGuestAuth = shouldUsePlatformRecommendRuntimeGuestAuth({
|
||||
allowRuntimeGuestAuth,
|
||||
authUserId: input.authUserId,
|
||||
hasStoredAccessToken: input.hasStoredAccessToken,
|
||||
});
|
||||
|
||||
if (useRuntimeGuestAuth) {
|
||||
return {
|
||||
requestKind: 'runtime-guest',
|
||||
puzzleRuntimeAuthMode: 'isolated',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requestKind: embedded ? 'background' : 'none',
|
||||
puzzleRuntimeAuthMode: 'default',
|
||||
};
|
||||
}
|
||||
178
src/components/platform-entry/platformRecommendation.test.ts
Normal file
178
src/components/platform-entry/platformRecommendation.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
|
||||
const NOW_MS = Date.parse('2026-06-07T12:00:00.000Z');
|
||||
|
||||
type PublicCardTestParams = {
|
||||
id: string;
|
||||
sourceType?: 'puzzle' | 'match3d' | 'jump-hop';
|
||||
subtitle?: string;
|
||||
summaryText?: string;
|
||||
coverImageSrc?: string | null;
|
||||
themeTags?: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
publishedAt?: string | null;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
function buildPublicCard(
|
||||
params: PublicCardTestParams,
|
||||
): PlatformPublicGalleryCard {
|
||||
const sourceType = params.sourceType ?? 'puzzle';
|
||||
|
||||
return {
|
||||
sourceType,
|
||||
workId: `${sourceType}-work-${params.id}`,
|
||||
profileId: `${sourceType}-profile-${params.id}`,
|
||||
publicWorkCode: `${sourceType.toUpperCase()}-${params.id}`,
|
||||
ownerUserId: `user-${params.id}`,
|
||||
authorDisplayName: `${params.id} 作者`,
|
||||
worldName: `${params.id} 作品`,
|
||||
subtitle: params.subtitle ?? '公开作品',
|
||||
summaryText: params.summaryText ?? '公开作品摘要。',
|
||||
coverImageSrc: params.coverImageSrc ?? `${params.id}.png`,
|
||||
themeTags: params.themeTags ?? ['推荐'],
|
||||
playCount: params.playCount ?? 0,
|
||||
remixCount: params.remixCount ?? 0,
|
||||
likeCount: params.likeCount ?? 0,
|
||||
recentPlayCount7d: params.recentPlayCount7d ?? 0,
|
||||
visibility: 'published',
|
||||
publishedAt: params.publishedAt ?? '2026-06-01T12:00:00.000Z',
|
||||
updatedAt:
|
||||
params.updatedAt ?? params.publishedAt ?? '2026-06-01T12:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
}
|
||||
|
||||
describe('buildPlatformRecommendedEntries', () => {
|
||||
test('combines heat, freshness and featured boost after de-duplicating works', () => {
|
||||
const coldEntry = buildPublicCard({
|
||||
id: 'cold',
|
||||
playCount: 1,
|
||||
publishedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
const hotRecentEntry = buildPublicCard({
|
||||
id: 'hot',
|
||||
playCount: 8,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 16,
|
||||
publishedAt: '2026-06-06T12:00:00.000Z',
|
||||
});
|
||||
const curatedEntry = buildPublicCard({
|
||||
id: 'curated',
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-05-10T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [curatedEntry],
|
||||
latestEntries: [coldEntry, hotRecentEntry, curatedEntry],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
hotRecentEntry.profileId,
|
||||
curatedEntry.profileId,
|
||||
coldEntry.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('interleaves close-score works from different play types', () => {
|
||||
const firstPuzzle = buildPublicCard({
|
||||
id: 'puzzle-a',
|
||||
sourceType: 'puzzle',
|
||||
likeCount: 2,
|
||||
});
|
||||
const secondPuzzle = buildPublicCard({
|
||||
id: 'puzzle-b',
|
||||
sourceType: 'puzzle',
|
||||
likeCount: 2,
|
||||
});
|
||||
const match3d = buildPublicCard({
|
||||
id: 'match3d-a',
|
||||
sourceType: 'match3d',
|
||||
likeCount: 2,
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [firstPuzzle, secondPuzzle, match3d],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
firstPuzzle.profileId,
|
||||
match3d.profileId,
|
||||
secondPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('separates same-type candidates while alternatives remain', () => {
|
||||
const hotPuzzle = buildPublicCard({
|
||||
id: 'hot-puzzle',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 50,
|
||||
likeCount: 20,
|
||||
});
|
||||
const warmPuzzle = buildPublicCard({
|
||||
id: 'warm-puzzle',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 32,
|
||||
likeCount: 12,
|
||||
});
|
||||
const coldMatch3d = buildPublicCard({
|
||||
id: 'cold-match3d',
|
||||
sourceType: 'match3d',
|
||||
publishedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [hotPuzzle, warmPuzzle, coldMatch3d],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
hotPuzzle.profileId,
|
||||
coldMatch3d.profileId,
|
||||
warmPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to same-type adjacency when no other type remains', () => {
|
||||
const firstPuzzle = buildPublicCard({
|
||||
id: 'only-puzzle-a',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 8,
|
||||
});
|
||||
const secondPuzzle = buildPublicCard({
|
||||
id: 'only-puzzle-b',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 4,
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [firstPuzzle, secondPuzzle],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
firstPuzzle.profileId,
|
||||
secondPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
225
src/components/platform-entry/platformRecommendation.ts
Normal file
225
src/components/platform-entry/platformRecommendation.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
buildPlatformPublicGalleryCardKey,
|
||||
isEdutainmentGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkSourceType,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const FEATURED_BONUS = 14;
|
||||
const MAX_FRESHNESS_SCORE = 12;
|
||||
|
||||
export type PlatformRecommendationOptions = {
|
||||
nowMs?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type RecommendationCandidate = {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
key: string;
|
||||
sourceType: string;
|
||||
firstSeenIndex: number;
|
||||
isFeatured: boolean;
|
||||
timestampMs: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type PlatformRecommendationMetricKey =
|
||||
| 'playCount'
|
||||
| 'remixCount'
|
||||
| 'likeCount'
|
||||
| 'recentPlayCount7d';
|
||||
|
||||
function parseRecommendationTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
|
||||
if (numericTimestamp?.[1]) {
|
||||
const rawTimestamp = Number(numericTimestamp[1]);
|
||||
if (Number.isFinite(rawTimestamp)) {
|
||||
const absoluteTimestamp = Math.abs(rawTimestamp);
|
||||
if (absoluteTimestamp >= 1_000_000_000_000_000) {
|
||||
return rawTimestamp / 1000;
|
||||
}
|
||||
if (absoluteTimestamp >= 1_000_000_000_000) {
|
||||
return rawTimestamp;
|
||||
}
|
||||
if (absoluteTimestamp >= 1_000_000_000) {
|
||||
return rawTimestamp * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date(normalized).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) {
|
||||
return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt);
|
||||
}
|
||||
|
||||
function getRecommendationMetric(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
key: PlatformRecommendationMetricKey,
|
||||
) {
|
||||
const value = (
|
||||
entry as Partial<Record<PlatformRecommendationMetricKey, number>>
|
||||
)[key];
|
||||
return Math.max(0, Math.round(Number(value ?? 0) || 0));
|
||||
}
|
||||
|
||||
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return `edutainment:${entry.templateId}`;
|
||||
}
|
||||
|
||||
return resolvePlatformPublicWorkSourceType(entry);
|
||||
}
|
||||
|
||||
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
|
||||
return 'themeTags' in entry && Array.isArray(entry.themeTags)
|
||||
? entry.themeTags
|
||||
: [];
|
||||
}
|
||||
|
||||
function scoreRecommendationCandidate(
|
||||
candidate: Omit<RecommendationCandidate, 'score'>,
|
||||
nowMs: number,
|
||||
) {
|
||||
const entry = candidate.entry;
|
||||
const ageDays =
|
||||
candidate.timestampMs > 0
|
||||
? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const freshnessScore = Number.isFinite(ageDays)
|
||||
? MAX_FRESHNESS_SCORE / (1 + ageDays / 7)
|
||||
: 0;
|
||||
const coverScore = entry.coverImageSrc ? 1.5 : 0;
|
||||
const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6;
|
||||
const summaryScore = entry.summaryText.trim() ? 0.8 : 0;
|
||||
|
||||
return (
|
||||
(candidate.isFeatured ? FEATURED_BONUS : 0) +
|
||||
Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 +
|
||||
freshnessScore +
|
||||
coverScore +
|
||||
tagScore +
|
||||
summaryScore
|
||||
);
|
||||
}
|
||||
|
||||
function compareRecommendationCandidates(
|
||||
left: RecommendationCandidate,
|
||||
right: RecommendationCandidate,
|
||||
) {
|
||||
const scoreDiff = right.score - left.score;
|
||||
if (scoreDiff !== 0) {
|
||||
return scoreDiff;
|
||||
}
|
||||
|
||||
const timeDiff = right.timestampMs - left.timestampMs;
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
|
||||
if (left.firstSeenIndex !== right.firstSeenIndex) {
|
||||
return left.firstSeenIndex - right.firstSeenIndex;
|
||||
}
|
||||
|
||||
return left.key.localeCompare(right.key, 'zh-CN');
|
||||
}
|
||||
|
||||
function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) {
|
||||
const remaining = [...candidates];
|
||||
const result: RecommendationCandidate[] = [];
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const lastSourceType = result[result.length - 1]?.sourceType ?? null;
|
||||
let nextIndex = 0;
|
||||
|
||||
if (lastSourceType) {
|
||||
const alternativeIndex = remaining.findIndex(
|
||||
(candidate) => candidate.sourceType !== lastSourceType,
|
||||
);
|
||||
if (alternativeIndex > 0) {
|
||||
nextIndex = alternativeIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const [nextCandidate] = remaining.splice(nextIndex, 1);
|
||||
if (nextCandidate) {
|
||||
result.push(nextCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildPlatformRecommendedEntries(
|
||||
params: {
|
||||
featuredEntries: PlatformPublicGalleryCard[];
|
||||
latestEntries: PlatformPublicGalleryCard[];
|
||||
},
|
||||
options: PlatformRecommendationOptions = {},
|
||||
) {
|
||||
const candidateMap = new Map<
|
||||
string,
|
||||
Omit<RecommendationCandidate, 'score'>
|
||||
>();
|
||||
let firstSeenIndex = 0;
|
||||
|
||||
const collectEntries = (
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
source: 'featured' | 'latest',
|
||||
) => {
|
||||
entries.forEach((entry) => {
|
||||
const key = buildPlatformPublicGalleryCardKey(entry);
|
||||
const timestampMs = getRecommendationTimestamp(entry);
|
||||
const existing = candidateMap.get(key);
|
||||
if (existing) {
|
||||
existing.isFeatured = existing.isFeatured || source === 'featured';
|
||||
if (timestampMs >= existing.timestampMs) {
|
||||
existing.entry = entry;
|
||||
existing.timestampMs = timestampMs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
candidateMap.set(key, {
|
||||
entry,
|
||||
key,
|
||||
sourceType: getRecommendationSourceType(entry),
|
||||
firstSeenIndex,
|
||||
isFeatured: source === 'featured',
|
||||
timestampMs,
|
||||
});
|
||||
firstSeenIndex += 1;
|
||||
});
|
||||
};
|
||||
|
||||
collectEntries(params.featuredEntries, 'featured');
|
||||
collectEntries(params.latestEntries, 'latest');
|
||||
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const rankedCandidates = Array.from(candidateMap.values())
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
score: scoreRecommendationCandidate(candidate, nowMs),
|
||||
}))
|
||||
.sort(compareRecommendationCandidates);
|
||||
const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates);
|
||||
const limit =
|
||||
typeof options.limit === 'number' && options.limit > 0
|
||||
? Math.floor(options.limit)
|
||||
: diversifiedCandidates.length;
|
||||
|
||||
return diversifiedCandidates
|
||||
.slice(0, limit)
|
||||
.map((candidate) => candidate.entry);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { createRpgCreationPublishedProfileFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
buildPlatformRpgAgentResultPublishGateView,
|
||||
type PlatformRpgAgentResultBlockerView,
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel,
|
||||
} from './platformRpgAgentResultPreviewModel';
|
||||
|
||||
function buildProfile(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): CustomWorldProfile {
|
||||
return {
|
||||
...createRpgCreationPublishedProfileFixture(),
|
||||
worldHook: '潮雾列岛旧灯塔重新点亮。',
|
||||
playerPremise: '玩家从回潮旧灯塔切入沉船旧案。',
|
||||
coreConflicts: ['守灯会与沉船旧案的冲突'],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
const missingWorldHookBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_world_hook',
|
||||
message: '缺少世界钩子',
|
||||
};
|
||||
const missingPlayerPremiseBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_player_premise',
|
||||
message: '缺少玩家前提',
|
||||
};
|
||||
const missingCoreConflictBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_core_conflict',
|
||||
message: '缺少核心冲突',
|
||||
};
|
||||
const missingMainChapterBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_main_chapter',
|
||||
message: '缺少主章节',
|
||||
};
|
||||
const missingFirstActBlocker: PlatformRpgAgentResultBlockerView = {
|
||||
code: 'publish_missing_first_act',
|
||||
message: '缺少首幕',
|
||||
};
|
||||
const structuralBlockers: PlatformRpgAgentResultBlockerView[] = [
|
||||
missingWorldHookBlocker,
|
||||
missingPlayerPremiseBlocker,
|
||||
missingCoreConflictBlocker,
|
||||
missingMainChapterBlocker,
|
||||
missingFirstActBlocker,
|
||||
];
|
||||
|
||||
describe('platformRpgAgentResultPreviewModel', () => {
|
||||
test('uses fallback blockers and publish readiness without a profile', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
null,
|
||||
structuralBlockers.slice(0, 2),
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: ['缺少世界钩子', '缺少玩家前提'],
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('filters structural blockers already satisfied by the profile', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile(),
|
||||
[
|
||||
...structuralBlockers,
|
||||
{
|
||||
code: 'future_blocker',
|
||||
message: '未知服务端阻断',
|
||||
},
|
||||
],
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: ['未知服务端阻断'],
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps unresolved structural blockers when profile fields are empty', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile({
|
||||
worldHook: '',
|
||||
playerPremise: '',
|
||||
settingText: '',
|
||||
creatorIntent: null,
|
||||
anchorContent: null,
|
||||
coreConflicts: [],
|
||||
chapters: [],
|
||||
sceneChapterBlueprints: [],
|
||||
sceneChapters: [],
|
||||
}),
|
||||
structuralBlockers,
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: structuralBlockers.map((entry) => entry.message),
|
||||
publishReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves structural blockers from nested profile compatibility fields', () => {
|
||||
expect(
|
||||
buildPlatformRpgAgentResultPublishGateView(
|
||||
buildProfile({
|
||||
worldHook: '',
|
||||
playerPremise: '',
|
||||
settingText: '',
|
||||
creatorIntent: {
|
||||
worldHook: '旧灯塔潮路重新开启。',
|
||||
},
|
||||
anchorContent: {
|
||||
playerEntryPoint: {
|
||||
openingProblem: '玩家被卷入沉船旧案。',
|
||||
},
|
||||
},
|
||||
coreConflicts: [''],
|
||||
chapters: [],
|
||||
sceneChapterBlueprints: null,
|
||||
sceneChapters: [
|
||||
{
|
||||
acts: [{}],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[
|
||||
missingWorldHookBlocker,
|
||||
missingPlayerPremiseBlocker,
|
||||
missingFirstActBlocker,
|
||||
],
|
||||
false,
|
||||
),
|
||||
).toEqual({
|
||||
blockers: [],
|
||||
publishReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('maps preview source to result label', () => {
|
||||
expect(resolvePlatformRpgAgentResultPreviewSourceLabel(null)).toBeNull();
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('published_profile'),
|
||||
).toBe('已发布世界');
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('session_preview'),
|
||||
).toBe('会话预览');
|
||||
expect(
|
||||
resolvePlatformRpgAgentResultPreviewSourceLabel('future_source'),
|
||||
).toBe(
|
||||
'服务端预览',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { RpgCreationPreviewSource } from '../../../packages/shared/src/contracts/rpgCreationPreview';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformRpgAgentResultBlockerView = {
|
||||
code?: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type PlatformRpgAgentResultPublishGateView = {
|
||||
blockers: string[];
|
||||
publishReady: boolean;
|
||||
};
|
||||
|
||||
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
||||
'publish_missing_world_hook',
|
||||
'publish_missing_player_premise',
|
||||
'publish_missing_core_conflict',
|
||||
'publish_missing_main_chapter',
|
||||
'publish_missing_first_act',
|
||||
]);
|
||||
|
||||
function readProfileTextField(
|
||||
profile: CustomWorldProfile | null,
|
||||
paths: string[],
|
||||
) {
|
||||
for (const path of paths) {
|
||||
let current: unknown = profile;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
current = null;
|
||||
break;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
if (typeof current === 'string' && current.trim()) {
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasProfileTextArray(profile: CustomWorldProfile | null, key: string) {
|
||||
const value = profile
|
||||
? (profile as unknown as Record<string, unknown>)[key]
|
||||
: null;
|
||||
return Array.isArray(value)
|
||||
? value.some((entry) => typeof entry === 'string' && entry.trim())
|
||||
: false;
|
||||
}
|
||||
|
||||
function hasProfileArray(profile: CustomWorldProfile | null, key: string) {
|
||||
const value = profile
|
||||
? (profile as unknown as Record<string, unknown>)[key]
|
||||
: null;
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
}
|
||||
|
||||
function hasSceneAct(profile: CustomWorldProfile | null) {
|
||||
const rawProfile = profile as unknown as Record<string, unknown> | null;
|
||||
const chapters =
|
||||
rawProfile &&
|
||||
(Array.isArray(rawProfile.sceneChapterBlueprints)
|
||||
? rawProfile.sceneChapterBlueprints
|
||||
: Array.isArray(rawProfile.sceneChapters)
|
||||
? rawProfile.sceneChapters
|
||||
: []);
|
||||
return Array.isArray(chapters)
|
||||
? chapters.some((chapter) => {
|
||||
const acts =
|
||||
chapter && typeof chapter === 'object'
|
||||
? (chapter as Record<string, unknown>).acts
|
||||
: null;
|
||||
return Array.isArray(acts) && acts.length > 0;
|
||||
})
|
||||
: false;
|
||||
}
|
||||
|
||||
function isAgentResultStructuralBlockerResolved(
|
||||
profile: CustomWorldProfile,
|
||||
code: string | null | undefined,
|
||||
) {
|
||||
if (!code || !AGENT_RESULT_STRUCTURAL_BLOCKER_CODES.has(code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code === 'publish_missing_world_hook') {
|
||||
return Boolean(
|
||||
readProfileTextField(profile, [
|
||||
'worldHook',
|
||||
'creatorIntent.worldHook',
|
||||
'anchorContent.worldPromise',
|
||||
'anchorContent.worldPromise.hook',
|
||||
'settingText',
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (code === 'publish_missing_player_premise') {
|
||||
return Boolean(
|
||||
readProfileTextField(profile, [
|
||||
'playerPremise',
|
||||
'creatorIntent.playerPremise',
|
||||
'anchorContent.playerEntryPoint',
|
||||
'anchorContent.playerEntryPoint.openingIdentity',
|
||||
'anchorContent.playerEntryPoint.openingProblem',
|
||||
'anchorContent.playerEntryPoint.entryMotivation',
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (code === 'publish_missing_core_conflict') {
|
||||
return hasProfileTextArray(profile, 'coreConflicts');
|
||||
}
|
||||
if (code === 'publish_missing_main_chapter') {
|
||||
return (
|
||||
hasProfileArray(profile, 'chapters') ||
|
||||
hasProfileArray(profile, 'sceneChapterBlueprints') ||
|
||||
hasProfileArray(profile, 'sceneChapters')
|
||||
);
|
||||
}
|
||||
return hasSceneAct(profile);
|
||||
}
|
||||
|
||||
export function buildPlatformRpgAgentResultPublishGateView(
|
||||
profile: CustomWorldProfile | null,
|
||||
fallbackBlockers: PlatformRpgAgentResultBlockerView[],
|
||||
fallbackPublishReady: boolean,
|
||||
): PlatformRpgAgentResultPublishGateView {
|
||||
if (!profile) {
|
||||
return {
|
||||
blockers: fallbackBlockers.map((entry) => entry.message),
|
||||
publishReady: fallbackPublishReady,
|
||||
};
|
||||
}
|
||||
|
||||
const blockers = fallbackBlockers
|
||||
.filter(
|
||||
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
|
||||
return {
|
||||
blockers,
|
||||
publishReady: blockers.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformRpgAgentResultPreviewSourceLabel(
|
||||
source: RpgCreationPreviewSource | string | null | undefined,
|
||||
) {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
if (source === 'published_profile') {
|
||||
return '已发布世界';
|
||||
}
|
||||
if (source === 'session_preview') {
|
||||
return '会话预览';
|
||||
}
|
||||
return '服务端预览';
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
import {
|
||||
type MissingCreationStateParams,
|
||||
resolveSelectionStageAfterMissingCreationState,
|
||||
resolveSelectionStageAfterProtectedDataLoss,
|
||||
} from './platformSelectionStageModel';
|
||||
|
||||
describe('platformSelectionStageModel', () => {
|
||||
test('keeps public and workspace stages after protected data loss', () => {
|
||||
const stableStages: SelectionStage[] = [
|
||||
'platform',
|
||||
'work-detail',
|
||||
'detail',
|
||||
'agent-workspace',
|
||||
'big-fish-agent-workspace',
|
||||
'match3d-agent-workspace',
|
||||
'square-hole-agent-workspace',
|
||||
'jump-hop-workspace',
|
||||
'wooden-fish-workspace',
|
||||
'puzzle-agent-workspace',
|
||||
'bark-battle-workspace',
|
||||
'visual-novel-agent-workspace',
|
||||
'baby-object-match-workspace',
|
||||
'creative-agent-workspace',
|
||||
'puzzle-gallery-detail',
|
||||
];
|
||||
|
||||
stableStages.forEach((stage) => {
|
||||
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(stage);
|
||||
});
|
||||
});
|
||||
|
||||
test('resets private result, generating, runtime and profile stages to platform', () => {
|
||||
const resetStages: SelectionStage[] = [
|
||||
'profile-feedback',
|
||||
'big-fish-generating',
|
||||
'big-fish-result',
|
||||
'big-fish-runtime',
|
||||
'match3d-generating',
|
||||
'match3d-result',
|
||||
'match3d-runtime',
|
||||
'square-hole-generating',
|
||||
'square-hole-result',
|
||||
'square-hole-runtime',
|
||||
'jump-hop-generating',
|
||||
'jump-hop-result',
|
||||
'jump-hop-runtime',
|
||||
'jump-hop-gallery-detail',
|
||||
'wooden-fish-generating',
|
||||
'wooden-fish-result',
|
||||
'wooden-fish-runtime',
|
||||
'visual-novel-generating',
|
||||
'visual-novel-result',
|
||||
'visual-novel-gallery-detail',
|
||||
'visual-novel-runtime',
|
||||
'baby-object-match-generating',
|
||||
'baby-object-match-result',
|
||||
'baby-object-match-runtime',
|
||||
'baby-love-drawing-runtime',
|
||||
'puzzle-generating',
|
||||
'puzzle-onboarding',
|
||||
'puzzle-result',
|
||||
'puzzle-runtime',
|
||||
'custom-world-generating',
|
||||
'custom-world-result',
|
||||
'bark-battle-generating',
|
||||
'bark-battle-result',
|
||||
'bark-battle-runtime',
|
||||
];
|
||||
|
||||
resetStages.forEach((stage) => {
|
||||
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(
|
||||
'platform',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves missing session draft result stages', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-result',
|
||||
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-agent-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-result',
|
||||
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'match3d-result',
|
||||
match3d: { hasSession: true, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('match3d-agent-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'square-hole-result',
|
||||
squareHole: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('square-hole-agent-workspace');
|
||||
});
|
||||
|
||||
test('resolves missing session run stages', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-runtime',
|
||||
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-runtime',
|
||||
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'match3d-runtime',
|
||||
match3d: { hasSession: true, hasSessionDraft: true, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('match3d-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'square-hole-runtime',
|
||||
squareHole: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: true,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('square-hole-result');
|
||||
});
|
||||
|
||||
test('resolves visual novel and baby object missing state stages', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-result',
|
||||
visualNovel: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: false,
|
||||
hasWork: false,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('visual-novel-agent-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-runtime',
|
||||
visualNovel: {
|
||||
hasSession: true,
|
||||
hasSessionDraft: false,
|
||||
hasWork: true,
|
||||
hasWorkDraft: true,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('visual-novel-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-gallery-detail',
|
||||
visualNovel: {
|
||||
hasSession: false,
|
||||
hasSessionDraft: false,
|
||||
hasWork: false,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'baby-object-match-result',
|
||||
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
|
||||
}),
|
||||
),
|
||||
).toBe('baby-object-match-workspace');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'baby-object-match-runtime',
|
||||
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
});
|
||||
|
||||
test('keeps stages when required creation state exists', () => {
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-result',
|
||||
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-result');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'big-fish-runtime',
|
||||
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: true },
|
||||
}),
|
||||
),
|
||||
).toBe('big-fish-runtime');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'visual-novel-gallery-detail',
|
||||
visualNovel: {
|
||||
hasSession: false,
|
||||
hasSessionDraft: false,
|
||||
hasWork: true,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('visual-novel-gallery-detail');
|
||||
expect(
|
||||
resolveSelectionStageAfterMissingCreationState(
|
||||
buildMissingCreationStateParams({
|
||||
stage: 'platform',
|
||||
}),
|
||||
),
|
||||
).toBe('platform');
|
||||
});
|
||||
});
|
||||
|
||||
function buildMissingCreationStateParams(
|
||||
overrides: Partial<MissingCreationStateParams> = {},
|
||||
): MissingCreationStateParams {
|
||||
return {
|
||||
stage: 'platform',
|
||||
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
match3d: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
squareHole: { hasSession: false, hasSessionDraft: false, hasRun: false },
|
||||
visualNovel: {
|
||||
hasSession: false,
|
||||
hasSessionDraft: false,
|
||||
hasWork: false,
|
||||
hasWorkDraft: false,
|
||||
hasRun: false,
|
||||
},
|
||||
babyObjectMatch: { hasDraft: false, hasFormPayload: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
157
src/components/platform-entry/platformSelectionStageModel.ts
Normal file
157
src/components/platform-entry/platformSelectionStageModel.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
||||
platform: true,
|
||||
'profile-feedback': false,
|
||||
'work-detail': true,
|
||||
detail: true,
|
||||
'agent-workspace': true,
|
||||
'big-fish-agent-workspace': true,
|
||||
'big-fish-generating': false,
|
||||
'big-fish-result': false,
|
||||
'big-fish-runtime': false,
|
||||
'match3d-agent-workspace': true,
|
||||
'match3d-generating': false,
|
||||
'match3d-result': false,
|
||||
'match3d-runtime': false,
|
||||
'square-hole-agent-workspace': true,
|
||||
'square-hole-generating': false,
|
||||
'square-hole-result': false,
|
||||
'square-hole-runtime': false,
|
||||
'jump-hop-workspace': true,
|
||||
'jump-hop-generating': false,
|
||||
'jump-hop-result': false,
|
||||
'jump-hop-runtime': false,
|
||||
'jump-hop-gallery-detail': false,
|
||||
'bark-battle-workspace': true,
|
||||
'bark-battle-generating': false,
|
||||
'bark-battle-result': false,
|
||||
'bark-battle-runtime': false,
|
||||
'wooden-fish-workspace': true,
|
||||
'wooden-fish-generating': false,
|
||||
'wooden-fish-result': false,
|
||||
'wooden-fish-runtime': false,
|
||||
'creative-agent-workspace': true,
|
||||
'visual-novel-agent-workspace': true,
|
||||
'visual-novel-generating': false,
|
||||
'visual-novel-result': false,
|
||||
'visual-novel-gallery-detail': false,
|
||||
'visual-novel-runtime': false,
|
||||
'baby-object-match-workspace': true,
|
||||
'baby-object-match-generating': false,
|
||||
'baby-object-match-result': false,
|
||||
'baby-object-match-runtime': false,
|
||||
'baby-love-drawing-runtime': false,
|
||||
'puzzle-agent-workspace': true,
|
||||
'puzzle-generating': false,
|
||||
'puzzle-onboarding': false,
|
||||
'puzzle-result': false,
|
||||
'puzzle-gallery-detail': true,
|
||||
'puzzle-runtime': false,
|
||||
'puzzle-clear-workspace': true,
|
||||
'puzzle-clear-generating': false,
|
||||
'puzzle-clear-result': false,
|
||||
'puzzle-clear-runtime': false,
|
||||
'custom-world-generating': false,
|
||||
'custom-world-result': false,
|
||||
} as const satisfies Record<SelectionStage, boolean>;
|
||||
|
||||
export function resolveSelectionStageAfterProtectedDataLoss(
|
||||
stage: SelectionStage,
|
||||
): SelectionStage {
|
||||
return PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE[stage] ? stage : 'platform';
|
||||
}
|
||||
|
||||
type SessionDraftRunState = {
|
||||
hasSession: boolean;
|
||||
hasSessionDraft: boolean;
|
||||
hasRun: boolean;
|
||||
};
|
||||
|
||||
type VisualNovelCreationState = {
|
||||
hasSession: boolean;
|
||||
hasSessionDraft: boolean;
|
||||
hasWork: boolean;
|
||||
hasWorkDraft: boolean;
|
||||
hasRun: boolean;
|
||||
};
|
||||
|
||||
type BabyObjectMatchCreationState = {
|
||||
hasDraft: boolean;
|
||||
hasFormPayload: boolean;
|
||||
};
|
||||
|
||||
export type MissingCreationStateParams = {
|
||||
stage: SelectionStage;
|
||||
bigFish: SessionDraftRunState;
|
||||
match3d: SessionDraftRunState;
|
||||
squareHole: SessionDraftRunState;
|
||||
visualNovel: VisualNovelCreationState;
|
||||
babyObjectMatch: BabyObjectMatchCreationState;
|
||||
};
|
||||
|
||||
export function resolveSelectionStageAfterMissingCreationState(
|
||||
params: MissingCreationStateParams,
|
||||
): SelectionStage {
|
||||
const { stage } = params;
|
||||
|
||||
if (stage === 'big-fish-result' && !params.bigFish.hasSessionDraft) {
|
||||
return params.bigFish.hasSession ? 'big-fish-agent-workspace' : 'platform';
|
||||
}
|
||||
if (stage === 'big-fish-runtime' && !params.bigFish.hasRun) {
|
||||
return params.bigFish.hasSessionDraft ? 'big-fish-result' : 'platform';
|
||||
}
|
||||
|
||||
if (stage === 'match3d-result' && !params.match3d.hasSessionDraft) {
|
||||
return params.match3d.hasSession ? 'match3d-agent-workspace' : 'platform';
|
||||
}
|
||||
if (stage === 'match3d-runtime' && !params.match3d.hasRun) {
|
||||
return params.match3d.hasSessionDraft ? 'match3d-result' : 'platform';
|
||||
}
|
||||
|
||||
if (stage === 'square-hole-result' && !params.squareHole.hasSessionDraft) {
|
||||
return params.squareHole.hasSession
|
||||
? 'square-hole-agent-workspace'
|
||||
: 'platform';
|
||||
}
|
||||
if (stage === 'square-hole-runtime' && !params.squareHole.hasRun) {
|
||||
return params.squareHole.hasSessionDraft
|
||||
? 'square-hole-result'
|
||||
: 'platform';
|
||||
}
|
||||
|
||||
if (
|
||||
stage === 'visual-novel-result' &&
|
||||
!params.visualNovel.hasSessionDraft &&
|
||||
!params.visualNovel.hasWorkDraft
|
||||
) {
|
||||
return params.visualNovel.hasSession
|
||||
? 'visual-novel-agent-workspace'
|
||||
: 'platform';
|
||||
}
|
||||
if (stage === 'visual-novel-runtime' && !params.visualNovel.hasRun) {
|
||||
return params.visualNovel.hasSessionDraft || params.visualNovel.hasWorkDraft
|
||||
? 'visual-novel-result'
|
||||
: 'platform';
|
||||
}
|
||||
if (stage === 'visual-novel-gallery-detail' && !params.visualNovel.hasWork) {
|
||||
return 'platform';
|
||||
}
|
||||
|
||||
if (
|
||||
stage === 'baby-object-match-result' &&
|
||||
!params.babyObjectMatch.hasDraft
|
||||
) {
|
||||
return params.babyObjectMatch.hasFormPayload
|
||||
? 'baby-object-match-workspace'
|
||||
: 'platform';
|
||||
}
|
||||
if (
|
||||
stage === 'baby-object-match-runtime' &&
|
||||
!params.babyObjectMatch.hasDraft
|
||||
) {
|
||||
return 'platform';
|
||||
}
|
||||
|
||||
return stage;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
|
||||
describe('isPuzzleCompileActionReady', () => {
|
||||
it('keeps compile action generating until the draft has a cover image', () => {
|
||||
const session = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
draft: {
|
||||
coverImageSrc: null,
|
||||
levels: [
|
||||
{
|
||||
generationStatus: 'generating',
|
||||
coverImageSrc: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as PuzzleAgentSessionSnapshot;
|
||||
|
||||
expect(isPuzzleCompileActionReady(session)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps compile action generating when only the selected cover exists', () => {
|
||||
const session = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
draft: {
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
levels: [
|
||||
{
|
||||
generationStatus: 'generating',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as PuzzleAgentSessionSnapshot;
|
||||
|
||||
expect(isPuzzleCompileActionReady(session)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats compile action as ready after all runtime assets exist', () => {
|
||||
const session = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
draft: {
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
levels: [
|
||||
{
|
||||
generationStatus: 'ready',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as PuzzleAgentSessionSnapshot;
|
||||
|
||||
expect(isPuzzleCompileActionReady(session)).toBe(true);
|
||||
});
|
||||
});
|
||||
36
src/components/platform-entry/puzzleDraftGenerationState.ts
Normal file
36
src/components/platform-entry/puzzleDraftGenerationState.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
|
||||
function hasText(value: string | null | undefined) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasAssetReference(
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
return hasText(imageSrc) || hasText(objectKey);
|
||||
}
|
||||
|
||||
export function isPuzzleCompileActionReady(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
const draft = session.draft;
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
draft.levels?.some(
|
||||
(level) =>
|
||||
(hasText(draft.coverImageSrc) || hasText(level.coverImageSrc)) &&
|
||||
hasAssetReference(level.levelSceneImageSrc, level.levelSceneImageObjectKey) &&
|
||||
hasAssetReference(
|
||||
level.uiSpritesheetImageSrc,
|
||||
level.uiSpritesheetImageObjectKey,
|
||||
) &&
|
||||
hasAssetReference(
|
||||
level.levelBackgroundImageSrc,
|
||||
level.levelBackgroundImageObjectKey,
|
||||
),
|
||||
) === true
|
||||
);
|
||||
}
|
||||
@@ -300,6 +300,91 @@ function ActionCompleteHarness({
|
||||
);
|
||||
}
|
||||
|
||||
function BeforeActionHarness({ events }: { events: string[] }) {
|
||||
const hasOpenedRef = useRef(false);
|
||||
const flow = usePlatformCreationAgentFlowController<
|
||||
ActionTestSession,
|
||||
Record<string, never>,
|
||||
{ session: ActionTestSession },
|
||||
TestMessagePayload,
|
||||
{ action: string },
|
||||
{ session: ActionTestSession }
|
||||
>({
|
||||
client: {
|
||||
createSession: async () => ({
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
},
|
||||
}),
|
||||
getSession: async () => ({
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
},
|
||||
}),
|
||||
streamMessage: async () => ({
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
}),
|
||||
executeAction: async () => {
|
||||
events.push('executeAction');
|
||||
return {
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-ready-1' },
|
||||
},
|
||||
};
|
||||
},
|
||||
selectSession: (response) => response.session,
|
||||
},
|
||||
createPayload: {},
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
resultStage: 'match3d-result',
|
||||
platformStage: 'platform',
|
||||
isCompileAction: () => true,
|
||||
resolveErrorMessage: (error, fallback) =>
|
||||
error instanceof Error ? error.message : fallback,
|
||||
errorMessages: {
|
||||
open: '打开失败',
|
||||
restoreMissingSession: '缺少会话',
|
||||
restore: '恢复失败',
|
||||
submit: '发送失败',
|
||||
execute: '执行失败',
|
||||
},
|
||||
enterCreateTab: () => {},
|
||||
setSelectionStage: () => {},
|
||||
beforeExecuteAction: async () => {
|
||||
events.push('beforeExecuteAction');
|
||||
await Promise.resolve();
|
||||
events.push('permissionResolved');
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasOpenedRef.current = true;
|
||||
void flow.openWorkspace({});
|
||||
}, [flow]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void flow.executeAction({ action: 'match3d_compile_draft' });
|
||||
}}
|
||||
>
|
||||
执行
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionChangeHarness({
|
||||
onSessionChanged,
|
||||
}: {
|
||||
@@ -547,6 +632,28 @@ test('creation agent flow suppresses compile result stage for background complet
|
||||
);
|
||||
});
|
||||
|
||||
test('creation agent flow waits for beforeExecuteAction before network action', async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
render(<BeforeActionHarness events={events} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '执行' })).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: '执行' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(events).toEqual([
|
||||
'beforeExecuteAction',
|
||||
'permissionResolved',
|
||||
'executeAction',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('creation agent flow notifies session changes after open restore and compile', async () => {
|
||||
const onSessionChanged = vi.fn();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { TextStreamOptions } from '../../services/aiTypes';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
@@ -90,7 +90,7 @@ type PlatformCreationAgentFlowControllerOptions<
|
||||
beforeExecuteAction?: (params: {
|
||||
payload: TActionPayload;
|
||||
session: TSession;
|
||||
}) => void;
|
||||
}) => void | Promise<void>;
|
||||
onActionError?: (params: {
|
||||
payload: TActionPayload;
|
||||
error: unknown;
|
||||
@@ -211,7 +211,7 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[isBusy, options, resetStreamingReply],
|
||||
[isBusy, options, resetStreamingReply, setSession],
|
||||
);
|
||||
|
||||
const restoreDraft = useCallback(
|
||||
@@ -249,7 +249,7 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[options, resetStreamingReply],
|
||||
[options, resetStreamingReply, setSession],
|
||||
);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
@@ -309,7 +309,13 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsStreamingReply(false);
|
||||
}
|
||||
},
|
||||
[isStreamingReply, options, session, updateStreamingReplyText],
|
||||
[
|
||||
isStreamingReply,
|
||||
options,
|
||||
session,
|
||||
setSession,
|
||||
updateStreamingReplyText,
|
||||
],
|
||||
);
|
||||
|
||||
const executeAction = useCallback(
|
||||
@@ -323,7 +329,7 @@ export function usePlatformCreationAgentFlowController<
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
options.beforeExecuteAction?.({ payload, session: targetSession });
|
||||
await options.beforeExecuteAction?.({ payload, session: targetSession });
|
||||
const response = await options.client.executeAction(
|
||||
targetSession.sessionId,
|
||||
payload,
|
||||
@@ -358,7 +364,7 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[isBusy, options, session],
|
||||
[isBusy, options, session, setSession],
|
||||
);
|
||||
|
||||
const leaveFlow = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleClearSessionResponse } from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import { puzzleClearClient } from '../../services/puzzle-clear/puzzleClearClient';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { PuzzleClearWorkspace } from './PuzzleClearWorkspace';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
refreshKey: _refreshKey,
|
||||
...rest
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
refreshKey?: unknown;
|
||||
[key: string]: unknown;
|
||||
}) =>
|
||||
src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
{...(rest as ImgHTMLAttributes<HTMLImageElement>)}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzleReferenceImage', () => ({
|
||||
readPuzzleReferenceImageAsDataUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-clear/puzzleClearClient', () => ({
|
||||
puzzleClearClient: {
|
||||
createSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function createSessionResponse(): PuzzleClearSessionResponse {
|
||||
return {
|
||||
session: {
|
||||
sessionId: 'puzzle-clear-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'draft',
|
||||
draft: {
|
||||
templateId: 'puzzle-clear',
|
||||
templateName: '拼消消',
|
||||
profileId: null,
|
||||
workTitle: '星港拼消消',
|
||||
workDescription: '霓虹星港主题',
|
||||
themePrompt: '霓虹星港',
|
||||
boardBackgroundPrompt: '星港中央棋盘底图',
|
||||
generateBoardBackground: false,
|
||||
boardBackgroundAsset: null,
|
||||
cardBackImageSrc: '/creation-type-references/puzzle-clear-card-back.webp',
|
||||
atlasAsset: null,
|
||||
patternGroups: [],
|
||||
cardAssets: [],
|
||||
generationStatus: 'draft',
|
||||
},
|
||||
createdAt: '2026-05-30T00:00:00.000Z',
|
||||
updatedAt: '2026-05-30T00:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(puzzleClearClient.createSession).mockReset();
|
||||
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockReset();
|
||||
});
|
||||
|
||||
test('工作台提交结构化表单与底图槽位 payload', async () => {
|
||||
const response = createSessionResponse();
|
||||
const onSubmitted = vi.fn();
|
||||
vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response);
|
||||
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
|
||||
'data:image/png;base64,board-background',
|
||||
);
|
||||
|
||||
render(
|
||||
<PuzzleClearWorkspace
|
||||
onBack={vi.fn()}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('作品标题'), {
|
||||
target: { value: ' 星港拼消消 ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('简介'), {
|
||||
target: { value: ' 霓虹星港主题 ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('主题词'), {
|
||||
target: { value: ' 霓虹星港 ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('场地底图'), {
|
||||
target: { value: '星港中央棋盘底图' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('上传底图'), {
|
||||
target: {
|
||||
files: [
|
||||
new File(['fake-image'], 'board.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(readPuzzleReferenceImageAsDataUrl).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(puzzleClearClient.createSession).toHaveBeenCalledWith({
|
||||
templateId: 'puzzle-clear',
|
||||
workTitle: '星港拼消消',
|
||||
workDescription: '霓虹星港主题',
|
||||
themePrompt: '霓虹星港',
|
||||
boardBackgroundPrompt: '星港中央棋盘底图',
|
||||
generateBoardBackground: false,
|
||||
boardBackgroundAsset: expect.objectContaining({
|
||||
imageSrc: 'data:image/png;base64,board-background',
|
||||
generationProvider: 'local-upload',
|
||||
prompt: '星港中央棋盘底图',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(onSubmitted).toHaveBeenCalledWith(
|
||||
response,
|
||||
expect.objectContaining({
|
||||
templateId: 'puzzle-clear',
|
||||
workTitle: '星港拼消消',
|
||||
themePrompt: '霓虹星港',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('工作台不渲染聊天式 Agent 输入', () => {
|
||||
render(
|
||||
<PuzzleClearWorkspace
|
||||
onBack={vi.fn()}
|
||||
onSubmitted={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/发送消息|聊天|对话|输入想法/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('关闭 AI 生成底图且未上传底图时不允许提交', async () => {
|
||||
render(
|
||||
<PuzzleClearWorkspace
|
||||
onBack={vi.fn()}
|
||||
onSubmitted={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('作品标题'), {
|
||||
target: { value: '星港拼消消' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('主题词'), {
|
||||
target: { value: '霓虹星港' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'AI 生成底图' }));
|
||||
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '生成' }) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(puzzleClearClient.createSession).not.toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
test('工作台支持原生表单提交生成', async () => {
|
||||
const response = createSessionResponse();
|
||||
const onSubmitted = vi.fn();
|
||||
vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response);
|
||||
|
||||
render(
|
||||
<PuzzleClearWorkspace
|
||||
onBack={vi.fn()}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('作品标题'), {
|
||||
target: { value: '星港拼消消' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('主题词'), {
|
||||
target: { value: '霓虹星港' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('场地底图'), {
|
||||
target: { value: '星港中央棋盘底图' },
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '生成' });
|
||||
const form = submitButton.closest('form');
|
||||
expect(form).toBeTruthy();
|
||||
fireEvent.submit(form!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(puzzleClearClient.createSession).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(onSubmitted).toHaveBeenCalledWith(
|
||||
response,
|
||||
expect.objectContaining({
|
||||
templateId: 'puzzle-clear',
|
||||
workTitle: '星港拼消消',
|
||||
}),
|
||||
);
|
||||
});
|
||||
326
src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx
Normal file
326
src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { ArrowLeft, Loader2, Send } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
PuzzleClearImageAsset,
|
||||
PuzzleClearSessionResponse,
|
||||
PuzzleClearWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import { puzzleClearClient } from '../../services/puzzle-clear/puzzleClearClient';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
|
||||
type PuzzleClearWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitted: (
|
||||
result: PuzzleClearSessionResponse,
|
||||
payload: PuzzleClearWorkspaceCreateRequest,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type PuzzleClearWorkspaceFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themePrompt: string;
|
||||
boardBackgroundPrompt: string;
|
||||
boardBackgroundAsset: PuzzleClearImageAsset | null;
|
||||
boardBackgroundImageSrc: string;
|
||||
generateBoardBackground: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_FORM_STATE: PuzzleClearWorkspaceFormState = {
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
themePrompt: '',
|
||||
boardBackgroundPrompt: '',
|
||||
boardBackgroundAsset: null,
|
||||
boardBackgroundImageSrc: '',
|
||||
generateBoardBackground: true,
|
||||
};
|
||||
|
||||
function buildLocalBoardBackgroundAsset(
|
||||
imageSrc: string,
|
||||
prompt: string,
|
||||
): PuzzleClearImageAsset {
|
||||
return {
|
||||
assetId: `local-board-background-${Date.now()}`,
|
||||
imageSrc,
|
||||
imageObjectKey: '',
|
||||
assetObjectId: '',
|
||||
generationProvider: 'local-upload',
|
||||
prompt,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function PuzzleClearWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
}: PuzzleClearWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const hasBoardBackgroundInput = useMemo(
|
||||
() =>
|
||||
formState.generateBoardBackground ||
|
||||
Boolean(formState.boardBackgroundAsset || formState.boardBackgroundImageSrc),
|
||||
[
|
||||
formState.boardBackgroundAsset,
|
||||
formState.boardBackgroundImageSrc,
|
||||
formState.generateBoardBackground,
|
||||
],
|
||||
);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
formState.workTitle.trim() &&
|
||||
formState.themePrompt.trim() &&
|
||||
hasBoardBackgroundInput,
|
||||
),
|
||||
[formState.themePrompt, formState.workTitle, hasBoardBackgroundInput],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSubmitting || isBusy) {
|
||||
setLocalError('请先补全输入。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const boardBackgroundAsset =
|
||||
formState.boardBackgroundAsset ??
|
||||
(formState.boardBackgroundImageSrc
|
||||
? buildLocalBoardBackgroundAsset(
|
||||
formState.boardBackgroundImageSrc,
|
||||
formState.boardBackgroundPrompt.trim() ||
|
||||
formState.themePrompt.trim(),
|
||||
)
|
||||
: null);
|
||||
const payload: PuzzleClearWorkspaceCreateRequest = {
|
||||
templateId: 'puzzle-clear',
|
||||
workTitle: formState.workTitle.trim(),
|
||||
workDescription: formState.workDescription.trim(),
|
||||
themePrompt: formState.themePrompt.trim(),
|
||||
boardBackgroundPrompt: formState.boardBackgroundPrompt.trim(),
|
||||
generateBoardBackground: formState.generateBoardBackground,
|
||||
boardBackgroundAsset,
|
||||
};
|
||||
const response = await puzzleClearClient.createSession(payload);
|
||||
onSubmitted(response, payload);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)]">
|
||||
<section className="platform-subpanel flex min-h-0 flex-col gap-3 overflow-y-auto rounded-[1.25rem] p-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标题
|
||||
</span>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
maxLength={32}
|
||||
disabled={isBusy || isSubmitting}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
简介
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.workDescription}
|
||||
maxLength={120}
|
||||
disabled={isBusy || isSubmitting}
|
||||
rows={4}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题词
|
||||
</span>
|
||||
<input
|
||||
value={formState.themePrompt}
|
||||
maxLength={80}
|
||||
disabled={isBusy || isSubmitting}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themePrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<span className="text-sm font-bold text-[var(--platform-text-strong)]">
|
||||
AI 生成底图
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.generateBoardBackground}
|
||||
disabled={isBusy || isSubmitting}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
generateBoardBackground: event.target.checked,
|
||||
}))
|
||||
}
|
||||
className="h-5 w-5 accent-[var(--platform-accent)]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{localError || error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{localError ?? error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="flex min-h-[28rem] min-w-0 flex-col">
|
||||
<CreativeImageInputPanel
|
||||
disabled={isBusy || isSubmitting}
|
||||
isSubmitting={isSubmitting}
|
||||
uploadedImageSrc={formState.boardBackgroundImageSrc}
|
||||
uploadedImageAlt="场地底图"
|
||||
mainImageInputId="puzzle-clear-board-background"
|
||||
promptTextareaId="puzzle-clear-board-background-prompt"
|
||||
prompt={formState.boardBackgroundPrompt}
|
||||
promptLabel="场地底图"
|
||||
promptRows={5}
|
||||
aiRedraw={formState.generateBoardBackground}
|
||||
promptReferenceImages={[]}
|
||||
showSubmitButton={false}
|
||||
submitLabel="生成"
|
||||
submitDisabled={!canSubmit || isSubmitting || isBusy}
|
||||
labels={{
|
||||
imageField: '中央底图',
|
||||
uploadImage: '上传底图',
|
||||
replaceImage: '替换底图',
|
||||
emptyImageHint: '上传图像',
|
||||
removeImage: '移除底图',
|
||||
removeImageConfirmTitle: '移除底图',
|
||||
removeImageConfirmBody: '移除后将使用主题词生成中央场地底图。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '场地底图参考',
|
||||
closePromptReferencePreview: '关闭预览',
|
||||
}}
|
||||
onMainImageFileSelect={(file) => {
|
||||
void readPuzzleReferenceImageAsDataUrl(file)
|
||||
.then((dataUrl) => {
|
||||
setLocalError(null);
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
boardBackgroundImageSrc: dataUrl,
|
||||
boardBackgroundAsset: buildLocalBoardBackgroundAsset(
|
||||
dataUrl,
|
||||
current.boardBackgroundPrompt.trim() ||
|
||||
current.themePrompt.trim(),
|
||||
),
|
||||
generateBoardBackground: false,
|
||||
}));
|
||||
})
|
||||
.catch((caughtError) => {
|
||||
setLocalError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '底图读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
onMainImageRemove={() => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
boardBackgroundImageSrc: '',
|
||||
boardBackgroundAsset: null,
|
||||
}));
|
||||
}}
|
||||
onAiRedrawChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
generateBoardBackground: value,
|
||||
}))
|
||||
}
|
||||
onPromptChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
boardBackgroundPrompt: value,
|
||||
}))
|
||||
}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit || isSubmitting || isBusy}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${
|
||||
!canSubmit || isSubmitting || isBusy
|
||||
? 'cursor-not-allowed opacity-55'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleClearWorkspace;
|
||||
@@ -0,0 +1,184 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
PuzzleClearCardAsset,
|
||||
PuzzleClearPatternGroup,
|
||||
PuzzleClearWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import { PuzzleClearResultView } from './PuzzleClearResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}) =>
|
||||
src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
{...(rest as ImgHTMLAttributes<HTMLImageElement>)}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
function createPatternGroup(index: number): PuzzleClearPatternGroup {
|
||||
return {
|
||||
groupId: `group-${index}`,
|
||||
shape: '1x2',
|
||||
width: 2,
|
||||
height: 1,
|
||||
atlasX: index * 64,
|
||||
atlasY: 0,
|
||||
atlasWidth: 128,
|
||||
atlasHeight: 64,
|
||||
};
|
||||
}
|
||||
|
||||
function createCard(index: number): PuzzleClearCardAsset {
|
||||
return {
|
||||
cardId: `card-${index}`,
|
||||
groupId: `group-${Math.floor(index / 2)}`,
|
||||
shape: '1x2',
|
||||
orientation: 'horizontal',
|
||||
partX: index % 2,
|
||||
partY: 0,
|
||||
imageSrc: `/cards/card-${index}.png`,
|
||||
imageObjectKey: `generated-puzzle-clear-assets/card-${index}.png`,
|
||||
assetObjectId: `assetobj_card_${index}`,
|
||||
sourceAtlasCell: `${index}:0:0`,
|
||||
};
|
||||
}
|
||||
|
||||
function createProfile(
|
||||
overrides: Partial<PuzzleClearWorkProfileResponse['summary']> = {},
|
||||
): PuzzleClearWorkProfileResponse {
|
||||
const atlasAsset = {
|
||||
assetId: 'atlas-1',
|
||||
imageSrc: '/atlas.png',
|
||||
imageObjectKey: 'generated-puzzle-clear-assets/atlas.png',
|
||||
assetObjectId: 'assetobj_atlas',
|
||||
generationProvider: 'gpt-image-2',
|
||||
prompt: '星港',
|
||||
width: 2560,
|
||||
height: 2560,
|
||||
};
|
||||
const boardBackgroundAsset = {
|
||||
...atlasAsset,
|
||||
assetId: 'board-background-1',
|
||||
imageSrc: '/board-background.png',
|
||||
imageObjectKey: 'generated-puzzle-clear-assets/board-background.png',
|
||||
assetObjectId: 'assetobj_board_background',
|
||||
};
|
||||
const patternGroups = Array.from({ length: 35 }, (_, index) =>
|
||||
createPatternGroup(index),
|
||||
);
|
||||
const cardAssets = Array.from({ length: 95 }, (_, index) => createCard(index));
|
||||
const draft = {
|
||||
templateId: 'puzzle-clear',
|
||||
templateName: '拼消消',
|
||||
profileId: 'puzzle-clear-profile-12345678',
|
||||
workTitle: '星港拼消消',
|
||||
workDescription: '霓虹星港主题',
|
||||
themePrompt: '星港',
|
||||
boardBackgroundPrompt: '星港中央棋盘底图',
|
||||
generateBoardBackground: true,
|
||||
boardBackgroundAsset,
|
||||
cardBackImageSrc: '/creation-type-references/puzzle-clear-card-back.webp',
|
||||
atlasAsset,
|
||||
patternGroups,
|
||||
cardAssets,
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
return {
|
||||
summary: {
|
||||
runtimeKind: 'puzzle-clear',
|
||||
workId: 'puzzle-clear-work-12345678',
|
||||
profileId: 'puzzle-clear-profile-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-clear-session-1',
|
||||
workTitle: '星港拼消消',
|
||||
workDescription: '霓虹星港主题',
|
||||
themePrompt: '星港',
|
||||
coverImageSrc: '/atlas.png',
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-30T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
...overrides,
|
||||
},
|
||||
draft,
|
||||
boardBackgroundAsset,
|
||||
atlasAsset,
|
||||
patternGroups,
|
||||
cardAssets,
|
||||
};
|
||||
}
|
||||
|
||||
test('结果页展示 atlas、中央底图与卡牌预览,并触发试玩、发布和图集重试', async () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const onPublish = vi.fn();
|
||||
const onRegenerateAtlas = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<PuzzleClearResultView
|
||||
profile={createProfile()}
|
||||
onBack={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onStartTestRun={onStartTestRun}
|
||||
onPublish={onPublish}
|
||||
onRegenerateAtlas={onRegenerateAtlas}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('场地底图').getAttribute('src')).toBe(
|
||||
'/board-background.png',
|
||||
);
|
||||
expect(screen.getByAltText('素材图集').getAttribute('src')).toBe('/atlas.png');
|
||||
expect(screen.getByText('35')).not.toBeNull();
|
||||
expect(screen.getByText('95')).not.toBeNull();
|
||||
expect(container.querySelectorAll('img[src^="/cards/"]')).toHaveLength(24);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /试玩/u }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /图集/u }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledTimes(1);
|
||||
expect(onPublish).toHaveBeenCalledTimes(1);
|
||||
expect(onRegenerateAtlas).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('结果页在素材未发布就绪时禁用发布,且不写入规则说明文案', () => {
|
||||
render(
|
||||
<PuzzleClearResultView
|
||||
profile={createProfile({ publishReady: false })}
|
||||
onBack={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onStartTestRun={vi.fn()}
|
||||
onPublish={vi.fn()}
|
||||
onRegenerateAtlas={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByRole('button', { name: /发布/u }) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.queryByText(/规则|玩法说明|拖动卡片|拼接完整/u)).toBeNull();
|
||||
});
|
||||
223
src/components/puzzle-clear-result/PuzzleClearResultView.tsx
Normal file
223
src/components/puzzle-clear-result/PuzzleClearResultView.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { ArrowLeft, Loader2, Play, RefreshCcw, Send } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type {
|
||||
PuzzleClearDraftResponse,
|
||||
PuzzleClearWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleClear';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleClearResultViewProps = {
|
||||
profile: PuzzleClearDraftResponse | PuzzleClearWorkProfileResponse;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
onStartTestRun: () => void;
|
||||
onPublish: () => void;
|
||||
onRegenerateAtlas: () => void;
|
||||
};
|
||||
|
||||
function isPuzzleClearWorkProfile(
|
||||
profile: PuzzleClearResultViewProps['profile'],
|
||||
): profile is PuzzleClearWorkProfileResponse {
|
||||
return 'summary' in profile;
|
||||
}
|
||||
|
||||
function getDraft(profile: PuzzleClearResultViewProps['profile']) {
|
||||
return isPuzzleClearWorkProfile(profile) ? profile.draft : profile;
|
||||
}
|
||||
|
||||
export function PuzzleClearResultView({
|
||||
profile,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onEdit,
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
onRegenerateAtlas,
|
||||
}: PuzzleClearResultViewProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const isWorkProfile = isPuzzleClearWorkProfile(profile);
|
||||
const draft = getDraft(profile);
|
||||
const summary = isWorkProfile ? profile.summary : null;
|
||||
const title = summary?.workTitle?.trim() || draft.workTitle.trim() || '拼消消';
|
||||
const description =
|
||||
summary?.workDescription?.trim() || draft.workDescription.trim();
|
||||
const boardBackgroundAsset = isWorkProfile
|
||||
? profile.boardBackgroundAsset ?? draft.boardBackgroundAsset
|
||||
: draft.boardBackgroundAsset;
|
||||
const atlasAsset = isWorkProfile ? profile.atlasAsset : draft.atlasAsset;
|
||||
const patternGroups = isWorkProfile ? profile.patternGroups : draft.patternGroups;
|
||||
const cardAssets = isWorkProfile ? profile.cardAssets : draft.cardAssets;
|
||||
const previewCards = cardAssets.slice(0, 24);
|
||||
const canPublish = Boolean(isWorkProfile && summary?.publishReady);
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
await Promise.resolve(onPublish());
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateAtlas}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
图集
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(19rem,0.95fr)]">
|
||||
<section className="platform-subpanel flex min-h-0 flex-col rounded-[1.25rem] p-4">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid min-h-0 flex-1 gap-3 sm:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{boardBackgroundAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={boardBackgroundAsset.imageSrc}
|
||||
alt="场地底图"
|
||||
className="aspect-[9/16] h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[9/16] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
底图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{atlasAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={atlasAsset.imageSrc}
|
||||
alt="素材图集"
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-square place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
图集
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{previewCards.map((card) => (
|
||||
<div
|
||||
key={card.cardId}
|
||||
className="aspect-square overflow-hidden rounded-[0.45rem] border border-white/80 bg-white shadow-sm"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={card.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="flex min-h-0 flex-col gap-3 overflow-y-auto">
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-[0.9rem] bg-white/76 px-2 py-3">
|
||||
<div className="text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{patternGroups.length}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.68rem] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
|
||||
图案组
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[0.9rem] bg-white/76 px-2 py-3">
|
||||
<div className="text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{cardAssets.length}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.68rem] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
|
||||
卡片
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[0.9rem] bg-white/76 px-2 py-3">
|
||||
<div className="text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{draft.generationStatus}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.68rem] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
|
||||
状态
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="platform-subpanel mt-auto rounded-[1.25rem] p-4">
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartTestRun}
|
||||
disabled={isBusy || !isWorkProfile}
|
||||
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
试玩
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
disabled={isBusy || isPublishing || !canPublish}
|
||||
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
发布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-11 justify-center px-4 py-3"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleClearResultView;
|
||||
1230
src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx
Normal file
1230
src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1476
src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx
Normal file
1476
src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -427,6 +427,19 @@ describe('PuzzleResultView', () => {
|
||||
const formalImageCard = formalImageTitle
|
||||
.closest('.creative-image-input-panel__image-field')
|
||||
?.querySelector('.puzzle-image-upload-card');
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '查看关卡图片' }),
|
||||
);
|
||||
const imagePreviewDialog = screen.getByRole('dialog', {
|
||||
name: '查看关卡图片',
|
||||
});
|
||||
expect(within(imagePreviewDialog).getByAltText('暖灯猫街')).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(imagePreviewDialog).getByRole('button', {
|
||||
name: '关闭关卡图片预览',
|
||||
}),
|
||||
);
|
||||
expect(within(dialog).getByRole('button', { name: '更换参考图' })).toBeTruthy();
|
||||
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
|
||||
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
|
||||
@@ -1224,11 +1237,13 @@ describe('PuzzleResultView', () => {
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
expect(await within(picker).findByText('image.png')).toBeTruthy();
|
||||
expect(await within(picker).findByText(/2024\/04\/21/u)).toBeTruthy();
|
||||
expect(within(picker).queryByText('image.png')).toBeNull();
|
||||
expect(within(picker).queryByText('账号 user-1')).toBeNull();
|
||||
fireEvent.click(
|
||||
await within(picker).findByRole('button', { name: /image\.png/u }),
|
||||
await within(picker).findByRole('button', {
|
||||
name: /选择2024\/04\/21.*的历史图片/u,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -867,6 +867,8 @@ function PuzzleLevelDetailDialog({
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
history: '选择历史图片',
|
||||
}}
|
||||
onMainImageFileSelect={(file) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user