diff --git a/noisicaa/ui/CMakeLists.txt b/noisicaa/ui/CMakeLists.txt index ed86d8b7..f3237ff5 100644 --- a/noisicaa/ui/CMakeLists.txt +++ b/noisicaa/ui/CMakeLists.txt @@ -25,6 +25,7 @@ add_python_package( device_list.py dynamic_layout.py editor_app.py + editor_app_test.py editor_window.py flowlayout.py gain_slider.py diff --git a/noisicaa/ui/editor_app.py b/noisicaa/ui/editor_app.py index dc4ea79e..c9e30f85 100644 --- a/noisicaa/ui/editor_app.py +++ b/noisicaa/ui/editor_app.py @@ -431,6 +431,9 @@ class EditorApp(ui_base.AbstractEditorApp): if not self.__windows: self.quit() + def windows(self) -> List[editor_window.EditorWindow]: + return self.__windows + def quit(self, exit_code: int = 0) -> None: # TODO: quit() is not a method of ProcessBase, only in UIProcess. Find some way to # fix that without a cyclic import. diff --git a/noisicaa/ui/editor_app_test.py b/noisicaa/ui/editor_app_test.py new file mode 100644 index 00000000..1a4d2741 --- /dev/null +++ b/noisicaa/ui/editor_app_test.py @@ -0,0 +1,222 @@ +#!/usr/bin/python3 + +# @begin:license +# +# Copyright (c) 2015-2019, Benjamin Niemann +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# @end:license + +import asyncio +import os +import os.path +import shutil +import time + +from PyQt5 import QtCore +from PyQt5 import QtWidgets + +from noisidev import qttest +from noisidev import unittest_mixins +from noisicaa.constants import TEST_OPTS +from noisicaa import runtime_settings +from noisicaa.core import storage +from noisicaa.core import process_manager +from noisicaa.music import project as project_lib +from . import editor_app + + +class EditorAppTest(unittest_mixins.ProcessManagerMixin, qttest.QtTestCase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.settings = None + self.runtime_settings = None + self.process = None + self.app = None + + async def setup_testcase(self): + self.music_dir = os.path.join(TEST_OPTS.TMP_DIR, 'projects') + if os.path.isdir(self.music_dir): + shutil.rmtree(self.music_dir) + os.makedirs(self.music_dir) + + self.setup_node_db_process(inline=True) + self.setup_urid_mapper_process(inline=True) + self.setup_instrument_db_process(inline=True) + self.setup_audioproc_process(inline=True) + self.setup_writer_process(inline=True) + + self.settings = QtCore.QSettings() + self.settings.setValue('audio/backend', 'null') + self.settings.setValue('project_folders', [self.music_dir]) + + self.runtime_settings = runtime_settings.RuntimeSettings() + + self.process = process_manager.ProcessBase( + name='ui', + manager=self.process_manager_client, + event_loop=self.loop, + tmp_dir=TEST_OPTS.TMP_DIR) + await self.process.setup() + + async def cleanup_testcase(self): + if self.app is not None: + await self.app.cleanup() + + if self.process is not None: + await self.process.cleanup() + + async def create_blank_project(self, name): + path = os.path.join(self.music_dir, name) + pool = project_lib.Pool() + project = pool.create(project_lib.Project) + pool.set_root(project) + ps = storage.ProjectStorage.create(path) + ps.add_checkpoint(project.serialize_object(project)) + ps.close() + return path + + async def create_app(self, paths=None): + self.app = editor_app.EditorApp( + qt_app=self.qt_app, + process=self.process, + paths=paths or [], + runtime_settings=self.runtime_settings, + settings=self.settings) + await self.app.setup() + + async def get_initial_window(self): + self.assertEqual(len(self.app.windows()), 1) + win = self.app.windows()[0] + self.assertEqual(win.windowTitle(), 'noisicaä') + return win + + async def window_get_project_tab(self, win, idx): + tabs = win.findChild(QtWidgets.QTabWidget, 'project-tabs') + assert tabs is not None + return tabs.widget(idx) + + async def window_num_project_tabs(self, win): + tabs = win.findChild(QtWidgets.QTabWidget, 'project-tabs') + assert tabs is not None + return tabs.count() + + async def window_close_current_project(self, win): + close_project_action = win.findChild(QtWidgets.QAction, 'close-project') + assert close_project_action is not None + close_project_action.trigger() + + async def project_tab_wait_for_page(self, tab, name): + page_changed = asyncio.Event(loop=self.loop) + conn = tab.currentPageChanged.connect(lambda _: page_changed.set()) + try: + # Wait until the "open project" dialog is visible. + t0 = time.time() + while tab.page().objectName() != name and time.time() < t0 + 10: + try: + await asyncio.wait_for(page_changed.wait(), timeout=1.0, loop=self.loop) + except asyncio.TimeoutError: + pass + else: + page_changed.clear() + + self.assertEqual(tab.page().objectName(), name) + + finally: + tab.currentPageChanged.disconnect(conn) + + async def project_tab_open_project(self, tab, row): + open_project_dialog = tab.findChild(QtWidgets.QWidget, 'open-project-dialog') + assert open_project_dialog is not None + + open_button = open_project_dialog.findChild(QtWidgets.QAbstractButton, 'open') + assert open_button is not None + self.assertFalse(open_button.isEnabled()) + + project_list = open_project_dialog.findChild(QtWidgets.QListView, 'project-list') + assert project_list is not None + project_list.setCurrentIndex(project_list.model().index(0, 0)) + self.assertTrue(open_button.isEnabled()) + + open_button.click() + + async def test_open_blank_project(self): + await self.create_blank_project('blank-project') + await self.create_app() + + # Get initial project tab. + win = await self.get_initial_window() + tab = await self.window_get_project_tab(win, 0) + + # Wait until the "open project" widget shows up. + await self.project_tab_wait_for_page(tab, 'open-project') + + # Select the first (and only) project. + await self.project_tab_open_project(tab, 0) + + # Wait until the project view is visible. + await self.project_tab_wait_for_page(tab, 'project-view') + + # Close the project. + await self.window_close_current_project(win) + + # Wait until the "open project" dialog is visible again. + await self.project_tab_wait_for_page(tab, 'open-project') + + async def test_open_project_from_cmdline(self): + path = await self.create_blank_project('blank-project') + await self.create_app([path]) + + # Get initial project tab. + win = await self.get_initial_window() + tab = await self.window_get_project_tab(win, 0) + + # Wait until the project view is visible. + await self.project_tab_wait_for_page(tab, 'project-view') + + # Close the project. + await self.window_close_current_project(win) + + # Wait until the "open project" dialog is visible again. + await self.project_tab_wait_for_page(tab, 'open-project') + + async def test_open_corrupt_project(self): + path = await self.create_blank_project('broken-project') + os.unlink(os.path.join(path, 'log.index')) + await self.create_app() + + # Get initial project tab. + win = await self.get_initial_window() + tab = await self.window_get_project_tab(win, 0) + + # Wait until the "open project" widget shows up. + await self.project_tab_wait_for_page(tab, 'open-project') + + # Select the first (and only) project. + await self.project_tab_open_project(tab, 0) + + # Wait for the error dialog to pop up. + t0 = time.time() + while t0 < time.time() + 10: + error_dialog = win.findChild(QtWidgets.QMessageBox, 'project-open-error') + if error_dialog is not None: + error_dialog.accept() + break + await asyncio.sleep(0.2, loop=self.loop) + + # Wait until the "open project" dialog is visible again. + await self.project_tab_wait_for_page(tab, 'open-project') diff --git a/noisicaa/ui/editor_window.py b/noisicaa/ui/editor_window.py index 1c463cee..c5894048 100644 --- a/noisicaa/ui/editor_window.py +++ b/noisicaa/ui/editor_window.py @@ -84,6 +84,8 @@ class SetupProgressWidget(QtWidgets.QWidget): class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget): + currentPageChanged = QtCore.pyqtSignal(QtWidgets.QWidget) + def __init__(self, parent: QtWidgets.QTabWidget, **kwargs: Any) -> None: super().__init__(parent=parent, **kwargs) @@ -105,6 +107,9 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget): def projectDebugger(self) -> Optional[project_debugger.ProjectDebugger]: return self.__project_debugger + def page(self) -> QtWidgets.QWidget: + return self.__page + def __setPage( self, name: str, @@ -123,6 +128,7 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget): self.__layout.addWidget(page) self.__page = page self.__page_cleanup_func = cleanup_func + self.currentPageChanged.emit(self.__page) def showOpenDialog(self) -> None: dialog = open_project_dialog.OpenProjectDialog( @@ -146,6 +152,7 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget): l2.addStretch(1) page = QtWidgets.QWidget(self) + page.setObjectName('open-project') page.setLayout(l2) self.__setPage("Open project...", page, dialog.cleanup) @@ -154,6 +161,7 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget): logger.error(traceback.format_exc()) dialog = QtWidgets.QMessageBox(self) + dialog.setObjectName('project-open-error') dialog.setWindowTitle("noisicaä - Error") dialog.setIcon(QtWidgets.QMessageBox.Critical) dialog.setText(message) @@ -183,6 +191,7 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget): else: view = project_view.ProjectView(project_connection=project, context=self.context) + view.setObjectName('project-view') await view.setup() self.__project_view = view self.__setPage(project.name, view) @@ -202,6 +211,7 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget): else: await self.app.project_registry.refresh() view = project_view.ProjectView(project_connection=project, context=self.context) + view.setObjectName('project-view') await view.setup() self.__project_view = view self.__setPage(project.name, view) @@ -210,12 +220,14 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget): self.showLoadSpinner(project.name, "Loading project \"%s\"..." % project.name) await self.app.setup_complete.wait() debugger = project_debugger.ProjectDebugger(project=project, context=self.context) + debugger.setObjectName('project-debugger') await debugger.setup() self.__project_debugger = debugger self.__setPage(project.name, debugger) def showLoadSpinner(self, name: str, message: str) -> None: page = QtWidgets.QWidget(self) + page.setObjectName('load-spinner') label = QtWidgets.QLabel(page) label.setText(message) @@ -283,6 +295,7 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow): self.loopEnabledChanged.connect(self.onLoopEnabledChanged) self.__project_tabs = QtWidgets.QTabWidget(self) + self.__project_tabs.setObjectName('project-tabs') self.__project_tabs.setTabBarAutoHide(True) self.__project_tabs.setUsesScrollButtons(True) self.__project_tabs.setTabsClosable(True) @@ -379,6 +392,7 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow): self._render_action.triggered.connect(self.onRender) self._close_current_project_action = QtWidgets.QAction("Close", self) + self._close_current_project_action.setObjectName('close-project') self._close_current_project_action.setShortcut(QtGui.QKeySequence.Close) self._close_current_project_action.setStatusTip("Close the current project") self._close_current_project_action.triggered.connect(self.onCloseCurrentProject) diff --git a/noisicaa/ui/open_project_dialog.py b/noisicaa/ui/open_project_dialog.py index 6a0800a1..ff136799 100644 --- a/noisicaa/ui/open_project_dialog.py +++ b/noisicaa/ui/open_project_dialog.py @@ -360,6 +360,8 @@ class OpenProjectDialog(ui_base.CommonMixin, QtWidgets.QWidget): def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) + self.setObjectName('open-project-dialog') + self.__filter_model = FilterModel() self.__filter_model.setSourceModel(FlatProjectListModel(self.app.project_registry)) self.__filter_model.setSortRole(project_registry_lib.Project.NameRole) @@ -399,6 +401,7 @@ class OpenProjectDialog(ui_base.CommonMixin, QtWidgets.QWidget): self.__updateSort() self.__open_button = QtWidgets.QPushButton(self) + self.__open_button.setObjectName('open') self.__open_button.setIcon(QtGui.QIcon.fromTheme('document-open')) self.__open_button.setText("Open") self.__open_button.clicked.connect(self.__openClicked) @@ -429,6 +432,7 @@ class OpenProjectDialog(ui_base.CommonMixin, QtWidgets.QWidget): self.__more_button.setMenu(self.__more_menu) self.__list = ProjectListView(self) + self.__list.setObjectName('project-list') self.__list.setModel(self.__filter_model) self.__list.numProjectsSelected.connect(lambda _: self.__updateButtons()) self.__list.itemDoubleClicked.connect(self.__itemDoubleClicked) diff --git a/noisicaa/ui/project_registry.py b/noisicaa/ui/project_registry.py index 4a7703fc..28455b84 100644 --- a/noisicaa/ui/project_registry.py +++ b/noisicaa/ui/project_registry.py @@ -211,8 +211,7 @@ class ProjectRegistry(ui_base.CommonMixin, QtCore.QAbstractItemModel): return list(self.__root.projects()) async def refresh(self) -> None: - # TODO: get list of directories from settings - directories = ['~/Music/Noisicaä'] + directories = self.app.settings.value('project_folders', ['~/Music/Noisicaä']) paths = await self.event_loop.run_in_executor(None, self.__scan_projects, directories)