import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Sidebar } from '@/components/layout/Sidebar'; import type { WorksResetCard, WorksTokenPointBalance } from '@/lib/works-square'; const fetchTokenPointBalanceMock = vi.hoisted(() => vi.fn()); const fetchResetCardsMock = vi.hoisted(() => vi.fn()); const redeemResetCardMock = vi.hoisted(() => vi.fn()); const getValidAccessTokenMock = vi.hoisted(() => vi.fn()); const authState = vi.hoisted(() => ({ user: { username: 'member@example.com', userId: 'user-1', tenantId: null, deptId: null, authorities: [], }, getValidAccessToken: getValidAccessTokenMock, logout: vi.fn(), })); const codingState = vi.hoisted(() => ({ projects: [], activeProject: null, load: vi.fn().mockResolvedValue(undefined), setActiveProject: vi.fn(), removeProject: vi.fn(), createProject: vi.fn(), })); const projectConfigState = vi.hoisted(() => ({ configsByProjectId: {}, load: vi.fn().mockResolvedValue(undefined), remove: vi.fn(), })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }), })); vi.mock('@/lib/works-square', () => ({ fetchWorksTokenPointBalance: (...args: unknown[]) => fetchTokenPointBalanceMock(...args), fetchWorksResetCards: (...args: unknown[]) => fetchResetCardsMock(...args), redeemWorksResetCard: (...args: unknown[]) => redeemResetCardMock(...args), })); vi.mock('@/lib/subscription-upgrade', () => ({ openWorksSquareSubscriptionUpgrade: vi.fn().mockResolvedValue(undefined), })); vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() })); vi.mock('@/hooks/use-current-user-profile', () => ({ useCurrentUserProfile: () => ({ userProfile: { displayName: '测试用户', avatarUrl: null }, profileRequired: false, profileSyncError: null, syncProfileNow: vi.fn().mockResolvedValue(undefined), }), })); vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: typeof authState) => unknown) => selector(authState), })); vi.mock('@/stores/settings', () => ({ useSettingsStore: (selector: (state: { sidebarCollapsed: boolean }) => unknown) => ( selector({ sidebarCollapsed: false }) ), })); vi.mock('@/stores/coding-workspace', () => ({ useCodingWorkspaceStore: (selector: (state: typeof codingState) => unknown) => selector(codingState), })); vi.mock('@/stores/project-config', () => ({ useProjectConfigStore: (selector: (state: typeof projectConfigState) => unknown) => ( selector(projectConfigState) ), })); vi.mock('@/stores/providers', () => ({ useProviderStore: (selector: (state: { refreshProviderSnapshot: () => Promise }) => unknown) => ( selector({ refreshProviderSnapshot: vi.fn().mockResolvedValue(undefined) }) ), })); vi.mock('@/components/layout/ModuleSwitcher', () => ({ ModuleSwitcher: () => null })); vi.mock('@/components/layout/ImageWorkspaceSidebar', () => ({ ImageWorkspaceSidebar: () => null })); vi.mock('@/components/layout/LearningSidebar', () => ({ LearningSidebar: () => null })); vi.mock('@/components/layout/SidebarUpdateButton', () => ({ SidebarUpdateButton: () => null })); vi.mock('@/components/profile/UserProfileDialog', () => ({ UserProfileDialog: () => null })); function pointBalance( overrides: Partial = {}, ): WorksTokenPointBalance { return { plan_code: 'mastery', plan_name: '精通', cycle_start: '2026-09-01T00:00:00Z', cycle_end: '2026-09-08T00:00:00Z', next_refresh_at: '2026-09-08T00:00:00Z', weekly_allowance: '500.00', weekly_used: '100.00', weekly_reserved: '25.00', weekly_remaining: '375.00', permanent_total: '50.00', permanent_used: '0.00', permanent_reserved: '0.00', permanent_remaining: '50.00', total_remaining: '425.00', entitlement_source: 'self', family_shared: false, can_manage_membership: true, upgrade_action: 'self_service', shared_available: null, ...overrides, }; } function resetCard(overrides: Partial = {}): WorksResetCard { return { id: 'card-1', status: 'available', granted_at: '2026-09-08T00:00:00Z', expires_at: '2099-09-15T00:00:00Z', redeemed_at: null, redeemed_cycle_id: null, ...overrides, }; } async function renderUsageDrawer(): Promise { render( , ); await waitFor(() => expect(getValidAccessTokenMock).toHaveBeenCalled()); fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger')); fireEvent.click(screen.getByTestId('sidebar-account-usage-menuitem')); return screen.getByTestId('sidebar-account-usage-drawer'); } async function renderResetCardDrawer(): Promise { render( , ); fireEvent.click(screen.getByTestId('sidebar-member-menu-trigger')); fireEvent.click(screen.getByTestId('sidebar-reset-cards-menuitem')); return screen.getByTestId('sidebar-reset-cards-drawer'); } describe('Sidebar V2 token point balance', () => { beforeEach(() => { getValidAccessTokenMock.mockReset(); fetchTokenPointBalanceMock.mockReset(); fetchResetCardsMock.mockReset(); redeemResetCardMock.mockReset(); getValidAccessTokenMock.mockResolvedValue('access-token'); fetchTokenPointBalanceMock.mockResolvedValue(pointBalance()); fetchResetCardsMock.mockResolvedValue([]); }); it('shows the authoritative weekly and total point balances without the retired rolling rows', async () => { fetchTokenPointBalanceMock.mockResolvedValue(pointBalance()); const drawer = await renderUsageDrawer(); await waitFor(() => expect(within(drawer).getByText('精通')).toBeInTheDocument()); expect(drawer).toHaveTextContent('本周剩余'); expect(screen.getByTestId('sidebar-weekly-token-points')).toHaveTextContent('375 / 500 点'); expect(drawer).toHaveTextContent('总可用'); expect(screen.getByTestId('sidebar-total-token-points')).toHaveTextContent('425 点'); expect(drawer).not.toHaveTextContent('5 小时'); expect(drawer).not.toHaveTextContent('1 周'); }); it('shows a distinct retrieval error instead of treating missing data as unlimited or zero', async () => { fetchTokenPointBalanceMock.mockRejectedValue(new Error('upstream unavailable')); const drawer = await renderUsageDrawer(); await waitFor(() => expect(screen.getByTestId('sidebar-token-points-error')).toBeInTheDocument()); expect(drawer).toHaveTextContent('词元点数暂时无法获取。'); expect(drawer).not.toHaveTextContent('不限'); expect(drawer).not.toHaveTextContent('0%'); }); it('shows only coarse availability for a family-shared member', async () => { fetchTokenPointBalanceMock.mockResolvedValue(pointBalance({ plan_code: null, plan_name: null, entitlement_source: 'shared_group', family_shared: true, can_manage_membership: false, upgrade_action: 'contact_family_owner', shared_available: false, weekly_allowance: null, weekly_used: null, weekly_reserved: null, weekly_remaining: null, permanent_total: null, permanent_used: null, permanent_reserved: null, permanent_remaining: null, total_remaining: null, })); const drawer = await renderUsageDrawer(); await waitFor(() => expect(within(drawer).getByText('共享会员')).toBeInTheDocument()); expect(drawer).toHaveTextContent('共享额度'); expect(drawer).toHaveTextContent('已用尽'); expect(drawer).toHaveTextContent('请联系家庭管理员'); expect(drawer).not.toHaveTextContent('本周剩余'); expect(drawer).not.toHaveTextContent('总可用'); expect(screen.queryByTestId('sidebar-account-upgrade-button')).not.toBeInTheDocument(); }); it('shows only parent-managed availability for a youth self entitlement', async () => { fetchTokenPointBalanceMock.mockResolvedValue(pointBalance({ plan_code: null, plan_name: null, entitlement_source: 'self', family_shared: false, can_manage_membership: false, upgrade_action: 'contact_family_owner', shared_available: false, weekly_allowance: null, weekly_used: null, weekly_reserved: null, weekly_remaining: null, permanent_total: null, permanent_used: null, permanent_reserved: null, permanent_remaining: null, total_remaining: null, })); const drawer = await renderUsageDrawer(); await waitFor(() => expect(within(drawer).getByText('青少年账户')).toBeInTheDocument()); expect(drawer).toHaveTextContent('词元点数'); expect(drawer).toHaveTextContent('已用尽'); expect(drawer).toHaveTextContent('请联系家长'); expect(drawer).not.toHaveTextContent('共享额度'); expect(drawer).not.toHaveTextContent('本周剩余'); expect(drawer).not.toHaveTextContent('总可用'); expect(screen.queryByTestId('sidebar-account-upgrade-button')).not.toBeInTheDocument(); }); it('shows available, expired, and redeemed reset cards with explicit dates', async () => { fetchTokenPointBalanceMock.mockResolvedValue(pointBalance()); fetchResetCardsMock.mockResolvedValue([ resetCard(), resetCard({ id: 'card-expired', status: 'expired', expires_at: '2026-09-01T00:00:00Z' }), resetCard({ id: 'card-redeemed', status: 'redeemed', redeemed_at: '2026-09-09T00:00:00Z', redeemed_cycle_id: 'cycle-new', }), ]); const drawer = await renderResetCardDrawer(); await waitFor(() => expect(within(drawer).getByText('可使用')).toBeInTheDocument()); expect(screen.getByTestId('sidebar-reset-card-available-count')).toHaveTextContent('1'); expect(drawer).toHaveTextContent('已过期'); expect(drawer).toHaveTextContent('已使用'); expect(drawer).toHaveTextContent('有效期至 2099-09-15'); expect(drawer).toHaveTextContent('使用于 2026-09-09'); expect(screen.getByTestId('sidebar-reset-card-redeem-card-1')).toHaveTextContent('立即使用'); expect(screen.queryByTestId('sidebar-reset-card-redeem-card-expired')).not.toBeInTheDocument(); }); it('redeems one card and refreshes both the card wallet and point balance', async () => { const available = resetCard(); const redeemed = resetCard({ status: 'redeemed', redeemed_at: '2026-09-09T00:00:00Z', redeemed_cycle_id: 'cycle-new', }); fetchTokenPointBalanceMock.mockResolvedValue(pointBalance()); fetchResetCardsMock .mockResolvedValueOnce([available]) .mockResolvedValue([redeemed]); redeemResetCardMock.mockResolvedValue(redeemed); const drawer = await renderResetCardDrawer(); await waitFor(() => expect(within(drawer).getByText('立即使用')).toBeInTheDocument()); const balanceCallsBeforeRedeem = fetchTokenPointBalanceMock.mock.calls.length; fireEvent.click(screen.getByTestId('sidebar-reset-card-redeem-card-1')); await waitFor(() => expect(redeemResetCardMock).toHaveBeenCalledWith('access-token', 'card-1')); await waitFor(() => expect(within(drawer).getByText('已使用')).toBeInTheDocument()); expect(fetchResetCardsMock.mock.calls.length).toBeGreaterThanOrEqual(2); expect(fetchTokenPointBalanceMock.mock.calls.length).toBeGreaterThan(balanceCallsBeforeRedeem); }); it('derives an elapsed available card as expired before offering redemption', async () => { fetchTokenPointBalanceMock.mockResolvedValue(pointBalance()); fetchResetCardsMock.mockResolvedValue([ resetCard({ id: 'card-stale', expires_at: '2000-01-01T00:00:00Z' }), ]); const drawer = await renderResetCardDrawer(); await waitFor(() => expect(within(drawer).getByText('已过期')).toBeInTheDocument()); expect(screen.queryByTestId('sidebar-reset-card-available-count')).not.toBeInTheDocument(); expect(screen.queryByTestId('sidebar-reset-card-redeem-card-stale')).not.toBeInTheDocument(); }); it('keeps a legacy owned card visible but disables redemption while using a shared wallet', async () => { fetchTokenPointBalanceMock.mockResolvedValue(pointBalance({ plan_code: null, plan_name: null, entitlement_source: 'shared_group', family_shared: true, can_manage_membership: false, upgrade_action: 'contact_family_owner', shared_available: true, weekly_allowance: null, weekly_used: null, weekly_reserved: null, weekly_remaining: null, permanent_total: null, permanent_used: null, permanent_reserved: null, permanent_remaining: null, total_remaining: null, })); fetchResetCardsMock.mockResolvedValue([resetCard()]); const drawer = await renderResetCardDrawer(); await waitFor(() => expect(within(drawer).getByText('家庭共享中不可用')).toBeDisabled()); expect(drawer).toHaveTextContent('请由家庭管理员使用自己的重置卡'); fireEvent.click(screen.getByTestId('sidebar-reset-card-redeem-card-1')); expect(redeemResetCardMock).not.toHaveBeenCalled(); }); });