d3ro-voice/apps/mobile-rn/__tests__/templates-memos-screens.test.tsx
2026-08-29 18:33:45 +09:00

237 lines
7.9 KiB
TypeScript

import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { HistoryEntry, UserTemplate } from '@d3ro/api-client'
const mockLoadTemplateLibrary = jest.fn()
const mockSelectTemplate = jest.fn()
const mockSearchMemos = jest.fn()
const mockAddMemoTag = jest.fn()
jest.mock('@react-navigation/native', () => ({ useIsFocused: () => true }))
jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) }))
jest.mock('@d3ro/i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => params === undefined
? key
: `${key}:${JSON.stringify(params)}`,
formatDate: () => '2026-08-21',
}),
}))
jest.mock('../src/lib/auth-context', () => ({
useAuth: () => ({
user: { id: '11111111-1111-4111-8111-111111111111' },
session: null,
loading: false,
}),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: '#000', sidebar: '#111', card: '#181818', inset: '#202020' },
text: { primary: '#fff', muted: '#999' },
border: { default: '#444', subtle: '#333' },
accent: { main: '#7cf', dim: '#234' },
tag: { red: '#f66' },
},
}),
}))
jest.mock('../src/theme/themed-components', () => {
const ReactValue = require('react') as typeof React
const { Pressable, Text, View } = require('react-native') as typeof import('react-native')
return {
ThemeText: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<Text {...props}>{children}</Text>
),
ThemeCard: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<View {...props}>{children}</View>
),
ThemeButton: ({
label,
onPress,
disabled,
...props
}: {
label: string
onPress?: () => void
disabled?: boolean
[key: string]: unknown
}) => (
<Pressable {...props} disabled={disabled} onPress={onPress}>
<Text>{label}</Text>
</Pressable>
),
__ReactValue: ReactValue,
}
})
jest.mock('../src/features/templates', () => {
class MockTemplateServiceError extends Error {
code: string
constructor(mockCode: string, mockMessage: string) {
super(mockMessage)
this.code = mockCode
}
}
return {
TemplateServiceError: MockTemplateServiceError,
loadTemplateLibrary: (...args: unknown[]) => mockLoadTemplateLibrary(...args),
selectTemplate: (...args: unknown[]) => mockSelectTemplate(...args),
subscribeToTemplates: () => ({ unsubscribe: async () => undefined }),
createTemplate: jest.fn(),
updateTemplate: jest.fn(),
deleteTemplate: jest.fn(),
normalizeTemplateDraft: jest.fn(() => ({})),
}
})
jest.mock('../src/features/memos', () => {
class MockMemoServiceError extends Error {
code: string
constructor(mockCode: string, mockMessage: string) {
super(mockMessage)
this.code = mockCode
}
}
const normalizeMemoTag = (value: string) => {
const tag = value.trim().replace(/\s+/g, ' ')
if (tag.length === 0) throw new MockMemoServiceError('validation', 'invalid')
return { tag, normalizedTag: tag.toLowerCase() }
}
return {
MemoServiceError: MockMemoServiceError,
normalizeMemoTag,
searchMemos: (...args: unknown[]) => mockSearchMemos(...args),
addMemoTag: (...args: unknown[]) => mockAddMemoTag(...args),
removeMemoTag: jest.fn(async () => undefined),
renameMemoTag: jest.fn(async () => 1),
buildMemoShareText: jest.fn(() => 'share'),
subscribeToMemoTags: () => ({ unsubscribe: async () => undefined }),
}
})
import TemplatesScreen from '../src/screens/TemplatesScreen'
import MemosScreen from '../src/screens/MemosScreen'
import { TemplateServiceError } from '../src/features/templates'
import { MemoServiceError } from '../src/features/memos'
const USER_ID = '11111111-1111-4111-8111-111111111111'
function template(): UserTemplate {
return {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
user_id: USER_ID,
template_kind: 'dictation',
builtin_key: 'builtin-email',
name: 'Email',
description: 'Email template',
fields: [{ id: 'body', name: 'body', label: 'Body', promptText: 'Body?', required: true, maxDurationSec: 120 }],
output_format: '{{body}}',
template_type: null,
system_prompt: null,
is_builtin: true,
revision: 1,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T00:00:00.000Z',
}
}
function history(): HistoryEntry {
return {
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
user_id: USER_ID,
title: 'Launch note',
original_text: 'Ship after checks',
polished_text: null,
focused_app: null,
focused_app_name: null,
focused_app_window_title: null,
mode: 'dictation',
status: 'completed',
error_code: null,
audio_storage_key: null,
duration: 1,
detected_language: 'en',
mic_device: null,
word_count: 3,
stt_model: null,
llm_model: null,
stt_latency_ms: null,
llm_latency_ms: null,
app_version: '1.0.0',
summary_text: null,
is_favorite: false,
revision: 1,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T00:00:00.000Z',
}
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('template and memo screens', () => {
beforeEach(() => {
mockLoadTemplateLibrary.mockReset()
mockSelectTemplate.mockReset()
mockSearchMemos.mockReset()
mockAddMemoTag.mockReset()
})
it('renders template loading data and rolls an optimistic selection back on conflict', async () => {
mockLoadTemplateLibrary.mockResolvedValue({ templates: [template()], selections: {} })
mockSelectTemplate.mockRejectedValue(new TemplateServiceError('conflict', 'conflict'))
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(<TemplatesScreen />)
})
await flush()
expect(renderer!.root.findByProps({ testID: `template-${template().id}` })).toBeTruthy()
await act(async () => {
renderer!.root.findByProps({ testID: `template-select-${template().id}` }).props.onPress()
await Promise.resolve()
})
await flush()
expect(mockSelectTemplate).toHaveBeenCalledTimes(1)
const text = renderer!.root.findAllByType(require('react-native').Text)
.map((node) => node.props.children).flat().join(' ')
expect(text).toContain('mobile.templates.error.conflict')
expect(text).not.toContain('mobile.templates.selected')
await act(async () => renderer!.unmount())
})
it('renders memo data and rolls an optimistic tag create back when the server rejects it', async () => {
mockSearchMemos.mockResolvedValue({
items: [{ history: history(), tags: [] }],
tagSummaries: [],
})
mockAddMemoTag.mockRejectedValue(new MemoServiceError('network', 'offline'))
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(<MemosScreen />)
})
await flush()
const memoId = history().id
await act(async () => {
renderer!.root.findByProps({ testID: `memo-add-tag-${memoId}` }).props.onPress()
})
const editor = renderer!.root.findByProps({ testID: `memo-tag-editor-${memoId}` })
const input = editor.findByType(require('react-native').TextInput)
await act(async () => input.props.onChangeText('Launch'))
await act(async () => {
renderer!.root.findByProps({ testID: `memo-tag-save-${memoId}` }).props.onPress()
await Promise.resolve()
})
await flush()
expect(mockAddMemoTag).toHaveBeenCalledWith(USER_ID, memoId, 'Launch')
const text = renderer!.root.findAllByType(require('react-native').Text)
.map((node) => node.props.children).flat().join(' ')
expect(text).toContain('mobile.memos.error.network')
expect(text).not.toContain('#Launch')
await act(async () => renderer!.unmount())
})
})