Files
makelore/tests/unit/sidebar-token-points.test.tsx
brother7 83ad10d95e
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate token point usage v2
2026-09-06 14:24:50 +08:00

211 lines
7.9 KiB
TypeScript

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<void> }) => 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> = {},
): 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<HTMLElement> {
render(
<MemoryRouter initialEntries={['/learning']}>
<Sidebar />
</MemoryRouter>,
);
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();
});
});