Update tests

This commit is contained in:
Veronica Berglyd Olsen
2026-11-03 19:28:22 +01:00
parent 3355b5edbe
commit 558ac0ae0c
3 changed files with 505 additions and 587 deletions

View File

@@ -61,7 +61,7 @@ from novelwriter.dialogs.editlink import GuiEditLink
from novelwriter.editor.editor import GuiDocEditor, _TagAction
from novelwriter.editor.editsearch import GuiDocEditSearch
from novelwriter.editor.textblock import TextBlockData, formatCheckText
from novelwriter.enum import nwComment, nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwState, nwVimMode
from novelwriter.enum import nwComment, nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwState
from novelwriter.guimain import GuiMain
from novelwriter.shared import _GuiAlert
from novelwriter.text.counting import standardCounter
@@ -71,7 +71,6 @@ from novelwriter.types import (
QtImCurrentSelection,
QtImCursorRectangle,
QtKeepAnchor,
QtKeyEscape,
QtKeyReturn,
QtModCtrl,
QtModNone,
@@ -4463,586 +4462,6 @@ def testGuiDocEditor_BigFixes(qtbot: QtBot, nwGUI: GuiMain):
assert isinstance(cV, QVariant)
@pytest.mark.gui
def testGuiDocEditor_Vim_EnableVimMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that enabling CONFIG.vimMode activates vim behavior."""
inputDelay = 2
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
CONFIG.vimMode = True
def resetText():
"""Reset test text."""
docEditor.setPlainText("Line1\nLine2\nLine3")
return docEditor.getText()
original_text = resetText()
# Normal mode: hjkl should NOT change text
for key in "hjkl":
qtbot.keyClick(docEditor, key, delay=inputDelay)
assert docEditor.getText() == original_text
# Enter insert mode with "i"
qtbot.keyClick(docEditor, "i", delay=inputDelay)
qtbot.keyClicks(docEditor, "TEST", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape)
# Text must have changed now
new_text = docEditor.getText()
assert new_text != original_text
assert "TEST" in new_text
@pytest.mark.gui
def testGuiDocEditor_Vim_StateInsertModeNoOp(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that the vim state machine ignores command keys while in
INSERT mode, which is a state neither the NORMAL nor VISUAL mode
key handlers ever pass through.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
vim = docEditor._vim
vim.setMode(nwVimMode.INSERT)
vim.pushCommandKey("d")
assert vim.command == ""
vim.setCommand("d")
assert vim.command == ""
@pytest.mark.gui
def testGuiDocEditor_Vim_InsertMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test vim hjkl movements and insert commands (i, I, A)."""
inputDelay = 2
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
CONFIG.vimMode = True
def resetText():
"""Reset test text."""
docEditor.setPlainText("Line1\nLine2\nLine3")
return docEditor.getText()
# Insert Mode
# ===========
# i: Insert before cursor
resetText()
qtbot.keyClick(docEditor, "i", delay=inputDelay)
qtbot.keyClicks(docEditor, "X", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape)
assert "X" in docEditor.getText()
# I: Insert at beginning of line
resetText()
qtbot.keyClick(docEditor, QtKeyEscape)
qtbot.keyClick(docEditor, "h", delay=inputDelay) # Move forward 1
qtbot.keyClick(docEditor, "h", delay=inputDelay) # Move forward 1
qtbot.keyClick(docEditor, "I", delay=inputDelay)
qtbot.keyClicks(docEditor, "START", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape)
lines = docEditor.getText().splitlines()
assert lines[0].startswith("START")
# A: Append at end of line
resetText()
qtbot.keyClick(docEditor, "A", delay=inputDelay)
qtbot.keyClicks(docEditor, "END", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape)
lines = docEditor.getText().splitlines()
assert lines[0].endswith("END")
# o: Open new line below
resetText()
qtbot.keyClick(docEditor, "o", delay=inputDelay)
qtbot.keyClicks(docEditor, "below", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape)
lines = docEditor.getText().splitlines()
assert "below" in lines[1] # Inserted after Line1
# O: Open new line above
resetText()
qtbot.keyClick(docEditor, "O", delay=inputDelay)
qtbot.keyClicks(docEditor, "above", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape)
lines = docEditor.getText().splitlines()
assert "above" in lines[0] # Inserted before Line1
# INSERT MODE MOTIONS
# ===================
# i: Insert before cursor
resetText()
qtbot.keyClick(docEditor, "i", delay=inputDelay)
qtbot.keyClicks(docEditor, "TEST", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
assert "TEST" in docEditor.getText()
# aX: Append after cursor
resetText()
qtbot.keyClick(docEditor, "a", delay=inputDelay)
qtbot.keyClicks(docEditor, "X", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
assert "X" in docEditor.getText()
# New line below
resetText()
qtbot.keyClick(docEditor, "o", delay=inputDelay)
qtbot.keyClicks(docEditor, "below", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
lines = docEditor.getText().splitlines()
assert "below" in lines[1]
# New line above
resetText()
qtbot.keyClick(docEditor, "O", delay=inputDelay)
qtbot.keyClicks(docEditor, "above", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
lines = docEditor.getText().splitlines()
assert "above" in lines[0]
@pytest.mark.gui
def testGuiDocEditor_Vim_DeleteYankPaste(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test vim delete (dd, x), yank (yy) and paste (p, P) commands."""
inputDelay = 2
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
CONFIG.vimMode = True
def resetText():
"""Reset test text."""
docEditor.setPlainText("Line1\nLine2\nLine3")
return docEditor.getText()
# p/P: Pasting with nothing yanked yet does nothing
resetText()
assert docEditor._vim.pasteFromInternal() == ""
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "p", delay=inputDelay)
assert docEditor.getText() == "Line1\nLine2\nLine3"
qtbot.keyClicks(docEditor, "P", delay=inputDelay)
assert docEditor.getText() == "Line1\nLine2\nLine3"
# dd: Delete entire line
resetText()
docEditor.setCursorPosition(docEditor.getText().find("Line2"))
qtbot.keyClicks(docEditor, "dd", delay=inputDelay)
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines == ["Line1", "Line3"]
# x: Delete single character
resetText()
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "x", delay=inputDelay)
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines[0] == "ine1" # 'L' deleted
# p: Paste after current line
resetText()
line2_pos = docEditor.getText().find("Line2")
docEditor.setCursorPosition(line2_pos)
qtbot.keyClicks(docEditor, "yy", delay=inputDelay) # Yank Line2
qtbot.keyClicks(docEditor, "p", delay=inputDelay) # Paste after Line2
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines == ["Line1", "Line2", "Line2", "Line3"]
# P: Paste before current line (using Line3)
resetText()
line2_pos = docEditor.getText().find("Line2")
docEditor.setCursorPosition(line2_pos)
line3_pos = docEditor.getText().find("Line3")
docEditor.setCursorPosition(line3_pos)
qtbot.keyClicks(docEditor, "yy", delay=inputDelay) # Yank Line3
docEditor.setCursorPosition(line2_pos)
qtbot.keyClicks(docEditor, "P", delay=inputDelay) # Paste before Line2
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines == ["Line1", "Line3", "Line2", "Line3"]
# dw: Delete word (from Line1 up to next word boundary)
resetText()
docEditor.setCursorPosition(0) # Start of Line1
qtbot.keyClicks(docEditor, "dw", delay=inputDelay)
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines == ["Line2", "Line3"]
# d$: Delete line
resetText()
docEditor.setCursorPosition(0) # Start of Line1
qtbot.keyClicks(docEditor, "d$", delay=inputDelay)
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines == ["Line2", "Line3"]
# de: Delete word (from Line1 up to end of word boundary)
resetText()
line2_pos = docEditor.getText().find("Line2")
docEditor.setCursorPosition(line2_pos)
qtbot.keyClicks(docEditor, "de", delay=inputDelay)
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines == ["Line1", "Line3"]
# de: Delete word already at end of word
resetText()
line2_pos = docEditor.getText().find("Line2")
end_pos = docEditor.getText().find("Line2") + len("Line2")
docEditor.setCursorPosition(end_pos)
qtbot.keyClicks(docEditor, "de", delay=inputDelay)
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines == ["Line1", "Line2"]
# de: Already at the very end of the document, nowhere further to delete
docEditor.setPlainText("Line1")
end_pos = len(docEditor.getText())
docEditor.setCursorPosition(end_pos)
qtbot.keyClicks(docEditor, "de", delay=inputDelay)
assert docEditor.getText() == "Line1"
# db: Delete word back (from Line2 up to end of word boundary)
resetText()
docEditor.setCursorPosition(0) # Start of Line1
qtbot.keyClicks(docEditor, "w", delay=inputDelay) # End of line1
qtbot.keyClicks(docEditor, "db", delay=inputDelay)
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines == ["Line2", "Line3"]
# yw: Yank word (yank Line1)
resetText()
docEditor.setCursorPosition(0) # Start of Line1
qtbot.keyClicks(docEditor, "yw", delay=inputDelay)
# Move to start of Line2 and paste
docEditor.setCursorPosition(docEditor.getText().find("Line2"))
qtbot.keyClicks(docEditor, "p", delay=inputDelay)
lines = list(filter(str.strip, docEditor.getText().splitlines()))
assert lines[0] == "Line1"
assert lines[1] == "Line2"
assert lines[2] == "Line1"
assert lines[3] == "Line3"
@pytest.mark.gui
def testGuiDocEditor_Vim_VisualMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test vim visual mode selection, yank and paste."""
inputDelay = 2
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
CONFIG.vimMode = True
def resetText():
"""Reset test text."""
docEditor.setPlainText("Line1\nLine2\nLine3")
return docEditor.getText()
original_text = resetText()
# Visual mode with yy
resetText()
docEditor.setCursorPosition(docEditor.getText().find("Line3") + 1) # Inside Line3
qtbot.keyClick(docEditor, "v", delay=inputDelay)
qtbot.keyClick(docEditor, "j", delay=inputDelay)
qtbot.keyClick(docEditor, "h", delay=inputDelay)
qtbot.keyClick(docEditor, "k", delay=inputDelay)
qtbot.keyClick(docEditor, "l", delay=inputDelay)
qtbot.keyClicks(docEditor, "y", delay=inputDelay)
qtbot.keyClick(docEditor, "p", delay=inputDelay)
text = docEditor.getText()
assert "Line3" in text or text.endswith("n")
# Line-wise visual mode (V) with yank and paste
resetText()
line2_pos = docEditor.getText().find("Line2")
docEditor.setCursorPosition(line2_pos)
qtbot.keyClick(docEditor, "V", delay=inputDelay) # Line-wise visual mode
qtbot.keyClick(docEditor, "j", delay=inputDelay)
qtbot.keyClick(docEditor, "h", delay=inputDelay)
qtbot.keyClick(docEditor, "k", delay=inputDelay)
qtbot.keyClick(docEditor, "l", delay=inputDelay)
qtbot.keyClick(docEditor, "y", delay=inputDelay) # Yank Line2
qtbot.keyClick(docEditor, "j", delay=inputDelay) # Move to Line3
qtbot.keyClick(docEditor, "p", delay=inputDelay) # Paste Line2 after Line3
lines = docEditor.getText().splitlines()
assert lines == ["Line1", "Line2", "Line3", "Line2"]
# Visual mode with w motion
resetText()
docEditor.setCursorPosition(0) # start of Line1
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
qtbot.keyClicks(docEditor, "w", delay=inputDelay) # Move by word
cursor_pos = docEditor.textCursor().position()
expected_pos = docEditor.getText().find("Line1") + len("Line1")
assert cursor_pos == expected_pos
# 0: Move to start of line
resetText()
line2_pos = docEditor.getText().find("Line2") + 3 # Middle of Line2
docEditor.setCursorPosition(line2_pos)
qtbot.keyClicks(docEditor, "0", delay=inputDelay)
cursor_pos = docEditor.textCursor().position()
# Start of Line2
expected_pos = docEditor.getText().find("Line2")
assert cursor_pos == expected_pos
# No-op command 'a': nothing happens
resetText()
line3_pos = docEditor.getText().find("Line3") + 2
docEditor.setCursorPosition(line3_pos)
text_before = docEditor.getText()
cursor_before = docEditor.textCursor().position()
qtbot.keyClicks(docEditor, "a", delay=inputDelay)
text_after = docEditor.getText()
cursor_after = docEditor.textCursor().position()
# Nothing should have changed
assert text_after == text_before
assert cursor_after == cursor_before
resetText()
start_pos = docEditor.getText().find("Line2") + 2 # Inside "Line2"
docEditor.setCursorPosition(start_pos)
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
qtbot.keyClicks(docEditor, "b", delay=inputDelay)
selected = docEditor.textCursor().selectedText()
# Should have selected from inside "Line2" back to its start
assert selected == "Li"
resetText()
start_pos = docEditor.getText().find("Line1") # Start of "Line1"
docEditor.setCursorPosition(start_pos)
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
selected = docEditor.textCursor().selectedText()
# Should have selected "Line1"
assert selected == "Line1"
docEditor.setPlainText("Line1 lineExtra Line2 Line3")
end_pos = docEditor.getText().find("Line1") + len("Line1") - 1
docEditor.setCursorPosition(end_pos)
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
selected = docEditor.textCursor().selectedText().strip()
# Should have selected whitespace + "lineExtra"
assert "lineExtra" in selected
# At the very end of the document, there's nowhere further to extend to
docEditor.setPlainText("Line1")
end_pos = len(docEditor.getText())
docEditor.setCursorPosition(end_pos)
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
assert docEditor.textCursor().position() == end_pos
resetText()
# Visual select all with ggVG
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
qtbot.keyClicks(docEditor, "g", delay=inputDelay) # Go to start
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
qtbot.keyClick(docEditor, "G", delay=inputDelay) # Extend to end of file
# Delete selection
qtbot.keyClick(docEditor, "d", delay=inputDelay)
assert docEditor.getText().strip() == "" # Everything deleted
# Undo
qtbot.keyClick(docEditor, "u", delay=inputDelay)
restored_text = docEditor.getText()
assert restored_text == original_text
# Move back to start
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
cursor_pos = docEditor.textCursor().position()
assert restored_text[cursor_pos] == "L" # First char restored
# Visual select all with Gvgg
qtbot.keyClick(docEditor, "G", delay=inputDelay) # End of file
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
qtbot.keyClicks(docEditor, "g", delay=inputDelay) # Extend select to start
# Delete selection
qtbot.keyClick(docEditor, "d", delay=inputDelay)
assert docEditor.getText().strip() == "" # Everything deleted
# Undo
qtbot.keyClick(docEditor, "u", delay=inputDelay)
restored_text = docEditor.getText()
assert restored_text == original_text
# Move back to start
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
cursor_pos = docEditor.textCursor().position()
assert restored_text[cursor_pos] == "L" # First char restored
# VISUAL MODE MOTIONS
resetText()
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual
qtbot.keyClick(docEditor, "l", delay=inputDelay) # Extend selection
qtbot.keyClick(docEditor, "y", delay=inputDelay) # Yank selection
qtbot.keyClick(docEditor, "p", delay=inputDelay) # Paste it
assert "Li" in docEditor.getText()
resetText()
qtbot.keyClick(docEditor, "v", delay=inputDelay)
qtbot.keyClick(docEditor, "$", delay=inputDelay) # Extend to EOL
qtbot.keyClick(docEditor, "d", delay=inputDelay) # Delete
line0 = docEditor.getText().splitlines()[0]
assert line0.strip() == "" # first line emptied
# VISUAL LINE MODE MOTIONS
resetText()
line2_pos = docEditor.getText().find("Line2")
docEditor.setCursorPosition(line2_pos)
qtbot.keyClick(docEditor, "V", delay=inputDelay) # Vline mode
qtbot.keyClick(docEditor, "j", delay=inputDelay) # Extend
qtbot.keyClick(docEditor, "y", delay=inputDelay) # Yank
qtbot.keyClick(docEditor, "p", delay=inputDelay) # Paste
lines = docEditor.getText().splitlines()
assert lines.count("Line2") > 1
resetText()
qtbot.keyClick(docEditor, "V", delay=inputDelay) # Vline select
qtbot.keyClick(docEditor, "G", delay=inputDelay) # Extend to EOF
qtbot.keyClick(docEditor, "d", delay=inputDelay) # Delete all
assert docEditor.getText().strip() == ""
@pytest.mark.gui
def testGuiDocEditor_Vim_NormalMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test vim NORMAL mode commands."""
inputDelay = 2
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
CONFIG.vimMode = True
def resetText():
"""Reset test text."""
docEditor.setPlainText("Line1\nLine2\nLine3")
return docEditor.getText()
originalText = resetText()
# NORMAL MODE: hjkl should NOT modify text
for key in "hjkl":
qtbot.keyClick(docEditor, key)
assert docEditor.getText() == originalText
# b: Move to beginning of word
resetText()
start_pos = docEditor.getText().find("Line2") + 2 # Inside the word
docEditor.setCursorPosition(start_pos)
qtbot.keyClicks(docEditor, "b", delay=inputDelay)
cursorPos = docEditor.textCursor().position()
expectedPos = docEditor.getText().find("Line2") # Beginning of Line2
assert cursorPos == expectedPos
# e: Move to end of current word
resetText()
start_pos = docEditor.getText().find("Line1") # Start of Line1
docEditor.setCursorPosition(start_pos)
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
cursorPos = docEditor.textCursor().position()
expectedPos = docEditor.getText().find("Line1") + len("Line1")
assert cursorPos == expectedPos
# e: Move to end of next word when already at end
docEditor.setPlainText("Line1 lineExtra Line2 Line3")
end_pos = docEditor.getText().find("Line1") + len("Line1")
docEditor.setCursorPosition(end_pos)
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
cursorPos = docEditor.textCursor().position()
expectedPos = docEditor.getText().find("lineExtra") + len("lineExtra")
assert cursorPos == expectedPos
# e: At the very end of the document, there's nowhere further to move
docEditor.setPlainText("Line1")
end_pos = len(docEditor.getText())
docEditor.setCursorPosition(end_pos)
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
assert docEditor.textCursor().position() == end_pos
# $: Move to end of first line
docEditor.setCursorPosition(0) # Start of Line1
qtbot.keyClick(docEditor, "$", delay=inputDelay)
cursorPos = docEditor.textCursor().position()
text = docEditor.getText().splitlines()[0]
assert cursorPos == len(text) # Cursor at end of first line
# a: Move right and enter insert mode
docEditor.setCursorPosition(0) # Start of Line1 again
qtbot.keyClick(docEditor, "a", delay=inputDelay)
# Cursor should have moved one character right
cursorPos = docEditor.textCursor().position()
assert cursorPos == 1
# Insert some text
qtbot.keyClicks(docEditor, "TEST", delay=inputDelay)
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
newText = docEditor.getText()
assert newText.startswith(("LTESSTine1", "LTESTine1"))
# w: Move forward by word
resetText()
docEditor.setCursorPosition(0) # Start of "Line1"
qtbot.keyClicks(docEditor, "w", delay=inputDelay)
cursorPos = docEditor.textCursor().position()
# Cursor should now be at end of "Line1"
expectedPos = docEditor.getText().find("Line1") + len("Line1")
assert cursorPos == expectedPos
# NORMAL MODE MOTIONS
resetText()
qtbot.keyClick(docEditor, "l", delay=inputDelay) # Move right
assert docEditor.textCursor().position() == 1
resetText()
qtbot.keyClick(docEditor, "0", delay=inputDelay) # Beginning of line
assert docEditor.textCursor().position() == 0
resetText()
docEditor.setCursorPosition(0) # Start of Line1
qtbot.keyClick(docEditor, "h", delay=inputDelay)
# Move otherwise "$" is somehow not captured always
qtbot.keyClick(docEditor, "l", delay=inputDelay)
qtbot.keyClick(docEditor, "$", delay=inputDelay) # End of line
cursorPos = docEditor.textCursor().position()
assert cursorPos == len("Line1")
resetText()
qtbot.keyClick(docEditor, "g", delay=inputDelay)
qtbot.keyClick(docEditor, "g", delay=inputDelay) # Top of buffer
assert docEditor.textCursor().position() == 0
qtbot.keyClick(docEditor, "z", delay=inputDelay)
qtbot.keyClick(docEditor, "z", delay=inputDelay) # Center view
resetText()
qtbot.keyClick(docEditor, "G", delay=inputDelay) # Bottom of buffer
cursorPos = docEditor.textCursor().position()
assert cursorPos == len(docEditor.getText())
@pytest.mark.gui
def testGuiDocEditor_BigDocLifecycle(
qtbot: QtBot,

496
tests/editor/test_vim.py Normal file
View File

@@ -0,0 +1,496 @@
"""
novelWriter - Editor Vim Mode Tests
===================================
This file is a part of novelWriter
Copyright (C) 2026 Veronica Berglyd Olsen and novelWriter 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
from pathlib import Path
import pytest
from pytestqt.qtbot import QtBot
from novelwriter import CONFIG
from novelwriter.core.project import NWProject
from novelwriter.editor.editor import GuiDocEditor
from novelwriter.editor.vim import VimState
from novelwriter.enum import nwVimMode
from novelwriter.guimain import GuiMain
from novelwriter.types import QtKeyEscape
from tests.helpers import C, MockRnd, buildTestProject
KEY_DELAY = 2
def openVimEditor(nwGUI: GuiMain, projPath: Path) -> GuiDocEditor:
"""Open the scene document in the editor with vim mode enabled."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
CONFIG.vimMode = True
nwGUI.docEditor.initSettings(updateVimMode=True)
return nwGUI.docEditor
def resetText(docEditor: GuiDocEditor) -> str:
"""Reset the editor to the standard test text."""
docEditor.setPlainText("Line1\nLine2\nLine3")
return docEditor.getText()
def testVimState_InsertModeNoOp():
"""Test that the vim state ignores command keys in INSERT mode."""
vim = VimState()
vim.setMode(nwVimMode.INSERT)
vim.pushCommandKey("d")
assert vim.command == ""
vim.setCommand("d")
assert vim.command == ""
@pytest.mark.gui
def testEditorVimMode_Init(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test enabling, refreshing and disabling vim mode in the editor."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
docFooter = docEditor.docFooter
# Disabled by default
assert docEditor._vimMode is None
assert docFooter.vimStatus.isHidden() is True
# Enable from settings
CONFIG.vimMode = True
docEditor.initSettings(updateVimMode=True)
vimMode = docEditor._vimMode
assert vimMode is not None
assert vimMode._vim.mode == nwVimMode.NORMAL
assert docFooter._vimMode == nwVimMode.NORMAL
assert docFooter.vimStatus.isHidden() is False
# Mode changes are reported to the footer
qtbot.keyClick(docEditor, "i", delay=KEY_DELAY)
assert vimMode._vim.mode == nwVimMode.INSERT
assert docFooter._vimMode == nwVimMode.INSERT
# Escape returns to NORMAL mode
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
assert vimMode._vim.mode == nwVimMode.NORMAL
assert docFooter._vimMode == nwVimMode.NORMAL
# Unrelated settings changes don't reset the mode
vimMode.setVimMode(nwVimMode.INSERT)
docEditor.initSettings()
assert vimMode._vim.mode == nwVimMode.INSERT
# Re-initialising the editor keeps the instance, but resets the mode
vimMode._vim.yankToInternal("Text")
docEditor.initEditor()
assert docEditor._vimMode is vimMode
assert vimMode._vim.mode == nwVimMode.NORMAL
assert vimMode._vim.pasteFromInternal() == "Text"
# Loading a document resets the mode
vimMode.setVimMode(nwVimMode.INSERT)
assert nwGUI.docPanes.openDocument(C.hChapterDoc)
assert vimMode._vim.mode == nwVimMode.NORMAL
# Disable from settings
CONFIG.vimMode = False
docEditor.initSettings(updateVimMode=True)
assert docEditor._vimMode is None
assert docFooter._vimMode is None
assert docFooter.vimStatus.isHidden() is True
@pytest.mark.gui
def testEditorVimMode_InsertMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test vim commands entering INSERT mode."""
docEditor = openVimEditor(nwGUI, projPath)
# NORMAL mode doesn't insert text
originalText = resetText(docEditor)
for key in "hjkl":
qtbot.keyClick(docEditor, key, delay=KEY_DELAY)
assert docEditor.getText() == originalText
# i: Insert before cursor
resetText(docEditor)
qtbot.keyClick(docEditor, "i", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "X", delay=KEY_DELAY)
qtbot.keyClick(docEditor, QtKeyEscape)
assert "X" in docEditor.getText()
# I: Insert at beginning of line
resetText(docEditor)
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "I", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "START", delay=KEY_DELAY)
qtbot.keyClick(docEditor, QtKeyEscape)
assert docEditor.getText().splitlines()[0].startswith("START")
# a: Append after cursor
resetText(docEditor)
qtbot.keyClick(docEditor, "a", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "X", delay=KEY_DELAY)
qtbot.keyClick(docEditor, QtKeyEscape)
assert "X" in docEditor.getText()
# A: Append at end of line
resetText(docEditor)
qtbot.keyClick(docEditor, "A", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "END", delay=KEY_DELAY)
qtbot.keyClick(docEditor, QtKeyEscape)
assert docEditor.getText().splitlines()[0].endswith("END")
# o: Open new line below
resetText(docEditor)
qtbot.keyClick(docEditor, "o", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "below", delay=KEY_DELAY)
qtbot.keyClick(docEditor, QtKeyEscape)
assert "below" in docEditor.getText().splitlines()[1]
# O: Open new line above
resetText(docEditor)
qtbot.keyClick(docEditor, "O", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "above", delay=KEY_DELAY)
qtbot.keyClick(docEditor, QtKeyEscape)
assert "above" in docEditor.getText().splitlines()[0]
@pytest.mark.gui
def testEditorVimMode_NormalMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test vim NORMAL mode motions."""
docEditor = openVimEditor(nwGUI, projPath)
# b: Move to beginning of word
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line2") + 2)
qtbot.keyClicks(docEditor, "b", delay=KEY_DELAY)
assert docEditor.textCursor().position() == text.find("Line2")
# e: Move to end of current word
text = resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
assert docEditor.textCursor().position() == len("Line1")
# e: Move to end of next word when already at end
docEditor.setPlainText("Line1 lineExtra Line2 Line3")
text = docEditor.getText()
docEditor.setCursorPosition(len("Line1"))
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
assert docEditor.textCursor().position() == text.find("lineExtra") + len("lineExtra")
# e: At the end of the document, there's nowhere further to move
docEditor.setPlainText("Line1")
docEditor.setCursorPosition(5)
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
assert docEditor.textCursor().position() == 5
# $: Move to end of line
docEditor.setCursorPosition(0)
qtbot.keyClick(docEditor, "$", delay=KEY_DELAY)
assert docEditor.textCursor().position() == len("Line1")
# a: Move right and enter INSERT mode
docEditor.setCursorPosition(0)
qtbot.keyClick(docEditor, "a", delay=KEY_DELAY)
assert docEditor.textCursor().position() == 1
qtbot.keyClicks(docEditor, "TEST", delay=KEY_DELAY)
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
assert docEditor.getText().startswith(("LTESSTine1", "LTESTine1"))
# w: Move forward by word
resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "w", delay=KEY_DELAY)
assert docEditor.textCursor().position() == len("Line1")
# l: Move right
resetText(docEditor)
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY)
assert docEditor.textCursor().position() == 1
# 0: Move to beginning of line
resetText(docEditor)
qtbot.keyClick(docEditor, "0", delay=KEY_DELAY)
assert docEditor.textCursor().position() == 0
# $: Move to end of line after a motion
resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY) # Otherwise "$" is not always captured
qtbot.keyClick(docEditor, "$", delay=KEY_DELAY)
assert docEditor.textCursor().position() == len("Line1")
# gg: Move to start of document
resetText(docEditor)
qtbot.keyClick(docEditor, "g", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "g", delay=KEY_DELAY)
assert docEditor.textCursor().position() == 0
# zz: Centre view
qtbot.keyClick(docEditor, "z", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "z", delay=KEY_DELAY)
# G: Move to end of document
text = resetText(docEditor)
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
assert docEditor.textCursor().position() == len(text)
@pytest.mark.gui
def testEditorVimMode_DeleteYankPaste(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test vim delete, yank and paste commands."""
docEditor = openVimEditor(nwGUI, projPath)
vimMode = docEditor._vimMode
assert vimMode is not None
def getLines() -> list[str]:
return list(filter(str.strip, docEditor.getText().splitlines()))
# p/P: Pasting with nothing yanked does nothing
resetText(docEditor)
assert vimMode._vim.pasteFromInternal() == ""
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "p", delay=KEY_DELAY)
assert docEditor.getText() == "Line1\nLine2\nLine3"
qtbot.keyClicks(docEditor, "P", delay=KEY_DELAY)
assert docEditor.getText() == "Line1\nLine2\nLine3"
# dd: Delete line
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line2"))
qtbot.keyClicks(docEditor, "dd", delay=KEY_DELAY)
assert getLines() == ["Line1", "Line3"]
# x: Delete character
resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "x", delay=KEY_DELAY)
assert getLines()[0] == "ine1"
# yy + p: Paste after current line
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line2"))
qtbot.keyClicks(docEditor, "yy", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "p", delay=KEY_DELAY)
assert getLines() == ["Line1", "Line2", "Line2", "Line3"]
# yy + P: Paste before current line
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line3"))
qtbot.keyClicks(docEditor, "yy", delay=KEY_DELAY)
docEditor.setCursorPosition(text.find("Line2"))
qtbot.keyClicks(docEditor, "P", delay=KEY_DELAY)
assert getLines() == ["Line1", "Line3", "Line2", "Line3"]
# dw: Delete word
resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "dw", delay=KEY_DELAY)
assert getLines() == ["Line2", "Line3"]
# d$: Delete to end of line
resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "d$", delay=KEY_DELAY)
assert getLines() == ["Line2", "Line3"]
# de: Delete to end of word
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line2"))
qtbot.keyClicks(docEditor, "de", delay=KEY_DELAY)
assert getLines() == ["Line1", "Line3"]
# de: Delete next word when already at end of word
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line2") + len("Line2"))
qtbot.keyClicks(docEditor, "de", delay=KEY_DELAY)
assert getLines() == ["Line1", "Line2"]
# de: At the end of the document, there's nothing to delete
docEditor.setPlainText("Line1")
docEditor.setCursorPosition(5)
qtbot.keyClicks(docEditor, "de", delay=KEY_DELAY)
assert docEditor.getText() == "Line1"
# db: Delete word backwards
resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "w", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "db", delay=KEY_DELAY)
assert getLines() == ["Line2", "Line3"]
# yw: Yank word
text = resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClicks(docEditor, "yw", delay=KEY_DELAY)
docEditor.setCursorPosition(text.find("Line2"))
qtbot.keyClicks(docEditor, "p", delay=KEY_DELAY)
assert getLines() == ["Line1", "Line2", "Line1", "Line3"]
@pytest.mark.gui
def testEditorVimMode_VisualMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test vim VISUAL and V-LINE mode selection, yank and paste."""
docEditor = openVimEditor(nwGUI, projPath)
originalText = resetText(docEditor)
# v: Select, yank and paste
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line3") + 1)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "j", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "k", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "y", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "p", delay=KEY_DELAY)
text = docEditor.getText()
assert "Line3" in text or text.endswith("n")
# V: Select line, yank and paste
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line2"))
qtbot.keyClick(docEditor, "V", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "j", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "k", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "y", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "j", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "p", delay=KEY_DELAY)
assert docEditor.getText().splitlines() == ["Line1", "Line2", "Line3", "Line2"]
# w: Extend selection by word
resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "w", delay=KEY_DELAY)
assert docEditor.textCursor().position() == len("Line1")
# 0: Extend selection to start of line
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line2") + 3)
qtbot.keyClicks(docEditor, "0", delay=KEY_DELAY)
assert docEditor.textCursor().position() == text.find("Line2")
# a: Not a visual command, so nothing happens
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line3") + 2)
position = docEditor.textCursor().position()
qtbot.keyClicks(docEditor, "a", delay=KEY_DELAY)
assert docEditor.getText() == text
assert docEditor.textCursor().position() == position
# b: Extend selection to start of word
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line2") + 2)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "b", delay=KEY_DELAY)
assert docEditor.textCursor().selectedText() == "Li"
# e: Extend selection to end of word
resetText(docEditor)
docEditor.setCursorPosition(0)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
assert docEditor.textCursor().selectedText() == "Line1"
# e: Extend selection to end of next word
docEditor.setPlainText("Line1 lineExtra Line2 Line3")
docEditor.setCursorPosition(len("Line1") - 1)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
assert "lineExtra" in docEditor.textCursor().selectedText()
# e: At the end of the document, there's nowhere further to extend
docEditor.setPlainText("Line1")
docEditor.setCursorPosition(5)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
assert docEditor.textCursor().position() == 5
# ggvG: Select all, delete and undo
resetText(docEditor)
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
assert docEditor.getText().strip() == ""
qtbot.keyClick(docEditor, "u", delay=KEY_DELAY)
assert docEditor.getText() == originalText
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
assert docEditor.textCursor().position() == 0
# Gvgg: Select all backwards, delete and undo
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
assert docEditor.getText().strip() == ""
qtbot.keyClick(docEditor, "u", delay=KEY_DELAY)
assert docEditor.getText() == originalText
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
assert docEditor.textCursor().position() == 0
# vly: Yank selection and paste
resetText(docEditor)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "y", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "p", delay=KEY_DELAY)
assert "Li" in docEditor.getText()
# v$d: Delete to end of line
resetText(docEditor)
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "$", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
assert docEditor.getText().splitlines()[0].strip() == ""
# Vjy: Yank lines and paste
text = resetText(docEditor)
docEditor.setCursorPosition(text.find("Line2"))
qtbot.keyClick(docEditor, "V", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "j", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "y", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "p", delay=KEY_DELAY)
assert docEditor.getText().splitlines().count("Line2") > 1
# VGd: Delete all lines
resetText(docEditor)
qtbot.keyClick(docEditor, "V", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
assert docEditor.getText().strip() == ""

View File

@@ -455,7 +455,10 @@ def testGuiMain_ProcessConfigChanges_LightweightSettings(
docViewer = nwGUI.docViewer
CONFIG.vimMode = True
docEditor._vim.setMode(nwVimMode.INSERT)
docEditor.initSettings(updateVimMode=True)
vimMode = docEditor._vimMode
assert vimMode is not None
vimMode._vim.setMode(nwVimMode.INSERT)
# Clear the headers so we can tell if they were refreshed
docEditor.docHeader._docHandle = None
@@ -477,7 +480,7 @@ def testGuiMain_ProcessConfigChanges_LightweightSettings(
nwGUI._processConfigChanges(noChange)
assert docEditor.docHeader._docHandle == C.hSceneDoc
assert docViewer.docHeader._docHandle == C.hSceneDoc
assert docEditor._vim.mode == nwVimMode.INSERT
assert vimMode._vim.mode == nwVimMode.INSERT
# A save where the Vim setting changed must reset the mode to Normal
vimChanged = GuiNeedsUpdate(
@@ -492,11 +495,11 @@ def testGuiMain_ProcessConfigChanges_LightweightSettings(
viewport=False,
)
nwGUI._processConfigChanges(vimChanged)
assert docEditor._vim.mode == nwVimMode.NORMAL
assert vimMode._vim.mode == nwVimMode.NORMAL
# A save that fully reinitialises the editor and viewer must not
# additionally run the lightweight path
docEditor._vim.setMode(nwVimMode.INSERT)
vimMode._vim.setMode(nwVimMode.INSERT)
fullReinit = GuiNeedsUpdate(
restart=False,
tree=False,
@@ -509,7 +512,7 @@ def testGuiMain_ProcessConfigChanges_LightweightSettings(
viewport=False,
)
nwGUI._processConfigChanges(fullReinit)
assert docEditor._vim.mode == nwVimMode.NORMAL
assert vimMode._vim.mode == nwVimMode.NORMAL
# A viewport-only change must reinitialise the viewport on both the
# editor and viewer even though neither is otherwise reinitialised