fc0dcb0782
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>
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import { describe, expect, test, vi } from 'vitest';
|
|
|
|
import type {
|
|
ProfileMudPointBalance,
|
|
ProfileRechargeCenterResponse,
|
|
} from '../contracts/runtime';
|
|
import { createProfileWalletStore } from './createProfileWalletStore';
|
|
|
|
function balance(totalPoints: number): ProfileMudPointBalance {
|
|
return {
|
|
totalPoints,
|
|
permanentPoints: totalPoints,
|
|
limitedPoints: 0,
|
|
limitedExpiresAt: null,
|
|
dailyFreePoints: 0,
|
|
dailyFreeResetPoints: 20,
|
|
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
|
};
|
|
}
|
|
|
|
function center(totalPoints: number): ProfileRechargeCenterResponse {
|
|
return {
|
|
mudPointBalance: balance(totalPoints),
|
|
} as ProfileRechargeCenterResponse;
|
|
}
|
|
|
|
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, resolve, reject };
|
|
}
|
|
|
|
describe('createProfileWalletStore', () => {
|
|
test('reads the first complete wallet snapshot for its owner', async () => {
|
|
const getRechargeCenter = vi.fn().mockResolvedValue(center(42));
|
|
const store = createProfileWalletStore({ getRechargeCenter });
|
|
|
|
store.getState().setWalletOwner('user-a');
|
|
await store.getState().onWalletBalanceMayHaveChanged();
|
|
|
|
expect(getRechargeCenter).toHaveBeenCalledTimes(1);
|
|
expect(store.getState()).toMatchObject({
|
|
ownerUserId: 'user-a',
|
|
mudPointBalance: balance(42),
|
|
mudPointBalanceStatus: 'ready',
|
|
mudPointBalanceError: '',
|
|
});
|
|
});
|
|
|
|
test('merges concurrent notifications into one request', async () => {
|
|
const pending = deferred<ProfileRechargeCenterResponse>();
|
|
const getRechargeCenter = vi.fn(() => pending.promise);
|
|
const store = createProfileWalletStore({ getRechargeCenter });
|
|
store.getState().setWalletOwner('user-a');
|
|
|
|
const first = store.getState().onWalletBalanceMayHaveChanged();
|
|
const second = store.getState().onWalletBalanceMayHaveChanged();
|
|
expect(first).toBe(second);
|
|
expect(getRechargeCenter).toHaveBeenCalledTimes(1);
|
|
|
|
pending.resolve(center(12));
|
|
await first;
|
|
expect(getRechargeCenter).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('reads again when balance changes during an active request', async () => {
|
|
const first = deferred<ProfileRechargeCenterResponse>();
|
|
const getRechargeCenter = vi
|
|
.fn()
|
|
.mockImplementationOnce(() => first.promise)
|
|
.mockResolvedValueOnce(center(30));
|
|
const store = createProfileWalletStore({ getRechargeCenter });
|
|
store.getState().setWalletOwner('user-a');
|
|
|
|
const refresh = store.getState().onWalletBalanceMayHaveChanged();
|
|
void store.getState().onWalletBalanceMayHaveChanged();
|
|
first.resolve(center(99));
|
|
await refresh;
|
|
|
|
expect(getRechargeCenter).toHaveBeenCalledTimes(2);
|
|
expect(store.getState().mudPointBalance?.totalPoints).toBe(30);
|
|
});
|
|
|
|
test('does not let an older read overwrite an applied mutation snapshot', async () => {
|
|
const pending = deferred<ProfileRechargeCenterResponse>();
|
|
const store = createProfileWalletStore({
|
|
getRechargeCenter: vi.fn(() => pending.promise),
|
|
});
|
|
store.getState().setWalletOwner('user-a');
|
|
|
|
const refresh = store.getState().onWalletBalanceMayHaveChanged();
|
|
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
|
|
expect(snapshot).not.toBeNull();
|
|
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(80));
|
|
pending.resolve(center(20));
|
|
await refresh;
|
|
|
|
expect(store.getState().mudPointBalance?.totalPoints).toBe(80);
|
|
});
|
|
|
|
test('refreshes the new owner without waiting for a stalled old-owner request', async () => {
|
|
const oldOwner = deferred<ProfileRechargeCenterResponse>();
|
|
const newOwner = deferred<ProfileRechargeCenterResponse>();
|
|
const requestSignals: Array<AbortSignal | undefined> = [];
|
|
const getRechargeCenter = vi
|
|
.fn()
|
|
.mockImplementationOnce((signal?: AbortSignal) => {
|
|
requestSignals.push(signal);
|
|
return oldOwner.promise;
|
|
})
|
|
.mockImplementationOnce((signal?: AbortSignal) => {
|
|
requestSignals.push(signal);
|
|
return newOwner.promise;
|
|
});
|
|
const store = createProfileWalletStore({ getRechargeCenter });
|
|
store.getState().setWalletOwner('user-a');
|
|
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
|
|
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(50));
|
|
|
|
const oldRefresh = store.getState().onWalletBalanceMayHaveChanged();
|
|
store.getState().setWalletOwner('user-b');
|
|
expect(store.getState()).toMatchObject({
|
|
ownerUserId: 'user-b',
|
|
mudPointBalance: null,
|
|
mudPointBalanceStatus: 'idle',
|
|
});
|
|
const newRefresh = store.getState().onWalletBalanceMayHaveChanged();
|
|
|
|
expect(getRechargeCenter).toHaveBeenCalledTimes(2);
|
|
expect(newRefresh).not.toBe(oldRefresh);
|
|
expect(requestSignals[0]?.aborted).toBe(true);
|
|
expect(requestSignals[1]?.aborted).toBe(false);
|
|
newOwner.resolve(center(7));
|
|
await newRefresh;
|
|
expect(store.getState().mudPointBalance?.totalPoints).toBe(7);
|
|
|
|
oldOwner.resolve(center(60));
|
|
await oldRefresh;
|
|
expect(store.getState().mudPointBalance?.totalPoints).toBe(7);
|
|
});
|
|
|
|
test('keeps an existing snapshot when refresh fails', async () => {
|
|
const store = createProfileWalletStore({
|
|
getRechargeCenter: vi.fn().mockRejectedValue(new Error('network down')),
|
|
});
|
|
store.getState().setWalletOwner('user-a');
|
|
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
|
|
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(55));
|
|
|
|
await store.getState().onWalletBalanceMayHaveChanged();
|
|
|
|
expect(store.getState()).toMatchObject({
|
|
mudPointBalance: balance(55),
|
|
mudPointBalanceStatus: 'error',
|
|
mudPointBalanceError: '泥点明细读取失败',
|
|
});
|
|
});
|
|
|
|
test('treats a recharge response without the balance breakdown as an error', async () => {
|
|
const store = createProfileWalletStore({
|
|
getRechargeCenter: vi
|
|
.fn()
|
|
.mockResolvedValue({} as ProfileRechargeCenterResponse),
|
|
});
|
|
store.getState().setWalletOwner('user-a');
|
|
|
|
await store.getState().onWalletBalanceMayHaveChanged();
|
|
|
|
expect(store.getState()).toMatchObject({
|
|
mudPointBalance: null,
|
|
mudPointBalanceStatus: 'error',
|
|
mudPointBalanceError: '泥点明细读取失败',
|
|
});
|
|
});
|
|
|
|
test('does not capture a snapshot for a different owner', () => {
|
|
const store = createProfileWalletStore({
|
|
getRechargeCenter: vi.fn(),
|
|
});
|
|
store.getState().setWalletOwner('user-b');
|
|
|
|
const snapshot = store.getState().captureWalletBalanceSnapshot('user-a');
|
|
|
|
expect(snapshot).toBeNull();
|
|
expect(store.getState().mudPointBalance).toBeNull();
|
|
expect(store.getState().mudPointBalanceStatus).toBe('idle');
|
|
});
|
|
|
|
test('normalizes a snapshot owner before matching it', () => {
|
|
const store = createProfileWalletStore({
|
|
getRechargeCenter: vi.fn(),
|
|
});
|
|
store.getState().setWalletOwner('user-a');
|
|
|
|
const snapshot = store
|
|
.getState()
|
|
.captureWalletBalanceSnapshot(' user-a ');
|
|
store.getState().applyWalletBalanceSnapshot(snapshot!, balance(100));
|
|
|
|
expect(store.getState()).toMatchObject({
|
|
mudPointBalance: balance(100),
|
|
mudPointBalanceStatus: 'ready',
|
|
});
|
|
});
|
|
|
|
test('does not let an older ordinary snapshot swallow a terminal refresh', async () => {
|
|
const terminalRefresh = deferred<ProfileRechargeCenterResponse>();
|
|
const requestSignals: Array<AbortSignal | undefined> = [];
|
|
const store = createProfileWalletStore({
|
|
getRechargeCenter: vi.fn((signal?: AbortSignal) => {
|
|
requestSignals.push(signal);
|
|
return terminalRefresh.promise;
|
|
}),
|
|
});
|
|
store.getState().setWalletOwner('user-a');
|
|
const ordinarySnapshot = store
|
|
.getState()
|
|
.captureWalletBalanceSnapshot('user-a');
|
|
|
|
const refresh = store.getState().onWalletBalanceMayHaveChanged();
|
|
expect(
|
|
store
|
|
.getState()
|
|
.applyWalletBalanceSnapshot(ordinarySnapshot!, balance(80)),
|
|
).toBe(false);
|
|
expect(requestSignals[0]?.aborted).toBe(false);
|
|
|
|
terminalRefresh.resolve(center(100));
|
|
await refresh;
|
|
|
|
expect(store.getState().mudPointBalance?.totalPoints).toBe(100);
|
|
});
|
|
|
|
test('rejects an older direct snapshot after a newer operation has applied', () => {
|
|
const store = createProfileWalletStore({ getRechargeCenter: vi.fn() });
|
|
store.getState().setWalletOwner('user-a');
|
|
const olderSnapshot = store
|
|
.getState()
|
|
.captureWalletBalanceSnapshot('user-a');
|
|
const newerSnapshot = store
|
|
.getState()
|
|
.captureWalletBalanceSnapshot('user-a');
|
|
|
|
expect(
|
|
store.getState().applyWalletBalanceSnapshot(newerSnapshot!, balance(100)),
|
|
).toBe(true);
|
|
expect(
|
|
store.getState().applyWalletBalanceSnapshot(olderSnapshot!, balance(20)),
|
|
).toBe(false);
|
|
expect(store.getState().mudPointBalance?.totalPoints).toBe(100);
|
|
});
|
|
|
|
test('retains a legacy total without inventing a balance breakdown', async () => {
|
|
const store = createProfileWalletStore({
|
|
getRechargeCenter: vi.fn().mockResolvedValue({
|
|
walletBalance: 37,
|
|
} as ProfileRechargeCenterResponse),
|
|
});
|
|
store.getState().setWalletOwner('user-a');
|
|
|
|
await store.getState().onWalletBalanceMayHaveChanged();
|
|
|
|
expect(store.getState()).toMatchObject({
|
|
legacyWalletBalance: 37,
|
|
mudPointBalance: null,
|
|
mudPointBalanceStatus: 'error',
|
|
mudPointBalanceError: '泥点明细读取失败',
|
|
});
|
|
});
|
|
|
|
test('clears a stale balance breakdown when a newer refresh only has a legacy total', async () => {
|
|
const getRechargeCenter = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(center(90))
|
|
.mockResolvedValueOnce({
|
|
walletBalance: 37,
|
|
} as ProfileRechargeCenterResponse);
|
|
const store = createProfileWalletStore({ getRechargeCenter });
|
|
store.getState().setWalletOwner('user-a');
|
|
|
|
await store.getState().onWalletBalanceMayHaveChanged();
|
|
await store.getState().onWalletBalanceMayHaveChanged();
|
|
|
|
expect(store.getState()).toMatchObject({
|
|
legacyWalletBalance: 37,
|
|
mudPointBalance: null,
|
|
mudPointBalanceStatus: 'error',
|
|
mudPointBalanceError: '泥点明细读取失败',
|
|
});
|
|
});
|
|
|
|
test('applies an owner-scoped legacy mutation snapshot atomically', () => {
|
|
const store = createProfileWalletStore({ getRechargeCenter: vi.fn() });
|
|
store.getState().setWalletOwner('user-a');
|
|
const structuredSnapshot = store
|
|
.getState()
|
|
.captureWalletBalanceSnapshot('user-a');
|
|
store
|
|
.getState()
|
|
.applyWalletBalanceSnapshot(structuredSnapshot!, balance(90));
|
|
const legacySnapshot = store
|
|
.getState()
|
|
.captureWalletBalanceSnapshot('user-a');
|
|
|
|
expect(
|
|
store.getState().applyLegacyWalletBalanceSnapshot(legacySnapshot!, 37),
|
|
).toBe(true);
|
|
expect(store.getState()).toMatchObject({
|
|
legacyWalletBalance: 37,
|
|
mudPointBalance: null,
|
|
mudPointBalanceStatus: 'ready',
|
|
mudPointBalanceError: '',
|
|
});
|
|
});
|
|
|
|
test('rejects a snapshot after switching away from and back to the same owner', () => {
|
|
const store = createProfileWalletStore({ getRechargeCenter: vi.fn() });
|
|
store.getState().setWalletOwner('user-a');
|
|
const oldLifecycleSnapshot = store
|
|
.getState()
|
|
.captureWalletBalanceSnapshot('user-a');
|
|
|
|
store.getState().setWalletOwner('user-b');
|
|
store.getState().setWalletOwner('user-a');
|
|
|
|
expect(
|
|
store
|
|
.getState()
|
|
.applyWalletBalanceSnapshot(oldLifecycleSnapshot!, balance(100)),
|
|
).toBe(false);
|
|
expect(store.getState().mudPointBalance).toBeNull();
|
|
});
|
|
});
|