diff --git a/app/file_importer.py b/app/file_importer.py index a26c0fc..b88cbdb 100644 --- a/app/file_importer.py +++ b/app/file_importer.py @@ -119,9 +119,6 @@ class FileImporter: # Data structure to track files to import self.import_files_data: list[TrackFileData] = [] - # Dictionary of exsting tracks indexed by track.id - self.existing_tracks = self._get_existing_tracks() - # Get signals self.signals = MusicMusterSignals() @@ -149,6 +146,10 @@ class FileImporter: ) return + # Refresh list of existing tracks as they may have been updated + # by previous imports + self.existing_tracks = self._get_existing_tracks() + for infile in [ os.path.join(Config.REPLACE_FILES_DEFAULT_SOURCE, f) for f in os.listdir(Config.REPLACE_FILES_DEFAULT_SOURCE) diff --git a/tests/test_file_importer.py b/tests/test_file_importer.py index 9143790..2d06a5a 100644 --- a/tests/test_file_importer.py +++ b/tests/test_file_importer.py @@ -1,20 +1,32 @@ +""" +Tests are named 'test_nnn_xxxx' where 'nn n' is a number. This is used to ensure that +the tests run in order as we rely (in some cases) upon the results of an earlier test. +Yes, we shouldn't do that. +""" + # Standard library imports +import os +import shutil +import tempfile import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch # PyQt imports +from PyQt6.QtWidgets import QDialog # Third party imports import pytest from pytestqt.plugin import QtBot # type: ignore # App imports -from config import Config +from app import musicmuster from app.models import ( db, Playlists, + Tracks, ) -from app import musicmuster +from config import Config +from file_importer import FileImporter # Custom fixture to adapt qtbot for use with unittest.TestCase @@ -24,59 +36,248 @@ def qtbot_adapter(qapp, request): request.cls.qtbot = QtBot(request) -# Wrapper to handle setup/teardown operations -def with_updown(function): - def test_wrapper(self, *args, **kwargs): - if callable(getattr(self, "up", None)): - self.up() - try: - function(self, *args, **kwargs) - finally: - if callable(getattr(self, "down", None)): - self.down() - - test_wrapper.__doc__ = function.__doc__ - return test_wrapper - - -# Apply the custom fixture to the test class @pytest.mark.usefixtures("qtbot_adapter") class MyTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + """Runs once before any test in this class""" - def up(self): db.create_all() - self.widget = musicmuster.Window() + cls.widget = musicmuster.Window() + + # Create a playlist for all tests playlist_name = "file importer playlist" - with db.Session() as session: playlist = Playlists(session, playlist_name) - self.widget.create_playlist_tab(playlist) - with self.qtbot.waitExposed(self.widget): - self.widget.show() + cls.widget.create_playlist_tab(playlist) + + # Create our musicstore + cls.import_source = tempfile.mkdtemp(suffix="_MMsource_pytest", dir="/tmp") + Config.REPLACE_FILES_DEFAULT_SOURCE = cls.import_source + cls.musicstore = tempfile.mkdtemp(suffix="_MMstore_pytest", dir="/tmp") + Config.IMPORT_DESTINATION = cls.musicstore + + @classmethod + def tearDownClass(cls): + """Runs once after all tests""" - def down(self): db.drop_all() + shutil.rmtree(cls.musicstore) + shutil.rmtree(cls.import_source) - @with_updown - @patch("file_importer.show_OK") - def test_import_no_files(self, mock_show_ok): + def setUp(self): + """Runs before each test""" + + with self.qtbot.waitExposed(self.widget): + self.widget.show() + + def tearDown(self): + """Runs after each test""" + self.widget.close() # Close UI to prevent side effects + + def test_001_import_no_files(self): """Try importing with no files to import""" - self.widget.import_files_wrapper() - mock_show_ok.assert_called_once_with( - "File import", - f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import", - None, - ) - # @with_updown - # def test_import_no_files(self): - # """Try importing with no files to import""" + with patch("file_importer.show_OK") as mock_show_ok: + self.widget.import_files_wrapper() + mock_show_ok.assert_called_once_with( + "File import", + f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import", + None, + ) - # with patch("file_importer.show_OK") as mock_show_ok: - # self.widget.import_files_wrapper() - # mock_show_ok.assert_called_once_with( - # "File import", - # f"No files in {Config.REPLACE_FILES_DEFAULT_SOURCE} to import", - # None, - # ) + def test_002_import_file_and_cancel(self): + """Cancel file import""" + + test_track_path = "testdata/isa.mp3" + shutil.copy(test_track_path, self.import_source) + + with ( + patch("file_importer.PickMatch") as MockPickMatch, + patch("file_importer.show_OK") as mock_show_ok, + ): + # Create a mock instance of PickMatch + mock_dialog_instance = MagicMock() + MockPickMatch.return_value = mock_dialog_instance + + # Simulate the user clicking OK in the dialog + mock_dialog_instance.exec.return_value = QDialog.DialogCode.Rejected + mock_dialog_instance.selected_track_id = -1 # Simulated return value + + self.widget.import_files_wrapper() + + # Ensure PickMatch was instantiated correctly + MockPickMatch.assert_called_once_with( + new_track_description="I'm So Afraid (Fleetwood Mac)", + choices=[("Do not import", -1, ""), ("Import as new track", 0, "")], + default=1, + ) + + # Verify exec() was called + mock_dialog_instance.exec.assert_called_once() + + # Ensure selected_track_id was accessed after dialog.exec() + assert mock_dialog_instance.selected_track_id < 0 + + mock_show_ok.assert_called_once_with( + "File not imported", + "isa.mp3 will not be imported because you asked not to import this file", + ) + + def test_003_import_first_file(self): + """Import file into empty directory""" + + test_track_path = "testdata/isa.mp3" + shutil.copy(test_track_path, self.import_source) + + with patch("file_importer.PickMatch") as MockPickMatch: + # Create a mock instance of PickMatch + mock_dialog_instance = MagicMock() + MockPickMatch.return_value = mock_dialog_instance + + # Simulate the user clicking OK in the dialog + mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted + mock_dialog_instance.selected_track_id = 0 # Simulated return value + + self.widget.import_files_wrapper() + + # Ensure PickMatch was instantiated correctly + MockPickMatch.assert_called_once_with( + new_track_description="I'm So Afraid (Fleetwood Mac)", + choices=[("Do not import", -1, ""), ("Import as new track", 0, "")], + default=1, + ) + + # Verify exec() was called + mock_dialog_instance.exec.assert_called_once() + + # Ensure selected_track_id was accessed after dialog.exec() + assert mock_dialog_instance.selected_track_id == 0 + + # Wait until workers have run to completion + # self.qtbot.wait(3000) + def workers_empty(): + assert FileImporter.workers == {} + + self.qtbot.waitUntil(workers_empty, timeout=10000) + + # Check track was imported + with db.Session() as session: + tracks = Tracks.get_all(session) + assert len(tracks) == 1 + track = tracks[0] + assert track.title == "I'm So Afraid" + assert track.artist == "Fleetwood Mac" + track_file = os.path.join( + self.musicstore, os.path.basename(test_track_path) + ) + assert track.path == track_file + assert os.path.exists(track_file) + assert os.listdir(self.import_source) == [] + + def test_004_import_second_file(self): + """Import a second file""" + + test_track_path = "testdata/lovecats.mp3" + shutil.copy(test_track_path, self.import_source) + + with patch("file_importer.PickMatch") as MockPickMatch: + # Create a mock instance of PickMatch + mock_dialog_instance = MagicMock() + MockPickMatch.return_value = mock_dialog_instance + + # Simulate the user clicking OK in the dialog + mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted + mock_dialog_instance.selected_track_id = 0 # Simulated return value + + self.widget.import_files_wrapper() + + # Ensure PickMatch was instantiated correctly + MockPickMatch.assert_called_once_with( + new_track_description="The Lovecats (The Cure)", + choices=[("Do not import", -1, ""), ("Import as new track", 0, "")], + default=1, + ) + + # Verify exec() was called + mock_dialog_instance.exec.assert_called_once() + + # Ensure selected_track_id was accessed after dialog.exec() + assert mock_dialog_instance.selected_track_id == 0 + + # Allow time for import thread to run + self.qtbot.wait(3000) + + # Check track was imported + with db.Session() as session: + tracks = Tracks.get_all(session) + assert len(tracks) == 2 + track = tracks[1] + assert track.title == "The Lovecats" + assert track.artist == "The Cure" + track_file = os.path.join( + self.musicstore, os.path.basename(test_track_path) + ) + assert track.path == track_file + assert os.path.exists(track_file) + assert os.listdir(self.import_source) == [] + + def test_005_replace_file(self): + """Import the same file again""" + + test_track_path = "testdata/lovecats.mp3" + shutil.copy(test_track_path, self.import_source) + + with patch("file_importer.PickMatch") as MockPickMatch: + # Create a mock instance of PickMatch + mock_dialog_instance = MagicMock() + MockPickMatch.return_value = mock_dialog_instance + + # Simulate the user clicking OK in the dialog + mock_dialog_instance.exec.return_value = QDialog.DialogCode.Accepted + mock_dialog_instance.selected_track_id = 1 # Simulated return value + + self.widget.import_files_wrapper() + + # Ensure PickMatch was instantiated correctly + MockPickMatch.assert_called_once_with( + new_track_description="The Lovecats (The Cure)", + choices=[("Do not import", -1, ""), + ("Import as new track", 0, ""), + ("The Lovecats (The Cure) (100%)", 2, + os.path.join(self.musicstore, os.path.basename(test_track_path)) + ), + ], + default=2, + ) + + # Verify exec() was called + mock_dialog_instance.exec.assert_called_once() + + # Ensure selected_track_id was accessed after dialog.exec() + assert mock_dialog_instance.selected_track_id == 1 + + # Allow time for import thread to run + self.qtbot.wait(3000) + + # Check track was imported + with db.Session() as session: + tracks = Tracks.get_all(session) + assert len(tracks) == 2 + track = tracks[1] + assert track.title == "The Lovecats" + assert track.artist == "The Cure" + track_file = os.path.join( + self.musicstore, os.path.basename(test_track_path) + ) + assert track.path == track_file + assert os.path.exists(track_file) + assert os.listdir(self.import_source) == [] + + # def test_import_replace_file + # def test_import_similar_file + # def test_import_new_file_existing_destination + # def test_import_file_and_cancel + # def test_import_file_no_tags + # def test_import_unreadable_file