Exemplo n.º 1
0
    def _get_issues_document(self, document, skip):
        """Yield all the item's issues against its document."""
        log.debug("getting issues against document...")

        if document in skip:
            log.debug("skipping issues against document %s...", document)
            return

        # Verify an item's UID matches its document's prefix
        if self.prefix != document.prefix:
            msg = "prefix differs from document ({})".format(document.prefix)
            yield DoorstopInfo(msg)

        # Verify an item has upward links
        if all((document.parent, self.normative,
                not self.derived)) and not self.links:
            msg = "no links to parent document: {}".format(document.parent)
            yield DoorstopWarning(msg)

        # Verify an item's links are to the correct parent
        for uid in self.links:
            try:
                prefix = uid.prefix
            except DoorstopError:
                msg = "invalid UID in links: {}".format(uid)
                yield DoorstopError(msg)
            else:
                if document.parent and prefix != document.parent:
                    # this is only 'info' because a document is allowed
                    # to contain items with a different prefix, but
                    # Doorstop will not create items like this
                    msg = "parent is '{}', but linked to: {}".format(
                        document.parent, uid)
                    yield DoorstopInfo(msg)
Exemplo n.º 2
0
 def _get_issues_level(items):
     """Yield all the document's issues related to item level."""
     prev = items[0] if items else None
     for item in items[1:]:
         puid = prev.uid
         plev = prev.level
         nuid = item.uid
         nlev = item.level
         log.debug("checking level {} to {}...".format(plev, nlev))
         # Duplicate level
         if plev == nlev:
             uids = sorted((puid, nuid))
             msg = "duplicate level: {} ({}, {})".format(plev, *uids)
             yield DoorstopWarning(msg)
         # Skipped level
         length = min(len(plev.value), len(nlev.value))
         for index in range(length):
             # Types of skipped levels:
             #         1. over: 1.0 --> 1.2
             #         2. out: 1.1 --> 3.0
             if (nlev.value[index] - plev.value[index] > 1 or
                     # 3. over and out: 1.1 --> 2.2
                     (plev.value[index] != nlev.value[index] and
                      index + 1 < length and
                      nlev.value[index + 1] not in (0, 1))):
                 msg = "skipped level: {} ({}), {} ({})".format(plev, puid,
                                                                nlev, nuid)
                 yield DoorstopInfo(msg)
                 break
         prev = item
Exemplo n.º 3
0
 def test_validate(self, mock_reorder, mock_get_issues):
     """Verify a document can be validated."""
     mock_get_issues.return_value = [DoorstopInfo('i')]
     with patch('doorstop.settings.REORDER', True):
         self.assertTrue(self.document.validate())
     mock_reorder.assert_called_once_with(_items=self.document.items)
     self.assertEqual(5, mock_get_issues.call_count)
Exemplo n.º 4
0
 def _get_issues_tree(self, tree):
     """Yield all the item's issues against its tree."""
     log.debug("getting issues against tree...")
     # Verify an item's links are valid
     identifiers = set()
     for uid in self.links:
         try:
             item = tree.find_item(uid)
         except DoorstopError:
             identifiers.add(uid)  # keep the invalid UID
             msg = "linked to unknown item: {}".format(uid)
             yield DoorstopError(msg)
         else:
             # check the linked item
             if not item.active:
                 msg = "linked to inactive item: {}".format(item)
                 yield DoorstopInfo(msg)
             if not item.normative:
                 msg = "linked to non-normative item: {}".format(item)
                 yield DoorstopWarning(msg)
             # check the link status
             if uid.stamp == Stamp(True):
                 uid.stamp = item.stamp()  # convert True to a stamp
             elif uid.stamp != item.stamp():
                 if settings.CHECK_SUSPECT_LINKS:
                     msg = "suspect link: {}".format(item)
                     yield DoorstopWarning(msg)
             # reformat the item's UID
             identifier2 = UID(item.uid, stamp=uid.stamp)
             identifiers.add(identifier2)
     # Apply the reformatted item UIDs
     if settings.REFORMAT:
         self._data['links'] = identifiers
Exemplo n.º 5
0
 def test_issues_skipped_level(self):
     """Verify skipped item levels are detected."""
     expect = DoorstopInfo("skipped level: 1.4 (REQ003), 1.6 (REQ004)")
     for issue in self.document.issues:
         logging.info(repr(issue))
         if type(issue) == type(expect) and issue.args == expect.args:
             break
     else:
         self.fail("issue not found: {}".format(expect))
Exemplo n.º 6
0
    def _get_issues_document(item, document, skip):
        """Yield all the item's issues against its document."""
        log.debug("getting issues against document...")

        if document in skip:
            log.debug("skipping issues against document %s...", document)
            return

        # Verify an item's UID matches its document's prefix
        if item.uid.prefix != document.prefix:
            msg = "prefix differs from document ({})".format(document.prefix)
            yield DoorstopInfo(msg)

        # Verify that normative, non-derived items in a child document have at
        # least one link.  It is recommended that these items have an upward
        # link to an item in the parent document, however, this is not
        # enforced.  An info message is generated if this is not the case.
        if all((document.parent, item.normative,
                not item.derived)) and not item.links:
            msg = "no links to parent document: {}".format(document.parent)
            yield DoorstopWarning(msg)

        # Verify an item's links are to the correct parent
        for uid in item.links:
            try:
                prefix = uid.prefix
            except DoorstopError:
                msg = "invalid UID in links: {}".format(uid)
                yield DoorstopError(msg)
            else:
                if document.parent and prefix != document.parent:
                    # this is only 'info' because a document is allowed
                    # to contain items with a different prefix, but
                    # Doorstop will not create items like this
                    msg = "parent is '{}', but linked to: {}".format(
                        document.parent, uid)
                    yield DoorstopInfo(msg)
Exemplo n.º 7
0
    def _get_issues_tree(self, item, tree):
        """Yield all the item's issues against its tree."""
        log.debug("getting issues against tree...")

        # Verify an item's links are valid
        identifiers = set()
        for uid in item.links:
            try:
                parent = tree.find_item(uid)
            except DoorstopError:
                identifiers.add(uid)  # keep the invalid UID
                msg = "linked to unknown item: {}".format(uid)
                yield DoorstopError(msg)
            else:
                # check the parent item
                if not parent.active:
                    msg = "linked to inactive item: {}".format(parent)
                    yield DoorstopInfo(msg)
                if not parent.normative:
                    msg = "linked to non-normative item: {}".format(parent)
                    yield DoorstopWarning(msg)
                # check the link status
                if uid.stamp == Stamp(True):
                    uid.stamp = parent.stamp()
                elif not str(uid.stamp) and settings.STAMP_NEW_LINKS:
                    uid.stamp = parent.stamp()
                elif uid.stamp != parent.stamp():
                    if settings.CHECK_SUSPECT_LINKS:
                        msg = "suspect link: {}".format(parent)
                        yield DoorstopWarning(msg)
                # reformat the item's UID
                identifiers.add(UID(parent.uid, stamp=uid.stamp))

        # Apply the reformatted item UIDs
        if settings.REFORMAT:
            item.links = identifiers
