Example #1
0
class TestItem(unittest.TestCase):
    """Unit tests for the Item class."""

    # pylint: disable=protected-access,no-value-for-parameter

    def setUp(self):
        path = os.path.join('path', 'to', 'RQ001.yml')
        self.item = MockItem(MockSimpleDocument(), path)

    def test_init_invalid(self):
        """Verify an item cannot be initialized from an invalid path."""
        self.assertRaises(DoorstopError, Item, None, 'not/a/path')

    def test_no_tree_references(self):
        """Verify a standalone item has no tree reference."""
        self.assertIs(None, self.item.tree)

    def test_load_empty(self):
        """Verify loading calls read."""
        self.item.load()
        self.item._read.assert_called_once_with(self.item.path)

    def test_load_error(self):
        """Verify an exception is raised with invalid YAML."""
        self.item._file = "invalid: -"
        self.assertRaises(DoorstopError, self.item.load)

    def test_load_unexpected(self):
        """Verify an exception is raised for unexpected file contents."""
        self.item._file = "unexpected"
        self.assertRaises(DoorstopError, self.item.load)

    def test_save_empty(self):
        """Verify saving calls write."""
        self.item.save()
        self.item._write.assert_called_once_with(YAML_DEFAULT, self.item.path)

    def test_set_attributes(self):
        """Verify setting attributes calls write with the attributes."""
        self.item.set_attributes({
            'a': ['b', 'c'],
            'd': {
                'e': 'f',
                'g': 'h'
            },
            'i': 'j',
            'k': None,
            'text': 'something',
        })
        self.item._write.assert_called_once_with(YAML_EXTENDED_ATTRIBUTES,
                                                 self.item.path)

    def test_string_attributes(self):
        """Verify string attributes are properly formatted."""
        self.item.set_attributes({
            'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa':
            'b',
            'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc':
            'd',
            'e':
            'fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
            'g':
            'hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh',
            'i': {
                'jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj':
                'k',
                'llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll':
                'm',
                'n':
                'ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo',
                'p':
                'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq',
                'r': [
                    'ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss',
                    'ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt',
                ],
            },
        })
        self.item._write.assert_called_once_with(YAML_STRING_ATTRIBUTES,
                                                 self.item.path)

    @patch('doorstop.common.verbosity', 2)
    def test_str(self):
        """Verify an item can be converted to a string."""
        self.assertEqual("RQ001", str(self.item))

    @patch('doorstop.common.verbosity', 3)
    def test_str_verbose(self):
        """Verify an item can be converted to a string (verbose)."""
        text = "RQ001 (@{}{})".format(os.sep, self.item.path)
        self.assertEqual(text, str(self.item))

    def test_hash(self):
        """Verify items can be hashed."""
        item1 = MockItem(None, 'path/to/fake1.yml')
        item2 = MockItem(None, 'path/to/fake2.yml')
        item3 = MockItem(None, 'path/to/fake2.yml')
        my_set = set()
        # Act
        my_set.add(item1)
        my_set.add(item2)
        my_set.add(item3)
        # Assert
        self.assertEqual(2, len(my_set))

    def test_ne(self):
        """Verify item non-equality is correct."""
        self.assertNotEqual(self.item, None)

    def test_lt(self):
        """Verify items can be compared."""
        item1 = MockItem(None, 'path/to/fake1.yml')
        item1.level = (1, 1)
        item2 = MockItem(None, 'path/to/fake1.yml')
        item2.level = (1, 1, 1)
        item3 = MockItem(None, 'path/to/fake1.yml')
        item3.level = (1, 1, 2)
        self.assertLess(item1, item2)
        self.assertLess(item2, item3)
        self.assertGreater(item3, item1)

    def test_uid(self):
        """Verify an item's UID can be read but not set."""
        self.assertEqual('RQ001', self.item.uid)
        self.assertRaises(AttributeError, setattr, self.item, 'uid', 'RQ002')

    def test_relpath(self):
        """Verify an item's relative path string can be read but not set."""
        text = "@{}{}".format(os.sep, self.item.path)
        self.assertEqual(text, self.item.relpath)
        self.assertRaises(AttributeError, setattr, self.item, 'relpath', '.')

    def test_prefix(self):
        """Verify an item's prefix can be read but not set."""
        self.assertEqual('RQ', self.item.prefix)
        self.assertRaises(AttributeError, setattr, self.item, 'prefix', 'REQ')

    def test_number(self):
        """Verify an item's number can be read but not set."""
        self.assertEqual(1, self.item.number)
        self.assertRaises(AttributeError, setattr, self.item, 'number', 2)

    def test_level(self):
        """Verify an item's level can be set and read."""
        self.item.level = (1, 2, 3)
        self.assertIn("level: 1.2.3\n", self.item._write.call_args[0][0])
        self.assertEqual((1, 2, 3), self.item.level)

    def test_level_with_float(self):
        """Verify an item's level can be set and read (2-part w/ float)."""
        self.item.level = (1, 10)
        self.assertIn("level: '1.10'\n", self.item._write.call_args[0][0])
        self.assertEqual((1, 10), self.item.level)

    def test_depth(self):
        """Verify the depth can be read from the item's level."""
        self.item.level = (1, )
        self.assertEqual(1, self.item.depth)
        self.item.level = (1, 0)
        self.assertEqual(1, self.item.depth)
        self.item.level = (2, 0, 1)
        self.assertEqual(3, self.item.depth)
        self.item.level = (2, 0, 1, 1, 0, 0)
        self.assertEqual(4, self.item.depth)

    def test_level_from_text(self):
        """Verify an item's level can be set from text and read."""
        self.item.level = "4.2.0 "
        self.assertIn("level: 4.2.0\n", self.item._write.call_args[0][0])
        self.assertEqual((4, 2), self.item.level)

    def test_level_from_text_2_digits(self):
        """Verify an item's level can be set from text (2 digits) and read."""
        self.item.level = "10.10"
        self.assertIn("level: '10.10'\n", self.item._write.call_args[0][0])
        self.assertEqual((10, 10), self.item.level)

    def test_level_from_float(self):
        """Verify an item's level can be set from a float and read."""
        self.item.level = 4.2
        self.assertIn("level: 4.2\n", self.item._write.call_args[0][0])
        self.assertEqual((4, 2), self.item.level)

    def test_level_from_int(self):
        """Verify an item's level can be set from a int and read."""
        self.item.level = 42
        self.assertIn("level: 42\n", self.item._write.call_args[0][0])
        self.assertEqual((42, ), self.item.level)

    def test_active(self):
        """Verify an item's active status can be set and read."""
        self.item.active = 0  # converted to False
        self.assertIn("active: false\n", self.item._write.call_args[0][0])
        self.assertFalse(self.item.active)

    def test_derived(self):
        """Verify an item's normative status can be set and read."""
        self.item.derived = 1  # converted to True
        self.assertIn("derived: true\n", self.item._write.call_args[0][0])
        self.assertTrue(self.item.derived)

    def test_normative(self):
        """Verify an item's normative status can be set and read."""
        self.item.normative = 0  # converted to False
        self.assertIn("normative: false\n", self.item._write.call_args[0][0])
        self.assertFalse(self.item.normative)

    def test_heading(self):
        """Verify an item's heading status can be set and read."""
        self.item.level = '1.1.1'
        self.item.heading = 1  # converted to True
        self.assertFalse(self.item.normative)
        self.assertTrue(self.item.heading)
        self.item.heading = 0  # converted to False
        self.assertTrue(self.item.normative)
        self.assertFalse(self.item.heading)

    def test_reviwed(self):
        """Verify an item's review status can be set and read."""
        self.assertFalse(self.item.reviewed)  # not reviewed by default
        self.item.reviewed = 1  # calls `review()`
        self.assertTrue(self.item.reviewed)
        self.item.reviewed = 0  # converted to None
        self.assertFalse(self.item.reviewed)

    def test_text(self):
        """Verify an item's text can be set and read."""
        value = "abc "
        text = "abc"
        yaml = "text: |\n  abc\n"
        self.item.text = value
        self.assertEqual(text, self.item.text)
        self.assertIn(yaml, self.item._write.call_args[0][0])

    def test_text_sbd(self):
        """Verify newlines separate sentences in an item's text."""
        value = ("A sentence. Another sentence! Hello? Hi.\n"
                 "A new line (here). And another sentence.")
        text = ("A sentence. Another sentence! Hello? Hi.\n"
                "A new line (here). And another sentence.")
        yaml = ("text: |\n"
                "  A sentence. Another sentence! Hello? Hi.\n"
                "  A new line (here). And another sentence.\n")
        self.item.text = value
        self.assertEqual(text, self.item.text)
        self.assertIn(yaml, self.item._write.call_args[0][0])

    def test_text_ordered_list(self):
        """Verify newlines are preserved in an ordered list."""
        self.item.text = "A list:\n\n1. Abc\n2. Def\n"
        expected = "A list:\n\n1. Abc\n2. Def"
        self.assertEqual(expected, self.item.text)

    def test_text_unordered_list(self):
        """Verify newlines are preserved in an ordered list."""
        self.item.text = "A list:\n\n- Abc\n- Def\n"
        expected = "A list:\n\n- Abc\n- Def"
        self.assertEqual(expected, self.item.text)

    def test_text_split_numbers(self):
        """Verify lines ending in numbers aren't changed."""
        self.item.text = "Split at a number: 1\n42 or punctuation.\nHere."
        expected = "Split at a number: 1\n42 or punctuation.\nHere."
        self.assertEqual(expected, self.item.text)

    def test_text_newlines(self):
        """Verify newlines are preserved when deliberate."""
        self.item.text = "Some text.\n\nNote: here.\n"
        expected = "Some text.\n\nNote: here."
        self.assertEqual(expected, self.item.text)

    def test_text_formatting(self):
        """Verify newlines are not removed around formatting."""
        self.item.text = "The thing\n**_SHALL_** do this.\n"
        expected = "The thing\n**_SHALL_** do this."
        self.assertEqual(expected, self.item.text)

    def test_text_non_heading(self):
        """Verify newlines are preserved around non-headings."""
        self.item.text = "break (before \n#2) symbol should not be a heading."
        expected = "break (before\n#2) symbol should not be a heading."
        self.assertEqual(expected, self.item.text)

    def test_text_heading(self):
        """Verify newlines are preserved around headings."""
        self.item.text = "should be a heading\n\n# right here"
        expected = "should be a heading\n\n# right here"
        self.assertEqual(expected, self.item.text)

    def test_ref(self):
        """Verify an item's reference can be set and read."""
        self.item.ref = "abc123"
        self.assertIn("ref: abc123\n", self.item._write.call_args[0][0])
        self.assertEqual("abc123", self.item.ref)

    def test_extended(self):
        """Verify an extended attribute (`str`) can be used."""
        self.item.set('ext1', 'foobar')
        self.assertIn("ext1: foobar\n", self.item._write.call_args[0][0])
        self.assertEqual('foobar', self.item.get('ext1'))
        self.assertEqual(['ext1'], self.item.extended)

    def test_extended_text(self):
        """Verify an extended attribute (`Text`) can be used."""
        self.item.set('ext1', Text('foobar'))
        self.assertIn("ext1: foobar\n", self.item._write.call_args[0][0])
        self.assertEqual('foobar', self.item.get('ext1'))
        self.assertEqual(['ext1'], self.item.extended)

    def test_extended_wrap(self):
        """Verify a long extended attribute is wrapped."""
        text = "This extended attribute should be long enough to wrap."
        self.item.set('a_very_long_extended_attr', text)
        self.assertEqual(text, self.item.get('a_very_long_extended_attr'))

    def test_extended_wrap_multi(self):
        """Verify a long extended attribute is wrapped with newlines."""
        text = "Another extended attribute.\n\nNote: with a note."
        self.item.set('ext2', text)
        self.assertEqual(text, self.item.get('ext2'))

    def test_extended_get_standard(self):
        """Verify extended attribute access can get standard properties."""
        active = self.item.get('active')
        self.assertEqual(self.item.active, active)

    def test_extended_set_standard(self):
        """Verify extended attribute access can set standard properties."""
        self.item.set('text', "extended access")
        self.assertEqual("extended access", self.item.text)

    @patch('doorstop.core.editor.launch')
    def test_edit(self, mock_launch):
        """Verify an item can be edited."""
        self.item.tree = Mock()
        # Act
        self.item.edit(tool='mock_editor')
        # Assert
        self.item.tree.vcs.lock.assert_called_once_with(self.item.path)
        self.item.tree.vcs.edit.assert_called_once_with(self.item.path)
        mock_launch.assert_called_once_with(self.item.path, tool='mock_editor')

    def test_link(self):
        """Verify links can be added to an item."""
        self.item.link('abc')
        self.item.link('123')
        self.assertEqual(['123', 'abc'], self.item.links)

    def test_link_duplicate(self):
        """Verify duplicate links are ignored."""
        self.item.link('abc')
        self.item.link('abc')
        self.assertEqual(['abc'], self.item.links)

    def test_unlink_duplicate(self):
        """Verify removing a link twice is not an error."""
        self.item.links = ['123', 'abc']
        self.item.unlink('abc')
        self.item.unlink('abc')
        self.assertEqual(['123'], self.item.links)

    def test_link_by_item(self):
        """Verify links can be added to an item (by item)."""
        path = os.path.join('path', 'to', 'ABC123.yml')
        item = MockItem(None, path)
        self.item.link(item)
        self.assertEqual(['ABC123'], self.item.links)

    def test_unlink_by_item(self):
        """Verify links can be removed (by item)."""
        path = os.path.join('path', 'to', 'ABC123.yml')
        item = MockItem(None, path)
        self.item.links = ['ABC123']
        self.item.unlink(item)
        self.assertEqual([], self.item.links)

    def test_links_alias(self):
        """Verify 'parent_links' is an alias for links."""
        links1 = ['alias1']
        links2 = ['alias2']
        self.item.parent_links = links1
        self.assertEqual(links1, self.item.links)
        self.item.links = links2
        self.assertEqual(links2, self.item.parent_links)

    def test_parent_items(self):
        """Verify 'parent_items' exists to mirror the child behavior."""
        mock_tree = Mock()
        mock_tree.find_item = Mock(return_value='mock_item')
        self.item.tree = mock_tree
        self.item.links = ['mock_uid']
        # Act
        items = self.item.parent_items
        # Assert
        self.assertEqual(['mock_item'], items)

    def test_parent_items_unknown(self):
        """Verify 'parent_items' can handle unknown items."""
        mock_tree = Mock()
        mock_tree.find_item = Mock(side_effect=DoorstopError)
        self.item.tree = mock_tree
        self.item.links = ['mock_uid']
        # Act
        items = self.item.parent_items
        # Assert
        self.assertIsInstance(items[0], UnknownItem)

    def test_parent_documents(self):
        """Verify 'parent_documents' exists to mirror the child behavior."""
        mock_tree = Mock()
        mock_tree.find_document = Mock(return_value='mock_document')
        self.item.tree = mock_tree
        self.item.links = ['mock_uid']
        self.item.document = Mock()
        self.item.document.prefix = 'mock_prefix'
        # Act
        documents = self.item.parent_documents
        # Assert
        self.assertEqual(['mock_document'], documents)

    def test_parent_documents_unknown(self):
        """Verify 'parent_documents' can handle unknown documents."""
        mock_tree = Mock()
        mock_tree.find_document = Mock(side_effect=DoorstopError)
        self.item.tree = mock_tree
        self.item.links = ['mock_uid']
        self.item.document = Mock()
        self.item.document.prefix = 'mock_prefix'
        # Act
        documents = self.item.parent_documents
        # Assert
        self.assertEqual([], documents)

    @patch('doorstop.settings.CACHE_PATHS', False)
    def test_find_ref(self):
        """Verify an item's reference can be found."""
        self.item.ref = "REF" "123"  # space to avoid matching in this file
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(EXTERNAL)
        # Act
        relpath, line = self.item.find_ref()
        # Assert
        self.assertEqual('text.txt', os.path.basename(relpath))
        self.assertEqual(3, line)

    def test_find_ref_filename(self):
        """Verify an item's reference can also be a filename."""
        self.item.ref = "text.txt"
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(FILES)
        self.item.tree.vcs._ignores_cache = ["*published*"]
        # Act
        relpath, line = self.item.find_ref()
        # Assert
        self.assertEqual('text.txt', os.path.basename(relpath))
        self.assertEqual(None, line)

    def test_find_ref_error(self):
        """Verify an error occurs when no external reference found."""
        self.item.ref = "not" "found"  # space to avoid matching in this file
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(EMPTY)
        # Act and assert
        self.assertRaises(DoorstopError, self.item.find_ref)

    def test_find_skip_self(self):
        """Verify reference searches skip the item's file."""
        self.item.path = __file__
        self.item.ref = "148710938710289248"  # random and unique to this file
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(EMPTY)
        self.item.tree.vcs._path_cache = [(__file__, 'filename', 'relpath')]
        # Act and assert
        self.assertRaises(DoorstopError, self.item.find_ref)

    def test_find_ref_none(self):
        """Verify nothing returned when no external reference is specified."""
        self.item.tree = Mock()
        self.assertEqual((None, None), self.item.find_ref())

    def test_find_child_objects(self):
        """Verify an item's child objects can be found."""

        mock_document_p = Mock()
        mock_document_p.prefix = 'RQ'

        mock_document_c = Mock()
        mock_document_c.parent = 'RQ'

        mock_item = Mock()
        mock_item.uid = 'TST001'
        mock_item.links = ['RQ001']

        def mock_iter(self):  # pylint: disable=W0613
            """Mock Tree.__iter__ to yield a mock Document."""
            def mock_iter2(self):  # pylint: disable=W0613
                """Mock Document.__iter__ to yield a mock Item."""
                yield mock_item

            mock_document_c.__iter__ = mock_iter2
            yield mock_document_c

        self.item.link('fake1')
        mock_tree = Mock()
        mock_tree.__iter__ = mock_iter
        mock_tree.find_item = lambda uid: Mock(uid='fake1')
        self.item.tree = mock_tree
        self.item.document = mock_document_p

        links = self.item.find_child_links()
        items = self.item.find_child_items()
        documents = self.item.find_child_documents()
        self.assertEqual(['TST001'], links)
        self.assertEqual([mock_item], items)
        self.assertEqual([mock_document_c], documents)

    def test_find_child_objects_standalone(self):
        """Verify a standalone item has no child objects."""
        self.assertEqual([], self.item.child_links)
        self.assertEqual([], self.item.child_items)
        self.assertEqual([], self.item.child_documents)

    def test_invalid_file_name(self):
        """Verify an invalid file name cannot be a requirement."""
        self.assertRaises(DoorstopError, MockItem, None, "path/to/REQ.yaml")
        self.assertRaises(DoorstopError, MockItem, None, "path/to/001.yaml")

    def test_invalid_file_ext(self):
        """Verify an invalid file extension cannot be a requirement."""
        self.assertRaises(DoorstopError, MockItem, None, "path/to/REQ001")
        self.assertRaises(DoorstopError, MockItem, None, "path/to/REQ001.txt")

    @patch('doorstop.core.item.Item', MockItem)
    def test_new(self):
        """Verify items can be created."""
        MockItem._create.reset_mock()
        item = MockItem.new(None,
                            MockSimpleDocument(),
                            EMPTY,
                            FILES,
                            'TEST00042',
                            level=(1, 2, 3))
        path = os.path.join(EMPTY, 'TEST00042.yml')
        self.assertEqual(path, item.path)
        self.assertEqual((1, 2, 3), item.level)
        MockItem._create.assert_called_once_with(path, name='item')

    @patch('doorstop.core.item.Item', MockItem)
    def test_new_cache(self):
        """Verify new items are cached."""
        mock_tree = Mock()
        mock_tree._item_cache = {}
        item = MockItem.new(mock_tree,
                            MockSimpleDocument(),
                            EMPTY,
                            FILES,
                            'TEST00042',
                            level=(1, 2, 3))
        self.assertEqual(item, mock_tree._item_cache[item.uid])
        mock_tree.vcs.add.assert_called_once_with(item.path)

    @patch('doorstop.core.item.Item', MockItem)
    def test_new_special(self):
        """Verify items can be created with a specially named prefix."""
        MockItem._create.reset_mock()
        item = MockItem.new(None,
                            MockSimpleDocument(),
                            EMPTY,
                            FILES,
                            'VSM.HLR_01-002-042',
                            level=(1, 0))
        path = os.path.join(EMPTY, 'VSM.HLR_01-002-042.yml')
        self.assertEqual(path, item.path)
        self.assertEqual((1, ), item.level)
        MockItem._create.assert_called_once_with(path, name='item')

    def test_new_existing(self):
        """Verify an exception is raised if the item already exists."""
        self.assertRaises(DoorstopError,
                          Item.new,
                          None,
                          None,
                          FILES,
                          FILES,
                          'REQ002',
                          level=(1, 2, 3))

    def test_validate_invalid_ref(self):
        """Verify an invalid reference fails validity."""
        with patch(
                'doorstop.core.item.Item.find_ref',
                Mock(side_effect=DoorstopError("test invalid ref")),
        ):
            with ListLogHandler(core.base.log) as handler:
                self.assertFalse(self.item.validate())
                self.assertIn("test invalid ref", handler.records)

    def test_validate_inactive(self):
        """Verify an inactive item is not checked."""
        self.item.active = False
        with patch('doorstop.core.item.Item.find_ref',
                   Mock(side_effect=DoorstopError)):
            self.assertTrue(self.item.validate())

    def test_validate_reviewed(self):
        """Verify that checking a reviewed item updates the stamp."""
        self.item._data['reviewed'] = True
        self.assertTrue(self.item.validate())
        stamp = 'c6a87755b8756b61731c704c6a7be4a2'
        self.assertEqual(stamp, self.item._data['reviewed'])

    @patch('doorstop.settings.REVIEW_NEW_ITEMS', False)
    def test_validate_reviewed_first(self):
        """Verify that a missing initial review leaves the stamp empty."""
        self.item._data['reviewed'] = Stamp(None)
        self.assertTrue(self.item.validate())
        self.assertEqual(Stamp(None), self.item._data['reviewed'])

    @patch('doorstop.settings.ERROR_ALL', True)
    def test_validate_reviewed_second(self):
        """Verify that a modified stamp fails review."""
        self.item._data['reviewed'] = Stamp('abc123')
        with ListLogHandler(core.base.log) as handler:
            self.assertFalse(self.item.validate())
            self.assertIn("unreviewed changes", handler.records)

    def test_validate_cleared(self):
        """Verify that checking a cleared link updates the stamp."""
        mock_item = Mock()
        mock_item.stamp = Mock(return_value=Stamp('abc123'))
        mock_tree = MagicMock()
        mock_tree.find_item = Mock(return_value=mock_item)
        self.item.tree = mock_tree
        self.item.links = [{'mock_uid': True}]
        self.item.disable_get_issues_document()
        self.assertTrue(self.item.validate())
        self.assertEqual('abc123', self.item.links[0].stamp)

    def test_validate_cleared_new(self):
        """Verify that new links are stamped automatically."""
        mock_item = Mock()
        mock_item.stamp = Mock(return_value=Stamp('abc123'))
        mock_tree = MagicMock()
        mock_tree.find_item = Mock(return_value=mock_item)
        self.item.tree = mock_tree
        self.item.links = [{'mock_uid': None}]
        self.item.disable_get_issues_document()
        self.assertTrue(self.item.validate())
        self.assertEqual('abc123', self.item.links[0].stamp)

    def test_validate_nonnormative_with_links(self):
        """Verify a non-normative item with links can be checked."""
        self.item.normative = False
        self.item.links = ['a']
        self.item.disable_get_issues_document()
        self.assertTrue(self.item.validate())

    @patch('doorstop.settings.STAMP_NEW_LINKS', False)
    def test_validate_link_to_inactive(self):
        """Verify a link to an inactive item can be checked."""
        mock_item = Mock()
        mock_item.active = False
        mock_tree = MagicMock()
        mock_tree.find_item = Mock(return_value=mock_item)
        self.item.links = ['a']
        self.item.tree = mock_tree
        self.item.disable_get_issues_document()
        self.assertTrue(self.item.validate())

    @patch('doorstop.settings.STAMP_NEW_LINKS', False)
    def test_validate_link_to_nonnormative(self):
        """Verify a link to an non-normative item can be checked."""
        mock_item = Mock()
        mock_item.normative = False
        mock_tree = MagicMock()
        mock_tree.find_item = Mock(return_value=mock_item)
        self.item.links = ['a']
        self.item.tree = mock_tree
        self.item.disable_get_issues_document()
        self.assertTrue(self.item.validate())

    def test_validate_document(self):
        """Verify an item can be checked against a document."""
        self.item.document.parent = 'fake'
        self.assertTrue(self.item.validate())

    def test_validate_document_with_links(self):
        """Verify an item can be checked against a document with links."""
        self.item.link('unknown1')
        self.item.document.parent = 'fake'
        self.assertTrue(self.item.validate())

    def test_validate_document_with_bad_link_uids(self):
        """Verify an item can be checked against a document w/ bad links."""
        self.item.link('invalid')
        self.item.document.parent = 'fake'
        with ListLogHandler(core.base.log) as handler:
            self.assertFalse(self.item.validate())
            self.assertIn("invalid UID in links: invalid", handler.records)

    @patch('doorstop.settings.STAMP_NEW_LINKS', False)
    def test_validate_tree(self):
        """Verify an item can be checked against a tree."""
        def mock_iter(self):  # pylint: disable=W0613
            """Mock Tree.__iter__ to yield a mock Document."""
            mock_document = Mock()
            mock_document.parent = 'RQ'

            def mock_iter2(self):  # pylint: disable=W0613
                """Mock Document.__iter__ to yield a mock Item."""
                mock_item = Mock()
                mock_item.uid = 'TST001'
                mock_item.links = ['RQ001']
                yield mock_item

            mock_document.__iter__ = mock_iter2
            yield mock_document

        self.item.link('fake1')

        mock_tree = Mock()
        mock_tree.__iter__ = mock_iter
        mock_tree.find_item = lambda uid: Mock(uid='fake1')

        self.item.tree = mock_tree

        self.assertTrue(self.item.validate())

    def test_validate_tree_error(self):
        """Verify an item can be checked against a tree with errors."""
        self.item.link('fake1')
        mock_tree = MagicMock()
        mock_tree.find_item = Mock(side_effect=DoorstopError)
        self.item.tree = mock_tree
        with ListLogHandler(core.base.log) as handler:
            self.assertFalse(self.item.validate())
            self.assertIn("linked to unknown item: fake1", handler.records)

    @patch('doorstop.settings.REVIEW_NEW_ITEMS', False)
    def test_validate_both(self):
        """Verify an item can be checked against both."""
        def mock_iter(seq):
            """Creates a mock __iter__ method."""
            def _iter(self):  # pylint: disable=W0613
                """Mock __iter__method."""
                yield from seq

            return _iter

        mock_item = Mock()
        mock_item.links = [self.item.uid]

        self.item.document.parent = 'BOTH'
        self.item.document.prefix = 'BOTH'
        self.item.document.set_items([mock_item])

        mock_tree = Mock()
        mock_tree.__iter__ = mock_iter([self.item.document])
        self.item.tree = mock_tree

        self.assertTrue(self.item.validate())

    @patch('doorstop.settings.STAMP_NEW_LINKS', False)
    @patch('doorstop.settings.REVIEW_NEW_ITEMS', False)
    def test_validate_both_no_reverse_links(self):
        """Verify an item can be checked against both (no reverse links)."""
        def mock_iter(self):  # pylint: disable=W0613
            """Mock Tree.__iter__ to yield a mock Document."""
            mock_document = Mock()
            mock_document.parent = 'RQ'

            def mock_iter2(self):  # pylint: disable=W0613
                """Mock Document.__iter__ to yield a mock Item."""
                mock_item = Mock()
                mock_item.uid = 'TST001'
                mock_item.links = []
                yield mock_item

            mock_document.__iter__ = mock_iter2
            yield mock_document

        self.item.link('fake1')

        mock_tree = Mock()
        mock_tree.__iter__ = mock_iter
        mock_tree.find_item = lambda uid: Mock(uid='fake1')
        self.item.tree = mock_tree

        self.assertTrue(self.item.validate())

    @patch('doorstop.core.item.Item.get_issues', Mock(return_value=[]))
    def test_issues(self):
        """Verify an item's issues convenience property can be accessed."""
        self.assertEqual(0, len(self.item.issues))

    def test_stamp(self):
        """Verify an item's contents can be stamped."""
        stamp = 'c6a87755b8756b61731c704c6a7be4a2'
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_one_extended_reviewed(self):
        """Verify fingerprint with one extended reviewed attribute."""
        self.item._data['type'] = 'functional'
        self.item.document.extended_reviewed = ['type']
        stamp = '04fdd093f67ce3a3160dfdc5d93e7813'
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_two_extended_reviewed(self):
        """Verify fingerprint with two extended reviewed attributes."""
        self.item._data['type'] = 'functional'
        self.item._data['verification-method'] = 'test'
        self.item.document.extended_reviewed = ['type', 'verification-method']
        stamp = 'cf8aaea03cd5765bac978ad74a42d729'
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_reversed_extended_reviewed_reverse(self):
        """Verify fingerprint with reversed extended reviewed attributes."""
        self.item._data['type'] = 'functional'
        self.item._data['verification-method'] = 'test'
        self.item.document.extended_reviewed = ['verification-method', 'type']
        stamp = '7b14dfcc17026e98790284c5cddb0900'
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_missing_extended_reviewed_reverse(self):
        """Verify fingerprint with missing extended reviewed attribute."""
        with ListLogHandler(core.item.log) as handler:
            self.item._data['type'] = 'functional'
            self.item._data['verification-method'] = 'test'
            self.item.document.extended_reviewed = [
                'missing',
                'type',
                'verification-method',
            ]
            stamp = 'cf8aaea03cd5765bac978ad74a42d729'
            self.assertEqual(stamp, self.item.stamp())
            self.assertIn(
                "RQ001: missing extended reviewed attribute: missing",
                handler.records)

    def test_stamp_links(self):
        """Verify an item's contents can be stamped."""
        self.item.link('mock_link')
        stamp = '1020719292bbdc4090bd236cf41cd104'
        self.assertEqual(stamp, self.item.stamp(links=True))

    def test_clear(self):
        """Verify an item's links can be cleared as suspect."""
        mock_item = Mock()
        mock_item.uid = 'mock_uid'
        mock_item.stamp = Mock(return_value=Stamp('abc123'))
        mock_tree = MagicMock()
        mock_tree.find_item = Mock(return_value=mock_item)
        self.item.tree = mock_tree
        self.item.link('mock_uid')
        self.assertFalse(self.item.cleared)
        self.assertEqual(None, self.item.links[0].stamp)
        # Act
        self.item.clear()
        # Assert
        self.assertTrue(self.item.cleared)
        self.assertEqual('abc123', self.item.links[0].stamp)

    def test_clear_by_uid(self):
        """Verify an item's links can be cleared as suspect by UID."""
        mock_item = Mock()
        mock_item.uid = 'mock_uid'
        mock_item.stamp = Mock(return_value=Stamp('abc123'))
        mock_tree = MagicMock()
        mock_tree.find_item = Mock(return_value=mock_item)
        self.item.tree = mock_tree
        self.item.link('mock_uid')
        self.assertFalse(self.item.cleared)
        self.assertEqual(None, self.item.links[0].stamp)
        # Act
        self.item.clear(['other_uid'])
        # Assert
        self.assertFalse(self.item.cleared)
        self.assertEqual(None, self.item.links[0].stamp)
        # Act
        self.item.clear(['mock_uid'])
        # Assert
        self.assertTrue(self.item.cleared)
        self.assertEqual('abc123', self.item.links[0].stamp)

    def test_review(self):
        """Verify an item can be marked as reviewed."""
        self.item.reviewed = False
        self.item.review()
        self.assertTrue(self.item.reviewed)

    @patch('doorstop.common.delete')
    def test_delete(self, mock_delete):
        """Verify an item can be deleted."""
        self.item.delete()
        mock_delete.assert_called_once_with(self.item.path)
        self.item.delete()  # ensure a second delete is ignored

    @patch('doorstop.common.delete', Mock())
    def test_delete_cache(self):
        """Verify an item is expunged after delete."""
        self.item.tree = Mock()
        self.item.tree._item_cache = {self.item.uid: self.item}
        self.item.delete()
        self.item.tree.vcs.delete.assert_called_once_with(self.item.path)
        self.assertIs(None, self.item.tree._item_cache[self.item.uid])
