diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx index 9ea5847..dcde474 100644 --- a/frontend/src/app/AppRoot.tsx +++ b/frontend/src/app/AppRoot.tsx @@ -210,67 +210,50 @@ function AuthenticatedScheduleRoute({ return ( - - - 账号:{username} - - void handleSignOut()} - style={({ pressed }) => [ - styles.signOutButton, - pressed && !isSigningOut && styles.signOutButtonPressed, - ]} - > - {isSigningOut ? '正在退出…' : '退出登录'} - - - - {currentLoadState?.status === 'ready' && - scheduleService && - pushToTalkApplication && - continuousApplication ? ( - - ) : ( - - - {currentLoadState?.status === 'error' ? '本地日程存储初始化失败' : '正在准备日程'} + {currentLoadState?.status === 'ready' && + scheduleService && + pushToTalkApplication && + continuousApplication ? ( + + ) : ( + + + {currentLoadState?.status === 'error' ? '本地日程存储初始化失败' : '正在准备日程'} + + {currentLoadState?.status === 'error' ? ( + + 重试 + + ) : null} + void handleSignOut()} + style={styles.loadStateSignOut} + > + + {isSigningOut ? '正在退出…' : '退出登录'} - {currentLoadState?.status === 'error' ? ( - - 重试 - - ) : null} - - )} - + + + )} ); } const styles = StyleSheet.create({ account: { color: colors.mutedText, fontSize: 16, marginTop: spacing.sm }, - accountBar: { - alignItems: 'center', - backgroundColor: colors.surface, - borderBottomColor: colors.border, - borderBottomWidth: 1, - flexDirection: 'row', - gap: spacing.md, - justifyContent: 'space-between', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - }, - accountIdentity: { color: colors.mutedText, flex: 1, fontSize: 14 }, - authenticatedContent: { flex: 1 }, authenticatedRoute: { backgroundColor: colors.background, flex: 1 }, authenticatedScreen: { alignItems: 'center', @@ -279,6 +262,16 @@ const styles = StyleSheet.create({ justifyContent: 'center', padding: spacing.xl, }, + loadStateSignOut: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + marginTop: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + loadStateSignOutText: { color: colors.mutedText, fontWeight: '600' }, retry: { backgroundColor: colors.text, borderRadius: 8, @@ -287,14 +280,5 @@ const styles = StyleSheet.create({ paddingVertical: spacing.sm, }, retryText: { color: colors.onPrimary, fontWeight: '700' }, - signOutButton: { - borderColor: colors.border, - borderRadius: 8, - borderWidth: 1, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - }, - signOutButtonPressed: { opacity: 0.7 }, - signOutText: { color: colors.text, fontSize: 14, fontWeight: '600' }, title: { color: colors.text, fontSize: 22, fontWeight: '700' }, }); diff --git a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx index ef3059b..30ad8ef 100644 --- a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import Svg, { Path } from 'react-native-svg'; import { colors, spacing } from '../../../shared/ui/theme'; import type { @@ -20,22 +21,32 @@ const SELECTED_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', { weekday: 'long', }); -export function ScheduleCalendarScreen({ - service, - accountId, - timezone, - refreshSignal, -}: { +interface ScheduleCalendarScreenProps { service: ScheduleCalendarReadService; accountId: string; timezone: string; + username: string; + onSignOut: () => void | Promise; + isSigningOut?: boolean; /** 外部触发刷新用(比如语音写完一条日程);变化即重取,不用管具体数值。 */ refreshSignal?: number; -}) { +} + +export function ScheduleCalendarScreen({ + service, + accountId, + timezone, + username, + onSignOut, + isSigningOut = false, + refreshSignal, +}: ScheduleCalendarScreenProps) { const calendar = useScheduleCalendar(service, accountId, timezone, undefined, refreshSignal); const [selectedOccurrence, setSelectedOccurrence] = useState(null); const [selectedLocation, setSelectedLocation] = useState(null); const selectedLabel = SELECTED_DATE_FORMATTER.format(calendar.selectedDate); + const displayUsername = username.trim() || '用户'; + const avatarInitial = Array.from(displayUsername)[0]?.toLocaleUpperCase() ?? '用'; return ( @@ -46,8 +57,45 @@ export function ScheduleCalendarScreen({ > - 我的日程 - {selectedLabel} + + 我的日程 + + + + {avatarInitial} + + + {displayUsername} + + + void onSignOut()} + style={({ pressed }) => [ + styles.signOutButton, + pressed && !isSigningOut && styles.signOutButtonPressed, + ]} + > + {isSigningOut ? ( + + ) : ( + + )} + + + + + {selectedLabel} + + + + ); +} + const styles = StyleSheet.create({ + accountActions: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + gap: spacing.sm, + justifyContent: 'flex-end', + marginLeft: spacing.sm, + maxWidth: 240, + minWidth: 0, + }, agenda: { paddingHorizontal: spacing.md, paddingTop: spacing.xl }, + avatar: { + alignItems: 'center', + backgroundColor: colors.accent, + borderRadius: 999, + height: 28, + justifyContent: 'center', + width: 28, + }, + avatarText: { color: colors.text, fontSize: 12, fontWeight: '800' }, center: { alignItems: 'center', backgroundColor: colors.surface, @@ -175,6 +256,12 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.lg, paddingTop: spacing.xl, }, + headerTop: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + minWidth: 0, + }, locationSection: { borderTopColor: colors.border, borderTopWidth: StyleSheet.hairlineWidth, @@ -199,6 +286,34 @@ const styles = StyleSheet.create({ paddingBottom: 14, }, sectionTitle: { color: colors.text, fontSize: 20, fontWeight: '800' }, + signOutButton: { + alignItems: 'center', + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 18, + borderWidth: 1, + flexShrink: 0, + height: 36, + justifyContent: 'center', + width: 36, + }, + signOutButtonPressed: { opacity: 0.62 }, stateText: { color: colors.mutedText }, title: { color: colors.text, fontSize: 28, fontWeight: '800', lineHeight: 34, marginTop: 4 }, + userPill: { + alignItems: 'center', + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 18, + borderWidth: 1, + flexDirection: 'row', + flexShrink: 1, + gap: spacing.sm, + maxWidth: 196, + minWidth: 0, + paddingHorizontal: 4, + paddingRight: 10, + paddingVertical: 3, + }, + username: { color: colors.text, flexShrink: 1, fontSize: 13, fontWeight: '700', minWidth: 0 }, }); diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index cf3725d..018344b 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -12,7 +12,10 @@ interface HomeScreenProps { continuousApplication: AssistantApplicationPort; scheduleService: ScheduleCalendarReadService; accountId: string; + isSigningOut: boolean; + onSignOut: () => Promise; timezone: string; + username: string; } /** @@ -25,7 +28,10 @@ export function HomeScreen({ continuousApplication, scheduleService, accountId, + isSigningOut, + onSignOut, timezone, + username, }: HomeScreenProps) { const { lastAppliedCommand: pttCommand } = useAssistantConversation(pushToTalkApplication); const { lastAppliedCommand: callCommand } = useAssistantConversation(continuousApplication); @@ -54,9 +60,12 @@ export function HomeScreen({ ({ SqliteScheduleClientService: jest.fn(), })); jest.mock('../../../src/features/schedule/presentation/ScheduleCalendarScreen', () => ({ - ScheduleCalendarScreen: () => { - const { Text } = jest.requireActual('react-native') as typeof import('react-native'); - return 日程日历; + ScheduleCalendarScreen: ({ + isSigningOut, + onSignOut, + username, + }: { + isSigningOut: boolean; + onSignOut: () => Promise; + username: string; + }) => { + const { Pressable, Text, View } = jest.requireActual( + 'react-native', + ) as typeof import('react-native'); + return ( + + 日程日历 + {username} + void onSignOut()} + /> + + ); }, })); // 语音助手这几个真实实现都要接原生模块(麦克风/播放/定位),测试环境没有对应的 @@ -95,7 +116,8 @@ describe('AppRoot', () => { }); await waitFor(() => expect(screen.getByText('日程日历')).toBeTruthy()); - expect(screen.getByText('账号:timeflow_user')).toBeTruthy(); + expect(screen.getByText('timeflow_user')).toBeTruthy(); + expect(screen.queryByText(/账号:/)).toBeNull(); expect(screen.queryByText(/acc_001/)).toBeNull(); expect(screen.queryByText('登录或注册')).toBeNull(); expect(screen.queryByText('opaque-token')).toBeNull(); @@ -110,7 +132,8 @@ describe('AppRoot', () => { }); render(); - await screen.findByText('账号:restored_user'); + await screen.findByText('restored_user'); + expect(screen.queryByText(/账号:/)).toBeNull(); expect(screen.queryByText(/acc_internal_001/)).toBeNull(); expect(screen.queryByText('opaque-token')).toBeNull(); }); @@ -132,6 +155,42 @@ describe('AppRoot', () => { await waitFor(() => expect(mockedOpenTimeflowDatabase).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.getByText('日程日历')).toBeTruthy()); + expect(screen.getByText('timeflow_user')).toBeTruthy(); + expect(screen.queryByText(/账号:/)).toBeNull(); + }); + + it('can sign out when SQLite initialization fails', async () => { + mockedOpenTimeflowDatabase.mockRejectedValue(new Error('database unavailable')); + const controller = createController({ + accountId: 'acc_001', + accessToken: 'opaque-token', + expiresAt: 200_000, + username: 'timeflow_user', + }); + render(); + + await screen.findByText('本地日程存储初始化失败'); + fireEvent.press(screen.getByRole('button', { name: '退出登录' })); + + await waitFor(() => expect(screen.getByText('登录或注册')).toBeTruthy()); + expect(controller.getViewState()).toEqual({ status: 'unauthenticated' }); + }); + + it('can sign out while SQLite initialization is pending', async () => { + mockedOpenTimeflowDatabase.mockImplementation(() => new Promise(() => undefined)); + const controller = createController({ + accountId: 'acc_001', + accessToken: 'opaque-token', + expiresAt: 200_000, + username: 'timeflow_user', + }); + render(); + + await screen.findByText('正在准备日程'); + fireEvent.press(screen.getByRole('button', { name: '退出登录' })); + + await waitFor(() => expect(screen.getByText('登录或注册')).toBeTruthy()); + expect(controller.getViewState()).toEqual({ status: 'unauthenticated' }); }); it('signs out from the authenticated account shell', async () => { diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index 685214a..7c2f698 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { describe, expect, it, jest } from '@jest/globals'; +import { StyleSheet } from 'react-native'; import type { ScheduleCalendarReadService } from '../../../../../src/features/schedule/application'; import { ScheduleCalendarScreen } from '../../../../../src/features/schedule/presentation/ScheduleCalendarScreen'; @@ -33,7 +34,13 @@ describe('ScheduleCalendarScreen location schedules', () => { const service = createService(); const accountId = 'internal-account-id-not-for-display'; render( - , + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, ); await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalled()); @@ -44,10 +51,74 @@ describe('ScheduleCalendarScreen location schedules', () => { expect(screen.queryByText(accountId)).toBeNull(); }); + it('reloads calendar queries when refreshSignal changes', async () => { + const service = createService(); + const props = { + accountId: 'account-a', + onSignOut: () => {}, + service, + timezone: 'Asia/Shanghai', + username: 'Sarah', + }; + const view = render(); + + await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalledTimes(1)); + + view.rerender(); + + await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalledTimes(2)); + expect(service.getSchedulesByRange).toHaveBeenLastCalledWith( + expect.objectContaining({ accountId: 'account-a' }), + ); + expect(service.getLocationSchedules).toHaveBeenLastCalledWith({ accountId: 'account-a' }); + }); + + it('renders compact account controls, truncates a long username, and signs out', async () => { + const service = createService(); + const onSignOut = jest.fn<() => void>(); + const username = 'zhangsan-with-an-extremely-long-account-name'; + render( + , + ); + + await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalled()); + expect(screen.getByText('我的日程')).toBeTruthy(); + expect(screen.getByText('Z')).toBeTruthy(); + expect(screen.getByText(username)).toBeTruthy(); + expect(screen.queryByText(/账号:/)).toBeNull(); + expect(screen.getByTestId('schedule-account-username').props).toMatchObject({ + ellipsizeMode: 'tail', + numberOfLines: 1, + }); + expect( + StyleSheet.flatten(screen.getByTestId('schedule-account-username').props.style), + ).toMatchObject({ flexShrink: 1, minWidth: 0 }); + expect( + StyleSheet.flatten(screen.getByTestId('schedule-account-actions').props.style), + ).toMatchObject({ maxWidth: 240, minWidth: 0 }); + + fireEvent.press(screen.getByRole('button', { name: '退出登录' })); + expect(onSignOut).toHaveBeenCalledTimes(1); + }); + it('shows a location section that stays visible after selecting a day and changing month', async () => { const service = createService(); render( - , + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, ); await waitFor(() => expect(screen.getByText('地点提醒')).toBeTruthy()); @@ -70,7 +141,13 @@ describe('ScheduleCalendarScreen location schedules', () => { it('opens a read-only location detail with the configured fields', async () => { const service = createService(); render( - , + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, ); await waitFor(() => expect(screen.getByLabelText('公司 到公司提醒我打卡')).toBeTruthy()); diff --git a/frontend/tests/unit/screens/HomeScreen.test.tsx b/frontend/tests/unit/screens/HomeScreen.test.tsx new file mode 100644 index 0000000..7883e75 --- /dev/null +++ b/frontend/tests/unit/screens/HomeScreen.test.tsx @@ -0,0 +1,90 @@ +import { act, render, waitFor } from '@testing-library/react-native'; +import { describe, expect, it, jest } from '@jest/globals'; + +import type { AssistantApplicationPort } from '../../../src/features/assistant/application/AssistantApplication'; +import type { + AppliedCommand, + ConversationTurnState, +} from '../../../src/features/assistant/domain/ConversationTurn'; +import type { ScheduleCalendarReadService } from '../../../src/features/schedule/application'; +import { HomeScreen } from '../../../src/screens/HomeScreen'; + +jest.mock('../../../src/features/assistant/presentation/AssistantVoiceOverlay', () => ({ + AssistantVoiceOverlay: () => null, +})); + +class FakeAssistantApplication implements AssistantApplicationPort { + private command: AppliedCommand | null = null; + private readonly listeners = new Set<(state: ConversationTurnState) => void>(); + + apply(command: AppliedCommand) { + this.command = command; + for (const listener of this.listeners) listener({ phase: 'idle' }); + } + + dismissReply = async () => {}; + dispose = () => {}; + endTurn = async () => {}; + getLastAppliedCommand = () => this.command; + getReplyText = () => null; + getSoundLevel = () => null; + getState = (): ConversationTurnState => ({ phase: 'idle' }); + startTurn = async () => {}; + subscribe = (listener: (state: ConversationTurnState) => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; +} + +describe('HomeScreen calendar refresh', () => { + it('reloads account-scoped calendar data after a voice command is applied', async () => { + const pushToTalkApplication = new FakeAssistantApplication(); + const continuousApplication = new FakeAssistantApplication(); + const scheduleService: ScheduleCalendarReadService = { + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + getSchedulesByDay: jest + .fn() + .mockResolvedValue([]), + getSchedulesByRange: jest + .fn() + .mockResolvedValue([]), + }; + render( + {}} + pushToTalkApplication={pushToTalkApplication} + scheduleService={scheduleService} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(scheduleService.getSchedulesByRange).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(1)); + + act(() => { + pushToTalkApplication.apply({ operation: 'update_schedule', status: 'applied' }); + }); + + await waitFor(() => expect(scheduleService.getSchedulesByRange).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(2)); + + act(() => { + continuousApplication.apply({ operation: 'create_schedule', status: 'applied' }); + }); + + await waitFor(() => expect(scheduleService.getSchedulesByRange).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(3)); + expect(scheduleService.getSchedulesByRange).toHaveBeenLastCalledWith( + expect.objectContaining({ accountId: 'account-a' }), + ); + expect(scheduleService.getLocationSchedules).toHaveBeenLastCalledWith({ + accountId: 'account-a', + }); + }); +});