Exemplo n.º 8
0
class TestTree(unittest.TestCase):
    """Unit tests for the Tree class."""
    def setUp(self):
        document = Document(SYS)
        self.tree = Tree(document)
        document.tree = self.tree
        document = Document(FILES)
        self.tree._place(document)  # pylint: disable=W0212
        document.tree = self.tree
        self.tree._vcs = Mock()  # pylint: disable=W0212

    @patch('doorstop.core.vcs.find_root', Mock(return_value=EMPTY))
    def test_palce_empty(self):
        """Verify a document can be placed in an empty tree."""
        tree = build(EMPTY)
        doc = MockDocumentSkip.new(tree, os.path.join(EMPTY, 'temp'), EMPTY,
                                   'TEMP')
        tree._place(doc)  # pylint: disable=W0212
        self.assertEqual(1, len(tree))

    @patch('doorstop.core.vcs.find_root', Mock(return_value=EMPTY))
    def test_palce_empty_no_parent(self):
        """Verify a document with parent cannot be placed in an empty tree."""
        tree = build(EMPTY)
        doc = MockDocumentSkip.new(tree,
                                   os.path.join(EMPTY, 'temp'),
                                   EMPTY,
                                   'TEMP',
                                   parent='REQ')
        self.assertRaises(DoorstopError, tree._place, doc)  # pylint: disable=W0212

    def test_documents(self):
        """Verify the documents in a tree can be accessed."""
        documents = self.tree.documents
        self.assertEqual(2, len(documents))
        for document in self.tree:
            logging.debug("document: {}".format(document))
            self.assertIs(self.tree, document.tree)

    @patch('doorstop.core.document.Document.get_issues')
    def test_validate(self, mock_get_issues):
        """Verify trees can be checked."""
        logging.info("tree: {}".format(self.tree))
        self.assertTrue(self.tree.validate())
        self.assertEqual(2, mock_get_issues.call_count)

    def test_validate_no_documents(self):
        """Verify an empty tree can be checked."""
        tree = Tree(None, root='.')
        self.assertTrue(tree.validate())

    @patch('doorstop.settings.REORDER', False)
    @patch('doorstop.core.document.Document.get_issues',
           Mock(return_value=[
               DoorstopError('error'),
               DoorstopWarning('warning'),
               DoorstopInfo('info')
           ]))
    def test_validate_document(self):
        """Verify a document error fails the tree validation."""
        self.assertFalse(self.tree.validate())

    @patch('doorstop.core.document.Document.get_issues', Mock(return_value=[]))
    def test_validate_hook(self):
        """Verify a document hook can be called."""
        mock_hook = MagicMock()
        self.tree.validate(document_hook=mock_hook)
        self.assertEqual(2, mock_hook.call_count)

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

    def test_get_traceability(self):
        """Verify traceability rows are correct."""
        rows = [
            (self.tree.find_item('SYS001'), self.tree.find_item('REQ001')),
            (self.tree.find_item('SYS002'), self.tree.find_item('REQ001')),
            (None, self.tree.find_item('REQ002')),
            (None, self.tree.find_item('REQ004')),
        ]
        # Act
        rows2 = self.tree.get_traceability()
        # Assert
        self.maxDiff = None
        self.assertListEqual(rows, rows2)

    def test_new_document(self):
        """Verify a new document can be created on a tree."""
        self.tree.create_document(EMPTY, '_TEST', parent='REQ')

    def test_new_document_unknown_parent(self):
        """Verify an exception is raised for an unknown parent."""
        temp = tempfile.mkdtemp()
        self.assertRaises(DoorstopError,
                          self.tree.create_document,
                          temp,
                          '_TEST',
                          parent='UNKNOWN')
        self.assertFalse(os.path.exists(temp))

    @patch('doorstop.core.document.Document.add_item')
    def test_add_item(self, mock_add_item):
        """Verify an item can be added to a document."""
        self.tree.add_item('REQ')
        mock_add_item.assert_called_once_with(number=None,
                                              level=None,
                                              reorder=True)

    @patch('doorstop.core.document.Document.add_item')
    def test_add_item_level(self, mock_add):
        """Verify an item can be added to a document with a level."""
        self.tree.add_item('REQ', level='1.2.3')
        mock_add.assert_called_once_with(number=None,
                                         level='1.2.3',
                                         reorder=True)

    def test_add_item_unknown_prefix(self):
        """Verify an exception is raised for an unknown prefix (item)."""
        # Cache miss
        self.assertRaises(DoorstopError, self.tree.add_item, 'UNKNOWN')
        # Cache hit
        self.assertRaises(DoorstopError, self.tree.add_item, 'UNKNOWN')

    @patch('doorstop.settings.REORDER', False)
    @patch('doorstop.core.item.Item.delete')
    def test_remove_item(self, mock_delete):
        """Verify an item can be removed from a document."""
        self.tree.remove_item('req1', reorder=False)
        mock_delete.assert_called_once_with()

    def test_remove_item_unknown_item(self):
        """Verify an exception is raised removing an unknown item."""
        self.assertRaises(DoorstopError, self.tree.remove_item, 'req9999')

    @patch('doorstop.core.item.Item.link')
    def test_link_items(self, mock_link):
        """Verify two items can be linked."""
        self.tree.link_items('req1', 'req2')
        mock_link.assert_called_once_with('REQ002')

    def test_link_items_unknown_child_prefix(self):
        """Verify an exception is raised with an unknown child prefix."""
        self.assertRaises(DoorstopError, self.tree.link_items, 'unknown1',
                          'req2')

    def test_link_items_unknown_child_number(self):
        """Verify an exception is raised with an unknown child number."""
        self.assertRaises(DoorstopError, self.tree.link_items, 'req9999',
                          'req2')

    def test_link_items_unknown_parent_prefix(self):
        """Verify an exception is raised with an unknown parent prefix."""
        self.assertRaises(DoorstopError, self.tree.link_items, 'req1',
                          'unknown1')

    def test_link_items_unknown_parent_number(self):
        """Verify an exception is raised with an unknown parent prefix."""
        self.assertRaises(DoorstopError, self.tree.link_items, 'req1',
                          'req9999')

    @patch('doorstop.core.item.Item.unlink')
    def test_unlink_items(self, mock_unlink):
        """Verify two items can be unlinked."""
        self.tree.unlink_items('req3', 'req1')
        mock_unlink.assert_called_once_with('REQ001')

    def test_unlink_items_unknown_child_prefix(self):
        """Verify an exception is raised with an unknown child prefix."""
        self.assertRaises(DoorstopError, self.tree.unlink_items, 'unknown1',
                          'req1')

    def test_unlink_items_unknown_child_number(self):
        """Verify an exception is raised with an unknown child number."""
        self.assertRaises(DoorstopError, self.tree.unlink_items, 'req9999',
                          'req1')

    def test_unlink_items_unknown_parent_prefix(self):
        """Verify an exception is raised with an unknown parent prefix."""
        # Cache miss
        self.assertRaises(DoorstopError, self.tree.unlink_items, 'req3',
                          'unknown1')
        # Cache hit
        self.assertRaises(DoorstopError, self.tree.unlink_items, 'req3',
                          'unknown1')

    def test_unlink_items_unknown_parent_number(self):
        """Verify an exception is raised with an unknown parent prefix."""
        self.assertRaises(DoorstopError, self.tree.unlink_items, 'req3',
                          'req9999')

    @patch('doorstop.core.editor.launch')
    def test_edit_item(self, mock_launch):
        """Verify an item can be edited in a tree."""
        self.tree.edit_item('req2', launch=True)
        path = os.path.join(FILES, 'REQ002.yml')
        mock_launch.assert_called_once_with(path, tool=None)

    def test_edit_item_unknown_prefix(self):
        """Verify an exception is raised for an unknown prefix (document)."""
        self.assertRaises(DoorstopError, self.tree.edit_item, 'unknown1')

    def test_edit_item_unknown_number(self):
        """Verify an exception is raised for an unknown number."""
        self.assertRaises(DoorstopError, self.tree.edit_item, 'req9999')

    def test_find_item(self):
        """Verify an item can be found by exact UID."""
        # Cache miss
        item = self.tree.find_item('req2-001')
        self.assertIsNot(None, item)
        # Cache hit
        item2 = self.tree.find_item('req2-001')
        self.assertIs(item2, item)

    def test_find_document(self):
        """Verify an document can be found by prefix"""
        # Cache miss
        document = self.tree.find_document('req')
        self.assertIsNot(None, document)
        # Cache hit
        document2 = self.tree.find_document('req')
        self.assertIs(document2, document)

    def test_load(self):
        """Verify an a tree can be reloaded."""
        self.tree.load()
        self.tree.load()  # should return immediately

    @patch('doorstop.core.document.Document.delete')
    def test_delete(self, mock_delete):
        """Verify a tree can be deleted."""
        self.tree.delete()
        self.assertEqual(0, len(self.tree))
        self.assertEqual(2, mock_delete.call_count)
        self.tree.delete()  # ensure a second delete is ignored
