237 lines
7.9 KiB
TypeScript
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())
|
|
})
|
|
})
|