mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
The profile edit affordance is useful but low-frequency, and it was always visible in the token activity header. Hide it by default so shared screenshots keep the profile header clean while hover and keyboard focus still expose the action. Constraint: The edit action must remain discoverable for pointer users and reachable for keyboard users. Rejected: Remove the edit button entirely | profile customization still needs an obvious local entry point. Confidence: high Scope-risk: narrow Reversibility: clean Tested: cd desktop && bun run test -- src/pages/ActivitySettings.test.tsx --run --reporter=verbose --pool=forks --maxWorkers=1 --minWorkers=1 Tested: git diff --check -- desktop/src/pages/ActivitySettings.tsx desktop/src/pages/ActivitySettings.test.tsx Not-tested: Manual packaged desktop screenshot capture.
369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
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(<ActivitySettings />)
|
|
|
|
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(<ActivitySettings />)
|
|
|
|
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(<ActivitySettings />)
|
|
|
|
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(<ActivitySettings />)
|
|
|
|
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(<ActivitySettings />)
|
|
|
|
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(<ActivitySettings />)
|
|
|
|
await flushActivityLoad()
|
|
|
|
expect(screen.getByText('1 小时 30 分钟')).toBeInTheDocument()
|
|
expect(screen.getByText('12 消息')).toBeInTheDocument()
|
|
expect(screen.getByText('暂无本地用量')).toBeInTheDocument()
|
|
})
|
|
})
|