beets/test/test_edit.py
Diego Moreda 6e6aa9700d edit: more assertions on existing tests, new tests
* Modify existing tests in order to explicitely check for differences (or lack
of) in the items fields, with the intention to ensure that no unintended changes
slip through.
* Added tests for modifying a single item from a list of items, and for editing
the album field of the items (stub for discussing whether the actual album
should be updated, etc).
2015-11-10 19:16:04 +01:00

226 lines
9.7 KiB
Python

# This file is part of beets.
# Copyright 2015, Adrian Sampson and Diego Moreda.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import (division, absolute_import, print_function,
unicode_literals)
import codecs
from mock import Mock, patch
from test._common import unittest
from test.helper import TestHelper, control_stdin
from beets import library
from beetsplug.edit import EditPlugin
class ModifyFileMocker(object):
"""Helper for modifying a file, replacing or editing its contents. Used for
mocking the calls to the external editor during testing."""
def __init__(self, contents=None, replacements=None):
""" `self.contents` and `self.replacements` are initalized here, in
order to keep the rest of the functions of this class with the same
signature as `EditPlugin.get_editor()`, making mocking easier.
- `contents`: string with the contents of the file to be used for
`overwrite_contents()`
- `replacement`: dict with the in-place replacements to be used for
`replace_contents()`, in the form {'previous string': 'new string'}
TODO: check if it can be solved more elegantly with a decorator
"""
self.contents = contents
self.replacements = replacements
self.action = self.overwrite_contents
if replacements:
self.action = self.replace_contents
def overwrite_contents(self, filename):
"""Modify `filename`, replacing its contents with `self.contents`. If
`self.contents` is empty, the file remains unchanged.
"""
if self.contents:
with codecs.open(filename, 'w', encoding='utf8') as f:
f.write(self.contents)
def replace_contents(self, filename):
"""Modify `filename`, reading its contents and replacing the strings
specified in `self.replacements`.
"""
with codecs.open(filename, 'r', encoding='utf8') as f:
contents = f.read()
for old, new_ in self.replacements.iteritems():
contents = contents.replace(old, new_)
with codecs.open(filename, 'w', encoding='utf8') as f:
f.write(contents)
class EditCommandTest(unittest.TestCase, TestHelper):
""" Black box tests for `beetsplug.edit`. Command line interaction is
simulated using `test.helper.control_stdin()`, and yaml editing via an
external editor is simulated using `ModifyFileMocker`.
"""
ALBUM_COUNT = 1
TRACK_COUNT = 10
def setUp(self):
self.setup_beets()
self.load_plugins('edit')
# make sure that we avoid invoking the editor except for making changes
self.config['edit']['diff_method'] = ''
# add an album, storing the original fields for comparison
self.album = self.add_album_fixture(track_count=self.TRACK_COUNT)
self.album_orig = {f: self.album[f] for f in self.album._fields}
self.items_orig = [{f: item[f] for f in item._fields} for
item in self.album.items()]
# keep track of write()s
library.Item.write = Mock()
def tearDown(self):
self.teardown_beets()
self.unload_plugins()
def run_mocked_command(self, modify_file_args={}, stdin=[]):
"""Run the edit command, with mocked stdin and yaml writing."""
m = ModifyFileMocker(**modify_file_args)
with patch.object(EditPlugin, 'get_editor', side_effect=m.action):
with control_stdin('\n'.join(stdin)):
self.run_command('edit')
def assertCounts(self, album_count=ALBUM_COUNT, track_count=TRACK_COUNT,
write_call_count=TRACK_COUNT, title_starts_with=''):
"""Several common assertions on Album, Track and call counts."""
self.assertEqual(len(self.lib.albums()), album_count)
self.assertEqual(len(self.lib.items()), track_count)
self.assertEqual(library.Item.write.call_count, write_call_count)
self.assertTrue(all(i.title.startswith(title_starts_with)
for i in self.lib.items()))
def assertItemFieldsModified(self, library_items, items, fields=[]):
"""Assert that items in the library (`lib_items`) have different values
on the specified `fields` (and *only* on those fields), compared to
`items`.
An empty `fields` list results in asserting that no modifications have
been performed.
"""
changed_fields = []
for lib_item, item in zip(library_items, items):
changed_fields.append([field for field in lib_item._fields
if lib_item[field] != item[field]])
self.assertTrue(all(diff_fields == fields for diff_fields in
changed_fields))
def test_title_edit_discard(self):
"""Edit title for all items in the library, then discard changes-"""
# edit titles
self.run_mocked_command({'replacements': {u't\u00eftle':
u'modified t\u00eftle'}},
# edit? y, done? y, modify? n
['y', 'y', 'n'])
self.assertCounts(write_call_count=0,
title_starts_with=u't\u00eftle')
self.assertItemFieldsModified(self.album.items(), self.items_orig, [])
def test_title_edit_apply(self):
"""Edit title for all items in the library, then apply changes."""
# edit titles
self.run_mocked_command({'replacements': {u't\u00eftle':
u'modified t\u00eftle'}},
# edit? y, done? y, modify? y
['y', 'y', 'y'])
self.assertCounts(write_call_count=self.TRACK_COUNT,
title_starts_with=u'modified t\u00eftle')
self.assertItemFieldsModified(self.album.items(), self.items_orig,
['title'])
def test_single_title_edit_apply(self):
"""Edit title for on items in the library, then apply changes."""
# edit title
self.run_mocked_command({'replacements': {u't\u00eftle 9':
u'modified t\u00eftle 9'}},
# edit? y, done? y, modify? y
['y', 'y', 'y'])
self.assertCounts(write_call_count=1,)
# no changes except on last item
self.assertItemFieldsModified(list(self.album.items())[:-1],
self.items_orig[:-1], [])
self.assertEqual(list(self.album.items())[-1].title,
u'modified t\u00eftle 9')
def test_noedit(self):
"""Do not edit anything."""
# do not edit anything
self.run_mocked_command({'contents': None},
# edit? n -> "no changes found"
['n'])
self.assertCounts(write_call_count=0,
title_starts_with=u't\u00eftle')
self.assertItemFieldsModified(self.album.items(), self.items_orig, [])
def test_album_edit_apply(self):
"""Edit the album field for all items in the library, apply changes
TODO: decide if the plugin should be wise enough to update the album,
and handle other complex cases (create new albums, etc). At the moment
this test only checks for modifications on items.
"""
# edit album
self.run_mocked_command({'replacements': {u'\u00e4lbum':
u'modified \u00e4lbum'}},
# edit? y, done? y, modify? y
['y', 'y', 'y'])
self.assertCounts(write_call_count=self.TRACK_COUNT)
self.assertItemFieldsModified(self.album.items(), self.items_orig,
['album'])
def test_malformed_yaml_discard(self):
"""Edit the yaml file incorrectly (resulting in a malformed yaml
document), then discard changes.
TODO: this test currently fails, on purpose. User gets into an endless
"fix?" -> "ok.fixed." prompt loop unless he is able to provide a
well-formed yaml."""
# edit the yaml file to an invalid file
self.run_mocked_command({'contents': '!MALFORMED'},
# edit? n, done? y, fix? n
['y', 'y', 'n'])
self.assertCounts(write_call_count=0,
title_starts_with=u't\u00eftle')
def test_invalid_yaml_discard(self):
"""Edit the yaml file incorrectly (resulting in a well-formed but
invalid yaml document), then discard changes.
TODO: this test currently fails, on purpose. `check_diff()` chokes
ungracefully"""
# edit the yaml file to an invalid file
self.run_mocked_command({'contents': 'wellformed: yes, but invalid'},
# edit? n, done? y
['y', 'y'])
self.assertCounts(write_call_count=0,
title_starts_with=u't\u00eftle')
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == b'__main__':
unittest.main(defaultTest='suite')