"""
novelWriter – Main GUI Project Tree Class Tester
================================================

This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock

import pytest

from PyQt5.QtCore import QEvent, QMimeData, QPoint, Qt, QTimer
from PyQt5.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QMouseEvent
from PyQt5.QtWidgets import QMenu, QMessageBox, QTreeWidget, QTreeWidgetItem

from novelwriter import CONFIG, SHARED
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
from novelwriter.dialogs.docmerge import GuiDocMerge
from novelwriter.dialogs.docsplit import GuiDocSplit
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.enum import nwFocus, nwItemClass, nwItemLayout, nwItemType
from novelwriter.gui.projtree import GuiProjectTree, GuiProjectView, _TreeContextMenu
from novelwriter.guimain import GuiMain
from novelwriter.types import (
    QtAccepted, QtModNone, QtMouseLeft, QtMouseMiddle, QtRejected,
    QtScrollAlwaysOff, QtScrollAsNeeded
)

from tests.mocked import causeOSError
from tests.tools import C, buildTestProject


@pytest.mark.gui
def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
    """Test adding and removing items from the project tree."""
    monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))

    projView = nwGUI.projView
    projTree = nwGUI.projView.projTree
    project = SHARED.project

    # Try to add item with no project
    assert projTree.newTreeItem(nwItemType.FILE) is False

    # Create a project
    buildTestProject(nwGUI, projPath)

    # No itemType set
    projTree.clearSelection()
    assert projTree.newTreeItem(None) is False

    # Root Items
    # ==========

    # No class set
    assert projTree.newTreeItem(nwItemType.ROOT) is False

    # Create root item
    assert projTree.newTreeItem(nwItemType.ROOT, nwItemClass.WORLD) is True
    assert "0000000000010" in project.tree

    # File/Folder Items
    # =================

    # No location selected for new item
    projTree.clearSelection()
    caplog.clear()
    assert projTree.newTreeItem(nwItemType.FILE) is False
    assert projTree.newTreeItem(nwItemType.FOLDER) is False
    assert "Did not find anywhere" in caplog.text

    # Create new folder as child of Novel folder
    projView.setSelectedHandle(C.hNovelRoot)
    assert projTree.newTreeItem(nwItemType.FOLDER) is True
    assert project.tree["0000000000011"].itemParent == C.hNovelRoot  # type: ignore
    assert project.tree["0000000000011"].itemRoot == C.hNovelRoot  # type: ignore
    assert project.tree["0000000000011"].itemClass == nwItemClass.NOVEL  # type: ignore

    # Add a new file in the new folder
    projView.setSelectedHandle("0000000000011")
    assert projTree.newTreeItem(nwItemType.FILE) is True
    assert project.tree["0000000000012"].itemParent == "0000000000011"  # type: ignore
    assert project.tree["0000000000012"].itemRoot == C.hNovelRoot  # type: ignore
    assert project.tree["0000000000012"].itemClass == nwItemClass.NOVEL  # type: ignore

    # Add a new chapter next to the other new file
    projView.setSelectedHandle("0000000000012")
    assert projTree.newTreeItem(nwItemType.FILE, hLevel=2) is True
    assert project.tree["0000000000013"].itemParent == "0000000000011"  # type: ignore
    assert project.tree["0000000000013"].itemRoot == C.hNovelRoot  # type: ignore
    assert project.tree["0000000000013"].itemClass == nwItemClass.NOVEL  # type: ignore
    assert nwGUI.openDocument("0000000000013")
    assert nwGUI.docEditor.getText() == "## New Chapter\n\n"

    # Add a new scene next to the other new file
    projView.setSelectedHandle("0000000000012")
    assert projTree.newTreeItem(nwItemType.FILE, hLevel=3) is True
    assert project.tree["0000000000014"].itemParent == "0000000000011"  # type: ignore
    assert project.tree["0000000000014"].itemRoot == C.hNovelRoot  # type: ignore
    assert project.tree["0000000000014"].itemClass == nwItemClass.NOVEL  # type: ignore
    assert nwGUI.openDocument("0000000000014")
    assert nwGUI.docEditor.getText() == "### New Scene\n\n"

    # Add a new scene with the content copied from the previous
    assert nwGUI.openDocument("0000000000014")
    nwGUI.docEditor.setPlainText("### New Scene\n\nWith Stuff\n\n")
    nwGUI.saveDocument()
    projView.setSelectedHandle("0000000000014")
    assert projTree.newTreeItem(nwItemType.FILE, copyDoc="0000000000014") is True
    assert project.tree["0000000000015"].itemParent == "0000000000011"  # type: ignore
    assert project.tree["0000000000015"].itemRoot == C.hNovelRoot  # type: ignore
    assert project.tree["0000000000015"].itemClass == nwItemClass.NOVEL  # type: ignore
    assert nwGUI.openDocument("0000000000015")
    assert nwGUI.docEditor.getText() == "### New Scene\n\nWith Stuff\n\n"

    # Add a new file to the characters folder
    projView.setSelectedHandle(C.hCharRoot)
    assert projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) is True
    assert project.tree["0000000000016"].itemParent == C.hCharRoot  # type: ignore
    assert project.tree["0000000000016"].itemRoot == C.hCharRoot  # type: ignore
    assert project.tree["0000000000016"].itemClass == nwItemClass.CHARACTER  # type: ignore
    assert nwGUI.openDocument("0000000000016")
    assert nwGUI.docEditor.getText() == "# New Note\n\n"

    # Make sure the sibling folder bug trap works
    projView.setSelectedHandle("0000000000013")
    project.tree["0000000000013"].setParent(None)  # This should not happen  # type: ignore
    caplog.clear()
    assert projTree.newTreeItem(nwItemType.FILE) is False
    assert "Internal error" in caplog.text
    project.tree["0000000000013"].setParent("0000000000011")  # type: ignore

    # Cancel during creation
    with monkeypatch.context() as mp:
        mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("", False))
        projView.setSelectedHandle("0000000000013")
        assert projTree.newTreeItem(nwItemType.FILE) is False

    # Get the trash folder
    with monkeypatch.context() as mp:
        mp.setattr(NWProject, "trashFolder", lambda *a: None)
        assert projTree._addTrashRoot() is None

    assert isinstance(projTree._addTrashRoot(), QTreeWidgetItem)
    trashHandle = project.trashFolder()
    projView.setSelectedHandle(trashHandle)
    assert projTree.newTreeItem(nwItemType.FILE) is False
    assert "Cannot add new files or folders to the Trash folder" in caplog.text

    # Rename Item
    # ===========

    # Rename plot folder
    with monkeypatch.context() as mp:
        mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("Stuff", True))
        projTree.renameTreeItem(C.hPlotRoot)
        assert project.tree[C.hPlotRoot].itemName == "Stuff"  # type: ignore

    # Other Checks
    # ============

    # Also check error handling in reveal function
    assert projTree.revealNewTreeItem("abc") is False

    # Add an item that cannot be displayed in the tree
    nHandle = project.newFile("Test", None)  # type: ignore
    assert projTree.revealNewTreeItem(nHandle) is False

    # Adding an invalid item directly to the tree should also fail
    assert projTree._addTreeItem(None) is None

    # Setting values for a non-existing tree item should be handled
    projTree.setTreeItemValues(None)
    projTree.setTreeItemValues(C.hInvalid)  # The function used to take handles

    # Clean up
    # qtbot.stop()
    nwGUI.closeProject()


@pytest.mark.gui
def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
    """Test adding and removing items from the project tree."""
    monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))

    projView = nwGUI.projView
    projTree = nwGUI.projView.projTree

    # Try to move item with no project
    assert projTree.moveTreeItem(1) is False

    # Create a project
    buildTestProject(nwGUI, projPath)

    # Move Documents
    # ==============

    # Add some files
    projView.setSelectedHandle(C.hChapterDir)
    assert projTree.newTreeItem(nwItemType.FILE) is True
    assert projTree.newTreeItem(nwItemType.FILE) is True
    assert projTree.newTreeItem(nwItemType.FILE) is True
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
        "0000000000010", "0000000000011", "0000000000012",
    ]

    # Move with no selections
    projTree.clearSelection()
    assert projTree.moveTreeItem(1) is False

    # Move second item up twice (should give same result)
    projView.setSelectedHandle(C.hSceneDoc)
    assert projTree.moveTreeItem(-1) is True
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hSceneDoc, C.hChapterDoc,
        "0000000000010", "0000000000011", "0000000000012",
    ]
    assert projTree.moveTreeItem(-1) is False
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hSceneDoc, C.hChapterDoc,
        "0000000000010", "0000000000011", "0000000000012",
    ]

    # Restore
    assert projTree.moveTreeItem(1) is True
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
        "0000000000010", "0000000000011", "0000000000012",
    ]

    # Move fifth item down twice (should give same result)
    projView.setSelectedHandle("0000000000011")
    assert projTree.moveTreeItem(1) is True
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
        "0000000000010", "0000000000012", "0000000000011",
    ]
    assert projTree.moveTreeItem(1) is False
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
        "0000000000010", "0000000000012", "0000000000011",
    ]

    # Restore
    assert projTree.moveTreeItem(-1) is True
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
        "0000000000010", "0000000000011", "0000000000012",
    ]

    # Move down again
    projView.setSelectedHandle("0000000000011")
    assert projTree.moveTreeItem(1) is True
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
        "0000000000010", "0000000000012", "0000000000011",
    ]

    # Root Folder
    # ===========

    projView.setSelectedHandle(C.hNovelRoot)
    assert SHARED.project.tree._order.index(C.hNovelRoot) == 0

    # Move novel folder up
    assert projTree.moveTreeItem(-1) is False
    assert SHARED.project.tree._order.index(C.hNovelRoot) == 0

    # Move novel folder down
    assert projTree.moveTreeItem(1) is True
    assert SHARED.project.tree._order.index(C.hNovelRoot) == 1

    # Move novel folder up again
    assert projTree.moveTreeItem(-1) is True
    assert SHARED.project.tree._order.index(C.hNovelRoot) == 0

    # Clean up
    # qtbot.stop()
    nwGUI.closeProject()


@pytest.mark.gui
def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
    """Test external requests for removing items from project tree."""
    monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))

    projView = nwGUI.projView
    projTree = nwGUI.projView.projTree

    # Try to run with no project
    assert projView.requestDeleteItem() is False

    # Create a project
    buildTestProject(nwGUI, projPath)

    # Try emptying the trash already now, when there is no trash folder
    assert projView.emptyTrash() is False

    # Add some files
    projView.setSelectedHandle(C.hChapterDir)
    assert projTree.newTreeItem(nwItemType.FILE) is True
    assert projTree.newTreeItem(nwItemType.FILE) is True
    assert projTree.newTreeItem(nwItemType.FILE) is True
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
        "0000000000010", "0000000000011", "0000000000012",
    ]

    # Delete item without focus -> blocked
    monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: False)
    projView.setSelectedHandle("0000000000012")
    assert projView.requestDeleteItem() is False
    monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True)

    # No selection made
    projTree.clearSelection()
    caplog.clear()
    assert projView.requestDeleteItem() is False
    assert "no item to delete" in caplog.text

    # Not a valid handle
    projTree.clearSelection()
    caplog.clear()
    assert projView.requestDeleteItem("0000000000000") is False
    assert "No tree item with handle '0000000000000'" in caplog.text

    # Delete Root Folders
    # ===================

    assert projView.requestDeleteItem(C.hNovelRoot) is False  # Novel Root is blocked
    assert projView.requestDeleteItem(C.hCharRoot) is True   # Character Root

    # Delete File
    # ===========

    # Block adding trash folder
    funcPointer = projTree._addTrashRoot
    projTree._addTrashRoot = lambda *a: None
    assert projView.requestDeleteItem("0000000000012") is False
    projTree._addTrashRoot = funcPointer

    # Delete last two documents, which also adds the trash folder
    assert projView.requestDeleteItem("0000000000012") is True
    assert projView.requestDeleteItem("0000000000011") is True
    assert projTree.getTreeFromHandle(C.hChapterDir) == [
        C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
        "0000000000010"
    ]
    trashHandle = SHARED.project.tree.trashRoot
    assert projTree.getTreeFromHandle(trashHandle) == [
        trashHandle, "0000000000012", "0000000000011"
    ]

    # Try to delete the trash folder
    caplog.clear()
    assert projView.requestDeleteItem("0000000000013") is False
    assert "Cannot delete the Trash folder" in caplog.text

    nwGUI.closeProject()


@pytest.mark.gui
def testGuiProjTree_MoveItemToTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
    """Test moving items to Trash."""
    monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))

    project = SHARED.project
    projTree = nwGUI.projView.projTree

    # Create a project
    buildTestProject(nwGUI, projPath)

    # Invalid item
    caplog.clear()
    assert projTree.moveItemToTrash(C.hInvalid) is False
    assert "Could not find tree item for deletion" in caplog.text

    # Root folders cannot be moved to Trash
    caplog.clear()
    assert projTree.moveItemToTrash(C.hNovelRoot) is False
    assert "Root folders cannot be moved to Trash" in caplog.text

    # Block adding trash folder
    funcPointer = projTree._addTrashRoot
    projTree._addTrashRoot = lambda *a: None

    caplog.clear()
    assert projTree.moveItemToTrash(C.hTitlePage) is False
    assert project.tree.isTrash(C.hTitlePage) is False
    assert "Could not delete item" in caplog.text

    projTree._addTrashRoot = funcPointer

    # User cancels action
    with monkeypatch.context() as mp:
        mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
        assert projTree.moveItemToTrash(C.hTitlePage) is False
        assert project.tree.isTrash(C.hTitlePage) is False

    # Move a document to Trash
    assert projTree.moveItemToTrash(C.hTitlePage) is True
    assert project.tree.isTrash(C.hTitlePage) is True

    # Cannot be moved again
    caplog.clear()
    assert projTree.moveItemToTrash(C.hTitlePage) is False
    assert "Item is already in the Trash folder" in caplog.text

    nwGUI.closeProject()


@pytest.mark.gui
def testGuiProjTree_PermanentlyDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
    """Test permanently deleting items."""
    monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))

    project = SHARED.project
    projTree = nwGUI.projView.projTree

    # Create a project
    buildTestProject(nwGUI, projPath)

    # Invalid item
    caplog.clear()
    assert projTree.permDeleteItem(C.hInvalid) is False
    assert "Could not find tree item for deletion" in caplog.text

    # Not deleting root item in use
    caplog.clear()
    assert projTree.permDeleteItem(C.hNovelRoot) is False
    assert "Root folders can only be deleted when they are empty" in caplog.text
    assert C.hNovelRoot in project.tree

    # Deleting unused root item is allowed
    caplog.clear()
    assert projTree.permDeleteItem(C.hPlotRoot) is True
    assert C.hPlotRoot not in project.tree

    # User cancels action
    with monkeypatch.context() as mp:
        mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
        assert projTree.permDeleteItem(C.hTitlePage) is False
        assert C.hTitlePage in project.tree

    # Deleting file is OK, and if it is open, it should close
    assert nwGUI.openDocument(C.hTitlePage) is True
    assert nwGUI.docEditor.docHandle == C.hTitlePage
    assert projTree.permDeleteItem(C.hTitlePage) is True
    assert C.hTitlePage not in project.tree
    assert nwGUI.docEditor.docHandle is None

    # Deleting folder + files recursively is ok
    assert projTree.permDeleteItem(C.hChapterDir) is True
    assert C.hChapterDir not in project.tree
    assert C.hChapterDoc not in project.tree
    assert C.hSceneDoc not in project.tree

    nwGUI.closeProject()


@pytest.mark.gui
def testGuiProjTree_EmptyTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
    """Test emptying Trash."""
    monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))

    project = SHARED.project
    projTree = nwGUI.projView.projTree

    # No project open
    caplog.clear()
    assert projTree.emptyTrash() is False
    assert "No project open" in caplog.text

    # Create a project
    buildTestProject(nwGUI, projPath)

    # No Trash folder
    assert projTree.emptyTrash() is False

    # Move some documents to Trash
    assert projTree.moveItemToTrash(C.hTitlePage) is True
    assert projTree.moveItemToTrash(C.hChapterDir) is True

    assert project.tree.isTrash(C.hTitlePage) is True
    assert project.tree.isTrash(C.hChapterDir) is True
    assert project.tree.isTrash(C.hChapterDoc) is True
    assert project.tree.isTrash(C.hSceneDoc) is True

    # User cancels
    with monkeypatch.context() as mp:
        mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
        assert projTree.emptyTrash() is False
        assert C.hTitlePage in project.tree
        assert C.hChapterDir in project.tree
        assert C.hChapterDoc in project.tree
        assert C.hSceneDoc in project.tree

    # Run again to empty all items
    assert projTree.emptyTrash() is True
    assert C.hTitlePage not in project.tree
    assert C.hChapterDir not in project.tree
    assert C.hChapterDoc not in project.tree
    assert C.hSceneDoc not in project.tree

    # Running Empty Trash again is cancelled due to empty folder
    assert projTree.emptyTrash() is False

    nwGUI.closeProject()


@pytest.mark.gui
def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
    """Test the merge document function."""
    mergeData = {}

    monkeypatch.setattr(GuiDocMerge, "__init__", lambda *a: None)
    monkeypatch.setattr(GuiDocMerge, "exec", lambda *a: None)
    monkeypatch.setattr(GuiDocMerge, "softDelete", lambda *a: None)
    monkeypatch.setattr(GuiDocMerge, "result", lambda *a: QtAccepted)
    monkeypatch.setattr(GuiDocMerge, "data", lambda *a: mergeData)

    buildTestProject(nwGUI, projPath)

    project = SHARED.project
    projTree = nwGUI.projView.projTree

    mergedDoc1 = "0000000000014"

    # Create File to Merge
    hChapter1 = project.newFile("Chapter 1", C.hNovelRoot)
    hSceneOne11 = project.newFile("Scene 1.1", hChapter1)  # type: ignore
    hSceneOne12 = project.newFile("Scene 1.2", hChapter1)  # type: ignore
    hSceneOne13 = project.newFile("Scene 1.3", hChapter1)  # type: ignore

    docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n"
    docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n"
    docText3 = "\n\n".join(ipsumText[2:4]) + "\n\n"
    docText4 = "\n\n".join(ipsumText[3:5]) + "\n\n"

    lenText1 = len(docText1)
    lenText2 = len(docText2)
    lenText3 = len(docText3)
    lenText4 = len(docText4)
    lenAll = lenText1 + lenText2 + lenText3 + lenText4

    project.writeNewFile(hChapter1, 2, True, docText1)  # type: ignore
    project.writeNewFile(hSceneOne11, 3, True, docText2)  # type: ignore
    project.writeNewFile(hSceneOne12, 3, True, docText3)  # type: ignore
    project.writeNewFile(hSceneOne13, 3, True, docText4)  # type: ignore

    projTree.revealNewTreeItem(hChapter1)
    projTree.revealNewTreeItem(hSceneOne11)
    projTree.revealNewTreeItem(hSceneOne12)
    projTree.revealNewTreeItem(hSceneOne13)

    # Invalid file handle
    assert projTree._mergeDocuments(C.hInvalid, False) is False

    # Cannot merge root item
    assert projTree._mergeDocuments(C.hNovelRoot, False) is False

    # Merge to new file, but there is now merge data
    mergeData.clear()
    assert projTree._mergeDocuments(hChapter1, True) is False

    # Merge to New Doc
    # ================

    # Set merge job for new documents
    mergeData["finalItems"] = [hChapter1, hSceneOne11, hSceneOne12, hSceneOne13]
    mergeData["moveToTrash"] = False

    # User cancels merge
    with monkeypatch.context() as mp:
        mp.setattr(GuiDocMerge, "result", lambda *a: QtRejected)
        assert projTree._mergeDocuments(hChapter1, True) is False

    # The merge goes through
    assert projTree._mergeDocuments(hChapter1, True) is True
    assert len(project.storage.getDocument(mergedDoc1).readDocument()) > lenAll  # type: ignore

    # Merge to Existing Doc
    # =====================

    # Set merge job for parent document
    mergeData["finalItems"] = [hSceneOne11, hSceneOne12, hSceneOne13]
    mergeData["moveToTrash"] = False

    # Merging to a folder is not allowed
    assert projTree._mergeDocuments(C.hChapterDir, False) is False

    # Block writing and check error handling
    with monkeypatch.context() as mp:
        mp.setattr("builtins.open", causeOSError)
        assert projTree._mergeDocuments(hChapter1, False) is False

    # Successful merge, and move to trash
    mergeData["moveToTrash"] = True
    assert len(project.storage.getDocument(hChapter1).readDocument()) < lenAll  # type: ignore
    assert projTree._mergeDocuments(hChapter1, False) is True
    assert len(project.storage.getDocument(hChapter1).readDocument()) > lenAll  # type: ignore

    assert project.tree.isTrash(hSceneOne11)  # type: ignore
    assert project.tree.isTrash(hSceneOne12)  # type: ignore
    assert project.tree.isTrash(hSceneOne13)  # type: ignore

    # qtbot.stop()


@pytest.mark.gui
def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
    """Test the split document function."""
    splitData = {}
    splitText = []

    monkeypatch.setattr(GuiDocSplit, "__init__", lambda *a: None)
    monkeypatch.setattr(GuiDocSplit, "exec", lambda *a: None)
    monkeypatch.setattr(GuiDocSplit, "softDelete", lambda *a: None)
    monkeypatch.setattr(GuiDocSplit, "result", lambda *a: QtAccepted)
    monkeypatch.setattr(GuiDocSplit, "data", lambda *a: (splitData, splitText))

    # Create a project
    buildTestProject(nwGUI, projPath)

    project = SHARED.project
    projTree = nwGUI.projView.projTree

    docText = (
        "Text\n\n"
        "##! Prologue\n\nText\n\n"
        "## Chapter One\n\nText\n\n"
        "### Scene One\n\nText\n\n"
        "### Scene Two\n\nText\n\n"
        "## Chapter Two\n\nText\n\n"
        "### Scene Three\n\nText\n\n"
        "### Scene Four\n\nText\n\n"
        "#! New Title\n\nText\n\n"
        "## New Chapter\n\nText\n\n"
        "### New Scene\n\nText\n\n"
        "#### New Section\n\nText\n\n"
    )

    hSplitDoc = project.newFile("Split Doc", C.hNovelRoot)
    project.writeNewFile(hSplitDoc, 1, True, docText)  # type: ignore
    projTree.revealNewTreeItem(hSplitDoc, nHandle=C.hNovelRoot, wordCount=True)

    docText = f"# Split Doc\n\n{docText}"
    splitData["headerList"] = [
        (0, 1, "Split Doc"),
        (4, 2, "Prologue"),
        (8, 2, "Chapter One"),
        (12, 3, "Scene One"),
        (16, 3, "Scene Two"),
        (20, 2, "Chapter Two"),
        (24, 3, "Scene Three"),
        (28, 3, "Scene Four"),
        (32, 1, "New Title"),
        (36, 2, "New Chapter"),
        (40, 3, "New Scene"),
        (44, 4, "New Section"),
    ]

    fstSet = [
        "0000000000011", "0000000000012", "0000000000013", "0000000000014",
        "0000000000015", "0000000000016", "0000000000017", "0000000000018",
        "0000000000019", "000000000001a", "000000000001b", "000000000001c",
    ]
    sndSet = [
        "000000000001d", "000000000001e", "000000000001f", "0000000000020",
        "0000000000021", "0000000000022", "0000000000023", "0000000000024",
        "0000000000025", "0000000000026", "0000000000027", "0000000000028",
    ]
    trdSet = [
        "000000000002a", "000000000002b", "000000000002c", "000000000002d",
        "000000000002e", "000000000002f", "0000000000030", "0000000000031",
        "0000000000032", "0000000000033", "0000000000034", "0000000000035",
    ]

    # Try to split an invalid document and a non-document
    assert projTree._splitDocument(C.hInvalid) is False
    assert projTree._splitDocument(C.hNovelRoot) is False

    # Split into same root folder
    splitData["intoFolder"] = False

    # Writing fails
    with monkeypatch.context() as mp:
        mp.setattr("builtins.open", causeOSError)
        assert projTree._splitDocument(hSplitDoc) is True
        for tHandle in fstSet:
            assert tHandle in project.tree
            assert not (projPath / "content" / f"{tHandle}.nwd").is_file()

    # Writing succeeds
    assert projTree._splitDocument(hSplitDoc) is True
    for tHandle in sndSet:
        assert tHandle in project.tree
        assert (projPath / "content" / f"{tHandle}.nwd").is_file()

    # Add to a folder and move source to trash
    splitData["intoFolder"] = True
    splitData["moveToTrash"] = True
    assert projTree._splitDocument(hSplitDoc) is True
    assert "0000000000029" in project.tree  # The folder
    for tHandle in trdSet:
        assert tHandle in project.tree
        assert (projPath / "content" / f"{tHandle}.nwd").is_file()

    assert project.tree.isTrash(hSplitDoc) is True  # type: ignore

    # Cancelled by user
    with monkeypatch.context() as mp:
        mp.setattr(GuiDocSplit, "result", lambda *a: QtRejected)
        assert projTree._splitDocument(hSplitDoc) is False

    # qtbot.stop()


@pytest.mark.gui
def testGuiProjTree_Duplicate(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
    """Test the duplicate items function."""
    # Create a project
    buildTestProject(nwGUI, projPath)
    assert len(SHARED.project.tree) == 8

    projTree = nwGUI.projView.projTree
    projTree._getTreeItem(C.hNovelRoot).setExpanded(True)  # type: ignore
    projTree._getTreeItem(C.hChapterDir).setExpanded(True)  # type: ignore

    # Nothing to do
    assert projTree._duplicateFromHandle(C.hInvalid) is False
    assert len(SHARED.project.tree) == 8

    # Duplicate title page, but select no
    with monkeypatch.context() as mp:
        mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
        assert projTree._duplicateFromHandle(C.hTitlePage) is False
        assert len(SHARED.project.tree) == 8

    # Duplicate title page
    assert projTree._duplicateFromHandle(C.hTitlePage) is True
    assert len(SHARED.project.tree) == 9

    # Duplicate folder
    assert projTree._duplicateFromHandle(C.hChapterDir) is True
    assert len(SHARED.project.tree) == 12

    # Duplicate novel root
    assert projTree._duplicateFromHandle(C.hNovelRoot) is True
    assert len(SHARED.project.tree) == 21

    # Check tree order that all items are next to each other
    assert SHARED.project.tree._order == [
        C.hNovelRoot, C.hTitlePage, "0000000000010", C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
        "0000000000011", "0000000000012", "0000000000013", "0000000000014", "0000000000015",
        "0000000000016", "0000000000017", "0000000000018", "0000000000019", "000000000001a",
        "000000000001b", "000000000001c", C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
    ]

    # Make the duplicator stop early
    content = SHARED.project.storage.contentPath
    assert isinstance(content, Path)
    (content / "000000000001e.nwd").touch()
    assert (content / "000000000001e.nwd").exists()

    # Should only create the folder, and skip the two files because the
    # next handle is already a file
    assert projTree._duplicateFromHandle(C.hChapterDir) is True
    assert len(SHARED.project.tree) == 22

    # qtbot.stop()


@pytest.mark.gui
def testGuiProjTree_AutoScroll(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
    """Test the auto scroll feature."""
    buildTestProject(nwGUI, projPath)
    projTree: GuiProjectTree = nwGUI.projView.projTree

    monkeypatch.setattr(GuiEditLabel, "getLabel", lambda parent, text: (text, True))
    monkeypatch.setattr(QTreeWidget, "dragMoveEvent", lambda *a, **k: None)
    monkeypatch.setattr(QTimer, "isActive", lambda *a: False)
    monkeypatch.setattr(QTimer, "start", lambda *a: None)

    projTree.setSelectedHandle(C.hChapterDir, True)
    projTree._getTreeItem(C.hChapterDir).setExpanded(True)  # type: ignore
    for i in range(100):
        projTree.newTreeItem(nwItemType.FILE, None, 3, False)

    projTree.setSelectedHandle("0000000000015", True)
    nwGUI.resize(500, 500)

    action = Qt.DropAction.MoveAction
    mime = QMimeData()
    mouse = QtMouseLeft
    modifier = QtModNone

    # Scroll Down
    h = projTree.height()
    projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, h-1), action, mime, mouse, modifier))
    assert projTree._scrollDirection == 1
    projTree._doAutoScroll()
    assert projTree._scrollDirection == 0
    projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, h-2), action, mime, mouse, modifier))
    assert projTree._scrollDirection == 1
    projTree._doAutoScroll()
    assert projTree._scrollDirection == 0
    projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, h-3), action, mime, mouse, modifier))
    assert projTree._scrollDirection == 1
    projTree._doAutoScroll()
    assert projTree._scrollDirection == 0
    projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, h-4), action, mime, mouse, modifier))
    assert projTree._scrollDirection == 1
    projTree._doAutoScroll()
    assert projTree._scrollDirection == 0

    # Scroll Up
    projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, 1), action, mime, mouse, modifier))
    assert projTree._scrollDirection == -1
    projTree._doAutoScroll()
    assert projTree._scrollDirection == 0
    projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, 2), action, mime, mouse, modifier))
    assert projTree._scrollDirection == -1
    projTree._doAutoScroll()
    assert projTree._scrollDirection == 0
    projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, 3), action, mime, mouse, modifier))
    assert projTree._scrollDirection == -1
    projTree._doAutoScroll()
    assert projTree._scrollDirection == 0
    projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, 4), action, mime, mouse, modifier))
    assert projTree._scrollDirection == -1
    projTree._doAutoScroll()
    assert projTree._scrollDirection == 0

    # qtbot.stop()


@pytest.mark.gui
def testGuiProjTree_DragAndDrop(qtbot, monkeypatch, caplog, nwGUI: GuiMain, projPath, mockRnd):
    """Test the auto scroll feature."""
    buildTestProject(nwGUI, projPath)
    projTree: GuiProjectTree = nwGUI.projView.projTree

    monkeypatch.setattr(QTreeWidget, "dropEvent", lambda *a, **k: None)

    projTree.setSelectedHandle(C.hSceneDoc, True)
    projTree._getTreeItem(C.hChapterDir).setExpanded(True)  # type: ignore

    nPos = projTree.visualItemRect(projTree._getTreeItem(C.hNovelRoot)).bottomLeft()
    action = Qt.DropAction.MoveAction
    mime = QMimeData()
    mouse = QtMouseLeft
    modifier = QtModNone

    projTree.saveTreeOrder()
    treeOrder = SHARED.project.tree._order

    # Move an item, but no selection
    event = QDropEvent(nPos, action, mime, mouse, modifier)
    projTree.dropEvent(event)
    projTree.saveTreeOrder()
    assert SHARED.project.tree._order == treeOrder

    # Invalid location
    caplog.clear()
    event = QDropEvent(QPoint(1000, 1000), action, mime, mouse, modifier)
    projTree.dropEvent(event)
    assert event.isAccepted() is False
    assert "Invalid drop location" in caplog.text
    projTree.saveTreeOrder()
    assert SHARED.project.tree._order == treeOrder

    # Root item selected
    caplog.clear()
    event = QDropEvent(nPos, action, mime, mouse, modifier)
    projTree.clearSelection()
    projTree._getTreeItem(C.hTitlePage).setSelected(True)  # type: ignore
    projTree._getTreeItem(C.hNovelRoot).setSelected(True)  # type: ignore
    projTree.dropEvent(event)
    assert event.isAccepted() is False
    projTree.saveTreeOrder()
    assert SHARED.project.tree._order == treeOrder

    # Make sure illegal drag events are cancelled
    with monkeypatch.context() as mp:
        mp.setattr(QTreeWidget, "dragEnterEvent", lambda *a: None)
        mime = QMimeData()
        mime.setText("foobar")
        event = QDragEnterEvent(nPos, action, mime, mouse, modifier)
        projTree.clearSelection()
        projTree._getTreeItem(C.hNovelRoot).setSelected(True)  # type: ignore
        projTree._getTreeItem(C.hTitlePage).setSelected(True)  # type: ignore
        projTree._getTreeItem(C.hChapterDoc).setSelected(True)  # type: ignore
        assert projTree.selectedItems() == [  # Novel Root selection is cancelled automatically
            projTree._getTreeItem(C.hTitlePage), projTree._getTreeItem(C.hChapterDoc)
        ]
        projTree.dragEnterEvent(event)
        assert mime.text() == ""
        assert projTree._popAlert is not None

    # Pop the alert
    with monkeypatch.context() as mp:
        mp.setattr(QTreeWidget, "startDrag", lambda *a: None)
        projTree.startDrag(None)  # type: ignore
        assert projTree._popAlert is None

    # Valid drag events are processed
    with monkeypatch.context() as mp:
        mp.setattr(QTreeWidget, "dragEnterEvent", lambda *a: None)
        mime = QMimeData()
        mime.setText("foobar")
        event = QDragEnterEvent(nPos, action, mime, mouse, modifier)
        projTree.clearSelection()
        projTree._getTreeItem(C.hChapterDoc).setSelected(True)  # type: ignore
        projTree._getTreeItem(C.hSceneDoc).setSelected(True)  # type: ignore
        assert projTree.selectedItems() == [  # Novel Root selection is cancelled automatically
            projTree._getTreeItem(C.hChapterDoc), projTree._getTreeItem(C.hSceneDoc)
        ]
        projTree.dragEnterEvent(event)
        assert mime.text() == "foobar"
        assert projTree._popAlert is None

    # qtbot.stop()


@pytest.mark.gui
def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
    """Test various parts of the project tree class not covered by
    other tests.
    """
    buildTestProject(nwGUI, projPath)
    projView: GuiProjectView = nwGUI.projView
    projTree: GuiProjectTree = nwGUI.projView.projTree

    # Method: initSettings
    # ====================

    # Test that the scrollbar setting works
    CONFIG.hideVScroll = True
    CONFIG.hideHScroll = True
    projView.initSettings()
    assert projTree.verticalScrollBarPolicy() == QtScrollAlwaysOff
    assert projTree.horizontalScrollBarPolicy() == QtScrollAlwaysOff

    CONFIG.hideVScroll = False
    CONFIG.hideHScroll = False
    projView.initSettings()
    assert projTree.verticalScrollBarPolicy() == QtScrollAsNeeded
    assert projTree.horizontalScrollBarPolicy() == QtScrollAsNeeded

    # Method: revealNewTreeItem
    # =========================

    # Send invalid handle
    assert projTree.revealNewTreeItem(C.hInvalid) is False

    # Try to add an orphaned file to the tree
    nHandle = SHARED.project.newFile("Test", C.hNovelRoot)
    SHARED.project.tree[nHandle].setParent(None)  # type: ignore
    assert projTree.revealNewTreeItem(nHandle) is False

    # Try to add an item with unknown parent to the tree
    nHandle = SHARED.project.newFile("Test", C.hNovelRoot)
    SHARED.project.tree[nHandle].setParent(C.hInvalid)  # type: ignore
    assert projTree.revealNewTreeItem(nHandle) is False

    # Slot: _treeDoubleClick
    # ======================

    # Try to open a file with nothings selected
    projTree.clearSelection()
    projTree._onDoubleClick(QTreeWidgetItem(), 0)
    assert nwGUI.docEditor.docHandle is None

    # When the item cannot be found
    projTree._getTreeItem(C.hTitlePage).setSelected(True)  # type: ignore
    with monkeypatch.context() as mp:
        mp.setattr("novelwriter.core.tree.NWTree.__getitem__", lambda *a: None)
        projTree._onDoubleClick(QTreeWidgetItem(), 0)
        assert nwGUI.docEditor.docHandle is None

    # Successfully open a file
    projTree._onDoubleClick(projTree._getTreeItem(C.hTitlePage), 0)
    assert nwGUI.docEditor.docHandle == C.hTitlePage
    projTree._getTreeItem(C.hTitlePage).setSelected(False)  # type: ignore

    # A non-file item should be expanded instead
    projTree._getTreeItem(C.hNovelRoot).setExpanded(False)  # type: ignore
    projTree._getTreeItem(C.hNovelRoot).setSelected(True)  # type: ignore
    projTree._onDoubleClick(projTree._getTreeItem(C.hNovelRoot), 1)
    assert nwGUI.docEditor.docHandle == C.hTitlePage
    assert projTree._getTreeItem(C.hNovelRoot).isExpanded() is True  # type: ignore

    # Navigate the Tree
    # =================

    # Expand handles
    projTree.setExpandedFromHandle(C.hNovelRoot, True)
    projTree.setSelectedHandle(C.hSceneDoc)
    assert projTree.getSelectedHandle() == C.hSceneDoc

    # Move between documents in that folder
    projTree.moveToNextItem(-1)
    assert projTree.getSelectedHandle() == C.hChapterDoc
    projTree.moveToNextItem(-1)  # Can't move further up
    assert projTree.getSelectedHandle() == C.hChapterDoc
    projTree.moveToNextItem(1)
    assert projTree.getSelectedHandle() == C.hSceneDoc
    projTree.moveToNextItem(1)  # Can't move further down
    assert projTree.getSelectedHandle() == C.hSceneDoc

    # Move up/down the parent/child hierarchy
    projTree.moveToLevel(-1)
    assert projTree.getSelectedHandle() == C.hChapterDir
    projTree.moveToLevel(-1)
    assert projTree.getSelectedHandle() == C.hNovelRoot
    projTree.moveToLevel(-1)  # Can't move further up
    assert projTree.getSelectedHandle() == C.hNovelRoot
    projTree.moveToLevel(1)
    assert projTree.getSelectedHandle() == C.hTitlePage
    projTree.moveToLevel(1)  # Can't move further down
    assert projTree.getSelectedHandle() == C.hTitlePage

    # Move between roots
    projTree.setSelectedHandle(C.hNovelRoot)
    projTree.moveToNextItem(1)
    assert projTree.getSelectedHandle() == C.hPlotRoot
    projTree.moveToNextItem(1)
    assert projTree.getSelectedHandle() == C.hCharRoot
    projTree.moveToNextItem(1)
    assert projTree.getSelectedHandle() == C.hWorldRoot
    projTree.moveToNextItem(1)  # Can't move further down
    assert projTree.getSelectedHandle() == C.hWorldRoot

    # When nothing is selected, nothing happens
    projTree.clearSelection()
    assert projTree.getSelectedHandle() is None
    projTree.moveToNextItem(-1)
    assert projTree.getSelectedHandle() is None
    projTree.moveToNextItem(1)
    assert projTree.getSelectedHandle() is None
    projTree.moveToLevel(-1)
    assert projTree.getSelectedHandle() is None
    projTree.moveToLevel(1)
    assert projTree.getSelectedHandle() is None

    # Mouse Button Clicks
    # ===================

    eType = QEvent.Type.MouseButtonPress
    pos = projTree.visualItemRect(projTree._getTreeItem(C.hChapterDoc)).center()
    button = QtMouseMiddle
    modifier = QtModNone

    # Trigger the viewer
    event = QMouseEvent(eType, pos, button, button, modifier)
    projTree.mousePressEvent(event)
    assert nwGUI.docViewer.docHandle == C.hChapterDoc

    # Trigger the left click clear
    pos = QPoint(5000, 5000)
    button = QtMouseLeft
    event = QMouseEvent(eType, pos, button, button, modifier)
    projTree.setSelectedHandle(C.hChapterDoc)
    projTree.mousePressEvent(event)
    assert projTree.selectedItems() == []

    # Rename Item
    # ===========

    with monkeypatch.context() as mp:
        mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("FooBar", True))
        projTree.clearSelection()
        assert SHARED.project.tree[C.hChapterDoc].itemName == "New Chapter"  # type: ignore
        projView.renameTreeItem(C.hChapterDoc)
        assert SHARED.project.tree[C.hChapterDoc].itemName == "FooBar"  # type: ignore

        projTree.setSelectedHandle(C.hSceneDoc)
        assert SHARED.project.tree[C.hSceneDoc].itemName == "New Scene"  # type: ignore
        projView.renameTreeItem()
        assert SHARED.project.tree[C.hSceneDoc].itemName == "FooBar"  # type: ignore

    # Check Crash Resistance
    # ======================
    projTree._postItemMove("dfghj")  # This should exit cleanly

    # qtbot.stop()


@pytest.mark.gui
def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
    """Test the building of the project tree context menu. All this does
    is test that the menu builds. It doesn't open the actual menu.
    """
    monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))

    # Create a project
    buildTestProject(nwGUI, projPath)
    nwGUI.openProject(projPath)
    nwGUI._switchFocus(nwFocus.TREE)

    # Handles for new objects
    hCharNote     = "0000000000011"
    hNovelNote    = "0000000000012"
    hSubNote      = "0000000000013"
    hNewFolderOne = "0000000000014"
    hNewFolderTwo = "0000000000016"

    projView = nwGUI.projView
    projTree = nwGUI.projView.projTree
    projTree.setExpandedFromHandle(None, True)

    projTree._addTrashRoot()
    hTrashRoot = SHARED.project.tree.trashRoot

    projTree.setSelectedHandle(C.hCharRoot)
    projTree.newTreeItem(nwItemType.FILE)
    projTree.setSelectedHandle(C.hNovelRoot)
    projTree.newTreeItem(nwItemType.FILE, isNote=True)

    SHARED.project.newFile("SubNote", hNovelNote)
    projTree.revealNewTreeItem(hSubNote)
    assert SHARED.project.tree[hSubNote].itemParent == hNovelNote  # type: ignore

    def itemPos(tHandle):
        return projTree.visualItemRect(projTree._getTreeItem(tHandle)).center()

    # Pop the menu in various positions and check for success
    with monkeypatch.context() as mp:
        mockMenu = MagicMock()
        mp.setattr(QMenu, "exec", mockMenu)
        projTree.clearSelection()

        # No item under menu
        projTree.openContextMenu(projTree.viewport().rect().bottomRight())
        assert mockMenu.call_count == 0

        # Open Trash Menu
        projTree.openContextMenu(itemPos(hTrashRoot))
        assert mockMenu.call_count == 1

        # Open Single Select Menu
        projTree.openContextMenu(itemPos(C.hNovelRoot))
        assert mockMenu.call_count == 2

        # Open Multi-Select Menu
        projTree._getTreeItem(hNovelNote).setSelected(True)
        projTree._getTreeItem(hSubNote).setSelected(True)
        projTree.openContextMenu(itemPos(hCharNote))
        assert mockMenu.call_count == 3

        # Check the keyboard shortcut handler as well
        projTree.setSelectedHandle(C.hNovelRoot)
        projTree.openContextMenu(None)
        assert mockMenu.call_count == 4
        projTree.clearSelection()
        projTree.openContextMenu(None)
        assert mockMenu.call_count == 4

    # Menu Builders
    # =============

    # Context Menu on Root Item
    nwItem = SHARED.project.tree[C.hNovelRoot]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildSingleSelectMenu(True)
    actions = [x.text() for x in ctxMenu.actions() if x.text()]
    assert actions == [
        "Create New ...", "Rename", "Set Status to ...", "Expand All",
        "Collapse All", "Duplicate", "Delete Permanently",
    ]

    # Context Menu on Folder Item
    nwItem = SHARED.project.tree[C.hChapterDir]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildSingleSelectMenu(True)
    actions = [x.text() for x in ctxMenu.actions() if x.text()]
    assert actions == [
        "Create New ...", "Rename", "Set Status to ...", "Transform ...", "Expand All",
        "Collapse All", "Duplicate", "Move to Trash",
    ]

    def getTransformSubMenu(menu: QMenu) -> list[str]:
        for action in menu.actions():
            if action.text() == "Transform ...":
                return [x.text() for x in action.menu().actions() if x.text()]
        return []

    # Context Menu on Document File Item
    nwItem = SHARED.project.tree[C.hChapterDoc]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildSingleSelectMenu(True)
    actions = [x.text() for x in ctxMenu.actions() if x.text()]
    assert actions == [
        "Open Document", "View Document", "Create New ...", "Rename", "Rename to Heading",
        "Toggle Active", "Set Status to ...", "Transform ...", "Expand All", "Collapse All",
        "Duplicate", "Move to Trash",
    ]
    assert getTransformSubMenu(ctxMenu) == [
        "Convert to Project Note", "Merge Child Items into Self",
        "Merge Child Items into New", "Split Document by Headings"
    ]

    # Context Menu on Note File Item in Character Folder
    nwItem = SHARED.project.tree[hCharNote]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildSingleSelectMenu(False)
    actions = [x.text() for x in ctxMenu.actions() if x.text()]
    assert actions == [
        "Open Document", "View Document", "Create New ...", "Rename", "Rename to Heading",
        "Toggle Active", "Set Importance to ...", "Transform ...", "Duplicate", "Move to Trash",
    ]
    assert getTransformSubMenu(ctxMenu) == [
        "Split Document by Headings",
    ]

    # Context Menu on Note File Item in Novel Tree
    nwItem = SHARED.project.tree[hNovelNote]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildSingleSelectMenu(False)
    actions = [x.text() for x in ctxMenu.actions() if x.text()]
    assert actions == [
        "Open Document", "View Document", "Create New ...", "Rename", "Rename to Heading",
        "Toggle Active", "Set Status to ...", "Transform ...", "Duplicate", "Move to Trash",
    ]
    assert getTransformSubMenu(ctxMenu) == [
        "Convert to Novel Document", "Split Document by Headings",
    ]

    # Context Menu on Multiple Items, Clicked on Document
    nwItem = SHARED.project.tree[hNovelNote]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote])
    actions = [x.text() for x in ctxMenu.actions() if x.text()]
    assert actions == [
        "Set Active to ...", "Set Status to ...", "Move to Trash",
    ]

    # Context Menu on Multiple Items, Clicked on Note
    nwItem = SHARED.project.tree[hCharNote]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote])
    actions = [x.text() for x in ctxMenu.actions() if x.text()]
    assert actions == [
        "Set Active to ...", "Set Importance to ...", "Move to Trash",
    ]

    # Direct Edit Functions, Single
    # =============================

    nwItem = SHARED.project.tree[hNovelNote]
    assert isinstance(nwItem, NWItem)

    # # Toggle active flag
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildSingleSelectMenu(False)
    assert nwItem.isActive is True
    ctxMenu._toggleItemActive()
    assert nwItem.isActive is False

    # Change item status
    assert nwItem.itemStatus == "s000000"
    ctxMenu._changeItemStatus("s000001")
    assert nwItem.itemStatus == "s000001"

    # Change item importance
    assert nwItem.itemImport == "i000004"
    ctxMenu._changeItemImport("i000005")
    assert nwItem.itemImport == "i000005"

    # Change item layout
    assert nwItem.itemLayout == nwItemLayout.NOTE
    ctxMenu._changeItemLayout(nwItemLayout.DOCUMENT)
    assert nwItem.itemLayout == nwItemLayout.DOCUMENT
    ctxMenu._changeItemLayout(nwItemLayout.NOTE)
    assert nwItem.itemLayout == nwItemLayout.NOTE

    # Convert Folders to Documents
    # ============================

    projView.setSelectedHandle(hNovelNote)
    assert projTree.newTreeItem(nwItemType.FOLDER) is True
    projView.setSelectedHandle(hNewFolderOne)
    assert projTree.newTreeItem(nwItemType.FILE) is True

    projView.setSelectedHandle(hNovelNote)
    assert projTree.newTreeItem(nwItemType.FOLDER) is True
    projView.setSelectedHandle(hNewFolderTwo)
    assert projTree.newTreeItem(nwItemType.FILE, isNote=True) is True

    nwItem = SHARED.project.tree[hNewFolderOne]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildSingleSelectMenu(False)

    # Click no on the dialog
    with monkeypatch.context() as mp:
        mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
        ctxMenu._covertFolderToFile(nwItemLayout.DOCUMENT)
        assert SHARED.project.tree[hNewFolderOne].isFolderType()  # type: ignore

    # Convert the first folder to a document
    ctxMenu._covertFolderToFile(nwItemLayout.DOCUMENT)
    assert SHARED.project.tree[hNewFolderOne].isFileType()  # type: ignore
    assert SHARED.project.tree[hNewFolderOne].isDocumentLayout()  # type: ignore

    nwItem = SHARED.project.tree[hNewFolderTwo]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildSingleSelectMenu(False)

    # Convert the second folder to a note
    ctxMenu._covertFolderToFile(nwItemLayout.NOTE)
    assert SHARED.project.tree[hNewFolderTwo].isFileType()  # type: ignore
    assert SHARED.project.tree[hNewFolderTwo].isNoteLayout()  # type: ignore

    # Direct Edit Functions, Multi
    # ============================

    nwItem = SHARED.project.tree[hCharNote]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote])

    projTree.clearSelection()
    projTree._getTreeItem(hCharNote).setSelected(True)
    projTree._getTreeItem(hNovelNote).setSelected(True)
    projTree._getTreeItem(hSubNote).setSelected(True)

    # Item Active
    assert SHARED.project.tree[hCharNote].isActive is True  # type: ignore
    assert SHARED.project.tree[hNovelNote].isActive is False  # type: ignore
    assert SHARED.project.tree[hSubNote].isActive is True  # type: ignore
    ctxMenu._iterItemActive(False)
    assert SHARED.project.tree[hCharNote].isActive is False  # type: ignore
    assert SHARED.project.tree[hNovelNote].isActive is False  # type: ignore
    assert SHARED.project.tree[hSubNote].isActive is False  # type: ignore
    ctxMenu._iterItemActive(True)
    assert SHARED.project.tree[hCharNote].isActive is True  # type: ignore
    assert SHARED.project.tree[hNovelNote].isActive is True  # type: ignore
    assert SHARED.project.tree[hSubNote].isActive is True  # type: ignore

    # Item Status
    assert SHARED.project.tree[hCharNote].itemStatus  == "s000000"  # type: ignore
    assert SHARED.project.tree[hNovelNote].itemStatus == "s000001"  # type: ignore
    assert SHARED.project.tree[hSubNote].itemStatus   == "s000000"  # type: ignore
    ctxMenu._iterSetItemStatus("s000003")
    assert SHARED.project.tree[hCharNote].itemStatus  == "s000000"  # type: ignore
    assert SHARED.project.tree[hNovelNote].itemStatus == "s000003"  # type: ignore
    assert SHARED.project.tree[hSubNote].itemStatus   == "s000003"  # type: ignore

    # Item Importance
    assert SHARED.project.tree[hCharNote].itemImport  == "i000004"  # type: ignore
    assert SHARED.project.tree[hNovelNote].itemImport == "i000005"  # type: ignore
    assert SHARED.project.tree[hSubNote].itemImport   == "i000004"  # type: ignore
    ctxMenu._iterSetItemImport("i000007")
    assert SHARED.project.tree[hCharNote].itemImport  == "i000007"  # type: ignore
    assert SHARED.project.tree[hNovelNote].itemImport == "i000005"  # type: ignore
    assert SHARED.project.tree[hSubNote].itemImport   == "i000004"  # type: ignore

    # Move to Trash
    assert SHARED.project.tree[hCharNote].itemRoot  == C.hCharRoot   # type: ignore
    assert SHARED.project.tree[hNovelNote].itemRoot == C.hNovelRoot  # type: ignore
    assert SHARED.project.tree[hSubNote].itemRoot   == C.hNovelRoot  # type: ignore
    ctxMenu._iterMoveToTrash()
    assert SHARED.project.tree[hCharNote].itemRoot  == hTrashRoot  # type: ignore
    assert SHARED.project.tree[hNovelNote].itemRoot == hTrashRoot  # type: ignore
    assert SHARED.project.tree[hSubNote].itemRoot   == hTrashRoot  # type: ignore

    # Permanently Delete Menu
    nwItem = SHARED.project.tree[hCharNote]
    assert isinstance(nwItem, NWItem)
    ctxMenu = _TreeContextMenu(projTree, nwItem)
    ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote])
    actions = [x.text() for x in ctxMenu.actions() if x.text()]
    assert actions == [
        "Set Active to ...", "Set Importance to ...", "Delete Permanently",
    ]

    # Permanently Delete
    ctxMenu._iterPermDelete()
    assert SHARED.project.tree[hCharNote] is None
    assert SHARED.project.tree[hNovelNote] is None
    assert SHARED.project.tree[hSubNote] is None

    # qtbot.stop()


@pytest.mark.gui
def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
    """Test the templates feature of the project tree."""
    monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))

    # Create a project
    buildTestProject(nwGUI, projPath)
    nwGUI.openProject(projPath)
    nwGUI._switchFocus(nwFocus.TREE)
    nwGUI.show()

    project = SHARED.project

    projView = nwGUI.projView
    projTree = projView.projTree
    projBar  = projView.projBar

    # Handles for new objects
    hTemplatesRoot = "0000000000010"
    hSceneTemplate = "0000000000011"
    hNoteTemplate  = "0000000000012"
    hNewScene      = "0000000000013"
    hNewCharacter  = "0000000000014"

    # Add template folder
    projTree.newTreeItem(nwItemType.ROOT, nwItemClass.TEMPLATE)
    nwTemplateRoot = project.tree[hTemplatesRoot]
    assert nwTemplateRoot is not None
    assert nwTemplateRoot.itemName == "Templates"

    # Add a scene template
    projTree.setSelectedHandle(hTemplatesRoot)
    projTree.newTreeItem(nwItemType.FILE, hLevel=3, isNote=False)
    nwSceneTemplate = project.tree[hSceneTemplate]
    assert nwSceneTemplate is not None
    assert nwSceneTemplate.itemName == "New Scene"
    assert projBar.mTemplates.actions()[0].text() == "New Scene"

    # Rename the scene template
    with qtbot.waitSignal(projTree.itemRefreshed, timeout=1000) as signal:
        projTree.renameTreeItem(hSceneTemplate, name="Scene")
        assert signal.args[0] == hSceneTemplate
        assert signal.args[1].itemName == "Scene"
        assert projBar.mTemplates.actions()[0].text() == "Scene"

    # Add a note template
    projTree.setSelectedHandle(hTemplatesRoot)
    projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True)
    nwNoteTemplate = project.tree[hNoteTemplate]
    assert nwNoteTemplate is not None
    assert nwNoteTemplate.itemName == "New Note"
    assert projBar.mTemplates.actions()[1].text() == "New Note"

    # Rename the note template
    with qtbot.waitSignal(projTree.itemRefreshed, timeout=1000) as signal:
        projTree.renameTreeItem(hNoteTemplate, name="Note")
        assert signal.args[0] == hNoteTemplate
        assert signal.args[1].itemName == "Note"
        assert projBar.mTemplates.actions()[1].text() == "Note"

    # Add new content to template files
    (projPath / "content" / f"{hSceneTemplate}.nwd").write_text("### Scene\n\n@pov: Jane\n\n")
    (projPath / "content" / f"{hNoteTemplate}.nwd").write_text("# Jane\n\n@tag: Jane\n\n")

    # Add a new scene using the template
    projTree.setSelectedHandle(C.hSceneDoc, doScroll=True)
    projBar.mTemplates.actions()[0].trigger()
    nwNewScene = project.tree[hNewScene]
    assert nwNewScene is not None
    assert nwNewScene.itemName == "Scene"
    assert project.storage.getDocument(hNewScene).readDocument() == "### Scene\n\n@pov: Jane\n\n"

    # Add a new note using the template
    projTree.setSelectedHandle(C.hCharRoot, doScroll=True)
    projBar.mTemplates.actions()[1].trigger()
    nwNewCharacter = project.tree[hNewCharacter]
    assert nwNewCharacter is not None
    assert nwNewCharacter.itemName == "Note"
    assert project.storage.getDocument(hNewCharacter).readDocument() == "# Jane\n\n@tag: Jane\n\n"

    # Remove the templates
    with qtbot.waitSignal(projTree.itemRefreshed, timeout=1000) as signal:
        assert projBar.mTemplates.menuAction().isVisible() is True
        assert len(projBar.mTemplates.actions()) == 2
        projTree.moveItemToTrash(hNoteTemplate)
        assert len(projBar.mTemplates.actions()) == 1
        projTree.moveItemToTrash(hSceneTemplate)
        assert len(projBar.mTemplates.actions()) == 0
        assert projBar.mTemplates.menuAction().isVisible() is False

    # qtbot.stop()
