import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { ActivitySettings } from './ActivitySettings' import { useSettingsStore } from '../stores/settingsStore' const { getStatsMock } = vi.hoisted(() => ({ getStatsMock: vi.fn(), })) const { getPreferencesMock, updateProfilePreferencesMock, uploadProfileAvatarMock, deleteProfileAvatarMock, } = vi.hoisted(() => ({ getPreferencesMock: vi.fn(), updateProfilePreferencesMock: vi.fn(), uploadProfileAvatarMock: vi.fn(), deleteProfileAvatarMock: vi.fn(), })) vi.mock('../api/activityStats', () => ({ activityStatsApi: { getStats: getStatsMock, }, })) vi.mock('../api/desktopUiPreferences', () => ({ desktopUiPreferencesApi: { getPreferences: getPreferencesMock, updateProfilePreferences: updateProfilePreferencesMock, uploadProfileAvatar: uploadProfileAvatarMock, deleteProfileAvatar: deleteProfileAvatarMock, }, getProfileAvatarUrl: () => '/api/desktop-ui/preferences/profile/avatar?mock=1', })) const activityResponse = { range: 'all', generatedAt: '2026-05-09T12:00:00.000Z', totalSessions: 52, totalMessages: 900, totalDays: 365, activeDays: 20, streaks: { currentStreak: 9, longestStreak: 18, currentStreakStart: '2026-05-01', longestStreakStart: '2026-03-01', longestStreakEnd: '2026-03-18', }, dailyActivity: [ { date: '2026-04-20', sessionCount: 38, messageCount: 420, toolCallCount: 160 }, { date: '2026-05-07', sessionCount: 2, messageCount: 30, toolCallCount: 12 }, { date: '2026-05-09', sessionCount: 4, messageCount: 58, toolCallCount: 21 }, ], dailyModelTokens: [ { date: '2026-04-20', tokensByModel: { 'claude-sonnet': 2_672_000 } }, { date: '2026-05-07', tokensByModel: { 'claude-sonnet': 64_000 } }, { date: '2026-05-09', tokensByModel: { 'claude-sonnet': 128_000 } }, ], longestSession: null, modelUsage: {}, firstSessionDate: '2025-06-01T10:00:00.000Z', lastSessionDate: '2026-05-09T11:00:00.000Z', peakActivityDay: '2026-04-20', peakActivityHour: 14, totalSpeculationTimeSavedMs: 0, } async function flushActivityLoad() { await act(async () => { await Promise.resolve() await Promise.resolve() }) } describe('ActivitySettings', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-09T12:00:00')) getStatsMock.mockReset() getStatsMock.mockResolvedValue(activityResponse) getPreferencesMock.mockReset() updateProfilePreferencesMock.mockReset() uploadProfileAvatarMock.mockReset() deleteProfileAvatarMock.mockReset() getPreferencesMock.mockResolvedValue({ exists: false, preferences: { schemaVersion: 2, profile: { displayName: 'cc-haha', subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, sidebar: { projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }, }, }) updateProfilePreferencesMock.mockImplementation((profile) => Promise.resolve({ ok: true, preferences: { schemaVersion: 2, profile: { displayName: profile.displayName, subtitle: profile.subtitle, avatarFile: null, avatarUpdatedAt: null, }, sidebar: { projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }, }, })) uploadProfileAvatarMock.mockImplementation(() => Promise.resolve({ ok: true, preferences: { schemaVersion: 2, profile: { displayName: 'cc-haha', subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: 'profile/avatar.png', avatarUpdatedAt: '2026-05-09T12:00:00.000Z', }, sidebar: { projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }, }, })) deleteProfileAvatarMock.mockImplementation(() => Promise.resolve({ ok: true, preferences: { schemaVersion: 2, profile: { displayName: 'cc-haha', subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, sidebar: { projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }, }, })) useSettingsStore.setState({ locale: 'en' }) }) afterEach(() => { vi.useRealTimers() }) it('renders summary metrics and a GitHub-style trailing heatmap without future days', async () => { render() await flushActivityLoad() expect(getStatsMock).toHaveBeenCalledWith('all') expect(screen.getByText('cc-haha')).toBeInTheDocument() expect(screen.getByAltText('cc-haha avatar')).toHaveAttribute('src', '/app-icon.png') expect(screen.getByAltText('cc-haha avatar')).toHaveClass('scale-[1.28]') expect(screen.getByRole('link', { name: 'github.com/NanmiCoder/cc-haha' })).toHaveAttribute( 'href', 'https://github.com/NanmiCoder/cc-haha', ) expect(screen.getByText('Token Activity')).toBeInTheDocument() expect(screen.getByText('Total tokens')).toBeInTheDocument() expect(screen.getByText('Peak tokens')).toBeInTheDocument() expect(screen.getByText('Longest task')).toBeInTheDocument() expect(screen.getByText('Current streak')).toBeInTheDocument() expect(screen.getByText('Longest streak')).toBeInTheDocument() expect(screen.getByText('2.9M')).toBeInTheDocument() expect(screen.getByText('2.7M')).toBeInTheDocument() expect(screen.getByText('0m')).toBeInTheDocument() expect(screen.getByText('9 days')).toBeInTheDocument() expect(screen.getByText('18 days')).toBeInTheDocument() expect(screen.getAllByText('May').length).toBeGreaterThan(0) expect(screen.queryByText('5月')).not.toBeInTheDocument() const todayCell = screen.getByRole('gridcell', { name: /May 9, 2026: 4 sessions · 128K Tokens/i, }) expect(todayCell).toBeInTheDocument() expect(screen.queryByRole('gridcell', { name: /May 10, 2026/i })).not.toBeInTheDocument() }) it('shows a compact hover preview without a persistent selected-day panel', async () => { render() await flushActivityLoad() const todayCell = screen.getByRole('gridcell', { name: /May 9, 2026: 4 sessions · 128K Tokens/i, }) fireEvent.mouseEnter(todayCell) const tooltip = screen.getByRole('tooltip') expect(tooltip).toHaveTextContent('May 9, 2026') expect(tooltip).toHaveTextContent('4 sessions · 128K Tokens') expect(tooltip).not.toHaveTextContent(/messages|tools/i) expect(tooltip.className).toContain('--color-activity-tooltip-surface') expect(tooltip.className).toContain('--color-activity-tooltip-border') expect(todayCell.className).toContain('activity-heat-cell') expect(todayCell.className).toContain('is-active') expect(todayCell.className).toContain('--color-activity-cell-border') expect(screen.queryByText('Selected day')).not.toBeInTheDocument() }) it('keeps the profile edit control out of screenshots until hover or keyboard focus', async () => { render() await flushActivityLoad() const editButton = screen.getByRole('button', { name: 'Edit profile' }) expect(editButton).toHaveClass('opacity-0') expect(editButton).toHaveClass('group-hover/activity-profile:opacity-100') expect(editButton).toHaveClass('focus-visible:opacity-100') expect(editButton.closest('div')).toHaveClass('group/activity-profile') }) it('supports localized heatmap mode switches and persisted display name edits', async () => { useSettingsStore.setState({ locale: 'zh' }) render() await flushActivityLoad() expect(screen.getByText('Token 活动')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '每周' })) expect(screen.getByRole('button', { name: '每周' })).toHaveAttribute('aria-pressed', 'true') fireEvent.click(screen.getByRole('button', { name: '累计' })) expect(screen.getByRole('button', { name: '累计' })).toHaveAttribute('aria-pressed', 'true') fireEvent.click(screen.getByRole('button', { name: '编辑个人资料' })) const input = screen.getByLabelText('显示名称') fireEvent.change(input, { target: { value: '本地舰长' } }) fireEvent.change(screen.getByLabelText('第二行'), { target: { value: 'relakkes.dev' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) await flushActivityLoad() expect(updateProfilePreferencesMock).toHaveBeenCalledWith({ displayName: '本地舰长', subtitle: 'relakkes.dev', }) expect(screen.getByText('本地舰长')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'relakkes.dev' })).toHaveAttribute('href', 'https://relakkes.dev') }) it('handles avatar upload, fallback, removal, save failure, and cancel reset', async () => { getPreferencesMock.mockResolvedValueOnce({ exists: true, preferences: { schemaVersion: 2, profile: { displayName: 'Local Captain', subtitle: 'Local workspace', avatarFile: 'profile/avatar.webp', avatarUpdatedAt: '2026-05-09T12:00:00.000Z', }, sidebar: { projectOrder: [], pinnedProjects: [], hiddenProjects: [], projectOrganization: 'recentProject', projectSortBy: 'updatedAt', }, }, }) updateProfilePreferencesMock.mockRejectedValueOnce(new Error('display name rejected')) render() await flushActivityLoad() const avatar = screen.getByAltText('Local Captain avatar') expect(avatar).toHaveAttribute('src', '/api/desktop-ui/preferences/profile/avatar?mock=1') expect(avatar).not.toHaveClass('scale-[1.28]') fireEvent.error(avatar) expect(avatar).toHaveAttribute('src', '/app-icon.png') expect(avatar).toHaveClass('scale-[1.28]') fireEvent.click(screen.getByRole('button', { name: 'Edit profile' })) fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Rejected Name' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) await flushActivityLoad() expect(screen.getByText('display name rejected')).toBeInTheDocument() fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Unsaved Name' } }) fireEvent.change(screen.getByLabelText('Second line'), { target: { value: 'Unsaved subtitle' } }) const cancelButtons = screen.getAllByRole('button', { name: 'Cancel' }) fireEvent.click(cancelButtons[cancelButtons.length - 1]!) fireEvent.click(screen.getByRole('button', { name: 'Edit profile' })) expect(screen.getByLabelText('Display name')).toHaveValue('Local Captain') expect(screen.getByLabelText('Second line')).toHaveValue('Local workspace') const input = document.querySelector('input[type="file"]') as HTMLInputElement const file = new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }) fireEvent.change(input, { target: { files: [file] } }) await flushActivityLoad() expect(uploadProfileAvatarMock).toHaveBeenCalledWith(file) expect(screen.getByText('Saved locally')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Remove avatar' })) await flushActivityLoad() expect(deleteProfileAvatarMock).toHaveBeenCalled() expect(screen.getByAltText('cc-haha avatar')).toHaveAttribute('src', '/app-icon.png') }) it('shows localized duration details and the empty usage state', async () => { useSettingsStore.setState({ locale: 'zh' }) getStatsMock.mockResolvedValueOnce({ ...activityResponse, totalSessions: 0, totalMessages: 0, activeDays: 0, dailyActivity: [], dailyModelTokens: [], longestSession: { id: 'session-1', startedAt: '2026-05-09T08:00:00.000Z', endedAt: '2026-05-09T09:30:00.000Z', duration: 90 * 60_000, messageCount: 12, toolCallCount: 4, }, peakActivityDay: null, streaks: { currentStreak: 0, longestStreak: 0, currentStreakStart: null, longestStreakStart: null, longestStreakEnd: null, }, }) render() await flushActivityLoad() expect(screen.getByText('1 小时 30 分钟')).toBeInTheDocument() expect(screen.getByText('12 消息')).toBeInTheDocument() expect(screen.getByText('暂无本地用量')).toBeInTheDocument() }) })