Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react-native';

import { AssistantChatSheet } from '@/features/assistant/components/AssistantChatSheet';

describe('AssistantChatSheet', () => {
it('shows empty state and closes', () => {
const onClose = jest.fn();
render(
<AssistantChatSheet
messages={[]}
onAction={jest.fn()}
onClose={onClose}
onVoiceEnd={jest.fn()}
visible
/>,
);
expect(screen.getByText('等你说第一句话')).toBeTruthy();
fireEvent.press(screen.getAllByLabelText('关闭语音助手')[0]!);
expect(onClose).toHaveBeenCalled();
});

it('renders user, draft and assistant messages', () => {
const onAction = jest.fn();
render(
<AssistantChatSheet
visible
onAction={onAction}
onClose={jest.fn()}
onVoiceEnd={jest.fn()}
messages={[
{ id: 'u1', role: 'user', createdAt: 1, text: '明天下午开会' },
{
id: 'd1',
role: 'assistant',
createdAt: 2,
draft: { title: '开会', whenLabel: '明天 15:00', state: 'pending' },
actions: [{ id: 'ok', kind: 'confirm', label: '加入' }],
},
{ id: 'a1', role: 'assistant', createdAt: 3, text: '已记下' },
]}
/>,
);
expect(screen.getByText('明天下午开会')).toBeTruthy();
expect(screen.getByText('开会')).toBeTruthy();
expect(screen.getByText('已记下')).toBeTruthy();
fireEvent.press(screen.getByLabelText('加入'));
expect(onAction).toHaveBeenCalledWith('d1', { id: 'ok', kind: 'confirm', label: '加入' });
});

it('hides when not visible', () => {
render(
<AssistantChatSheet
messages={[]}
onAction={jest.fn()}
onClose={jest.fn()}
onVoiceEnd={jest.fn()}
visible={false}
/>,
);
expect(screen.queryByText('语音助手')).toBeNull();
});

it('shows a processing state after recording is released', () => {
render(
<AssistantChatSheet
isProcessing
messages={[]}
onAction={jest.fn()}
onClose={jest.fn()}
onVoiceEnd={jest.fn()}
visible
/>,
);
expect(screen.getByText('正在整理录音…')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react-native';

import { AssistantDock } from '@/features/assistant/components/AssistantDock';

jest.mock('@/features/assistant/components/VoiceHoldButton', () => ({
VoiceHoldButton: ({ onPress }: { onPress?: () => void }) => {
const { Pressable, Text } = require('react-native');
return (
<Pressable accessibilityLabel="mock-open-assistant" onPress={onPress}>
<Text>voice-hold</Text>
</Pressable>
);
},
}));

describe('AssistantDock', () => {
it('hides when requested', () => {
const { queryByText } = render(
<AssistantDock hidden onOpen={jest.fn()} onVoiceEnd={jest.fn()} />,
);
expect(queryByText('voice-hold')).toBeNull();
});

it('shows the voice control when visible', () => {
const onOpen = jest.fn();
render(<AssistantDock onOpen={onOpen} onVoiceEnd={jest.fn()} />);
fireEvent.press(screen.getByLabelText('mock-open-assistant'));
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react-native';

import type { AssistantDraft } from '@/features/assistant/types';
import { AssistantDraftCard } from '@/features/assistant/components/AssistantDraftCard';

const draft = (overrides: Partial<AssistantDraft> = {}): AssistantDraft => ({
title: '下午评审',
whenLabel: '今天 15:00',
state: 'pending',
...overrides,
});

describe('AssistantDraftCard', () => {
it('shows actions while pending', () => {
const onAction = jest.fn();
render(
<AssistantDraftCard
draft={draft({ metaLabel: '会议室 A' })}
actions={[
{ id: 'ok', kind: 'confirm', label: '加入' },
{ id: 'no', kind: 'dismiss', label: '忽略' },
]}
onAction={onAction}
/>,
);
expect(screen.getByText('待确认')).toBeTruthy();
expect(screen.getByText('会议室 A')).toBeTruthy();
fireEvent.press(screen.getByLabelText('加入'));
expect(onAction).toHaveBeenCalledWith({ id: 'ok', kind: 'confirm', label: '加入' });
});

it('hides actions after the draft is resolved', () => {
render(
<AssistantDraftCard
draft={draft({ state: 'added' })}
actions={[{ id: 'ok', kind: 'confirm', label: '加入' }]}
onAction={jest.fn()}
/>,
);
expect(screen.getByText('已加入日程')).toBeTruthy();
expect(screen.queryByLabelText('加入')).toBeNull();
});

it('renders dismissed chip', () => {
render(<AssistantDraftCard draft={draft({ state: 'dismissed' })} onAction={jest.fn()} />);
expect(screen.getByText('已忽略')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from '@jest/globals';
import { render } from '@testing-library/react-native';

import { TempoAssistantIcon } from '@/features/assistant/components/TempoAssistantIcon';

describe('TempoAssistantIcon', () => {
it('renders with default and custom props', () => {
const { rerender, toJSON } = render(<TempoAssistantIcon />);
expect(toJSON()).toBeTruthy();
rerender(<TempoAssistantIcon color="#15352B" size={30} />);
expect(toJSON()).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react-native';
import { Vibration } from 'react-native';

import { VoiceHoldButton } from '@/features/assistant/components/VoiceHoldButton';

describe('VoiceHoldButton', () => {
it('renders the hold-to-talk control', () => {
render(<VoiceHoldButton onVoiceEnd={jest.fn()} onVoiceStart={jest.fn()} />);
});

it('opens the assistant on a tap without starting or ending audio', () => {
const onPress = jest.fn();
const onVoiceStart = jest.fn();
const onVoiceEnd = jest.fn();
render(
<VoiceHoldButton onPress={onPress} onVoiceEnd={onVoiceEnd} onVoiceStart={onVoiceStart} />,
);

fireEvent.press(screen.getByLabelText('轻点打开语音助手,按住说话,上滑取消'));

expect(onPress).toHaveBeenCalledTimes(1);
expect(onVoiceStart).not.toHaveBeenCalled();
expect(onVoiceEnd).not.toHaveBeenCalled();
});

it('starts on long press and ends when released', () => {
const vibrate = jest.spyOn(Vibration, 'vibrate').mockImplementation(() => undefined);
const onPress = jest.fn();
const onVoiceStart = jest.fn();
const onVoiceEnd = jest.fn();
render(
<VoiceHoldButton onPress={onPress} onVoiceEnd={onVoiceEnd} onVoiceStart={onVoiceStart} />,
);
const button = screen.getByLabelText('轻点打开语音助手,按住说话,上滑取消');
const event = { nativeEvent: { pageY: 200 } };

fireEvent(button, 'pressIn', event);
fireEvent(button, 'longPress', event);
fireEvent(button, 'pressOut', event);

expect(onVoiceStart).toHaveBeenCalledTimes(1);
expect(onVoiceEnd).toHaveBeenCalledTimes(1);
expect(onPress).not.toHaveBeenCalled();
expect(vibrate).toHaveBeenCalledTimes(1);
vibrate.mockRestore();
});

it('cancels a long press moved upward before release', () => {
const vibrate = jest.spyOn(Vibration, 'vibrate').mockImplementation(() => undefined);
const onVoiceCancel = jest.fn();
render(
<VoiceHoldButton
onVoiceCancel={onVoiceCancel}
onVoiceEnd={jest.fn()}
onVoiceStart={jest.fn()}
/>,
);
const button = screen.getByLabelText('按住说话,松开发送,上滑取消');

fireEvent(button, 'pressIn', { nativeEvent: { pageY: 200 } });
fireEvent(button, 'longPress', { nativeEvent: { pageY: 200 } });
fireEvent(button, 'touchMove', { nativeEvent: { pageY: 100 } });
fireEvent(button, 'touchMove', { nativeEvent: { pageY: 90 } });
fireEvent(button, 'pressOut', { nativeEvent: { pageY: 100 } });

expect(onVoiceCancel).toHaveBeenCalledTimes(1);
expect(vibrate).toHaveBeenCalledTimes(2);
vibrate.mockRestore();
});
});
21 changes: 21 additions & 0 deletions frontend/__tests__/features/assistant/data/VoiceStreamPort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from '@jest/globals';

import { FakeWsServer } from '@/dev/fakes/FakeWsServer';
import { WsVoiceStreamPort } from '@/features/assistant/data/VoiceStreamPort';
import { WsClient } from '@/infrastructure/ws/WsClient';

describe('WsVoiceStreamPort with FakeWsServer', () => {
it('correlates the final parse result to the start request', async () => {
const server = new FakeWsServer();
const client = new WsClient({ fakeHandler: server.handleMessage });
server.attach(client);
await client.connect();
const voice = new WsVoiceStreamPort(client);

const started = await voice.start();
const result = await voice.end(started.streamId, started.jobId, started.resultRequestId);

expect(result.draft.title).toBe('语音创建的日程');
client.close();
});
});
Loading
Loading