diff --git a/tests/editor_undo/CMakeLists.txt b/tests/editor_undo/CMakeLists.txt new file mode 100644 index 00000000..efec5fb2 --- /dev/null +++ b/tests/editor_undo/CMakeLists.txt @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +# SPDX-License-Identifier: GPL-3.0-or-later +# +# 模块 editor_undo(批次 B5):src/editor 撤销/重做命令类(QUndoCommand 子类)+ +# UndoList 组合命令。共 14 个类,逐类独立可执行文件,共享一个静态核心库 +#(被测 9 个源文件 + 链接接缝 editor_undo_seam.cpp)。 +# +# 链接接缝说明(详见 editor_undo_seam.h):TextEdit / EditWrapper / Window / +# BottomBar 的真实实现不参与链接;本目录所有目标一律不开 AUTOMOC +#(Q_OBJECT 类不实例化元对象特性,空壳定义由 editor_undo_seam.cpp 提供), +# 因此此处显式 set(CMAKE_AUTOMOC OFF) 防御上游目录属性渗透。 + +set(CMAKE_AUTOMOC OFF) + +set(EDITOR_DIR "${CMAKE_SOURCE_DIR}/src/editor") +set(STUB_SHADOW "${CMAKE_SOURCE_DIR}/tests/3rdparty/stub/stub-shadow.cpp") + +# ---- 静态核心库:被测源码 + 链接接缝(含传递头文件闭包的 include 目录) ---- +add_library(editor_undo_core STATIC + ${EDITOR_DIR}/inserttextundocommand.cpp + ${EDITOR_DIR}/deletebackcommond.cpp + ${EDITOR_DIR}/deletetextundocommand.cpp + ${EDITOR_DIR}/indenttextcommond.cpp + ${EDITOR_DIR}/insertblockbytextcommond.cpp + ${EDITOR_DIR}/changemarkcommand.cpp + ${EDITOR_DIR}/endlineformatcommond.cpp + ${EDITOR_DIR}/replaceallcommond.cpp + ${EDITOR_DIR}/undolist.cpp + editor_undo_seam.cpp +) + +target_include_directories(editor_undo_core PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_SOURCE_DIR}/tests/3rdparty/stub" + "${CMAKE_SOURCE_DIR}/src" + "${CMAKE_SOURCE_DIR}/src/editor" + "${CMAKE_SOURCE_DIR}/src/widgets" + "${CMAKE_SOURCE_DIR}/src/common" + "${CMAKE_SOURCE_DIR}/src/editor/markdown" +) + +target_link_libraries(editor_undo_core PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets + Qt6::DBus + Qt6::PrintSupport + Qt6::Core5Compat + Dtk6::Core + Dtk6::Widget + KF6::SyntaxHighlighting +) + +# ---- 逐类测试可执行文件 ---- +function(eu_add_test name) + add_executable(test_${name} test_${name}.cpp ${STUB_SHADOW}) + target_include_directories(test_${name} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_SOURCE_DIR}/tests/3rdparty/stub" + ) + target_link_libraries(test_${name} PRIVATE + editor_undo_core + GTest::gtest + GTest::gtest_main + ) + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + target_link_libraries(test_${name} PRIVATE gcov) + endif() + gtest_discover_tests(test_${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + message(STATUS "UT: test_${name} configured") +endfunction() + +eu_add_test(inserttextundocommand) +eu_add_test(midbuttoninserttextundocommand) +eu_add_test(draginserttextundocommand) +eu_add_test(deletebackcommand) +eu_add_test(deletebackaltcommand) +eu_add_test(deletetextundocommand) +eu_add_test(deletetextundocommand2) +eu_add_test(indenttextcommand) +eu_add_test(unindenttextcommand) +eu_add_test(insertblockbytextcommand) +eu_add_test(changemarkcommand) +eu_add_test(endlineformartcommand) +eu_add_test(replaceallcommand) +eu_add_test(undolist) diff --git a/tests/editor_undo/editor_undo_helpers.h b/tests/editor_undo/editor_undo_helpers.h new file mode 100644 index 00000000..56a9f97a --- /dev/null +++ b/tests/editor_undo/editor_undo_helpers.h @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// editor_undo 批次(B5)测试公共设施: +// - ut::ensureApp():offscreen QApplication(QT_QPA_PLATFORM 由 CMake 测试属性与 +// 运行环境注入,本文件不读取/设置环境变量,无 qputenv/qunsetenv 配对问题) +// - ut::toLf():QTextCursor::selectedText() 以 U+2029 表示换行,比较前统一归一化 +// - ut::makeSelection():按起止绝对位置构造绑定文档的 ExtraSelection +// - RecordingCommand:undo/redo 顺序记录命令(UndoList / ChangeMarkCommand 子命令) +// - EditorUndoTestBase:接缝状态 + 空壳 TextEdit 的通用夹具基类 +// +// 夹具基类说明:各测试 Fixture 命名为 {ClassName}Test : public EditorUndoTestBase, +// 符合 test-types.md §3.4.3 派生夹具约定(共享 SetUp:重置并安装接缝、构造编辑器; +// 共享 TearDown:卸载接缝、销毁编辑器)。被测 QUndoCommand 子类无信号发射 +//(QUndoCommand 非 QObject),故不使用 QSignalSpy,副作用经接缝记录与文档状态断言。 + +#ifndef AUTOTESTS_EDITOR_UNDO_HELPERS_H +#define AUTOTESTS_EDITOR_UNDO_HELPERS_H + +#include + +#include +#include +#include +#include +#include +#include + +#include "editor_undo_seam.h" +#include "editwrapper.h" +#include "../widgets/bottombar.h" + +namespace ut { + +// 确保 offscreen QApplication 存在(Qt widget 类构造必需)。 +inline QApplication *ensureApp() +{ + if (!QApplication::instance()) { + static int argc = 1; + static char argv0[] = "test_editor_undo"; + static char *argv[] = { argv0, nullptr }; + static QApplication app(argc, argv); + return &app; + } + return qobject_cast(QApplication::instance()); +} + +// U+2029(段落分隔符)→ '\n',用于比较 QTextCursor::selectedText() 结果。 +inline QString toLf(const QString &s) +{ + QString r = s; + r.replace(QChar(0x2029), QChar('\n')); + return r; +} + +// 构造绑定 doc 的选区光标:anchor=startPos,position=endPos。 +inline QTextCursor cursorAt(QTextDocument *doc, int startPos, int endPos) +{ + QTextCursor cursor(doc); + cursor.setPosition(startPos); + cursor.setPosition(endPos, QTextCursor::KeepAnchor); + return cursor; +} + +inline QTextEdit::ExtraSelection makeSelection(QTextDocument *doc, int startPos, int endPos) +{ + QTextEdit::ExtraSelection selection; + selection.cursor = cursorAt(doc, startPos, endPos); + return selection; +} + +// 以普通 QWidget 内存伪装 BottomBar*/Window*/EditWrapper*:这三者的接缝成员均为 +// 非虚函数,直接调用记录桩,不触碰伪造对象的 vtable,安全可控。 +inline BottomBar *fakeBottomBar(QObject *host) +{ + return reinterpret_cast(host); +} + +} // namespace ut + +// 顺序记录命令:undo/redo 向夹具成员 orderLog 追加 "name:undo"/"name:redo"。 +// orderLog 指向夹具成员(非 static/全局),用例间无残留。 +class RecordingCommand : public QUndoCommand +{ +public: + explicit RecordingCommand(QStringList *orderLog, const QString &name, QUndoCommand *parent = nullptr) + : QUndoCommand(parent) + , m_orderLog(orderLog) + , m_name(name) + { + } + + void undo() override + { + if (m_orderLog) + *m_orderLog << (m_name + ":undo"); + } + + void redo() override + { + if (m_orderLog) + *m_orderLog << (m_name + ":redo"); + } + +private: + QStringList *m_orderLog = nullptr; + QString m_name; +}; + +// 通用夹具基类:接缝状态成员 + 空壳 TextEdit(行为等价 DPlainTextEdit)。 +class EditorUndoTestBase : public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + // 空壳 TextEdit 为 QWidget 派生,须 QApplication;offscreen 平台无真实窗口。 + ut::ensureApp(); + } + + void SetUp() override + { + seam = EditorUndoSeam(); // 全部计数器/记录归零 + ut_install_seam(&seam); + edit = new TextEdit(); + } + + void TearDown() override + { + ut_install_seam(nullptr); + delete edit; + edit = nullptr; + } + + QString docText() const { return edit->document()->toPlainText(); } + + EditorUndoSeam seam; + TextEdit *edit = nullptr; +}; + +#endif // AUTOTESTS_EDITOR_UNDO_HELPERS_H diff --git a/tests/editor_undo/editor_undo_seam.cpp b/tests/editor_undo/editor_undo_seam.cpp new file mode 100644 index 00000000..6cd4a3cf --- /dev/null +++ b/tests/editor_undo/editor_undo_seam.cpp @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// editor_undo 批次(B5)链接接缝实现:见 editor_undo_seam.h 头部说明。 +// 本文件提供 src/editor 被测撤销命令源文件引用的 TextEdit / EditWrapper / Window / +// BottomBar 成员符号的空壳/记录定义。真实实现(dtextedit.cpp、editwrapper.cpp、 +// window.cpp、bottombar.cpp)不参与本测试目标链接,因此无重复符号。 + +#include "editor_undo_seam.h" + +#include "editwrapper.h" +#include "FlashTween.h" +#include "uncommentselection.h" +#include "../widgets/bottombar.h" +#include "../widgets/window.h" + +namespace { +// 接缝状态指针:仅由 ut_install_seam 设置,指向夹具持有的 EditorUndoSeam 实例。 +// 未安装(nullptr)时所有记录桩为无操作,保证接缝函数独立安全。 +EditorUndoSeam *g_seam = nullptr; +} // namespace + +void ut_install_seam(EditorUndoSeam *seam) +{ + g_seam = seam; +} + +// ==================== TextEdit 值成员符号空壳 ==================== +// TextEdit 以值成员形式持有 FlashTween(Q_OBJECT,无自定义虚函数/信号)与 +// Comment::CommentDefinition,其构造/析构符号在真实实现 cpp 中;此处空壳提供, +// 使空壳 TextEdit 构造可链接(这些成员从不被使用,空行为安全)。 + +FlashTween::FlashTween() +{ +} + +FlashTween::~FlashTween() +{ +} + +const QMetaObject *FlashTween::metaObject() const +{ + return &QObject::staticMetaObject; +} + +void *FlashTween::qt_metacast(const char *cname) +{ + return QObject::qt_metacast(cname); +} + +int FlashTween::qt_metacall(QMetaObject::Call call, int id, void **args) +{ + return QObject::qt_metacall(call, id, args); +} + +Comment::CommentDefinition::CommentDefinition() +{ +} + +// ==================== TextEdit 空壳(行为等价于 DPlainTextEdit) ==================== +// 构造/析构 + 事件 override 委托基类,文档与光标操作走 Qt 真实路径; +// 被测命令专用的三个成员为可观测记录桩。Q_OBJECT 声明的三个虚函数同样给出 +// 委托定义(vtable 需要),metaObject 汇报基类元对象即可满足本批次全部用法。 + +TextEdit::TextEdit(QWidget *parent) + : DPlainTextEdit(parent) +{ +} + +TextEdit::~TextEdit() +{ + // 空壳:真实析构中的子控件清理不参与测试;成员中非平凡对象由编译器自动析构。 +} + +const QMetaObject *TextEdit::metaObject() const +{ + return &DPlainTextEdit::staticMetaObject; +} + +void *TextEdit::qt_metacast(const char *cname) +{ + return DPlainTextEdit::qt_metacast(cname); +} + +int TextEdit::qt_metacall(QMetaObject::Call call, int id, void **args) +{ + return DPlainTextEdit::qt_metacall(call, id, args); +} + +// staticMetaObject:Qt6 PMF 连接的 assertObjectType 会 ODR 使用该符号。 +// 空壳直接复制基类元对象(本批次不使用 TextEdit 自有元方法)。 +const QMetaObject TextEdit::staticMetaObject = DPlainTextEdit::staticMetaObject; + +// cursorPositionChanged 槽:insertblockbytextcommond.cpp treat() 以 PMF 形式 +// connect/disconnect 该成员(receiver 侧可为任意成员函数),空壳实现即可。 +void TextEdit::cursorPositionChanged() +{ +} + +bool TextEdit::event(QEvent *evt) +{ + return DPlainTextEdit::event(evt); +} + +void TextEdit::dragEnterEvent(QDragEnterEvent *event) +{ + DPlainTextEdit::dragEnterEvent(event); +} + +void TextEdit::dragMoveEvent(QDragMoveEvent *event) +{ + DPlainTextEdit::dragMoveEvent(event); +} + +void TextEdit::dropEvent(QDropEvent *event) +{ + DPlainTextEdit::dropEvent(event); +} + +void TextEdit::inputMethodEvent(QInputMethodEvent *e) +{ + DPlainTextEdit::inputMethodEvent(e); +} + +void TextEdit::mousePressEvent(QMouseEvent *e) +{ + DPlainTextEdit::mousePressEvent(e); +} + +void TextEdit::mouseMoveEvent(QMouseEvent *e) +{ + DPlainTextEdit::mouseMoveEvent(e); +} + +void TextEdit::mouseReleaseEvent(QMouseEvent *e) +{ + DPlainTextEdit::mouseReleaseEvent(e); +} + +void TextEdit::keyPressEvent(QKeyEvent *e) +{ + DPlainTextEdit::keyPressEvent(e); +} + +void TextEdit::wheelEvent(QWheelEvent *e) +{ + DPlainTextEdit::wheelEvent(e); +} + +bool TextEdit::eventFilter(QObject *object, QEvent *event) +{ + return DPlainTextEdit::eventFilter(object, event); +} + +void TextEdit::contextMenuEvent(QContextMenuEvent *event) +{ + DPlainTextEdit::contextMenuEvent(event); +} + +void TextEdit::paintEvent(QPaintEvent *e) +{ + DPlainTextEdit::paintEvent(e); +} + +void TextEdit::resizeEvent(QResizeEvent *e) +{ + DPlainTextEdit::resizeEvent(e); +} + +// 列编辑选区恢复:记录桩(被 InsertTextUndoCommand / DeleteBackAltCommand / +// DeleteTextUndoCommand 的列编辑撤销/重做路径调用)。 +void TextEdit::restoreColumnEditSelection(const QList &selections) +{ + if (g_seam) { + ++g_seam->restoreColumnEditSelectionCalls; + g_seam->lastRestoredSelections = selections; + } +} + +// MarkReplaceInfo -> (MarkOperation, time) 的确定性映射,供 ChangeMarkCommand 断言透传。 +QList> TextEdit::convertReplaceToMark( + const QList &replaceInfo) +{ + if (g_seam) + ++g_seam->convertReplaceToMarkCalls; + + QList> marks; + marks.reserve(replaceInfo.size()); + for (const TextEdit::MarkReplaceInfo &info : replaceInfo) + marks.append(qMakePair(info.opt, info.time)); + return marks; +} + +// 颜色标记整体刷新:记录桩(ChangeMarkCommand undo/redo 调用)。 +void TextEdit::manualUpdateAllMark(const QList> &markInfo) +{ + if (g_seam) { + ++g_seam->manualUpdateAllMarkCalls; + g_seam->lastManualMarks = markInfo; + } +} + +// ==================== EditWrapper / Window / BottomBar 记录桩 ==================== +// 测试中不构造这三者的实例:EditWrapper* / Window* / BottomBar* 以伪指针传入 +// (reinterpret_cast 自普通 QWidget),下列非虚成员被直接调用并记录。 + +Window *EditWrapper::window() +{ + return g_seam ? reinterpret_cast(g_seam->fakeWindowObject) : nullptr; +} + +BottomBar *EditWrapper::bottomBar() +{ + return g_seam ? reinterpret_cast(g_seam->fakeBottomBarObject) : nullptr; +} + +bool EditWrapper::isQuit() +{ + return g_seam ? g_seam->fakeIsQuit : false; +} + +void Window::setPrintEnabled(bool enabled) +{ + if (g_seam) { + ++g_seam->setPrintEnabledCalls; + g_seam->lastPrintEnabled = enabled; + } +} + +void BottomBar::setChildEnabled(bool enabled) +{ + if (g_seam) { + ++g_seam->setChildEnabledCalls; + g_seam->lastChildEnabled = enabled; + } +} + +void BottomBar::setEndlineMenuText(BottomBar::EndlineFormat format) +{ + if (g_seam) { + ++g_seam->setEndlineMenuTextCalls; + g_seam->lastEndlineFormat = static_cast(format); + } +} diff --git a/tests/editor_undo/editor_undo_seam.h b/tests/editor_undo/editor_undo_seam.h new file mode 100644 index 00000000..dd54ac9f --- /dev/null +++ b/tests/editor_undo/editor_undo_seam.h @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// editor_undo 批次(B5)链接接缝(link seam)状态头。 +// +// 被测的 9 个 QUndoCommand 源文件(src/editor/*command*.cpp、undolist.cpp)引用了 +// TextEdit / EditWrapper / Window / BottomBar 的少量成员函数。本测试模块不编译这些 +// 类的真实实现(dtextedit.cpp 9589 行 + editwrapper/window/bottombar 会级联拖入整个 +// 应用),而是在 editor_undo_seam.cpp 中给出“空壳”定义(链接接缝): +// - TextEdit:构造/析构 + 14 个事件 override 全部委托 DPlainTextEdit 基类实现, +// 使其行为等价于一个真实可用的 DPlainTextEdit(文档/光标操作全部走 Qt 真实路径); +// restoreColumnEditSelection / convertReplaceToMark / manualUpdateAllMark 为可观测 +// 记录桩(写入 EditorUndoSeam 状态)。 +// - EditWrapper / Window / BottomBar:测试中从不构造实例,仅以“伪指针”形式传入 +// (reinterpret_cast 自普通 QWidget),被调用的非虚成员 window()/bottomBar()/ +// isQuit()/setPrintEnabled()/setChildEnabled()/setEndlineMenuText() 为记录桩。 +// 本模块所有目标均不开 AUTOMOC(Q_OBJECT 声明只需编译通过,不实例化其元对象特性; +// TextEdit 的 12 个信号与 Q_OBJECT 声明的 metaObject/qt_metacast/qt_metacall 由 +// 接缝 TU 提供空壳定义)。 +// +// 状态归属约定(对应 test-types.md §7 / self_checker 5b“计数器为夹具成员”): +// EditorUndoSeam 实例由各测试夹具持有(fixture 成员),SetUp 中重置并经 +// ut_install_seam(&seam) 安装,TearDown 中 ut_install_seam(nullptr) 卸载; +// 文件内的 g_seam 指针仅是接缝定义到夹具成员的管道,不承载用例间可残留状态。 + +#ifndef AUTOTESTS_EDITOR_UNDO_SEAM_H +#define AUTOTESTS_EDITOR_UNDO_SEAM_H + +#include +#include +#include +#include + +#include "dtextedit.h" + +// 接缝观测状态:所有计数器/记录字段在夹具 SetUp() 中随实例重建自动归零。 +struct EditorUndoSeam { + // TextEdit::restoreColumnEditSelection + int restoreColumnEditSelectionCalls = 0; + QList lastRestoredSelections; + + // TextEdit::convertReplaceToMark(static)与 manualUpdateAllMark + int convertReplaceToMarkCalls = 0; + int manualUpdateAllMarkCalls = 0; + QList> lastManualMarks; + + // EditWrapper::window() / bottomBar() / isQuit() + void *fakeWindowObject = nullptr; // 实际为 Window*(避免头文件闭包扩散,用 void* 传递) + void *fakeBottomBarObject = nullptr; // 实际为 BottomBar* + bool fakeIsQuit = false; + + // Window::setPrintEnabled + int setPrintEnabledCalls = 0; + bool lastPrintEnabled = false; + + // BottomBar::setChildEnabled / setEndlineMenuText + int setChildEnabledCalls = 0; + bool lastChildEnabled = false; + int setEndlineMenuTextCalls = 0; + int lastEndlineFormat = 0; // BottomBar::EndlineFormat 的 int 值 +}; + +// 安装/卸载接缝状态;seam 为 nullptr 时所有接缝记录桩退化为无操作。 +void ut_install_seam(EditorUndoSeam *seam); + +#endif // AUTOTESTS_EDITOR_UNDO_SEAM_H diff --git a/tests/editor_undo/test_changemarkcommand.cpp b/tests/editor_undo/test_changemarkcommand.cpp new file mode 100644 index 00000000..170ac578 --- /dev/null +++ b/tests/editor_undo/test_changemarkcommand.cpp @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// ChangeMarkCommand 单元测试(src/editor/changemarkcommand.cpp)。 +// 颜色标记变更撤销项:undo/redo 先执行子命令(QUndoCommand 基类),再经 +// convertReplaceToMark + manualUpdateAllMark(均为接缝记录桩)刷新标记。 +// +// 分支清单: +// U1: undo —— QUndoCommand::undo()(子命令逆序);m_EditPtr 空 / 旧标记空 → 跳过; +// 否则 convert(m_oldMarkReplace) → manualUpdateAllMark +// R1: redo —— 同构,使用 m_newMarkReplace +// +// 用例映射: +// - Redo_NewMarks_ConvertedAndApplied → R1(正常) +// - Undo_OldMarks_ConvertedAndApplied → U1(正常)(含 redo 后回退) +// - Redo_NullEdit_SkipsMarkUpdate → R1/U1(m_EditPtr 空) +// - Redo_EmptyMarkLists_SkipsMarkUpdate → R1/U1(列表空) +// - Redo_WithChildCommand_ChildRunsBeforeMark → R1/U1 子命令先于标记刷新 +// - Redo_MarkOperationFields_PropagateThroughConvert → R1 字段透传(类型/颜色/匹配文本) +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor/dtor/undo/redo)✔ 2 输入等价类(空/单/多 +// 标记)✔ 3 边界(空列表/空指针/多元素)✔ 4 字段参数化并入透传用例 ✔ 5 分支映射 ✔ +// 6 全分支 ✔ 7 无异常路径 ✔ 8 负面(空指针/空列表)✔ 9 状态可逆(undo 后记录回旧值)✔ +// 10 无 gMock(TextEdit 不可注入虚接口,用接缝记录桩)✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/changemarkcommand.h" + +class ChangeMarkCommandTest : public EditorUndoTestBase +{ +protected: + static QList makeMarks(int count, const QString &color) + { + QList marks; + for (int i = 0; i < count; ++i) { + TextEdit::MarkReplaceInfo info; + info.start = i * 10; + info.end = i * 10 + 5; + info.time = 1000 + i; + info.opt.type = TextEdit::MarkOnce; + info.opt.color = color; + marks.append(info); + } + return marks; + } +}; + +// ---- R1 正常路径:新标记经 convert 后送 manualUpdateAllMark ---- +TEST_F(ChangeMarkCommandTest, Redo_NewMarks_ConvertedAndApplied) +{ + // Arrange + QList oldMark = makeMarks(1, "red"); + QList newMark = makeMarks(2, "blue"); + ChangeMarkCommand cmd(QPointer(edit), oldMark, newMark); + + // Act + cmd.redo(); + + // Assert:convert 恰好一次;manual 收到的列表与 newMark 元素一一对应 + EXPECT_EQ(seam.convertReplaceToMarkCalls, 1); + EXPECT_EQ(seam.manualUpdateAllMarkCalls, 1); + ASSERT_EQ(seam.lastManualMarks.size(), 2); + EXPECT_EQ(seam.lastManualMarks.at(0).first.color, QString("blue")); + EXPECT_EQ(seam.lastManualMarks.at(0).second, qint64(1000)); // time 透传 + EXPECT_EQ(seam.lastManualMarks.at(1).second, qint64(1001)); +} + +// ---- U1 正常路径:undo 恢复旧标记 ---- +TEST_F(ChangeMarkCommandTest, Undo_OldMarks_ConvertedAndApplied) +{ + // Arrange + QList oldMark = makeMarks(1, "red"); + QList newMark = makeMarks(2, "blue"); + ChangeMarkCommand cmd(QPointer(edit), oldMark, newMark); + + // Act + cmd.redo(); + cmd.undo(); + + // Assert:undo 后最后一次 manual 使用旧标记(单元素、红色、时间回退) + EXPECT_EQ(seam.convertReplaceToMarkCalls, 2); + EXPECT_EQ(seam.manualUpdateAllMarkCalls, 2); + ASSERT_EQ(seam.lastManualMarks.size(), 1); + EXPECT_EQ(seam.lastManualMarks.at(0).first.color, QString("red")); + EXPECT_EQ(seam.lastManualMarks.at(0).second, qint64(1000)); +} + +// ---- R1/U1 m_EditPtr 空分支 ---- +TEST_F(ChangeMarkCommandTest, Redo_NullEdit_SkipsMarkUpdate) +{ + // Arrange:非空标记 + 空编辑器指针 + QList oldMark = makeMarks(1, "red"); + QList newMark = makeMarks(1, "blue"); + ChangeMarkCommand cmd(QPointer(), oldMark, newMark); + + // Act + cmd.redo(); + cmd.undo(); + + // Assert:空指针分支不触发转换与刷新 + EXPECT_EQ(seam.convertReplaceToMarkCalls, 0); + EXPECT_EQ(seam.manualUpdateAllMarkCalls, 0); +} + +// ---- R1/U1 标记列表空分支 ---- +TEST_F(ChangeMarkCommandTest, Redo_EmptyMarkLists_SkipsMarkUpdate) +{ + // Arrange:编辑器有效但两侧标记均为空 + ChangeMarkCommand cmd(QPointer(edit), {}, {}); + + // Act + cmd.redo(); + cmd.undo(); + + // Assert + EXPECT_EQ(seam.convertReplaceToMarkCalls, 0); + EXPECT_EQ(seam.manualUpdateAllMarkCalls, 0); +} + +// ---- R1/U1 子命令先于标记刷新(顺序验证) ---- +TEST_F(ChangeMarkCommandTest, Redo_WithChildCommand_ChildRunsBeforeMark) +{ + // Arrange:QUndoCommand 子项机制——子命令以父命令指针构造挂入 child_list; + // ChangeMarkCommand::redo 内先调 QUndoCommand::redo()(遍历子命令)再刷新标记 + QStringList orderLog; + ChangeMarkCommand *cmd = new ChangeMarkCommand(QPointer(edit), + makeMarks(1, "red"), + makeMarks(1, "blue")); + new RecordingCommand(&orderLog, "child", cmd); // 以 cmd 为 parent 挂为子命令 + + // Act + cmd->redo(); + + // Assert:子命令 redo 先执行,其后标记刷新 + ASSERT_EQ(orderLog.size(), 1); + EXPECT_EQ(orderLog.at(0), QString("child:redo")); + EXPECT_EQ(seam.manualUpdateAllMarkCalls, 1); // 子命令之后标记刷新发生 + + // Act & Assert:undo 同样子命令先行(逆序遍历,单子命令等价) + cmd->undo(); + ASSERT_EQ(orderLog.size(), 2); + EXPECT_EQ(orderLog.at(1), QString("child:undo")); + EXPECT_EQ(seam.manualUpdateAllMarkCalls, 2); // undo/redo 各一次刷新 + + delete cmd; // 父命令析构连带子命令 +} + +// ---- R1 字段透传:MarkOperationType / color / matchText 经 convert 保留 ---- +TEST_F(ChangeMarkCommandTest, Redo_MarkOperationFields_PropagateThroughConvert) +{ + // Arrange:两个不同类型的标记操作 + QList newMark; + TextEdit::MarkReplaceInfo markAll; + markAll.opt.type = TextEdit::MarkAll; + markAll.opt.color = "green"; + markAll.opt.matchText = "TODO"; + markAll.time = 42; + newMark.append(markAll); + ChangeMarkCommand cmd(QPointer(edit), {}, newMark); + + // Act + cmd.redo(); + + // Assert:接缝 convert 逐字段透传 + ASSERT_EQ(seam.lastManualMarks.size(), 1); + EXPECT_EQ(seam.lastManualMarks.at(0).first.type, TextEdit::MarkAll); + EXPECT_EQ(seam.lastManualMarks.at(0).first.color, QString("green")); + EXPECT_EQ(seam.lastManualMarks.at(0).first.matchText, QString("TODO")); + EXPECT_EQ(seam.lastManualMarks.at(0).second, qint64(42)); +} diff --git a/tests/editor_undo/test_deletebackaltcommand.cpp b/tests/editor_undo/test_deletebackaltcommand.cpp new file mode 100644 index 00000000..79a01858 --- /dev/null +++ b/tests/editor_undo/test_deletebackaltcommand.cpp @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DeleteBackAltCommand 单元测试(src/editor/deletebackcommond.cpp)。 +// 列编辑模式退格/删除撤销项。私有 DelNode 链表(空/单/多节点)经公开 ctor/redo/undo +// 间接覆盖(test_writer §4.0 第 5 条:private 逻辑必须经 public 路径覆盖分支)。 +// +// 分支清单(ctor 循环体内): +// B1: cursor.hasSelection() 真 → 直接取 selectedText +// B2: 假 && backward=true → !atEnd 时向右扩展 1 字符 +// B3: 假 && backward=false → !atStart 时向左扩展 1 字符(backspace) +// B4: 扩展后 text 为空(atStart 且 backspace / atEnd 且 delete)→ 不生成 DelNode(空链) +// B5: text 非空 → 生成 DelNode(delPos 含 sum 偏移累计);leftToRight = anchorsetPlainText("aa\nbb\ncc"); + const QString snapshot = docText(); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 2) + << ut::makeSelection(edit->document(), 3, 5) + << ut::makeSelection(edit->document(), 6, 8); + DeleteBackAltCommand cmd(selections, edit); + + // Act + cmd.redo(); + + // Assert:三个 DelNode 按 delPos 偏移累计逐字符删除 + EXPECT_EQ(docText(), QString("\n\n")); // 行内容全删,仅剩换行 + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 1); // redo 末尾恢复列选区状态 + + // Act & Assert:undo 依 insertPos 重插全部文本 + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 2); // undo 同样恢复列选区 + ASSERT_EQ(seam.lastRestoredSelections.size(), 3); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[0].cursor.selectedText()), QString("aa")); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[2].cursor.selectedText()), QString("cc")); +} + +// ---- B1 + B3 混合:真实选区 + 裸光标(backspace 扩展),验证 sum 偏移 ---- +TEST_F(DeleteBackAltCommandTest, Redo_MixedSelectionAndBareCursor_BuildsBothNodeKinds) +{ + // Arrange:sel0 裸光标 position=1(backspace 扩展为 'a');sel1 选 "d" + edit->setPlainText("ab\ncd"); + const QString snapshot = docText(); + QList selections; + selections << ut::makeSelection(edit->document(), 1, 1) // 无选区,backward=false + << ut::makeSelection(edit->document(), 3, 4); + DeleteBackAltCommand cmd(selections, edit, false); + + // Act + cmd.redo(); + + // Assert:节点0 删 'a'(delPos=0),节点1 delPos=3-1=2(sum 偏移)删 'c' + EXPECT_EQ(docText(), QString("b\nd")); + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 +} + +// ---- B3:backspace 裸光标向左扩展单字符 ---- +TEST_F(DeleteBackAltCommandTest, Ctor_BareCursorBackspace_ExtendsBackwardChar) +{ + // Arrange:光标 position=2,backward=false(backspace 语义) + edit->setPlainText("abc"); + QList selections; + selections << ut::makeSelection(edit->document(), 2, 2); + DeleteBackAltCommand cmd(selections, edit, false); + + // Act + cmd.redo(); + + // Assert:向左扩展选中 'b' 并删除 + EXPECT_EQ(docText(), QString("ac")); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 状态可逆 + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[0].cursor.selectedText()), QString("b")); +} + +// ---- B2:delete 裸光标向右扩展单字符 ---- +TEST_F(DeleteBackAltCommandTest, Ctor_BareCursorDelete_ExtendsForwardChar) +{ + // Arrange:光标 position=1,backward=true(delete 语义) + edit->setPlainText("abc"); + QList selections; + selections << ut::makeSelection(edit->document(), 1, 1); + DeleteBackAltCommand cmd(selections, edit, true); + + // Act + cmd.redo(); + + // Assert:向右扩展选中 'b' 并删除 + EXPECT_EQ(docText(), QString("ac")); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 状态可逆 +} + +// ---- B4 atStart 边界:backspace 于块首无法扩展 → 空链,redo/undo 均无操作 ---- +TEST_F(DeleteBackAltCommandTest, Ctor_CursorAtStartBackspace_EmptyNodeChainNoop) +{ + // Arrange:光标 position=0(文档首即块首) + edit->setPlainText("ab"); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 0); + DeleteBackAltCommand cmd(selections, edit, false); + + // Act + cmd.redo(); + + // Assert:未生成 DelNode,文档无变化(空链边界) + EXPECT_EQ(docText(), QString("ab")); + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 1); // 列选区状态仍被恢复 + + // Act & Assert:undo 对空链同样无操作(强异常安全:状态未损坏) + cmd.undo(); + EXPECT_EQ(docText(), QString("ab")); +} + +// ---- B4 atEnd 边界:delete 于文档尾无法扩展 → 空链 ---- +TEST_F(DeleteBackAltCommandTest, Ctor_CursorAtEndDelete_EmptyNodeChainNoop) +{ + // Arrange:光标 position=2(文档尾) + edit->setPlainText("ab"); + QList selections; + selections << ut::makeSelection(edit->document(), 2, 2); + DeleteBackAltCommand cmd(selections, edit, true); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("ab")); // 空链无删除 + cmd.undo(); + EXPECT_EQ(docText(), QString("ab")); // 状态未损坏 +} + +// ---- B5 leftToRight=false:反向选区的 DelNode 在 undo 后保持方向 ---- +TEST_F(DeleteBackAltCommandTest, Redo_RightToLeftNode_KeepsOrientationOnUndo) +{ + // Arrange:反向选区 anchor=4 position=1(选中 "ell") + edit->setPlainText("hello"); + QList selections; + selections << ut::makeSelection(edit->document(), 4, 1); + DeleteBackAltCommand cmd(selections, edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("ho")); + cmd.undo(); + EXPECT_EQ(docText(), QString("hello")); // 状态可逆 + ASSERT_EQ(seam.lastRestoredSelections.size(), 1); + const QTextCursor &restored = seam.lastRestoredSelections[0].cursor; + EXPECT_GT(restored.anchor(), restored.position()); // 方向恢复:anchor(4) > position(1) + EXPECT_EQ(ut::toLf(restored.selectedText()), QString("ell")); +} + +// ---- B1 中文等价类:多字节选区双向可逆 ---- +TEST_F(DeleteBackAltCommandTest, Redo_MultiByteSelections_ReversibleBothWays) +{ + // Arrange:两行中文,各选中全部字符 + edit->setPlainText(QString::fromUtf8("你好\n世界")); + const QString snapshot = docText(); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 2) + << ut::makeSelection(edit->document(), 3, 5); + DeleteBackAltCommand cmd(selections, edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("\n")); + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 多字节状态可逆 + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[1].cursor.selectedText()), + QString::fromUtf8("世界")); +} + +// ---- R/U 的 m_edit 空分支:文档仍按节点修改,不触碰列选区恢复 ---- +TEST_F(DeleteBackAltCommandTest, Redo_NullEdit_StillMutatesDocument) +{ + // Arrange + edit->setPlainText("abc"); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1); + DeleteBackAltCommand cmd(selections, nullptr); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("bc")); + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 0); // 空 edit 不触发列选区恢复 + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 状态可逆 + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 0); +} + +// ---- ID ---- +TEST_F(DeleteBackAltCommandTest, Id_ColumnDelete_ReturnsIdColumnEditDelete) +{ + // Arrange + edit->setPlainText("ab"); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1); + DeleteBackAltCommand cmd(selections, edit); + + // Assert + EXPECT_EQ(cmd.id(), Utils::IdColumnEditDelete); // IdColumnEdit | IdDelete + EXPECT_EQ(cmd.id(), Utils::IdColumnEdit | Utils::IdDelete); +} + +// ---- D1:经基类指针虚析构删除(deleting destructor 路径) ---- +TEST_F(DeleteBackAltCommandTest, Destructor_DeleteViaBasePointer_ReleasesCleanly) +{ + // Arrange + edit->setPlainText("ab"); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1); + QUndoCommand *cmd = new DeleteBackAltCommand(selections, edit); + cmd->redo(); + EXPECT_EQ(docText(), QString("b")); // 前提:命令已生效 + + // Act + delete cmd; + + // Assert:析构后文档保持命令执行后的状态 + EXPECT_EQ(docText(), QString("b")); + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 1); // 析构不再触发列选区恢复 +} diff --git a/tests/editor_undo/test_deletebackcommand.cpp b/tests/editor_undo/test_deletebackcommand.cpp new file mode 100644 index 00000000..79ee96cd --- /dev/null +++ b/tests/editor_undo/test_deletebackcommand.cpp @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DeleteBackCommand 单元测试(src/editor/deletebackcommond.cpp)。 +// 退格/删除键的向后删除撤销项(单光标,m_edit 为 QPlainTextEdit*,undo/redo 无空判断, +// 必须传入真实控件——使用空壳 TextEdit,其行为等价 DPlainTextEdit)。 +// +// 分支清单: +// CT: 构造 —— m_delText = cursor.selectedText()(选区/无选区);delPos = min(anchor,position) +// R1: redo —— 选中 [delPos, delPos+size) 并 deleteChar;m_edit->setTextCursor +// U1: undo —— 在 insertPos 重新插入 delText;编辑器光标选中恢复的文本 +// +// 用例映射: +// - Redo_WithSelection_DeletesAndUndoRestoresWithSelection → CT/R1/U1(有选区,可逆主路径) +// - Redo_NoSelection_DocumentesForwardDeleteSemantics → CT/R1/U1(无选区边界: +// delText 为空但 redo 的 deleteChar 在无选区时前向删除一个字符——记录真实语义) +// - Redo_SelectionAtDocumentStart_DeletesPrefix → CT(delPos=0 边界) +// - Redo_MultiByteSelection_ReversibleBothWays → 中文选区(多字节等价类) +// - Redo_RepeatCycles_StaysReversible → 多轮 redo/undo 幂等 +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor/dtor/undo/redo,dtor 由作用域析构覆盖)✔ +// 2 输入等价类(有/无选区、ASCII/中文)✔ 3 边界(文档首、无选区)✔ 4 同质多组并入 +// 多字节用例(断言逻辑不同的不参数化)✔ 5 分支映射 ✔ 6 全分支 ✔ 7 无异常路径 ✔ +// 8 负面(无选区)✔ 9 双向可逆断言 ✔ 10 空壳接缝无 gMock ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/deletebackcommond.h" + +class DeleteBackCommandTest : public EditorUndoTestBase +{ +}; + +// ---- CT/R1/U1 主路径:选中文本删除且 undo 精确恢复(含选中态) ---- +TEST_F(DeleteBackCommandTest, Redo_WithSelection_DeletesAndUndoRestoresWithSelection) +{ + // Arrange:选中 "world" + edit->setPlainText("hello world"); + const QString snapshot = docText(); + QTextCursor cursor = ut::cursorAt(edit->document(), 6, 11); + DeleteBackCommand cmd(cursor, edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("hello ")); // 选区被删除 + EXPECT_EQ(edit->textCursor().position(), 6); // 删除后光标塌缩到删除点 + + // Act & Assert:undo 在原位重插并重新选中 + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(ut::toLf(edit->textCursor().selectedText()), QString("world")); // 恢复选中态 +} + +// ---- CT/R1/U1 无选区边界:delText 为空;redo 的 deleteChar 前向删除一个字符(真实语义) ---- +TEST_F(DeleteBackCommandTest, Redo_NoSelection_DocumentsForwardDeleteSemantics) +{ + // Arrange:光标 position=2,无选区 + edit->setPlainText("abc"); + QTextCursor cursor = ut::cursorAt(edit->document(), 2, 2); + DeleteBackCommand cmd(cursor, edit); + + // Act + cmd.redo(); + + // Assert:QTextCursor::deleteChar 在无选区时删除光标处字符('c') + EXPECT_EQ(docText(), QString("ab")); + EXPECT_EQ(edit->textCursor().position(), 2); + + // Act & Assert:undo 插入空串,文档保持删除后状态(构造期未捕获字符,此为源码语义) + cmd.undo(); + EXPECT_EQ(docText(), QString("ab")); + EXPECT_EQ(edit->textCursor().position(), 2); // 光标稳定 +} + +// ---- CT delPos=0 边界:文档首部选区 ---- +TEST_F(DeleteBackCommandTest, Redo_SelectionAtDocumentStart_DeletesPrefix) +{ + // Arrange:选中 "he" + edit->setPlainText("hello"); + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 2); + DeleteBackCommand cmd(cursor, edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("llo")); + EXPECT_EQ(edit->textCursor().position(), 0); // 删除点为文档首 + cmd.undo(); + EXPECT_EQ(docText(), QString("hello")); // 状态可逆 + EXPECT_EQ(ut::toLf(edit->textCursor().selectedText()), QString("he")); +} + +// ---- 中文选区(多字节等价类)双向可逆 ---- +TEST_F(DeleteBackCommandTest, Redo_MultiByteSelection_ReversibleBothWays) +{ + // Arrange:选中 "中文"(2 个 QChar) + edit->setPlainText(QString::fromUtf8("中文abc")); + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 2); + DeleteBackCommand cmd(cursor, edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("abc")); + cmd.undo(); + EXPECT_EQ(docText(), QString::fromUtf8("中文abc")); // 状态可逆(多字节) + EXPECT_EQ(ut::toLf(edit->textCursor().selectedText()), QString::fromUtf8("中文")); +} + +// ---- 多轮 redo/undo 幂等性 ---- +TEST_F(DeleteBackCommandTest, Redo_RepeatCycles_StaysReversible) +{ + // Arrange + edit->setPlainText("0123456789"); + QTextCursor cursor = ut::cursorAt(edit->document(), 3, 6); // "345" + DeleteBackCommand cmd(cursor, edit); + + // Act & Assert:三轮循环,每轮结束都应精确回到初始文本 + for (int round = 0; round < 3; ++round) { + cmd.redo(); + EXPECT_EQ(docText(), QString("0126789")) << "round " << round; + cmd.undo(); + EXPECT_EQ(docText(), QString("0123456789")) << "round " << round; + } +} + +// ---- D1:经基类指针虚析构删除(deleting destructor 路径),文档状态不受影响 ---- +TEST_F(DeleteBackCommandTest, Destructor_DeleteViaBasePointer_ReleasesCleanly) +{ + // Arrange:堆上构造并执行一次 redo 建立有效状态 + edit->setPlainText("hello"); + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 2); + QUndoCommand *cmd = new DeleteBackCommand(cursor, edit); + cmd->redo(); + EXPECT_EQ(docText(), QString("llo")); // 前提:命令已生效 + + // Act:经 QUndoCommand* 虚析构删除(虚析构分派 → deleting dtor 路径) + delete cmd; + + // Assert:析构仅释放命令对象,不触碰文档 + EXPECT_EQ(docText(), QString("llo")); +} diff --git a/tests/editor_undo/test_deletetextundocommand.cpp b/tests/editor_undo/test_deletetextundocommand.cpp new file mode 100644 index 00000000..aebc25d9 --- /dev/null +++ b/tests/editor_undo/test_deletetextundocommand.cpp @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DeleteTextUndoCommand 单元测试(src/editor/deletetextundocommand.cpp)。 +// +// 分支清单: +// CT1(单光标):hasSelection 真 → m_sInsertText=selectedText; +// 假 → positionInBlock-1 >= 0 → 块内前一字符;< 0(块首)→ "\n"(上一行换行符) +// CT2(列编辑):逐选区同上三分支 → m_selectTextList +// R1: redo 单光标 —— deletePreviousChar;m_edit 空/非空 +// R2: redo 列编辑 —— 逐选区 deletePreviousChar;restoreColumnEditSelection +// U1: undo 单光标 —— beginPos 重插 + 重新选中;m_edit 空/非空 +// U2: undo 列编辑 —— 逐选区重插 selectTextList[i] + 选中;restoreColumnEditSelection +// ID: id() —— 列编辑选区空 → Utils::IdDelete;非空 → Utils::IdColumnEditDelete +// +// 用例映射: +// - Redo_WithSelection_DeletesAndUndoRestoresSelected → CT1(真)+R1/U1 +// - Redo_NoSelectionMidBlock_DeletesPreviousChar → CT1(块内)+R1/U1 +// - Redo_NoSelectionAtBlockStart_DeletesNewline → CT1(块首 "\n")+R1/U1 +// - Redo_ColumnSelections_DeletesAllThenUndoRestores → CT2(真)+R2/U2 +// - Redo_ColumnBareCursorMidBlock_DeletesPreviousChar → CT2(块内)+R2/U2 +// - Redo_MultiByteText_ReversibleBothWays(TEST_P) → 输入等价类(中文/emoji/ASCII) +// - Id_SingleMode_ReturnsIdDelete / Id_ColumnMode_ReturnsIdColumnEditDelete → ID +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor×2/undo/redo/id)✔ 2 输入等价类 ✔ +// 3 边界(块首/块内/空)✔ 4 TEST_P ≥3 组 ✔ 5 分支映射 ✔ 6 全分支 ✔ +// 7 无异常路径 ✔ 8 负面(块首删除换行合并)✔ 9 双向可逆断言 ✔ 10 空壳接缝无 gMock ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/deletetextundocommand.h" + +#include "../../../src/common/utils.h" + +class DeleteTextUndoCommandTest : public EditorUndoTestBase +{ +}; + +// ---- CT1(真)+R1/U1:选区删除与恢复 ---- +TEST_F(DeleteTextUndoCommandTest, Redo_WithSelection_DeletesAndUndoRestoresSelected) +{ + // Arrange:选中 "he" + edit->setPlainText("hello"); + const QString snapshot = docText(); + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 2); + DeleteTextUndoCommand cmd(cursor, edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("llo")); + EXPECT_EQ(edit->textCursor().position(), 0); // 删除后光标位于 beginPos + + // Act & Assert:undo 在 beginPos 重插并选中 + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(ut::toLf(edit->textCursor().selectedText()), QString("he")); +} + +// ---- CT1(块内)+R1/U1:无选区时光标前一个字符被删除 ---- +// 源码行为注记(defect 候选,见批次报告):无选区路径 m_beginPos 记录的是光标位置 +// (被删字符之后),undo 在 m_beginPos 重插导致字符落在原字符位置之后("acb"), +// 仅当选区路径(beginPos=selectionStart)恢复正确。按实际行为断言留证。 +TEST_F(DeleteTextUndoCommandTest, Redo_NoSelectionMidBlock_DeletesPreviousChar) +{ + // Arrange:光标 position=2("abc" 中 'b' 之前) + edit->setPlainText("abc"); + QTextCursor cursor = ut::cursorAt(edit->document(), 2, 2); + DeleteTextUndoCommand cmd(cursor, edit); + + // Act + cmd.redo(); + + // Assert:deletePreviousChar 删除 'b' + EXPECT_EQ(docText(), QString("ac")); + cmd.undo(); + EXPECT_EQ(docText(), QString("acb")); // 非 "abc":见 defect 注记 + EXPECT_NE(docText(), QString("abc")); +} + +// ---- CT1(块首)+R1/U1:块首删除的是上一行换行符(行合并);undo 重插位置同 defect 注记 ---- +TEST_F(DeleteTextUndoCommandTest, Redo_NoSelectionAtBlockStart_DeletesNewline) +{ + // Arrange:光标位于第二行行首 position=3 + edit->setPlainText("ab\ncd"); + QTextCursor cursor = ut::cursorAt(edit->document(), 3, 3); + DeleteTextUndoCommand cmd(cursor, edit); + + // Act + cmd.redo(); + + // Assert:删除换行符,两行合并 + EXPECT_EQ(docText(), QString("abcd")); + EXPECT_EQ(edit->document()->blockCount(), 1); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc\nd")); // 非 "ab\ncd":同 defect 注记 + EXPECT_NE(docText(), QString("ab\ncd")); +} + +// ---- CT2(真)+R2/U2:列编辑多选区 ---- +TEST_F(DeleteTextUndoCommandTest, Redo_ColumnSelections_DeletesAllThenUndoRestores) +{ + // Arrange:两行各选一个字符 + edit->setPlainText("aa\nbb"); + const QString snapshot = docText(); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1) + << ut::makeSelection(edit->document(), 3, 4); + DeleteTextUndoCommand cmd(selections, edit); + + // Act + cmd.redo(); + + // Assert:逐选区 deletePreviousChar(选区存在时删除选区) + EXPECT_EQ(docText(), QString("a\nb")); + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 1); + + // Act & Assert:undo 逐选区重插 + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 2); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[0].cursor.selectedText()), QString("a")); +} + +// ---- CT2(块内)+R2/U2:列编辑裸光标删前一个字符 ---- +TEST_F(DeleteTextUndoCommandTest, Redo_ColumnBareCursorMidBlock_DeletesPreviousChar) +{ + // Arrange:两行各一个块中裸光标(position 1 与 4) + edit->setPlainText("ab\ncd"); + const QString snapshot = docText(); + QList selections; + selections << ut::makeSelection(edit->document(), 1, 1) + << ut::makeSelection(edit->document(), 4, 4); + DeleteTextUndoCommand cmd(selections, edit); + + // Act + cmd.redo(); + + // Assert:各自删除前一个字符('a' 与 'c') + EXPECT_EQ(docText(), QString("b\nd")); + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 +} + +// ---- CT2(块首裸光标):列编辑变体捕获 "\n"(上一行换行符) ---- +TEST_F(DeleteTextUndoCommandTest, Redo_ColumnBareCursorAtBlockStart_DeletesNewline) +{ + // Arrange:裸光标位于第二行行首 position=3(posInBlock-1 < 0 → 记录 "\n") + edit->setPlainText("ab\ncd"); + QList selections; + selections << ut::makeSelection(edit->document(), 3, 3); + DeleteTextUndoCommand cmd(selections, edit); + + // Act + cmd.redo(); + + // Assert:列编辑 redo 逐选区 deletePreviousChar → 删除换行符合并行 + EXPECT_EQ(docText(), QString("abcd")); + EXPECT_EQ(edit->document()->blockCount(), 1); + cmd.undo(); + EXPECT_EQ(docText(), QString("ab\ncd")); // 列编辑 undo 在光标现位重插,精确还原 + EXPECT_EQ(edit->document()->blockCount(), 2); +} + +// ---- 输入等价类 TEST_P:选区内容 ASCII/中文/emoji(redo→undo 双向精确) ---- +namespace { +struct DeleteTextCase { + QString docContent; + int selStart; + int selEnd; + QString expectedAfterRedo; +}; +} // namespace + +class DeleteTextParamTest : public EditorUndoTestBase, + public ::testing::WithParamInterface { +}; + +TEST_P(DeleteTextParamTest, Redo_MultiByteText_ReversibleBothWays) +{ + // Arrange + const DeleteTextCase &c = GetParam(); + edit->setPlainText(c.docContent); + QTextCursor cursor = ut::cursorAt(edit->document(), c.selStart, c.selEnd); + DeleteTextUndoCommand cmd(cursor, edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), c.expectedAfterRedo); + cmd.undo(); + EXPECT_EQ(docText(), c.docContent); // 状态可逆 + EXPECT_EQ(edit->document()->characterCount() - 1, c.docContent.size()); +} + +INSTANTIATE_TEST_SUITE_P(TextVariants, DeleteTextParamTest, + ::testing::Values( + DeleteTextCase{ QString("hello"), 1, 4, QString("ho") }, + DeleteTextCase{ QString::fromUtf8("中文文本"), 0, 2, QString::fromUtf8("文本") }, + DeleteTextCase{ QString::fromUtf8("a\xF0\x9F\x98\x80" "b"), 1, 3, QString("ab") } // emoji 代理对选区 + )); + +// ---- ID 两分支 ---- +TEST_F(DeleteTextUndoCommandTest, Id_SingleMode_ReturnsIdDelete) +{ + // Arrange + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 0); + DeleteTextUndoCommand cmd(cursor, nullptr); + + // Assert + EXPECT_EQ(cmd.id(), Utils::IdDelete); + EXPECT_NE(cmd.id(), Utils::IdColumnEditDelete); +} + +TEST_F(DeleteTextUndoCommandTest, Id_ColumnMode_ReturnsIdColumnEditDelete) +{ + // Arrange + edit->setPlainText("ab"); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1); + DeleteTextUndoCommand cmd(selections, nullptr); + + // Assert + EXPECT_EQ(cmd.id(), Utils::IdColumnEditDelete); + EXPECT_EQ(cmd.id(), Utils::IdColumnEdit | Utils::IdDelete); +} diff --git a/tests/editor_undo/test_deletetextundocommand2.cpp b/tests/editor_undo/test_deletetextundocommand2.cpp new file mode 100644 index 00000000..2486aeaf --- /dev/null +++ b/tests/editor_undo/test_deletetextundocommand2.cpp @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DeleteTextUndoCommand2 单元测试(src/editor/deletetextundocommand.cpp)。 +// Ctrl+K / Ctrl+Shift+K 删除到行尾、删除整行的撤销项。 +// +// 分支清单(redo 单光标): +// B1: m_iscurrLine=true → 选整块 + NextCharacter(整行删除) +// B2: isEmptyLine(构造文本空)|| atBlockEnd → NextCharacter(选换行符) +// B3: isBlankLine && atBlockStart → 选整块(删除纯空白行) +// B4: 其余 → 选至 EndOfBlock(删到行尾) +// 注:m_sInsertText 在 redo 中被实际删除内容覆盖;构造文本只决定 B2-B4 分支选择。 +// R2/U2: 列编辑变体 —— 逐选区 deletePreviousChar / 以 m_beginPostion 重插 +// CT: 两个构造重载(单光标/列编辑)均含 "\r\n"→"\n" 归一化 +// +// 用例映射: +// - Redo_CurrLineTrue_DeletesWholeLine → B1 +// - Redo_NotCurrLine_AtBlockEnd_DeletesNewline → B2(atBlockEnd 侧) +// - Redo_NotCurrLine_EmptyCtorText_DeletesNextChar → B2(isEmptyLine 短路侧) +// - Redo_NotCurrLine_BlankLineAtStart_DeletesWholeBlock → B3 +// - Redo_NotCurrLine_MidLine_DeletesToEndOfBlock → B4 +// - Ctor_CrlfText_NormalizedAffectsBranchOnly → CT 归一化 +// - Redo_ColumnSingleSelection_ReversibleBothWays → R2/U2(单选区) +// - Redo_ColumnMultiSelection_UndoUsesLastBeginPos → R2/U2(多选区;见 defect 注记) +// - Redo_TextVariants_RoundTripExact(TEST_P) → 中文/emoji/ASCII 等价类 +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor×2/undo/redo)✔ 2 输入等价类(分支文本/ +// 多字节)✔ 3 边界(块首/块尾/空构造文本)✔ 4 TEST_P ≥3 组 ✔ 5 分支映射 ✔ +// 6 全分支 ✔ 7 无异常路径 ✔ 8 负面(空文本)✔ 9 双向可逆断言 ✔ 10 无 gMock ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/deletetextundocommand.h" + +class DeleteTextUndoCommand2Test : public EditorUndoTestBase +{ +}; + +// ---- B1:删除整行(含行尾换行符),undo 精确重建 ---- +TEST_F(DeleteTextUndoCommand2Test, Redo_CurrLineTrue_DeletesWholeLine) +{ + // Arrange:光标位于第二行中部 position=4("cd" 中间) + edit->setPlainText("ab\ncd\nef"); + const QString snapshot = docText(); + QTextCursor cursor = ut::cursorAt(edit->document(), 4, 4); + DeleteTextUndoCommand2 cmd(cursor, "xy", edit, true); + + // Act + cmd.redo(); + + // Assert:整行 "cd\n" 被删除 + EXPECT_EQ(docText(), QString("ab\nef")); + EXPECT_EQ(edit->document()->blockCount(), 2); + + // Act & Assert:undo 在 beginPostion 重插 "cd"+段落分隔符 + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(edit->document()->blockCount(), 3); +} + +// ---- B2 atBlockEnd 侧:行尾删除换行符(行合并) ---- +TEST_F(DeleteTextUndoCommand2Test, Redo_NotCurrLine_AtBlockEnd_DeletesNewline) +{ + // Arrange:光标位于第二行行尾 position=5 + edit->setPlainText("ab\ncd\nef"); + QTextCursor cursor = ut::cursorAt(edit->document(), 5, 5); + DeleteTextUndoCommand2 cmd(cursor, "xy", edit, false); + + // Act + cmd.redo(); + + // Assert:选中并删除行尾换行符 → 与下一行合并 + EXPECT_EQ(docText(), QString("ab\ncdef")); + cmd.undo(); + EXPECT_EQ(docText(), QString("ab\ncd\nef")); // 状态可逆 +} + +// ---- B2 isEmptyLine 短路侧:构造文本为空 → 删除光标后一个字符 ---- +TEST_F(DeleteTextUndoCommand2Test, Redo_NotCurrLine_EmptyCtorText_DeletesNextChar) +{ + // Arrange:光标 position=2,构造文本为空串 + edit->setPlainText("abcdef"); + QTextCursor cursor = ut::cursorAt(edit->document(), 2, 2); + DeleteTextUndoCommand2 cmd(cursor, "", edit, false); + + // Act + cmd.redo(); + + // Assert:NextCharacter 选中 'c' 并删除 + EXPECT_EQ(docText(), QString("abdef")); + cmd.undo(); + EXPECT_EQ(docText(), QString("abcdef")); // 状态可逆 +} + +// ---- B3:纯空白行且光标在块首 → 删除整块 ---- +TEST_F(DeleteTextUndoCommand2Test, Redo_NotCurrLine_BlankLineAtStart_DeletesWholeBlock) +{ + // Arrange:第二行为三个空格,光标在其块首 position=3;构造文本非空但 trim 后为空 + edit->setPlainText("ab\n \ncd"); + QTextCursor cursor = ut::cursorAt(edit->document(), 3, 3); + DeleteTextUndoCommand2 cmd(cursor, " ", edit, false); + + // Act + cmd.redo(); + + // Assert:整块 " " 被删除(保留换行符) + EXPECT_EQ(docText(), QString("ab\n\ncd")); + cmd.undo(); + EXPECT_EQ(docText(), QString("ab\n \ncd")); // 状态可逆 +} + +// ---- B4:行中删除到行尾 ---- +TEST_F(DeleteTextUndoCommand2Test, Redo_NotCurrLine_MidLine_DeletesToEndOfBlock) +{ + // Arrange + edit->setPlainText("abcdef"); + QTextCursor cursor = ut::cursorAt(edit->document(), 2, 2); + DeleteTextUndoCommand2 cmd(cursor, "xy", edit, false); + + // Act + cmd.redo(); + + // Assert:"cdef" 被删除 + EXPECT_EQ(docText(), QString("ab")); + cmd.undo(); + EXPECT_EQ(docText(), QString("abcdef")); // 状态可逆 +} + +// ---- CT:CRLF 归一化(归一化后非空白 → 走 B4 删到行尾分支) ---- +TEST_F(DeleteTextUndoCommand2Test, Ctor_CrlfText_NormalizedAffectsBranchOnly) +{ + // Arrange + edit->setPlainText("abcdef"); + QTextCursor cursor = ut::cursorAt(edit->document(), 2, 2); + DeleteTextUndoCommand2 cmd(cursor, "a\r\nb", edit, false); + + // Act + cmd.redo(); + + // Assert:"a\r\nb" 归一化为 "a\nb"(非空非纯空白)→ B4 分支删到行尾 + EXPECT_EQ(docText(), QString("ab")); + cmd.undo(); + EXPECT_EQ(docText(), QString("abcdef")); +} + +// ---- R2/U2 单选区:正确的双向可逆 ---- +TEST_F(DeleteTextUndoCommand2Test, Redo_ColumnSingleSelection_ReversibleBothWays) +{ + // Arrange:单个列选区 [0,1) + edit->setPlainText("aa\nbb"); + const QString snapshot = docText(); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1); + DeleteTextUndoCommand2 cmd(selections, "t", edit, false); + + // Act + cmd.redo(); + + // Assert:选区被 deletePreviousChar 删除 + EXPECT_EQ(docText(), QString("a\nbb")); + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 +} + +// ---- R2/U2 多选区:undo 以最后一次 redo 记录的 m_beginPostion 统一重插 ---- +// 源码行为注记(defect 候选,见批次报告):redo 列编辑循环里 m_beginPostion 被 +// 每个选区覆盖,undo 循环对所有选区统一使用最后写入值,导致多选区场景文本被 +// 恢复到最后一个选区起点而非各自起点;本用例按实际行为断言并保留可逆性失败的证据。 +TEST_F(DeleteTextUndoCommand2Test, Redo_ColumnMultiSelection_UndoUsesLastBeginPos) +{ + // Arrange:两个列选区 [0,1) 与 [3,4) + edit->setPlainText("aa\nbb"); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1) + << ut::makeSelection(edit->document(), 3, 4); + DeleteTextUndoCommand2 cmd(selections, "t", edit, false); + + // Act + cmd.redo(); + + // Assert:redo 删除两个选区内容 + EXPECT_EQ(docText(), QString("a\nb")); + + // Act & Assert:undo 将两段文本都插到最后一个选区起点 position=3(实际行为) + cmd.undo(); + EXPECT_EQ(docText(), QString("a\nbab")); // 非 "aa\nbb":见 defect 注记 + EXPECT_NE(docText(), QString("aa\nbb")); +} + +// ---- R2/U2 列编辑裸光标(块中/块首):ctor2 捕获前字符与 "\n" 两分支 ---- +TEST_F(DeleteTextUndoCommand2Test, Redo_ColumnBareCursorMidBlock_DeletesPreviousChar) +{ + // Arrange:块中裸光标 position=2("abc" 中 'b' 之前)→ selectTextList 记录 'b' + edit->setPlainText("abc"); + QList selections; + selections << ut::makeSelection(edit->document(), 2, 2); + DeleteTextUndoCommand2 cmd(selections, "t", edit, false); + + // Act + cmd.redo(); + + // Assert:deletePreviousChar 删除 'b' + EXPECT_EQ(docText(), QString("ac")); + cmd.undo(); + EXPECT_EQ(docText(), QString("acb")); // 同前述重插位置偏移语义 +} + +// ---- R2/U2 列编辑裸光标(块首):捕获 "\n" ---- +TEST_F(DeleteTextUndoCommand2Test, Redo_ColumnBareCursorAtBlockStart_DeletesNewline) +{ + // Arrange:裸光标位于第二行行首 position=3 → posInBlock-1 < 0 → 记录 "\n" + edit->setPlainText("ab\ncd"); + QList selections; + selections << ut::makeSelection(edit->document(), 3, 3); + DeleteTextUndoCommand2 cmd(selections, "t", edit, false); + + // Act + cmd.redo(); + + // Assert:删除上一行换行符,两行合并 + EXPECT_EQ(docText(), QString("abcd")); + EXPECT_EQ(edit->document()->blockCount(), 1); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc\nd")); // undo 重插换行(位置同上偏移语义) +} + +// ---- 输入等价类 TEST_P:中文/emoji/ASCII 内容的整行删除往返 ---- +namespace { +struct Delete2Case { + QString docContent; + int cursorPos; + bool currLine; + QString expectedAfterRedo; +}; +} // namespace + +class Delete2ParamTest : public EditorUndoTestBase, + public ::testing::WithParamInterface { +}; + +TEST_P(Delete2ParamTest, Redo_TextVariants_RoundTripExact) +{ + // Arrange + const Delete2Case &c = GetParam(); + edit->setPlainText(c.docContent); + QTextCursor cursor = ut::cursorAt(edit->document(), c.cursorPos, c.cursorPos); + DeleteTextUndoCommand2 cmd(cursor, "xy", edit, c.currLine); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), c.expectedAfterRedo); + cmd.undo(); + EXPECT_EQ(docText(), c.docContent); // 多字节往返精确 + EXPECT_EQ(edit->document()->characterCount() - 1, c.docContent.size()); +} + +INSTANTIATE_TEST_SUITE_P(TextVariants, Delete2ParamTest, + ::testing::Values( + Delete2Case{ QString("first\nsecond"), 7, false, QString("first\ns") }, // 行中删到行尾 + Delete2Case{ QString::fromUtf8("第一行\n第二行"), 4, true, + QString::fromUtf8("第一行\n") }, // 删整行(末行无换行可带) + Delete2Case{ QString::fromUtf8("a\xF0\x9F\x98\x80" "\nz"), 0, false, + QString("\nz") } // emoji 行删到行尾 + )); diff --git a/tests/editor_undo/test_draginserttextundocommand.cpp b/tests/editor_undo/test_draginserttextundocommand.cpp new file mode 100644 index 00000000..e482739c --- /dev/null +++ b/tests/editor_undo/test_draginserttextundocommand.cpp @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DragInsertTextUndoCommand 单元测试(src/editor/inserttextundocommand.cpp)。 +// 拖拽插入撤销项:使用固定偏移量(构造时的 selectionStart),不信任 redo 时的光标位置。 +// +// 分支清单: +// CT: 构造 —— m_beginPostion = textcursor.selectionStart() +// R1: redo —— beginPostion != cursor.position() → setPosition(beginPostion)(分支真) +// R2: redo —— 相等 → 跳过重定位(分支假);插入;m_pEdit 空/非空 +// U1: undo —— 删除 [begin, begin+size);m_pEdit 空/非空 +// +// 用例映射: +// - Redo_CursorDrifted_RepositionsToDropTargetAndRestores → R1(分支真)+ U1 +// - Redo_CursorAtDropTarget_SkipsRepositionBranch → R2(分支假) +// - Ctor_CrlfText_NormalizedToLf → CT + 插入结果 +// - Redo_TextVariants_ExactInsertion(TEST_P) → CT/R1 输入等价类 +// - Redo_EmptyText_EmptyDoc_NoStateChange → 空文本/空文档边界 +// - Redo_NullEdit_StillMutatesDocument → R1/U1 空 edit 分支 +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor/undo/redo)✔ 2 输入等价类 ✔ +// 3 边界(空文本/空文档/代理对)✔ 4 TEST_P ≥3 组 ✔ 5 分支映射 ✔ 6 全分支 ✔ +// 7 无异常路径 ✔ 8 负面(空文本)✔ 9 双向可逆断言 ✔ 10 空壳接缝无 gMock ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/inserttextundocommand.h" + +class DragInsertTextUndoCommandTest : public EditorUndoTestBase +{ +}; + +// ---- R1 分支真:构造时光标带选区(position=selectionEnd ≠ selectionStart=drop 目标), +// redo 先重定位回选区起点再插入(插入替换选区内容) ---- +TEST_F(DragInsertTextUndoCommandTest, Redo_CursorDrifted_RepositionsToDropTargetAndRestores) +{ + // Arrange:光标选中 [4,6)("ef"),构造期 beginPostion = selectionStart = 4, + // 而 position = 6 ≠ 4 → redo 进入重定位分支 + edit->setPlainText("abcdef"); + QTextCursor cursor = ut::cursorAt(edit->document(), 4, 6); + DragInsertTextUndoCommand cmd(cursor, "XY", edit); + + // Act + cmd.redo(); + + // Assert:重定位 setPosition 清空选区后原位插入(不替换 "ef") + EXPECT_EQ(docText(), QString("abcdXYef")); + EXPECT_EQ(edit->textCursor().position(), 6); // 编辑器光标 = begin + size + + // Act & Assert:undo 删除 [4,6) 精确还原 + cmd.undo(); + EXPECT_EQ(docText(), QString("abcdef")); // 状态可逆 + EXPECT_EQ(edit->textCursor().position(), 4); +} + +// ---- R2 分支假:redo 时光标仍等于 drop 目标,跳过重定位 ---- +TEST_F(DragInsertTextUndoCommandTest, Redo_CursorAtDropTarget_SkipsRepositionBranch) +{ + // Arrange + edit->setPlainText("abc"); + QTextCursor cursor = ut::cursorAt(edit->document(), 3, 3); + DragInsertTextUndoCommand cmd(cursor, "Z", edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("abcZ")); + EXPECT_EQ(edit->textCursor().position(), 4); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 状态可逆 + EXPECT_EQ(edit->textCursor().position(), 3); +} + +// ---- CT:CRLF 文本 ---- +// 源码行为注记(defect 候选,见批次报告):DragInsertTextUndoCommand 构造不做 +// "\r\n"→"\n" 归一化(同类 InsertText/MidButton 均做),m_sInsertText.size() 按 +// "\r\n" 计 2 个 QChar;QTextDocument 插入时把 "\r\n" 折叠为单个段落分隔符, +// undo 计算 [begin, begin+4) 越界,setPosition 拒绝移动导致选区为空,deleteChar +// 前向删除单字符 → 撤销结果错误("line\nb" 而非 "line")。按实际行为断言留证。 +TEST_F(DragInsertTextUndoCommandTest, Ctor_CrlfText_InsertFoldsButUndoOvershoots) +{ + // Arrange + edit->setPlainText("line"); + QTextCursor cursor = ut::cursorAt(edit->document(), 4, 4); + DragInsertTextUndoCommand cmd(cursor, "a\r\nb", edit); + + // Act + cmd.redo(); + + // Assert:插入侧由 QTextDocument 折叠 "\r\n",文档得到单个换行 + EXPECT_EQ(docText(), QString("linea\nb")); + + // Act & Assert:undo 侧长度按 4 计算越界,仅前向删除一个字符(实际行为) + cmd.undo(); + EXPECT_EQ(docText(), QString("line\nb")); // 非 "line":见 defect 注记 + EXPECT_NE(docText(), QString("line")); +} + +// ---- CT/R1 输入等价类:ASCII / 中文 / emoji ---- +namespace { +struct DragTextCase { + QString text; +}; +} // namespace + +class DragTextParamTest : public EditorUndoTestBase, + public ::testing::WithParamInterface { +}; + +TEST_P(DragTextParamTest, Redo_TextVariants_ExactInsertion) +{ + // Arrange:空文档,drop 目标 0 + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 0); + DragInsertTextUndoCommand cmd(cursor, GetParam().text, edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), GetParam().text); + EXPECT_EQ(edit->textCursor().position(), GetParam().text.size()); // begin + UTF-16 长度 + + cmd.undo(); + EXPECT_EQ(docText(), QString("")); // 状态可逆 + EXPECT_EQ(edit->document()->characterCount() - 1, 0); +} + +INSTANTIATE_TEST_SUITE_P(TextVariants, DragTextParamTest, + ::testing::Values( + DragTextCase{ QString("hello") }, + DragTextCase{ QString::fromUtf8("中文字符") }, + DragTextCase{ QString::fromUtf8("e\xF0\x9F\x98\x80moji") } + )); + +// ---- 空文本 + 空文档边界:零长度范围,redo/undo 均无状态变化 ---- +TEST_F(DragInsertTextUndoCommandTest, Redo_EmptyText_EmptyDoc_NoStateChange) +{ + // Arrange + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 0); + DragInsertTextUndoCommand cmd(cursor, "", edit); + + // Act + cmd.redo(); + + // Assert:[0,0) 无插入内容,m_delPos 语义不受影响 + EXPECT_EQ(docText(), QString("")); + EXPECT_EQ(edit->textCursor().position(), 0); + cmd.undo(); + EXPECT_EQ(docText(), QString("")); // 强异常安全:状态未损坏 + EXPECT_EQ(edit->textCursor().position(), 0); +} + +// ---- R1/U1 空 edit 分支 ---- +TEST_F(DragInsertTextUndoCommandTest, Redo_NullEdit_StillMutatesDocument) +{ + // Arrange + edit->setPlainText("abc"); + QTextCursor cursor = ut::cursorAt(edit->document(), 2, 2); + DragInsertTextUndoCommand cmd(cursor, "Q", nullptr); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("abQc")); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 状态可逆 +} diff --git a/tests/editor_undo/test_endlineformartcommand.cpp b/tests/editor_undo/test_endlineformartcommand.cpp new file mode 100644 index 00000000..6390e259 --- /dev/null +++ b/tests/editor_undo/test_endlineformartcommand.cpp @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// EndlineFormartCommand 单元测试(src/editor/endlineformatcommond.cpp)。 +// 行尾格式切换撤销项:redo 通过“插入+删除空格”触发文档变更信号后设置底栏格式, +// undo 直接回设旧格式。BottomBar::setEndlineMenuText 为接缝记录桩。 +// +// 分支清单: +// R1: redo —— 编辑器光标处插入 " " 再原位删除(文档净变化为零,触发变更信号); +// bar->setEndlineMenuText(m_to) +// U1: undo —— bar->setEndlineMenuText(m_from) +// +// 用例映射: +// - Redo_SetsBarToTargetFormat_KeepsDocumentAndCursor → R1(文档/光标不变式) +// - Undo_DirectCall_SetsBarToSourceFormat → U1(redo→undo 序列) +// - Redo_AllFormatPairs_PropagatedExactly(TEST_P) → R1/U1 枚举全值 +// (Unknow=-1 / Unix=0 / Windows=1 三组双向) +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor/dtor/undo/redo)✔ 2 输入等价类(枚举 +// 全值)✔ 3 边界(Unknow 负值)✔ 4 TEST_P ≥3 组 ✔ 5 分支映射 ✔ 6 全分支 ✔ +// 7 无异常路径 ✔ 8 负面(Unknow)✔ 9 状态可逆(undo 回旧格式)✔ 10 无 gMock ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/endlineformatcommond.h" + +// undo/redo 为 protected(QUndoCommand 约定):测试子类以 using 声明提升为可调用 +//(test-types.md 抽象/受保护方法处理),不改变任何被测行为。 +class TestableEndlineFormartCommand : public EndlineFormartCommand +{ +public: + using EndlineFormartCommand::EndlineFormartCommand; + using EndlineFormartCommand::undo; + using EndlineFormartCommand::redo; +}; + +class EndlineFormartCommandTest : public EditorUndoTestBase +{ +}; + +// ---- R1:redo 设置目标格式且文档内容/光标位置净不变 ---- +TEST_F(EndlineFormartCommandTest, Redo_SetsBarToTargetFormat_KeepsDocumentAndCursor) +{ + // Arrange:伪 BottomBar(非虚 setEndlineMenuText 调接缝记录桩) + QWidget barHost; + auto *bar = ut::fakeBottomBar(&barHost); + edit->setPlainText("line1\nline2"); + edit->setTextCursor(ut::cursorAt(edit->document(), 3, 3)); + const int cursorBefore = edit->textCursor().position(); + TestableEndlineFormartCommand cmd(edit, bar, BottomBar::Unix, BottomBar::Windows); + + // Act + cmd.redo(); + + // Assert:文档内容不变(插入与删除相抵),接缝记录目标格式 + EXPECT_EQ(docText(), QString("line1\nline2")); // 文档净变化为零 + EXPECT_EQ(edit->textCursor().position(), cursorBefore); // 光标位置保持 + EXPECT_EQ(seam.setEndlineMenuTextCalls, 1); + EXPECT_EQ(seam.lastEndlineFormat, static_cast(BottomBar::Windows)); + + // Act & Assert:undo 恢复源格式 + cmd.undo(); + EXPECT_EQ(docText(), QString("line1\nline2")); // 状态未损坏 + EXPECT_EQ(seam.setEndlineMenuTextCalls, 2); + EXPECT_EQ(seam.lastEndlineFormat, static_cast(BottomBar::Unix)); +} + +// ---- U1:undo 单独验证(此前有其他 redo 的场景下回设 from) ---- +TEST_F(EndlineFormartCommandTest, Undo_DirectCall_SetsBarToSourceFormat) +{ + // Arrange + QWidget barHost; + auto *bar = ut::fakeBottomBar(&barHost); + TestableEndlineFormartCommand cmd(edit, bar, BottomBar::Windows, BottomBar::Unix); + + // Act + cmd.undo(); + + // Assert:直接 undo 亦设置源格式(不触碰文档) + EXPECT_EQ(seam.setEndlineMenuTextCalls, 1); + EXPECT_EQ(seam.lastEndlineFormat, static_cast(BottomBar::Windows)); + EXPECT_EQ(docText(), QString("")); // 空文档未被触碰 +} + +// ---- 枚举全值 TEST_P:Unknow/Unix/Windows 三组 from→to 双向传播 ---- +namespace { +struct EndlineCase { + BottomBar::EndlineFormat from; + BottomBar::EndlineFormat to; +}; +} // namespace + +class EndlineParamTest : public EditorUndoTestBase, + public ::testing::WithParamInterface { +}; + +TEST_P(EndlineParamTest, Redo_AllFormatPairs_PropagatedExactly) +{ + // Arrange + const EndlineCase &c = GetParam(); + QWidget barHost; + auto *bar = ut::fakeBottomBar(&barHost); + edit->setPlainText("x"); + TestableEndlineFormartCommand cmd(edit, bar, c.from, c.to); + + // Act + cmd.redo(); + + // Assert:redo 恰好传播 to;文档不变 + EXPECT_EQ(seam.setEndlineMenuTextCalls, 1); + EXPECT_EQ(seam.lastEndlineFormat, static_cast(c.to)); + EXPECT_EQ(docText(), QString("x")); + + // Act & Assert:undo 传播 from + cmd.undo(); + EXPECT_EQ(seam.setEndlineMenuTextCalls, 2); + EXPECT_EQ(seam.lastEndlineFormat, static_cast(c.from)); + EXPECT_EQ(docText(), QString("x")); // 状态未损坏 +} + +INSTANTIATE_TEST_SUITE_P(FormatVariants, EndlineParamTest, + ::testing::Values( + EndlineCase{ BottomBar::Unknow, BottomBar::Unix }, + EndlineCase{ BottomBar::Unix, BottomBar::Windows }, + EndlineCase{ BottomBar::Windows, BottomBar::Unknow } + )); + +// ---- D1:经基类指针虚析构删除(deleting destructor 路径)---- +// 注:undo/redo 为 protected,本用例只构造具体基类对象并删除(D0 分派要求动态类型 +// 恰为 EndlineFormartCommand,不能经由测试子类)。 +TEST_F(EndlineFormartCommandTest, Destructor_DeleteViaBasePointer_ReleasesCleanly) +{ + // Arrange:具体类堆上构造(不调用受保护的 undo/redo) + QWidget barHost; + auto *bar = ut::fakeBottomBar(&barHost); + QUndoCommand *cmd = new EndlineFormartCommand(edit, bar, BottomBar::Unix, BottomBar::Windows); + + // Act + delete cmd; + + // Assert:构造/析构全程不触碰文档与底栏接口 + EXPECT_EQ(docText(), QString("")); + EXPECT_EQ(seam.setEndlineMenuTextCalls, 0); +} diff --git a/tests/editor_undo/test_indenttextcommand.cpp b/tests/editor_undo/test_indenttextcommand.cpp new file mode 100644 index 00000000..da2a50fc --- /dev/null +++ b/tests/editor_undo/test_indenttextcommand.cpp @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// IndentTextCommand 单元测试(src/editor/indenttextcommond.cpp)。 +// 多行前加 "\t" 缩进的撤销项。构造函数无条件解引用 m_edit->textCursor(), +// 必须传入真实(空壳)TextEdit。 +// +// 分支清单: +// CT: 构造 —— m_hasselected = edit->textCursor().hasSelection()(编辑器选区状态前置) +// R1: redo 循环 —— startline..endline 逐行 StartOfBlock 插 "\t"(单行/多行边界; +// 末行 NextBlock 失败无害) +// R2: redo 选区恢复 —— !m_hasselected 跳过;单行:选整块;多行:[startpos+1, +// endpos+(endline-startline)+1) +// U1: undo 循环 —— 逐行 StartOfBlock deleteChar(删 "\t") +// U2: undo 选区恢复 —— !m_hasselected 跳过;有选区:[startpos, endpos) +// +// 用例映射: +// - Redo_SingleLineNoSelection_AddsTab → CT(假)+R1/U1(无选区路径) +// - Redo_MultiLineNoSelection_IndentsEveryLine → R1 多行循环 +// - Redo_MultiLineWithSelection_IndentsAndRestoresRange → CT(真)+R2 多行公式 + U2 +// - Redo_SingleLineWithSelection_SelectsWholeBlock → R2 单行分支 + U2 +// - Redo_MultiByteContent_IndentsPreserveText(TEST_P) → 中文/emoji 内容等价类 +// - Undo_AfterRedo_SelectionExactlyRestored → U2 精确选区 +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor/dtor/redo/undo)✔ 2 输入等价类 ✔ +// 3 边界(单行/多行/末行)✔ 4 TEST_P ≥3 组 ✔ 5 分支映射 ✔ 6 全分支 ✔ +// 7 无异常路径 ✔ 8 负面并入无选区边界 ✔ 9 双向可逆断言 ✔ 10 无 gMock ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/indenttextcommond.h" + +class IndentTextCommandTest : public EditorUndoTestBase +{ +}; + +// ---- CT(假)+R1/U1:无选区单行缩进/还原 ---- +TEST_F(IndentTextCommandTest, Redo_SingleLineNoSelection_AddsTab) +{ + // Arrange:编辑器光标无选区(position 0) + edit->setPlainText("abc"); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 0)); + IndentTextCommand cmd(edit, 0, 3, 0, 0); + + // Act + cmd.redo(); + + // Assert:行首插入 "\t" + EXPECT_EQ(docText(), QString("\tabc")); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 状态可逆 +} + +// ---- R1 多行循环:每行加 "\t",末行 NextBlock 失败不影响结果 ---- +TEST_F(IndentTextCommandTest, Redo_MultiLineNoSelection_IndentsEveryLine) +{ + // Arrange + edit->setPlainText("aa\nbb\ncc"); + const QString snapshot = docText(); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 0)); + IndentTextCommand cmd(edit, 0, 8, 0, 2); + + // Act + cmd.redo(); + + // Assert:三行行首各一个 "\t"(含最后一行) + EXPECT_EQ(docText(), QString("\taa\n\tbb\n\tcc")); + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆:逐行删除 "\t" +} + +// ---- CT(真)+R2 多行公式 + U2:带选区缩进后选区按源码公式恢复 ---- +TEST_F(IndentTextCommandTest, Redo_MultiLineWithSelection_IndentsAndRestoresRange) +{ + // Arrange:编辑器选区 [0,8)(覆盖全部三行) + edit->setPlainText("aa\nbb\ncc"); + const QString snapshot = docText(); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 8)); + IndentTextCommand cmd(edit, 0, 8, 0, 2); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("\taa\n\tbb\n\tcc")); + // R2 多行公式:[startpos+1, endpos + (endline-startline) + 1) = [1, 11) + EXPECT_EQ(edit->textCursor().selectionStart(), 1); + EXPECT_EQ(edit->textCursor().selectionEnd(), 8 + 2 + 1); + + // Act & Assert:undo 删 "\t" 并按 [startpos, endpos) 恢复选区 + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(edit->textCursor().selectionStart(), 0); // U2 恢复原始选区 + EXPECT_EQ(edit->textCursor().selectionEnd(), 8); +} + +// ---- R2 单行分支:选整块 ---- +TEST_F(IndentTextCommandTest, Redo_SingleLineWithSelection_SelectsWholeBlock) +{ + // Arrange:选中 "ello" [1,5) + edit->setPlainText("hello"); + edit->setTextCursor(ut::cursorAt(edit->document(), 1, 5)); + IndentTextCommand cmd(edit, 1, 5, 0, 0); + + // Act + cmd.redo(); + + // Assert:R2 单行分支选整块 "\thello" + EXPECT_EQ(docText(), QString("\thello")); + EXPECT_EQ(ut::toLf(edit->textCursor().selectedText()), QString("\thello")); + cmd.undo(); + EXPECT_EQ(docText(), QString("hello")); + EXPECT_EQ(ut::toLf(edit->textCursor().selectedText()), QString("ello")); // U2 原选区 +} + +// ---- 内容等价类 TEST_P:缩进/还原保持多字节文本 ---- +namespace { +struct IndentCase { + QString lineText; +}; +} // namespace + +class IndentParamTest : public EditorUndoTestBase, + public ::testing::WithParamInterface { +}; + +TEST_P(IndentParamTest, Redo_MultiByteContent_IndentsPreserveText) +{ + // Arrange:单行多字节内容,无选区 + edit->setPlainText(GetParam().lineText); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 0)); + IndentTextCommand cmd(edit, 0, GetParam().lineText.size(), 0, 0); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("\t") + GetParam().lineText); + cmd.undo(); + EXPECT_EQ(docText(), GetParam().lineText); // 状态可逆 + EXPECT_EQ(edit->document()->characterCount() - 1, GetParam().lineText.size()); +} + +INSTANTIATE_TEST_SUITE_P(ContentVariants, IndentParamTest, + ::testing::Values( + IndentCase{ QString("plain ascii") }, + IndentCase{ QString::fromUtf8("中文缩进行") }, + IndentCase{ QString::fromUtf8("\xF0\x9F\x98\x80 emoji line") } + )); + +// ---- U2 精确性独立用例:中途选区(非整行)恢复原样 ---- +TEST_F(IndentTextCommandTest, Undo_AfterRedo_SelectionExactlyRestored) +{ + // Arrange:三行文档,选区 [2,7)(跨行中部) + edit->setPlainText("aa\nbb\ncc"); + const QString snapshot = docText(); + edit->setTextCursor(ut::cursorAt(edit->document(), 2, 7)); + IndentTextCommand cmd(edit, 2, 7, 0, 2); + + // Act & Assert + cmd.redo(); + EXPECT_EQ(docText(), QString("\taa\n\tbb\n\tcc")); + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(edit->textCursor().selectionStart(), 2); // U2 精确恢复中途选区 + EXPECT_EQ(edit->textCursor().selectionEnd(), 7); +} + +// ---- D1:经基类指针虚析构删除(deleting destructor 路径) ---- +TEST_F(IndentTextCommandTest, Destructor_DeleteViaBasePointer_ReleasesCleanly) +{ + // Arrange + edit->setPlainText("line"); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 0)); + QUndoCommand *cmd = new IndentTextCommand(edit, 0, 4, 0, 0); + cmd->redo(); + EXPECT_EQ(docText(), QString("\tline")); // 前提:命令已生效 + + // Act + delete cmd; + + // Assert:析构不改变文档状态 + EXPECT_EQ(docText(), QString("\tline")); +} diff --git a/tests/editor_undo/test_insertblockbytextcommand.cpp b/tests/editor_undo/test_insertblockbytextcommand.cpp new file mode 100644 index 00000000..bf551eb0 --- /dev/null +++ b/tests/editor_undo/test_insertblockbytextcommand.cpp @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// InsertBlockByTextCommand 单元测试(src/editor/insertblockbytextcommond.cpp)。 +// 分块插入大文本的撤销项。EditWrapper/Window/BottomBar 以接缝伪指针注入 +//(不构造实例;window()/bottomBar()/isQuit()/setPrintEnabled()/setChildEnabled() +// 均为接缝记录桩),TextEdit 为空壳真实控件。 +// +// 分支清单: +// CT: 构造 —— m_edit 空 / m_text 空 / m_wrapper 空 → 提前返回(三分支); +// 有选区 → 暂存 m_selected 与 m_insertPos +// R1: redo —— treat(true)(断开 cursorPositionChanged);insertByBlock;treat(false) +// I1: insertByBlock —— size <= 1MB:不插入,仅记录 m_delPos = 光标位置 +// I2: size > 1MB —— 循环整块插入(m_wrapper 非空且 !isQuit)+ processEvents; +// 余量 y 插入(y!=0);isQuit=true 时跳过插入;y==0 时跳过余量分支 +// U1: undo —— 删除 [m_delPos - size, m_delPos);m_selected 非空 → 原位重插 +// T: treat —— window/bar 空/非空(setPrintEnabled/setChildEnabled 副作用) +// +// 用例映射: +// - Redo_SmallText_SkipsBlockInsert_UndoTrimsTail → I1 + U1(m_selected 空) +// - Redo_LargeText_InsertsAllBlocksAndUndoClears → I2(n 循环 + y 余量)+ T(非空) +// - Redo_LargeText_ExactMultiple_NoRemainder → I2(y==0 边界) +// - Redo_LargeText_QuitFlag_SkipsInsert → I2(isQuit 分支) +// - Redo_LargeTextWithSelection_FullyReversible → CT(选区) + U1(重插) +// - Ctor_NullWrapper_SmallTextRedoSafe → CT(m_wrapper 空) + I1 +// - Ctor_EmptyText_EarlyReturnThenRedoSafe → CT(m_text 空) + I1 +// - Ctor_NullEdit_EarlyReturnOnly → CT(m_edit 空;仅构造,undo/redo +// 会解引用 m_edit,不在本用例调用——源码契约:该构造参数组合仅供不入栈场景) +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor/dtor/redo/undo;私有 treat/insertByBlock +// 经 redo/undo 间接全覆盖)✔ 2 输入等价类(小/大/整除/退出/选区)✔ 3 边界(1MB 整除、 +// y=0、y!=0、空文本)✔ 4 分组场景各异不参数化(块大小参数无法快速参数化)✔ +// 5 分支映射 ✔ 6 全分支 ✔ 7 无异常路径 ✔ 8 负面(空文本/空 wrapper)✔ +// 9 双向可逆断言 ✔ 10 无 gMock ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/insertblockbytextcommond.h" + +class InsertBlockByTextCommandTest : public EditorUndoTestBase +{ +protected: + static constexpr int kBlockSize = 1 * 1024 * 1024; // 与源码一致的分块阈值 +}; + +// ---- I1 + U1(m_selected 空):小文本 redo 不插入,undo 按固定区间裁剪 ---- +TEST_F(InsertBlockByTextCommandTest, Redo_SmallText_SkipsBlockInsert_UndoTrimsTail) +{ + // Arrange:宿主 QWidget 充当伪 EditWrapper(非虚调用,安全);window/bar 为空 + QWidget wrapperHost; + auto *wrapper = reinterpret_cast(&wrapperHost); + edit->setPlainText("1234567890"); + edit->setTextCursor(ut::cursorAt(edit->document(), 10, 10)); // 光标在文档尾 + InsertBlockByTextCommand cmd("abc", edit, wrapper); + + // Act + cmd.redo(); + + // Assert:小文本不触发分块插入,文档不变;treat 的 bar 为空不调用 setChildEnabled + EXPECT_EQ(docText(), QString("1234567890")); + EXPECT_EQ(seam.setChildEnabledCalls, 0); + EXPECT_EQ(seam.setPrintEnabledCalls, 0); + + // Act & Assert:undo 删除 [m_delPos-3, m_delPos) = [7,10)(源码固定偏移语义) + cmd.undo(); + EXPECT_EQ(docText(), QString("1234567")); +} + +// ---- I2 整块 + 余量 + T(非空):大文本分块插入与清空还原 ---- +TEST_F(InsertBlockByTextCommandTest, Redo_LargeText_InsertsAllBlocksAndUndoClears) +{ + // Arrange:1MB + 10 字符(n=1 整块 + y=10 余量);window/bar 均为伪指针 + QWidget wrapperHost, windowHost, barHost; + auto *wrapper = reinterpret_cast(&wrapperHost); + seam.fakeWindowObject = &windowHost; + seam.fakeBottomBarObject = &barHost; + const QString text = QString(kBlockSize + 10, 'a'); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 0)); + InsertBlockByTextCommand cmd(text, edit, wrapper); + + // Act + cmd.redo(); + + // Assert:全部插入;treat(true)→setPrintEnabled(false)/setChildEnabled(false), + // treat(false)→setPrintEnabled(true)/setChildEnabled(true)(各调用一次) + EXPECT_EQ(edit->document()->characterCount() - 1, kBlockSize + 10); + EXPECT_EQ(docText().at(0), QChar('a')); + EXPECT_EQ(docText().at(docText().size() - 1), QChar('a')); + EXPECT_EQ(seam.setPrintEnabledCalls, 2); + EXPECT_TRUE(seam.lastPrintEnabled); // 最后一次为 treat(false) + EXPECT_EQ(seam.setChildEnabledCalls, 2); + EXPECT_TRUE(seam.lastChildEnabled); + + // Act & Assert:undo 删除全部插入内容 + cmd.undo(); + EXPECT_EQ(docText(), QString("")); // 状态可逆 + EXPECT_EQ(edit->document()->characterCount() - 1, 0); +} + +// ---- I2 y==0 边界:恰好整数倍块大小(2 块),余量分支跳过 ---- +// 注:源码条件为 size > 1MB(严格大于),恰好 1MB 不触发分块插入;取 2MB 覆盖 +// n=2、y=0 的整除边界。 +TEST_F(InsertBlockByTextCommandTest, Redo_LargeText_ExactMultiple_NoRemainder) +{ + // Arrange:恰好 2MB(n=2、y=0) + QWidget wrapperHost; + auto *wrapper = reinterpret_cast(&wrapperHost); + const QString text = QString(2 * kBlockSize, 'b'); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 0)); + InsertBlockByTextCommand cmd(text, edit, wrapper); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(edit->document()->characterCount() - 1, 2 * kBlockSize); + EXPECT_EQ(docText(), text); + cmd.undo(); + EXPECT_EQ(docText(), QString("")); // 状态可逆 +} + +// ---- I2 isQuit 分支:退出标志使分块与余量插入全部跳过 ---- +TEST_F(InsertBlockByTextCommandTest, Redo_LargeText_QuitFlag_SkipsInsert) +{ + // Arrange:预填文档长度 = 文本长度 + 5,光标在文档尾;isQuit=true + QWidget wrapperHost; + auto *wrapper = reinterpret_cast(&wrapperHost); + seam.fakeIsQuit = true; + const QString text = QString(kBlockSize + 5, 'x'); + edit->setPlainText(QString(text.size() + 5, 'c')); + edit->setTextCursor(ut::cursorAt(edit->document(), text.size() + 5, text.size() + 5)); + InsertBlockByTextCommand cmd(text, edit, wrapper); + + // Act + cmd.redo(); + + // Assert:不插入,文档不变;m_delPos = 光标位置(文档尾) + EXPECT_EQ(edit->document()->characterCount() - 1, text.size() + 5); + + // Act & Assert:undo 按固定区间裁剪 [len-size, len) → 剩 5 个字符 + cmd.undo(); + EXPECT_EQ(edit->document()->characterCount() - 1, 5); + EXPECT_EQ(docText(), QString("ccccc")); +} + +// ---- CT(选区) + U1(重插):大文本替换选区后完全可逆 ---- +TEST_F(InsertBlockByTextCommandTest, Redo_LargeTextWithSelection_FullyReversible) +{ + // Arrange:选中 "hello",大文本插入将替换选区 + QWidget wrapperHost; + auto *wrapper = reinterpret_cast(&wrapperHost); + edit->setPlainText("hello world"); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 5)); + const QString text = QString(kBlockSize + 10, 'z'); + InsertBlockByTextCommand cmd(text, edit, wrapper); + + // Act + cmd.redo(); + + // Assert:选区被替换为插入文本(insertText 在选区光标上执行) + EXPECT_EQ(edit->document()->characterCount() - 1, text.size() + 6); // z...z + " world" + EXPECT_EQ(docText().right(6), QString(" world")); + + // Act & Assert:undo 删除插入文本并原位重插 "hello" + cmd.undo(); + EXPECT_EQ(docText(), QString("hello world")); // 完全可逆 +} + +// ---- CT(m_wrapper 空) + I1:空 wrapper 构造提前返回,redo/undo 仍走固定偏移 ---- +TEST_F(InsertBlockByTextCommandTest, Ctor_NullWrapper_SmallTextRedoSafe) +{ + // Arrange + edit->setPlainText("abcdef"); + edit->setTextCursor(ut::cursorAt(edit->document(), 6, 6)); + InsertBlockByTextCommand cmd("XY", edit, nullptr); + + // Act + cmd.redo(); + + // Assert:treat 的 m_wrapper 分支整体跳过 + EXPECT_EQ(docText(), QString("abcdef")); + EXPECT_EQ(seam.setChildEnabledCalls, 0); + cmd.undo(); // m_delPos 已由 redo 记录 + EXPECT_EQ(docText(), QString("abcd")); // [6-2,6) 被裁剪 +} + +// ---- CT(m_text 空):空文本提前返回;redo 后 m_delPos 有值,undo 安全 ---- +TEST_F(InsertBlockByTextCommandTest, Ctor_EmptyText_EarlyReturnThenRedoSafe) +{ + // Arrange + QWidget wrapperHost; + auto *wrapper = reinterpret_cast(&wrapperHost); + edit->setPlainText("keep"); + edit->setTextCursor(ut::cursorAt(edit->document(), 4, 4)); + InsertBlockByTextCommand cmd("", edit, wrapper); + + // Act + cmd.redo(); + + // Assert:空文本无插入;m_delPos = 光标位置 4 + EXPECT_EQ(docText(), QString("keep")); + + // Act & Assert:undo 删除 [4-0,4) 空范围 → 文档不变(强异常安全) + cmd.undo(); + EXPECT_EQ(docText(), QString("keep")); +} + +// ---- CT(m_edit 空):仅覆盖构造提前返回(redo/undo 会解引用 m_edit,不调用) ---- +TEST_F(InsertBlockByTextCommandTest, Ctor_NullEdit_EarlyReturnOnly) +{ + // Arrange & Act:空 edit 构造直接提前返回,无任何文档访问 + QWidget wrapperHost; + auto *wrapper = reinterpret_cast(&wrapperHost); + InsertBlockByTextCommand cmd("text", nullptr, wrapper); + + // Assert:命令对象可用(id/text 等 QUndoCommand 基类接口不受影响) + EXPECT_EQ(cmd.text(), QString("")); + EXPECT_EQ(docText(), QString("")); // 编辑器状态未受影响 +} + +// ---- D1:经基类指针虚析构删除(deleting destructor 路径) ---- +TEST_F(InsertBlockByTextCommandTest, Destructor_DeleteViaBasePointer_ReleasesCleanly) +{ + // Arrange:小文本 redo 记录 m_delPos 后保持文档不变 + QWidget wrapperHost; + auto *wrapper = reinterpret_cast(&wrapperHost); + edit->setPlainText("12345"); + edit->setTextCursor(ut::cursorAt(edit->document(), 5, 5)); + QUndoCommand *cmd = new InsertBlockByTextCommand("ab", edit, wrapper); + cmd->redo(); + EXPECT_EQ(docText(), QString("12345")); // 前提:小文本 redo 无插入 + + // Act + delete cmd; + + // Assert:析构不改变文档状态 + EXPECT_EQ(docText(), QString("12345")); +} diff --git a/tests/editor_undo/test_inserttextundocommand.cpp b/tests/editor_undo/test_inserttextundocommand.cpp new file mode 100644 index 00000000..63c3c669 --- /dev/null +++ b/tests/editor_undo/test_inserttextundocommand.cpp @@ -0,0 +1,280 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// InsertTextUndoCommand 单元测试(src/editor/inserttextundocommand.cpp)。 +// +// 分支清单(来源:inserttextundocommand.cpp InsertTextUndoCommand 部分): +// CT1: 单光标构造 —— m_sInsertText.replace("\r\n","\n")(CRLF 归一化) +// CT2: 列编辑构造 —— 遍历 selections 填充 m_replaces(ColumnReplaceNode 结构体随用例覆盖) +// R1: redo 单光标 —— m_textCursor.hasSelection() 真:暂存 m_selectText + removeSelectedText +// R2: redo 单光标 —— 无选区:直接插入;m_pEdit 空/非空(setTextCursor 分支) +// R3: redo 列编辑 —— 循环替换各选区;leftToRight 真/假(选区方向分支); +// columnOffset 累加;m_pEdit 空/非空 +// U1: undo 单光标 —— 删除 [begin,end);m_selectText 空/非空(恢复原选中文本分支); +// m_pEdit 空/非空 +// U2: undo 列编辑 —— 循环恢复 originText;leftToRight 真/假;restoreColumnEditSelection +// ID: id() —— 列编辑选区空 → Utils::IdInsert;非空 → Utils::IdColumnEditInsert +// +// 用例映射: +// - Redo_SingleCursor_InsertsTextAndMovesEditCursor → R2(非空 edit)/CT1 +// - Redo_SingleCursor_NullEdit_StillMutatesDocument → R2(空 edit 分支) +// - Redo_SingleCursorWithSelection_ReplacesAndUndoRestores → R1 + U1(非空 selectText) +// - Redo_MultiByteText_Variants_InsertExactly(TEST_P) → CT1 + R2 输入等价类 +// - Ctor_CrlfText_NormalizedToLf → CT1(CRLF 边界) +// - Undo_BeforeRedo_EmptyDoc_NoStateChange → U1(m_selectText 空 + 空文档边界) +// - Redo_ColumnSelections_ReplacesEachAndUndoRestores → CT2/R3/U2 + ID(列) +// - Redo_ColumnMultiByteAndUnevenSizes_ReverseBothWays → R3/U2(多字节 + 不等长 origin) +// - Redo_ColumnRightToLeft_KeepsOrientation → R3/U2 leftToRight=false +// - Id_SingleMode_ReturnsIdInsert / Id_ColumnMode_ReturnsIdColumnEditInsert → ID +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor×2/undo/redo/id)✔ +// 2 输入等价类:ASCII/中文/emoji/空/CRLF/空文档 ✔ 3 边界显式(空文档、空文本、 +// 列首尾选区、不等长替换)✔ 4 TEST_P ≥3 组(多字节文本)✔ 5 分支映射 ✔ +// 6 全分支覆盖(上表)✔ 7 无异常路径(源码无 throw)✔ 8 负面:空文档/空选区 ✔ +// 9 双向可逆断言(执行前快照→redo→断言→undo→断言还原)✔ +// 10 依赖为 Qt 内置类 + 项目内空壳(stub 接缝),无 gMock 注入点 ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/inserttextundocommand.h" + +#include "../../../src/common/utils.h" + +#include + +class InsertTextUndoCommandTest : public EditorUndoTestBase +{ +}; + +// ---- R2 正常路径:无选区光标插入,edit 非空时编辑器光标移到插入末尾 ---- +TEST_F(InsertTextUndoCommandTest, Redo_SingleCursor_InsertsTextAndMovesEditCursor) +{ + // Arrange + edit->setPlainText("hello"); + QTextCursor cursor = ut::cursorAt(edit->document(), 5, 5); + InsertTextUndoCommand cmd(cursor, " world", edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("hello world")); // 文档状态 + EXPECT_EQ(edit->textCursor().position(), 11); // 副作用:编辑器光标位于插入末尾 + + // Act & Assert:undo 还原(状态可逆性) + cmd.undo(); + EXPECT_EQ(docText(), QString("hello")); + EXPECT_EQ(edit->textCursor().position(), 5); // 撤销后光标回到插入起点 +} + +// ---- R2 空 edit 分支:文档仍被修改,不触碰编辑器 ---- +TEST_F(InsertTextUndoCommandTest, Redo_SingleCursor_NullEdit_StillMutatesDocument) +{ + // Arrange + edit->setPlainText("abc"); + QTextCursor cursor = ut::cursorAt(edit->document(), 3, 3); + InsertTextUndoCommand cmd(cursor, "XY", nullptr); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("abcXY")); // 光标绑定文档,插入仍生效 + const int posBeforeUndo = edit->textCursor().position(); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 可逆 + EXPECT_EQ(edit->textCursor().position(), posBeforeUndo); // 空 edit 分支不移动编辑器光标 +} + +// ---- R1 + U1 非空 m_selectText:选中文本被替换且 undo 精确恢复(含选中态) ---- +TEST_F(InsertTextUndoCommandTest, Redo_SingleCursorWithSelection_ReplacesAndUndoRestores) +{ + // Arrange + edit->setPlainText("hello world"); + const QString snapshot = docText(); + QTextCursor cursor = ut::cursorAt(edit->document(), 6, 11); // 选中 "world" + InsertTextUndoCommand cmd(cursor, "there", edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("hello there")); // 选中文本被替换 + EXPECT_EQ(edit->textCursor().position(), 11); + + // Act & Assert:undo 恢复原文本并重新选中原文 + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(edit->textCursor().selectedText(), QString("world")); // 原选中文本恢复为选中态 +} + +// ---- CT1 + R2 输入等价类:ASCII / 中文 / emoji(代理对)多字节文本 ---- +namespace { +struct InsertTextCase { + QString insertText; + QString expectedDoc; +}; +} // namespace + +class InsertTextParamTest : public EditorUndoTestBase, + public ::testing::WithParamInterface { +}; + +TEST_P(InsertTextParamTest, Redo_MultiByteText_Variants_InsertExactly) +{ + // Arrange + edit->setPlainText("A"); + QTextCursor cursor = ut::cursorAt(edit->document(), 1, 1); + InsertTextUndoCommand cmd(cursor, GetParam().insertText, nullptr); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), GetParam().expectedDoc); // 多字节按 QChar 语义精确插入 + cmd.undo(); + EXPECT_EQ(docText(), QString("A")); // 多字节长度回退精确 + EXPECT_EQ(edit->document()->characterCount() - 1, 1); // 文档长度还原(不含末尾段落符) +} + +INSTANTIATE_TEST_SUITE_P(TextVariants, InsertTextParamTest, + ::testing::Values( + InsertTextCase{ QString::fromUtf8("中文"), QString::fromUtf8("A中文") }, + InsertTextCase{ QString::fromUtf8("\xF0\x9F\x98\x80!"), QString::fromUtf8("A\xF0\x9F\x98\x80!") }, // emoji 代理对 + InsertTextCase{ QString("xyz"), QString("Axyz") } + )); + +// ---- CT1:CRLF 文本在构造时归一化为 LF ---- +TEST_F(InsertTextUndoCommandTest, Ctor_CrlfText_NormalizedToLf) +{ + // Arrange + edit->setPlainText("line"); + QTextCursor cursor = ut::cursorAt(edit->document(), 4, 4); + InsertTextUndoCommand cmd(cursor, "a\r\nb", edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("linea\nb")); // \r\n 被替换为 \n(真换行) + EXPECT_EQ(edit->document()->blockCount(), 2); // "a\nb" 产生一个换行分段 + cmd.undo(); + EXPECT_EQ(docText(), QString("line")); +} + +// ---- U1 边界:未执行 redo 直接 undo(空文档、空 m_selectText)不产生状态变化 ---- +TEST_F(InsertTextUndoCommandTest, Undo_BeforeRedo_EmptyDoc_NoStateChange) +{ + // Arrange:空文档 + 无选区光标,m_beginPostion/m_endPostion 均为 0 + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 0); + InsertTextUndoCommand cmd(cursor, "t", edit); + + // Act + cmd.undo(); + + // Assert:空文档上 [0,0) deleteChar 无操作、m_selectText 为空不插入 + EXPECT_EQ(docText(), QString("")); // 强异常安全:状态未损坏 + EXPECT_EQ(edit->textCursor().position(), 0); +} + +// ---- CT2/R3/U2:列编辑多选区逐个替换 + 双向可逆 + restoreColumnEditSelection 副作用 ---- +TEST_F(InsertTextUndoCommandTest, Redo_ColumnSelections_ReplacesEachAndUndoRestores) +{ + // Arrange:三行等长选区 + edit->setPlainText("aa\nbb\ncc"); + const QString snapshot = docText(); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 2) + << ut::makeSelection(edit->document(), 3, 5) + << ut::makeSelection(edit->document(), 6, 8); + InsertTextUndoCommand cmd(selections, "X", edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("X\nX\nX")); // 每个选区替换为插入文本 + EXPECT_EQ(edit->textCursor().position(), 5); // 编辑器光标位于最后一个新选区末尾 + + // Act & Assert:undo 恢复每个选区原文与方向(内部选区副本经接缝可观测) + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 1); // 副作用:列选区恢复被调用一次 + ASSERT_EQ(seam.lastRestoredSelections.size(), 3); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[0].cursor.selectedText()), QString("aa")); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[1].cursor.selectedText()), QString("bb")); + EXPECT_EQ(ut::toLf(seam.lastRestoredSelections[2].cursor.selectedText()), QString("cc")); +} + +// ---- R3/U2 多字节 + 不等长 origin(columnOffset 为负的累加场景) ---- +TEST_F(InsertTextUndoCommandTest, Redo_ColumnMultiByteAndUnevenSizes_ReverseBothWays) +{ + // Arrange:选区长度 2 / 4 / 1,插入长度 1 —— columnOffset 依次 -1、-4 + edit->setPlainText(QString::fromUtf8("你好\nabcd\nx")); + const QString snapshot = docText(); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 2) + << ut::makeSelection(edit->document(), 3, 7) + << ut::makeSelection(edit->document(), 8, 9); + InsertTextUndoCommand cmd(selections, QString::fromUtf8("中"), edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString::fromUtf8("中\n中\n中")); // 负 offset 累加后各选区仍精确命中 + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆(含中文多字节) + EXPECT_EQ(seam.restoreColumnEditSelectionCalls, 1); +} + +// ---- R3/U2 leftToRight=false(anchor > position 的反向选区)方向保持 ---- +TEST_F(InsertTextUndoCommandTest, Redo_ColumnRightToLeft_KeepsOrientation) +{ + // Arrange:反向选区 anchor=4 position=1(选中 "ell") + edit->setPlainText("hello"); + QList selections; + selections << ut::makeSelection(edit->document(), 4, 1); + InsertTextUndoCommand cmd(selections, "XY", edit); + + // Act + cmd.redo(); + + // Assert:反向选区替换后文档正确,编辑器光标落在新选区末尾("hXYo" 中 Y 后 = 3) + EXPECT_EQ(docText(), QString("hXYo")); + EXPECT_EQ(edit->textCursor().position(), 3); + + // Act & Assert:undo 后经接缝观测内部选区仍为反向且选中原文本 + cmd.undo(); + EXPECT_EQ(docText(), QString("hello")); + ASSERT_EQ(seam.lastRestoredSelections.size(), 1); + const QTextCursor &restored = seam.lastRestoredSelections[0].cursor; + EXPECT_GT(restored.anchor(), restored.position()); // 方向保持:anchor(4) > position(1) + EXPECT_EQ(ut::toLf(restored.selectedText()), QString("ell")); +} + +// ---- ID:单光标模式返回 Utils::IdInsert ---- +TEST_F(InsertTextUndoCommandTest, Id_SingleMode_ReturnsIdInsert) +{ + // Arrange + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 0); + InsertTextUndoCommand cmd(cursor, "t", nullptr); + + // Assert + EXPECT_EQ(cmd.id(), Utils::IdInsert); // 期望 257(IdUnknown=256 后第一个) + EXPECT_NE(cmd.id(), Utils::IdColumnEditInsert); // 不得带列编辑标志位 +} + +// ---- ID:列编辑模式返回 Utils::IdColumnEditInsert ---- +TEST_F(InsertTextUndoCommandTest, Id_ColumnMode_ReturnsIdColumnEditInsert) +{ + // Arrange + edit->setPlainText("ab"); + QList selections; + selections << ut::makeSelection(edit->document(), 0, 1); + InsertTextUndoCommand cmd(selections, "Z", nullptr); + + // Assert + EXPECT_EQ(cmd.id(), Utils::IdColumnEditInsert); // 期望 IdColumnEdit | IdInsert + EXPECT_EQ(cmd.id(), Utils::IdColumnEdit | Utils::IdInsert); +} diff --git a/tests/editor_undo/test_midbuttoninserttextundocommand.cpp b/tests/editor_undo/test_midbuttoninserttextundocommand.cpp new file mode 100644 index 00000000..ec2e3020 --- /dev/null +++ b/tests/editor_undo/test_midbuttoninserttextundocommand.cpp @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// MidButtonInsertTextUndoCommand 单元测试(src/editor/inserttextundocommand.cpp)。 +// 鼠标中键插入撤销项:中键插入不覆盖已选中文本(与 InsertTextUndoCommand 的差异点)。 +// +// 分支清单: +// CT: 构造 —— CRLF 归一化;m_beginPostion/m_endPostion 由 cursor.position() 与文本 +// 长度即时计算(不依赖 redo) +// R1: redo —— 插入文本并选中;m_pEdit 空/非空(setTextCursor 分支) +// U1: undo —— 删除 [begin,end);m_pEdit 空/非空 +// +// 用例映射: +// - Redo_InsertsAtCursor_MovesEditCursorAndUndoRestores → CT/R1/U1(edit 非空) +// - Redo_MidButtonInsert_DoesNotOverwriteExistingText → R1(中键语义:不删除已有文本) +// - Ctor_EmptyTextBoundary_ZeroRangeNoStateChange → CT(空文本边界 + U1 空范围) +// - Redo_TextVariants_ExactPositions(TEST_P) → CT/R1(ASCII/中文/emoji) +// - Redo_NullEdit_StillMutatesDocument → R1/U1(空 edit 分支) +// - Undo_BeforeRedo_EmptyDoc_NoStateChange → U1(空文档边界) +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor/undo/redo)✔ 2 输入等价类 ✔ +// 3 边界(空文本/空文档/代理对)✔ 4 TEST_P ≥3 组 ✔ 5 分支映射 ✔ 6 全分支 ✔ +// 7 无异常路径 ✔ 8 负面(空文本)✔ 9 双向可逆断言 ✔ 10 空壳接缝无 gMock ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/inserttextundocommand.h" + +class MidButtonInsertTextUndoCommandTest : public EditorUndoTestBase +{ +}; + +// ---- CT/R1/U1 正常路径:插入、编辑器光标移动、undo 精确还原 ---- +TEST_F(MidButtonInsertTextUndoCommandTest, Redo_InsertsAtCursor_MovesEditCursorAndUndoRestores) +{ + // Arrange + edit->setPlainText("abc"); + QTextCursor cursor = ut::cursorAt(edit->document(), 3, 3); + MidButtonInsertTextUndoCommand cmd(cursor, "XY", edit); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("abcXY")); // 插入生效 + EXPECT_EQ(edit->textCursor().position(), 5); // 编辑器光标移到插入末尾 + + // Act & Assert:undo 删除 [3,5) 还原 + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 状态可逆 + EXPECT_EQ(edit->textCursor().position(), 3); // 撤销后光标停在插入起点 +} + +// ---- R1 中键语义:插入不覆盖(对比 InsertTextUndoCommand 会先删除选中文本) ---- +TEST_F(MidButtonInsertTextUndoCommandTest, Redo_MidButtonInsert_DoesNotOverwriteExistingText) +{ + // Arrange:光标 position=1(无选区,中键插入点) + edit->setPlainText("abc"); + QTextCursor cursor = ut::cursorAt(edit->document(), 1, 1); + MidButtonInsertTextUndoCommand cmd(cursor, "X", edit); + + // Act + cmd.redo(); + + // Assert:字符 'a' 与 'b' 均保留(未被删除/替换) + EXPECT_EQ(docText(), QString("aXbc")); // 中键插入只插入不覆盖 + EXPECT_EQ(edit->textCursor().position(), 2); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 状态可逆 +} + +// ---- CT 空文本边界:begin==end==position,redo 无插入;undo 的 deleteChar 在 +// 零长选区上按 QTextCursor 语义前向删除一个字符(QUndoCommand 契约:undo 前应有 redo) ---- +TEST_F(MidButtonInsertTextUndoCommandTest, Ctor_EmptyTextBoundary_ZeroRangeNoStateChange) +{ + // Arrange:光标 position=1,插入文本为空串 + edit->setPlainText("ab"); + QTextCursor cursor = ut::cursorAt(edit->document(), 1, 1); + MidButtonInsertTextUndoCommand cmd(cursor, "", edit); + + // Act + cmd.redo(); + + // Assert:构造时按空文本计算 begin=end=1,插入空串无变化 + EXPECT_EQ(docText(), QString("ab")); + EXPECT_EQ(edit->textCursor().position(), 1); + + // Act & Assert:undo 删除 [1,1) 零长范围 —— deleteChar 前向删除光标处字符 'b' + cmd.undo(); + EXPECT_EQ(docText(), QString("a")); // QTextCursor::deleteChar 实际语义 + EXPECT_EQ(edit->textCursor().position(), 1); +} + +// ---- CT/R1 输入等价类:ASCII / 中文 / emoji(UTF-16 代理对占 2 个 QChar) ---- +namespace { +struct MidButtonTextCase { + QString text; + int expectedEnd; // 插入后 endPostion = position + QString::length()(UTF-16 单位数) +}; +} // namespace + +class MidButtonTextParamTest : public EditorUndoTestBase, + public ::testing::WithParamInterface { +}; + +TEST_P(MidButtonTextParamTest, Redo_TextVariants_ExactPositions) +{ + // Arrange + edit->setPlainText("A"); + QTextCursor cursor = ut::cursorAt(edit->document(), 1, 1); + MidButtonInsertTextUndoCommand cmd(cursor, GetParam().text, edit); + + // Act + cmd.redo(); + + // Assert:endPostion 按 UTF-16 代码单位计数,编辑器光标精确落在文本尾 + EXPECT_EQ(edit->textCursor().position(), GetParam().expectedEnd); + EXPECT_EQ(docText(), QString("A") + GetParam().text); + + cmd.undo(); + EXPECT_EQ(docText(), QString("A")); // 多字节回退精确 + EXPECT_EQ(edit->document()->characterCount() - 1, 1); +} + +INSTANTIATE_TEST_SUITE_P(TextVariants, MidButtonTextParamTest, + ::testing::Values( + MidButtonTextCase{ QString("bcd"), 4 }, + MidButtonTextCase{ QString::fromUtf8("中文"), 3 }, + MidButtonTextCase{ QString::fromUtf8("\xF0\x9F\x98\x80"), 3 } // emoji 代理对长度 2 + )); + +// ---- R1/U1 空 edit 分支:文档仍被修改 ---- +TEST_F(MidButtonInsertTextUndoCommandTest, Redo_NullEdit_StillMutatesDocument) +{ + // Arrange + edit->setPlainText("abc"); + const int initialPos = edit->textCursor().position(); // 空文档起始光标 0 + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 0); + MidButtonInsertTextUndoCommand cmd(cursor, "Z", nullptr); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("Zabc")); + cmd.undo(); + EXPECT_EQ(docText(), QString("abc")); // 状态可逆 + // 空 edit 不调用 setTextCursor;编辑器光标随文档增删自动调整回初始位置 + EXPECT_EQ(edit->textCursor().position(), initialPos); +} + +// ---- U1 空文档边界:未 redo 直接 undo,[0,0) deleteChar 无操作 ---- +TEST_F(MidButtonInsertTextUndoCommandTest, Undo_BeforeRedo_EmptyDoc_NoStateChange) +{ + // Arrange:空文档,构造时 begin=end=0 + QTextCursor cursor = ut::cursorAt(edit->document(), 0, 0); + MidButtonInsertTextUndoCommand cmd(cursor, "t", edit); + + // Act + cmd.undo(); + + // Assert + EXPECT_EQ(docText(), QString("")); // 状态未损坏 + EXPECT_EQ(edit->textCursor().position(), 0); +} diff --git a/tests/editor_undo/test_replaceallcommand.cpp b/tests/editor_undo/test_replaceallcommand.cpp new file mode 100644 index 00000000..4e20efb9 --- /dev/null +++ b/tests/editor_undo/test_replaceallcommand.cpp @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// ReplaceAllCommand 单元测试(src/editor/replaceallcommond.cpp)。 +// 全部替换撤销项:redo/undo 均为“全选删除 + 插入”,完全对称。 +// +// 分支清单: +// R1: redo —— setPosition(0) + End KeepAnchor → deleteChar → insertText(m_newText) +// U1: undo —— 同构,insertText(m_oldText) +// (两方法无条件分支;输入维度为 old/new 文本内容) +// +// 用例映射: +// - Redo_ReplacesEntireDocument_UndoRestoresOriginal → R1/U1 主路径 +// - Redo_EmptyNewText_ClearsDocument → R1(newText 空边界) +// - Redo_EmptyOldTextOnEmptyDoc_UndoClearsAgain → U1(oldText 空边界) +// - Redo_TextVariants_RoundTripExact(TEST_P) → ASCII/中文/emoji 等价类 +// - Redo_RepeatCycles_StaysReversible → 多轮幂等 +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor/dtor/redo/undo)✔ 2 输入等价类 ✔ +// 3 边界(空文本/空文档)✔ 4 TEST_P ≥3 组 ✔ 5 分支映射 ✔ 6 全分支 ✔ +// 7 无异常路径 ✔ 8 负面(空文本)✔ 9 双向可逆断言 ✔ 10 无 gMock ✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/replaceallcommond.h" + +class ReplaceAllCommandTest : public EditorUndoTestBase +{ +}; + +// ---- R1/U1 主路径:整文档替换与还原 ---- +TEST_F(ReplaceAllCommandTest, Redo_ReplacesEntireDocument_UndoRestoresOriginal) +{ + // Arrange:光标绑定编辑器文档 + edit->setPlainText("foo bar foo"); + QString oldText = docText(); + QString newText("baz"); + ReplaceAllCommand cmd(oldText, newText, edit->textCursor()); + + // Act + cmd.redo(); + + // Assert:文档整体替换为新文本 + EXPECT_EQ(docText(), QString("baz")); + EXPECT_EQ(edit->document()->characterCount() - 1, 3); + + // Act & Assert:undo 精确还原 + cmd.undo(); + EXPECT_EQ(docText(), QString("foo bar foo")); // 状态可逆 + EXPECT_EQ(edit->document()->characterCount() - 1, 11); +} + +// ---- R1 空 newText 边界:清空文档 ---- +TEST_F(ReplaceAllCommandTest, Redo_EmptyNewText_ClearsDocument) +{ + // Arrange + edit->setPlainText("content"); + QString oldText = docText(); + QString newText(""); + ReplaceAllCommand cmd(oldText, newText, edit->textCursor()); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("")); + EXPECT_EQ(edit->document()->characterCount() - 1, 0); // 仅剩末尾段落符 + cmd.undo(); + EXPECT_EQ(docText(), QString("content")); // 状态可逆 +} + +// ---- U1 空 oldText 边界:空文档起步,undo 清回空 ---- +TEST_F(ReplaceAllCommandTest, Redo_EmptyOldTextOnEmptyDoc_UndoClearsAgain) +{ + // Arrange:空文档(oldText 为空) + QString oldText(""); + QString newText("inserted"); + ReplaceAllCommand cmd(oldText, newText, edit->textCursor()); + + // Act + cmd.redo(); + + // Assert:空文档上插入新文本 + EXPECT_EQ(docText(), QString("inserted")); + cmd.undo(); + EXPECT_EQ(docText(), QString("")); // 状态可逆:清回空 + EXPECT_EQ(edit->document()->characterCount() - 1, 0); +} + +// ---- 输入等价类 TEST_P:ASCII/中文/emoji(含代理对)整替往返 ---- +namespace { +struct ReplaceAllCase { + QString original; + QString replacement; +}; +} // namespace + +class ReplaceAllParamTest : public EditorUndoTestBase, + public ::testing::WithParamInterface { +}; + +TEST_P(ReplaceAllParamTest, Redo_TextVariants_RoundTripExact) +{ + // Arrange + const ReplaceAllCase &c = GetParam(); + edit->setPlainText(c.original); + QString oldText = docText(); + QString newText = c.replacement; // 构造参数为非 const 引用 + ReplaceAllCommand cmd(oldText, newText, edit->textCursor()); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), c.replacement); + EXPECT_EQ(edit->document()->characterCount() - 1, c.replacement.size()); + + cmd.undo(); + EXPECT_EQ(docText(), c.original); // 多字节往返精确 + EXPECT_EQ(edit->document()->characterCount() - 1, c.original.size()); +} + +INSTANTIATE_TEST_SUITE_P(TextVariants, ReplaceAllParamTest, + ::testing::Values( + ReplaceAllCase{ QString("abcabc"), QString("xyz") }, + ReplaceAllCase{ QString::fromUtf8("你好,世界"), QString::fromUtf8("再见") }, + ReplaceAllCase{ QString::fromUtf8("old\xF0\x9F\x98\x80"), QString::fromUtf8("new\xF0\x9F\x8E\x89") } + )); + +// ---- 多轮幂等 ---- +TEST_F(ReplaceAllCommandTest, Redo_RepeatCycles_StaysReversible) +{ + // Arrange + edit->setPlainText("0123456789"); + QString oldText = docText(); + QString newText("X"); + ReplaceAllCommand cmd(oldText, newText, edit->textCursor()); + + // Act & Assert + for (int round = 0; round < 3; ++round) { + cmd.redo(); + EXPECT_EQ(docText(), QString("X")) << "round " << round; + cmd.undo(); + EXPECT_EQ(docText(), QString("0123456789")) << "round " << round; + } +} + +// ---- D1:经基类指针虚析构删除(deleting destructor 路径) ---- +TEST_F(ReplaceAllCommandTest, Destructor_DeleteViaBasePointer_ReleasesCleanly) +{ + // Arrange + edit->setPlainText("origin"); + QString oldText = docText(); + QString newText("n"); + QUndoCommand *cmd = new ReplaceAllCommand(oldText, newText, edit->textCursor()); + cmd->redo(); + EXPECT_EQ(docText(), QString("n")); // 前提:命令已生效 + + // Act + delete cmd; + + // Assert:析构不改变文档状态 + EXPECT_EQ(docText(), QString("n")); +} diff --git a/tests/editor_undo/test_undolist.cpp b/tests/editor_undo/test_undolist.cpp new file mode 100644 index 00000000..4aa7616d --- /dev/null +++ b/tests/editor_undo/test_undolist.cpp @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// UndoList 单元测试(src/editor/undolist.cpp)。 +// 组合命令:appendCom 追加(忽略空指针);undo 逆序执行;redo 正序执行; +// 析构逐个 delete 并置空。 +// +// 分支清单: +// A1: appendCom —— com 非空 → 追加;空 → 忽略 +// U1: undo —— 逆序遍历(rbegin..rend);processed%10==0 || processed==total 进度日志分支 +// R1: redo —— 正序遍历(foreach);同上进度分支 +// D1: 析构 —— 逐个非空判断 + delete + 置空 + clear +// +// 用例映射: +// - Undo_ThreeCommands_ExecutesInReverseOrder → U1(逆序核心语义) +// - Redo_ThreeCommands_ExecutesInForwardOrder → R1(正序核心语义) +// - Undo_EmptyList_Noop → U1(0 次循环边界) +// - AppendCom_NullPointer_Ignored → A1(空指针分支) +// - Undo_TenCommands_CoversProgressLogBranch → U1/R1(processed%10==0 分支) +// - Destructor_AppendedCommands_AllDeleted → D1(实例存活计数归零) +// - UndoRedo_RoundTrip_OrderStableAfterCycles → U1+R1 组合往返 +// +// 最小清单自检:1 每公开方法 ≥1 用例(ctor/dtor/appendCom/undo/redo)✔ +// 2 输入等价类(0/1/3/10 条命令、空指针)✔ 3 边界(空列表、10 条、重复追加)✔ +// 4 同质多组以数量维度并入专用用例 ✔ 5 分支映射 ✔ 6 全分支 ✔ 7 无异常路径 ✔ +// 8 负面(空指针/空列表)✔ 9 状态可逆(undo/redo 往返顺序稳定)✔ +// 10 无 gMock(QUndoCommand 组合,用记录子命令)✔ + +#include "editor_undo_helpers.h" + +#include "../../../src/editor/undolist.h" + +// undo/redo 为 protected(QUndoCommand 约定):测试子类以 using 声明提升为可调用。 +class TestableUndoList : public UndoList +{ +public: + using UndoList::UndoList; + using UndoList::undo; + using UndoList::redo; +}; + +#include + +namespace { +// 存活计数子命令:构造/析构维护 fixture 成员 aliveCount(非 static,无跨用例污染)。 +class CountingCommand : public QUndoCommand +{ +public: + explicit CountingCommand(int *aliveCount) + : m_aliveCount(aliveCount) + { + ++(*m_aliveCount); + } + + ~CountingCommand() override { --(*m_aliveCount); } + + void undo() override {} + void redo() override {} + +private: + int *m_aliveCount = nullptr; +}; +} // namespace + +class UndoListTest : public EditorUndoTestBase +{ +}; + +// ---- U1:三条命令逆序撤销 ---- +TEST_F(UndoListTest, Undo_ThreeCommands_ExecutesInReverseOrder) +{ + // Arrange + QStringList orderLog; + TestableUndoList list; + list.appendCom(new RecordingCommand(&orderLog, "first")); + list.appendCom(new RecordingCommand(&orderLog, "second")); + list.appendCom(new RecordingCommand(&orderLog, "third")); + + // Act + list.undo(); + + // Assert:逆序 third → second → first + ASSERT_EQ(orderLog.size(), 3); + EXPECT_EQ(orderLog.at(0), QString("third:undo")); + EXPECT_EQ(orderLog.at(1), QString("second:undo")); + EXPECT_EQ(orderLog.at(2), QString("first:undo")); +} + +// ---- R1:三条命令正序重做 ---- +TEST_F(UndoListTest, Redo_ThreeCommands_ExecutesInForwardOrder) +{ + // Arrange + QStringList orderLog; + TestableUndoList list; + list.appendCom(new RecordingCommand(&orderLog, "first")); + list.appendCom(new RecordingCommand(&orderLog, "second")); + list.appendCom(new RecordingCommand(&orderLog, "third")); + + // Act + list.redo(); + + // Assert:正序 first → second → third + ASSERT_EQ(orderLog.size(), 3); + EXPECT_EQ(orderLog.at(0), QString("first:redo")); + EXPECT_EQ(orderLog.at(1), QString("second:redo")); + EXPECT_EQ(orderLog.at(2), QString("third:redo")); +} + +// ---- U1/R1 0 次循环边界:空列表安全无操作 ---- +TEST_F(UndoListTest, Undo_EmptyList_Noop) +{ + // Arrange + TestableUndoList list; + + // Act:空列表 undo/redo 不应崩溃或产生任何副作用 + list.undo(); + list.redo(); + + // Assert:编辑器状态未受影响(强异常安全) + EXPECT_EQ(docText(), QString("")); + EXPECT_EQ(edit->textCursor().position(), 0); // 光标亦未被触碰 +} + +// ---- A1 空指针分支 ---- +TEST_F(UndoListTest, AppendCom_NullPointer_Ignored) +{ + // Arrange + QStringList orderLog; + TestableUndoList list; + list.appendCom(nullptr); // 空指针被忽略 + list.appendCom(new RecordingCommand(&orderLog, "only")); + + // Act + list.undo(); + list.redo(); + + // Assert:仅有一条有效命令被执行(undo 一次 + redo 一次) + ASSERT_EQ(orderLog.size(), 2); + EXPECT_EQ(orderLog.at(0), QString("only:undo")); + EXPECT_EQ(orderLog.at(1), QString("only:redo")); +} + +// ---- U1/R1 进度日志分支(processed%10==0 与 processed==total 同时命中) ---- +TEST_F(UndoListTest, Undo_TenCommands_CoversProgressLogBranch) +{ + // Arrange:恰好 10 条命令(第 10 条时 processed%10==0 与 ==total 均为真) + QStringList orderLog; + TestableUndoList list; + for (int i = 0; i < 10; ++i) + list.appendCom(new RecordingCommand(&orderLog, QString("c%1").arg(i))); + + // Act + list.undo(); + + // Assert:10 条全部逆序执行 + ASSERT_EQ(orderLog.size(), 10); + EXPECT_EQ(orderLog.at(0), QString("c9:undo")); + EXPECT_EQ(orderLog.at(9), QString("c0:undo")); + + // Act & Assert:redo 正序 10 条 + orderLog.clear(); + list.redo(); + ASSERT_EQ(orderLog.size(), 10); + EXPECT_EQ(orderLog.at(0), QString("c0:redo")); + EXPECT_EQ(orderLog.at(9), QString("c9:redo")); +} + +// ---- D1:析构逐个释放全部追加命令 ---- +TEST_F(UndoListTest, Destructor_AppendedCommands_AllDeleted) +{ + // Arrange + int aliveCount = 0; // 用例局部存活计数 + { + TestableUndoList list; + list.appendCom(new CountingCommand(&aliveCount)); + list.appendCom(nullptr); // 混入空指针验证析构判空分支 + list.appendCom(new CountingCommand(&aliveCount)); + list.appendCom(new CountingCommand(&aliveCount)); + EXPECT_EQ(aliveCount, 3); // 3 个子命令存活 + + // Act:作用域结束触发 ~UndoList + } + + // Assert:全部子命令被析构删除,计数归零 + EXPECT_EQ(aliveCount, 0); +} + +// ---- U1+R1 组合往返:多轮撤销/重做顺序稳定 ---- +TEST_F(UndoListTest, UndoRedo_RoundTrip_OrderStableAfterCycles) +{ + // Arrange + QStringList orderLog; + TestableUndoList list; + list.appendCom(new RecordingCommand(&orderLog, "A")); + list.appendCom(new RecordingCommand(&orderLog, "B")); + + // Act & Assert:两轮往返,每轮 undo 逆序、redo 正序 + for (int round = 0; round < 2; ++round) { + orderLog.clear(); + list.undo(); + ASSERT_EQ(orderLog.size(), 2) << "round " << round; + EXPECT_EQ(orderLog.at(0), QString("B:undo")) << "round " << round; + EXPECT_EQ(orderLog.at(1), QString("A:undo")) << "round " << round; + + orderLog.clear(); + list.redo(); + ASSERT_EQ(orderLog.size(), 2) << "round " << round; + EXPECT_EQ(orderLog.at(0), QString("A:redo")) << "round " << round; + EXPECT_EQ(orderLog.at(1), QString("B:redo")) << "round " << round; + } +} + +// ---- D1:经基类指针虚析构删除 UndoList 自身(deleting destructor 路径) ---- +TEST_F(UndoListTest, Destructor_DeleteViaBasePointer_ReleasesCleanly) +{ + // Arrange:具体类堆上构造(D0 分派要求动态类型恰为 UndoList) + QUndoCommand *cmd = new UndoList(); + EXPECT_EQ(docText(), QString("")); // 前提:无副作用 + + // Act + delete cmd; + + // Assert:析构安全,文档状态不变 + EXPECT_EQ(docText(), QString("")); +} diff --git a/tests/editor_undo/test_unindenttextcommand.cpp b/tests/editor_undo/test_unindenttextcommand.cpp new file mode 100644 index 00000000..0eae5ad4 --- /dev/null +++ b/tests/editor_undo/test_unindenttextcommand.cpp @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// UnindentTextCommand 单元测试(src/editor/indenttextcommond.cpp)。 +// 多行去缩进(tab 整体删 1 个;空格删至多 tabSpaceNumber 个)撤销项。 +// +// 分支清单(redo 逐行): +// B1: 行首 '\t' → deleteChar,removed=1 +// B2: 行首 ' ' → 数至多 tabSpaceNumber 个连续空格并删除,removed=cnt +// (cnt ∈ {1..tabSpaceNumber};空格数不足 tabSpaceNumber 时只删现有数量) +// B3: 行首无缩进字符 → removed=0(不动) +// R3: 选区恢复 —— 单行:newStartPos = startpos - removed[0](负数钳 0)+ 选整块; +// 多行:newStartPos/newEndPos 负值与倒置钳制 +// U1: undo 逐行 —— removed==1 重插 "\t";removed>1 重插 removed 个空格;removed==0 跳过 +// U2: undo 选区恢复 —— [startpos, endpos) +// +// 用例映射: +// - Redo_TabIndented_RemovesTabEachLine → B1 + U1(==1) +// - Redo_SpaceIndented_RemovesUpToTabWidth → B2(4 空格 + 2 空格两种量)+ U1(>1) +// - Redo_SpacesMoreThanTabWidth_RemovesExactlyTabWidth → B2 边界(6 空格只删 4) +// - Redo_NoIndent_NoChangeAndUndoStable → B3 + U1(==0) +// - Redo_MixedIndentTypes_HandledPerLine → B1+B2+B3 混合 +// - Redo_SingleLineWithSelection_ClampsStartAndSelectsBlock → R3 单行(负值钳 0) +// - Redo_MultiLineContrivedRange_ClampsEndToStart → R3 多行(newEndPossetPlainText("\taa\n\tbb"); + const QString snapshot = docText(); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 7)); + UnindentTextCommand cmd(edit, 0, 7, 0, 1, 4); + + // Act + cmd.redo(); + + // Assert:各行删 1 个 "\t";R3 多行:[0-1→0, 7-2=5) 选区 + EXPECT_EQ(docText(), QString("aa\nbb")); + EXPECT_EQ(edit->textCursor().selectionStart(), 0); + EXPECT_EQ(edit->textCursor().selectionEnd(), 5); + + // Act & Assert:undo 重插 "\t" 并恢复原选区 + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(edit->textCursor().selectionStart(), 0); + EXPECT_EQ(edit->textCursor().selectionEnd(), 7); +} + +// ---- B2 + U1(>1):空格缩进(4 空格行删 4;2 空格行删 2) ---- +TEST_F(UnindentTextCommandTest, Redo_SpaceIndented_RemovesUpToTabWidth) +{ + // Arrange + edit->setPlainText(" aa\n bb"); + const QString snapshot = docText(); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 11)); + UnindentTextCommand cmd(edit, 0, 11, 0, 1, 4); + + // Act + cmd.redo(); + + // Assert:第一行删 4 空格、第二行只有 2 个全删 + EXPECT_EQ(docText(), QString("aa\nbb")); + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆:4 空格 + 2 空格还原 +} + +// ---- B2 边界:空格数超过 tabSpaceNumber 只删 tabSpaceNumber 个 ---- +TEST_F(UnindentTextCommandTest, Redo_SpacesMoreThanTabWidth_RemovesExactlyTabWidth) +{ + // Arrange:单行 6 个前导空格 + edit->setPlainText(" aa"); + const QString snapshot = docText(); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 8)); + UnindentTextCommand cmd(edit, 0, 8, 0, 0, 4); + + // Act + cmd.redo(); + + // Assert:只删 4 个空格,剩 2 个 + EXPECT_EQ(docText(), QString(" aa")); + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆:重插 4 个空格 +} + +// ---- B3 + U1(==0):无缩进行不动、undo 稳定 ---- +TEST_F(UnindentTextCommandTest, Redo_NoIndent_NoChangeAndUndoStable) +{ + // Arrange + edit->setPlainText("aa\nbb"); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 5)); + UnindentTextCommand cmd(edit, 0, 5, 0, 1, 4); + + // Act + cmd.redo(); + + // Assert:removed=0,文档不变(强异常安全) + EXPECT_EQ(docText(), QString("aa\nbb")); + EXPECT_EQ(edit->textCursor().selectionStart(), 0); // 选区 [0-0, 5-0) + EXPECT_EQ(edit->textCursor().selectionEnd(), 5); + cmd.undo(); + EXPECT_EQ(docText(), QString("aa\nbb")); // undo 同样无操作 +} + +// ---- B1+B2+B3 混合:逐行独立判定 ---- +TEST_F(UnindentTextCommandTest, Redo_MixedIndentTypes_HandledPerLine) +{ + // Arrange:tab 行 / 2 空格行 / 无缩进行 + edit->setPlainText("\ta\n b\nc"); + const QString snapshot = docText(); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 9)); + UnindentTextCommand cmd(edit, 0, 9, 0, 2, 4); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), QString("a\nb\nc")); + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 各行按 removed 值独立还原 +} + +// ---- R3 单行:newStartPos 负值钳 0 + 选整块 ---- +TEST_F(UnindentTextCommandTest, Redo_SingleLineWithSelection_ClampsStartAndSelectsBlock) +{ + // Arrange:选区 [0,6)(含 tab);startpos=0 时 newStartPos = 0-1 = -1 → 钳 0 + edit->setPlainText("\thello"); + const QString snapshot = docText(); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 6)); + UnindentTextCommand cmd(edit, 0, 6, 0, 0, 4); + + // Act + cmd.redo(); + + // Assert:R3 单行分支——钳 0 后选整块 "hello" + EXPECT_EQ(docText(), QString("hello")); + EXPECT_EQ(ut::toLf(edit->textCursor().selectedText()), QString("hello")); + EXPECT_EQ(edit->textCursor().selectionStart(), 0); + + cmd.undo(); + EXPECT_EQ(docText(), snapshot); // 状态可逆 + EXPECT_EQ(edit->textCursor().selectionStart(), 0); // U2 恢复 [startpos,endpos) + EXPECT_EQ(edit->textCursor().selectionEnd(), 6); +} + +// ---- R3 多行倒置钳制:构造 endpos < totalRemoved 触发 newEndPos 钳到 newStartPos ---- +TEST_F(UnindentTextCommandTest, Redo_MultiLineContrivedRange_ClampsEndToStart) +{ + // Arrange:两行 tab;endpos=1 < totalRemoved=2 → newEndPos=-1 → 钳为 newStartPos=0 + edit->setPlainText("\ta\n\tb"); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 1)); + UnindentTextCommand cmd(edit, 0, 1, 0, 1, 4); + + // Act + cmd.redo(); + + // Assert:选区塌缩为 [0,0)(钳制分支命中) + EXPECT_EQ(docText(), QString("a\nb")); + EXPECT_EQ(edit->textCursor().selectionStart(), 0); + EXPECT_EQ(edit->textCursor().selectionEnd(), 0); + cmd.undo(); + EXPECT_EQ(docText(), QString("\ta\n\tb")); // 状态可逆 +} + +// ---- 等价类 TEST_P:tab / 空格 / 无缩进三组同一往返断言 ---- +namespace { +struct UnindentCase { + QString docContent; + QString expectedAfterRedo; +}; +} // namespace + +class UnindentParamTest : public EditorUndoTestBase, + public ::testing::WithParamInterface { +}; + +TEST_P(UnindentParamTest, Redo_IndentStyles_ParamRoundTrip) +{ + // Arrange:单行,无选区路径(cursor 无选区) + const UnindentCase &c = GetParam(); + edit->setPlainText(c.docContent); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 0)); + UnindentTextCommand cmd(edit, 0, c.docContent.size(), 0, 0, 4); + + // Act + cmd.redo(); + + // Assert + EXPECT_EQ(docText(), c.expectedAfterRedo); + cmd.undo(); + EXPECT_EQ(docText(), c.docContent); // 状态可逆 +} + +INSTANTIATE_TEST_SUITE_P(IndentVariants, UnindentParamTest, + ::testing::Values( + UnindentCase{ QString("\ttabbed"), QString("tabbed") }, + UnindentCase{ QString(" twoSpaces"), QString("twoSpaces") }, + UnindentCase{ QString("noIndent"), QString("noIndent") } + )); + +// ---- D1:经基类指针虚析构删除(deleting destructor 路径) ---- +TEST_F(UnindentTextCommandTest, Destructor_DeleteViaBasePointer_ReleasesCleanly) +{ + // Arrange + edit->setPlainText("\tline"); + edit->setTextCursor(ut::cursorAt(edit->document(), 0, 5)); + QUndoCommand *cmd = new UnindentTextCommand(edit, 0, 5, 0, 0, 4); + cmd->redo(); + EXPECT_EQ(docText(), QString("line")); // 前提:命令已生效 + + // Act + delete cmd; + + // Assert:析构不改变文档状态 + EXPECT_EQ(docText(), QString("line")); +}