Exemplo n.º 9
0
    def get_issues(self, skip=None, document_hook=None, item_hook=None):  # pylint: disable=unused-argument
        """Yield all the item's issues.

        :param skip: list of document prefixes to skip

        :return: generator of :class:`~doorstop.common.DoorstopError`,
                              :class:`~doorstop.common.DoorstopWarning`,
                              :class:`~doorstop.common.DoorstopInfo`

        """
        assert document_hook is None
        assert item_hook is None
        skip = [] if skip is None else skip

        log.info("checking item %s...", self)

        # Verify the file can be parsed
        self.load()

        # Skip inactive items
        if not self.active:
            log.info("skipped inactive item: %s", self)
            return

        # Delay item save if reformatting
        if settings.REFORMAT:
            self.auto = False

        # Check text
        if not self.text:
            yield DoorstopWarning("no text")

        # Check external references
        if settings.CHECK_REF:
            try:
                self.find_ref()
            except DoorstopError as exc:
                yield exc

        # Check links
        if not self.normative and self.links:
            yield DoorstopWarning("non-normative, but has links")

        # Check links against the document
        if self.document:
            yield from self._get_issues_document(self.document, skip)

        # Check links against the tree
        if self.tree:
            yield from self._get_issues_tree(self.tree)

        # Check links against both document and tree
        if self.document and self.tree:
            yield from self._get_issues_both(self.document, self.tree, skip)

        # Check review status
        if not self.reviewed:
            if settings.CHECK_REVIEW_STATUS:
                if not self._data['reviewed']:
                    if settings.REVIEW_NEW_ITEMS:
                        self.review()
                    else:
                        yield DoorstopInfo("needs initial review")
                else:
                    yield DoorstopWarning("unreviewed changes")

        # Reformat the file
        if settings.REFORMAT:
            log.debug("reformatting item %s...", self)
            self.save()
