修复泥点消耗刷新不及时 (#138)
Project CI / Repository checks (push) Successful in 50s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

refactor:
- 主站复用game agent的zustand store

fix:
- 修复旧请求覆盖问题
- 修复账号切换问题

- 主站原本已经每 4 秒轮询 external generation 任务状态, 在这里补充触发余额刷新的时机

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/138
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #138.
This commit is contained in:
2026-08-08 10:25:02 +08:00
committed by 段舒康
parent 44de26d204
commit fc0dcb0782
36 changed files with 4160 additions and 491 deletions
+53 -1
View File
@@ -2,7 +2,7 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useEffect, useState } from 'react';
import { StrictMode, useEffect, useState } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
@@ -16,6 +16,26 @@ import { useAuthUi } from './AuthUiContext';
const browserReloadMock = vi.hoisted(() => vi.fn());
const walletLifecycleMocks = vi.hoisted(() => ({
usePlatformWalletLifecycle: vi.fn(),
}));
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, String(value)),
};
}
const memoryLocalStorage = createMemoryStorage();
const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
@@ -77,6 +97,8 @@ vi.mock('../../services/authService', () => ({
startWechatLogin: authMocks.startWechatLogin,
}));
vi.mock('../../stores/usePlatformWalletStore', () => walletLifecycleMocks);
const hostBridgeMocks = vi.hoisted(() => ({
getHostRuntime: vi.fn(() => ({
kind: 'browser',
@@ -136,6 +158,10 @@ const mockUser: AuthUser = {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: memoryLocalStorage,
});
window.localStorage.clear();
window.history.replaceState(null, '', '/');
setAuthGateReloadForTest(vi.fn());
@@ -381,6 +407,32 @@ test('auth gate keeps a valid local token login when refresh rotation fails afte
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth root binds the single wallet lifecycle under StrictMode', async () => {
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
authMocks.refreshStoredAccessToken.mockRejectedValue(
new Error('refresh cookie 失效'),
);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<StrictMode>
<AuthGate>
<LogoutStateProbe />
</AuthGate>
</StrictMode>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
await waitFor(() => {
expect(
walletLifecycleMocks.usePlatformWalletLifecycle,
).toHaveBeenCalledWith('user-1', true);
});
});
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: [],
+24 -20
View File
@@ -50,6 +50,7 @@ import {
reloadHostWebView,
requestHostLogin,
} from '../../services/host-bridge/hostBridge';
import { usePlatformWalletLifecycle } from '../../stores/usePlatformWalletStore';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { AccountModal } from './AccountModal';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
@@ -117,10 +118,7 @@ function normalizeAvailableLoginMethods(
// 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。
return Array.from(
new Set<AuthLoginMethod>([
...REQUIRED_LOGIN_METHODS,
...normalizedMethods,
]),
new Set<AuthLoginMethod>([...REQUIRED_LOGIN_METHODS, ...normalizedMethods]),
);
}
@@ -192,10 +190,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
const markAuthStateReloadIfChanged = useCallback(
(
nextUser: AuthUser | null,
options: { reloadOnChange?: boolean } = {},
) => {
(nextUser: AuthUser | null, options: { reloadOnChange?: boolean } = {}) => {
const nextHasUser = Boolean(nextUser);
const previousHasUser = lastStableAuthPresenceRef.current;
if (previousHasUser === null) {
@@ -204,23 +199,23 @@ export function AuthGate({ children }: AuthGateProps) {
}
lastStableAuthPresenceRef.current = nextHasUser;
if (
previousHasUser !== nextHasUser &&
options.reloadOnChange !== false
) {
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 activateReadyUser = useCallback(
(nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
authHydrateVersionRef.current += 1;
markAuthStateReloadIfChanged(nextUser);
setUser(nextUser);
setStatus('ready');
},
[markAuthStateReloadIfChanged],
);
const clearLocalAuthenticatedState = useCallback(
(options: { reloadOnChange?: boolean } = {}) => {
@@ -687,6 +682,11 @@ export function AuthGate({ children }: AuthGateProps) {
],
);
usePlatformWalletLifecycle(
readyUser?.id ?? null,
status === 'ready' && Boolean(readyUser),
);
if (status === 'checking' && !canKeepPlatformContentMounted) {
return (
<div
@@ -969,7 +969,11 @@ export function AuthGate({ children }: AuthGateProps) {
const registrationInviteCode =
pendingInviteCode || readInviteCodeFromLocation();
const response = registrationInviteCode
? await loginWithPhoneCode(phone, code, registrationInviteCode)
? await loginWithPhoneCode(
phone,
code,
registrationInviteCode,
)
: await loginWithPhoneCode(phone, code);
const autoRedeemedInvite = response.referral?.ok === true;
setStoredLastLoginPhone(phone);
@@ -994,9 +994,11 @@ describe('CreationLandingView', () => {
'creation-landing__asset-preview--campaign',
);
expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200');
const campaignImage = await screen.findByRole('img', {
name: '活动精选',
});
const campaignImage = await screen.findByRole(
'img',
{ name: '活动精选' },
{ timeout: 5_000 },
);
expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl);
expect(fetchMock).toHaveBeenCalledWith(
`/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`,
@@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event';
import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
import {
ApiClientError,
@@ -294,6 +295,7 @@ describe('ImageCanvasEditorView', () => {
});
beforeEach(() => {
usePlatformWalletStore.getState().resetWalletBalance();
loadFrontendRuntimeConfigMock.mockImplementation(() =>
immediateAsync({
imageEditorAgentSidebarEnabled: false,
@@ -593,6 +595,21 @@ describe('ImageCanvasEditorView', () => {
});
it('shows the live mud point balance in the canvas topbar when logged in', async () => {
usePlatformWalletStore.getState().setWalletOwner('user-1');
const walletSnapshot = usePlatformWalletStore
.getState()
.captureWalletBalanceSnapshot('user-1');
usePlatformWalletStore
.getState()
.applyWalletBalanceSnapshot(walletSnapshot!, {
totalPoints: 1234,
permanentPoints: 1000,
limitedPoints: 214,
limitedExpiresAt: '2026-07-31T16:00:00Z',
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
});
render(
<AuthUiContext.Provider
value={createAuthValue({
@@ -614,12 +631,7 @@ describe('ImageCanvasEditorView', () => {
);
expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy();
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
});
expect(getPlatformProfileDashboardMock).not.toHaveBeenCalled();
});
it('opens the account modal from the canvas topbar avatar entry', async () => {
@@ -658,8 +670,97 @@ describe('ImageCanvasEditorView', () => {
expect(openAccountModal).toHaveBeenCalledTimes(1);
});
it('shows the owner-matched legacy wallet total without inventing breakdown rows', async () => {
const user = userEvent.setup();
usePlatformWalletStore.getState().setWalletOwner('user-1');
const walletSnapshot = usePlatformWalletStore
.getState()
.captureWalletBalanceSnapshot('user-1');
usePlatformWalletStore
.getState()
.applyLegacyWalletBalanceSnapshot(walletSnapshot!, 37);
getPlatformProfileRechargeCenterMock.mockResolvedValue({
walletBalance: 37,
});
render(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
const walletButton = await screen.findByRole('button', {
name: '泥点 37',
});
await user.hover(walletButton);
const details = await screen.findByRole('dialog', {
name: '泥点账户详情',
});
expect(within(details).getByText('泥点明细读取失败')).toBeTruthy();
expect(within(details).queryByText('充值中心响应缺少泥点余额')).toBeNull();
expect(within(details).queryByText('不限时泥点')).toBeNull();
expect(within(details).queryByText('每日免费泥点')).toBeNull();
});
it('keeps the wallet entry loading while the authenticated owner is not bound yet', async () => {
render(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
expect(
(await screen.findByRole('button', { name: '泥点 --' })).getAttribute(
'aria-busy',
),
).toBe('true');
});
it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => {
const user = userEvent.setup();
usePlatformWalletStore.getState().setWalletOwner('user-1');
const walletSnapshot = usePlatformWalletStore
.getState()
.captureWalletBalanceSnapshot('user-1');
usePlatformWalletStore
.getState()
.applyWalletBalanceSnapshot(walletSnapshot!, {
totalPoints: 1234,
permanentPoints: 1000,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 234,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
});
render(
<AuthUiContext.Provider
value={createAuthValue({
@@ -23,7 +23,7 @@ import {
loadEditorProject,
} from '../../services/image-editor/editorProjectClient';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
@@ -137,6 +137,7 @@ const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
];
type ImageCanvasEditorViewProps = {
legacyWalletBalance?: number | null;
onProjectAccessLost?: () => void;
};
@@ -319,12 +320,50 @@ const DEAD_INLINE_PLACEHOLDER_NOTICE =
'上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。';
export function ImageCanvasEditorView({
legacyWalletBalance = null,
onProjectAccessLost,
}: ImageCanvasEditorViewProps = {}) {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
const [walletBalance, setWalletBalance] = useState<number | null>(null);
const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false);
const walletOwnerUserId = usePlatformWalletStore(
(state) => state.ownerUserId,
);
const storedMudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const storedLegacyWalletBalance = usePlatformWalletStore(
(state) => state.legacyWalletBalance,
);
const storedMudPointBalanceStatus = usePlatformWalletStore(
(state) => state.mudPointBalanceStatus,
);
const storedMudPointBalanceError = usePlatformWalletStore(
(state) => state.mudPointBalanceError,
);
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
const currentWalletOwnerUserId =
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
const walletOwnerMatchesCurrentUser =
Boolean(currentWalletOwnerUserId) &&
walletOwnerUserId === currentWalletOwnerUserId;
const mudPointBalance = walletOwnerMatchesCurrentUser
? storedMudPointBalance
: null;
const mudPointBalanceError = walletOwnerMatchesCurrentUser
? storedMudPointBalanceError
: '';
const walletBalance =
mudPointBalance?.totalPoints ??
(walletOwnerMatchesCurrentUser
? (storedLegacyWalletBalance ?? legacyWalletBalance)
: null);
const isWalletBalanceLoading =
Boolean(currentWalletOwnerUserId) &&
(!walletOwnerMatchesCurrentUser ||
storedMudPointBalanceStatus === 'idle' ||
storedMudPointBalanceStatus === 'loading');
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
@@ -507,21 +546,6 @@ export function ImageCanvasEditorView({
},
[],
);
const refreshEditorWalletBalance = useCallback(() => {
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
return;
}
void getPlatformProfileDashboard({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
})
.then((dashboard) => {
setWalletBalance(dashboard.walletBalance);
})
.catch(() => undefined);
}, []);
const {
buyRechargeProduct,
closeNativeWechatPayment,
@@ -557,7 +581,6 @@ export function ImageCanvasEditorView({
activeTab: 'editor-canvas',
isAuthenticated: Boolean(authUi?.user),
showRechargeEntry,
onRechargeSuccess: refreshEditorWalletBalance,
requestLogin: () => authUiRef.current?.openLoginModal(),
currentUser: authUi?.user ?? null,
});
@@ -565,9 +588,8 @@ export function ImageCanvasEditorView({
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
return;
}
refreshEditorWalletBalance();
loadRechargeCenter();
}, [loadRechargeCenter, refreshEditorWalletBalance]);
void onWalletBalanceMayHaveChanged();
}, [onWalletBalanceMayHaveChanged]);
const isAccountPaymentModalOpen =
isRewardCodeOpen ||
isRechargeOpen ||
@@ -589,66 +611,6 @@ export function ImageCanvasEditorView({
window.location.reload();
});
}, [authUi]);
useEffect(() => {
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
setWalletBalance(null);
setIsWalletBalanceLoading(false);
return;
}
let isMounted = true;
let requestId = 0;
const refreshWalletBalance = () => {
const currentRequestId = requestId + 1;
requestId = currentRequestId;
setIsWalletBalanceLoading(true);
void getPlatformProfileDashboard({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
})
.then((dashboard) => {
if (!isMounted || currentRequestId !== requestId) {
return;
}
setWalletBalance(dashboard.walletBalance);
})
.catch(() => {
if (!isMounted || currentRequestId !== requestId) {
return;
}
setWalletBalance(null);
})
.finally(() => {
if (!isMounted || currentRequestId !== requestId) {
return;
}
setIsWalletBalanceLoading(false);
});
};
refreshWalletBalance();
const handleWindowFocus = () => {
refreshWalletBalance();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshWalletBalance();
}
};
window.addEventListener('focus', handleWindowFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
isMounted = false;
window.removeEventListener('focus', handleWindowFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [authUi?.canAccessProtectedData, authUi?.user?.id]);
const {
projectTitle,
setProjectTitle,
@@ -1081,8 +1043,10 @@ export function ImageCanvasEditorView({
layer.assetKind === 'character-animation' &&
(persistedAssetKind !== 'character-animation' ||
(asset.imageSequenceFrames?.length ?? 0) < 2 ||
!(asset.imageSequenceDurationMs &&
asset.imageSequenceDurationMs > 0))
!(
asset.imageSequenceDurationMs &&
asset.imageSequenceDurationMs > 0
))
) {
throw new Error('服务器未返回完整的角色动作正式字段');
}
@@ -1508,9 +1472,8 @@ export function ImageCanvasEditorView({
if (warning) {
showGenerationWarning(warning);
}
refreshEditorWalletState();
},
[projectId, refreshEditorWalletState, showGenerationWarning],
[projectId, showGenerationWarning],
);
const effectiveIsAgentConversationOpen =
isAgentConversationEnabled && isAgentConversationOpen;
@@ -2498,10 +2461,10 @@ export function ImageCanvasEditorView({
projectRenameError,
layers,
walletBalance,
walletBreakdown: rechargeCenter?.mudPointBalance ?? null,
walletBreakdown: mudPointBalance,
isWalletBalanceLoading,
isWalletDetailsLoading: isLoadingRechargeCenter,
walletDetailsError: rechargeError,
walletDetailsError: rechargeError || mudPointBalanceError || null,
currentUser: authUi?.user,
assetExportStatus,
isExportingAssets,
@@ -2609,6 +2572,7 @@ export function ImageCanvasEditorView({
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusExternalTask: focusExternalGenerationTask,
onExternalTasksCompleted: handleExternalGenerationTasksCompleted,
onExternalTaskWalletMayHaveChanged: refreshEditorWalletState,
onEditorAgentConfirmSent: handleEditorAgentConfirmSent,
onToggleTaskSidebar: toggleTaskSidebar,
onToggleAgentConversation: toggleAgentConversation,
@@ -141,6 +141,7 @@ export type ImageCanvasStageViewProps = {
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void;
onExternalTaskWalletMayHaveChanged?: () => void;
onEditorAgentConfirmSent?: () => void;
onToggleTaskSidebar: () => void;
onToggleAgentConversation: () => void;
@@ -284,6 +285,7 @@ export function ImageCanvasStageView({
onActivateGenerationDialog,
onFocusExternalTask,
onExternalTasksCompleted,
onExternalTaskWalletMayHaveChanged,
onEditorAgentConfirmSent,
onToggleTaskSidebar,
onToggleAgentConversation,
@@ -421,8 +423,7 @@ export function ImageCanvasStageView({
selectedLayer && perfectPixelLayerIds.has(selectedLayer.id),
)}
isPerfectPixelPendingConfirmation={Boolean(
selectedLayer &&
pendingPerfectPixelLayerIds.has(selectedLayer.id),
selectedLayer && pendingPerfectPixelLayerIds.has(selectedLayer.id),
)}
onOpenQuickEditPanel={onOpenQuickEditPanel}
onOpenRedrawPanel={onOpenRedrawPanel}
@@ -529,6 +530,7 @@ export function ImageCanvasStageView({
onToggleOpen={onToggleTaskSidebar}
onFocusExternalTask={onFocusExternalTask}
onExternalTasksCompleted={onExternalTasksCompleted}
onExternalTaskWalletMayHaveChanged={onExternalTaskWalletMayHaveChanged}
/>
{isAgentConversationEnabled ? (
@@ -474,6 +474,423 @@ describe('ImageCanvasTaskSidebarView', () => {
}
});
it('refreshes the wallet while an external task is running and after a failed refund settles', async () => {
vi.useFakeTimers();
try {
const onExternalTasksCompleted = vi.fn();
const onExternalTaskWalletMayHaveChanged = vi.fn();
let activeRequestCount = 0;
const runningTask = createExternalTask({
jobId: 'wallet-running-task',
requestLabel: '长耗时图片生成',
status: 'running',
priceMudPoints: 20,
});
const failedTask = createExternalTask({
...runningTask,
status: 'failed',
progress: 0,
phaseDetail: '生成失败。',
error: '生成失败,泥点已退回。',
completedAt: new Date().toISOString(),
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: activeRequestCount === 1 ? 1 : 0,
unacknowledgedTerminalCount: activeRequestCount === 1 ? 0 : 1,
updatedAtMicros: activeRequestCount,
},
tasks: activeRequestCount === 1 ? [runningTask] : [],
});
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: activeRequestCount > 1 ? 1 : 0,
updatedAtMicros: activeRequestCount,
},
tasks: activeRequestCount > 1 ? [failedTask] : [],
});
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTasksCompleted={onExternalTasksCompleted}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2);
expect(onExternalTasksCompleted).not.toHaveBeenCalled();
expect(refreshCanvasMock).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('keeps wallet polling global when the active external task belongs to another project', async () => {
vi.useFakeTimers();
try {
const onExternalTaskWalletMayHaveChanged = vi.fn();
let activeRequestCount = 0;
const otherProjectTask = createExternalTask({
jobId: 'other-project-wallet-task',
sourceEntityId: 'project-other',
status: 'running',
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: activeRequestCount === 1 ? 1 : 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: activeRequestCount === 1 ? [otherProjectTask] : [],
});
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: [],
});
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.queryByText('发光猫咪主视觉')).toBeNull();
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('retries a failed bootstrap and starts wallet polling from the recovered active task', async () => {
vi.useFakeTimers();
try {
const onExternalTaskWalletMayHaveChanged = vi.fn();
const runningTask = createExternalTask({
jobId: 'bootstrap-recovered-task',
status: 'running',
});
let activeRequestCount = 0;
listExternalGenerationTasksMock.mockImplementation(
(
options: Parameters<typeof listExternalGenerationTasks>[0] = {},
): ReturnType<typeof listExternalGenerationTasks> => {
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
if (activeRequestCount === 1) {
return Promise.reject(new Error('temporary bootstrap failure'));
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 1,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: [runningTask],
});
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: [],
});
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2);
expect(activeRequestCount).toBe(3);
} finally {
vi.useRealTimers();
}
});
it('keeps a successful active task and wallet polling when only completed tasks fail', async () => {
vi.useFakeTimers();
try {
const onExternalTaskWalletMayHaveChanged = vi.fn();
const runningTask = createExternalTask({
jobId: 'active-while-completed-fails',
requestLabel: '仍在生成的任务',
status: 'running',
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
if (options.statuses?.includes('running')) {
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 1,
unacknowledgedTerminalCount: 0,
updatedAtMicros: 1,
},
tasks: [runningTask],
});
}
return Promise.reject(new Error('completed tasks unavailable'));
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('仍在生成的任务')).toBeTruthy();
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await Promise.resolve();
});
expect(screen.getByText('仍在生成的任务')).toBeTruthy();
expect(
onExternalTaskWalletMayHaveChanged.mock.calls.length,
).toBeGreaterThan(1);
} finally {
vi.useRealTimers();
}
});
it('stops bootstrap retries after the bounded retry schedule is exhausted', async () => {
vi.useFakeTimers();
try {
listExternalGenerationTasksMock.mockRejectedValue(
new Error('task list unavailable'),
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await Promise.resolve();
});
expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(8);
} finally {
vi.useRealTimers();
}
});
it('clears stale tasks after a refreshed bootstrap exhausts its retries', async () => {
vi.useFakeTimers();
try {
const staleTask = createExternalTask({
jobId: 'stale-running-task',
requestLabel: '旧生成任务',
status: 'running',
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) =>
Promise.resolve({
overview: {
pendingCount: 0,
runningCount: options.statuses?.includes('running') ? 1 : 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: 1,
},
tasks: options.statuses?.includes('running') ? [staleTask] : [],
}),
);
const { rerender } = render(
<ImageCanvasTaskSidebarView
refreshKey={0}
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('旧生成任务')).toBeTruthy();
listExternalGenerationTasksMock.mockRejectedValue(
new Error('task list unavailable'),
);
rerender(
<ImageCanvasTaskSidebarView
refreshKey={1}
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await Promise.resolve();
});
expect(screen.queryByText('旧生成任务')).toBeNull();
expect(screen.getByText('暂无任务')).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it('aborts every bootstrap attempt when one branch fails and after retry unmounts', async () => {
vi.useFakeTimers();
try {
const requestSignals: AbortSignal[] = [];
let activeRequestCount = 0;
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
const signal = options.signal;
if (!signal) {
return Promise.reject(new Error('missing abort signal'));
}
requestSignals.push(signal);
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
if (activeRequestCount === 1) {
return Promise.reject(new Error('active bootstrap failed'));
}
}
return new Promise<
Awaited<ReturnType<typeof listExternalGenerationTasks>>
>((_, reject) => {
signal.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
);
});
},
);
const { unmount } = render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(requestSignals).toHaveLength(2);
expect(requestSignals[0]).toBe(requestSignals[1]);
expect(requestSignals[0]!.aborted).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
await Promise.resolve();
});
expect(requestSignals).toHaveLength(4);
expect(requestSignals[2]).toBe(requestSignals[3]);
expect(requestSignals[2]!.aborted).toBe(false);
unmount();
expect(requestSignals[2]!.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('loads more completed tasks while scrolling and keeps the request capped', async () => {
const completedTasks = Array.from({ length: 120 }, (_, index) => {
const completedAt = new Date(Date.now() - index * 1000).toISOString();
@@ -26,6 +26,7 @@ const COMPLETED_TASK_LIST_LIMIT = 20;
const COMPLETED_TASK_LIST_MAX_LIMIT = 100;
const ACTIVE_TASK_LIST_LIMIT = 100;
const ACTIVE_TASK_POLL_INTERVAL_MS = 4000;
const TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000] as const;
type TaskSidebarTab = 'active' | 'completed';
@@ -49,6 +50,7 @@ type ImageCanvasTaskSidebarViewProps = {
onToggleOpen: () => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void;
onExternalTaskWalletMayHaveChanged?: () => void;
};
function parseTaskTimeMs(value?: string | null) {
@@ -280,6 +282,7 @@ export function ImageCanvasTaskSidebarView({
onToggleOpen,
onFocusExternalTask,
onExternalTasksCompleted,
onExternalTaskWalletMayHaveChanged,
}: ImageCanvasTaskSidebarViewProps) {
const { refreshCanvas } = useImageCanvasActions();
const projectId = useImageCanvasContextStore((state) => state.projectId);
@@ -289,6 +292,8 @@ export function ImageCanvasTaskSidebarView({
const [externalTasks, setExternalTasks] = useState<
ExternalGenerationTaskRecord[]
>([]);
const [walletActiveExternalTaskIds, setWalletActiveExternalTaskIds] =
useState<string[]>([]);
const [completedListState, setCompletedListState] = useState(() => ({
limit: COMPLETED_TASK_LIST_LIMIT,
projectId: normalizedProjectId,
@@ -353,54 +358,123 @@ export function ImageCanvasTaskSidebarView({
useEffect(() => {
let disposed = false;
const controller = new AbortController();
Promise.all([
listExternalGenerationTasks({
let controller: AbortController | null = null;
let retryTimerId: number | null = null;
let retryIndex = 0;
let hasLoadedActiveTasks = false;
let hasLoadedCompletedTasks = false;
const loadTaskLists = () => {
const attemptController = new AbortController();
controller = attemptController;
const activeTasksRequest = listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: controller.signal,
}),
listExternalGenerationTasks({
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
}),
])
.then(([activeResponse, completedResponse]) => {
if (disposed) {
signal: attemptController.signal,
}).then((activeResponse) => {
if (disposed || attemptController.signal.aborted) {
return;
}
const visibleActiveTasks = filterVisibleExternalTasks(
activeResponse.tasks,
);
const visibleCompletedTasks = filterVisibleExternalTasks(
completedResponse.tasks,
);
notifyCompletedExternalTasks(visibleCompletedTasks, {
unacknowledgedOnly: true,
});
setExternalTasks(
hasLoadedActiveTasks = true;
const activeTasks = activeResponse.tasks.filter(isActiveExternalTask);
const visibleActiveTasks = filterVisibleExternalTasks(activeTasks);
setWalletActiveExternalTaskIds(activeTasks.map((task) => task.jobId));
if (activeTasks.length > 0) {
onExternalTaskWalletMayHaveChanged?.();
}
setExternalTasks((currentTasks) =>
trimStoredExternalTasks(
mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks),
mergeExternalTasks(
visibleActiveTasks,
currentTasks.filter(isTerminalExternalTask),
),
completedListLimit,
),
);
})
.catch(() => {
if (!disposed) {
setExternalTasks([]);
}
});
const completedTasksRequest = listExternalGenerationTasks({
limit: completedListLimit,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: attemptController.signal,
}).then((completedResponse) => {
if (disposed || attemptController.signal.aborted) {
return;
}
hasLoadedCompletedTasks = true;
const visibleCompletedTasks = filterVisibleExternalTasks(
completedResponse.tasks,
);
if (
completedResponse.tasks.some(
(task) =>
isTerminalExternalTask(task) && !task.notificationAcknowledgedAt,
)
) {
onExternalTaskWalletMayHaveChanged?.();
}
notifyCompletedExternalTasks(visibleCompletedTasks, {
unacknowledgedOnly: true,
});
setExternalTasks((currentTasks) =>
trimStoredExternalTasks(
mergeExternalTasks(
currentTasks.filter(isActiveExternalTask),
visibleCompletedTasks,
),
completedListLimit,
),
);
});
Promise.all([activeTasksRequest, completedTasksRequest])
.catch(() => {
attemptController.abort();
if (controller === attemptController) {
controller = null;
}
if (disposed) {
return;
}
if (retryIndex >= TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS.length) {
setExternalTasks((currentTasks) =>
trimStoredExternalTasks(
currentTasks.filter(
(task) =>
(hasLoadedActiveTasks && isActiveExternalTask(task)) ||
(hasLoadedCompletedTasks && isTerminalExternalTask(task)),
),
completedListLimit,
),
);
if (!hasLoadedActiveTasks) {
setWalletActiveExternalTaskIds([]);
}
return;
}
const retryDelayMs = TASK_LIST_BOOTSTRAP_RETRY_DELAYS_MS[retryIndex];
retryIndex += 1;
retryTimerId = window.setTimeout(loadTaskLists, retryDelayMs);
})
.finally(() => {
if (controller === attemptController) {
controller = null;
}
});
};
loadTaskLists();
return () => {
disposed = true;
controller.abort();
controller?.abort();
if (retryTimerId !== null) {
window.clearTimeout(retryTimerId);
}
};
}, [
completedListLimit,
filterVisibleExternalTasks,
notifyCompletedExternalTasks,
onExternalTaskWalletMayHaveChanged,
refreshKey,
]);
@@ -412,13 +486,14 @@ export function ImageCanvasTaskSidebarView({
[externalTasks],
);
const activeExternalTaskKey = activeExternalTaskIds.join('|');
const walletActiveExternalTaskKey = walletActiveExternalTaskIds.join('|');
useEffect(() => {
activeExternalTaskIdsRef.current = new Set(activeExternalTaskIds);
}, [activeExternalTaskKey, activeExternalTaskIds]);
useEffect(() => {
if (!activeExternalTaskIds.length) {
if (!walletActiveExternalTaskKey) {
return undefined;
}
let disposed = false;
@@ -448,6 +523,15 @@ export function ImageCanvasTaskSidebarView({
filterVisibleExternalTasks(activeResponse.tasks),
filterVisibleExternalTasks(completedResponse.tasks),
);
setWalletActiveExternalTaskIds(
activeResponse.tasks
.filter(isActiveExternalTask)
.map((task) => task.jobId),
);
// external job 在 worker 领取后才预扣泥点,running 可能先于扣费落账。
// 只要仍有全局 active task,每轮成功轮询都推动钱包收敛;终态这一轮
// 同时覆盖成功结算或失败退款。共享 Store 会合并并发刷新。
onExternalTaskWalletMayHaveChanged?.();
notifyCompletedExternalTasks(refreshedTasks, {
activeTaskIds: activeExternalTaskIdsRef.current,
});
@@ -483,11 +567,11 @@ export function ImageCanvasTaskSidebarView({
}
};
}, [
activeExternalTaskIds,
activeExternalTaskKey,
completedListLimit,
filterVisibleExternalTasks,
notifyCompletedExternalTasks,
onExternalTaskWalletMayHaveChanged,
walletActiveExternalTaskKey,
]);
const handleTaskListScroll = useCallback(
@@ -48,6 +48,9 @@ describe('PlatformActiveProfileView', () => {
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
legacyWalletBalance={null}
mudPointBalance={null}
user={null}
/>,
);
@@ -68,6 +71,17 @@ describe('PlatformActiveProfileView', () => {
updatedAt: '2026-07-18T00:00:00.000Z',
}}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
legacyWalletBalance={null}
mudPointBalance={{
totalPoints: 108,
permanentPoints: 88,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
}}
user={authenticatedUser}
/>,
);
@@ -101,6 +115,9 @@ describe('PlatformActiveProfileView', () => {
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
legacyWalletBalance={null}
mudPointBalance={null}
user={authenticatedUser}
/>,
);
@@ -133,6 +150,9 @@ describe('PlatformActiveProfileView', () => {
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
legacyWalletBalance={null}
mudPointBalance={null}
user={authenticatedUser}
/>,
);
@@ -195,6 +215,9 @@ describe('PlatformActiveProfileView', () => {
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
isLoadingWalletBalance={false}
legacyWalletBalance={null}
mudPointBalance={null}
user={authenticatedUser}
/>,
);
@@ -11,6 +11,12 @@ import {
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import type {
AuthUser,
ProfileDashboardSummary,
ProfileMudPointBalance,
} from '@/packages/shared/src';
import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
@@ -20,8 +26,6 @@ import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
import profileMascotImage from '../../../media/profile/_Image (9).png';
import profilePointImage from '../../../media/profile/_Image.png';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import { updateAuthProfile } from '../../services/authService';
import {
canUseNativeHostCapability,
@@ -58,6 +62,9 @@ import {
type PlatformActiveProfileViewProps = {
dashboard: ProfileDashboardSummary | null;
isLoadingDashboard: boolean;
isLoadingWalletBalance: boolean;
legacyWalletBalance: number | null;
mudPointBalance: ProfileMudPointBalance | null;
onLogin: () => void;
onOpenApiKeys: () => void;
onOpenCommunity: () => void;
@@ -237,6 +244,23 @@ function formatDashboardCount(value: number) {
return Math.max(0, Math.round(value)).toLocaleString('zh-CN');
}
function formatWalletBalance(
balance: ProfileMudPointBalance | null,
legacyBalance: number | null,
isLoading: boolean,
) {
if (balance) {
return formatDashboardCount(balance.totalPoints);
}
if (legacyBalance !== null) {
return formatDashboardCount(legacyBalance);
}
if (isLoading) {
return '读取中';
}
return '暂不可用';
}
function formatTotalPlayTime(value: number) {
const hours = Math.max(0, Math.round(value / 360000) / 10);
return `${hours.toLocaleString('zh-CN', {
@@ -247,6 +271,9 @@ function formatTotalPlayTime(value: number) {
export function PlatformActiveProfileView({
dashboard,
isLoadingDashboard,
isLoadingWalletBalance,
legacyWalletBalance,
mudPointBalance,
onLogin,
onOpenApiKeys,
onOpenCommunity,
@@ -539,11 +566,11 @@ export function PlatformActiveProfileView({
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value={
dashboard
? formatDashboardCount(dashboard.walletBalance)
: '暂不可用'
}
value={formatWalletBalance(
mudPointBalance,
legacyWalletBalance,
isLoadingWalletBalance,
)}
icon={Coins}
imageSrc={profilePointImage}
onClick={onOpenWalletLedger}
@@ -1,15 +1,27 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { useState } from 'react';
import {
act,
fireEvent,
render as testingLibraryRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { type ReactElement, type ReactNode, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import {
usePlatformWalletLifecycle,
usePlatformWalletStore,
} from '../../stores/usePlatformWalletStore';
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
import type { SelectionStage } from './platformEntryActiveTypes';
const authUiMock = vi.hoisted(() => ({
value: {
user: null,
user: null as AuthUser | null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
openAccountModal: vi.fn(),
@@ -21,6 +33,16 @@ const responsiveMock = vi.hoisted(() => ({
isDesktopLayout: true,
}));
const profileClientMock = vi.hoisted(() => ({
getPlatformProfileDashboard: vi.fn(),
getPlatformProfileRechargeCenter: vi.fn(),
}));
const profileCenterMock = vi.hoisted(() => ({
isWalletLedgerOpen: false,
rechargeCenter: null as { walletBalance: number } | null,
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => authUiMock.value,
}));
@@ -80,12 +102,22 @@ vi.mock('../project/ProjectGalleryView', () => ({
}));
vi.mock('../image-editor/ImageCanvasEditorView', () => ({
ImageCanvasEditorView: () => <main aria-label="图片画布编辑器" />,
ImageCanvasEditorView: ({
legacyWalletBalance,
}: {
legacyWalletBalance?: number | null;
}) => (
<main
aria-label="图片画布编辑器"
data-legacy-wallet-balance={legacyWalletBalance ?? ''}
/>
),
}));
vi.mock('../../services/platform-entry/platformProfileClient', () => ({
getPlatformProfileDashboard: vi.fn(),
}));
vi.mock(
'../../services/platform-entry/platformProfileClient',
() => profileClientMock,
);
vi.mock('./usePlatformProfileCenterController', () => ({
usePlatformProfileCenterController: () => ({
@@ -95,11 +127,11 @@ vi.mock('./usePlatformProfileCenterController', () => ({
isLoadingRechargeCenter: false,
isLoadingWalletLedger: false,
isRechargeOpen: false,
isWalletLedgerOpen: false,
isWalletLedgerOpen: profileCenterMock.isWalletLedgerOpen,
loadRechargeCenter: vi.fn(),
nativeWechatPayment: null,
openWalletLedgerPanel: vi.fn(),
rechargeCenter: null,
rechargeCenter: profileCenterMock.rechargeCenter,
rechargeError: null,
rechargePaymentResult: null,
setIsRechargeOpen: vi.fn(),
@@ -112,6 +144,24 @@ vi.mock('./usePlatformProfileCenterController', () => ({
}),
}));
function AuthWalletLifecycleTestBoundary({
children,
}: {
children: ReactNode;
}) {
usePlatformWalletLifecycle(
authUiMock.value.user?.id ?? null,
authUiMock.value.canAccessProtectedData,
);
return children;
}
function render(ui: ReactElement) {
return testingLibraryRender(ui, {
wrapper: AuthWalletLifecycleTestBoundary,
});
}
function StatefulPlatformEntryFlowShell({
initialStage,
}: {
@@ -130,10 +180,225 @@ function StatefulPlatformEntryFlowShell({
describe('PlatformEntryActiveFlowShell', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/creation');
usePlatformWalletStore.getState().resetWalletBalance();
profileClientMock.getPlatformProfileDashboard.mockReset();
profileClientMock.getPlatformProfileRechargeCenter.mockReset();
profileCenterMock.isWalletLedgerOpen = false;
profileCenterMock.rechargeCenter = null;
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
authUiMock.value.openLoginModal.mockReset();
responsiveMock.isDesktopLayout = true;
});
it('uses one recharge-center snapshot for the topbar and profile wallet when dashboard differs', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
profileClientMock.getPlatformProfileDashboard.mockResolvedValue({
walletBalance: 999,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: null,
});
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
walletBalance: 888,
mudPointBalance: {
totalPoints: 207,
permanentPoints: 180,
limitedPoints: 7,
limitedExpiresAt: '2026-08-31T16:00:00Z',
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
},
});
const { rerender } = render(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={vi.fn()}
/>,
);
expect(await screen.findByLabelText('泥点 207')).toBeTruthy();
rerender(
<PlatformEntryFlowShellImpl
selectionStage="profile"
setSelectionStage={vi.fn()}
/>,
);
expect(
await screen.findByRole('button', { name: '泥点余额 207' }),
).toBeTruthy();
expect(screen.queryByText('999')).toBeNull();
});
it('passes the lifecycle legacy total to profile and editor without opening recharge', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
walletBalance: 37,
});
profileCenterMock.isWalletLedgerOpen = true;
const { rerender } = render(
<PlatformEntryFlowShellImpl
selectionStage="profile"
setSelectionStage={vi.fn()}
/>,
);
expect(
await screen.findByRole('button', { name: '泥点余额 37' }),
).toBeTruthy();
expect(await screen.findByText('37泥点')).toBeTruthy();
expect(screen.getByText('暂无账单记录')).toBeTruthy();
rerender(
<PlatformEntryFlowShellImpl
selectionStage="image-editor"
setSelectionStage={vi.fn()}
/>,
);
const imageEditor = await screen.findByRole('main', {
name: '图片画布编辑器',
});
expect(imageEditor.getAttribute('data-legacy-wallet-balance')).toBe('37');
});
it('clears the previous wallet synchronously when the authenticated account changes', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '用户一',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
mudPointBalance: {
totalPoints: 66,
permanentPoints: 66,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
},
});
const { rerender } = render(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={vi.fn()}
/>,
);
await screen.findByLabelText('泥点 66');
let resolveNextOwner!: (value: unknown) => void;
profileClientMock.getPlatformProfileRechargeCenter.mockImplementation(
() =>
new Promise((resolve) => {
resolveNextOwner = resolve;
}),
);
authUiMock.value.user = { ...authUiMock.value.user, id: 'user-2' };
rerender(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={vi.fn()}
/>,
);
expect(screen.queryByLabelText('泥点 66')).toBeNull();
await waitFor(() => {
expect(usePlatformWalletStore.getState()).toMatchObject({
ownerUserId: 'user-2',
mudPointBalance: null,
});
});
resolveNextOwner({
mudPointBalance: {
totalPoints: 67,
permanentPoints: 67,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
},
});
await screen.findByLabelText('泥点 67');
});
it('coalesces visibility and focus refreshes when the page returns to the foreground', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
mudPointBalance: {
totalPoints: 10,
permanentPoints: 10,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
},
});
render(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={vi.fn()}
/>,
);
await screen.findByLabelText('泥点 10');
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('focus'));
});
await waitFor(() => {
expect(
profileClientMock.getPlatformProfileRechargeCenter,
).toHaveBeenCalledTimes(2);
});
});
it('keeps the active desktop rail and the shared account capsule', async () => {
const setSelectionStage = vi.fn();
const { container, rerender } = render(
@@ -241,13 +506,9 @@ describe('PlatformEntryActiveFlowShell', () => {
expect(
await screen.findByRole('main', { name: '桌面端创作提示' }),
).toBeTruthy();
expect(
screen.queryByRole('main', { name: '陶泥儿创作主页' }),
).toBeNull();
expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull();
expect(screen.queryByRole('main', { name: '项目' })).toBeNull();
expect(
screen.queryByRole('main', { name: '图片画布编辑器' }),
).toBeNull();
expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull();
},
);
@@ -263,9 +524,7 @@ describe('PlatformEntryActiveFlowShell', () => {
expect(
await screen.findByRole('main', { name: '桌面端创作提示' }),
).toBeTruthy();
expect(
screen.queryByRole('main', { name: '图片画布编辑器' }),
).toBeNull();
expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull();
});
it('switches between creation and projects and passes search to the active page', async () => {
@@ -25,6 +25,7 @@ import {
replaceAppHistoryPath,
} from '../../routing/activeAppPageRoutes';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import { useAuthUi } from '../auth/AuthUiContext';
import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel';
import { PlatformActionButton } from '../common/PlatformActionButton';
@@ -224,6 +225,37 @@ export function PlatformEntryFlowShellImpl({
const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] =
useState(false);
const isDesktopLayout = usePlatformDesktopLayout();
const currentWalletOwnerUserId =
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
const walletOwnerUserId = usePlatformWalletStore(
(state) => state.ownerUserId,
);
const storedMudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const storedLegacyWalletBalance = usePlatformWalletStore(
(state) => state.legacyWalletBalance,
);
const storedMudPointBalanceStatus = usePlatformWalletStore(
(state) => state.mudPointBalanceStatus,
);
const storedMudPointBalanceError = usePlatformWalletStore(
(state) => state.mudPointBalanceError,
);
const walletOwnerMatchesCurrentUser =
Boolean(currentWalletOwnerUserId) &&
walletOwnerUserId === currentWalletOwnerUserId;
const mudPointBalance = walletOwnerMatchesCurrentUser
? storedMudPointBalance
: null;
const mudPointBalanceError = walletOwnerMatchesCurrentUser
? storedMudPointBalanceError
: '';
const isWalletBalanceLoading =
Boolean(currentWalletOwnerUserId) &&
(!walletOwnerMatchesCurrentUser ||
storedMudPointBalanceStatus === 'idle' ||
storedMudPointBalanceStatus === 'loading');
const refreshDashboard = useCallback(async () => {
if (!authUi?.user || !authUi.canAccessProtectedData) {
@@ -250,10 +282,14 @@ export function PlatformEntryFlowShellImpl({
activeTab: isProfileStage ? 'profile' : 'project',
isAuthenticated: Boolean(authUi?.user),
showRechargeEntry: true,
onRechargeSuccess: refreshDashboard,
requestLogin: () => authUi?.openLoginModal(),
currentUser: authUi?.user,
});
const legacyWalletBalance = walletOwnerMatchesCurrentUser
? (storedLegacyWalletBalance ??
profileCenter.rechargeCenter?.walletBalance ??
null)
: null;
const openCreation = useCallback(() => {
if (!isDesktopLayout) {
@@ -329,6 +365,7 @@ export function PlatformEntryFlowShellImpl({
<div className="image-editor-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<Suspense fallback={<LoadingPanel label="正在加载编辑器..." />}>
<ImageCanvasEditorView
legacyWalletBalance={legacyWalletBalance}
onProjectAccessLost={replaceWithProjectGallery}
/>
</Suspense>
@@ -337,11 +374,7 @@ export function PlatformEntryFlowShellImpl({
}
const isAuthenticated = Boolean(authUi?.user);
const balance =
dashboard?.walletBalance ??
profileCenter.rechargeCenter?.mudPointBalance?.totalPoints ??
profileCenter.rechargeCenter?.walletBalance ??
null;
const balance = mudPointBalance?.totalPoints ?? legacyWalletBalance;
const isCreationStage =
!isProfileStage &&
(selectionStage === 'platform' || selectionStage === 'creation-home');
@@ -455,14 +488,9 @@ export function PlatformEntryFlowShellImpl({
<PlatformMudPointWalletEntry
variant={isDesktopLayout ? 'desktop' : 'mobile'}
balance={balance}
breakdown={
profileCenter.rechargeCenter?.mudPointBalance ?? null
}
isLoading={
isLoadingDashboard ||
profileCenter.isLoadingRechargeCenter
}
error={profileCenter.rechargeError}
breakdown={mudPointBalance}
isLoading={isWalletBalanceLoading}
error={mudPointBalanceError || null}
className={
isDesktopLayout
? 'platform-desktop-create-wallet-chip'
@@ -515,6 +543,9 @@ export function PlatformEntryFlowShellImpl({
<PlatformActiveProfileView
dashboard={dashboard}
isLoadingDashboard={isLoadingDashboard}
isLoadingWalletBalance={isWalletBalanceLoading}
legacyWalletBalance={legacyWalletBalance}
mudPointBalance={mudPointBalance}
user={authUi?.user}
onLogin={() => authUi?.openLoginModal()}
onOpenApiKeys={() => setIsApiKeysOpen(true)}
@@ -0,0 +1,370 @@
/* @vitest-environment jsdom */
import { act, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ProfileRechargeProduct } from '../../../packages/shared/src/contracts/runtime';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import {
apiClientMocks,
hostBridgeMocks,
paymentPlatformMocks,
profileClientMocks,
renderController,
userA,
userB,
} from './usePlatformProfileCenterController.testSupport';
const rechargeProduct = {
productId: 'points-60',
kind: 'points',
} as ProfileRechargeProduct;
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function mudPointBalance(totalPoints: number) {
return {
totalPoints,
permanentPoints: totalPoints,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-08-07T00:00:00+08:00',
};
}
function nativeRechargeResponse(totalPoints = 80, orderId = 'order-a') {
return {
order: {
orderId,
productTitle: '60 泥点',
amountCents: 600,
status: 'pending',
expirationCheckedAt: null,
},
center: {
walletBalance: totalPoints,
mudPointBalance: mudPointBalance(totalPoints),
},
wechatNativePayment: {
codeUrl: `weixin://wxpay/${orderId}`,
expiresAt: '2099-08-07T00:00:00+08:00',
},
};
}
describe('usePlatformProfileCenterController recharge fallback', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/profile');
usePlatformWalletStore.getState().resetWalletBalance();
vi.clearAllMocks();
paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue(
'wechat_native',
);
hostBridgeMocks.getHostRuntime.mockReturnValue({ kind: 'browser' });
profileClientMocks.watchWechatPlatformProfileRechargeOrder.mockReturnValue(
new Promise(() => undefined),
);
});
test('keeps the response legacy balance when no shared snapshot exists', async () => {
usePlatformWalletStore.getState().setWalletOwner('user-a');
profileClientMocks.getPlatformProfileRechargeCenter.mockResolvedValue({
walletBalance: 37,
membership: null,
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
});
const { result } = renderController(userA);
act(() => result.current.loadRechargeCenter());
await waitFor(() => {
expect(result.current.rechargeCenter?.walletBalance).toBe(37);
});
});
test('synchronously rejects a duplicate recharge submission before React commits state', async () => {
const pendingOrder = deferred<ReturnType<typeof nativeRechargeResponse>>();
profileClientMocks.createPlatformProfileRechargeOrder.mockReturnValue(
pendingOrder.promise,
);
usePlatformWalletStore.getState().setWalletOwner('user-a');
const { result } = renderController(userA);
act(() => {
result.current.buyRechargeProduct(rechargeProduct);
result.current.buyRechargeProduct(rechargeProduct);
});
expect(
profileClientMocks.createPlatformProfileRechargeOrder,
).toHaveBeenCalledTimes(1);
await act(async () => {
pendingOrder.resolve(nativeRechargeResponse());
await pendingOrder.promise;
});
});
test('does not let a late payment confirmation overwrite a newer wallet operation', async () => {
const pendingConfirmation = deferred<{
order: ReturnType<typeof nativeRechargeResponse>['order'];
center: ReturnType<typeof nativeRechargeResponse>['center'];
}>();
profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue(
nativeRechargeResponse(80),
);
profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockReturnValue(
pendingConfirmation.promise,
);
profileClientMocks.getPlatformProfileRechargeCenter.mockRejectedValue(
new Error('refresh unavailable'),
);
usePlatformWalletStore.getState().setWalletOwner('user-a');
const { result } = renderController(userA);
act(() => result.current.buyRechargeProduct(rechargeProduct));
await waitFor(() => {
expect(result.current.nativeWechatPayment?.orderId).toBe('order-a');
});
act(() => result.current.confirmNativeWechatPayment());
act(() => {
const newerSnapshot = usePlatformWalletStore
.getState()
.captureWalletBalanceSnapshot('user-a');
usePlatformWalletStore
.getState()
.applyWalletBalanceSnapshot(newerSnapshot!, mudPointBalance(150));
});
await act(async () => {
pendingConfirmation.resolve({
order: {
...nativeRechargeResponse().order,
status: 'paid',
},
center: nativeRechargeResponse(100).center,
});
await pendingConfirmation.promise;
await Promise.resolve();
});
expect(usePlatformWalletStore.getState().mudPointBalance?.totalPoints).toBe(
150,
);
});
test('ignores an old owner order response after the account changes', async () => {
const pendingOrder = deferred<ReturnType<typeof nativeRechargeResponse>>();
profileClientMocks.createPlatformProfileRechargeOrder
.mockReturnValueOnce(pendingOrder.promise)
.mockResolvedValueOnce(nativeRechargeResponse(90, 'order-b'));
usePlatformWalletStore.getState().setWalletOwner('user-a');
const { result, rerender } = renderController(userA);
act(() => result.current.buyRechargeProduct(rechargeProduct));
const oldAccountSignal = profileClientMocks
.createPlatformProfileRechargeOrder.mock.calls[0]?.[2]
?.signal as AbortSignal;
expect(oldAccountSignal.aborted).toBe(false);
expect(result.current.submittingRechargeProductId).toBe('points-60');
act(() => {
rerender({ user: userB });
usePlatformWalletStore.getState().setWalletOwner('user-b');
});
expect(oldAccountSignal.aborted).toBe(true);
act(() => result.current.buyRechargeProduct(rechargeProduct));
await waitFor(() => {
expect(result.current.nativeWechatPayment?.orderId).toBe('order-b');
});
await act(async () => {
pendingOrder.resolve(nativeRechargeResponse());
await pendingOrder.promise;
await Promise.resolve();
});
expect(result.current.submittingRechargeProductId).toBeNull();
expect(result.current.nativeWechatPayment?.orderId).toBe('order-b');
expect(result.current.rechargePaymentResult).toBeNull();
expect(result.current.rechargeCenter?.walletBalance).toBe(90);
expect(usePlatformWalletStore.getState()).toMatchObject({
ownerUserId: 'user-b',
mudPointBalance: mudPointBalance(90),
});
});
test('aborts an in-flight recharge write when the controller unmounts', () => {
profileClientMocks.createPlatformProfileRechargeOrder.mockReturnValue(
new Promise(() => undefined),
);
usePlatformWalletStore.getState().setWalletOwner('user-a');
const { result, unmount } = renderController(userA);
act(() => result.current.buyRechargeProduct(rechargeProduct));
const signal = profileClientMocks.createPlatformProfileRechargeOrder.mock
.calls[0]?.[2]?.signal as AbortSignal;
expect(signal.aborted).toBe(false);
unmount();
expect(signal.aborted).toBe(true);
});
test('ignores an old owner native confirmation after the account changes', async () => {
const pendingConfirmation = deferred<{
order: ReturnType<typeof nativeRechargeResponse>['order'];
center: ReturnType<typeof nativeRechargeResponse>['center'];
}>();
const onRechargeSuccess = vi.fn();
profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue(
nativeRechargeResponse(),
);
profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockReturnValue(
pendingConfirmation.promise,
);
usePlatformWalletStore.getState().setWalletOwner('user-a');
const { result, rerender } = renderController(userA, onRechargeSuccess);
act(() => result.current.buyRechargeProduct(rechargeProduct));
await waitFor(() => {
expect(result.current.nativeWechatPayment?.orderId).toBe('order-a');
});
act(() => result.current.confirmNativeWechatPayment());
act(() => {
rerender({ user: userB });
usePlatformWalletStore.getState().setWalletOwner('user-b');
});
await act(async () => {
pendingConfirmation.resolve({
order: {
...nativeRechargeResponse().order,
status: 'paid',
},
center: nativeRechargeResponse(100).center,
});
await pendingConfirmation.promise;
await Promise.resolve();
});
expect(result.current.nativeWechatPayment).toBeNull();
expect(result.current.rechargePaymentResult).toBeNull();
expect(result.current.wechatRechargeOrderConfirmationState).toBeNull();
expect(onRechargeSuccess).not.toHaveBeenCalled();
expect(usePlatformWalletStore.getState()).toMatchObject({
ownerUserId: 'user-b',
mudPointBalance: null,
});
});
test('aborts delayed confirmation retries when the account changes', async () => {
vi.useFakeTimers();
try {
profileClientMocks.createPlatformProfileRechargeOrder.mockResolvedValue(
nativeRechargeResponse(),
);
profileClientMocks.confirmWechatPlatformProfileRechargeOrder.mockResolvedValue(
{
order: nativeRechargeResponse().order,
center: nativeRechargeResponse().center,
},
);
usePlatformWalletStore.getState().setWalletOwner('user-a');
const { result, rerender } = renderController(userA);
await act(async () => {
result.current.buyRechargeProduct(rechargeProduct);
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.nativeWechatPayment?.orderId).toBe('order-a');
await act(async () => {
result.current.confirmNativeWechatPayment();
await Promise.resolve();
await Promise.resolve();
});
expect(
profileClientMocks.confirmWechatPlatformProfileRechargeOrder,
).toHaveBeenCalledTimes(1);
const confirmationSignal = profileClientMocks
.confirmWechatPlatformProfileRechargeOrder.mock.calls[0]?.[1]
?.signal as AbortSignal;
expect(confirmationSignal.aborted).toBe(false);
expect(
profileClientMocks.watchWechatPlatformProfileRechargeOrder,
).toHaveBeenCalledWith('order-a', { signal: confirmationSignal });
act(() => {
rerender({ user: userB });
usePlatformWalletStore.getState().setWalletOwner('user-b');
});
expect(confirmationSignal.aborted).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
expect(
profileClientMocks.confirmWechatPlatformProfileRechargeOrder,
).toHaveBeenCalledTimes(1);
expect(apiClientMocks.clearStoredAccessToken).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
test('does not clear the new owner token from an old owner payment error', async () => {
const pendingOrder = deferred<never>();
paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue(
'wechat_mp_virtual',
);
hostBridgeMocks.getHostRuntime.mockReturnValue({
kind: 'wechat_mini_program',
});
profileClientMocks.createPlatformProfileRechargeOrder
.mockReturnValueOnce(pendingOrder.promise)
.mockResolvedValueOnce(nativeRechargeResponse(70, 'order-b'));
usePlatformWalletStore.getState().setWalletOwner('user-a');
const { result, rerender } = renderController(userA);
act(() => result.current.buyRechargeProduct(rechargeProduct));
act(() => {
rerender({ user: userB });
usePlatformWalletStore.getState().setWalletOwner('user-b');
});
paymentPlatformMocks.resolveProfileRechargeProductPaymentChannel.mockReturnValue(
'wechat_native',
);
act(() => result.current.buyRechargeProduct(rechargeProduct));
await waitFor(() => {
expect(result.current.nativeWechatPayment?.orderId).toBe('order-b');
});
await act(async () => {
pendingOrder.reject(new Error('当前登录设备不支持充值'));
await pendingOrder.promise.catch(() => undefined);
await Promise.resolve();
});
expect(apiClientMocks.clearStoredAccessToken).not.toHaveBeenCalled();
expect(hostBridgeMocks.requestHostLogin).not.toHaveBeenCalled();
expect(result.current.rechargeError).toBeNull();
expect(result.current.rechargePaymentResult).toBeNull();
expect(result.current.nativeWechatPayment?.orderId).toBe('order-b');
expect(usePlatformWalletStore.getState()).toMatchObject({
ownerUserId: 'user-b',
mudPointBalance: mudPointBalance(70),
});
});
});
@@ -0,0 +1,128 @@
/* @vitest-environment jsdom */
import { act } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import {
profileClientMocks,
renderController,
userA,
userB,
} from './usePlatformProfileCenterController.testSupport';
describe('usePlatformProfileCenterController redemption lifecycle', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/profile');
usePlatformWalletStore.getState().resetWalletBalance();
vi.clearAllMocks();
});
test('ignores a reward-code response after the account changes', async () => {
let resolveReward!: (value: {
walletBalance: number;
amountGranted: number;
ledgerEntry: never;
}) => void;
profileClientMocks.redeemPlatformProfileRewardCode.mockImplementation(
() =>
new Promise((resolve) => {
resolveReward = resolve;
}),
);
const onRechargeSuccess = vi.fn();
const { result, rerender } = renderController(userA, onRechargeSuccess);
act(() => result.current.setRewardCodeInput('reward-code'));
act(() => result.current.submitRewardCode());
const oldAccountSignal = profileClientMocks.redeemPlatformProfileRewardCode
.mock.calls[0]?.[1]?.signal as AbortSignal;
expect(oldAccountSignal.aborted).toBe(false);
rerender({ user: userB });
expect(oldAccountSignal.aborted).toBe(true);
await act(async () => {
resolveReward({
walletBalance: 100,
amountGranted: 20,
ledgerEntry: undefined as never,
});
});
expect(result.current.rewardCodeSuccess).toBeNull();
expect(result.current.isSubmittingRewardCode).toBe(false);
expect(onRechargeSuccess).not.toHaveBeenCalled();
});
test('ignores an invite-code response after the account changes', async () => {
let resolveReferral!: (value: {
center: never;
inviteeRewardGranted: boolean;
inviterRewardGranted: boolean;
inviteeBalanceAfter: number;
inviterBalanceAfter: number;
}) => void;
profileClientMocks.redeemPlatformProfileReferralInviteCode.mockImplementation(
() =>
new Promise((resolve) => {
resolveReferral = resolve;
}),
);
const onRechargeSuccess = vi.fn();
const { result, rerender } = renderController(userA, onRechargeSuccess);
act(() => result.current.setReferralRedeemCode('invite-code'));
act(() => result.current.submitReferralRedeemCode());
const oldAccountSignal = profileClientMocks
.redeemPlatformProfileReferralInviteCode.mock.calls[0]?.[1]
?.signal as AbortSignal;
expect(oldAccountSignal.aborted).toBe(false);
rerender({ user: userB });
expect(oldAccountSignal.aborted).toBe(true);
await act(async () => {
resolveReferral({
center: undefined as never,
inviteeRewardGranted: true,
inviterRewardGranted: false,
inviteeBalanceAfter: 100,
inviterBalanceAfter: 0,
});
});
expect(result.current.referralSuccess).toBeNull();
expect(result.current.isSubmittingReferralRedeem).toBe(false);
expect(onRechargeSuccess).not.toHaveBeenCalled();
});
test('clears referral UI and ignores a late referral-center read after account change', async () => {
let resolveReferralCenter!: (value: never) => void;
profileClientMocks.getPlatformProfileReferralInviteCenter.mockImplementation(
() =>
new Promise((resolve) => {
resolveReferralCenter = resolve;
}),
);
const { result, rerender } = renderController(userA);
act(() => {
result.current.openProfilePopupPanel('redeem');
result.current.setReferralRedeemCode('USER-A-CODE');
result.current.loadReferralCenter();
});
expect(result.current.profilePopupPanel).toBe('redeem');
expect(result.current.isLoadingReferral).toBe(true);
rerender({ user: userB });
expect(result.current.profilePopupPanel).toBeNull();
expect(result.current.referralRedeemCode).toBe('');
expect(result.current.referralCenter).toBeNull();
expect(result.current.isLoadingReferral).toBe(false);
await act(async () => {
resolveReferralCenter({
inviteCode: 'USER-A',
inviteLinkPath: '/?inviteCode=USER-A',
} as never);
});
expect(result.current.referralCenter).toBeNull();
expect(result.current.isLoadingReferral).toBe(false);
});
});
@@ -0,0 +1,102 @@
/* @vitest-environment jsdom */
import { renderHook } from '@testing-library/react';
import { vi } from 'vitest';
import type { AuthUser } from '../../services/authService';
const profileClientMocks = vi.hoisted(() => ({
confirmWechatPlatformProfileRechargeOrder: vi.fn(),
createPlatformProfileRechargeOrder: vi.fn(),
getPlatformProfileRechargeCenter: vi.fn(),
getPlatformProfileReferralInviteCenter: vi.fn(),
getPlatformProfileWalletLedger: vi.fn(),
redeemPlatformProfileReferralInviteCode: vi.fn(),
redeemPlatformProfileRewardCode: vi.fn(),
watchWechatPlatformProfileRechargeOrder: vi.fn(),
}));
const apiClientMocks = vi.hoisted(() => ({
clearStoredAccessToken: vi.fn(),
}));
const hostBridgeMocks = vi.hoisted(() => ({
getHostRuntime: vi.fn(() => ({ kind: 'browser' })),
requestHostLogin: vi.fn(),
requestHostPayment: vi.fn(),
}));
const paymentPlatformMocks = vi.hoisted(() => ({
resolveProfileRechargeProductPaymentChannel: vi.fn(() => 'wechat_native'),
}));
vi.mock(
'../../services/platform-entry/platformProfileClient',
() => profileClientMocks,
);
vi.mock('../../services/apiClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/apiClient')
>('../../services/apiClient');
return { ...actual, ...apiClientMocks };
});
vi.mock('../../services/host-bridge/hostBridge', async () => {
const actual = await vi.importActual<
typeof import('../../services/host-bridge/hostBridge')
>('../../services/host-bridge/hostBridge');
return { ...actual, ...hostBridgeMocks };
});
vi.mock('../../services/payment/paymentPlatform', async () => {
const actual = await vi.importActual<
typeof import('../../services/payment/paymentPlatform')
>('../../services/payment/paymentPlatform');
return { ...actual, ...paymentPlatformMocks };
});
export {
apiClientMocks,
hostBridgeMocks,
paymentPlatformMocks,
profileClientMocks,
};
import { usePlatformProfileCenterController } from './usePlatformProfileCenterController';
const baseUser = {
publicUserCode: '100001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
} satisfies Omit<AuthUser, 'id'>;
export const userA = { ...baseUser, id: 'user-a' } satisfies AuthUser;
export const userB = {
...baseUser,
id: 'user-b',
publicUserCode: '100002',
} satisfies AuthUser;
export function renderController(
currentUser: AuthUser,
onRechargeSuccess = vi.fn(),
) {
const requestLogin = vi.fn();
return renderHook(
({ user }) =>
usePlatformProfileCenterController({
activeTab: 'settings',
isAuthenticated: true,
showRechargeEntry: true,
onRechargeSuccess,
requestLogin,
currentUser: user,
}),
{ initialProps: { user: currentUser } },
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
/* @vitest-environment jsdom */
import { act, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import {
profileClientMocks,
renderController,
userA,
} from './usePlatformProfileCenterController.testSupport';
describe('usePlatformProfileCenterController wallet ledger', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/profile');
usePlatformWalletStore.getState().resetWalletBalance();
vi.clearAllMocks();
});
test('settles a valid read without depending on wallet-store owner initialization', async () => {
profileClientMocks.getPlatformProfileWalletLedger.mockResolvedValue({
entries: [],
});
const { result } = renderController(userA);
act(() => result.current.loadWalletLedger());
await waitFor(() => {
expect(result.current.walletLedger).toEqual({ entries: [] });
expect(result.current.isLoadingWalletLedger).toBe(false);
});
});
});