You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Chore: Mobile: Add note screen tests (#10766)
This commit is contained in:
		| @@ -14,6 +14,7 @@ import { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnM | ||||
| import Resource from '@joplin/lib/models/Resource'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import getWebViewDomById from '../../utils/testing/getWebViewDomById'; | ||||
|  | ||||
| interface WrapperProps { | ||||
| 	noteBody: string; | ||||
| @@ -56,17 +57,8 @@ const WrappedNoteViewer: React.FC<WrapperProps> = ( | ||||
| 	</MenuProvider>; | ||||
| }; | ||||
|  | ||||
| const getNoteViewerDom = async (): Promise<Document> => { | ||||
| 	const webviewContent = await screen.findByTestId('NoteBodyViewer'); | ||||
| 	expect(webviewContent).toBeVisible(); | ||||
|  | ||||
| 	await waitFor(() => { | ||||
| 		expect(!!webviewContent.props.document).toBe(true); | ||||
| 	}); | ||||
|  | ||||
| 	// Return the composite ExtendedWebView component | ||||
| 	// See https://callstack.github.io/react-native-testing-library/docs/advanced/testing-env#tree-navigation | ||||
| 	return webviewContent.props.document; | ||||
| const getNoteViewerDom = async () => { | ||||
| 	return await getWebViewDomById('NoteBodyViewer'); | ||||
| }; | ||||
|  | ||||
| describe('NoteBodyViewer', () => { | ||||
|   | ||||
| @@ -66,8 +66,8 @@ export const WarningBannerComponent: React.FC<Props> = props => { | ||||
| 		warningComps.push(renderWarningBox( | ||||
| 			'ShareManager', | ||||
| 			_('%s (%s) would like to share a notebook with you.', | ||||
| 				substrWithEllipsis(sharer.full_name, 0, 48), | ||||
| 				substrWithEllipsis(sharer.email, 0, 52)), | ||||
| 				substrWithEllipsis(sharer?.full_name ?? 'Unknown', 0, 48), | ||||
| 				substrWithEllipsis(sharer?.email ?? 'Unknown', 0, 52)), | ||||
| 		)); | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -540,7 +540,10 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | ||||
| 				} else { | ||||
| 					menuOptionComponents.push( | ||||
| 						<MenuOption value={o.onPress} key={`menuOption_${key++}`} style={this.styles().contextMenuItem} disabled={!!o.disabled}> | ||||
| 							<Text style={o.disabled ? this.styles().contextMenuItemTextDisabled : this.styles().contextMenuItemText}>{o.title}</Text> | ||||
| 							<Text | ||||
| 								style={o.disabled ? this.styles().contextMenuItemTextDisabled : this.styles().contextMenuItemText} | ||||
| 								disabled={!!o.disabled} | ||||
| 							>{o.title}</Text> | ||||
| 						</MenuOption>, | ||||
| 					); | ||||
| 				} | ||||
| @@ -655,7 +658,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | ||||
| 		const menuComp = | ||||
| 			!menuOptionComponents.length || !showContextMenuButton ? null : ( | ||||
| 				<Menu onSelect={value => this.menu_select(value)} style={this.styles().contextMenu}> | ||||
| 					<MenuTrigger style={contextMenuStyle}> | ||||
| 					<MenuTrigger style={contextMenuStyle} testID='screen-header-menu-trigger'> | ||||
| 						<View accessibilityLabel={_('Actions')}> | ||||
| 							<Icon name="ellipsis-vertical" style={this.styles().contextMenuTrigger} /> | ||||
| 						</View> | ||||
|   | ||||
							
								
								
									
										173
									
								
								packages/app-mobile/components/screens/Note.test.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								packages/app-mobile/components/screens/Note.test.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,173 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| import { describe, it, beforeEach } from '@jest/globals'; | ||||
| import { fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native'; | ||||
| import '@testing-library/jest-native/extend-expect'; | ||||
| import { Provider } from 'react-redux'; | ||||
|  | ||||
| import NoteScreen from './Note'; | ||||
| import { MenuProvider } from 'react-native-popup-menu'; | ||||
| import { runWithFakeTimers, setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv } from '@joplin/lib/testing/test-utils'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import { AppState } from '../../utils/types'; | ||||
| import { Store } from 'redux'; | ||||
| import createMockReduxStore from '../../utils/testing/createMockReduxStore'; | ||||
| import initializeCommandService from '../../utils/initializeCommandService'; | ||||
| import { PaperProvider } from 'react-native-paper'; | ||||
| import getWebViewDomById from '../../utils/testing/getWebViewDomById'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import Folder from '@joplin/lib/models/Folder'; | ||||
| import BaseItem from '@joplin/lib/models/BaseItem'; | ||||
| import { ModelType } from '@joplin/lib/BaseModel'; | ||||
| import ItemChange from '@joplin/lib/models/ItemChange'; | ||||
| import { getDisplayParentId } from '@joplin/lib/services/trash'; | ||||
| import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly'; | ||||
| import { LayoutChangeEvent } from 'react-native'; | ||||
|  | ||||
| interface WrapperProps { | ||||
| } | ||||
|  | ||||
| let store: Store<AppState>; | ||||
|  | ||||
| const WrappedNoteScreen: React.FC<WrapperProps> = _props => { | ||||
| 	return <MenuProvider> | ||||
| 		<PaperProvider> | ||||
| 			<Provider store={store}> | ||||
| 				<NoteScreen /> | ||||
| 			</Provider> | ||||
| 		</PaperProvider> | ||||
| 	</MenuProvider>; | ||||
| }; | ||||
|  | ||||
| const getNoteViewerDom = async () => { | ||||
| 	return await getWebViewDomById('NoteBodyViewer'); | ||||
| }; | ||||
|  | ||||
| const openNewNote = async (noteProperties: NoteEntity) => { | ||||
| 	const note = await Note.save({ | ||||
| 		parent_id: (await Folder.defaultFolder()).id, | ||||
| 		...noteProperties, | ||||
| 	}); | ||||
|  | ||||
| 	const displayParentId = getDisplayParentId(note, await Folder.load(note.parent_id)); | ||||
|  | ||||
| 	store.dispatch({ | ||||
| 		type: 'NOTE_UPDATE_ALL', | ||||
| 		notes: await Note.previews(displayParentId), | ||||
| 	}); | ||||
|  | ||||
| 	store.dispatch({ | ||||
| 		type: 'FOLDER_AND_NOTE_SELECT', | ||||
| 		id: note.id, | ||||
| 		folderId: displayParentId, | ||||
| 	}); | ||||
| 	return note.id; | ||||
| }; | ||||
|  | ||||
| const openNoteActionsMenu = async () => { | ||||
| 	// It doesn't seem possible to find the menu trigger with role/label. | ||||
| 	const actionMenuButton = await screen.findByTestId('screen-header-menu-trigger'); | ||||
|  | ||||
| 	// react-native-action-menu only shows the menu content after receiving onLayout | ||||
| 	// events from various components (including a View that wraps the screen). | ||||
| 	let cursor = actionMenuButton; | ||||
| 	while (cursor.parent) { | ||||
| 		if (cursor.props.onLayout) { | ||||
| 			const mockedEvent = { nativeEvent: { layout: { x: 0, y: 0, width: 120, height: 100 } } }; | ||||
| 			cursor.props.onLayout(mockedEvent as LayoutChangeEvent); | ||||
| 		} | ||||
| 		cursor = cursor.parent; | ||||
| 	} | ||||
|  | ||||
| 	await runWithFakeTimers(() => userEvent.press(actionMenuButton)); | ||||
| }; | ||||
|  | ||||
| describe('Note', () => { | ||||
| 	beforeEach(async () => { | ||||
| 		await setupDatabaseAndSynchronizer(0); | ||||
| 		await switchClient(0); | ||||
|  | ||||
| 		store = createMockReduxStore(); | ||||
| 		initializeCommandService(store); | ||||
|  | ||||
| 		// In order for note changes to be saved, note-screen-shared requires | ||||
| 		// that at least one folder exist. | ||||
| 		await Folder.save({ title: 'test', parent_id: '' }); | ||||
| 	}); | ||||
|  | ||||
| 	afterEach(() => { | ||||
| 		screen.unmount(); | ||||
| 	}); | ||||
|  | ||||
| 	it('should show the currently selected note', async () => { | ||||
| 		await openNewNote({ title: 'Test note (title)', body: '# Testing...' }); | ||||
| 		render(<WrappedNoteScreen />); | ||||
|  | ||||
| 		const titleInput = await screen.findByDisplayValue('Test note (title)'); | ||||
| 		expect(titleInput).toBeVisible(); | ||||
|  | ||||
| 		const renderedNote = await getNoteViewerDom(); | ||||
| 		expect(renderedNote.querySelector('h1')).toMatchObject({ textContent: 'Testing...' }); | ||||
| 	}); | ||||
|  | ||||
| 	it('changing the note title input should update the note\'s title', async () => { | ||||
| 		const noteId = await openNewNote({ title: 'Change me!', body: 'Unchanged body' }); | ||||
| 		render(<WrappedNoteScreen />); | ||||
|  | ||||
| 		const titleInput = await screen.findByDisplayValue('Change me!'); | ||||
| 		// We need to use fake timers while using userEvent to avoid warnings: | ||||
| 		await runWithFakeTimers(async () => { | ||||
| 			const user = userEvent.setup(); | ||||
| 			await user.clear(titleInput); | ||||
| 			await user.type(titleInput, 'New title'); | ||||
| 		}); | ||||
|  | ||||
| 		await waitFor(async () => { | ||||
| 			expect(await Note.load(noteId)).toMatchObject({ title: 'New title', body: 'Unchanged body' }); | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| 	it('pressing "delete" should move the note to the trash', async () => { | ||||
| 		const noteId = await openNewNote({ title: 'To be deleted', body: '...' }); | ||||
| 		render(<WrappedNoteScreen />); | ||||
|  | ||||
| 		await openNoteActionsMenu(); | ||||
| 		const deleteButton = await screen.findByText('Delete'); | ||||
| 		fireEvent.press(deleteButton); | ||||
|  | ||||
| 		await waitFor(async () => { | ||||
| 			expect((await Note.load(noteId)).deleted_time).toBeGreaterThan(0); | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| 	it('delete should be disabled in a read-only note', async () => { | ||||
| 		const shareId = 'testShare'; | ||||
| 		const noteId = await openNewNote({ | ||||
| 			title: 'Title: Read-only note', | ||||
| 			body: 'A **read-only** note.', | ||||
| 			share_id: shareId, | ||||
| 		}); | ||||
| 		const cleanup = simulateReadOnlyShareEnv(shareId, store); | ||||
| 		expect( | ||||
| 			itemIsReadOnlySync( | ||||
| 				ModelType.Note, | ||||
| 				ItemChange.SOURCE_UNSPECIFIED, | ||||
| 				await Note.load(noteId) as ItemSlice, | ||||
| 				'', | ||||
| 				BaseItem.syncShareCache, | ||||
| 			), | ||||
| 		).toBe(true); | ||||
|  | ||||
| 		render(<WrappedNoteScreen />); | ||||
|  | ||||
| 		const titleInput = await screen.findByDisplayValue('Title: Read-only note'); | ||||
| 		expect(titleInput).toBeVisible(); | ||||
| 		expect(titleInput).toBeDisabled(); | ||||
|  | ||||
| 		await openNoteActionsMenu(); | ||||
| 		const deleteButton = await screen.findByText('Delete'); | ||||
| 		expect(deleteButton).toBeDisabled(); | ||||
|  | ||||
| 		cleanup(); | ||||
| 	}); | ||||
| }); | ||||
| @@ -10,7 +10,7 @@ const FileViewer = require('react-native-file-viewer').default; | ||||
| const React = require('react'); | ||||
| import { Keyboard, View, TextInput, StyleSheet, Linking, Share, NativeSyntheticEvent } from 'react-native'; | ||||
| import { Platform, PermissionsAndroid } from 'react-native'; | ||||
| const { connect } = require('react-redux'); | ||||
| import { connect } from 'react-redux'; | ||||
| // const { MarkdownEditor } = require('@joplin/lib/../MarkdownEditor/index.js'); | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import BaseItem from '@joplin/lib/models/BaseItem'; | ||||
| @@ -35,7 +35,7 @@ import { BaseScreenComponent } from '../base-screen'; | ||||
| import { themeStyle, editorFont } from '../global-style'; | ||||
| const { dialogs } = require('../../utils/dialogs.js'); | ||||
| const DialogBox = require('react-native-dialogbox').default; | ||||
| import shared, { BaseNoteScreenComponent } from '@joplin/lib/components/shared/note-screen-shared'; | ||||
| import shared, { BaseNoteScreenComponent, Props as BaseProps } from '@joplin/lib/components/shared/note-screen-shared'; | ||||
| import { Asset, ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker'; | ||||
| import SelectDateTimeDialog from '../SelectDateTimeDialog'; | ||||
| import ShareExtension from '../../utils/ShareExtension.js'; | ||||
| @@ -47,7 +47,7 @@ import promptRestoreAutosave from '../NoteEditor/ImageEditor/promptRestoreAutosa | ||||
| import isEditableResource from '../NoteEditor/ImageEditor/isEditableResource'; | ||||
| import VoiceTypingDialog from '../voiceTyping/VoiceTypingDialog'; | ||||
| import { voskEnabled } from '../../services/voiceTyping/vosk'; | ||||
| import { isSupportedLanguage } from '../../services/voiceTyping/vosk.android'; | ||||
| import { isSupportedLanguage } from '../../services/voiceTyping/vosk'; | ||||
| import { ChangeEvent as EditorChangeEvent, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events'; | ||||
| import { join } from 'path'; | ||||
| import { Dispatch } from 'redux'; | ||||
| @@ -71,7 +71,7 @@ const emptyArray: any[] = []; | ||||
|  | ||||
| const logger = Logger.create('screens/Note'); | ||||
|  | ||||
| interface Props { | ||||
| interface Props extends BaseProps { | ||||
| 	provisionalNoteIds: string[]; | ||||
| 	dispatch: Dispatch; | ||||
| 	noteId: string; | ||||
| @@ -81,8 +81,8 @@ interface Props { | ||||
| 	editorFontSize: number; | ||||
| 	editorFont: number; // e.g. Setting.FONT_MENLO | ||||
| 	showSideMenu: boolean; | ||||
| 	searchQuery: string[]; | ||||
| 	ftsEnabled: boolean; | ||||
| 	searchQuery: string; | ||||
| 	ftsEnabled: number; | ||||
| 	highlightedWords: string[]; | ||||
| 	noteHash: string; | ||||
| 	toolbarEnabled: boolean; | ||||
| @@ -1186,7 +1186,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
|  | ||||
| 		const pluginCommands = pluginUtils.commandNamesFromViews(this.props.plugins, 'noteToolbar'); | ||||
|  | ||||
| 		const cacheKey = md5([isTodo, isSaved, pluginCommands.join(',')].join('_')); | ||||
| 		const cacheKey = md5([isTodo, isSaved, pluginCommands.join(','), readOnly].join('_')); | ||||
| 		if (!this.menuOptionsCache_) this.menuOptionsCache_ = {}; | ||||
|  | ||||
| 		if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey]; | ||||
| @@ -1675,7 +1675,7 @@ const NoteScreen = connect((state: AppState) => { | ||||
| 		folders: state.folders, | ||||
| 		searchQuery: state.searchQuery, | ||||
| 		themeId: state.settings.theme, | ||||
| 		editorFont: [state.settings['style.editor.fontFamily']], | ||||
| 		editorFont: state.settings['style.editor.fontFamily'] as number, | ||||
| 		editorFontSize: state.settings['style.editor.fontSize'], | ||||
| 		toolbarEnabled: state.settings['editor.mobile.toolbarEnabled'], | ||||
| 		ftsEnabled: state.settings['db.ftsEnabled'], | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import { _, languageName } from '@joplin/lib/locale'; | ||||
| import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| import { getVosk, Recorder, startRecording, Vosk } from '../../services/voiceTyping/vosk'; | ||||
| import { IconSource } from 'react-native-paper/lib/typescript/components/Icon'; | ||||
| import { modelIsDownloaded } from '../../services/voiceTyping/vosk.android'; | ||||
| import { modelIsDownloaded } from '../../services/voiceTyping/vosk'; | ||||
|  | ||||
| interface Props { | ||||
| 	locale: string; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user