Exemplo n.º 10
0
class TestDocument(unittest.TestCase):
    """Unit tests for the Document class."""  # pylint: disable=W0212

    def setUp(self):
        self.document = MockDocument(FILES, root=ROOT)

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

    def test_object_references(self):
        """Verify a standalone document does not have object references."""
        self.assertIs(None, self.document.tree)

    def test_load_empty(self):
        """Verify loading calls read."""
        self.document.load()
        self.document._read.assert_called_once_with(self.document.config)

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

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

    def test_load(self):
        """Verify the document config can be loaded from file."""
        self.document._file = YAML_CUSTOM
        self.document.load(reload=True)
        self.assertEqual('CUSTOM', self.document.prefix)
        self.assertEqual('-', self.document.sep)
        self.assertEqual(4, self.document.digits)

    def test_load_parent(self):
        """Verify the document config can be loaded from file with a parent."""
        self.document._file = YAML_CUSTOM_PARENT
        self.document.load()
        self.assertEqual('PARENT', self.document.parent)

    def test_save_empty(self):
        """Verify saving calls write."""
        self.document.tree = Mock()
        self.document.save()
        self.document._write.assert_called_once_with(YAML_DEFAULT,
                                                     self.document.config)
        self.document.tree.vcs.edit.assert_called_once_with(
            self.document.config)

    def test_save_parent(self):
        """Verify a document can be saved with a parent."""
        self.document.parent = 'SYS'
        self.document.save()
        self.assertIn("parent: SYS", self.document._file)

    def test_save_custom(self):
        """Verify a document can be saved with a custom attribute."""
        self.document._data['custom'] = 'this'
        self.document.save()
        self.assertIn("custom: this", self.document._file)

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

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

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

    def test_hash(self):
        """Verify documents can be hashed."""
        document1 = MockDocument('path/to/fake1')
        document2 = MockDocument('path/to/fake2')
        document3 = MockDocument('path/to/fake2')
        my_set = set()
        # Act
        my_set.add(document1)
        my_set.add(document2)
        my_set.add(document3)
        # Assert
        self.assertEqual(2, len(my_set))

    def test_len(self):
        """Verify a document has a length."""
        self.assertEqual(5, len(self.document))

    def test_items(self):
        """Verify the items in a document can be accessed."""
        items = self.document.items
        self.assertEqual(5, len(items))
        for item in self.document:
            logging.debug("item: {}".format(item))
            self.assertIs(self.document, item.document)
            self.assertIs(self.document.tree, item.tree)

    def test_items_cache(self):
        """Verify the items in a document get cached."""
        self.document.tree = Mock()
        self.document.tree._item_cache = {}
        print(self.document.items)
        self.assertEqual(6, len(self.document.tree._item_cache))

    @patch('doorstop.core.document.Document', MockDocument)
    def test_new(self):
        """Verify a new document can be created with defaults."""
        MockDocument._create.reset_mock()
        path = os.path.join(EMPTY, '.doorstop.yml')
        document = MockDocument.new(None,
                                    EMPTY,
                                    root=FILES,
                                    prefix='NEW',
                                    digits=2)
        self.assertEqual('NEW', document.prefix)
        self.assertEqual(2, document.digits)
        MockDocument._create.assert_called_once_with(path, name='document')

    def test_new_existing(self):
        """Verify an exception is raised if the document already exists."""
        self.assertRaises(DoorstopError,
                          Document.new,
                          None,
                          FILES,
                          ROOT,
                          prefix='DUPL')

    @patch('doorstop.core.document.Document', MockDocument)
    def test_new_cache(self):
        """Verify a new documents are cached."""
        mock_tree = Mock()
        mock_tree._document_cache = {}
        document = MockDocument.new(mock_tree,
                                    EMPTY,
                                    root=FILES,
                                    prefix='NEW',
                                    digits=2)
        mock_tree.vcs.add.assert_called_once_with(document.config)
        self.assertEqual(document, mock_tree._document_cache[document.prefix])

    def test_invalid(self):
        """Verify an exception is raised on an invalid document."""
        self.assertRaises(DoorstopError, Document, EMPTY)

    def test_relpath(self):
        """Verify the document's relative path string can be determined."""
        relpath = os.path.relpath(self.document.path, self.document.root)
        text = "@{}{}".format(os.sep, relpath)
        self.assertEqual(text, self.document.relpath)

    def test_sep(self):
        """Verify an documents's separator can be set and read."""
        self.document.sep = '_'
        self.assertIn("sep: _\n", self.document._write.call_args[0][0])
        self.assertEqual('_', self.document.sep)

    def test_sep_invalid(self):
        """Verify an invalid separator is rejected."""
        self.assertRaises(AssertionError, setattr, self.document, 'sep', '?')

    def test_digits(self):
        """Verify an documents's digits can be set and read."""
        self.document.digits = 42
        self.assertIn("digits: 42\n", self.document._write.call_args[0][0])
        self.assertEqual(42, self.document.digits)

    def test_depth(self):
        """Verify the maximum item level depth can be determined."""
        self.assertEqual(3, self.document.depth)

    def test_next_number(self):
        """Verify the next item number can be determined."""
        self.assertEqual(6, self.document.next_number)

    def test_next_number_server(self):
        """Verify the next item number can be determined with a server."""
        self.document.tree = MagicMock()
        self.document.tree.request_next_number = Mock(side_effect=[1, 42])
        self.assertEqual(42, self.document.next_number)

    def test_index_get(self):
        """Verify a document's index can be retrieved."""
        self.assertIs(None, self.document.index)
        with patch('os.path.isfile', Mock(return_value=True)):
            path = os.path.join(self.document.path, self.document.INDEX)
            self.assertEqual(path, self.document.index)

    @patch('doorstop.common.write_lines')
    @patch('doorstop.settings.MAX_LINE_LENGTH', 40)
    def test_index_set(self, mock_write_lines):
        """Verify an document's index can be created."""
        lines = [
            'initial: 1.2.3', 'outline:',
            '            - REQ001: # Lorem ipsum d...',
            '        - REQ003: # Unicode: -40° ±1%',
            '        - REQ004: # Hello, world!',
            '        - REQ002: # Hello, world!',
            '        - REQ2-001: # Hello, world!'
        ]
        # Act
        self.document.index = True  # create index
        # Assert
        gen, path = mock_write_lines.call_args[0]
        lines2 = list(gen)[6:]  # skip lines of info comments
        self.assertListEqual(lines, lines2)
        self.assertEqual(os.path.join(FILES, 'index.yml'), path)

    @patch('doorstop.common.delete')
    def test_index_del(self, mock_delete):
        """Verify a document's index can be deleted."""
        del self.document.index
        mock_delete.assert_called_once_with(None)

    @patch('doorstop.core.document.Document._reorder_automatic')
    @patch('doorstop.core.item.Item.new')
    def test_add_item(self, mock_new, mock_reorder):
        """Verify an item can be added to a document."""
        with patch('doorstop.settings.REORDER', True):
            self.document.add_item()
        mock_new.assert_called_once_with(None,
                                         self.document,
                                         FILES,
                                         ROOT,
                                         'REQ006',
                                         level=Level('2.2'))
        self.assertEqual(0, mock_reorder.call_count)

    @patch('doorstop.core.document.Document.reorder')
    @patch('doorstop.core.item.Item.new')
    def test_add_item_with_level(self, mock_new, mock_reorder):
        """Verify an item can be added to a document with a level."""
        with patch('doorstop.settings.REORDER', True):
            item = self.document.add_item(level='4.2')
        mock_new.assert_called_once_with(None,
                                         self.document,
                                         FILES,
                                         ROOT,
                                         'REQ006',
                                         level='4.2')
        mock_reorder.assert_called_once_with(keep=item)

    @patch('doorstop.core.item.Item.new')
    def test_add_item_with_number(self, mock_new):
        """Verify an item can be added to a document with a number."""
        self.document.add_item(number=999)
        mock_new.assert_called_once_with(None,
                                         self.document,
                                         FILES,
                                         ROOT,
                                         'REQ999',
                                         level=Level('2.2'))

    @patch('doorstop.core.item.Item.new')
    def test_add_item_empty(self, mock_new):
        """Verify an item can be added to an new document."""
        document = MockDocument(NEW, ROOT)
        document.prefix = 'NEW'
        self.assertIsNot(None, document.add_item(reorder=False))
        mock_new.assert_called_once_with(None,
                                         document,
                                         NEW,
                                         ROOT,
                                         'NEW001',
                                         level=None)

    @patch('doorstop.core.item.Item.new')
    def test_add_item_after_header(self, mock_new):
        """Verify the next item after a header is indented."""
        mock_item = Mock()
        mock_item.number = 1
        mock_item.level = Level('1.0')
        self.document._iter = Mock(return_value=[mock_item])
        self.document.add_item()
        mock_new.assert_called_once_with(None,
                                         self.document,
                                         FILES,
                                         ROOT,
                                         'REQ002',
                                         level=Level('1.1'))

    def test_add_item_contains(self):
        """Verify an added item is contained in the document."""
        item = self.document.items[0]
        self.assertIn(item, self.document)
        item2 = self.document.add_item(reorder=False)
        self.assertIn(item2, self.document)

    def test_add_item_cache(self):
        """Verify an added item is cached."""
        self.document.tree = Mock()
        self.document.tree._item_cache = {}
        self.document.tree.request_next_number = None
        item = self.document.add_item(reorder=False)
        self.assertEqual(item, self.document.tree._item_cache[item.uid])

    @patch('doorstop.core.document.Document._reorder_automatic')
    @patch('os.remove')
    def test_remove_item(self, mock_remove, mock_reorder):
        """Verify an item can be removed."""
        with patch('doorstop.settings.REORDER', True):
            item = self.document.remove_item('REQ001')
        mock_reorder.assert_called_once_with(self.document.items,
                                             keep=None,
                                             start=None)
        mock_remove.assert_called_once_with(item.path)

    @patch('os.remove')
    def test_remove_item_contains(self, mock_remove):
        """Verify a removed item is not contained in the document."""
        item = self.document.items[0]
        self.assertIn(item, self.document)
        removed_item = self.document.remove_item(item.uid, reorder=False)
        self.assertEqual(item, removed_item)
        self.assertNotIn(item, self.document)
        mock_remove.assert_called_once_with(item.path)

    @patch('os.remove')
    def test_remove_item_by_item(self, mock_remove):
        """Verify an item can be removed (by item)."""
        item = self.document.items[0]
        self.assertIn(item, self.document)
        removed_item = self.document.remove_item(item, reorder=False)
        self.assertEqual(item, removed_item)
        mock_remove.assert_called_once_with(item.path)

    @patch('os.remove', Mock())
    def test_remove_item_cache(self):
        """Verify a removed item is expunged."""
        self.document.tree = Mock()
        self.document.tree._item_cache = {}
        item = self.document.items[0]
        removed_item = self.document.remove_item(item.uid, reorder=False)
        self.assertIs(None, self.document.tree._item_cache[removed_item.uid])

    @patch('doorstop.core.document.Document._reorder_automatic')
    @patch('doorstop.core.document.Document._reorder_from_index')
    def test_reorder(self, mock_index, mock_auto):
        """Verify items can be reordered."""
        path = os.path.join(self.document.path, 'index.yml')
        common.touch(path)
        # Act
        self.document.reorder()
        # Assert
        mock_index.assert_called_once_with(self.document, path)
        mock_auto.assert_called_once_with(self.document.items,
                                          start=None,
                                          keep=None)

    def test_reorder_automatic(self):
        """Verify items can be reordered automatically."""
        mock_items = [
            Mock(level=Level('2.3')),
            Mock(level=Level('2.3')),
            Mock(level=Level('2.7')),
            Mock(level=Level('3.2.2')),
            Mock(level=Level('3.4.2')),
            Mock(level=Level('3.5.0')),
            Mock(level=Level('3.5.0')),
            Mock(level=Level('3.6')),
            Mock(level=Level('5.0')),
            Mock(level=Level('5.9'))
        ]
        expected = [
            Level('2.3'),
            Level('2.4'),
            Level('2.5'),
            Level('3.1.1'),
            Level('3.2.1'),
            Level('3.3.0'),
            Level('3.4.0'),
            Level('3.5'),
            Level('4.0'),
            Level('4.1')
        ]
        Document._reorder_automatic(mock_items)
        actual = [item.level for item in mock_items]
        self.assertListEqual(expected, actual)

    def test_reorder_automatic_no_change(self):
        """Verify already ordered items can be reordered."""
        mock_items = [
            Mock(level=Level('1.1')),
            Mock(level=Level('1.1.1.1')),
            Mock(level=Level('2')),
            Mock(level=Level('3')),
            Mock(level=Level('4.1.1'))
        ]
        expected = [
            Level('1.1'),
            Level('1.1.1.1'),
            Level('2'),
            Level('3'),
            Level('4.1.1')
        ]
        Document._reorder_automatic(mock_items)
        actual = [item.level for item in mock_items]
        self.assertListEqual(expected, actual)

    def test_reorder_automatic_with_start(self):
        """Verify items can be reordered with a given start."""
        mock_item = Mock(level=Level('2.3'))
        mock_items = [
            Mock(level=Level('2.2')), mock_item,
            Mock(level=Level('2.3')),
            Mock(level=Level('2.7')),
            Mock(level=Level('3.2.2')),
            Mock(level=Level('3.4.2')),
            Mock(level=Level('3.5.0')),
            Mock(level=Level('3.5.0')),
            Mock(level=Level('3.6')),
            Mock(level=Level('5.0')),
            Mock(level=Level('5.9'))
        ]
        expected = [
            Level('1.2'),
            Level('1.3'),
            Level('1.4'),
            Level('1.5'),
            Level('2.1.1'),
            Level('2.2.1'),
            Level('2.3.0'),
            Level('2.4.0'),
            Level('2.5'),
            Level('3.0'),
            Level('3.1')
        ]
        Document._reorder_automatic(mock_items, start=(1, 2), keep=mock_item)
        actual = [item.level for item in mock_items]
        self.assertListEqual(expected, actual)

    def test_reorder_from_index(self):
        """Verify items can be reordered from an index."""
        mock_items = []

        def mock_find_item(uid):
            """Return a mock item and store it."""
            mock_item = MagicMock()
            if uid == 'bb':
                mock_item.level = Level('3.2')
            elif uid == 'bab':
                raise DoorstopError("unknown UID: bab")
            mock_item.uid = uid
            mock_items.append(mock_item)
            return mock_item

        mock_document = Mock()
        mock_document.find_item = mock_find_item

        data = {
            'initial':
            2.0,
            'outline': [{
                'a': None
            }, {
                'b': [{
                    'ba': [{
                        'baa': None
                    }, {
                        'bab': None
                    }, {
                        'bac': None
                    }]
                }, {
                    'bb': None
                }]
            }, {
                'c': None
            }]
        }
        expected = [
            Level('2'),
            Level('3.0'),
            Level('3.1.0'),
            Level('3.1.1'),
            Level('3.1.3'),
            Level('3.2'),
            Level('4')
        ]

        # Act
        with patch('doorstop.common.read_text') as mock_read_text:
            with patch('doorstop.common.load_yaml', Mock(return_value=data)):
                Document._reorder_from_index(mock_document, 'mock_path')

        # Assert
        mock_read_text.assert_called_once_with('mock_path')
        actual = [item.level for item in mock_items]
        self.assertListEqual(expected, actual)

    def test_find_item(self):
        """Verify an item can be found by UID."""
        item = self.document.find_item('req2')
        self.assertIsNot(None, item)

    def test_find_item_exact(self):
        """Verify an item can be found by its exact UID."""
        item = self.document.find_item('req2-001')
        self.assertIsNot(None, item)

    def test_find_item_unknown_number(self):
        """Verify an exception is raised on an unknown number."""
        self.assertRaises(DoorstopError, self.document.find_item, 'req99')

    def test_find_item_unknown_uid(self):
        """Verify an exception is raised on an unknown UID."""
        self.assertRaises(DoorstopError, self.document.find_item, 'unknown99')

    @patch('doorstop.core.item.Item.get_issues')
    @patch('doorstop.core.document.Document.reorder')
    def test_validate(self, mock_reorder, mock_get_issues):
        """Verify a document can be validated."""
        mock_get_issues.return_value = [DoorstopInfo('i')]
        with patch('doorstop.settings.REORDER', True):
            self.assertTrue(self.document.validate())
        mock_reorder.assert_called_once_with(_items=self.document.items)
        self.assertEqual(5, mock_get_issues.call_count)

    @patch('doorstop.core.item.Item.get_issues',
           Mock(return_value=[
               DoorstopError('error'),
               DoorstopWarning('warning'),
               DoorstopInfo('info')
           ]))
    def test_validate_item(self):
        """Verify an item error fails the document check."""
        self.assertFalse(self.document.validate())

    @patch('doorstop.core.item.Item.get_issues', Mock(return_value=[]))
    def test_validate_hook(self):
        """Verify an item hook can be called."""
        mock_hook = MagicMock()
        self.document.validate(item_hook=mock_hook)
        self.assertEqual(5, mock_hook.call_count)

    @patch('doorstop.core.item.Item.delete')
    @patch('os.rmdir')
    def test_delete(self, mock_delete, mock_item_delete):
        """Verify a document can be deleted."""
        self.document.delete()
        self.assertEqual(6, mock_item_delete.call_count)
        self.assertEqual(1, mock_delete.call_count)
        self.document.delete()  # ensure a second delete is ignored

    @patch('doorstop.core.item.Item.delete')
    @patch('os.rmdir')
    def test_delete_with_assets(self, mock_delete, mock_item_delete):
        """Verify a document's assets aren't deleted."""
        mock_delete.side_effect = OSError
        self.document.delete()
        self.assertEqual(6, mock_item_delete.call_count)
        self.assertEqual(1, mock_delete.call_count)
        self.document.delete()  # ensure a second delete is ignored

    @patch('doorstop.core.item.Item.delete', Mock())
    @patch('doorstop.common.delete', Mock())
    def test_delete_cache(self):
        """Verify a deleted document is expunged."""
        self.document.tree = Mock()
        self.document.tree._item_cache = {}
        self.document.tree._document_cache = {}
        self.document.delete()
        self.document.tree.vcs.delete.assert_called_once_with(
            self.document.config)
        self.assertIs(None,
                      self.document.tree._document_cache[self.document.prefix])

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

    def test_issues_duplicate_level(self):
        """Verify duplicate item levels are detected."""
        mock_item1 = Mock()
        mock_item1.uid = 'HLT001'
        mock_item1.level = Level('4.2')
        mock_item2 = Mock()
        mock_item2.uid = 'HLT002'
        mock_item2.level = Level('4.2')
        mock_items = [mock_item1, mock_item2]
        expected = DoorstopWarning("duplicate level: 4.2 (HLT001, HLT002)")
        issue = list(self.document._get_issues_level(mock_items))[0]
        self.assertIsInstance(issue, type(expected))
        self.assertEqual(expected.args, issue.args)

    def test_issues_skipped_level_over(self):
        """Verify skipped (over) item levels are detected."""
        mock_item1 = Mock()
        mock_item1.uid = 'HLT001'
        mock_item1.level = Level('1.1')
        mock_item2 = Mock()
        mock_item2.uid = 'HLT002'
        mock_item2.level = Level('1.3')
        mock_items = [mock_item1, mock_item2]
        expected = DoorstopWarning("skipped level: 1.1 (HLT001), 1.3 (HLT002)")
        issues = list(self.document._get_issues_level(mock_items))
        self.assertEqual(1, len(issues))
        self.assertIsInstance(issues[0], type(expected))
        self.assertEqual(expected.args, issues[0].args)

    def test_issues_skipped_level_out(self):
        """Verify skipped (out) item levels are detected."""
        mock_item1 = Mock()
        mock_item1.uid = 'HLT001'
        mock_item1.level = Level('1.1')
        mock_item2 = Mock()
        mock_item2.uid = 'HLT002'
        mock_item2.level = Level('3.0')
        mock_items = [mock_item1, mock_item2]
        expected = DoorstopWarning("skipped level: 1.1 (HLT001), 3.0 (HLT002)")
        issues = list(self.document._get_issues_level(mock_items))
        self.assertEqual(1, len(issues))
        self.assertIsInstance(issues[0], type(expected))
        self.assertEqual(expected.args, issues[0].args)

    def test_issues_skipped_level_out_over(self):
        """Verify skipped (out and over) item levels are detected."""
        mock_item1 = Mock()
        mock_item1.uid = 'HLT001'
        mock_item1.level = Level('1.1')
        mock_item2 = Mock()
        mock_item2.uid = 'HLT002'
        mock_item2.level = Level('2.2')
        mock_items = [mock_item1, mock_item2]
        expected = DoorstopWarning("skipped level: 1.1 (HLT001), 2.2 (HLT002)")
        issues = list(self.document._get_issues_level(mock_items))
        self.assertEqual(1, len(issues))
        self.assertIsInstance(issues[0], type(expected))
        self.assertEqual(expected.args, issues[0].args)
