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() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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(); 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(); 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(); 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(); const newOwner = deferred(); const requestSignals: Array = []; 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(); const requestSignals: Array = []; 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(); }); });