1# -*- coding: utf-8 -*- 2# This file is part of beets. 3# Copyright 2016, Adrian Sampson and Diego Moreda. 4# 5# Permission is hereby granted, free of charge, to any person obtaining 6# a copy of this software and associated documentation files (the 7# "Software"), to deal in the Software without restriction, including 8# without limitation the rights to use, copy, modify, merge, publish, 9# distribute, sublicense, and/or sell copies of the Software, and to 10# permit persons to whom the Software is furnished to do so, subject to 11# the following conditions: 12# 13# The above copyright notice and this permission notice shall be 14# included in all copies or substantial portions of the Software. 15 16from __future__ import division, absolute_import, print_function 17import codecs 18import unittest 19 20from mock import patch 21from test import _common 22from test.helper import TestHelper, control_stdin 23from test.test_ui_importer import TerminalImportSessionSetup 24from test.test_importer import ImportHelper, AutotagStub 25from beets.dbcore.query import TrueQuery 26from beets.library import Item 27from beetsplug.edit import EditPlugin 28 29 30class ModifyFileMocker(object): 31 """Helper for modifying a file, replacing or editing its contents. Used for 32 mocking the calls to the external editor during testing. 33 """ 34 35 def __init__(self, contents=None, replacements=None): 36 """ `self.contents` and `self.replacements` are initialized here, in 37 order to keep the rest of the functions of this class with the same 38 signature as `EditPlugin.get_editor()`, making mocking easier. 39 - `contents`: string with the contents of the file to be used for 40 `overwrite_contents()` 41 - `replacement`: dict with the in-place replacements to be used for 42 `replace_contents()`, in the form {'previous string': 'new string'} 43 44 TODO: check if it can be solved more elegantly with a decorator 45 """ 46 self.contents = contents 47 self.replacements = replacements 48 self.action = self.overwrite_contents 49 if replacements: 50 self.action = self.replace_contents 51 52 # The two methods below mock the `edit` utility function in the plugin. 53 54 def overwrite_contents(self, filename, log): 55 """Modify `filename`, replacing its contents with `self.contents`. If 56 `self.contents` is empty, the file remains unchanged. 57 """ 58 if self.contents: 59 with codecs.open(filename, 'w', encoding='utf-8') as f: 60 f.write(self.contents) 61 62 def replace_contents(self, filename, log): 63 """Modify `filename`, reading its contents and replacing the strings 64 specified in `self.replacements`. 65 """ 66 with codecs.open(filename, 'r', encoding='utf-8') as f: 67 contents = f.read() 68 for old, new_ in self.replacements.items(): 69 contents = contents.replace(old, new_) 70 with codecs.open(filename, 'w', encoding='utf-8') as f: 71 f.write(contents) 72 73 74class EditMixin(object): 75 """Helper containing some common functionality used for the Edit tests.""" 76 def assertItemFieldsModified(self, library_items, items, fields=[], # noqa 77 allowed=['path']): 78 """Assert that items in the library (`lib_items`) have different values 79 on the specified `fields` (and *only* on those fields), compared to 80 `items`. 81 82 An empty `fields` list results in asserting that no modifications have 83 been performed. `allowed` is a list of field changes that are ignored 84 (they may or may not have changed; the assertion doesn't care). 85 """ 86 for lib_item, item in zip(library_items, items): 87 diff_fields = [field for field in lib_item._fields 88 if lib_item[field] != item[field]] 89 self.assertEqual(set(diff_fields).difference(allowed), 90 set(fields)) 91 92 def run_mocked_interpreter(self, modify_file_args={}, stdin=[]): 93 """Run the edit command during an import session, with mocked stdin and 94 yaml writing. 95 """ 96 m = ModifyFileMocker(**modify_file_args) 97 with patch('beetsplug.edit.edit', side_effect=m.action): 98 with control_stdin('\n'.join(stdin)): 99 self.importer.run() 100 101 def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]): 102 """Run the edit command, with mocked stdin and yaml writing, and 103 passing `args` to `run_command`.""" 104 m = ModifyFileMocker(**modify_file_args) 105 with patch('beetsplug.edit.edit', side_effect=m.action): 106 with control_stdin('\n'.join(stdin)): 107 self.run_command('edit', *args) 108 109 110@_common.slow_test() 111@patch('beets.library.Item.write') 112class EditCommandTest(unittest.TestCase, TestHelper, EditMixin): 113 """Black box tests for `beetsplug.edit`. Command line interaction is 114 simulated using `test.helper.control_stdin()`, and yaml editing via an 115 external editor is simulated using `ModifyFileMocker`. 116 """ 117 ALBUM_COUNT = 1 118 TRACK_COUNT = 10 119 120 def setUp(self): 121 self.setup_beets() 122 self.load_plugins('edit') 123 # Add an album, storing the original fields for comparison. 124 self.album = self.add_album_fixture(track_count=self.TRACK_COUNT) 125 self.album_orig = {f: self.album[f] for f in self.album._fields} 126 self.items_orig = [{f: item[f] for f in item._fields} for 127 item in self.album.items()] 128 129 def tearDown(self): 130 EditPlugin.listeners = None 131 self.teardown_beets() 132 self.unload_plugins() 133 134 def assertCounts(self, mock_write, album_count=ALBUM_COUNT, track_count=TRACK_COUNT, # noqa 135 write_call_count=TRACK_COUNT, title_starts_with=''): 136 """Several common assertions on Album, Track and call counts.""" 137 self.assertEqual(len(self.lib.albums()), album_count) 138 self.assertEqual(len(self.lib.items()), track_count) 139 self.assertEqual(mock_write.call_count, write_call_count) 140 self.assertTrue(all(i.title.startswith(title_starts_with) 141 for i in self.lib.items())) 142 143 def test_title_edit_discard(self, mock_write): 144 """Edit title for all items in the library, then discard changes.""" 145 # Edit track titles. 146 self.run_mocked_command({'replacements': {u't\u00eftle': 147 u'modified t\u00eftle'}}, 148 # Cancel. 149 ['c']) 150 151 self.assertCounts(mock_write, write_call_count=0, 152 title_starts_with=u't\u00eftle') 153 self.assertItemFieldsModified(self.album.items(), self.items_orig, []) 154 155 def test_title_edit_apply(self, mock_write): 156 """Edit title for all items in the library, then apply changes.""" 157 # Edit track titles. 158 self.run_mocked_command({'replacements': {u't\u00eftle': 159 u'modified t\u00eftle'}}, 160 # Apply changes. 161 ['a']) 162 163 self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT, 164 title_starts_with=u'modified t\u00eftle') 165 self.assertItemFieldsModified(self.album.items(), self.items_orig, 166 ['title', 'mtime']) 167 168 def test_single_title_edit_apply(self, mock_write): 169 """Edit title for one item in the library, then apply changes.""" 170 # Edit one track title. 171 self.run_mocked_command({'replacements': {u't\u00eftle 9': 172 u'modified t\u00eftle 9'}}, 173 # Apply changes. 174 ['a']) 175 176 self.assertCounts(mock_write, write_call_count=1,) 177 # No changes except on last item. 178 self.assertItemFieldsModified(list(self.album.items())[:-1], 179 self.items_orig[:-1], []) 180 self.assertEqual(list(self.album.items())[-1].title, 181 u'modified t\u00eftle 9') 182 183 def test_noedit(self, mock_write): 184 """Do not edit anything.""" 185 # Do not edit anything. 186 self.run_mocked_command({'contents': None}, 187 # No stdin. 188 []) 189 190 self.assertCounts(mock_write, write_call_count=0, 191 title_starts_with=u't\u00eftle') 192 self.assertItemFieldsModified(self.album.items(), self.items_orig, []) 193 194 def test_album_edit_apply(self, mock_write): 195 """Edit the album field for all items in the library, apply changes. 196 By design, the album should not be updated."" 197 """ 198 # Edit album. 199 self.run_mocked_command({'replacements': {u'\u00e4lbum': 200 u'modified \u00e4lbum'}}, 201 # Apply changes. 202 ['a']) 203 204 self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) 205 self.assertItemFieldsModified(self.album.items(), self.items_orig, 206 ['album', 'mtime']) 207 # Ensure album is *not* modified. 208 self.album.load() 209 self.assertEqual(self.album.album, u'\u00e4lbum') 210 211 def test_single_edit_add_field(self, mock_write): 212 """Edit the yaml file appending an extra field to the first item, then 213 apply changes.""" 214 # Append "foo: bar" to item with id == 2. ("id: 1" would match both 215 # "id: 1" and "id: 10") 216 self.run_mocked_command({'replacements': {u"id: 2": 217 u"id: 2\nfoo: bar"}}, 218 # Apply changes. 219 ['a']) 220 221 self.assertEqual(self.lib.items(u'id:2')[0].foo, 'bar') 222 # Even though a flexible attribute was written (which is not directly 223 # written to the tags), write should still be called since templates 224 # might use it. 225 self.assertCounts(mock_write, write_call_count=1, 226 title_starts_with=u't\u00eftle') 227 228 def test_a_album_edit_apply(self, mock_write): 229 """Album query (-a), edit album field, apply changes.""" 230 self.run_mocked_command({'replacements': {u'\u00e4lbum': 231 u'modified \u00e4lbum'}}, 232 # Apply changes. 233 ['a'], 234 args=['-a']) 235 236 self.album.load() 237 self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) 238 self.assertEqual(self.album.album, u'modified \u00e4lbum') 239 self.assertItemFieldsModified(self.album.items(), self.items_orig, 240 ['album', 'mtime']) 241 242 def test_a_albumartist_edit_apply(self, mock_write): 243 """Album query (-a), edit albumartist field, apply changes.""" 244 self.run_mocked_command({'replacements': {u'album artist': 245 u'modified album artist'}}, 246 # Apply changes. 247 ['a'], 248 args=['-a']) 249 250 self.album.load() 251 self.assertCounts(mock_write, write_call_count=self.TRACK_COUNT) 252 self.assertEqual(self.album.albumartist, u'the modified album artist') 253 self.assertItemFieldsModified(self.album.items(), self.items_orig, 254 ['albumartist', 'mtime']) 255 256 def test_malformed_yaml(self, mock_write): 257 """Edit the yaml file incorrectly (resulting in a malformed yaml 258 document).""" 259 # Edit the yaml file to an invalid file. 260 self.run_mocked_command({'contents': '!MALFORMED'}, 261 # Edit again to fix? No. 262 ['n']) 263 264 self.assertCounts(mock_write, write_call_count=0, 265 title_starts_with=u't\u00eftle') 266 267 def test_invalid_yaml(self, mock_write): 268 """Edit the yaml file incorrectly (resulting in a well-formed but 269 invalid yaml document).""" 270 # Edit the yaml file to an invalid but parseable file. 271 self.run_mocked_command({'contents': u'wellformed: yes, but invalid'}, 272 # No stdin. 273 []) 274 275 self.assertCounts(mock_write, write_call_count=0, 276 title_starts_with=u't\u00eftle') 277 278 279@_common.slow_test() 280class EditDuringImporterTest(TerminalImportSessionSetup, unittest.TestCase, 281 ImportHelper, TestHelper, EditMixin): 282 """TODO 283 """ 284 IGNORED = ['added', 'album_id', 'id', 'mtime', 'path'] 285 286 def setUp(self): 287 self.setup_beets() 288 self.load_plugins('edit') 289 # Create some mediafiles, and store them for comparison. 290 self._create_import_dir(3) 291 self.items_orig = [Item.from_path(f.path) for f in self.media_files] 292 self.matcher = AutotagStub().install() 293 self.matcher.matching = AutotagStub.GOOD 294 self.config['import']['timid'] = True 295 296 def tearDown(self): 297 EditPlugin.listeners = None 298 self.unload_plugins() 299 self.teardown_beets() 300 self.matcher.restore() 301 302 def test_edit_apply_asis(self): 303 """Edit the album field for all items in the library, apply changes, 304 using the original item tags. 305 """ 306 self._setup_import_session() 307 # Edit track titles. 308 self.run_mocked_interpreter({'replacements': {u'Tag Title': 309 u'Edited Title'}}, 310 # eDit, Apply changes. 311 ['d', 'a']) 312 313 # Check that only the 'title' field is modified. 314 self.assertItemFieldsModified(self.lib.items(), self.items_orig, 315 ['title'], 316 self.IGNORED + ['albumartist', 317 'mb_albumartistid']) 318 self.assertTrue(all('Edited Title' in i.title 319 for i in self.lib.items())) 320 321 # Ensure album is *not* fetched from a candidate. 322 self.assertEqual(self.lib.albums()[0].mb_albumid, u'') 323 324 def test_edit_discard_asis(self): 325 """Edit the album field for all items in the library, discard changes, 326 using the original item tags. 327 """ 328 self._setup_import_session() 329 # Edit track titles. 330 self.run_mocked_interpreter({'replacements': {u'Tag Title': 331 u'Edited Title'}}, 332 # eDit, Cancel, Use as-is. 333 ['d', 'c', 'u']) 334 335 # Check that nothing is modified, the album is imported ASIS. 336 self.assertItemFieldsModified(self.lib.items(), self.items_orig, 337 [], 338 self.IGNORED + ['albumartist', 339 'mb_albumartistid']) 340 self.assertTrue(all('Tag Title' in i.title 341 for i in self.lib.items())) 342 343 # Ensure album is *not* fetched from a candidate. 344 self.assertEqual(self.lib.albums()[0].mb_albumid, u'') 345 346 def test_edit_apply_candidate(self): 347 """Edit the album field for all items in the library, apply changes, 348 using a candidate. 349 """ 350 self._setup_import_session() 351 # Edit track titles. 352 self.run_mocked_interpreter({'replacements': {u'Applied Title': 353 u'Edited Title'}}, 354 # edit Candidates, 1, Apply changes. 355 ['c', '1', 'a']) 356 357 # Check that 'title' field is modified, and other fields come from 358 # the candidate. 359 self.assertTrue(all('Edited Title ' in i.title 360 for i in self.lib.items())) 361 self.assertTrue(all('match ' in i.mb_trackid 362 for i in self.lib.items())) 363 364 # Ensure album is fetched from a candidate. 365 self.assertIn('albumid', self.lib.albums()[0].mb_albumid) 366 367 def test_edit_retag_apply(self): 368 """Import the album using a candidate, then retag and edit and apply 369 changes. 370 """ 371 self._setup_import_session() 372 self.run_mocked_interpreter({}, 373 # 1, Apply changes. 374 ['1', 'a']) 375 376 # Retag and edit track titles. On retag, the importer will reset items 377 # ids but not the db connections. 378 self.importer.paths = [] 379 self.importer.query = TrueQuery() 380 self.run_mocked_interpreter({'replacements': {u'Applied Title': 381 u'Edited Title'}}, 382 # eDit, Apply changes. 383 ['d', 'a']) 384 385 # Check that 'title' field is modified, and other fields come from 386 # the candidate. 387 self.assertTrue(all('Edited Title ' in i.title 388 for i in self.lib.items())) 389 self.assertTrue(all('match ' in i.mb_trackid 390 for i in self.lib.items())) 391 392 # Ensure album is fetched from a candidate. 393 self.assertIn('albumid', self.lib.albums()[0].mb_albumid) 394 395 def test_edit_discard_candidate(self): 396 """Edit the album field for all items in the library, discard changes, 397 using a candidate. 398 """ 399 self._setup_import_session() 400 # Edit track titles. 401 self.run_mocked_interpreter({'replacements': {u'Applied Title': 402 u'Edited Title'}}, 403 # edit Candidates, 1, Apply changes. 404 ['c', '1', 'a']) 405 406 # Check that 'title' field is modified, and other fields come from 407 # the candidate. 408 self.assertTrue(all('Edited Title ' in i.title 409 for i in self.lib.items())) 410 self.assertTrue(all('match ' in i.mb_trackid 411 for i in self.lib.items())) 412 413 # Ensure album is fetched from a candidate. 414 self.assertIn('albumid', self.lib.albums()[0].mb_albumid) 415 416 def test_edit_apply_asis_singleton(self): 417 """Edit the album field for all items in the library, apply changes, 418 using the original item tags and singleton mode. 419 """ 420 self._setup_import_session(singletons=True) 421 # Edit track titles. 422 self.run_mocked_interpreter({'replacements': {u'Tag Title': 423 u'Edited Title'}}, 424 # eDit, Apply changes, aBort. 425 ['d', 'a', 'b']) 426 427 # Check that only the 'title' field is modified. 428 self.assertItemFieldsModified(self.lib.items(), self.items_orig, 429 ['title'], 430 self.IGNORED + ['albumartist', 431 'mb_albumartistid']) 432 self.assertTrue(all('Edited Title' in i.title 433 for i in self.lib.items())) 434 435 def test_edit_apply_candidate_singleton(self): 436 """Edit the album field for all items in the library, apply changes, 437 using a candidate and singleton mode. 438 """ 439 self._setup_import_session() 440 # Edit track titles. 441 self.run_mocked_interpreter({'replacements': {u'Applied Title': 442 u'Edited Title'}}, 443 # edit Candidates, 1, Apply changes, aBort. 444 ['c', '1', 'a', 'b']) 445 446 # Check that 'title' field is modified, and other fields come from 447 # the candidate. 448 self.assertTrue(all('Edited Title ' in i.title 449 for i in self.lib.items())) 450 self.assertTrue(all('match ' in i.mb_trackid 451 for i in self.lib.items())) 452 453 454def suite(): 455 return unittest.TestLoader().loadTestsFromName(__name__) 456 457if __name__ == '__main__': 458 unittest.main(defaultTest='suite') 459