diff --git a/backend/src/timeflow/intelligence/realtime/instructions.py b/backend/src/timeflow/intelligence/realtime/instructions.py index 6b70ffdb..0640fbf6 100644 --- a/backend/src/timeflow/intelligence/realtime/instructions.py +++ b/backend/src/timeflow/intelligence/realtime/instructions.py @@ -45,7 +45,7 @@ - 没提到地点,就当时间型日程处理,不要为了填 latitude/longitude 去调用 location_search 或编一个地点——地点型日程的地点仍然必须问清楚,这条只管时间型日程不要凭空加地点。 - 没说提醒方式,默认建一个 reminder_strength 为 medium 的提醒:非全天日程用 - reminder_type=before_start、reminder_offset_minutes=200(开始前 200 分钟);全天日程用 + reminder_type=before_start、reminder_offset_minutes=15(开始前 15 分钟);全天日程用 reminder_type=at_time、reminder_trigger_at 填当天上午 10:00(带时区偏移)。 地点怎么定 diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index 32850331..86a775c2 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -393,7 +393,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat case 'voice.dialogue.question': // 缺字段/地点歧义之类的追问,对当前这轮来说就是系统的回复——记进历史, // 不然标题过了这一阵子就变回通用文案,这句追问在记录里再也找不到。 - this.updateLastTurnReply(message.payload.speech_text); + this.updateLastTurnReply(message.request_id, message.payload.speech_text); this.setState({ conversationId: message.conversation_id, phase: 'asking', @@ -402,7 +402,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat return; case 'voice.dialogue.reply': this.replyText = message.payload.speech_text; - this.updateLastTurnReply(message.payload.speech_text); + this.updateLastTurnReply(message.request_id, message.payload.speech_text); this.notifyListeners(); return; case 'voice.tts.start': @@ -594,15 +594,21 @@ export class AssistantContinuousConversationService implements AssistantApplicat return this.connection; } - /** speech_text 是累计到目前为止的完整文字(不是增量),直接覆盖最后一轮即可。 - * 没有轮次可更新时(理论上不会发生,reply 总跟在 asr.completed 后面)不做 - * 任何事,不新建一条没有 transcript 的记录。 */ - private updateLastTurnReply(replyText: string): void { + /** speech_text 是累计到目前为止的完整文字(不是增量),直接覆盖对应轮次即可。 + * 必须按 request_id 找到它真正所属的那一轮,不能想当然地假设"最后一轮"—— + * 麦克风连续开着,用户可能在上一轮的回复还没到达前就已经开口问了下一句, + * asr.completed 先把新一轮 push 进 turns,这条迟到的回复到达时"最后一轮" + * 已经变成了下一轮,会把上一轮的回复错记成下一轮的(下一轮回复还没来时 + * 显示的会是上一轮内容)。找不到匹配 id(服务端没带 request_id 这种理论上 + * 不该发生的情况)时退化成更新最后一轮,好歹不丢内容。 */ + private updateLastTurnReply(requestId: string | undefined, replyText: string): void { if (this.turns.length === 0) { return; } - const last = this.turns[this.turns.length - 1]; - this.turns = [...this.turns.slice(0, -1), { ...last, replyText }]; + const targetIndex = + requestId !== undefined ? this.turns.findIndex((turn) => turn.id === requestId) : -1; + const index = targetIndex === -1 ? this.turns.length - 1 : targetIndex; + this.turns = this.turns.map((turn, i) => (i === index ? { ...turn, replyText } : turn)); } private armIdleTimer(): void { diff --git a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx index 6ac2ac02..af75f348 100644 --- a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx +++ b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx @@ -2,6 +2,10 @@ import { useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + FLOATING_VOICE_BAR_HEIGHT, + floatingVoiceBarBottomOffset, +} from '../../../shared/ui/floatingVoiceBarLayout'; import { colors, spacing } from '../../../shared/ui/theme'; import type { AssistantApplicationPort } from '../application/AssistantApplication'; import type { ConversationTurnState } from '../domain/ConversationTurn'; @@ -121,11 +125,19 @@ export function AssistantVoiceOverlay({ } return ( - - + + {ptt.replyText ? ( + + ) : null} + {ptt.replyText ? ( {ptt.replyText} @@ -170,14 +182,6 @@ const styles = StyleSheet.create({ gap: spacing.sm, width: '100%', }, - container: { - backgroundColor: colors.surface, - borderTopColor: colors.border, - borderTopWidth: StyleSheet.hairlineWidth, - paddingHorizontal: spacing.lg, - paddingTop: spacing.sm, - width: '100%', - }, bubble: { backgroundColor: colors.surface, borderColor: colors.border, @@ -195,7 +199,7 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: colors.text, borderRadius: 999, - height: 52, + height: FLOATING_VOICE_BAR_HEIGHT, justifyContent: 'center', width: 52, }, @@ -207,6 +211,9 @@ const styles = StyleSheet.create({ }, overlay: { alignItems: 'center', - width: '100%', + left: 0, + paddingHorizontal: spacing.lg, + position: 'absolute', + right: 0, }, }); diff --git a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx index 5d898c4b..a1a04d28 100644 --- a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx +++ b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Animated, Easing, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import { FLOATING_VOICE_BAR_HEIGHT } from '../../../shared/ui/floatingVoiceBarLayout'; import { colors, spacing } from '../../../shared/ui/theme'; const WAVE_BAR_HEIGHTS = [10, 16, 22, 16, 10] as const; @@ -101,12 +102,10 @@ function normalizeLevel(dbfs: number | null): number { const styles = StyleSheet.create({ bar: { alignItems: 'center', - backgroundColor: colors.input, - borderColor: colors.border, - borderRadius: 14, - borderWidth: 1, + backgroundColor: colors.text, + borderRadius: 999, flex: 1, - height: 52, + height: FLOATING_VOICE_BAR_HEIGHT, justifyContent: 'center', }, barActive: { @@ -119,12 +118,12 @@ const styles = StyleSheet.create({ opacity: 0.86, }, label: { - color: colors.text, + color: colors.onPrimary, fontSize: 14, fontWeight: '600', }, labelDisabled: { - color: colors.mutedText, + color: colors.onPrimary, }, wave: { alignItems: 'center', diff --git a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx index 7a601fc1..29398549 100644 --- a/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx +++ b/frontend/src/features/assistant/presentation/VoiceCallScreen.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; import { Animated, Easing, @@ -9,6 +10,7 @@ import { Text, View, } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors, spacing } from '../../../shared/ui/theme'; import type { ConversationTurnRecord } from '../domain/ConversationTurn'; @@ -27,6 +29,7 @@ interface VoiceCallScreenProps { const BREATH_SCALE = { duration: 1600, from: 1, to: 1.06 }; const TALK_SCALE = { duration: 650, from: 1, to: 1.14 }; +const STICK_TO_BOTTOM_THRESHOLD = 48; /** * 免提通话的沉浸式全屏层:主体是一份可回看的完整问答记录(每轮一条用户话 @@ -44,8 +47,14 @@ export function VoiceCallScreen({ onEnd, onTogglePause, }: VoiceCallScreenProps) { + const insets = useSafeAreaInsets(); const [scale] = useState(() => new Animated.Value(1)); + const [statusRowHeight, setStatusRowHeight] = useState(0); const historyRef = useRef(null); + // 回复是流式的(累计文字每收到一段就整段刷新一次),跟着一路自动滚会跟 + // 用户手动上滑打架——用户一滑走就不再强制拉回底部,直到他自己滑回底部 + // 附近才恢复跟随,不然会出现看似“滑不动”的情况。 + const stickToBottomRef = useRef(true); useEffect(() => { scale.stopAnimation(); @@ -75,24 +84,58 @@ export function VoiceCallScreen({ return undefined; }, [status, scale]); + // 只在用户真的拖动结束时才重新判断是否贴底——不能用 onScroll,流式内容 + // 一直在长,我们自己触发的 scrollToEnd 也会产生 onScroll 事件,那时候 + // contentSize 可能已经比滚动目标又长了一截,会被误判成“用户滑走了”, + // 之后就再也不会自动跟随,导致流式说完了还有一段没露出来。 + // onScrollEndDrag/onMomentumScrollEnd 只在手指真正划过之后才触发,不受 + // animated:false 的程序化跳转影响。 + function handleScrollSettled(event: NativeSyntheticEvent) { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height; + stickToBottomRef.current = distanceFromBottom <= STICK_TO_BOTTOM_THRESHOLD; + } + return ( - [styles.collapseButton, pressed && styles.buttonPressed]} + - - + [styles.collapseButton, pressed && styles.buttonPressed]} + > + + + historyRef.current?.scrollToEnd({ animated: true })} + onContentSizeChange={() => { + if (!stickToBottomRef.current) { + return; + } + // 延后两帧再滚:Android 上 onContentSizeChange 触发时原生 ScrollView + // 有时还没把新内容高度提交完,内容越高时越容易滚不到底。用 + // animated:false 直接跳到底,流式刷新很密集,动画会跟下一次 + // 内容变化互相打断、显得卡住不动。 + requestAnimationFrame(() => + requestAnimationFrame(() => historyRef.current?.scrollToEnd({ animated: false })), + ); + }} + onMomentumScrollEnd={handleScrollSettled} + onScrollEndDrag={handleScrollSettled} style={styles.history} > {turns.length > 0 ? ( @@ -109,7 +152,10 @@ export function VoiceCallScreen({ )} - + setStatusRowHeight(event.nativeEvent.layout.height)} + style={styles.body} + > - + item.scheduleId === selectedLocationId) ?? null; + const topSafeAreaPadding = Platform.OS === 'android' ? insets.top : 0; const selectedLabel = SELECTED_DATE_FORMATTER.format(calendar.selectedDate); const agendaTitle = formatAgendaSectionTitle(calendar.selectedDate); const emptyAgenda = emptyAgendaMessage(calendar.selectedDate); @@ -65,9 +77,16 @@ export function ScheduleCalendarScreen({ return ( diff --git a/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx b/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx index 997233ec..7f85117a 100644 --- a/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors, spacing } from '../../../shared/ui/theme'; @@ -14,6 +15,7 @@ export function ScheduleDetailSheet({ title: string; visible: boolean; }) { + const insets = useSafeAreaInsets(); return ( @@ -31,7 +33,14 @@ export function ScheduleDetailSheet({ × - + {title} diff --git a/frontend/src/shared/ui/floatingVoiceBarLayout.ts b/frontend/src/shared/ui/floatingVoiceBarLayout.ts new file mode 100644 index 00000000..5f29faa4 --- /dev/null +++ b/frontend/src/shared/ui/floatingVoiceBarLayout.ts @@ -0,0 +1,11 @@ +import { spacing } from './theme'; + +export const FLOATING_VOICE_BAR_HEIGHT = 52; + +export function floatingVoiceBarBottomOffset(bottomInset: number): number { + return Math.max(spacing.xl, bottomInset + spacing.md); +} + +export function floatingVoiceContentBottomInset(bottomInset: number): number { + return floatingVoiceBarBottomOffset(bottomInset) + FLOATING_VOICE_BAR_HEIGHT + spacing.md; +} diff --git a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts index aff2c91d..9a1d105f 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -254,6 +254,40 @@ describe('AssistantContinuousConversationService', () => { ]); }); + it('attaches a late reply to the turn it answers, not whichever turn is now last', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = createService(deps); + + await startListening(fake, service); + fake.emitMessage({ + conversation_id: 'conv_001', + request_id: 'req_1', + payload: { duration_ms: 800, language: 'zh', transcript: '明天几点开会' }, + type: 'voice.asr.completed', + } as AssistantServerMessage); + // 用户没等 req_1 的回复就开口问了下一句,新一轮先落地成了 turns 里最后一条。 + fake.emitMessage({ + conversation_id: 'conv_001', + request_id: 'req_2', + payload: { duration_ms: 500, language: 'zh', transcript: '谁参加' }, + type: 'voice.asr.completed', + } as AssistantServerMessage); + // req_1 的回复这时候才迟到到达。 + fake.emitMessage({ + conversation_id: 'conv_001', + request_id: 'req_1', + payload: { done: true, reply_id: 'reply_1', speech_text: '明天下午三点' }, + type: 'voice.dialogue.reply', + } as AssistantServerMessage); + await flushAsync(); + + expect(service.getTurns()).toEqual([ + { id: 'req_1', replyText: '明天下午三点', transcript: '明天几点开会' }, + { id: 'req_2', replyText: null, transcript: '谁参加' }, + ]); + }); + it.each([ ['missing_field', '你是想订哪一天的会议室?'], ['ambiguous_target', '你是指三楼小会议室还是五楼大会议室?'], diff --git a/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx index 41f61c80..25055216 100644 --- a/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx @@ -11,7 +11,7 @@ jest.mock('../../../../../src/features/assistant/presentation/useAssistantConver dismissReply: mockDismissReply, endTurn: application.endTurn, lastAppliedCommand: null, - replyText: application === mockPttApplication ? '已创建' : null, + replyText: application === mockPttApplication ? mockReplyText : null, soundLevel: null, startTurn: application.startTurn, state: application === mockPttApplication ? { phase: 'idle' as const } : mockCallState, @@ -25,6 +25,7 @@ jest.mock('react-native-safe-area-context', () => ({ let mockPttApplication: AssistantApplicationPort; let mockBottomInset = 0; +let mockReplyText: string | null = '已创建'; let mockCallState: ConversationTurnState = { phase: 'idle' }; const mockDismissReply = jest.fn(); @@ -45,6 +46,7 @@ function createApplication(): AssistantApplicationPort { describe('AssistantVoiceOverlay layout', () => { beforeEach(() => { mockBottomInset = 0; + mockReplyText = '已创建'; mockCallState = { phase: 'idle' }; mockDismissReply.mockClear(); }); @@ -53,7 +55,7 @@ describe('AssistantVoiceOverlay layout', () => { mockCallState = { phase: 'idle' }; }); - it('does not add a fullscreen reply dismiss target over the calendar', () => { + it('dismisses the reply from the bubble or surrounding overlay', () => { mockPttApplication = createApplication(); const continuousApplication = createApplication(); render( @@ -63,7 +65,8 @@ describe('AssistantVoiceOverlay layout', () => { />, ); - expect(screen.queryByLabelText('关闭回复')).toBeNull(); + fireEvent.press(screen.getByLabelText('关闭回复')); + expect(mockDismissReply).toHaveBeenCalledTimes(1); expect(screen.getByText('按住说话')).toBeTruthy(); }); @@ -81,10 +84,26 @@ describe('AssistantVoiceOverlay layout', () => { expect(mockDismissReply).toHaveBeenCalledTimes(1); }); + it('renders the controls without a reply bubble when there is no reply', () => { + mockReplyText = null; + mockPttApplication = createApplication(); + const continuousApplication = createApplication(); + + render( + , + ); + + expect(screen.queryByText('已创建')).toBeNull(); + expect(screen.getByText('按住说话')).toBeTruthy(); + }); + it.each([ - ['keeps the design spacing when the inset is smaller', 8, 16], - ['keeps the voice controls above the system navigation area', 34, 34], - ])('%s', (_name, bottomInset, expectedPadding) => { + ['keeps a comfortable offset on devices with a small inset', 8, 32], + ['moves controls above the system navigation area', 34, 50], + ])('%s', (_name, bottomInset, expectedBottom) => { mockBottomInset = bottomInset; mockPttApplication = createApplication(); const continuousApplication = createApplication(); @@ -96,8 +115,10 @@ describe('AssistantVoiceOverlay layout', () => { ); expect( - StyleSheet.flatten(screen.getByTestId('assistant-voice-overlay').props.style).paddingBottom, - ).toBe(expectedPadding); + StyleSheet.flatten(screen.getByTestId('assistant-voice-controls').props.style), + ).toMatchObject({ + bottom: expectedBottom, + }); }); it('shows only a generic status label while replying, never the reply content', () => { @@ -116,6 +137,22 @@ describe('AssistantVoiceOverlay layout', () => { expect(screen.getByText('回答中…')).toBeTruthy(); }); + it('starts the continuous conversation when entering from idle', () => { + mockPttApplication = createApplication(); + const startTurn = jest.fn(async () => {}); + const continuousApplication = { ...createApplication(), startTurn }; + render( + , + ); + + fireEvent.press(screen.getByLabelText('进入免提通话')); + + expect(startTurn).toHaveBeenCalledTimes(1); + }); + it('shows a generic "已打断" label when the reply is interrupted', () => { mockPttApplication = createApplication(); const continuousApplication = createApplication(); diff --git a/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx b/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx index a6b64cdc..064b3903 100644 --- a/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx @@ -5,7 +5,7 @@ import { StyleSheet } from 'react-native'; import { PushToTalkBar } from '../../../../../src/features/assistant/presentation/PushToTalkBar'; describe('PushToTalkBar layout', () => { - it('uses a light bordered input treatment in the idle state', () => { + it('uses a dark pill treatment in the idle state', () => { render( { const button = screen.getByRole('button'); expect(StyleSheet.flatten(button.props.style)).toMatchObject({ - backgroundColor: '#F0F2EE', - borderWidth: 1, + backgroundColor: '#12352D', + borderRadius: 999, }); }); }); diff --git a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx index 48c735d5..732a40d2 100644 --- a/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/VoiceCallScreen.test.tsx @@ -1,9 +1,16 @@ import { afterEach, describe, expect, it, jest } from '@jest/globals'; -import { fireEvent, render, screen } from '@testing-library/react-native'; -import { ScrollView } from 'react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { ScrollView, StyleSheet } from 'react-native'; import { VoiceCallScreen } from '../../../../../src/features/assistant/presentation/VoiceCallScreen'; +let mockTopInset = 0; +let mockBottomInset = 0; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: mockTopInset }), +})); + function renderScreen(overrides: Partial[0]> = {}) { const props = { onCollapse: jest.fn(), @@ -20,6 +27,30 @@ function renderScreen(overrides: Partial[0]> describe('VoiceCallScreen', () => { afterEach(() => { jest.useRealTimers(); + mockTopInset = 0; + mockBottomInset = 0; + }); + + it.each([ + ['keeps the default top padding on devices with a small top inset', 4, 16], + ['pads the collapse button below a notch or status bar', 44, 44], + ])('%s', (_name, topInset, expectedPaddingTop) => { + mockTopInset = topInset; + renderScreen(); + expect( + StyleSheet.flatten(screen.getByTestId('voice-call-navigation').props.style), + ).toMatchObject({ paddingTop: expectedPaddingTop }); + }); + + it.each([ + ['keeps the default bottom padding on devices with a small home indicator', 4, 32], + ['moves the call actions above the home indicator / gesture nav area', 34, 50], + ])('%s', (_name, bottomInset, expectedPaddingBottom) => { + mockBottomInset = bottomInset; + renderScreen(); + expect(StyleSheet.flatten(screen.getByTestId('voice-call-actions').props.style)).toMatchObject({ + paddingBottom: expectedPaddingBottom, + }); }); it('shows the status title', () => { @@ -72,11 +103,17 @@ describe('VoiceCallScreen', () => { expect(screen.getByText('对话开始后,这里会显示完整记录')).toBeTruthy(); }); - it('scrolls the history area when its content grows', () => { + it('scrolls the history area when its content grows', async () => { renderScreen({ turns: [{ id: 't1', replyText: null, transcript: '明天几点开会' }] }); const history = screen.UNSAFE_getByType(ScrollView); expect(history.props.onContentSizeChange).toEqual(expect.any(Function)); - history.props.onContentSizeChange(); + await act(async () => { + history.props.onContentSizeChange(); + // 滚动被延后了两帧,测试要等这两帧都跑完再收尾,不然回调会在 + // jest 环境已经卸载之后才触发。 + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); }); }); diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index ab99107d..8f3166b0 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -1,6 +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 { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { Platform, StyleSheet } from 'react-native'; import type { ScheduleCalendarReadService, @@ -8,6 +8,13 @@ import type { } from '../../../../../src/features/schedule/application'; import { ScheduleCalendarScreen } from '../../../../../src/features/schedule/presentation/ScheduleCalendarScreen'; +let mockBottomInset = 0; +let mockTopInset = 0; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: mockTopInset }), +})); + function occurrenceOnSelectedDay( hourUtc: number, overrides: Partial = {}, @@ -59,6 +66,112 @@ function createService( } describe('ScheduleCalendarScreen location schedules', () => { + beforeEach(() => { + Platform.OS = 'ios'; + }); + + afterEach(() => { + mockBottomInset = 0; + mockTopInset = 0; + Platform.OS = 'ios'; + }); + + it('leaves enough scroll space for the floating voice controls and system navigation, and keeps the last occurrence reachable', async () => { + mockBottomInset = 34; + const service = createService(); + // 补上真正渲染出来的最后一项日程,不能只断言算出来的 padding 数字—— + // 那个数字本身不能证明这一项没被浮动语音条挡住、还是可以点开的。 + // selectedOccurrences 来自 getSchedulesByRange(不是 getSchedulesByDay), + // 按当前选中日期在客户端分组,所以要选到日程所在的那一天才会渲染出来。 + ( + service.getSchedulesByRange as jest.MockedFunction< + ScheduleCalendarReadService['getSchedulesByRange'] + > + ).mockResolvedValue([ + { + scheduleId: 'schedule-last', + scheduleCategory: 'time', + category: null, + recurrenceMode: 'once', + title: '当日最后一条日程', + isAllDay: false, + timezone: 'Asia/Shanghai', + locationName: null, + reminderType: null, + reminderStrength: null, + occurrenceStart: '2026-08-13T09:00:00.000Z', + occurrenceEnd: '2026-08-13T10:00:00.000Z', + }, + ]); + render( + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); + expect( + StyleSheet.flatten( + screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, + ), + ).toMatchObject({ paddingBottom: 118, paddingTop: 0 }); + + fireEvent.press(screen.getByLabelText(/月13日$/)); + const lastRow = await screen.findByLabelText(/当日最后一条日程$/); + fireEvent.press(lastRow); + // 点开之后详情抽屉真的弹出来了,证明这一行不只是渲染出来、还真的可以点击响应, + // 不是被浮动语音条盖住了个摆设。 + expect(screen.getByText('日程详情')).toBeTruthy(); + }); + + it('avoids the Android status bar in edge-to-edge mode', async () => { + Platform.OS = 'android'; + mockTopInset = 24; + const service = createService(); + render( + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); + expect( + StyleSheet.flatten( + screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, + ), + ).toMatchObject({ paddingTop: 24 }); + }); + + it('leaves iOS to its own automatic safe-area adjustment instead of double-padding the top', async () => { + Platform.OS = 'ios'; + mockTopInset = 44; + const service = createService(); + render( + {}} + service={service} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); + expect( + StyleSheet.flatten( + screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, + ), + ).toMatchObject({ paddingTop: 0 }); + }); + it('keeps accountId in the calendar data flow without rendering it', async () => { const service = createService(); const accountId = 'internal-account-id-not-for-display'; diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx index e13ca8f7..64b59f25 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react-native'; import { describe, expect, it, jest } from '@jest/globals'; -import { Modal } from 'react-native'; +import { Modal, StyleSheet } from 'react-native'; import type { LocationScheduleView, @@ -9,6 +9,12 @@ import type { import { LocationScheduleDetailSheet } from '../../../../../src/features/schedule/presentation/LocationScheduleDetailSheet'; import { ScheduleOccurrenceDetailSheet } from '../../../../../src/features/schedule/presentation/ScheduleOccurrenceDetailSheet'; +let mockBottomInset = 0; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: 0 }), +})); + const timedOccurrence: ScheduleOccurrenceView = { scheduleId: 'schedule-a', scheduleCategory: 'time', @@ -40,6 +46,17 @@ const allDayOccurrence: ScheduleOccurrenceView = { }; describe('schedule detail sheets', () => { + it('keeps detail content above the system navigation area', () => { + mockBottomInset = 34; + render( {}} />); + + const content = screen.getByTestId('schedule-detail-content'); + expect(StyleSheet.flatten(content.props.contentContainerStyle)).toMatchObject({ + paddingBottom: 50, + }); + mockBottomInset = 0; + }); + it('prioritizes occurrence date and time while retaining optional information', () => { render( {}} />); diff --git a/frontend/tests/unit/screens/HomeScreen.test.tsx b/frontend/tests/unit/screens/HomeScreen.test.tsx index 996f6f41..463a624e 100644 --- a/frontend/tests/unit/screens/HomeScreen.test.tsx +++ b/frontend/tests/unit/screens/HomeScreen.test.tsx @@ -9,6 +9,10 @@ import type { import type { ScheduleCalendarReadService } from '../../../src/features/schedule/application'; import { HomeScreen } from '../../../src/screens/HomeScreen'; +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }), +})); + jest.mock('../../../src/features/assistant/presentation/AssistantVoiceOverlay', () => ({ AssistantVoiceOverlay: () => null, }));