Exemplo n.º 11
0
class TestDocument(unittest.TestCase):
    """Unit tests for the Document class."""

    # pylint: disable=no-value-for-parameter

    def setUp(self):
        self.document = MockDocument(FILES, root=ROOT)

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

    def test_object_references(self):
        """Verify a standalone document does not have object references."""
        self.assertIs(None, self.document.tree)

    def test_load_empty(self):
        """Verify loading calls read."""
        self.document.load()
        self.document._read.assert_called_once_with(self.document.config)

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

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

    def test_load(self):
        """Verify the document config can be loaded from file."""
        self.document._file = YAML_CUSTOM
        self.document.load(reload=True)
        self.assertEqual('CUSTOM', self.document.prefix)
        self.assertEqual('-', self.document.sep)
        self.assertEqual(4, self.document.digits)

    def test_load_parent(self):
        """Verify the document config can be loaded from file with a parent."""
        self.document._file = YAML_CUSTOM_PARENT
        self.document.load()
        self.assertEqual('PARENT', self.document.parent)

    def test_load_invalid(self):
        """Verify that an invalid document config raises an exception."""
        self.document._file = YAML_INVALID
        msg = "^invalid value for 'digits' in: .*\\.doorstop.yml$"
        self.assertRaisesRegex(DoorstopError, msg, self.document.load)

    def test_load_unknown(self):
        """Verify loading a document config with an unknown key fails."""
        self.document._file = YAML_UNKNOWN
        msg = "^unexpected document setting 'John' in: .*\\.doorstop.yml$"
        self.assertRaisesRegex(DoorstopError, msg, self.document.load)

    def test_load_unknown_attributes(self):
        """Verify loading a document config with unknown attributes fails."""
        self.document._file = YAML_UNKNOWN_ATTRIBUTES
        msg = "^unexpected attributes configuration 'unknown' in: .*\\.doorstop.yml$"
        self.assertRaisesRegex(DoorstopError, msg, self.document.load)

    def test_load_with_non_existing_include(self):
        """Verify include of non-existing file fails."""
        self.document._file = YAML_INCLUDE_NO_SUCH_FILE
        msg = "^include in '.*\\.doorstop.yml' failed: .*$"
        self.assertRaisesRegex(DoorstopError, msg, self.document.load)

    def test_load_extended_reviewed(self):
        """Verify loaded extended reviewed attribute keys of a document."""
        self.document._file = YAML_EXTENDED_REVIEWED
        self.document.load()
        self.assertEqual(
            self.document.extended_reviewed, ['type', 'verification-method']
        )

    def test_load_custom_defaults(self):
        """Verify loaded custom defaults for attributes of a document."""
        self.document._file = YAML_CUSTOM_DEFAULTS
        self.document.load()
        self.assertEqual(
            self.document._attribute_defaults,
            {'a': ['b', 'c'], 'd': {'e': 'f', 'g': 'h'}, 'i': 'j', 'k': None},
        )

    def test_load_defaults_via_include(self):
        """Verify loaded defaults for attributes via includes."""
        self.document._file = YAML_INCLUDE_DEFAULTS
        self.document.load()
        self.assertEqual(self.document._attribute_defaults, {'text': 'Some text'})

    def test_save_empty(self):
        """Verify saving calls write."""
        self.document.tree = Mock()
        self.document.save()
        self.document._write.assert_called_once_with(YAML_DEFAULT, self.document.config)
        self.document.tree.vcs.edit.assert_called_once_with(self.document.config)

    def test_save_parent(self):
        """Verify a document can be saved with a parent."""
        self.document.parent = 'SYS'
        self.document.save()
        self.assertIn("parent: SYS", self.document._file)

    def test_save_custom(self):
        """Verify a document can be saved with a custom attribute."""
        self.document._data['custom'] = 'this'
        self.document.save()
        self.assertIn("custom: this", self.document._file)

    def test_save_extended_reviewed(self):
        """Verify saving of extended reviewed attribute keys."""
        self.document._extended_reviewed = ['type', 'verification-method']
        self.document.save()
        self.assertIn("attributes:", self.document._file)
        self.assertIn("  reviewed:", self.document._file)
        self.assertIn("  - type", self.document._file)
        self.assertIn("  - verification-method", self.document._file)

    def test_no_save_empty_extended_reviewed(self):
        """Verify not saving of empty extended reviewed attribute keys."""
        self.document._extended_reviewed = []
        self.document.save()
        self.assertNotIn("attributes:", self.document._file)
        self.assertNotIn("  reviewed:", self.document._file)

    def test_save_custom_defaults(self):
        """Verify saving of custom default attributes."""
        self.document._attribute_defaults = {'key': 'value'}
        self.document.save()
        self.assertIn("attributes:", self.document._file)
        self.assertIn("  defaults:", self.document._file)
        self.assertIn("    key: value", self.document._file)

    def test_no_save_missing_custom_defaults(self):
        """Verify not saving of missing custom default attributes."""
        self.document.save()
        self.assertNotIn("attributes:", self.document._file)
        self.assertNotIn("  defaults:", self.document._file)

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

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

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

    def test_hash(self):
        """Verify documents can be hashed."""
        document1 = MockDocument('path/to/fake1')
        document2 = MockDocument('path/to/fake2')
        document3 = MockDocument('path/to/fake2')
        my_set = set()
        # Act
        my_set.add(document1)
        my_set.add(document2)
        my_set.add(document3)
        # Assert
        self.assertEqual(2, len(my_set))

    def test_len(self):
        """Verify a document has a length."""
        self.assertEqual(5, len(self.document))

    def test_items(self):
        """Verify the items in a document can be accessed."""
        items = self.document.items
        self.assertEqual(5, len(items))
        for item in self.document:
            logging.debug("item: {}".format(item))
            self.assertIs(self.document, item.document)
            self.assertIs(self.document.tree, item.tree)

    def test_items_cache(self):
        """Verify the items in a document get cached."""
        self.document.tree = Mock()
        self.document.tree._item_cache = {}
        print(self.document.items)
        self.assertEqual(6, len(self.document.tree._item_cache))

    @patch('doorstop.core.document.Document', MockDocument)
    def test_new(self):
        """Verify a new document can be created with defaults."""
        MockDocument._create.reset_mock()
        path = os.path.join(EMPTY, '.doorstop.yml')
        document = MockDocument.new(None, EMPTY, root=FILES, prefix='NEW', digits=2)
        self.assertEqual('NEW', document.prefix)
        self.assertEqual(2, document.digits)
        self.assertEqual(None, document._attribute_defaults)
        self.assertEqual([], document.extended_reviewed)
        MockDocument._create.assert_called_once_with(path, name='document')

    def test_new_existing(self):
        """Verify an exception is raised if the document already exists."""
        self.assertRaises(DoorstopError, Document.new, None, FILES, ROOT, prefix='DUPL')

    @patch('doorstop.core.document.Document', MockDocument)
    def test_new_cache(self):
        """Verify a new documents are cached."""
        mock_tree = Mock()
        mock_tree._document_cache = {}
        document = MockDocument.new(
            mock_tree, EMPTY, root=FILES, prefix='NEW', digits=2
        )
        mock_tree.vcs.add.assert_called_once_with(document.config)
        self.assertEqual(document, mock_tree._document_cache[document.prefix])

    def test_invalid(self):
        """Verify an exception is raised on an invalid document."""
        self.assertRaises(DoorstopError, Document, EMPTY)

    def test_relpath(self):
        """Verify the document's relative path string can be determined."""
        relpath = os.path.relpath(self.document.path, self.document.root)
        text = "@{}{}".format(os.sep, relpath)
        self.assertEqual(text, self.document.relpath)

    def test_sep(self):
        """Verify an documents's separator can be set and read."""
        self.document.sep = '_'
        self.assertIn("sep: _\n", self.document._write.call_args[0][0])
        self.assertEqual('_', self.document.sep)

    def test_sep_invalid(self):
        """Verify an invalid separator is rejected."""
        self.assertRaises(AssertionError, setattr, self.document, 'sep', '?')

    def test_digits(self):
        """Verify an documents's digits can be set and read."""
        self.document.digits = 42
        self.assertIn("digits: 42\n", self.document._write.call_args[0][0])
        self.assertEqual(42, self.document.digits)

    def test_depth(self):
        """Verify the maximum item level depth can be determined."""
        self.assertEqual(3, self.document.depth)

    def test_next_number(self):
        """Verify the next item number can be determined."""
        self.assertEqual(6, self.document.next_number)

    def test_next_number_server(self):
        """Verify the next item number can be determined with a server."""
        self.document.tree = MagicMock()
        self.document.tree.request_next_number = Mock(side_effect=[1, 42])
        self.assertEqual(42, self.document.next_number)

    def test_index_get(self):
        """Verify a document's index can be retrieved."""
        self.assertIs(None, self.document.index)
        with patch('os.path.isfile', Mock(return_value=True)):
            path = os.path.join(self.document.path, self.document.INDEX)
            self.assertEqual(path, self.document.index)

    @patch('doorstop.common.write_lines')
    @patch('doorstop.settings.MAX_LINE_LENGTH', 40)
    def test_index_set(self, mock_write_lines):
        """Verify an document's index can be created."""
        lines = [
            'initial: 1.2.3',
            'outline:',
            '            - REQ001: # Lorem ipsum d...',
            '        - REQ003: # Unicode: -40° ±1%',
            '        - REQ004: # Hello, world!',
            '        - REQ002: # Hello, world!',
            '        - REQ2-001: # Hello, world!',
        ]
        # Act
        self.document.index = True  # create index
        # Assert
        gen, path = mock_write_lines.call_args[0]
        lines2 = list(gen)[8:]  # skip lines of info comments
        self.assertListEqual(lines, lines2)
        self.assertEqual(os.path.join(FILES, 'index.yml'), path)

    @patch('doorstop.common.write_lines')
    @patch('doorstop.settings.MAX_LINE_LENGTH', 40)
    def test_read_index(self, mock_write_lines):
        """Verify a document index can be read."""
        lines = '''initial: 1.2.3
outline:
        - REQ001: # Lorem ipsum d...
        - REQ003: # Unicode: -40° ±1%
        - REQ004: # Hello, world! !['..
        - REQ002: # Hello, world! !["...
        - REQ2-001: # Hello, world!'''

        expected = {
            'initial': '1.2.3',
            'outline': [
                {'REQ001': [{'text': 'Lorem ipsum d...'}]},
                {'REQ003': [{'text': 'Unicode: -40° ±1%'}]},
                {'REQ004': [{'text': "Hello, world! !['.."}]},
                {'REQ002': [{'text': 'Hello, world! !["...'}]},
                {'REQ2-001': [{'text': 'Hello, world!'}]},
            ],
        }
        # Act
        with patch('builtins.open') as mock_open:
            mock_open.side_effect = lambda *args, **kw: mock.mock_open(
                read_data=lines
            ).return_value
            actual = self.document._read_index('mock_path')
            # Check string can be parsed as yaml
        # Assert
        self.assertEqual(expected, actual)

    @patch('doorstop.common.delete')
    def test_index_del(self, mock_delete):
        """Verify a document's index can be deleted."""
        del self.document.index
        mock_delete.assert_called_once_with(None)

    @patch('doorstop.core.document.Document._reorder_automatic')
    @patch('doorstop.core.item.Item.new')
    def test_add_item(self, mock_new, mock_reorder):
        """Verify an item can be added to a document."""
        with patch('doorstop.settings.REORDER', True):
            self.document.add_item()
        mock_new.assert_called_once_with(
            None, self.document, FILES, ROOT, 'REQ006', level=Level('2.2')
        )
        self.assertEqual(0, mock_reorder.call_count)

    @patch('doorstop.core.document.Document.reorder')
    @patch('doorstop.core.item.Item.new')
    def test_add_item_with_level(self, mock_new, mock_reorder):
        """Verify an item can be added to a document with a level."""
        with patch('doorstop.settings.REORDER', True):
            item = self.document.add_item(level='4.2')
        mock_new.assert_called_once_with(
            None, self.document, FILES, ROOT, 'REQ006', level='4.2'
        )
        mock_reorder.assert_called_once_with(keep=item)

    @patch('doorstop.core.item.Item.new')
    def test_add_item_with_number(self, mock_new):
        """Verify an item can be added to a document with a number."""
        self.document.add_item(number=999)
        mock_new.assert_called_once_with(
            None, self.document, FILES, ROOT, 'REQ999', level=Level('2.2')
        )

    @patch('doorstop.core.item.Item.set_attributes')
    def test_add_item_with_defaults(self, mock_set_attributes):
        """Verify an item can be added to a document with defaults."""
        self.document._file = "text: 'abc'"
        self.document.add_item(defaults='mock.yml')
        mock_set_attributes.assert_called_once_with({'text': 'abc'})

    @patch('doorstop.core.item.Item.new')
    def test_add_item_empty(self, mock_new):
        """Verify an item can be added to an new document."""
        document = MockDocument(NEW, ROOT)
        document.prefix = 'NEW'
        self.assertIsNot(None, document.add_item(reorder=False))
        mock_new.assert_called_once_with(
            None, document, NEW, ROOT, 'NEW001', level=None
        )

    @patch('doorstop.core.item.Item.new')
    def test_add_item_after_header(self, mock_new):
        """Verify the next item after a header is indented."""
        mock_item = Mock()
        mock_item.number = 1
        mock_item.level = Level('1.0')
        self.document._iter = Mock(return_value=[mock_item])
        self.document.add_item()
        mock_new.assert_called_once_with(
            None, self.document, FILES, ROOT, 'REQ002', level=Level('1.1')
        )

    def test_add_item_contains(self):
        """Verify an added item is contained in the document."""
        item = self.document.items[0]
        self.assertIn(item, self.document)
        item2 = self.document.add_item(reorder=False)
        self.assertIn(item2, self.document)

    def test_add_item_cache(self):
        """Verify an added item is cached."""
        self.document.tree = Mock()
        self.document.tree._item_cache = {}
        self.document.tree.request_next_number = None
        item = self.document.add_item(reorder=False)
        self.assertEqual(item, self.document.tree._item_cache[item.uid])

    @patch('doorstop.core.document.Document._reorder_automatic')
    @patch('os.remove')
    def test_remove_item(self, mock_remove, mock_reorder):
        """Verify an item can be removed."""
        with patch('doorstop.settings.REORDER', True):
            item = self.document.remove_item('REQ001')
        mock_reorder.assert_called_once_with(self.document.items, keep=None, start=None)
        mock_remove.assert_called_once_with(item.path)

    @patch('os.remove')
    def test_remove_item_contains(self, mock_remove):
        """Verify a removed item is not contained in the document."""
        item = self.document.items[0]
        self.assertIn(item, self.document)
        removed_item = self.document.remove_item(item.uid, reorder=False)
        self.assertEqual(item, removed_item)
        self.assertNotIn(item, self.document)
        mock_remove.assert_called_once_with(item.path)

    @patch('os.remove')
    def test_remove_item_by_item(self, mock_remove):
        """Verify an item can be removed (by item)."""
        item = self.document.items[0]
        self.assertIn(item, self.document)
        removed_item = self.document.remove_item(item, reorder=False)
        self.assertEqual(item, removed_item)
        mock_remove.assert_called_once_with(item.path)

    @patch('os.remove', Mock())
    def test_remove_item_cache(self):
        """Verify a removed item is expunged."""
        self.document.tree = Mock()
        self.document.tree._item_cache = {}
        item = self.document.items[0]
        removed_item = self.document.remove_item(item.uid, reorder=False)
        self.assertIs(None, self.document.tree._item_cache[removed_item.uid])

    @patch('doorstop.core.document.Document._reorder_automatic')
    @patch('doorstop.core.document.Document._reorder_from_index')
    def test_reorder(self, mock_index, mock_auto):
        """Verify items can be reordered."""
        path = os.path.join(self.document.path, 'index.yml')
        common.touch(path)
        # Act
        self.document.reorder()
        # Assert
        mock_index.assert_called_once_with(self.document, path)
        mock_auto.assert_called_once_with(self.document.items, start=None, keep=None)

    def test_reorder_automatic(self):
        """Verify items can be reordered automatically."""
        mock_items = [
            Mock(level=Level('2.3')),
            Mock(level=Level('2.3')),
            Mock(level=Level('2.7')),
            Mock(level=Level('3.2.2')),
            Mock(level=Level('3.4.2')),
            Mock(level=Level('3.5.0')),
            Mock(level=Level('3.5.0')),
            Mock(level=Level('3.6')),
            Mock(level=Level('5.0')),
            Mock(level=Level('5.9')),
        ]
        expected = [
            Level('2.3'),
            Level('2.4'),
            Level('2.5'),
            Level('3.1.1'),
            Level('3.2.1'),
            Level('3.3.0'),
            Level('3.4.0'),
            Level('3.5'),
            Level('4.0'),
            Level('4.1'),
        ]
        Document._reorder_automatic(mock_items)
        actual = [item.level for item in mock_items]
        self.assertListEqual(expected, actual)

    def test_reorder_automatic_no_change(self):
        """Verify already ordered items can be reordered."""
        mock_items = [
            Mock(level=Level('1.1')),
            Mock(level=Level('1.1.1.1')),
            Mock(level=Level('2')),
            Mock(level=Level('3')),
            Mock(level=Level('4.1.1')),
        ]
        expected = [
            Level('1.1'),
            Level('1.1.1.1'),
            Level('2'),
            Level('3'),
            Level('4.1.1'),
        ]
        Document._reorder_automatic(mock_items)
        actual = [item.level for item in mock_items]
        self.assertListEqual(expected, actual)

    def test_reorder_automatic_with_start(self):
        """Verify items can be reordered with a given start."""
        mock_item = Mock(level=Level('2.3'))
        mock_items = [
            Mock(level=Level('2.2')),
            mock_item,
            Mock(level=Level('2.3')),
            Mock(level=Level('2.7')),
            Mock(level=Level('3.2.2')),
            Mock(level=Level('3.4.2')),
            Mock(level=Level('3.5.0')),
            Mock(level=Level('3.5.0')),
            Mock(level=Level('3.6')),
            Mock(level=Level('5.0')),
            Mock(level=Level('5.9')),
        ]
        expected = [
            Level('1.2'),
            Level('1.3'),
            Level('1.4'),
            Level('1.5'),
            Level('2.1.1'),
            Level('2.2.1'),
            Level('2.3.0'),
            Level('2.4.0'),
            Level('2.5'),
            Level('3.0'),
            Level('3.1'),
        ]
        Document._reorder_automatic(mock_items, start=(1, 2), keep=mock_item)
        actual = [item.level for item in mock_items]
        self.assertListEqual(expected, actual)

    def test_find_item(self):
        """Verify an item can be found by UID."""
        item = self.document.find_item('req2')
        self.assertIsNot(None, item)

    def test_find_item_exact(self):
        """Verify an item can be found by its exact UID."""
        item = self.document.find_item('req2-001')
        self.assertIsNot(None, item)

    def test_find_item_unknown_number(self):
        """Verify an exception is raised on an unknown number."""
        self.assertRaises(DoorstopError, self.document.find_item, 'req99')

    def test_find_item_unknown_uid(self):
        """Verify an exception is raised on an unknown UID."""
        self.assertRaises(DoorstopError, self.document.find_item, 'unknown99')

    @patch('doorstop.core.item.Item.get_issues')
    @patch('doorstop.core.document.Document.reorder')
    def test_validate(self, mock_reorder, mock_get_issues):
        """Verify a document can be validated."""
        mock_get_issues.return_value = [DoorstopInfo('i')]
        with patch('doorstop.settings.REORDER', True):
            self.assertTrue(self.document.validate())
        mock_reorder.assert_called_once_with(_items=self.document.items)
        self.assertEqual(5, mock_get_issues.call_count)

    @patch(
        'doorstop.core.item.Item.get_issues',
        Mock(
            return_value=[
                DoorstopError('error'),
                DoorstopWarning('warning'),
                DoorstopInfo('info'),
            ]
        ),
    )
    def test_validate_item(self):
        """Verify an item error fails the document check."""
        self.assertFalse(self.document.validate())

    @patch('doorstop.core.item.Item.get_issues', Mock(return_value=[]))
    def test_validate_hook(self):
        """Verify an item hook can be called."""
        mock_hook = MagicMock()
        self.document.validate(item_hook=mock_hook)
        self.assertEqual(5, mock_hook.call_count)

    @patch('doorstop.core.item.Item.delete')
    @patch('os.rmdir')
    def test_delete(self, mock_delete, mock_item_delete):
        """Verify a document can be deleted."""
        self.document.delete()
        self.assertEqual(6, mock_item_delete.call_count)
        self.assertEqual(1, mock_delete.call_count)
        self.document.delete()  # ensure a second delete is ignored

    @patch('doorstop.core.item.Item.delete')
    @patch('os.rmdir')
    def test_delete_with_assets(self, mock_delete, mock_item_delete):
        """Verify a document's assets aren't deleted."""
        mock_delete.side_effect = OSError
        self.document.delete()
        self.assertEqual(6, mock_item_delete.call_count)
        self.assertEqual(1, mock_delete.call_count)
        self.document.delete()  # ensure a second delete is ignored

    @patch('doorstop.core.item.Item.delete', Mock())
    @patch('doorstop.common.delete', Mock())
    def test_delete_cache(self):
        """Verify a deleted document is expunged."""
        self.document.tree = Mock()
        self.document.tree._item_cache = {}
        self.document.tree._document_cache = {}
        self.document.delete()
        self.document.tree.vcs.delete.assert_called_once_with(self.document.config)
        self.assertIs(None, self.document.tree._document_cache[self.document.prefix])

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

    def test_issues_duplicate_level(self):
        """Verify duplicate item levels are detected."""
        mock_item1 = Mock()
        mock_item1.uid = 'HLT001'
        mock_item1.level = Level('4.2')
        mock_item2 = Mock()
        mock_item2.uid = 'HLT002'
        mock_item2.level = Level('4.2')
        mock_items = [mock_item1, mock_item2]
        expected = DoorstopWarning("duplicate level: 4.2 (HLT001, HLT002)")
        issue = list(self.document._get_issues_level(mock_items))[0]
        self.assertIsInstance(issue, type(expected))
        self.assertEqual(expected.args, issue.args)

    def test_issues_skipped_level_over(self):
        """Verify skipped (over) item levels are detected."""
        mock_item1 = Mock()
        mock_item1.uid = 'HLT001'
        mock_item1.level = Level('1.1')
        mock_item2 = Mock()
        mock_item2.uid = 'HLT002'
        mock_item2.level = Level('1.3')
        mock_items = [mock_item1, mock_item2]
        expected = DoorstopWarning("skipped level: 1.1 (HLT001), 1.3 (HLT002)")
        issues = list(self.document._get_issues_level(mock_items))
        self.assertEqual(1, len(issues))
        self.assertIsInstance(issues[0], type(expected))
        self.assertEqual(expected.args, issues[0].args)

    def test_issues_skipped_level_out(self):
        """Verify skipped (out) item levels are detected."""
        mock_item1 = Mock()
        mock_item1.uid = 'HLT001'
        mock_item1.level = Level('1.1')
        mock_item2 = Mock()
        mock_item2.uid = 'HLT002'
        mock_item2.level = Level('3.0')
        mock_items = [mock_item1, mock_item2]
        expected = DoorstopWarning("skipped level: 1.1 (HLT001), 3.0 (HLT002)")
        issues = list(self.document._get_issues_level(mock_items))
        self.assertEqual(1, len(issues))
        self.assertIsInstance(issues[0], type(expected))
        self.assertEqual(expected.args, issues[0].args)

    def test_issues_skipped_level_out_over(self):
        """Verify skipped (out and over) item levels are detected."""
        mock_item1 = Mock()
        mock_item1.uid = 'HLT001'
        mock_item1.level = Level('1.1')
        mock_item2 = Mock()
        mock_item2.uid = 'HLT002'
        mock_item2.level = Level('2.2')
        mock_items = [mock_item1, mock_item2]
        expected = DoorstopWarning("skipped level: 1.1 (HLT001), 2.2 (HLT002)")
        issues = list(self.document._get_issues_level(mock_items))
        self.assertEqual(1, len(issues))
        self.assertIsInstance(issues[0], type(expected))
        self.assertEqual(expected.args, issues[0].args)

    @patch('os.path.isdir', Mock(return_value=True))
    def test_assets_exist(self):
        """Verify a document can report the existence of the assets folder."""
        path = os.path.join(self.document.path, self.document.ASSETS)
        self.assertEqual(path, self.document.assets)

    @patch('os.path.isdir', Mock(return_value=False))
    def test_assets_missing(self):
        """Verify a document can report the existence of the assets folder."""
        self.assertEqual(None, self.document.assets)

    @patch('os.path.isdir', Mock(return_value=True))
    @patch('glob.glob')
    @patch('shutil.copytree')
    def test_copy_assets(self, mock_copytree, mock_glob):
        """Verify a document can copy its assets"""
        assets = ['css', 'logo.png']
        assets_full_path = [
            os.path.join(self.document.path, self.document.ASSETS, dir)
            for dir in assets
        ]
        mock_glob.return_value = assets_full_path
        dst = os.path.join('publishdir', 'assets')
        expected_calls = [
            call(assets_full_path[0], os.path.join(dst, assets[0])),
            call(assets_full_path[1], os.path.join(dst, assets[1])),
        ]
        # Act]
        self.document.copy_assets(dst)
        # Assert
        self.assertEqual(expected_calls, mock_copytree.call_args_list)

    @patch('os.path.exists', Mock(return_value=True))
    @patch('os.path.isdir', Mock(return_value=True))
    @patch('glob.glob')
    @patch('shutil.copytree')
    def test_copy_assets_skipping(self, mock_copytree, mock_glob):
        """Verify duplicate file or directory names are skipped """
        assets = ['doorstop']
        mock_glob.return_value = assets
        mock_copytree.side_effect = FileExistsError
        dst = os.path.join('publishdir', 'assets')
        # Act]
        self.document.copy_assets(dst)
        # Assert
        self.assertEqual([], mock_copytree.call_args_list)