Merge remote-tracking branch 'origin/master' into editor-agent-refactored
# Conflicts: # server-rs/crates/api-server/src/editor_generation_queue.rs # src/components/image-editor/ImageCanvasEditorView.test.tsx # src/components/image-editor/ImageCanvasEditorView.tsx
This commit is contained in:
+6
-2
@@ -161,7 +161,9 @@ describe('App title sync', () => {
|
||||
test('主站阶段变化会同步浏览器与宿主标题', () => {
|
||||
renderApp();
|
||||
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith(
|
||||
'陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图创作' }));
|
||||
@@ -187,7 +189,9 @@ describe('App title sync', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出 RPG' }));
|
||||
});
|
||||
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith(
|
||||
'陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台',
|
||||
);
|
||||
});
|
||||
|
||||
test('启动时回读宿主 runtime 后刷新壳能力 UI', async () => {
|
||||
|
||||
@@ -30,7 +30,15 @@ test('renders auth modal shell with platform theme and auth card chrome', () =>
|
||||
expect(dialog.className).toContain('!max-w-md');
|
||||
expect(within(dialog).getByText('登录表单')).toBeTruthy();
|
||||
|
||||
fireEvent.click(dialog.parentElement as HTMLElement);
|
||||
const backdrop = dialog.parentElement as HTMLElement;
|
||||
fireEvent.pointerDown(within(dialog).getByText('登录表单'));
|
||||
fireEvent.pointerUp(backdrop);
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerDown(backdrop);
|
||||
fireEvent.pointerUp(backdrop);
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry';
|
||||
import { formatMudPointCount } from './platformMudPointWalletModel';
|
||||
|
||||
const breakdown = {
|
||||
totalPoints: 207,
|
||||
permanentPoints: 100,
|
||||
limitedPoints: 80,
|
||||
limitedExpiresAt: '2026-07-06T16:00:00Z',
|
||||
dailyFreePoints: 27,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
|
||||
};
|
||||
|
||||
test('formats mud point counts consistently', () => {
|
||||
expect(formatMudPointCount(12_345)).toBe('12,345');
|
||||
expect(formatMudPointCount(12_345, true)).toBe('1.2万');
|
||||
});
|
||||
|
||||
test('shows only permanent and daily free points in the shared wallet panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRequestDetails = vi.fn();
|
||||
const onRecharge = vi.fn();
|
||||
const onOpenLedger = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={999}
|
||||
breakdown={breakdown}
|
||||
onRequestDetails={onRequestDetails}
|
||||
onRecharge={onRecharge}
|
||||
onOpenLedger={onOpenLedger}
|
||||
/>,
|
||||
);
|
||||
|
||||
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
|
||||
await user.hover(balanceButton);
|
||||
|
||||
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
|
||||
expect(details.className).toContain('rounded-[1.12rem]');
|
||||
expect(within(details).getByText('不限时泥点')).toBeTruthy();
|
||||
expect(within(details).getByText('按量充值、兑换码获得')).toBeTruthy();
|
||||
expect(within(details).getByText('100')).toBeTruthy();
|
||||
expect(within(details).queryByText('限时泥点')).toBeNull();
|
||||
expect(within(details).queryByText('2026-07-07 到期')).toBeNull();
|
||||
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
|
||||
expect(within(details).getByText('27')).toBeTruthy();
|
||||
expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy();
|
||||
expect(onRequestDetails).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(within(details).getByRole('button', { name: '使用详情' }));
|
||||
expect(onOpenLedger).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '充值' }));
|
||||
expect(onRecharge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps the desktop panel open while moving across the gap without click pinning', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
render(
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={207}
|
||||
breakdown={breakdown}
|
||||
onRequestDetails={vi.fn()}
|
||||
onRecharge={vi.fn()}
|
||||
onOpenLedger={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
|
||||
const root = balanceButton.closest('.platform-mud-point-wallet-entry');
|
||||
expect(root).toBeTruthy();
|
||||
|
||||
fireEvent.mouseEnter(balanceButton);
|
||||
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
|
||||
|
||||
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
|
||||
fireEvent.mouseEnter(details);
|
||||
act(() => vi.advanceTimersByTime(120));
|
||||
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
|
||||
|
||||
balanceButton.focus();
|
||||
fireEvent.click(balanceButton);
|
||||
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
|
||||
|
||||
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
|
||||
act(() => vi.advanceTimersByTime(120));
|
||||
expect(screen.queryByRole('dialog', { name: '泥点账户详情' })).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('requests the balance breakdown when a compact entry opens', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRequestDetails = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={20}
|
||||
breakdown={null}
|
||||
isLoading={false}
|
||||
variant="mobile"
|
||||
onRequestDetails={onRequestDetails}
|
||||
onRecharge={vi.fn()}
|
||||
onOpenLedger={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '泥点 20' }));
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('余额明细暂不可用')).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { ChevronRight, ReceiptText } from 'lucide-react';
|
||||
import {
|
||||
type FocusEvent,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { formatMudPointCount } from './platformMudPointWalletModel';
|
||||
|
||||
const MUD_POINT_ICON_SRC = '/creation-home/topbar-wallet.png';
|
||||
|
||||
export type PlatformMudPointWalletEntryProps = {
|
||||
balance: number | null;
|
||||
breakdown?: ProfileMudPointBalance | null;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
variant?: 'desktop' | 'mobile' | 'editor';
|
||||
className?: string;
|
||||
onRequestDetails: () => void;
|
||||
onRecharge: () => void;
|
||||
onOpenLedger: () => void;
|
||||
};
|
||||
|
||||
function MudPointBalanceRow({
|
||||
label,
|
||||
points,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
points: number;
|
||||
detail?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[3.25rem] items-center justify-between gap-4 border-t border-[var(--platform-subpanel-border)] px-4 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-bold text-[var(--platform-text-strong)]">
|
||||
{label}
|
||||
</div>
|
||||
{detail ? (
|
||||
<div className="mt-0.5 truncate text-[11px] text-[var(--platform-text-soft)]">
|
||||
{detail}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-[15px] font-black tabular-nums text-[var(--platform-text-strong)]">
|
||||
{formatMudPointCount(points)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformMudPointWalletEntry({
|
||||
balance,
|
||||
breakdown,
|
||||
isLoading = false,
|
||||
variant = 'desktop',
|
||||
className,
|
||||
onRequestDetails,
|
||||
onRecharge,
|
||||
onOpenLedger,
|
||||
}: PlatformMudPointWalletEntryProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const isOpenRef = useRef(false);
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isCompact = variant === 'mobile';
|
||||
const displayedBalance = breakdown?.totalPoints ?? balance;
|
||||
const balanceLabel =
|
||||
displayedBalance === null
|
||||
? '--'
|
||||
: formatMudPointCount(displayedBalance, true);
|
||||
const exactBalanceLabel =
|
||||
displayedBalance === null ? '--' : formatMudPointCount(displayedBalance);
|
||||
|
||||
const cancelPendingClose = useCallback(() => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeDetails = useCallback(() => {
|
||||
cancelPendingClose();
|
||||
isOpenRef.current = false;
|
||||
setIsOpen(false);
|
||||
}, [cancelPendingClose]);
|
||||
|
||||
const requestAndOpen = useCallback(() => {
|
||||
cancelPendingClose();
|
||||
if (isOpenRef.current) {
|
||||
return;
|
||||
}
|
||||
isOpenRef.current = true;
|
||||
setIsOpen(true);
|
||||
if (!breakdown && !isLoading) {
|
||||
onRequestDetails();
|
||||
}
|
||||
}, [breakdown, cancelPendingClose, isLoading, onRequestDetails]);
|
||||
|
||||
useEffect(() => cancelPendingClose, [cancelPendingClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
closeDetails();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDetails();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [closeDetails, isOpen]);
|
||||
|
||||
const closeAfterFocusLeaves = (event: FocusEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (
|
||||
!(nextTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(nextTarget)
|
||||
) {
|
||||
closeDetails();
|
||||
}
|
||||
};
|
||||
const closeAfterPointerLeaves = (event: MouseEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (
|
||||
nextTarget instanceof Node &&
|
||||
event.currentTarget.contains(nextTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
cancelPendingClose();
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null;
|
||||
closeDetails();
|
||||
}, 120);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`platform-mud-point-wallet-entry relative shrink-0 ${className ?? ''}`}
|
||||
onMouseEnter={isCompact ? undefined : requestAndOpen}
|
||||
onMouseLeave={isCompact ? undefined : closeAfterPointerLeaves}
|
||||
onFocusCapture={isCompact ? undefined : requestAndOpen}
|
||||
onBlurCapture={closeAfterFocusLeaves}
|
||||
>
|
||||
<div
|
||||
className={`flex items-stretch overflow-hidden rounded-full border border-[rgba(214,184,159,0.78)] bg-[rgba(255,250,244,0.9)] text-[#6f3d24] shadow-[0_0.18rem_0.55rem_rgba(112,62,32,0.08)] ${
|
||||
isCompact ? 'h-8 text-[11px]' : 'h-9 text-xs'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-1.5 px-2 font-black outline-none transition-colors hover:bg-white/70 focus-visible:bg-white/80"
|
||||
aria-label={`泥点 ${exactBalanceLabel}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-busy={isLoading}
|
||||
onClick={
|
||||
isCompact
|
||||
? () => {
|
||||
if (isOpenRef.current) {
|
||||
closeDetails();
|
||||
return;
|
||||
}
|
||||
requestAndOpen();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={MUD_POINT_ICON_SRC}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={`${isCompact ? 'h-[1.05rem] w-[1.05rem]' : 'h-[1.2rem] w-[1.2rem]'} shrink-0 object-cover mix-blend-multiply`}
|
||||
/>
|
||||
<span className="truncate whitespace-nowrap">
|
||||
泥点 {balanceLabel}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
className="my-1.5 w-px shrink-0 bg-[rgba(196,153,120,0.62)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 px-2.5 font-black outline-none transition-colors hover:bg-white/75 focus-visible:bg-white/80"
|
||||
onClick={() => {
|
||||
closeDetails();
|
||||
onRecharge();
|
||||
}}
|
||||
>
|
||||
充值
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="泥点账户详情"
|
||||
onMouseEnter={isCompact ? undefined : cancelPendingClose}
|
||||
className="absolute right-0 top-[calc(100%+0.5rem)] z-[95] w-[min(19rem,calc(100vw-1rem))] overflow-hidden rounded-[1.12rem] border border-[var(--platform-subpanel-border)] bg-[#fffaf4] text-left shadow-[0_1rem_2.8rem_rgba(76,44,27,0.2)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="text-[15px] font-black text-[var(--platform-text-strong)]">
|
||||
泥点 {exactBalanceLabel}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-black text-[var(--platform-accent-strong)] outline-none hover:underline focus-visible:underline"
|
||||
onClick={() => {
|
||||
closeDetails();
|
||||
onRecharge();
|
||||
}}
|
||||
>
|
||||
充值
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{breakdown ? (
|
||||
<>
|
||||
<MudPointBalanceRow
|
||||
label="不限时泥点"
|
||||
points={breakdown.permanentPoints}
|
||||
detail="按量充值、兑换码获得"
|
||||
/>
|
||||
<MudPointBalanceRow
|
||||
label="每日免费泥点"
|
||||
points={breakdown.dailyFreePoints}
|
||||
detail={`每天重置为 ${formatMudPointCount(breakdown.dailyFreeResetPoints)} 泥点`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="border-t border-[var(--platform-subpanel-border)] px-4 py-6 text-center text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
{isLoading ? '余额读取中' : '余额明细暂不可用'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-center gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-3 text-[13px] font-black text-[var(--platform-text-strong)] outline-none transition-colors hover:bg-white/45 focus-visible:bg-white/55"
|
||||
onClick={() => {
|
||||
closeDetails();
|
||||
onOpenLedger();
|
||||
}}
|
||||
>
|
||||
<ReceiptText className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
使用详情
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,24 @@ test('closes through backdrop and escape', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('keeps the modal open when a pointer press starts inside and releases over the backdrop', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
|
||||
<button type="button">窗口内容</button>
|
||||
</UnifiedModal>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const backdrop = dialog.parentElement as HTMLElement;
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: '窗口内容' }));
|
||||
fireEvent.pointerUp(backdrop);
|
||||
fireEvent.click(backdrop);
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('supports disabling escape close while keeping the custom close button chrome', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
@@ -118,6 +119,7 @@ function UnifiedModalContent({
|
||||
const generatedTitleId = useId();
|
||||
const descriptionId = useId();
|
||||
const titleId = titleIdProp ?? generatedTitleId;
|
||||
const backdropPointerSequenceRef = useRef<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || closeDisabled || !closeOnEscape) {
|
||||
@@ -175,10 +177,26 @@ function UnifiedModalContent({
|
||||
<div
|
||||
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
|
||||
style={overlayStyle}
|
||||
onPointerDownCapture={(event) => {
|
||||
backdropPointerSequenceRef.current =
|
||||
event.target === event.currentTarget;
|
||||
}}
|
||||
onPointerUpCapture={(event) => {
|
||||
backdropPointerSequenceRef.current =
|
||||
backdropPointerSequenceRef.current === true &&
|
||||
event.target === event.currentTarget;
|
||||
}}
|
||||
onPointerCancelCapture={() => {
|
||||
backdropPointerSequenceRef.current = false;
|
||||
}}
|
||||
onClick={(event) => {
|
||||
const pointerSequenceStayedOnBackdrop =
|
||||
backdropPointerSequenceRef.current !== false;
|
||||
backdropPointerSequenceRef.current = null;
|
||||
if (
|
||||
closeOnBackdrop &&
|
||||
!closeDisabled &&
|
||||
pointerSequenceStayedOnBackdrop &&
|
||||
event.target === event.currentTarget
|
||||
) {
|
||||
onClose();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function formatMudPointCount(value: number, compact = false) {
|
||||
const normalizedValue = Math.max(0, Math.round(value));
|
||||
if (compact && normalizedValue >= 100_000_000) {
|
||||
return `${(normalizedValue / 100_000_000).toFixed(1)}亿`;
|
||||
}
|
||||
if (compact && normalizedValue >= 10_000) {
|
||||
return `${(normalizedValue / 10_000).toFixed(1)}万`;
|
||||
}
|
||||
return normalizedValue.toLocaleString('zh-CN');
|
||||
}
|
||||
@@ -156,16 +156,34 @@ describe('CreationLandingView', () => {
|
||||
renderCreationLanding();
|
||||
|
||||
expect(screen.getByRole('main', { name: '陶泥儿创作主页' })).toBeTruthy();
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
level: 1,
|
||||
name: '陶泥儿 · 开启全民精品游戏创作',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('登录即送100泥点,可以免费制作50个素材'),
|
||||
screen.getByText('陶泥儿 Genarrative|游戏美术 AI 创作工具'),
|
||||
).toBeTruthy();
|
||||
const subtitle = screen.getByText(
|
||||
/面向个人创作者的游戏美术 AI 工作台/u,
|
||||
);
|
||||
expect(subtitle.textContent).toContain('美术 Agent');
|
||||
expect(subtitle.textContent).toContain('无限画布');
|
||||
expect(subtitle.textContent).toContain('角色、场景、UI 与宣发素材');
|
||||
expect(
|
||||
screen.getByText('登录即送 100 泥点,可以免费制作 50 个素材'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
level: 2,
|
||||
name: '游戏美术 AI 创作工具',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 3, name: '游戏视觉规范' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '创作工具' })).toBeTruthy();
|
||||
expect(screen.getByText('游戏视觉规范')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '陶泥儿精选' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '全部' })).toBeTruthy();
|
||||
expect(screen.queryByRole('tab', { name: '素材包' })).toBeNull();
|
||||
@@ -310,6 +328,10 @@ describe('CreationLandingView', () => {
|
||||
await user.click(screen.getByRole('button', { name: /游戏特效/u }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '抱歉' });
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||
expect(
|
||||
within(dialog).getByRole('heading', { level: 2, name: '抱歉' }),
|
||||
).toBeTruthy();
|
||||
expect(dialog.textContent).toContain('功能还在调试中');
|
||||
expect(dialog.textContent).toContain('暂未开放');
|
||||
expect(dialog.closest('.platform-theme--light')).toBeTruthy();
|
||||
|
||||
@@ -858,9 +858,20 @@ export function CreationLandingView({
|
||||
<main className="creation-landing" aria-label="陶泥儿创作主页">
|
||||
<section className="creation-landing__hero">
|
||||
<div className="creation-landing__hero-copy">
|
||||
<span className="creation-landing__eyebrow">陶泥儿创作工具</span>
|
||||
<h1>陶泥儿 - 开启全民精品游戏创作</h1>
|
||||
<p>登录即送100泥点,可以免费制作50个素材</p>
|
||||
<span className="creation-landing__eyebrow">
|
||||
陶泥儿 Genarrative|游戏美术 AI 创作工具
|
||||
</span>
|
||||
<h1>陶泥儿 · 开启全民精品游戏创作</h1>
|
||||
<div className="creation-landing__hero-detail">
|
||||
<p className="creation-landing__hero-subtitle">
|
||||
面向个人创作者的游戏美术 AI 工作台。
|
||||
<br />
|
||||
用美术 Agent 与无限画布,快速制作角色、场景、UI 与宣发素材。
|
||||
</p>
|
||||
<p className="creation-landing__hero-benefit">
|
||||
登录即送 100 泥点,可以免费制作 50 个素材
|
||||
</p>
|
||||
</div>
|
||||
<div className="creation-landing__hero-actions">
|
||||
<PlatformActionButton
|
||||
size="md"
|
||||
@@ -908,7 +919,7 @@ export function CreationLandingView({
|
||||
<section className="creation-landing__section">
|
||||
<div className="creation-landing__section-header">
|
||||
<div>
|
||||
<h2>创作工具</h2>
|
||||
<h2>游戏美术 AI 创作工具</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="creation-landing__feature-grid">
|
||||
@@ -1084,7 +1095,7 @@ export function CreationLandingView({
|
||||
alt=""
|
||||
className="platform-mobile-home-welcome-dialog__icon"
|
||||
/>
|
||||
<h1 className="platform-mobile-home-welcome-dialog__title">抱歉</h1>
|
||||
<h2 className="platform-mobile-home-welcome-dialog__title">抱歉</h2>
|
||||
<p className="platform-mobile-home-welcome-dialog__copy">
|
||||
功能还在调试中
|
||||
<br />
|
||||
|
||||
@@ -99,8 +99,11 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
|
||||
isProjectRenameSaving: false,
|
||||
projectRenameError: null,
|
||||
layers: [],
|
||||
walletBalanceLabel: '0泥点',
|
||||
walletBalance: 0,
|
||||
walletBreakdown: null,
|
||||
isWalletBalanceLoading: false,
|
||||
isWalletDetailsLoading: false,
|
||||
walletDetailsError: null,
|
||||
currentUser: null,
|
||||
assetExportStatus: null,
|
||||
isExportingAssets: false,
|
||||
@@ -111,7 +114,9 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
|
||||
resetProjectRenameError: vi.fn(),
|
||||
exportCanvasAssets: vi.fn(),
|
||||
onOpenShortcuts: vi.fn(),
|
||||
onOpenWallet: vi.fn(),
|
||||
onRequestWalletDetails: vi.fn(),
|
||||
onRecharge: vi.fn(),
|
||||
onOpenWalletLedger: vi.fn(),
|
||||
onOpenAccount: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import userEvent from '@testing-library/user-event';
|
||||
import JSZip from 'jszip';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation.ts';
|
||||
import {
|
||||
ApiClientError,
|
||||
AuthUiContext,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
readZipText,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
|
||||
|
||||
type EditorAgentListConversations =
|
||||
EditorAgentConversationClient['listConversations'];
|
||||
@@ -121,6 +121,8 @@ const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn());
|
||||
const loadFrontendRuntimeConfigMock = vi.hoisted(() => vi.fn());
|
||||
const getRpgProfileRechargeCenterMock = vi.hoisted(() => vi.fn());
|
||||
const getRpgProfileWalletLedgerMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -151,6 +153,17 @@ vi.mock('../../services/platform-entry/platformProfileClient', () => ({
|
||||
getPlatformProfileDashboard: getPlatformProfileDashboardMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/rpg-entry/rpgProfileClient')
|
||||
>('../../services/rpg-entry/rpgProfileClient');
|
||||
return {
|
||||
...actual,
|
||||
getRpgProfileRechargeCenter: getRpgProfileRechargeCenterMock,
|
||||
getRpgProfileWalletLedger: getRpgProfileWalletLedgerMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/frontendRuntimeConfigService', () => ({
|
||||
loadFrontendRuntimeConfig: loadFrontendRuntimeConfigMock,
|
||||
}));
|
||||
@@ -319,6 +332,46 @@ describe('ImageCanvasEditorView', () => {
|
||||
playedWorldCount: 0,
|
||||
updatedAt: null,
|
||||
});
|
||||
getRpgProfileRechargeCenterMock.mockResolvedValue({
|
||||
walletBalance: 1234,
|
||||
mudPointBalance: {
|
||||
totalPoints: 1234,
|
||||
permanentPoints: 1000,
|
||||
limitedPoints: 214,
|
||||
limitedExpiresAt: '2026-07-31T16:00:00Z',
|
||||
dailyFreePoints: 20,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
|
||||
},
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
cycleStartedAt: null,
|
||||
cycleResetsAt: null,
|
||||
cycleGrantedPoints: 0,
|
||||
cycleRemainingPoints: 0,
|
||||
cyclePeriodDays: 30,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
});
|
||||
getRpgProfileWalletLedgerMock.mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
id: 'editor-ledger-1',
|
||||
amountDelta: -5,
|
||||
balanceAfter: 1234,
|
||||
sourceType: 'asset_operation_consume',
|
||||
createdAt: '2026-07-12T08:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -330,6 +383,8 @@ describe('ImageCanvasEditorView', () => {
|
||||
confirmEditorAgentToolCallMock.mockReset();
|
||||
cancelEditorAgentToolCallMock.mockReset();
|
||||
getPlatformProfileDashboardMock.mockReset();
|
||||
getRpgProfileRechargeCenterMock.mockReset();
|
||||
getRpgProfileWalletLedgerMock.mockReset();
|
||||
loadFrontendRuntimeConfigMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -540,7 +595,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy();
|
||||
expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy();
|
||||
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
@@ -585,7 +640,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(openAccountModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the account recharge entry from the canvas topbar mud point button', async () => {
|
||||
it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => {
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
@@ -607,15 +662,23 @@ describe('ImageCanvasEditorView', () => {
|
||||
);
|
||||
|
||||
const walletButton = await screen.findByRole('button', {
|
||||
name: '泥点余额 1,234泥点',
|
||||
name: '泥点 1,234',
|
||||
});
|
||||
|
||||
fireEvent.click(walletButton);
|
||||
|
||||
const details = await screen.findByRole('dialog', {
|
||||
name: '泥点账户详情',
|
||||
});
|
||||
expect(within(details).getByText('不限时泥点')).toBeTruthy();
|
||||
expect(within(details).queryByText('限时泥点')).toBeNull();
|
||||
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(details).getByRole('button', { name: '使用详情' }));
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '账户充值' }),
|
||||
await screen.findByRole('dialog', { name: '泥点账单' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull();
|
||||
expect(getRpgProfileWalletLedgerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('suspends canvas interaction during account payment dialogs and restores completed quick edit selections', async () => {
|
||||
@@ -707,11 +770,11 @@ describe('ImageCanvasEditorView', () => {
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: '泥点余额 1,234泥点',
|
||||
name: '充值',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '账户充值' }),
|
||||
await screen.findByRole('dialog', { name: '购买更多泥点' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
|
||||
@@ -737,7 +800,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
).toBe('true');
|
||||
expect(viewport.getAttribute('aria-disabled')).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭账户充值' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' }));
|
||||
|
||||
const resumedToolbar = await screen.findByRole('toolbar', {
|
||||
name: '快速编辑框选工具',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
import {
|
||||
@@ -25,12 +26,12 @@ import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
|
||||
import {
|
||||
PlatformRechargePaymentConfirmationMask,
|
||||
PlatformRechargePaymentResultDialog,
|
||||
} from '../platform-entry/PlatformRechargePaymentStatusDialogs';
|
||||
import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController';
|
||||
import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation';
|
||||
import {
|
||||
canvasAssetKindOrNull,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
@@ -294,9 +295,7 @@ export function ImageCanvasEditorView({
|
||||
}: ImageCanvasEditorViewProps = {}) {
|
||||
const authUi = useAuthUi();
|
||||
const [, setGenerationPricingVersion] = useState(0);
|
||||
const [walletBalanceLabel, setWalletBalanceLabel] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [walletBalance, setWalletBalance] = useState<number | null>(null);
|
||||
const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false);
|
||||
const editorRootRef = useRef<HTMLElement | null>(null);
|
||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -478,37 +477,40 @@ export function ImageCanvasEditorView({
|
||||
clearAuthOnUnauthorized: false,
|
||||
})
|
||||
.then((dashboard) => {
|
||||
setWalletBalanceLabel(
|
||||
`${formatDashboardCount(dashboard.walletBalance)}泥点`,
|
||||
);
|
||||
setWalletBalance(dashboard.walletBalance);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const {
|
||||
activeRechargeTab,
|
||||
buyRechargeProduct,
|
||||
closeNativeWechatPayment,
|
||||
confirmNativeWechatPayment,
|
||||
isLoadingRechargeCenter,
|
||||
isLoadingWalletLedger,
|
||||
isRechargeOpen,
|
||||
isRewardCodeOpen,
|
||||
isSubmittingRewardCode,
|
||||
isWalletLedgerOpen,
|
||||
loadRechargeCenter,
|
||||
loadWalletLedger,
|
||||
nativeWechatPayment,
|
||||
openRechargeOrRewardCodeModal,
|
||||
openWalletLedgerPanel,
|
||||
rechargeCenter,
|
||||
rechargeError,
|
||||
rechargePaymentResult,
|
||||
rewardCodeError,
|
||||
rewardCodeInput,
|
||||
rewardCodeSuccess,
|
||||
setActiveRechargeTab,
|
||||
setIsRechargeOpen,
|
||||
setIsRewardCodeOpen,
|
||||
setIsWalletLedgerOpen,
|
||||
setRechargePaymentResult,
|
||||
setRewardCodeInput,
|
||||
submittingRechargeProductId,
|
||||
submitRewardCode,
|
||||
walletLedger,
|
||||
walletLedgerError,
|
||||
wechatRechargeOrderConfirmationState,
|
||||
} = usePlatformProfileCenterController({
|
||||
activeTab: 'editor-canvas',
|
||||
@@ -521,6 +523,7 @@ export function ImageCanvasEditorView({
|
||||
const isAccountPaymentModalOpen =
|
||||
isRewardCodeOpen ||
|
||||
isRechargeOpen ||
|
||||
isWalletLedgerOpen ||
|
||||
Boolean(nativeWechatPayment) ||
|
||||
Boolean(rechargePaymentResult) ||
|
||||
Boolean(wechatRechargeOrderConfirmationState);
|
||||
@@ -540,7 +543,7 @@ export function ImageCanvasEditorView({
|
||||
}, [authUi]);
|
||||
useEffect(() => {
|
||||
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
|
||||
setWalletBalanceLabel(null);
|
||||
setWalletBalance(null);
|
||||
setIsWalletBalanceLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -562,15 +565,13 @@ export function ImageCanvasEditorView({
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setWalletBalanceLabel(
|
||||
`${formatDashboardCount(dashboard.walletBalance)}泥点`,
|
||||
);
|
||||
setWalletBalance(dashboard.walletBalance);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setWalletBalanceLabel(null);
|
||||
setWalletBalance(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
@@ -2022,8 +2023,11 @@ export function ImageCanvasEditorView({
|
||||
isProjectRenameSaving,
|
||||
projectRenameError,
|
||||
layers,
|
||||
walletBalanceLabel,
|
||||
walletBalance,
|
||||
walletBreakdown: rechargeCenter?.mudPointBalance ?? null,
|
||||
isWalletBalanceLoading,
|
||||
isWalletDetailsLoading: isLoadingRechargeCenter,
|
||||
walletDetailsError: rechargeError,
|
||||
currentUser: authUi?.user,
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
@@ -2034,7 +2038,9 @@ export function ImageCanvasEditorView({
|
||||
resetProjectRenameError,
|
||||
exportCanvasAssets,
|
||||
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
|
||||
onOpenWallet: openAccountPaymentModal,
|
||||
onRequestWalletDetails: loadRechargeCenter,
|
||||
onRecharge: openAccountPaymentModal,
|
||||
onOpenWalletLedger: openWalletLedgerPanel,
|
||||
onOpenAccount: () => {
|
||||
if (authUi?.user) {
|
||||
authUi.openAccountModal();
|
||||
@@ -2225,8 +2231,6 @@ export function ImageCanvasEditorView({
|
||||
error={rechargeError}
|
||||
submittingProductId={submittingRechargeProductId}
|
||||
nativePayment={nativeWechatPayment}
|
||||
activeTab={activeRechargeTab}
|
||||
onTabChange={setActiveRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onRetry={loadRechargeCenter}
|
||||
onBuy={buyRechargeProduct}
|
||||
@@ -2234,6 +2238,16 @@ export function ImageCanvasEditorView({
|
||||
onCloseNativePayment={closeNativeWechatPayment}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
<PlatformProfileWalletLedgerModal
|
||||
ledger={walletLedger}
|
||||
fallbackBalance={walletBalance ?? 0}
|
||||
isLoading={isLoadingWalletLedger}
|
||||
error={walletLedgerError}
|
||||
onClose={() => setIsWalletLedgerOpen(false)}
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
{rechargePaymentResult ? (
|
||||
<PlatformRechargePaymentResultDialog
|
||||
result={rechargePaymentResult}
|
||||
|
||||
@@ -287,8 +287,8 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
const sourceLayer = createSourceLayer({
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 768,
|
||||
originalWidth: 1537,
|
||||
originalHeight: 1025,
|
||||
});
|
||||
const layer = applyImageEditResultToSourceLayer({
|
||||
generated: createGenerated({
|
||||
@@ -313,10 +313,10 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
id: 'layer-source',
|
||||
title: '源图',
|
||||
src: 'data:image/png;base64,edited',
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
originalWidth: 1536,
|
||||
originalHeight: 1024,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 1537,
|
||||
originalHeight: 1025,
|
||||
resourceId: 'resource-edited',
|
||||
sourceResourceId: 'resource-source',
|
||||
objectKey: 'generated/edited.png',
|
||||
|
||||
@@ -247,18 +247,10 @@ export function applyImageEditResultToSourceLayer({
|
||||
sourceLayer: CanvasLayer;
|
||||
generationInputs: CanvasGenerationInputs;
|
||||
}): CanvasLayer {
|
||||
const originalWidth =
|
||||
generated.width || sourceLayer.originalWidth || sourceLayer.width;
|
||||
const originalHeight =
|
||||
generated.height || sourceLayer.originalHeight || sourceLayer.height;
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
{
|
||||
width: sourceLayer.width,
|
||||
height: sourceLayer.height,
|
||||
},
|
||||
);
|
||||
const originalWidth = sourceLayer.originalWidth || sourceLayer.width;
|
||||
const originalHeight = sourceLayer.originalHeight || sourceLayer.height;
|
||||
const width = sourceLayer.width;
|
||||
const height = sourceLayer.height;
|
||||
return applyGeneratedMetadata(
|
||||
{
|
||||
...sourceLayer,
|
||||
|
||||
@@ -122,6 +122,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
const sourceLayer = createLayer({
|
||||
objectKey: 'generated-character-drafts/editor/source.png',
|
||||
assetKind: 'character',
|
||||
originalWidth: 1537,
|
||||
originalHeight: 1025,
|
||||
});
|
||||
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
@@ -150,7 +152,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
normalizedPrompt: '把当前图改成雨天',
|
||||
sourceLayer,
|
||||
editInput: {
|
||||
size: '1024x768',
|
||||
size: '1537x1025',
|
||||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
},
|
||||
result: {
|
||||
|
||||
@@ -41,11 +41,8 @@ import {
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
normalizeEditorImageModel,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
SEEDANCE_VIDEO_REFERENCE_LIMITS,
|
||||
SPEC_GENERATION_ASPECT_RATIO,
|
||||
SPEC_GENERATION_IMAGE_SIZE,
|
||||
@@ -260,29 +257,12 @@ export function buildImageGenerationSubmissionPlan({
|
||||
const basePrompt = dialog.prompt.trim() || '快速编辑图片';
|
||||
const normalizedQuickEditPrompt = basePrompt;
|
||||
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
const aspectRatio =
|
||||
dialog.aspectRatio ??
|
||||
inferEditorImageAspectRatio(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const imageSize =
|
||||
dialog.imageSize ??
|
||||
inferEditorImageSizeLabel(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const outputSize = resolveEditorImageGenerationPixelSize({
|
||||
model: imageModel,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
});
|
||||
return {
|
||||
kind: 'quick-edit',
|
||||
normalizedPrompt: normalizedQuickEditPrompt,
|
||||
sourceLayer,
|
||||
editInput: {
|
||||
size: `${outputSize.width}x${outputSize.height}`,
|
||||
size: `${sourceLayer.originalWidth}x${sourceLayer.originalHeight}`,
|
||||
model: imageModel,
|
||||
},
|
||||
result: {
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasTopbarView } from './ImageCanvasTopbarView';
|
||||
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
|
||||
|
||||
const walletBreakdown = {
|
||||
totalPoints: 1_234,
|
||||
permanentPoints: 1_000,
|
||||
limitedPoints: 214,
|
||||
limitedExpiresAt: '2026-07-31T16:00:00Z',
|
||||
dailyFreePoints: 20,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
|
||||
};
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
const id = overrides.id ?? 'layer-a';
|
||||
return {
|
||||
@@ -27,9 +38,7 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
}
|
||||
|
||||
function renderTopbar(
|
||||
overrides: Partial<
|
||||
Parameters<typeof ImageCanvasTopbarView>[0]
|
||||
> = {},
|
||||
overrides: Partial<Parameters<typeof ImageCanvasTopbarView>[0]> = {},
|
||||
) {
|
||||
useImageCanvasContextStore.getState().setProjectId('project-a');
|
||||
const props: Parameters<typeof ImageCanvasTopbarView>[0] = {
|
||||
@@ -39,8 +48,11 @@ function renderTopbar(
|
||||
isProjectRenameSaving: false,
|
||||
projectRenameError: null,
|
||||
layers: [],
|
||||
walletBalanceLabel: '1,234泥点',
|
||||
walletBalance: 1_234,
|
||||
walletBreakdown,
|
||||
isWalletBalanceLoading: false,
|
||||
isWalletDetailsLoading: false,
|
||||
walletDetailsError: null,
|
||||
currentUser: null,
|
||||
assetExportStatus: null,
|
||||
isExportingAssets: false,
|
||||
@@ -51,7 +63,9 @@ function renderTopbar(
|
||||
resetProjectRenameError: vi.fn(),
|
||||
exportCanvasAssets: vi.fn(),
|
||||
onOpenShortcuts: vi.fn(),
|
||||
onOpenWallet: vi.fn(),
|
||||
onRequestWalletDetails: vi.fn(),
|
||||
onRecharge: vi.fn(),
|
||||
onOpenWalletLedger: vi.fn(),
|
||||
onOpenAccount: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
@@ -80,22 +94,36 @@ describe('ImageCanvasTopbarView', () => {
|
||||
expect(props.onOpenShortcuts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the current mud point balance in the topbar', () => {
|
||||
it('shows the shared mud point breakdown and wallet actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderTopbar({
|
||||
walletBalanceLabel: '1.2万泥点',
|
||||
walletBalance: 12_345,
|
||||
walletBreakdown: {
|
||||
...walletBreakdown,
|
||||
totalPoints: 12_345,
|
||||
},
|
||||
});
|
||||
const walletChip = screen.getByRole('button', {
|
||||
name: '泥点余额 1.2万泥点',
|
||||
name: '泥点 12,345',
|
||||
});
|
||||
|
||||
expect(walletChip.textContent).toBe('1.2万泥点');
|
||||
expect(walletChip.textContent).toBe('泥点 1.2万');
|
||||
expect(walletChip.querySelector('img')?.getAttribute('src')).toBe(
|
||||
'/creation-home/topbar-wallet.png',
|
||||
);
|
||||
|
||||
fireEvent.click(walletChip);
|
||||
await user.hover(walletChip);
|
||||
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
|
||||
expect(within(details).getByText('不限时泥点')).toBeTruthy();
|
||||
expect(within(details).queryByText('限时泥点')).toBeNull();
|
||||
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
|
||||
|
||||
expect(props.onOpenWallet).toHaveBeenCalledTimes(1);
|
||||
await user.click(within(details).getByRole('button', { name: '使用详情' }));
|
||||
expect(props.onOpenWalletLedger).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '充值' }));
|
||||
expect(props.onRecharge).toHaveBeenCalledTimes(1);
|
||||
expect(props.onRequestWalletDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the current user avatar beside the mud point balance', () => {
|
||||
@@ -151,8 +179,11 @@ describe('ImageCanvasTopbarView', () => {
|
||||
isProjectRenameSaving={false}
|
||||
projectRenameError={null}
|
||||
layers={[]}
|
||||
walletBalanceLabel="0泥点"
|
||||
walletBalance={0}
|
||||
walletBreakdown={null}
|
||||
isWalletBalanceLoading={false}
|
||||
isWalletDetailsLoading={false}
|
||||
walletDetailsError={null}
|
||||
currentUser={null}
|
||||
assetExportStatus={null}
|
||||
isExportingAssets={false}
|
||||
@@ -163,14 +194,19 @@ describe('ImageCanvasTopbarView', () => {
|
||||
resetProjectRenameError={vi.fn()}
|
||||
exportCanvasAssets={exportCanvasAssets}
|
||||
onOpenShortcuts={vi.fn()}
|
||||
onOpenWallet={vi.fn()}
|
||||
onRequestWalletDetails={vi.fn()}
|
||||
onRecharge={vi.fn()}
|
||||
onOpenWalletLedger={vi.fn()}
|
||||
onOpenAccount={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '下载画布素材',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
|
||||
rerender(
|
||||
@@ -181,8 +217,11 @@ describe('ImageCanvasTopbarView', () => {
|
||||
isProjectRenameSaving={false}
|
||||
projectRenameError={null}
|
||||
layers={[createLayer()]}
|
||||
walletBalanceLabel="0泥点"
|
||||
walletBalance={0}
|
||||
walletBreakdown={null}
|
||||
isWalletBalanceLoading={false}
|
||||
isWalletDetailsLoading={false}
|
||||
walletDetailsError={null}
|
||||
currentUser={null}
|
||||
assetExportStatus={{
|
||||
tone: 'success',
|
||||
@@ -196,7 +235,9 @@ describe('ImageCanvasTopbarView', () => {
|
||||
resetProjectRenameError={vi.fn()}
|
||||
exportCanvasAssets={exportCanvasAssets}
|
||||
onOpenShortcuts={vi.fn()}
|
||||
onOpenWallet={vi.fn()}
|
||||
onRequestWalletDetails={vi.fn()}
|
||||
onRecharge={vi.fn()}
|
||||
onOpenWalletLedger={vi.fn()}
|
||||
onOpenAccount={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformMudPointWalletEntry } from '../common/PlatformMudPointWalletEntry';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
@@ -21,8 +23,11 @@ export type ImageCanvasTopbarViewProps = {
|
||||
isProjectRenameSaving: boolean;
|
||||
projectRenameError: string | null;
|
||||
layers: CanvasLayer[];
|
||||
walletBalanceLabel: string | null;
|
||||
walletBalance: number | null;
|
||||
walletBreakdown?: ProfileMudPointBalance | null;
|
||||
isWalletBalanceLoading: boolean;
|
||||
isWalletDetailsLoading: boolean;
|
||||
walletDetailsError: string | null;
|
||||
currentUser:
|
||||
| {
|
||||
id: string;
|
||||
@@ -41,13 +46,13 @@ export type ImageCanvasTopbarViewProps = {
|
||||
resetProjectRenameError: () => void;
|
||||
exportCanvasAssets: () => void | Promise<void>;
|
||||
onOpenShortcuts: () => void;
|
||||
onOpenWallet: () => void;
|
||||
onRequestWalletDetails: () => void;
|
||||
onRecharge: () => void;
|
||||
onOpenWalletLedger: () => void;
|
||||
onOpenAccount: () => void;
|
||||
};
|
||||
|
||||
function buildCanvasUserCode(
|
||||
user: ImageCanvasTopbarViewProps['currentUser'],
|
||||
) {
|
||||
function buildCanvasUserCode(user: ImageCanvasTopbarViewProps['currentUser']) {
|
||||
if (user?.publicUserCode?.trim()) {
|
||||
return user.publicUserCode.trim();
|
||||
}
|
||||
@@ -63,8 +68,11 @@ export function ImageCanvasTopbarView({
|
||||
isProjectRenameSaving,
|
||||
projectRenameError,
|
||||
layers,
|
||||
walletBalanceLabel,
|
||||
walletBalance,
|
||||
walletBreakdown,
|
||||
isWalletBalanceLoading,
|
||||
isWalletDetailsLoading,
|
||||
walletDetailsError,
|
||||
currentUser,
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
@@ -75,17 +83,15 @@ export function ImageCanvasTopbarView({
|
||||
resetProjectRenameError,
|
||||
exportCanvasAssets,
|
||||
onOpenShortcuts,
|
||||
onOpenWallet,
|
||||
onRequestWalletDetails,
|
||||
onRecharge,
|
||||
onOpenWalletLedger,
|
||||
onOpenAccount,
|
||||
}: ImageCanvasTopbarViewProps) {
|
||||
const projectId = useImageCanvasContextStore((state) => state.projectId);
|
||||
const hasExportableLayer = layers.some(
|
||||
(layer) => layer.src.trim().length > 0,
|
||||
);
|
||||
const walletDisplayLabel = walletBalanceLabel ?? '--泥点';
|
||||
const walletAriaLabel = walletBalanceLabel
|
||||
? `泥点余额 ${walletBalanceLabel}`
|
||||
: '泥点余额读取中';
|
||||
const userDisplayName = currentUser?.displayName?.trim() || '登录';
|
||||
const userAvatarUrl = currentUser?.avatarUrl?.trim() || null;
|
||||
const userAvatarLabel = userDisplayName.slice(0, 1).toUpperCase();
|
||||
@@ -198,22 +204,16 @@ export function ImageCanvasTopbarView({
|
||||
{assetExportStatus.message}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__wallet-chip"
|
||||
onClick={onOpenWallet}
|
||||
aria-label={walletAriaLabel}
|
||||
aria-busy={isWalletBalanceLoading}
|
||||
aria-live="polite"
|
||||
>
|
||||
<img
|
||||
src="/creation-home/topbar-wallet.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
/>
|
||||
<span>{walletDisplayLabel}</span>
|
||||
</button>
|
||||
<PlatformMudPointWalletEntry
|
||||
variant="editor"
|
||||
balance={walletBalance}
|
||||
breakdown={walletBreakdown}
|
||||
isLoading={isWalletBalanceLoading || isWalletDetailsLoading}
|
||||
error={walletDetailsError}
|
||||
onRequestDetails={onRequestWalletDetails}
|
||||
onRecharge={onRecharge}
|
||||
onOpenLedger={onOpenWalletLedger}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__account-chip"
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import {
|
||||
applyQueuedEditorGenerationProject,
|
||||
resolveEditorGenerationMediaReference,
|
||||
useImageCanvasGenerationSubmissionWorkflow,
|
||||
} from './useImageCanvasGenerationSubmissionWorkflow';
|
||||
|
||||
@@ -32,6 +33,7 @@ const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||||
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
|
||||
vi.fn(),
|
||||
);
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
|
||||
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
|
||||
@@ -68,6 +70,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/external-generation', () => ({
|
||||
getExternalGenerationJobStatus: getExternalGenerationJobStatusMock,
|
||||
}));
|
||||
@@ -466,6 +472,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockReset();
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockReset();
|
||||
uploadEditorMediaAssetFileMock.mockReset();
|
||||
editEditorImageMock.mockReset();
|
||||
extractEditorUiDesignAssetsMock.mockReset();
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
|
||||
@@ -483,16 +490,58 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation(
|
||||
async (src: string) => src,
|
||||
);
|
||||
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
||||
src: 'https://signed.example.test/generation-reference.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
assetObjectId: 'asset-object-generation-reference',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/generation-references/reference.png',
|
||||
});
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
|
||||
'data:image/png;base64,marked-ui-design',
|
||||
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses distinct upload paths for concurrent inline references in the same millisecond', async () => {
|
||||
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_752_144_000_000);
|
||||
try {
|
||||
await Promise.all([
|
||||
resolveEditorGenerationMediaReference(
|
||||
{ src: 'data:image/png;base64,YQ==' },
|
||||
'image',
|
||||
'project-1',
|
||||
),
|
||||
resolveEditorGenerationMediaReference(
|
||||
{ src: 'data:image/png;base64,Yg==' },
|
||||
'image',
|
||||
'project-1',
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(2);
|
||||
const [firstFile, , firstOptions] =
|
||||
uploadEditorMediaAssetFileMock.mock.calls[0] ?? [];
|
||||
const [secondFile, , secondOptions] =
|
||||
uploadEditorMediaAssetFileMock.mock.calls[1] ?? [];
|
||||
expect((firstFile as File).name).not.toBe((secondFile as File).name);
|
||||
expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments);
|
||||
});
|
||||
|
||||
it('submits quick edits and updates the source layer directly', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '快速修图' }),
|
||||
);
|
||||
render(<SubmissionWorkflowHarness />);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({ originalWidth: 1537, originalHeight: 1025 }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
||||
@@ -506,8 +555,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '快速修图',
|
||||
sourceImageSrc: 'data:image/png;base64,source.png',
|
||||
size: '1024x768',
|
||||
sourceImageSrc: 'resource-source',
|
||||
size: '1537x1025',
|
||||
model: 'gpt-image-2',
|
||||
}),
|
||||
);
|
||||
@@ -516,6 +565,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
'canvasCompletion',
|
||||
);
|
||||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-source:源图:resource-source:-:-:data:image/png;base64,generated',
|
||||
@@ -606,7 +656,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '把当前图改成雨天',
|
||||
sourceImageSrc: 'data:image/png;base64,source.png',
|
||||
sourceImageSrc: 'resource-source',
|
||||
size: '1024x768',
|
||||
model: 'gpt-image-2',
|
||||
sourceResourceId: 'resource-source',
|
||||
@@ -727,7 +777,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
/>,
|
||||
);
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,marked-source',
|
||||
'data:image/png;base64,bWFya2VkLXNvdXJjZQ==',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||||
@@ -751,13 +801,24 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'image',
|
||||
expect.objectContaining({
|
||||
assetKind: 'editor_generation_reference_image',
|
||||
}),
|
||||
);
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-source',
|
||||
sourceImageSrc:
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
targetLayerId: 'layer-source',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0);
|
||||
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'referenceImageSrcs',
|
||||
);
|
||||
@@ -812,7 +873,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
/>,
|
||||
);
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,marked-source',
|
||||
'data:image/png;base64,bWFya2VkLXNvdXJjZQ==',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
@@ -839,7 +900,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
await waitFor(() => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-source',
|
||||
sourceImageSrc:
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
model: 'gpt-image-2',
|
||||
targetLayerId: 'layer-source',
|
||||
}),
|
||||
@@ -907,7 +969,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
imageSize: '1K',
|
||||
kind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
referenceImageSrcs: ['data:image/png;base64,source.png'],
|
||||
referenceImageSrcs: ['resource-source'],
|
||||
assetKind: 'character',
|
||||
}),
|
||||
);
|
||||
@@ -959,8 +1021,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
title: '源视频',
|
||||
src: 'https://assets.example.test/source.mp4',
|
||||
objectKey: 'generated-character-drafts/editor/videos/source.mp4',
|
||||
src: 'data:video/mp4;base64,c291cmNlLXZpZGVv',
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
model: 'gpt-image-2',
|
||||
@@ -1001,7 +1062,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
mode: 'std',
|
||||
sound: 'off',
|
||||
referenceVideoSrcs: [
|
||||
'generated-character-drafts/editor/videos/source.mp4',
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
],
|
||||
assetFolderId: 'project',
|
||||
assetLabel: '源视频 快速编辑',
|
||||
@@ -1026,6 +1087,66 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads inline video references and keeps image objectKey and asset URLs', async () => {
|
||||
generateEditorVideoMock.mockResolvedValueOnce(createVideoGenerated());
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialDialog={{
|
||||
id: 'dialog-video-references',
|
||||
mode: 'video',
|
||||
prompt: '让画面动起来',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
videoModel: 'seedance2.0-fast',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'image-reference',
|
||||
label: '图片参考',
|
||||
src: 'data:image/png;base64,aW1hZ2U=',
|
||||
objectKey: 'generated-character-drafts/editor/refs/image.png',
|
||||
},
|
||||
{
|
||||
id: 'video-reference',
|
||||
label: '视频参考',
|
||||
src: 'data:video/mp4;base64,dmlkZW8=',
|
||||
mediaType: 'video',
|
||||
},
|
||||
{
|
||||
id: 'audio-reference',
|
||||
label: '音频参考',
|
||||
src: 'asset://asset-audio-reference',
|
||||
mediaType: 'audio',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'video',
|
||||
expect.objectContaining({
|
||||
assetKind: 'editor_generation_reference_video',
|
||||
}),
|
||||
);
|
||||
expect(generateEditorVideoMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrcs: [
|
||||
'generated-character-drafts/editor/refs/image.png',
|
||||
],
|
||||
referenceVideoSrcs: [
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
],
|
||||
referenceAudioSrcs: ['asset://asset-audio-reference'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('submits character action quick edits through image edits', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
@@ -1075,7 +1196,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '让当前图跳得更高',
|
||||
sourceImageSrc: 'data:image/png;base64,frame.png',
|
||||
sourceImageSrc: 'resource-source',
|
||||
model: 'gpt-image-2',
|
||||
assetLabel: '角色动作 快速编辑',
|
||||
targetLayerId: 'layer-source',
|
||||
@@ -1117,7 +1238,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '修改当前图片',
|
||||
sourceImageSrc: 'data:image/png;base64,source.png',
|
||||
sourceImageSrc: 'resource-source',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1341,10 +1462,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves uploaded objectKey references before submitting image generation', async () => {
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
|
||||
'data:image/jpeg;base64,normalized-ref',
|
||||
);
|
||||
it('submits uploaded objectKey references without downloading or inlining them', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '参考图生成' }),
|
||||
);
|
||||
@@ -1382,16 +1500,17 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock,
|
||||
).toHaveBeenCalledWith('generated-character-drafts/editor/refs/ref.png');
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '参考图生成',
|
||||
referenceImageSrcs: ['data:image/jpeg;base64,normalized-ref'],
|
||||
referenceImageSrcs: [
|
||||
'generated-character-drafts/editor/refs/ref.png',
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the wallet balance after a queued generation reaches a terminal state', async () => {
|
||||
@@ -1525,10 +1644,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
1,
|
||||
unresolvedProject,
|
||||
);
|
||||
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
completedProject,
|
||||
);
|
||||
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(2, completedProject);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -1691,7 +1807,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
{
|
||||
id: 'ref-1',
|
||||
label: '主视觉参考',
|
||||
src: 'data:image/png;base64,large-ref',
|
||||
src: 'DaTa:image/png;base64,bGFyZ2UtcmVm',
|
||||
},
|
||||
],
|
||||
placeholder: {
|
||||
@@ -1721,7 +1837,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '9:16',
|
||||
imageSize: '1K',
|
||||
referenceImageSrcs: ['data:image/png;base64,large-ref'],
|
||||
referenceImageSrcs: [
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1742,10 +1860,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('compresses publication references before submitting generation', async () => {
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
|
||||
'data:image/jpeg;base64,compressed-ref',
|
||||
);
|
||||
it('submits publication resource ids without downloading or inlining them', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
imageSrc: 'data:image/png;base64,publication',
|
||||
@@ -1773,6 +1888,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
id: 'ref-1',
|
||||
label: '主视觉参考',
|
||||
src: 'data:image/png;base64,large-original-ref',
|
||||
resourceId: 'resource-publication-ref',
|
||||
},
|
||||
],
|
||||
}}
|
||||
@@ -1783,18 +1899,17 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock,
|
||||
).toHaveBeenCalledWith('data:image/png;base64,large-original-ref');
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'publication-material',
|
||||
aspectRatio: '4:3',
|
||||
imageSize: '1K',
|
||||
referenceImageSrcs: ['data:image/jpeg;base64,compressed-ref'],
|
||||
referenceImageSrcs: ['resource-publication-ref'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits icon spec objects without requiring an icon spec reference', async () => {
|
||||
@@ -1913,7 +2028,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
iconSpecReference: {
|
||||
id: 'spec',
|
||||
label: '图标规范',
|
||||
src: 'data:image/png;base64,spec',
|
||||
src: 'data:image/png;base64,c3BlYw==',
|
||||
},
|
||||
iconDescriptions: ['旧返回', '旧设置'],
|
||||
placeholder: {
|
||||
@@ -1934,7 +2049,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
await waitFor(() => {
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrc: 'data:image/png;base64,spec',
|
||||
referenceImageSrc:
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
model: 'gpt-image-2',
|
||||
}),
|
||||
@@ -1992,7 +2108,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
id: 'ref-style',
|
||||
label: '风格参考',
|
||||
src: 'https://signed.example.test/icon-style.png?expires=1',
|
||||
objectKey: 'generated-character-drafts/editor/refs/icon-style.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/refs/icon-style.png',
|
||||
assetObjectId: 'assetobj-icon-style',
|
||||
},
|
||||
],
|
||||
@@ -2004,7 +2121,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrc:
|
||||
@@ -2115,6 +2234,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
});
|
||||
|
||||
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
|
||||
);
|
||||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||||
spritesheetWidth: 512,
|
||||
@@ -2166,7 +2288,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
await waitFor(() => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||||
sourceImageSrc:
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
@@ -2213,7 +2336,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提取带参考图UI素材' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
@@ -2290,7 +2415,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
createLayer({
|
||||
id: 'character-layer',
|
||||
assetKind: 'character',
|
||||
objectKey: 'generated/character.png',
|
||||
src: 'data:image/png;base64,Y2hhcmFjdGVy',
|
||||
}),
|
||||
]}
|
||||
initialCharacterAnimationPanel={{
|
||||
@@ -2314,7 +2439,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceLayerId: 'character-layer',
|
||||
sourceImageSrc: 'generated/character.png',
|
||||
sourceImageSrc:
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
promptText: '跑步',
|
||||
model: 'seedance2.0-fast',
|
||||
}),
|
||||
@@ -2450,7 +2576,12 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialLayers={[createLayer({ id: 'character-layer' })]}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
id: 'character-layer',
|
||||
objectKey: 'generated/character.png',
|
||||
}),
|
||||
]}
|
||||
initialCharacterAnimationPanel={{
|
||||
sourceLayerId: 'character-layer',
|
||||
promptText: '跑步',
|
||||
@@ -2478,7 +2609,12 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error(''));
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialLayers={[createLayer({ id: 'character-layer' })]}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
id: 'character-layer',
|
||||
objectKey: 'generated/character.png',
|
||||
}),
|
||||
]}
|
||||
initialCharacterAnimationPanel={{
|
||||
sourceLayerId: 'character-layer',
|
||||
promptText: '',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -12,9 +18,9 @@ import type {
|
||||
ImageContextMenuState,
|
||||
SidebarPanel,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
||||
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
|
||||
|
||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
||||
@@ -1268,7 +1274,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
assetFolderId: undefined,
|
||||
assetKind: undefined,
|
||||
assetLabel: '生成图片 1',
|
||||
referenceImageSrcs: ['data:image/png;base64,source'],
|
||||
referenceImageSrcs: ['resource-source'],
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '一张生成图' }],
|
||||
references: [
|
||||
@@ -1514,6 +1520,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
});
|
||||
|
||||
it('submits spec generation with reference image and reference prompt semantics', async () => {
|
||||
uploadEditorMediaAssetFileMock.mockResolvedValueOnce({
|
||||
src: 'https://signed.example.test/generation-reference.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
assetObjectId: 'asset-object-generation-reference',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/generation-references/reference.png',
|
||||
});
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: 'UI规范图' }),
|
||||
);
|
||||
@@ -1531,7 +1545,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
referenceImageSrcs: ['data:image/png;base64,ref'],
|
||||
referenceImageSrcs: [
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
],
|
||||
prompt: expect.stringContaining('参考图生成规范'),
|
||||
}),
|
||||
);
|
||||
@@ -1630,7 +1646,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '快速修图',
|
||||
sourceImageSrc: 'data:image/png;base64,source',
|
||||
sourceImageSrc: 'resource-source',
|
||||
assetLabel: '源图 快速编辑',
|
||||
sourceResourceId: 'resource-source',
|
||||
generationInputs: {
|
||||
@@ -1789,7 +1805,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(removeImageBackgroundMock).toHaveBeenCalledWith({
|
||||
sourceImageSrc: '/generated-character-drafts/editor/private.png',
|
||||
sourceImageSrc: 'generated-character-drafts/editor/private.png',
|
||||
projectId: undefined,
|
||||
targetLayerId: 'layer-source',
|
||||
assetKind: 'character',
|
||||
@@ -1832,6 +1848,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
expect(removeImageBackgroundMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'resource-source',
|
||||
projectId: 'project-1',
|
||||
targetLayerId: 'layer-source',
|
||||
assetLabel: '源图 去背景',
|
||||
@@ -1845,6 +1862,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
resolveBackgroundRemoval?.({
|
||||
imageSrc:
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
@@ -36,7 +37,6 @@ import type {
|
||||
SpecFormValues,
|
||||
SpecGenerationType,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||||
import {
|
||||
appendCharacterReference,
|
||||
appendGenerationReference,
|
||||
@@ -103,6 +103,8 @@ import {
|
||||
} from './ImageCanvasUiAssetExtractionModel';
|
||||
import {
|
||||
applyQueuedEditorGenerationProject,
|
||||
createEditorGenerationMediaUploadId,
|
||||
resolveEditorGenerationMediaReference,
|
||||
useImageCanvasGenerationSubmissionWorkflow,
|
||||
} from './useImageCanvasGenerationSubmissionWorkflow';
|
||||
|
||||
@@ -222,7 +224,10 @@ function preserveSourceLayerInProjectSnapshot(
|
||||
resources: sourceResource
|
||||
? [...project.resources, sourceResource]
|
||||
: project.resources,
|
||||
layers: [createProjectLayerSnapshotFromLayer(sourceLayer), ...project.layers],
|
||||
layers: [
|
||||
createProjectLayerSnapshotFromLayer(sourceLayer),
|
||||
...project.layers,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1434,9 +1439,10 @@ export function useImageCanvasGenerationWorkflow({
|
||||
cropExpandSourceLayer.resourceId;
|
||||
let cropExpandAssetKind = cropExpandSourceLayer.assetKind;
|
||||
if (cropExpandProjectId) {
|
||||
const cropExpandUploadId = createEditorGenerationMediaUploadId();
|
||||
const cropExpandFile = dataUrlToImageFile(
|
||||
result.imageSrc,
|
||||
`crop-expand-${cropExpandIndex}.png`,
|
||||
`crop-expand-${cropExpandIndex}-${cropExpandUploadId}.png`,
|
||||
);
|
||||
const uploadedCropExpand = await uploadEditorMediaAssetFile(
|
||||
cropExpandFile,
|
||||
@@ -1447,7 +1453,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
'editor',
|
||||
'crop-expand',
|
||||
cropExpandProjectId,
|
||||
`${Date.now()}`,
|
||||
cropExpandUploadId,
|
||||
],
|
||||
entityId: cropExpandProjectId,
|
||||
metadata: {
|
||||
@@ -1480,8 +1486,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
cropExpandObjectKey =
|
||||
cropExpandResource.objectKey ?? uploadedCropExpand.objectKey;
|
||||
cropExpandAssetObjectId =
|
||||
cropExpandResource.assetObjectId ??
|
||||
uploadedCropExpand.assetObjectId;
|
||||
cropExpandResource.assetObjectId ?? uploadedCropExpand.assetObjectId;
|
||||
cropExpandSourceResourceId =
|
||||
cropExpandResource.sourceResourceId ??
|
||||
cropExpandSourceLayer.resourceId;
|
||||
@@ -1574,10 +1579,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
const sourceObjectKey = sourceLayer.objectKey?.trim();
|
||||
const sourceImageSrc = sourceObjectKey
|
||||
? `/${sourceObjectKey.replace(/^\/+/u, '')}`
|
||||
: await resolveEditorImageReferenceDataUrl(sourceLayer.src);
|
||||
const sourceImageSrc = await resolveEditorGenerationMediaReference(
|
||||
sourceLayer,
|
||||
'image',
|
||||
projectId,
|
||||
);
|
||||
const result = await removeImageBackground({
|
||||
sourceImageSrc,
|
||||
projectId,
|
||||
@@ -1672,7 +1678,6 @@ export function useImageCanvasGenerationWorkflow({
|
||||
openPlacedCanvasGenerationDialog,
|
||||
projectId,
|
||||
refreshTaskListForQueuedGeneration,
|
||||
resolveEditorImageReferenceDataUrl,
|
||||
setActiveSidebarPanel,
|
||||
setImageContextMenu,
|
||||
setMetadataLayer,
|
||||
|
||||
@@ -95,7 +95,10 @@ test('renders mobile home welcome copy and closes with the acknowledge button',
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '欢迎' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '欢迎' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '欢迎' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('heading', { level: 1 })).toBeNull();
|
||||
const copy = document.querySelector(
|
||||
'.platform-mobile-home-welcome-dialog__copy',
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ export function PlatformMobileHomeWelcomeDialog({
|
||||
alt=""
|
||||
className="platform-mobile-home-welcome-dialog__icon"
|
||||
/>
|
||||
<h1 className="platform-mobile-home-welcome-dialog__title">欢迎</h1>
|
||||
<h2 className="platform-mobile-home-welcome-dialog__title">欢迎</h2>
|
||||
<p className="platform-mobile-home-welcome-dialog__copy">
|
||||
尼嚎,新陶泥er!
|
||||
<br />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileRechargeProduct } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal';
|
||||
|
||||
function buildNormalMembership() {
|
||||
@@ -21,22 +22,73 @@ function buildNormalMembership() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPointProduct() {
|
||||
function buildPointProduct(
|
||||
overrides: Partial<ProfileRechargeProduct> = {},
|
||||
): ProfileRechargeProduct {
|
||||
return {
|
||||
productId: 'points_60',
|
||||
title: '60泥点',
|
||||
priceCents: 600,
|
||||
kind: 'points' as const,
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 30,
|
||||
bonusPoints: 0,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠30泥点',
|
||||
badgeLabel: '',
|
||||
description: '60泥点',
|
||||
tier: 'normal' as const,
|
||||
membershipPeriodPoints: 0,
|
||||
membershipPeriodDays: 0,
|
||||
membershipQueueLimit: 0,
|
||||
membershipDiscountBps: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPointProducts(): ProfileRechargeProduct[] {
|
||||
return [
|
||||
buildPointProduct(),
|
||||
buildPointProduct({
|
||||
productId: 'points_180',
|
||||
title: '180泥点',
|
||||
priceCents: 1800,
|
||||
pointsAmount: 180,
|
||||
bonusPoints: 90,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠90泥点',
|
||||
}),
|
||||
buildPointProduct({
|
||||
productId: 'points_300',
|
||||
title: '300泥点',
|
||||
priceCents: 3000,
|
||||
pointsAmount: 300,
|
||||
bonusPoints: 150,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠150泥点',
|
||||
}),
|
||||
buildPointProduct({
|
||||
productId: 'points_680',
|
||||
title: '680泥点',
|
||||
priceCents: 6800,
|
||||
pointsAmount: 680,
|
||||
bonusPoints: 340,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠340泥点',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function buildMembershipProduct(): ProfileRechargeProduct {
|
||||
return {
|
||||
...buildPointProduct(),
|
||||
productId: 'member_month',
|
||||
title: '会员月卡',
|
||||
priceCents: 2800,
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
durationDays: 30,
|
||||
description: '会员月卡',
|
||||
tier: 'month',
|
||||
membershipPeriodDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +99,7 @@ vi.mock('qrcode', () => ({
|
||||
}));
|
||||
|
||||
describe('PlatformProfileRechargeModal', () => {
|
||||
test('renders point products and forwards buy action', async () => {
|
||||
test('renders the four point products and forwards the selected buy action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBuy = vi.fn();
|
||||
|
||||
@@ -56,8 +108,8 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
center={{
|
||||
walletBalance: 29,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [buildPointProduct()],
|
||||
membershipProducts: [],
|
||||
pointProducts: buildPointProducts(),
|
||||
membershipProducts: [buildMembershipProduct()],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
@@ -66,8 +118,6 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={null}
|
||||
activeTab="points"
|
||||
onTabChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={onBuy}
|
||||
@@ -76,24 +126,48 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('账户充值')).toBeTruthy();
|
||||
expect(screen.getByText('29泥点 · 普通用户')).toBeTruthy();
|
||||
expect(screen.getByText('60+30泥点')).toBeTruthy();
|
||||
const dialog = screen.getByRole('dialog', { name: '购买更多泥点' });
|
||||
expect(within(dialog).getByText('当前余额 29 泥点')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '180泥点 +90泥点 ¥18 购买',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '300泥点 +150泥点 ¥30 购买',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '680泥点 +340泥点 ¥68 购买',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getAllByText('首充加赠')).toHaveLength(3);
|
||||
expect(within(dialog).queryByRole('tablist')).toBeNull();
|
||||
expect(within(dialog).queryByText('会员月卡')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /60泥点.*购买/u }));
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '180泥点 +90泥点 ¥18 购买',
|
||||
}),
|
||||
);
|
||||
expect(onBuy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ productId: 'points_60' }),
|
||||
expect.objectContaining({ productId: 'points_180' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows empty state when the selected tab has no products', () => {
|
||||
test('shows the point-product empty state without exposing membership purchase', () => {
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
membershipProducts: [buildMembershipProduct()],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
@@ -102,8 +176,6 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={null}
|
||||
activeTab="membership"
|
||||
onTabChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
@@ -113,49 +185,8 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('暂无可购买套餐')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('uses shared segmented tabs for recharge type switching', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTabChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={null}
|
||||
activeTab="points"
|
||||
onTabChange={onTabChange}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={vi.fn()}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tablist = screen.getByRole('tablist', { name: '充值类型' });
|
||||
const pointsTab = screen.getByRole('tab', { name: '泥点充值' });
|
||||
const membershipTab = screen.getByRole('tab', { name: '会员卡' });
|
||||
|
||||
expect(tablist.className).toContain('grid');
|
||||
expect(tablist.className).toContain('grid-cols-2');
|
||||
expect(pointsTab.getAttribute('aria-selected')).toBe('true');
|
||||
expect(membershipTab.getAttribute('aria-selected')).toBe('false');
|
||||
|
||||
await user.click(membershipTab);
|
||||
|
||||
expect(onTabChange).toHaveBeenCalledWith('membership');
|
||||
expect(screen.queryByText('会员月卡')).toBeNull();
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
});
|
||||
|
||||
test('opens native payment QR code in a separate dialog', async () => {
|
||||
@@ -186,8 +217,6 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
isConfirming: false,
|
||||
confirmMessage: '暂未确认到账,请确认付款完成后再点一次。',
|
||||
}}
|
||||
activeTab="points"
|
||||
onTabChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
@@ -196,7 +225,9 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const rechargeDialog = screen.getByRole('dialog', { name: '账户充值' });
|
||||
const rechargeDialog = screen.getByRole('dialog', {
|
||||
name: '购买更多泥点',
|
||||
});
|
||||
const paymentDialog = screen.getByRole('dialog', {
|
||||
name: '微信扫码支付',
|
||||
});
|
||||
@@ -253,8 +284,6 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
expiresAt: '2000-01-01T00:00:00Z',
|
||||
isConfirming: false,
|
||||
}}
|
||||
activeTab="points"
|
||||
onTabChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user