Files
UmbraEdit/tests/helpers.py
B.D. Murphy 70d74e675f chore: rename project from novelWriter to UmbraEdit
- Rename project name and package: novelWriter -> UmbraEdit, novelwriter -> umbraedit
- Rename entry script novelWriter.py -> UmbraEdit.py
- Rename package directory novelwriter/ -> umbraedit/
- Rename i18n translation sources nw_*.ts -> ue_*.ts
- Rename packaging files (appdata, project xml template)
- Update all imports, references, docs, workflows, tests and build config
- Update pyproject.toml project name, GUI script and URLs
- Preserve full upstream git history; origin repointed below
2026-11-05 07:08:12 -08:00

291 lines
9.0 KiB
Python

"""
UmbraEdit - Test Suite Helpers
================================
This file is a part of UmbraEdit.
Copyright (C) 2019 Veronica Berglyd Olsen and UmbraEdit contributors.
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/>.
""" # noqa
from __future__ import annotations
import difflib
import gc
import weakref
import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QWidget
from umbraedit import logger
if TYPE_CHECKING:
import pytest
from pytestqt.qtbot import QtBot
from umbraedit.core.project import NWProject
XML_IGNORE = ("<UmbraEditXML ", "<project ", "<dailyTarget ")
ODT_IGNORE = ("<meta:generator", "<meta:creation-date", "<dc:date", "<meta:editing")
EPUB_IGNORE = ('<meta property="dcterms:date">', '<meta property="dcterms:modified">', "<dc:date>")
NWD_IGNORE = ("createdDate =", "updatedDate =")
DOCX_IGNORE = ("<dcterms:created", "<dcterms:modified", "<ns0:Application")
MOCK_TIME = datetime(2019, 5, 10, 18, 52, 0).timestamp()
class C:
"""Constants from the test project when the random generator is mocked."""
# Import and status items from test project when random generator is mocked
sNew = "s000000"
sNote = "s000001"
sDraft = "s000002"
sFinished = "s000003"
iNew = "i000004"
iMinor = "i000005"
iMajor = "i000006"
iMain = "i000007"
# Handles from test project when random generator is mocked
hInvalid = "0000000000000"
hNovelRoot = "0000000000008"
hPlotRoot = "0000000000009"
hTrashRoot = "0000000000010"
hCharRoot = "000000000000a"
hWorldRoot = "000000000000b"
hTitlePage = "000000000000c"
hChapterDir = "000000000000d"
hChapterDoc = "000000000000e"
hSceneDoc = "000000000000f"
@dataclass(frozen=True)
class TstPaths:
"""Paths used by the test suite."""
testDir: Path
filesDir: Path
refDir: Path
outDir: Path
tmpDir: Path
cnfDir: Path
class MockRnd:
"""Mock random generator counting upwards from 0 for predictable handles."""
def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._monkeypatch = monkeypatch
self.reset()
def _rnd(self, n: int) -> Iterator[int]:
yield from range(n)
def reset(self) -> None:
"""Restart the sequence from 0."""
gen = self._rnd(1000)
self._monkeypatch.setattr("random.getrandbits", lambda *a: next(gen))
def cmpFiles(
fromFile: Path,
toFile: Path,
ignLines: Sequence[int] | None = None,
ignStart: Sequence[str] | None = None,
) -> bool:
"""Compare two files, with optional line ignore by line number, or
by matching the start of a line, ignoring leading whitespace.
"""
ignLines = ignLines or []
ignStart = tuple(ignStart or [])
def loadLines(path: Path) -> list[str] | None:
"""Load a file and return its lines, stripping ignored lines."""
try:
lines = path.read_text(encoding="utf-8").strip().splitlines()
except Exception as exc:
print(str(exc))
return None
return [
line
for num, line in enumerate(lines, start=1)
if not (num in ignLines or line.lstrip().startswith(ignStart))
]
def colourDiffLine(line: str) -> str:
"""Add bright ANSI colours to a line of a unified diff."""
if line.startswith(("+++", "---")):
return f"\033[1m{line}\033[0m"
elif line.startswith("+"):
return f"\033[92m{line}\033[0m"
elif line.startswith("-"):
return f"\033[91m{line}\033[0m"
elif line.startswith("@@"):
return f"\033[96m{line}\033[0m"
return line
fromText = loadLines(fromFile)
toText = loadLines(toFile)
if fromText is None or toText is None:
return False
if fromText == toText:
return True
diff = difflib.unified_diff(fromText, toText, fromfile=str(fromFile), tofile=str(toFile), lineterm="")
print("\n".join(colourDiffLine(line) for line in diff))
return False
def xmlToText(xElem: ET.Element) -> str:
"""Get the text content of an XML element."""
text = ET.tostring(xElem, encoding="utf-8", xml_declaration=False).decode()
bits = text.partition(">")
node = bits[0].partition(" ")
rest = " ".join(x for x in node[2].split() if not x.startswith("xmlns")).replace("/", " /")
return f"{node[0]}{rest}{bits[1]}{bits[2]}"
def readFile(file: Path) -> str:
"""Return the content of a file as a string."""
return file.read_text(encoding="utf-8")
def writeFile(file: Path, data: str) -> None:
"""Write the contents of a string to a file."""
file.write_text(data, encoding="utf-8")
def clearLogHandlers() -> None:
"""Clear all log handlers."""
for handler in logger.handlers:
logger.removeHandler(handler)
def buildTestProject(project: NWProject, path: Path) -> None:
"""Build a standard test project in path using the given project
object.
"""
from umbraedit.enum import nwItemClass
project.storage.createNewProject(path)
project.setDefaultStatusImport()
project.data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")
project.data.setName("New Project")
project.data.setAuthor("Jane Doe")
# Creating a minimal project with a few root folders and a
# single chapter folder with a single file.
nrHandle = project.newRoot(nwItemClass.NOVEL)
project.newRoot(nwItemClass.PLOT)
project.newRoot(nwItemClass.CHARACTER)
project.newRoot(nwItemClass.WORLD)
tdHandle = project.newFile("Title Page", nrHandle)
cfHandle = project.newFolder("New Folder", nrHandle) or ""
cdHandle = project.newFile("New Chapter", cfHandle)
sdHandle = project.newFile("New Scene", cfHandle)
aDoc = project.storage.getDocument(tdHandle)
aDoc.writeDocument("#! New Novel\n\n>> By Jane Doe <<\n")
project.index.reIndexHandle(tdHandle)
aDoc = project.storage.getDocument(cdHandle)
aDoc.writeDocument("## {0}\n\n".format(project.tr("New Chapter")))
project.index.reIndexHandle(cdHandle)
aDoc = project.storage.getDocument(sdHandle)
aDoc.writeDocument("### {0}\n\n".format(project.tr("New Scene")))
project.index.reIndexHandle(sdHandle)
project.session.startSession()
project.setProjectChanged(True)
project.saveProject()
project._valid = True
project._tree._ready = True
def checkDialogFreedOnClose(qtbot: QtBot, factory: Callable[[], QDialog]) -> None:
"""Check that a dialog is freed by reference count alone when it is
closed. This guards against PyQt6 reference loops where a closure
connected to a signal captures the dialog that owns the sender, in
which case the dialog lingers until the cyclic garbage collector
runs. The dialog must be created by the factory so that the check
holds the only reference to it.
"""
dialog = factory()
dialog.show()
qtbot.wait(20)
ref = weakref.ref(dialog)
gc.disable()
try:
dialog.close()
qtbot.wait(20)
del dialog
assert ref() is None, "Dialog was not freed by reference count on close"
finally:
gc.enable()
def checkWidgetFreedOnRelease(factory: Callable[[], QWidget]) -> None:
"""Check that a transient widget, like a context menu, is freed by
reference count alone when its parent is cleared and the last
reference is dropped. The widget must be created by the factory so
that the check holds the only reference to it.
"""
widget = factory()
ref = weakref.ref(widget)
gc.disable()
try:
widget.setParent(None) # type: ignore
del widget
assert ref() is None, "Widget was not freed by reference count on release"
finally:
gc.enable()
class SimpleDialog(QDialog):
"""Dialog for showing a single widget in tests."""
def __init__(self, widget: QWidget | None = None) -> None:
super().__init__()
self._widget = widget
layout = QVBoxLayout()
layout.setContentsMargins(40, 40, 40, 40)
self.setLayout(layout)
if widget:
layout.addWidget(widget)
@property
def widget(self) -> QWidget | None:
return self._widget
def addWidget(self, widget: QWidget) -> None:
"""Add a widget to the dialog."""
self._widget = widget
layout = self.layout()
assert layout is not None
layout.addWidget(widget)