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 { WorksTokenPointBalance } from '@/lib/works-square'; const fetchTokenPointBalanceMock = 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), })); 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, }; } 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'); } describe('Sidebar V2 token point balance', () => { beforeEach(() => { getValidAccessTokenMock.mockResolvedValue('access-token'); }); 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(); }); });