Example #2
0
class TestItem(unittest.TestCase):
    """Unit tests for the Item class."""

    # pylint: disable=protected-access,no-value-for-parameter

    def setUp(self):
        path = os.path.join('path', 'to', 'RQ001.yml')
        self.item = MockItem(MockSimpleDocument(), path)

    def test_init_invalid(self):
        """Verify an item cannot be initialized from an invalid path."""
        self.assertRaises(DoorstopError, Item, None, 'not/a/path')

    def test_no_tree_references(self):
        """Verify a standalone item has no tree reference."""
        self.assertIs(None, self.item.tree)

    def test_load_empty(self):
        """Verify loading calls read."""
        self.item.load()
        self.item._read.assert_called_once_with(self.item.path)

    def test_load_error(self):
        """Verify an exception is raised with invalid YAML."""
        self.item._file = "invalid: -"
        self.assertRaises(DoorstopError, self.item.load)

    def test_load_unexpected(self):
        """Verify an exception is raised for unexpected file contents."""
        self.item._file = "unexpected"
        self.assertRaises(DoorstopError, self.item.load)

    def test_save_empty(self):
        """Verify saving calls write."""
        self.item.save()
        self.item._write.assert_called_once_with(YAML_DEFAULT, self.item.path)

    def test_set_attributes(self):
        """Verify setting attributes calls write with the attributes."""
        self.item.set_attributes(
            {
                'a': ['b', 'c'],
                'd': {'e': 'f', 'g': 'h'},
                'i': 'j',
                'k': None,
                'text': 'something',
            }
        )
        self.item._write.assert_called_once_with(
            YAML_EXTENDED_ATTRIBUTES, self.item.path
        )

    def test_string_attributes(self):
        """Verify string attributes are properly formatted."""
        self.item.set_attributes(
            {
                'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa': 'b',
                'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc': 'd',
                'e': 'fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
                'g': 'hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh',
                'i': {
                    'jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj': 'k',
                    'llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll': 'm',
                    'n': 'ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo',
                    'p': 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq',
                    'r': [
                        'ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss',
                        'ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt',
                    ],
                },
            }
        )
        self.item._write.assert_called_once_with(YAML_STRING_ATTRIBUTES, self.item.path)

    def test_set_attributes_reference_valid_input(self):
        """Verify that setting 'references' with a correct value does not raise errors."""
        try:
            self.item._set_attributes(
                {'references': [{'type': 'file', 'path': 'some/path'}]}
            )
        except AttributeError:
            self.fail("didn't expect _set_attributes to raise AttributeError")

    def test_set_attributes_reference_malformed_input(self):
        """Verify that setting 'references' with a wrong value raises errors."""
        with self.assertRaises(AttributeError):
            self.item._set_attributes({'references': 'foo'})
        with self.assertRaises(AttributeError):
            self.item._set_attributes({'references': ['foo']})
        with self.assertRaises(AttributeError):
            self.item._set_attributes({'references': [{'type': 'FOO'}]})
        with self.assertRaises(AttributeError):
            self.item._set_attributes({'references': [{'type': 'file'}]})
        with self.assertRaises(AttributeError):
            self.item._set_attributes(
                {'references': [{'type': 'file', 'path': 0xDEAD}]}
            )

    @patch('doorstop.common.verbosity', 2)
    def test_str(self):
        """Verify an item can be converted to a string."""
        self.assertEqual("RQ001", str(self.item))

    @patch('doorstop.common.verbosity', 3)
    def test_str_verbose(self):
        """Verify an item can be converted to a string (verbose)."""
        text = "RQ001 (@{}{})".format(os.sep, self.item.path)
        self.assertEqual(text, str(self.item))

    def test_hash(self):
        """Verify items can be hashed."""
        item1 = MockItem(None, 'path/to/fake1.yml')
        item2 = MockItem(None, 'path/to/fake2.yml')
        item3 = MockItem(None, 'path/to/fake2.yml')
        my_set = set()
        # Act
        my_set.add(item1)
        my_set.add(item2)
        my_set.add(item3)
        # Assert
        self.assertEqual(2, len(my_set))

    def test_ne(self):
        """Verify item non-equality is correct."""
        self.assertNotEqual(self.item, None)

    def test_lt(self):
        """Verify items can be compared."""
        item1 = MockItem(None, 'path/to/fake1.yml')
        item1.level = (1, 1)  # type: ignore
        item2 = MockItem(None, 'path/to/fake1.yml')
        item2.level = (1, 1, 1)  # type: ignore
        item3 = MockItem(None, 'path/to/fake1.yml')
        item3.level = (1, 1, 2)  # type: ignore
        self.assertLess(item1, item2)
        self.assertLess(item2, item3)
        self.assertGreater(item3, item1)

    def test_uid(self):
        """Verify an item's UID can be read but not set."""
        self.assertEqual('RQ001', self.item.uid)
        self.assertRaises(AttributeError, setattr, self.item, 'uid', 'RQ002')

    def test_relpath(self):
        """Verify an item's relative path string can be read but not set."""
        text = "@{}{}".format(os.sep, self.item.path)
        self.assertEqual(text, self.item.relpath)
        self.assertRaises(AttributeError, setattr, self.item, 'relpath', '.')

    def test_level(self):
        """Verify an item's level can be set and read."""
        self.item.level = (1, 2, 3)
        self.assertIn("level: 1.2.3\n", self.item._write.call_args[0][0])
        self.assertEqual((1, 2, 3), self.item.level)

    def test_level_with_float(self):
        """Verify an item's level can be set and read (2-part w/ float)."""
        self.item.level = (1, 10)
        self.assertIn("level: '1.10'\n", self.item._write.call_args[0][0])
        self.assertEqual((1, 10), self.item.level)

    def test_depth(self):
        """Verify the depth can be read from the item's level."""
        self.item.level = (1,)
        self.assertEqual(1, self.item.depth)
        self.item.level = (1, 0)
        self.assertEqual(1, self.item.depth)
        self.item.level = (2, 0, 1)
        self.assertEqual(3, self.item.depth)
        self.item.level = (2, 0, 1, 1, 0, 0)
        self.assertEqual(4, self.item.depth)

    def test_level_from_text(self):
        """Verify an item's level can be set from text and read."""
        self.item.level = "4.2.0 "
        self.assertIn("level: 4.2.0\n", self.item._write.call_args[0][0])
        self.assertEqual((4, 2), self.item.level)

    def test_level_from_text_2_digits(self):
        """Verify an item's level can be set from text (2 digits) and read."""
        self.item.level = "10.10"
        self.assertIn("level: '10.10'\n", self.item._write.call_args[0][0])
        self.assertEqual((10, 10), self.item.level)

    def test_level_from_float(self):
        """Verify an item's level can be set from a float and read."""
        self.item.level = 4.2
        self.assertIn("level: 4.2\n", self.item._write.call_args[0][0])
        self.assertEqual((4, 2), self.item.level)

    def test_level_from_int(self):
        """Verify an item's level can be set from a int and read."""
        self.item.level = 42
        self.assertIn("level: 42\n", self.item._write.call_args[0][0])
        self.assertEqual((42,), self.item.level)

    def test_active(self):
        """Verify an item's active status can be set and read."""
        self.item.active = 0  # converted to False
        self.assertIn("active: false\n", self.item._write.call_args[0][0])
        self.assertFalse(self.item.active)

    def test_derived(self):
        """Verify an item's normative status can be set and read."""
        self.item.derived = 1  # converted to True
        self.assertIn("derived: true\n", self.item._write.call_args[0][0])
        self.assertTrue(self.item.derived)

    def test_normative(self):
        """Verify an item's normative status can be set and read."""
        self.item.normative = 0  # converted to False
        self.assertIn("normative: false\n", self.item._write.call_args[0][0])
        self.assertFalse(self.item.normative)

    def test_heading(self):
        """Verify an item's heading status can be set and read."""
        self.item.level = '1.1.1'
        self.item.heading = 1  # converted to True
        self.assertFalse(self.item.normative)
        self.assertTrue(self.item.heading)
        self.item.heading = 0  # converted to False
        self.assertTrue(self.item.normative)
        self.assertFalse(self.item.heading)

    def test_reviewed(self):
        """Verify an item's review status can be set and read."""
        self.assertFalse(self.item.reviewed)  # not reviewed by default
        self.item.reviewed = 1  # calls `review()`
        self.assertTrue(self.item.reviewed)
        self.item.reviewed = 0  # converted to None
        self.assertFalse(self.item.reviewed)

    def test_text(self):
        """Verify an item's text can be set and read."""
        value = "abc "
        text = "abc"
        yaml = "text: |\n  abc\n"
        self.item.text = value
        self.assertEqual(text, self.item.text)
        self.assertIn(yaml, self.item._write.call_args[0][0])

    def test_text_sbd(self):
        """Verify newlines separate sentences in an item's text."""
        value = (
            "A sentence. Another sentence! Hello? Hi.\n"
            "A new line (here). And another sentence."
        )
        text = (
            "A sentence. Another sentence! Hello? Hi.\n"
            "A new line (here). And another sentence."
        )
        yaml = (
            "text: |\n"
            "  A sentence. Another sentence! Hello? Hi.\n"
            "  A new line (here). And another sentence.\n"
        )
        self.item.text = value
        self.assertEqual(text, self.item.text)
        self.assertIn(yaml, self.item._write.call_args[0][0])

    def test_text_ordered_list(self):
        """Verify newlines are preserved in an ordered list."""
        self.item.text = "A list:\n\n1. Abc\n2. Def\n"
        expected = "A list:\n\n1. Abc\n2. Def"
        self.assertEqual(expected, self.item.text)

    def test_text_unordered_list(self):
        """Verify newlines are preserved in an ordered list."""
        self.item.text = "A list:\n\n- Abc\n- Def\n"
        expected = "A list:\n\n- Abc\n- Def"
        self.assertEqual(expected, self.item.text)

    def test_text_split_numbers(self):
        """Verify lines ending in numbers aren't changed."""
        self.item.text = "Split at a number: 1\n42 or punctuation.\nHere."
        expected = "Split at a number: 1\n42 or punctuation.\nHere."
        self.assertEqual(expected, self.item.text)

    def test_text_newlines(self):
        """Verify newlines are preserved when deliberate."""
        self.item.text = "Some text.\n\nNote: here.\n"
        expected = "Some text.\n\nNote: here."
        self.assertEqual(expected, self.item.text)

    def test_text_formatting(self):
        """Verify newlines are not removed around formatting."""
        self.item.text = "The thing\n**_SHALL_** do this.\n"
        expected = "The thing\n**_SHALL_** do this."
        self.assertEqual(expected, self.item.text)

    def test_text_non_heading(self):
        """Verify newlines are preserved around non-headings."""
        self.item.text = "break (before \n#2) symbol should not be a heading."
        expected = "break (before\n#2) symbol should not be a heading."
        self.assertEqual(expected, self.item.text)

    def test_text_heading(self):
        """Verify newlines are preserved around headings."""
        self.item.text = "should be a heading\n\n# right here"
        expected = "should be a heading\n\n# right here"
        self.assertEqual(expected, self.item.text)

    def test_ref(self):
        """Verify an item's reference can be set and read."""
        self.item.ref = "abc123"
        self.assertIn("ref: abc123\n", self.item._write.call_args[0][0])
        self.assertNotIn("references:", self.item._write.call_args[0][0])
        self.assertEqual("abc123", self.item.ref)

    def test_references(self):
        """Verify an item's reference can be set and read."""
        references = [
            {'type': 'file', 'path': 'abc1'},
            {"type": "file", "path": "abc2"},
        ]
        self.item.references = references
        self.assertIn(
            "references:\n- path: abc1\n  type: file\n- path: abc2\n  type: file",
            self.item._write.call_args[0][0],
        )
        # We let 'references' and 'ref' co-exist for now.
        self.assertIn("ref: ''", self.item._write.call_args[0][0])
        self.assertListEqual(references, self.item.references)

    def test_extended(self):
        """Verify an extended attribute (`str`) can be used."""
        self.item.set('ext1', 'foobar')
        self.assertIn("ext1: foobar\n", self.item._write.call_args[0][0])
        self.assertEqual('foobar', self.item.get('ext1'))
        self.assertEqual(['ext1'], self.item.extended)

    def test_extended_text(self):
        """Verify an extended attribute (`Text`) can be used."""
        self.item.set('ext1', Text('foobar'))
        self.assertIn("ext1: foobar\n", self.item._write.call_args[0][0])
        self.assertEqual('foobar', self.item.get('ext1'))
        self.assertEqual(['ext1'], self.item.extended)

    def test_extended_wrap(self):
        """Verify a long extended attribute is wrapped."""
        text = "This extended attribute should be long enough to wrap."
        self.item.set('a_very_long_extended_attr', text)
        self.assertEqual(text, self.item.get('a_very_long_extended_attr'))

    def test_extended_wrap_multi(self):
        """Verify a long extended attribute is wrapped with newlines."""
        text = "Another extended attribute.\n\nNote: with a note."
        self.item.set('ext2', text)
        self.assertEqual(text, self.item.get('ext2'))

    def test_extended_get_standard(self):
        """Verify extended attribute access can get standard properties."""
        active = self.item.get('active')
        self.assertEqual(self.item.active, active)

    def test_extended_set_standard(self):
        """Verify extended attribute access can set standard properties."""
        self.item.set('text', "extended access")
        self.assertEqual("extended access", self.item.text)

    @patch('doorstop.core.item.Item.load')
    @patch('doorstop.core.editor.launch')
    def test_edit(self, mock_launch, mock_load):
        """Verify an item can be edited."""
        self.item.tree = Mock()
        # Act
        self.item.edit(tool='mock_editor')
        # Assert
        self.item.tree.vcs.lock.assert_called_once_with(self.item.path)
        self.item.tree.vcs.edit.assert_called_once_with(self.item.path)
        mock_launch.assert_called_once_with(self.item.path, tool='mock_editor')
        mock_load.assert_called_once_with(True)

    def test_link(self):
        """Verify links can be added to an item."""
        self.item.link('abc')
        self.item.link('123')
        self.assertEqual(['123', 'abc'], self.item.links)

    def test_link_duplicate(self):
        """Verify duplicate links are ignored."""
        self.item.link('abc')
        self.item.link('abc')
        self.assertEqual(['abc'], self.item.links)

    def test_unlink_duplicate(self):
        """Verify removing a link twice is not an error."""
        self.item.links = ['123', 'abc']
        self.item.unlink('abc')
        self.item.unlink('abc')
        self.assertEqual(['123'], self.item.links)

    def test_link_by_item(self):
        """Verify links can be added to an item (by item)."""
        path = os.path.join('path', 'to', 'ABC123.yml')
        item = MockItem(None, path)
        self.item.link(item)
        self.assertEqual(['ABC123'], self.item.links)

    def test_unlink_by_item(self):
        """Verify links can be removed (by item)."""
        path = os.path.join('path', 'to', 'ABC123.yml')
        item = MockItem(None, path)
        self.item.links = ['ABC123']
        self.item.unlink(item)
        self.assertEqual([], self.item.links)

    def test_links_alias(self):
        """Verify 'parent_links' is an alias for links."""
        links1 = ['alias1']
        links2 = ['alias2']
        self.item.parent_links = links1
        self.assertEqual(links1, self.item.links)
        self.item.links = links2
        self.assertEqual(links2, self.item.parent_links)

    def test_parent_items(self):
        """Verify 'parent_items' exists to mirror the child behavior."""
        mock_tree = Mock()
        mock_tree.find_item = Mock(return_value='mock_item')
        self.item.tree = mock_tree
        self.item.links = ['mock_uid']
        # Act
        items = self.item.parent_items
        # Assert
        self.assertEqual(['mock_item'], items)

    def test_parent_items_unknown(self):
        """Verify 'parent_items' can handle unknown items."""
        mock_tree = Mock()
        mock_tree.find_item = Mock(side_effect=DoorstopError)
        self.item.tree = mock_tree
        self.item.links = ['mock_uid']
        # Act
        items = self.item.parent_items
        # Assert
        self.assertIsInstance(items[0], UnknownItem)

    def test_parent_documents(self):
        """Verify 'parent_documents' exists to mirror the child behavior."""
        mock_tree = Mock()
        mock_tree.find_document = Mock(return_value='mock_document')
        self.item.tree = mock_tree
        self.item.links = ['mock_uid']
        self.item.document = Mock()
        self.item.document.prefix = 'mock_prefix'
        # Act
        documents = self.item.parent_documents
        # Assert
        self.assertEqual(['mock_document'], documents)

    def test_parent_documents_unknown(self):
        """Verify 'parent_documents' can handle unknown documents."""
        mock_tree = Mock()
        mock_tree.find_document = Mock(side_effect=DoorstopError)
        self.item.tree = mock_tree
        self.item.links = ['mock_uid']
        self.item.document = Mock()
        self.item.document.prefix = 'mock_prefix'
        # Act
        documents = self.item.parent_documents
        # Assert
        self.assertEqual([], documents)

    @patch('doorstop.settings.CACHE_PATHS', False)
    def test_find_ref(self):
        """Verify an item's reference can be found."""
        self.item.ref = "REF" "123"  # space to avoid matching in this file
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(EXTERNAL)
        # Act
        relpath, line = self.item.find_ref()
        # Assert
        self.assertEqual('text.txt', os.path.basename(relpath))
        self.assertEqual(3, line)

    def test_find_ref_filename(self):
        """Verify an item's reference can also be a filename."""
        self.item.ref = "text.txt"
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(FILES)
        self.item.tree.vcs._ignores_cache = ["*published*"]
        # Act
        relpath, line = self.item.find_ref()
        # Assert
        self.assertEqual('text.txt', os.path.basename(relpath))
        self.assertEqual(None, line)

    def test_find_ref_error(self):
        """Verify an error occurs when no external reference found."""
        self.item.ref = "not" "found"  # space to avoid matching in this file
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(EMPTY)
        # Act and assert
        self.assertRaises(DoorstopError, self.item.find_ref)

    def test_find_skip_self(self):
        """Verify reference searches skip the item's file."""
        self.item.path = __file__
        self.item.ref = "148710938710289248"  # random and unique to this file
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(EMPTY)
        self.item.tree.vcs._path_cache = [(__file__, 'filename', 'relpath')]
        # Act and assert
        self.assertRaises(DoorstopError, self.item.find_ref)

    def test_find_ref_none(self):
        """Verify nothing returned when no external reference is specified."""
        self.item.tree = Mock()
        self.assertEqual((None, None), self.item.find_ref())

    @patch('doorstop.settings.CACHE_PATHS', False)
    def test_find_references(self):
        """Verify an item's references can be found."""
        self.item.references = [
            {"path": "files/REQ001.yml"},
            {"path": "files/REQ002.yml"},
        ]

        self.item.root = TESTS_ROOT
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(TESTS_ROOT)

        # Act
        ref = self.item.find_references()

        # Assert
        self.assertEqual(2, len(ref))

        relpath_1, keyword_line_1 = ref[0]
        self.assertEqual(relpath_1, 'files/REQ001.yml')
        self.assertEqual(keyword_line_1, None)

        relpath_2, keyword_line_2 = ref[1]
        self.assertEqual(relpath_2, 'files/REQ002.yml')
        self.assertEqual(keyword_line_2, None)

    @patch('doorstop.settings.CACHE_PATHS', False)
    def test_find_references_valid_keyword(self):
        """Verify an item's references can be found."""
        keyword = "Lorem ipsum dolor sit amet"
        self.item.references = [{"path": "files/REQ001.yml", "keyword": keyword}]

        self.item.root = TESTS_ROOT
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(TESTS_ROOT)

        # Act
        ref = self.item.find_references()

        # Assert
        self.assertEqual(1, len(ref))

        ref_path, ref_keyword_line = ref[0]
        self.assertEqual(ref_path, 'files/REQ001.yml')
        self.assertEqual(ref_keyword_line, 12)

    @patch('doorstop.settings.CACHE_PATHS', False)
    def test_find_references_invalid_keyword(self):
        """Verify an item's references can be found."""
        self.item.references = [
            {"path": "files/REQ001.yml", "keyword": "INVALID KEYWORD"}
        ]

        self.item.root = TESTS_ROOT
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(TESTS_ROOT)

        with self.assertRaises(DoorstopError) as context:
            self.item.find_references()

        self.assertTrue('external reference not found' in str(context.exception))

    def test_find_ref_error_multiple(self):
        """Verify an error occurs when no external reference found."""
        self.item.references = [{"path": "this/path/does/not/exist.yml"}]
        self.item.tree = Mock()
        self.item.tree.vcs = WorkingCopy(EMPTY)
        # Act and assert
        self.assertRaises(DoorstopError, self.item.find_references)

    def test_find_child_objects(self):
        """Verify an item's child objects can be found."""

        mock_document_p = Mock()
        mock_document_p.prefix = 'RQ'

        mock_document_c = Mock()
        mock_document_c.parent = 'RQ'

        mock_item = Mock()
        mock_item.uid = 'TST001'
        mock_item.links = ['RQ001']

        def mock_iter(self):  # pylint: disable=W0613
            """Mock Tree.__iter__ to yield a mock Document."""

            def mock_iter2(self):  # pylint: disable=W0613
                """Mock Document.__iter__ to yield a mock Item."""
                yield mock_item

            mock_document_c.__iter__ = mock_iter2
            yield mock_document_c

        self.item.link('fake1')
        mock_tree = Mock()
        mock_tree.__iter__ = mock_iter
        mock_tree.find_item = lambda uid: Mock(uid='fake1')
        self.item.tree = mock_tree
        self.item.document = mock_document_p

        links = self.item.find_child_links()
        items = self.item.find_child_items()
        documents = self.item.find_child_documents()
        self.assertEqual(['TST001'], links)
        self.assertEqual([mock_item], items)
        self.assertEqual([mock_document_c], documents)

    def test_find_child_objects_standalone(self):
        """Verify a standalone item has no child objects."""
        self.assertEqual([], self.item.child_links)
        self.assertEqual([], self.item.child_items)
        self.assertEqual([], self.item.child_documents)

    def test_invalid_file_name(self):
        """Verify an invalid file name cannot be a requirement."""
        self.assertRaises(DoorstopError, MockItem, None, "path/to/REQ.yaml")
        self.assertRaises(DoorstopError, MockItem, None, "path/to/001.yaml")

    def test_invalid_file_ext(self):
        """Verify an invalid file extension cannot be a requirement."""
        self.assertRaises(DoorstopError, MockItem, None, "path/to/REQ001")
        self.assertRaises(DoorstopError, MockItem, None, "path/to/REQ001.txt")

    @patch('doorstop.core.item.Item', MockItem)
    def test_new(self):
        """Verify items can be created."""
        MockItem._create.reset_mock()
        item = MockItem.new(
            None, MockSimpleDocument(), EMPTY, FILES, 'TEST00042', level=(1, 2, 3)
        )
        path = os.path.join(EMPTY, 'TEST00042.yml')
        self.assertEqual(path, item.path)
        self.assertEqual((1, 2, 3), item.level)
        MockItem._create.assert_called_once_with(path, name='item')

    @patch('doorstop.core.item.Item', MockItem)
    def test_new_cache(self):
        """Verify new items are cached."""
        mock_tree = Mock()
        mock_tree._item_cache = {}
        item = MockItem.new(
            mock_tree, MockSimpleDocument(), EMPTY, FILES, 'TEST00042', level=(1, 2, 3)
        )
        self.assertEqual(item, mock_tree._item_cache[item.uid])
        mock_tree.vcs.add.assert_called_once_with(item.path)

    @patch('doorstop.core.item.Item', MockItem)
    def test_new_special(self):
        """Verify items can be created with a specially named prefix."""
        MockItem._create.reset_mock()
        item = MockItem.new(
            None, MockSimpleDocument(), EMPTY, FILES, 'VSM.HLR_01-002-042', level=(1, 0)
        )
        path = os.path.join(EMPTY, 'VSM.HLR_01-002-042.yml')
        self.assertEqual(path, item.path)
        self.assertEqual((1,), item.level)
        MockItem._create.assert_called_once_with(path, name='item')

    def test_new_existing(self):
        """Verify an exception is raised if the item already exists."""
        self.assertRaises(
            DoorstopError, Item.new, None, None, FILES, FILES, 'REQ002', level=(1, 2, 3)
        )

    def test_stamp(self):
        """Verify an item's contents can be stamped."""
        stamp = 'OoHOpBnrt8us7ph8DVnz5KrQs6UBqj_8MEACA0gWpjY='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_contribution_references(self):
        """Verify that references attribute contributes to a stamp."""
        expected_stamp_before = 'OoHOpBnrt8us7ph8DVnz5KrQs6UBqj_8MEACA0gWpjY='

        stamp_before = self.item.stamp()
        self.assertEqual(expected_stamp_before, stamp_before)

        self.item.references = [{'type': 'file', 'path': 'foo'}]

        stamp_after = self.item.stamp()
        self.assertNotEqual(stamp_after, expected_stamp_before)

    def test_stamp_with_one_extended_reviewed(self):
        """Verify fingerprint with one extended reviewed attribute."""
        self.item._data['type'] = 'functional'
        self.item.document.extended_reviewed = ['type']
        stamp = 'MmcvtzB20PHv0IBhxpNtpZCa0CfYwHnPr3Jk8W-TRxk='
        self.assertEqual(stamp, self.item.stamp())
        self.item.document.extended_reviewed = []
        stamp = 'OoHOpBnrt8us7ph8DVnz5KrQs6UBqj_8MEACA0gWpjY='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_complex_extended_reviewed(self):
        """Verify fingerprint with complex extended reviewed attribute."""
        self.item.document.extended_reviewed = ['attr']
        self.item._data['attr'] = ['a', 'b', ['c', {'d': 'e', 'f': ['g']}]]
        stamp = 'JcCRKBgLLTOatY8OpAMabblP7Mu24JZRn3WgoXwjmSk='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_none_extended_reviewed(self):
        """Verify fingerprint with None extended reviewed attribute."""
        self.item.document.extended_reviewed = ['attr']
        self.item._data['attr'] = None
        stamp = 'e0qDli7ZJwhf161b_v7AdGNNl7xHx-bs28aFFk7aqT4='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_value_one_extended_reviewed(self):
        """Verify fingerprint with value one extended reviewed attribute."""
        self.item.document.extended_reviewed = ['attr']
        self.item._data['attr'] = 1
        stamp = '0s4QQh2AZXSoZNYGcfybCGLHAgO4EWY9gxK_LVNiqOA='
        self.assertEqual(stamp, self.item.stamp())
        self.item._data['attr'] = '1'
        stamp = 'GWlkpsRSzT_lgE4CNvE4wrUZZwM3iHKHOa6idcHUSUw='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_empty_string_extended_reviewed(self):
        """Verify fingerprint with empty string extended reviewed attribute."""
        self.item.document.extended_reviewed = ['attr']
        self.item._data['attr'] = ''
        stamp = 'H70VgWPTH89Q9KfIJBfeilC7-wYAtWigxZ2iUcZ9j-8='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_list_extended_reviewed(self):
        """Verify fingerprint with list extended reviewed attributes."""
        self.item.document.extended_reviewed = ['attr']
        self.item._data['attr'] = []
        stamp = 'qwUP7VgUbHWIdj-T2ZfGhROfJQwSHDhsC6WR9vUTk1U='
        self.assertEqual(stamp, self.item.stamp())
        self.item._data['attr'] = [None]
        stamp = 'GHDRiY4C3twnXDTCqoCAD_iymfe892ZzQuYjuccFBT0='
        self.assertEqual(stamp, self.item.stamp())
        self.item._data['attr'] = ['']
        stamp = 'Rfwtl2j56CdQLtE4b5StEa0ECVTqlOpABLdhEa1avyo='
        self.assertEqual(stamp, self.item.stamp())
        self.item._data['attr'] = [[]]
        stamp = 'AXWIEp9CYI4UWzIw4NinvDrUFzQl_8rCL9B_PmGisYk='
        self.assertEqual(stamp, self.item.stamp())
        self.item._data['attr'] = [{}]
        stamp = 'C5Bm5ej09zaJxbtbE9PIcno8M9lIBIC6sJOmNJkrJH8='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_empty_dict_extended_reviewed(self):
        """Verify fingerprint with empty dict extended reviewed attribute."""
        self.item.document.extended_reviewed = ['attr']
        self.item._data['attr'] = {}
        stamp = '5Yv0vWG2h5rAQt_1LujZuD9X6udWO52KVaSA5SJ3Emc='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_two_extended_reviewed(self):
        """Verify fingerprint with two extended reviewed attributes."""
        self.item._data['type'] = 'functional'
        self.item._data['verification-method'] = 'test'
        self.item.document.extended_reviewed = ['type', 'verification-method']
        stamp = 'TF_q0ofVwjaJI1RYu9jtDeSCm5gAQWIqsxpPxAW5D64='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_reversed_extended_reviewed(self):
        """Verify fingerprint with reversed extended reviewed attributes."""
        self.item._data['type'] = 'functional'
        self.item._data['verification-method'] = 'test'
        self.item.document.extended_reviewed = ['verification-method', 'type']
        stamp = 'dMWAazlLoeZSwlD87nEwQtAFq32WQuX_Bd_8kehaKJg='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_with_missing_extended_reviewed_reverse(self):
        """Verify fingerprint with missing extended reviewed attributes."""
        self.item._data['type'] = 'functional'
        self.item._data['verification-method'] = 'test'
        self.item.document.extended_reviewed = [
            'missing',
            'type',
            'verification-method',
        ]
        stamp = 'TF_q0ofVwjaJI1RYu9jtDeSCm5gAQWIqsxpPxAW5D64='
        self.assertEqual(stamp, self.item.stamp())
        self.item.document.extended_reviewed = [
            'missing',
            'type',
            'verification-method',
            'missing-2',
        ]
        stamp = 'TF_q0ofVwjaJI1RYu9jtDeSCm5gAQWIqsxpPxAW5D64='
        self.assertEqual(stamp, self.item.stamp())
        self.item.document.extended_reviewed = [
            'type',
            'verification-method',
            'missing-2',
        ]
        stamp = 'TF_q0ofVwjaJI1RYu9jtDeSCm5gAQWIqsxpPxAW5D64='
        self.assertEqual(stamp, self.item.stamp())

    def test_stamp_links(self):
        """Verify an item's contents can be stamped."""
        self.item.link('mock_link')
        stamp = 'yE7YshtnqRzPryOsmNI6nkeRmE97LPB19eenX0b5cIk='
        self.assertEqual(stamp, self.item.stamp(links=True))

    def test_clear(self):
        """Verify an item's links can be cleared as suspect."""
        mock_item = Mock()
        mock_item.uid = 'mock_uid'
        mock_item.stamp = Mock(return_value=Stamp('abc123'))
        mock_tree = MagicMock()
        mock_tree.find_item = Mock(return_value=mock_item)
        self.item.tree = mock_tree
        self.item.link('mock_uid')
        self.assertFalse(self.item.cleared)
        self.assertEqual(None, self.item.links[0].stamp)
        # Act
        self.item.clear()
        # Assert
        self.assertTrue(self.item.cleared)
        self.assertEqual('abc123', self.item.links[0].stamp)

    def test_clear_by_uid(self):
        """Verify an item's links can be cleared as suspect by UID."""
        mock_item = Mock()
        mock_item.uid = 'mock_uid'
        mock_item.stamp = Mock(return_value=Stamp('abc123'))
        mock_tree = MagicMock()
        mock_tree.find_item = Mock(return_value=mock_item)
        self.item.tree = mock_tree
        self.item.link('mock_uid')
        self.assertFalse(self.item.cleared)
        self.assertEqual(None, self.item.links[0].stamp)
        # Act
        self.item.clear(['other_uid'])
        # Assert
        self.assertFalse(self.item.cleared)
        self.assertEqual(None, self.item.links[0].stamp)
        # Act
        self.item.clear(['mock_uid'])
        # Assert
        self.assertTrue(self.item.cleared)
        self.assertEqual('abc123', self.item.links[0].stamp)

    def test_review(self):
        """Verify an item can be marked as reviewed."""
        self.item.reviewed = False
        self.item.review()
        self.assertTrue(self.item.reviewed)

    @patch('doorstop.common.delete')
    def test_delete(self, mock_delete):
        """Verify an item can be deleted."""
        self.item.delete()
        mock_delete.assert_called_once_with(self.item.path)
        self.item.delete()  # ensure a second delete is ignored

    @patch('doorstop.common.delete', Mock())
    def test_delete_cache(self):
        """Verify an item is expunged after delete."""
        self.item.tree = Mock()
        self.item.tree._item_cache = {self.item.uid: self.item}
        self.item.delete()
        self.item.tree.vcs.delete.assert_called_once_with(self.item.path)
        self.assertIs(None, self.item.tree._item_cache[self.item.uid])