Merge branch 'master' into extract-wallet-entry-to-shared
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
render as testingLibraryRender,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
|
||||
function render(ui: Parameters<typeof testingLibraryRender>[0]) {
|
||||
const result = testingLibraryRender(ui);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
||||
return result;
|
||||
}
|
||||
|
||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -39,6 +39,7 @@ const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -71,6 +72,10 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
|
||||
renderUiDesignAssetExtractionMarkedImageMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./ImageCanvasProjectCoverSnapshotModel')
|
||||
@@ -106,8 +111,14 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
window.localStorage.clear();
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
|
||||
'data:image/png;base64,marked-ui-design',
|
||||
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
|
||||
);
|
||||
uploadEditorMediaAssetFileMock.mockReset();
|
||||
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
assetObjectId: 'asset-object-marked-ui-design',
|
||||
});
|
||||
});
|
||||
|
||||
const openGenerationDimensionsPanel = (
|
||||
@@ -1344,6 +1355,13 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
clientY: 120,
|
||||
},
|
||||
);
|
||||
fireEvent.change(
|
||||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||||
'textbox',
|
||||
{ name: '素材描述' },
|
||||
),
|
||||
{ target: { value: '返回按钮' } },
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||||
'button',
|
||||
@@ -1351,7 +1369,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '生成图标素材' })).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '生成图标素材' }),
|
||||
).toBeNull();
|
||||
});
|
||||
const frame = screen.getByLabelText('图标素材生成占位图');
|
||||
expect(frame.className).toContain(
|
||||
'image-canvas-editor__generation-frame--generating',
|
||||
@@ -1743,6 +1765,13 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
screen.getByRole('dialog', { name: '生成图标素材' }),
|
||||
'图标规范 2',
|
||||
);
|
||||
fireEvent.change(
|
||||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||||
'textbox',
|
||||
{ name: '素材描述' },
|
||||
),
|
||||
{ target: { value: '返回按钮' } },
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||||
'button',
|
||||
@@ -1990,7 +2019,12 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
{ target: { value: '返回按钮\n设置按钮\n提示按钮' } },
|
||||
);
|
||||
|
||||
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(1);
|
||||
expect(
|
||||
within(iconPanel).getByRole('textbox', { name: '素材描述' }).tagName,
|
||||
).toBe('TEXTAREA');
|
||||
expect(
|
||||
within(iconPanel).getByRole('textbox', { name: '资源名称' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(iconPanel).queryByRole('button', { name: '添加素材描述' }),
|
||||
).toBeNull();
|
||||
@@ -2389,8 +2423,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
referenceImageSrcs: [
|
||||
'data:image/png;base64,cm9sZS1zcGVj',
|
||||
'data:image/png;base64,YmlnLWZpc2gtcmVm',
|
||||
'resource-character-spec',
|
||||
'resource-big-fish',
|
||||
],
|
||||
projectId: 'editor-project-default',
|
||||
assetFolderId: 'project',
|
||||
@@ -2759,6 +2793,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
await waitFor(() => {
|
||||
expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '添加清爽按钮素材图集' }),
|
||||
).toBeTruthy();
|
||||
@@ -3038,7 +3073,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
await waitFor(() => {
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
|
||||
referenceImageSrc: 'resource-editor-icon-spec-renamed-1',
|
||||
iconDescriptions: ['背包按钮'],
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
@@ -3258,20 +3293,22 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '生成120泥点' }));
|
||||
|
||||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceLayerId: 'layer-character',
|
||||
sourceImageSrc:
|
||||
'generated-character-drafts/editor/character-images/source/image.png',
|
||||
sourceWidth: 1024,
|
||||
sourceHeight: 1024,
|
||||
resolution: '720p',
|
||||
ratio: '16:9',
|
||||
frameCount: 48,
|
||||
durationSeconds: 6,
|
||||
model: 'seedance2.0-fast',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceLayerId: 'layer-character',
|
||||
sourceImageSrc:
|
||||
'generated-character-drafts/editor/character-images/source/image.png',
|
||||
sourceWidth: 1024,
|
||||
sourceHeight: 1024,
|
||||
resolution: '720p',
|
||||
ratio: '16:9',
|
||||
frameCount: 48,
|
||||
durationSeconds: 6,
|
||||
model: 'seedance2.0-fast',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText,
|
||||
).toBe(precisePrompt);
|
||||
@@ -3429,7 +3466,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
await waitFor(() => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||||
sourceImageSrc:
|
||||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
aspectRatio: '1:1',
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
clearStoredAccessToken,
|
||||
refreshStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import { type AuthUser,startWechatBind } from '../../services/authService';
|
||||
import { type AuthUser, startWechatBind } from '../../services/authService';
|
||||
import {
|
||||
getHostRuntime,
|
||||
requestHostLogin,
|
||||
@@ -52,6 +52,7 @@ const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000;
|
||||
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
||||
const WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS = [800, 1600] as const;
|
||||
const WECHAT_NATIVE_WATCH_RETRY_DELAY_MS = 1000;
|
||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
|
||||
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
|
||||
@@ -68,7 +69,11 @@ type WechatPayResult = {
|
||||
};
|
||||
|
||||
type RechargePaymentResultKind =
|
||||
'success' | 'pending' | 'cancel' | 'failed' | 'expired';
|
||||
| 'success'
|
||||
| 'pending'
|
||||
| 'cancel'
|
||||
| 'failed'
|
||||
| 'expired';
|
||||
|
||||
export type RechargePaymentResult = {
|
||||
kind: RechargePaymentResultKind;
|
||||
@@ -774,6 +779,12 @@ export function usePlatformProfileCenterController({
|
||||
);
|
||||
void confirmWechatRechargeOrderQuickly(nativeWechatPayment.orderId)
|
||||
.then((response) => {
|
||||
if (
|
||||
pendingWechatRechargeOrderIdRef.current !==
|
||||
nativeWechatPayment.orderId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
const isPaid = result.kind === 'success';
|
||||
setRechargeCenter(response.center);
|
||||
@@ -810,6 +821,70 @@ export function usePlatformProfileCenterController({
|
||||
.finally(() => setSubmittingRechargeProductId(null));
|
||||
}, [nativeWechatPayment, onRechargeSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
const orderId = nativeWechatPayment?.orderId;
|
||||
const expiresAtMs = Date.parse(nativeWechatPayment?.expiresAt ?? '');
|
||||
if (!orderId || !Number.isFinite(expiresAtMs)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const abortController = new AbortController();
|
||||
const watchUntilSettled = async () => {
|
||||
while (!cancelled && Date.now() < expiresAtMs) {
|
||||
try {
|
||||
const response = await watchWechatRpgProfileRechargeOrder(orderId, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (
|
||||
cancelled ||
|
||||
!response ||
|
||||
pendingWechatRechargeOrderIdRef.current !== orderId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
setRechargeCenter(response.center);
|
||||
if (result.kind === 'pending') {
|
||||
await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
if (confirmingWechatRechargeOrderIdRef.current === orderId) {
|
||||
confirmingWechatRechargeOrderIdRef.current = null;
|
||||
}
|
||||
setNativeWechatPayment((current) =>
|
||||
current?.orderId === orderId ? null : current,
|
||||
);
|
||||
setSubmittingRechargeProductId(null);
|
||||
setRechargePaymentResult(result);
|
||||
if (result.kind === 'success') {
|
||||
void onRechargeSuccess?.();
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
if (cancelled || abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS);
|
||||
}
|
||||
};
|
||||
|
||||
void watchUntilSettled();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
abortController.abort();
|
||||
};
|
||||
}, [
|
||||
nativeWechatPayment?.expiresAt,
|
||||
nativeWechatPayment?.orderId,
|
||||
onRechargeSuccess,
|
||||
]);
|
||||
|
||||
// 中文注释:H5 / 小程序支付返回页、页面恢复和 hash 轮询都统一走同一套到账确认逻辑,
|
||||
// 避免页面组件自己感知微信支付细节。
|
||||
useEffect(() => {
|
||||
|
||||
@@ -386,7 +386,10 @@ const {
|
||||
updatedAt: '2026-06-19T10:00:00Z',
|
||||
},
|
||||
})),
|
||||
mockWatchWechatRpgProfileRechargeOrder: vi.fn(async () => null),
|
||||
mockWatchWechatRpgProfileRechargeOrder: vi.fn(
|
||||
() =>
|
||||
new Promise<ConfirmWechatProfileRechargeOrderResponse>(() => undefined),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2649,6 +2652,99 @@ test('profile native qr confirmation refreshes only after server reports paid',
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('profile native qr closes automatically when the order stream reports paid', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
let resolveOrderWatch!: (
|
||||
response: ConfirmWechatProfileRechargeOrderResponse,
|
||||
) => void;
|
||||
mockDesktopLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-native-auto-paid',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_native',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
wechatNativePayment: {
|
||||
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-auto-paid',
|
||||
expiresAt: '2099-01-01T00:05:00Z',
|
||||
},
|
||||
});
|
||||
mockWatchWechatRpgProfileRechargeOrder.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOrderWatch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: /60泥点/u }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '微信扫码支付' }),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(mockWatchWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'order-native-auto-paid',
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
resolveOrderWatch({
|
||||
order: {
|
||||
orderId: 'order-native-auto-paid',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
paymentChannel: 'wechat_native',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: '2026-04-25T10:01:00Z',
|
||||
providerTransactionId: 'wx-native-auto-1',
|
||||
pointsDelta: 60,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 60,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '微信扫码支付' })).toBeNull();
|
||||
expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('profile native qr confirmation closes qr dialog when order is expired', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
@@ -2907,11 +3003,12 @@ test('profile native qr confirmation keeps qr dialog when payment is still pendi
|
||||
},
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '微信扫码支付' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('dialog', { name: '微信扫码支付' })).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '支付处理中' })).toBeNull();
|
||||
expect(mockWatchWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
|
||||
expect(mockWatchWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'order-native-pending',
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
expect(onRechargeSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -81,6 +81,9 @@ test('profile funds ViewModel resolves ledger source labels with raw fallback',
|
||||
expect(getWalletLedgerSourceLabel('puzzle_author_incentive_claim')).toBe(
|
||||
'拼图作者奖励',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('recharge_refund_recovery')).toBe(
|
||||
'充值退款追回',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source');
|
||||
expect(getWalletLedgerSourceLabel('')).toBe('未知来源');
|
||||
});
|
||||
@@ -98,9 +101,18 @@ test('profile funds ViewModel builds wallet ledger presentation', () => {
|
||||
balanceAfter: 79,
|
||||
sourceType: 'asset_operation_consume',
|
||||
});
|
||||
const recoveryEntry = buildLedgerEntry({
|
||||
id: 'ledger-recovery',
|
||||
amountDelta: -30,
|
||||
balanceAfter: 49,
|
||||
sourceType: 'recharge_refund_recovery',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildWalletLedgerPresentation({ entries: [incomeEntry, outcomeEntry] }, 12),
|
||||
buildWalletLedgerPresentation(
|
||||
{ entries: [incomeEntry, outcomeEntry, recoveryEntry] },
|
||||
12,
|
||||
),
|
||||
).toEqual({
|
||||
balance: 80,
|
||||
balanceLabel: '80泥点',
|
||||
@@ -121,6 +133,14 @@ test('profile funds ViewModel builds wallet ledger presentation', () => {
|
||||
isIncome: false,
|
||||
sourceLabel: '资产操作消耗',
|
||||
},
|
||||
{
|
||||
amountLabel: '-30',
|
||||
balanceLabel: '余额 49',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
id: 'ledger-recovery',
|
||||
isIncome: false,
|
||||
sourceLabel: '充值退款追回',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
|
||||
daily_free_reset: '每日免费重置',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
asset_operation_refund: '资产操作退回',
|
||||
recharge_refund_recovery: '充值退款追回',
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
puzzle_author_incentive_claim: '拼图作者奖励',
|
||||
daily_task_reward: '每日任务奖励',
|
||||
|
||||
Reference in New Issue
Block a user