349 lines
14 KiB
TypeScript
349 lines
14 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 { 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<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,
|
|
};
|
|
}
|
|
|
|
function resetCard(overrides: Partial<WorksResetCard> = {}): 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<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');
|
|
}
|
|
|
|
async function renderResetCardDrawer(): Promise<HTMLElement> {
|
|
render(
|
|
<MemoryRouter initialEntries={['/learning']}>
|
|
<Sidebar />
|
|
</MemoryRouter>,
|
|
);
|
|
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 and expired reset cards while hiding redeemed cards', 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(screen.queryByTestId('sidebar-reset-card-card-redeemed')).not.toBeInTheDocument();
|
|
expect(drawer).toHaveTextContent('有效期至 2099-09-15');
|
|
expect(drawer).not.toHaveTextContent('使用于');
|
|
expect(screen.getByTestId('sidebar-reset-card-redeem-card-1')).toHaveTextContent('立即使用');
|
|
expect(screen.queryByTestId('sidebar-reset-card-redeem-card-expired')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('shows an empty wallet when all cards were already redeemed', async () => {
|
|
fetchTokenPointBalanceMock.mockResolvedValue(pointBalance());
|
|
fetchResetCardsMock.mockResolvedValue([resetCard({ status: 'redeemed', redeemed_at: '2026-09-09T00:00:00Z' })]);
|
|
|
|
const drawer = await renderResetCardDrawer();
|
|
|
|
await waitFor(() => expect(drawer).toHaveTextContent('暂无未使用的重置卡。'));
|
|
expect(screen.queryByTestId('sidebar-reset-card-card-1')).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId('sidebar-reset-card-available-count')).not.toBeInTheDocument();
|
|
expect(redeemResetCardMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('hides a redeemed 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(screen.queryByTestId('sidebar-reset-card-card-1')).not.toBeInTheDocument());
|
|
await waitFor(() => expect(drawer).toHaveTextContent('暂无未使用的重置卡。'));
|
|
expect(screen.queryByTestId('sidebar-reset-card-available-count')).not.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();
|
|
});
|
|
});
|