From cdcfd8e0a7f28f4f581ebe8c228e76acbb107740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Sun, 3 May 2026 01:52:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CSV=20=E5=AF=BC=E5=85=A5=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E3=80=81=E5=AF=BC=E5=85=A5=E6=92=A4=E9=94=80=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=8A=E5=A4=9A=E9=A1=B9=20bug=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能: - 新增 CSV 格式导入导出支持(含 BOM 处理、引号转义、智能标题行检测) - 导入操作支持撤销/重做 - 保存时 PATH 长度检查与警告 - 深色模式状态持久化(darkmode.txt) - 提取 get_current_target/push_record 为共享函数,消除控制器层重复代码 - 新增 string_list_insert_at,修复撤销删除时的索引恢复 - 新增 undo_redo、error_code、import_export 单元测试 Bug 修复: - 修复备份目录对话框和失败原因的硬编码中文字符串 - 提取 get_exe_dir 到 os_env 消除 i18n.c/ui_utils.c 重复定义 - 修复导入撤销 old_sys/old_user 内存管理(push 后置 NULL 防止重复释放) - 修复 CSV 导出转义与导入解析不一致(移除反斜杠转义,依赖 CSV 引号机制) - 修正 PATH 长度 8191 限制描述为 "command line safe limit" --- include/controller/callbacks_internal.h | 8 + include/ui/ui_utils.h | 1 + include/utils/os_env.h | 5 + include/utils/string_ext.h | 1 + po/en_US.po | 30 +- po/zh_CN.po | 30 +- src/controller/callbacks.c | 32 + src/controller/callbacks_basic.c | 31 - src/controller/callbacks_io.c | 89 ++- src/controller/callbacks_nav.c | 31 - src/controller/callbacks_search.c | 9 + src/controller/callbacks_sys.c | 62 +- src/core/import_export.c | 149 +++- src/core/undo_redo.c | 23 +- src/main.c | 30 +- src/ui/ui_utils.c | 39 ++ src/utils/i18n.c | 17 +- src/utils/os_env.c | 23 + src/utils/string_ext.c | 30 + tests/CMakeLists.txt | 3 + tests/unit/error_code/CMakeLists.txt | 18 + tests/unit/error_code/test_error_code.c | 107 +++ tests/unit/import_export/CMakeLists.txt | 23 + tests/unit/import_export/test_import_export.c | 324 +++++++++ tests/unit/string_ext/test_string_ext.c | 157 +++++ tests/unit/undo_redo/CMakeLists.txt | 24 + tests/unit/undo_redo/test_undo_redo.c | 637 ++++++++++++++++++ 27 files changed, 1823 insertions(+), 110 deletions(-) create mode 100644 tests/unit/error_code/CMakeLists.txt create mode 100644 tests/unit/error_code/test_error_code.c create mode 100644 tests/unit/import_export/CMakeLists.txt create mode 100644 tests/unit/import_export/test_import_export.c create mode 100644 tests/unit/undo_redo/CMakeLists.txt create mode 100644 tests/unit/undo_redo/test_undo_redo.c diff --git a/include/controller/callbacks_internal.h b/include/controller/callbacks_internal.h index b14af9d..2058c44 100644 --- a/include/controller/callbacks_internal.h +++ b/include/controller/callbacks_internal.h @@ -3,6 +3,7 @@ #include #include "core/app_context.h" +#include "core/undo_redo.h" #include "utils/i18n.h" // 内部辅助函数声明(供各 callbacks_*.c 文件共享) @@ -26,4 +27,11 @@ void refresh_undo_redo_buttons(Ihandle *dlg); // 同步合并预览列表 void sync_merged_list(Ihandle *dlg); +// 获取当前 Tab 对应的 TargetType +TargetType get_current_target(Ihandle *dlg); + +// 创建并推送撤销记录 +void push_record(Ihandle *dlg, OperationType op_type, int index, int count, + char **old_paths, char **new_paths); + #endif // CALLBACKS_INTERNAL_H diff --git a/include/ui/ui_utils.h b/include/ui/ui_utils.h index eec015c..7401572 100644 --- a/include/ui/ui_utils.h +++ b/include/ui/ui_utils.h @@ -20,5 +20,6 @@ void sync_string_list_to_ui(Ihandle *list_ui, const StringList *str_list); // 深色模式状态管理 void set_dark_mode(int enabled); int get_dark_mode(void); +void init_dark_mode(void); #endif // UI_UTILS_H diff --git a/include/utils/os_env.h b/include/utils/os_env.h index 5b84251..eebc8ef 100644 --- a/include/utils/os_env.h +++ b/include/utils/os_env.h @@ -2,6 +2,11 @@ #define OS_ENV_H #include "utils/error_code.h" +#include + +// 获取可执行文件所在目录(带缓存) +// buf: 输出缓冲区,size: 缓冲区大小 +void get_exe_dir(char *buf, size_t size); // 检查是否以管理员权限运行 int check_admin(void); diff --git a/include/utils/string_ext.h b/include/utils/string_ext.h index 62a8a45..f8b227f 100644 --- a/include/utils/string_ext.h +++ b/include/utils/string_ext.h @@ -14,6 +14,7 @@ typedef struct // 字符串列表 void init_string_list(StringList *list); void add_string_list(StringList *list, const char *str); +int string_list_insert_at(StringList *list, int index, const char *str); void clear_string_list(StringList *list); // 访问器函数 - 安全访问内部数据 diff --git a/po/en_US.po b/po/en_US.po index 89359c6..2769758 100644 --- a/po/en_US.po +++ b/po/en_US.po @@ -414,4 +414,32 @@ msgstr "Dark Mode" #: src/ui/main_window.c msgid "Light Mode" -msgstr "Light Mode" \ No newline at end of file +msgstr "Light Mode" + +#: src/controller/callbacks_sys.c +msgid "Select backup directory" +msgstr "Select backup directory" + +#: src/controller/callbacks_sys.c +msgid "Unknown error" +msgstr "Unknown error" + +#: src/controller/callbacks_sys.c +msgid "Failed to get AppData path" +msgstr "Failed to get AppData path" + +#: src/controller/callbacks_sys.c +msgid "Failed to create backup directory or file" +msgstr "Failed to create backup directory or file" + +#: src/controller/callbacks_sys.c +msgid "Failed to read PATH from registry" +msgstr "Failed to read PATH from registry" + +#: src/controller/callbacks_sys.c +msgid "Backup failed! Reason: %s\n\nContinue saving?\n(Continuing may prevent recovery)" +msgstr "Backup failed! Reason: %s\n\nContinue saving?\n(Continuing may prevent recovery)" + +#: src/controller/callbacks_sys.c +msgid "Total PATH length: %d characters (command line safe limit: 8191)\n" +msgstr "Total PATH length: %d characters (command line safe limit: 8191)\n" \ No newline at end of file diff --git a/po/zh_CN.po b/po/zh_CN.po index 5c92c63..1a60fb4 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -414,4 +414,32 @@ msgstr "深色模式" #: src/ui/main_window.c msgid "Light Mode" -msgstr "浅色模式" \ No newline at end of file +msgstr "浅色模式" + +#: src/controller/callbacks_sys.c +msgid "Select backup directory" +msgstr "选择备份目录" + +#: src/controller/callbacks_sys.c +msgid "Unknown error" +msgstr "未知错误" + +#: src/controller/callbacks_sys.c +msgid "Failed to get AppData path" +msgstr "无法获取 AppData 路径" + +#: src/controller/callbacks_sys.c +msgid "Failed to create backup directory or file" +msgstr "无法创建备份目录或文件" + +#: src/controller/callbacks_sys.c +msgid "Failed to read PATH from registry" +msgstr "无法读取注册表中的 PATH 值" + +#: src/controller/callbacks_sys.c +msgid "Backup failed! Reason: %s\n\nContinue saving?\n(Continuing may prevent recovery)" +msgstr "备份失败!原因:%s\n\n是否继续保存?\n(继续保存可能导致无法恢复)" + +#: src/controller/callbacks_sys.c +msgid "Total PATH length: %d characters (command line safe limit: 8191)\n" +msgstr "总 PATH 长度:%d 字符(命令行安全限制:8191)\n" \ No newline at end of file diff --git a/src/controller/callbacks.c b/src/controller/callbacks.c index 26dc8c0..c1042e0 100644 --- a/src/controller/callbacks.c +++ b/src/controller/callbacks.c @@ -5,6 +5,7 @@ #include "utils/ui_constants.h" #include "ui/ui_utils.h" #include +#include // 辅助函数:获取主对话框 Ihandle *get_main_dlg(void) @@ -84,3 +85,34 @@ void sync_merged_list(Ihandle *dlg) IupSetInt(list_merged, "COUNT", total); refresh_single_list_style(list_merged); } + +// 获取当前 Tab 对应的 TargetType +TargetType get_current_target(Ihandle *dlg) +{ + Ihandle *tabs = IupGetDialogChild(dlg, CTRL_TABS_MAIN); + if (tabs) + { + int pos = IupGetInt(tabs, "VALUEPOS"); + return (pos == 0) ? TARGET_SYSTEM : TARGET_USER; + } + return TARGET_USER; +} + +// 辅助函数:创建并推送撤销记录 +void push_record(Ihandle *dlg, OperationType op_type, int index, int count, + char **old_paths, char **new_paths) +{ + AppContext *ctx = get_app_context_from_dlg(dlg); + if (!ctx || !ctx->undo_redo_mgr) + return; + + OpRecord record; + record.type = op_type; + record.target = get_current_target(dlg); + record.index = index; + record.count = count; + record.old_paths = old_paths; + record.new_paths = new_paths; + + push_undo_record(ctx->undo_redo_mgr, &record); +} diff --git a/src/controller/callbacks_basic.c b/src/controller/callbacks_basic.c index 45caa35..dcbe03c 100644 --- a/src/controller/callbacks_basic.c +++ b/src/controller/callbacks_basic.c @@ -14,37 +14,6 @@ #include #include -// 辅助函数:检查当前目标是系统还是用户 -static TargetType get_current_target(Ihandle *dlg) -{ - Ihandle *tabs = IupGetDialogChild(dlg, CTRL_TABS_MAIN); - if (tabs) - { - int tab = IupGetInt(tabs, "VALUE"); - return (tab == 1) ? TARGET_SYSTEM : TARGET_USER; - } - return TARGET_USER; -} - -// 辅助函数:创建并推送撤销记录 -static void push_record(Ihandle *dlg, OperationType op_type, int index, int count, - char **old_paths, char **new_paths) -{ - AppContext *ctx = get_app_context_from_dlg(dlg); - if (!ctx || !ctx->undo_redo_mgr) - return; - - OpRecord record; - record.type = op_type; - record.target = get_current_target(dlg); - record.index = index; - record.count = count; - record.old_paths = old_paths; - record.new_paths = new_paths; - - push_undo_record(ctx->undo_redo_mgr, &record); -} - // 按钮回调:新建 int btn_new_cb(Ihandle *self) { diff --git a/src/controller/callbacks_io.c b/src/controller/callbacks_io.c index 4db51e4..5d96afe 100644 --- a/src/controller/callbacks_io.c +++ b/src/controller/callbacks_io.c @@ -3,6 +3,7 @@ #include "core/app_context.h" #include "core/import_export.h" #include "core/lua_config.h" +#include "core/undo_redo.h" #include "utils/string_ext.h" #include "utils/safe_string.h" #include "utils/error_code.h" @@ -12,6 +13,7 @@ #include "ui/ui_utils.h" #include #include +#include #include // 按钮回调:导入 @@ -32,7 +34,7 @@ int btn_import_cb(Ihandle *self) IupSetAttribute(filedlg, "DIALOGTYPE", "OPEN"); IupSetAttribute(filedlg, "TITLE", lua_config_get_string("label", "import_title")); IupSetAttribute(filedlg, "FILTER", "json"); - IupSetAttribute(filedlg, "EXTFILTER", "JSON 文件 (*.json)|*.json|文本文件 (*.txt)|*.txt|所有文件 (*.*)|*.*"); + IupSetAttribute(filedlg, "EXTFILTER", "JSON 文件 (*.json)|*.json|CSV 文件 (*.csv)|*.csv|文本文件 (*.txt)|*.txt|所有文件 (*.*)|*.*"); IupPopup(filedlg, IUP_CENTER, IUP_CENTER); @@ -59,6 +61,23 @@ int btn_import_cb(Ihandle *self) return IUP_DEFAULT; } + // 保存导入前的状态用于撤销 + char **old_sys = NULL, **old_user = NULL; + int old_sys_count = ctx->sys_paths.count; + int old_user_count = ctx->user_paths.count; + if (old_sys_count > 0) + { + old_sys = (char **)malloc(old_sys_count * sizeof(char *)); + for (int i = 0; i < old_sys_count; i++) + old_sys[i] = _strdup(string_list_get(&ctx->sys_paths, i)); + } + if (old_user_count > 0) + { + old_user = (char **)malloc(old_user_count * sizeof(char *)); + for (int i = 0; i < old_user_count; i++) + old_user[i] = _strdup(string_list_get(&ctx->user_paths, i)); + } + int choice = 0; if (has_system && has_user) { @@ -81,6 +100,32 @@ int btn_import_cb(Ihandle *self) if (choice == 1 || choice == 3) { + // 记录系统变量导入的撤销信息 + char **new_sys = NULL; + if (imported.system.count > 0) + { + new_sys = (char **)malloc(imported.system.count * sizeof(char *)); + for (int i = 0; i < imported.system.count; i++) + new_sys[i] = _strdup(imported.system.items[i]); + } + OpRecord rec = {0}; + rec.type = OP_IMPORT; + rec.target = TARGET_SYSTEM; + rec.index = 0; + rec.count = imported.system.count; + rec.old_paths = old_sys; + rec.new_paths = new_sys; + push_undo_record(ctx->undo_redo_mgr, &rec); + // push_undo_record 会深拷贝,释放临时副本 + if (new_sys) + { + for (int i = 0; i < imported.system.count; i++) + free(new_sys[i]); + free(new_sys); + } + // old_sys 已被深拷贝到撤销管理器,置 NULL 避免末尾重复释放 + old_sys = NULL; + clear_string_list(&ctx->sys_paths); for (int i = 0; i < imported.system.count; i++) { @@ -93,6 +138,32 @@ int btn_import_cb(Ihandle *self) if (choice == 2 || choice == 3) { + // 记录用户变量导入的撤销信息 + char **new_user = NULL; + if (imported.user.count > 0) + { + new_user = (char **)malloc(imported.user.count * sizeof(char *)); + for (int i = 0; i < imported.user.count; i++) + new_user[i] = _strdup(imported.user.items[i]); + } + OpRecord rec = {0}; + rec.type = OP_IMPORT; + rec.target = TARGET_USER; + rec.index = 0; + rec.count = imported.user.count; + rec.old_paths = old_user; + rec.new_paths = new_user; + push_undo_record(ctx->undo_redo_mgr, &rec); + // push_undo_record 会深拷贝,释放临时副本 + if (new_user) + { + for (int i = 0; i < imported.user.count; i++) + free(new_user[i]); + free(new_user); + } + // old_user 已被深拷贝到撤销管理器,置 NULL 避免末尾重复释放 + old_user = NULL; + clear_string_list(&ctx->user_paths); for (int i = 0; i < imported.user.count; i++) { @@ -103,6 +174,20 @@ int btn_import_cb(Ihandle *self) total_imported += imported.user.count; } + // 释放未被撤销记录使用的旧状态 + if (old_sys) + { + for (int i = 0; i < old_sys_count; i++) + free(old_sys[i]); + free(old_sys); + } + if (old_user) + { + for (int i = 0; i < old_user_count; i++) + free(old_user[i]); + free(old_user); + } + // 释放导入数据 clear_string_list(&imported.system); clear_string_list(&imported.user); @@ -114,6 +199,8 @@ int btn_import_cb(Ihandle *self) Ihandle *lbl_status = IupGetDialogChild(dlg, CTRL_LBL_STATUS); if (lbl_status) IupSetAttribute(lbl_status, "TITLE", lua_config_get_string("status", "loaded")); + + refresh_undo_redo_buttons(dlg); } else { diff --git a/src/controller/callbacks_nav.c b/src/controller/callbacks_nav.c index 6f7cfd0..c0cb208 100644 --- a/src/controller/callbacks_nav.c +++ b/src/controller/callbacks_nav.c @@ -11,37 +11,6 @@ #include #include -// 辅助函数:检查当前目标是系统还是用户 -static TargetType get_current_target(Ihandle *dlg) -{ - Ihandle *tabs = IupGetDialogChild(dlg, CTRL_TABS_MAIN); - if (tabs) - { - int tab = IupGetInt(tabs, "VALUE"); - return (tab == 1) ? TARGET_SYSTEM : TARGET_USER; - } - return TARGET_USER; -} - -// 辅助函数:创建并推送撤销记录 -static void push_record(Ihandle *dlg, OperationType op_type, int index, int count, - char **old_paths, char **new_paths) -{ - AppContext *ctx = get_app_context_from_dlg(dlg); - if (!ctx || !ctx->undo_redo_mgr) - return; - - OpRecord record; - record.type = op_type; - record.target = get_current_target(dlg); - record.index = index; - record.count = count; - record.old_paths = old_paths; - record.new_paths = new_paths; - - push_undo_record(ctx->undo_redo_mgr, &record); -} - // 按钮回调:上移 int btn_up_cb(Ihandle *self) { diff --git a/src/controller/callbacks_search.c b/src/controller/callbacks_search.c index ba2f894..eaa1363 100644 --- a/src/controller/callbacks_search.c +++ b/src/controller/callbacks_search.c @@ -2,11 +2,13 @@ #include "controller/callbacks_internal.h" #include "core/app_context.h" #include "core/lua_config.h" +#include "core/undo_redo.h" #include "utils/string_ext.h" #include "utils/safe_string.h" #include "utils/ui_constants.h" #include "ui/ui_utils.h" #include +#include #include // 搜索回调 @@ -65,10 +67,17 @@ int list_dropfiles_cb(Ihandle *self, const char *filename, int num, int x, int y if (txt_search) IupSetAttribute(txt_search, "VALUE", ""); + // 记录撤销信息 + char *path_copy = _strdup(filename); + char *paths[1] = {path_copy}; + push_record(dlg, OP_ADD, raw_data->count, 1, NULL, paths); + free(path_copy); + add_string_list(raw_data, filename); sync_string_list_to_ui(current_list, raw_data); IupSetInt(current_list, "VALUE", raw_data->count); + refresh_undo_redo_buttons(dlg); } else { diff --git a/src/controller/callbacks_sys.c b/src/controller/callbacks_sys.c index a090e01..ad7d434 100644 --- a/src/controller/callbacks_sys.c +++ b/src/controller/callbacks_sys.c @@ -42,7 +42,7 @@ int btn_ok_cb(Ihandle *self) { Ihandle *filedlg = IupFileDlg(); IupSetAttribute(filedlg, "DIALOGTYPE", "DIR"); - IupSetAttribute(filedlg, "TITLE", "选择备份目录"); + IupSetAttribute(filedlg, "TITLE", _("Select backup directory")); IupPopup(filedlg, IUP_CENTER, IUP_CENTER); @@ -79,22 +79,72 @@ int btn_ok_cb(Ihandle *self) if (backup_result != ERR_OK) { log_error("Backup failed: error code %d", backup_result); - const char *reason = "未知错误"; + const char *reason = _("Unknown error"); if (backup_result == ERR_FAILED) - reason = "无法获取 AppData 路径"; + reason = _("Failed to get AppData path"); else if (backup_result == ERR_FILE_NOT_FOUND) - reason = "无法创建备份目录或文件"; + reason = _("Failed to create backup directory or file"); else if (backup_result == ERR_REGISTRY_FAILED) - reason = "无法读取注册表中的 PATH 值"; + reason = _("Failed to read PATH from registry"); char msg[512]; - snprintf(msg, sizeof(msg), "备份失败!原因:%s\n\n是否继续保存?\n(继续保存可能导致无法恢复)", reason); + snprintf(msg, sizeof(msg), _("Backup failed! Reason: %s\n\nContinue saving?\n(Continuing may prevent recovery)"), reason); int choice = IupAlarm(_("Warning"), msg, _("Continue Saving"), _("Cancel"), NULL); if (choice != 1) return IUP_DEFAULT; } } + // PATH 长度检查 + { + int sys_len = 0, user_len = 0; + for (int i = 0; i < ctx->sys_paths.count; i++) + { + const char *p = string_list_get(&ctx->sys_paths, i); + if (p) sys_len += (int)strlen(p) + 1; // +1 for semicolon + } + for (int i = 0; i < ctx->user_paths.count; i++) + { + const char *p = string_list_get(&ctx->user_paths, i); + if (p) user_len += (int)strlen(p) + 1; + } + + int warnings = 0; + char warn_msg[1024] = ""; + + if (sys_len > 2048) + { + snprintf(warn_msg, sizeof(warn_msg), + _("System PATH length: %d characters (recommended max: 2048)\n"), sys_len); + warnings++; + } + if (user_len > 2048) + { + char tmp[256]; + snprintf(tmp, sizeof(tmp), + _("User PATH length: %d characters (recommended max: 2048)\n"), user_len); + strncat(warn_msg, tmp, sizeof(warn_msg) - strlen(warn_msg) - 1); + warnings++; + } + if (sys_len + user_len > 8191) + { + char tmp[256]; + snprintf(tmp, sizeof(tmp), + _("Total PATH length: %d characters (command line safe limit: 8191)\n"), sys_len + user_len); + strncat(warn_msg, tmp, sizeof(warn_msg) - strlen(warn_msg) - 1); + warnings++; + } + + if (warnings > 0) + { + strncat(warn_msg, _("\nSaving may cause system instability. Continue?"), + sizeof(warn_msg) - strlen(warn_msg) - 1); + int choice = IupAlarm(_("PATH Length Warning"), warn_msg, _("Continue Saving"), _("Cancel"), NULL); + if (choice != 1) + return IUP_DEFAULT; + } + } + ErrorCode sys_ok = save_system_paths(&ctx->sys_paths); ErrorCode user_ok = save_user_paths(&ctx->user_paths); diff --git a/src/core/import_export.c b/src/core/import_export.c index 9daa913..bb6d89c 100644 --- a/src/core/import_export.c +++ b/src/core/import_export.c @@ -5,6 +5,7 @@ #include #include #include +#include #include // 获取当前日期时间 @@ -103,14 +104,6 @@ static char *escape_csv_field(const char *str) *p++ = '"'; *p++ = '"'; break; - case '\n': - *p++ = '\\'; - *p++ = 'n'; - break; - case '\r': - *p++ = '\\'; - *p++ = 'r'; - break; default: *p++ = str[i]; break; @@ -169,7 +162,7 @@ static ErrorCode export_paths_to_json(const ExportData *data, FILE *fp) // 导出 PATH 到 CSV 文件 // 格式:type,path -// type: "system" 或 "user" +// type: system 或 user static ErrorCode export_paths_to_csv(const ExportData *data, FILE *fp) { // 写入 UTF-8 BOM @@ -186,7 +179,7 @@ static ErrorCode export_paths_to_csv(const ExportData *data, FILE *fp) char *escaped = escape_csv_field(data->system.items[i]); if (escaped) { - fprintf(fp, "\"system\",\"%s\"\n", escaped); + fprintf(fp, "system,%s\n", escaped); free(escaped); } } @@ -200,7 +193,7 @@ static ErrorCode export_paths_to_csv(const ExportData *data, FILE *fp) char *escaped = escape_csv_field(data->user.items[i]); if (escaped) { - fprintf(fp, "\"user\",\"%s\"\n", escaped); + fprintf(fp, "user,%s\n", escaped); free(escaped); } } @@ -263,7 +256,7 @@ static void trim_whitespace(char *str) start++; char *end = str + strlen(str) - 1; - while (end > start && (*end == ' ' || *end == '\t' || *end == '\n' || *end == '\r')) + while (end >= start && (*end == ' ' || *end == '\t' || *end == '\n' || *end == '\r')) *end-- = '\0'; if (start != str) @@ -289,6 +282,133 @@ static int is_json_file(const char *filepath) return ext && _stricmp(ext, ".json") == 0; } +// 检查文件是否为 CSV 格式(通过扩展名) +static int is_csv_file(const char *filepath) +{ + const char *ext = strrchr(filepath, '.'); + return ext && _stricmp(ext, ".csv") == 0; +} + +// 解析 CSV 字段(处理引号包围的字段) +// 返回值:指向下一个字段的指针,或 NULL +static const char *parse_csv_field(const char *line, char *field, int field_size) +{ + if (!line || !field || field_size <= 0) + return NULL; + + const char *p = line; + char *out = field; + char *end = field + field_size - 1; + + if (*p == '"') + { + // 引号包围的字段 + p++; // 跳过开始引号 + while (*p && out < end) + { + if (*p == '"') + { + if (*(p + 1) == '"') + { + // 转义的引号 "" + *out++ = '"'; + p += 2; + } + else + { + // 结束引号 + p++; // 跳过结束引号 + break; + } + } + else + { + *out++ = *p++; + } + } + // 跳过逗号分隔符 + if (*p == ',') p++; + } + else + { + // 非引号字段(按逗号分隔) + while (*p && *p != ',' && out < end) + { + *out++ = *p++; + } + if (*p == ',') p++; + } + + *out = '\0'; + return p; +} + +// 导入 CSV 格式的 PATH 文件 +static ErrorCode import_paths_from_csv(const char *filepath, ExportData *data) +{ + FILE *fp = fopen(filepath, "rb"); + if (!fp) + { + log_error("Failed to open file for import: %s", filepath); + return ERR_FILE_NOT_FOUND; + } + + char line[4096]; + int line_num = 0; + int header_skipped = 0; + + while (fgets(line, sizeof(line), fp)) + { + line_num++; + trim_whitespace(line); + + if (line[0] == '\0') + continue; + + // 跳过 UTF-8 BOM + const char *start = line; + if ((unsigned char)start[0] == 0xEF && + (unsigned char)start[1] == 0xBB && + (unsigned char)start[2] == 0xBF) + { + start += 3; + } + + // 智能检测标题行:第一行包含 "type" 和 "path" 则视为标题 + if (!header_skipped) + { + header_skipped = 1; + char lower[256]; + strncpy(lower, start, sizeof(lower) - 1); + lower[sizeof(lower) - 1] = '\0'; + for (char *p = lower; *p; p++) *p = (char)tolower((unsigned char)*p); + if (strstr(lower, "type") && strstr(lower, "path")) + continue; + } + + char type[32] = {0}; + char path[4096] = {0}; + + start = parse_csv_field(start, type, sizeof(type)); + if (!start) + continue; + parse_csv_field(start, path, sizeof(path)); + + if (path[0] == '\0') + continue; + + if (_stricmp(type, "system") == 0) + add_string_list(&data->system, path); + else if (_stricmp(type, "user") == 0) + add_string_list(&data->user, path); + } + + fclose(fp); + log_info("Imported paths from CSV file: sys=%d, user=%d, file=%s", + data->system.count, data->user.count, filepath); + return ERR_OK; +} + // 检查引号前是否有奇数个连续反斜杠(奇数个表示引号被转义) static int is_quote_escaped(const char *quote_pos, const char *line_start) { @@ -311,6 +431,11 @@ ErrorCode import_paths_from_file(const char *filepath, ExportData *data) init_string_list(&data->system); init_string_list(&data->user); + if (is_csv_file(filepath)) + { + return import_paths_from_csv(filepath, data); + } + if (!is_json_file(filepath)) { FILE *fp = fopen(filepath, "rb"); diff --git a/src/core/undo_redo.c b/src/core/undo_redo.c index 686b4b8..3dd923f 100644 --- a/src/core/undo_redo.c +++ b/src/core/undo_redo.c @@ -149,11 +149,11 @@ int undo(UndoRedoManager *mgr, StringList *sys_paths, StringList *user_paths) break; case OP_DELETE: - // 撤销删除:恢复被删除的路径 + // 撤销删除:恢复被删除的路径到原始位置 for (int i = 0; i < rec->count; i++) { if (rec->old_paths[i]) - add_string_list(target, rec->old_paths[i]); + string_list_insert_at(target, rec->index + i, rec->old_paths[i]); } break; @@ -221,23 +221,10 @@ int redo(UndoRedoManager *mgr, StringList *sys_paths, StringList *user_paths) break; case OP_DELETE: - // 重做删除:重新删除路径 - for (int i = 0; i < rec->count; i++) + // 重做删除:从高索引到低索引删除,避免索引偏移 + for (int i = rec->count - 1; i >= 0; i--) { - // 找到并删除对应路径 - for (int j = 0; j < target->count; j++) - { - if (target->items[j] && rec->old_paths[i] && - strcmp(target->items[j], rec->old_paths[i]) == 0) - { - free(target->items[j]); - // 移动后面的元素 - for (int k = j; k < target->count - 1; k++) - target->items[k] = target->items[k + 1]; - target->count--; - break; - } - } + path_manager_remove_at(target, rec->index + i); } break; diff --git a/src/main.c b/src/main.c index 20073cf..d86c703 100644 --- a/src/main.c +++ b/src/main.c @@ -7,6 +7,13 @@ #include "core/app_context.h" #include "utils/ui_constants.h" #include "core/lua_config.h" +#include "utils/string_ext.h" +#include "utils/os_env.h" +#include "utils/logger.h" +#include "utils/i18n.h" +#include "controller/callbacks.h" +#include "ui/main_window.h" +#include "ui/ui_utils.h" // 需要在非管理员模式禁用的按钮列表 #define ADMIN_DISABLE_COUNT 10 @@ -15,12 +22,6 @@ static const char* ADMIN_DISABLE_BUTTONS[] = { CTRL_BTN_UP, CTRL_BTN_DOWN, CTRL_BTN_CLEAN, CTRL_BTN_OK, CTRL_BTN_IMPORT, CTRL_BTN_EXPORT }; -#include "utils/string_ext.h" -#include "utils/os_env.h" -#include "utils/logger.h" -#include "utils/i18n.h" -#include "controller/callbacks.h" -#include "ui/main_window.h" /* !编译命令: @@ -65,6 +66,9 @@ int main(int argc, char **argv) // 初始化国际化系统 i18n_init(NULL); + // 初始化深色模式(从配置文件加载) + init_dark_mode(); + // 在管理员模式下,解决无法拖拽文件到列表框的问题 (UIPI) // 需要加载 User32.dll 获取 ChangeWindowMessageFilter 函数 HMODULE hUser32 = LoadLibraryW(L"user32.dll"); @@ -97,6 +101,20 @@ int main(int argc, char **argv) Ihandle *dlg = create_main_window(); + // 如果深色模式已启用,应用深色主题 + if (get_dark_mode()) + { + const char *dark_bg = lua_config_get_string("theme", "dark_bg"); + if (dark_bg && *dark_bg) + IupSetAttribute(dlg, "BGCOLOR", dark_bg); + + // 更新深色模式按钮标题 + Ihandle *btn_darkmode = IupGetDialogChild(dlg, CTRL_BTN_DARKMODE); + if (btn_darkmode) + IupSetAttribute(btn_darkmode, "TITLE", + _(lua_config_get_string("button", "lightmode"))); + } + // 绑定上下文到对话框 IupSetAttribute(dlg, "APP_CONTEXT", (char *)ctx); // 注册主窗口句柄,方便其他地方获取 diff --git a/src/ui/ui_utils.c b/src/ui/ui_utils.c index f17eae4..09d1c09 100644 --- a/src/ui/ui_utils.c +++ b/src/ui/ui_utils.c @@ -1,6 +1,7 @@ #include "ui/ui_utils.h" #include "utils/os_env.h" #include "utils/string_ext.h" +#include "utils/logger.h" #include "core/lua_config.h" #include #include @@ -8,9 +9,41 @@ static int g_dark_mode = 0; +static void load_dark_mode(void) +{ + char path[512]; + get_exe_dir(path, sizeof(path)); + strncat(path, "\\darkmode.txt", sizeof(path) - strlen(path) - 1); + + FILE *fp = fopen(path, "r"); + if (fp) + { + char val[8] = {0}; + if (fgets(val, sizeof(val), fp)) + g_dark_mode = (atoi(val) == 1) ? 1 : 0; + fclose(fp); + log_info("Loaded dark mode: %d", g_dark_mode); + } +} + +static void save_dark_mode(void) +{ + char path[512]; + get_exe_dir(path, sizeof(path)); + strncat(path, "\\darkmode.txt", sizeof(path) - strlen(path) - 1); + + FILE *fp = fopen(path, "w"); + if (fp) + { + fprintf(fp, "%d\n", g_dark_mode); + fclose(fp); + } +} + void set_dark_mode(int enabled) { g_dark_mode = enabled; + save_dark_mode(); } int get_dark_mode(void) @@ -18,6 +51,12 @@ int get_dark_mode(void) return g_dark_mode; } +// 初始化深色模式(启动时调用) +void init_dark_mode(void) +{ + load_dark_mode(); +} + // 刷新列表样式(斑马纹 + 有效性检查) void refresh_single_list_style(Ihandle *list) { diff --git a/src/utils/i18n.c b/src/utils/i18n.c index fa4dfe8..7cb5a07 100644 --- a/src/utils/i18n.c +++ b/src/utils/i18n.c @@ -1,5 +1,6 @@ #include "utils/i18n.h" #include "utils/logger.h" +#include "utils/os_env.h" #include "core/lua_config.h" #include #include @@ -10,7 +11,11 @@ static char locale_path[256] = {0}; static void load_saved_language(void) { - FILE *fp = fopen("language.txt", "r"); + char path[512]; + get_exe_dir(path, sizeof(path)); + strncat(path, "\\language.txt", sizeof(path) - strlen(path) - 1); + + FILE *fp = fopen(path, "r"); if (fp != NULL) { char lang[16] = {0}; @@ -39,7 +44,9 @@ void i18n_init(const char *default_lang) { setlocale(LC_ALL, ""); - snprintf(locale_path, sizeof(locale_path), "./locale"); + char dir[256]; + get_exe_dir(dir, sizeof(dir)); + snprintf(locale_path, sizeof(locale_path), "%s/locale", dir); bindtextdomain("messages", locale_path); textdomain("messages"); @@ -96,7 +103,11 @@ void i18n_change_language(const char *lang) snprintf(new_lang_path, sizeof(new_lang_path), "%s/%s/LC_MESSAGES/%s.mo", locale_path, lang, lang); bindtextdomain("messages", new_lang_path); - FILE *fp = fopen("language.txt", "w"); + char path[512]; + get_exe_dir(path, sizeof(path)); + strncat(path, "\\language.txt", sizeof(path) - strlen(path) - 1); + + FILE *fp = fopen(path, "w"); if (fp != NULL) { fprintf(fp, "%s\n", lang); diff --git a/src/utils/os_env.c b/src/utils/os_env.c index 987aad1..b793501 100644 --- a/src/utils/os_env.c +++ b/src/utils/os_env.c @@ -5,10 +5,33 @@ #include #include #include +#include #include #include #include +// 获取可执行文件所在目录(带缓存) +static char s_exe_dir[256] = {0}; + +void get_exe_dir(char *buf, size_t size) +{ + if (s_exe_dir[0] != '\0') + { + strncpy(buf, s_exe_dir, size - 1); + buf[size - 1] = '\0'; + return; + } + DWORD len = GetModuleFileNameA(NULL, buf, (DWORD)size); + if (len > 0 && len < size) + { + char *last_sep = strrchr(buf, '\\'); + if (!last_sep) last_sep = strrchr(buf, '/'); + if (last_sep) *last_sep = '\0'; + } + strncpy(s_exe_dir, buf, sizeof(s_exe_dir) - 1); + s_exe_dir[sizeof(s_exe_dir) - 1] = '\0'; +} + // 检查管理员权限 int check_admin(void) { diff --git a/src/utils/string_ext.c b/src/utils/string_ext.c index 7065f5e..5ced6f3 100644 --- a/src/utils/string_ext.c +++ b/src/utils/string_ext.c @@ -92,6 +92,36 @@ void add_string_list(StringList *list, const char *str) list->count++; } +// 在指定索引位置插入字符串(后续元素后移) +int string_list_insert_at(StringList *list, int index, const char *str) +{ + if (!list || !str || index < 0 || index > list->count) + return -1; + + // 扩容检查 + if (list->count >= list->capacity) + { + int new_capacity = (list->capacity == 0) ? 16 : list->capacity * 2; + char **new_items = (char **)realloc(list->items, new_capacity * sizeof(char *)); + if (!new_items) + return -1; + list->items = new_items; + list->capacity = new_capacity; + } + + char *dup = _strdup(str); + if (!dup) + return -1; + + // 后移元素 + for (int i = list->count; i > index; i--) + list->items[i] = list->items[i - 1]; + + list->items[index] = dup; + list->count++; + return 0; +} + // 获取指定索引的字符串(只读) const char *string_list_get(const StringList *list, int index) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cbaf685..e9275ef 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,3 +38,6 @@ include(CTest) add_subdirectory(unit/safe_string) add_subdirectory(unit/string_ext) add_subdirectory(unit/path_manager) +add_subdirectory(unit/undo_redo) +add_subdirectory(unit/error_code) +add_subdirectory(unit/import_export) diff --git a/tests/unit/error_code/CMakeLists.txt b/tests/unit/error_code/CMakeLists.txt new file mode 100644 index 0000000..dd9f1b2 --- /dev/null +++ b/tests/unit/error_code/CMakeLists.txt @@ -0,0 +1,18 @@ +# error_code 单元测试 +add_executable(test_error_code test_error_code.c + ${CMAKE_SOURCE_DIR}/src/utils/error_code.c +) + +target_link_libraries(test_error_code cmocka) + +target_include_directories(test_error_code PRIVATE + ${CMAKE_SOURCE_DIR}/include +) + +add_custom_command(TARGET test_error_code POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_BINARY_DIR}/_deps/cmocka-build/src/cmocka.dll + $ +) + +add_test(NAME error_code_test COMMAND test_error_code) diff --git a/tests/unit/error_code/test_error_code.c b/tests/unit/error_code/test_error_code.c new file mode 100644 index 0000000..2b9ef76 --- /dev/null +++ b/tests/unit/error_code/test_error_code.c @@ -0,0 +1,107 @@ +/* + * error_code.c 单元测试 + * 测试错误码字符串映射 + */ +#include +#include +#include +#include +#include +#include "utils/error_code.h" + +/* ==================== error_code_to_string 测试 ==================== */ + +static void test_err_ok(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_OK), "Success"); +} + +static void test_err_failed(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_FAILED), "Operation failed"); +} + +static void test_err_null_ptr(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_NULL_PTR), "Null pointer error"); +} + +static void test_err_out_of_memory(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_OUT_OF_MEMORY), "Out of memory"); +} + +static void test_err_file_not_found(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_FILE_NOT_FOUND), "File not found"); +} + +static void test_err_permission_denied(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_PERMISSION_DENIED), "Permission denied"); +} + +static void test_err_invalid_format(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_INVALID_FORMAT), "Invalid format"); +} + +static void test_err_registry_failed(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_REGISTRY_FAILED), "Registry operation failed"); +} + +static void test_err_not_found(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_NOT_FOUND), "Item not found"); +} + +static void test_err_exists(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_EXISTS), "Item already exists"); +} + +static void test_err_invalid_index(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string(ERR_INVALID_INDEX), "Invalid index"); +} + +static void test_unknown_error_code(void **state) +{ + (void)state; + assert_string_equal(error_code_to_string((ErrorCode)9999), "Unknown error"); + assert_string_equal(error_code_to_string((ErrorCode)-99), "Unknown error"); +} + +/* ==================== 主函数 ==================== */ + +int main(void) +{ + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_err_ok), + cmocka_unit_test(test_err_failed), + cmocka_unit_test(test_err_null_ptr), + cmocka_unit_test(test_err_out_of_memory), + cmocka_unit_test(test_err_file_not_found), + cmocka_unit_test(test_err_permission_denied), + cmocka_unit_test(test_err_invalid_format), + cmocka_unit_test(test_err_registry_failed), + cmocka_unit_test(test_err_not_found), + cmocka_unit_test(test_err_exists), + cmocka_unit_test(test_err_invalid_index), + cmocka_unit_test(test_unknown_error_code), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/tests/unit/import_export/CMakeLists.txt b/tests/unit/import_export/CMakeLists.txt new file mode 100644 index 0000000..6f52de4 --- /dev/null +++ b/tests/unit/import_export/CMakeLists.txt @@ -0,0 +1,23 @@ +# import_export 单元测试 +add_executable(test_import_export test_import_export.c + ${CMAKE_SOURCE_DIR}/src/core/import_export.c + ${CMAKE_SOURCE_DIR}/src/utils/string_ext.c + ${CMAKE_SOURCE_DIR}/src/utils/safe_string.c + ${CMAKE_SOURCE_DIR}/src/utils/error_code.c +) + +target_link_libraries(test_import_export cmocka) + +target_include_directories(test_import_export PRIVATE + ${CMAKE_SOURCE_DIR}/include +) + +target_compile_definitions(test_import_export PRIVATE TESTING) + +add_custom_command(TARGET test_import_export POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_BINARY_DIR}/_deps/cmocka-build/src/cmocka.dll + $ +) + +add_test(NAME import_export_test COMMAND test_import_export) diff --git a/tests/unit/import_export/test_import_export.c b/tests/unit/import_export/test_import_export.c new file mode 100644 index 0000000..6b12434 --- /dev/null +++ b/tests/unit/import_export/test_import_export.c @@ -0,0 +1,324 @@ +/* + * import_export.c 单元测试 + * 测试 is_valid_path_format 和文件导入导出 + */ +#include +#include +#include +#include +#include +#include +#include +#include "core/import_export.h" +#include "utils/string_ext.h" + +/* ==================== Mock 函数 ==================== */ + +#ifdef TESTING + +void log_info(const char *fmt, ...) { (void)fmt; } +void log_debug(const char *fmt, ...) { (void)fmt; } +void log_warn(const char *fmt, ...) { (void)fmt; } +void log_error(const char *fmt, ...) { (void)fmt; } + +#endif + +/* ==================== is_valid_path_format 测试 ==================== */ + +static void test_valid_drive_path(void **state) +{ + (void)state; + assert_int_equal(is_valid_path_format("C:\\Windows"), 1); + assert_int_equal(is_valid_path_format("D:\\Program Files"), 1); + assert_int_equal(is_valid_path_format("c:\\test"), 1); +} + +static void test_valid_unc_path(void **state) +{ + (void)state; + assert_int_equal(is_valid_path_format("\\\\server\\share"), 1); +} + +static void test_valid_env_var(void **state) +{ + (void)state; + assert_int_equal(is_valid_path_format("%JAVA_HOME%\\bin"), 1); + assert_int_equal(is_valid_path_format("%PATH%"), 1); +} + +static void test_valid_relative_path(void **state) +{ + (void)state; + assert_int_equal(is_valid_path_format(".\\subdir"), 1); + assert_int_equal(is_valid_path_format("subdir\\file"), 1); + assert_int_equal(is_valid_path_format("subdir/file"), 1); +} + +static void test_null_path(void **state) +{ + (void)state; + assert_int_equal(is_valid_path_format(NULL), 0); +} + +static void test_empty_path(void **state) +{ + (void)state; + assert_int_equal(is_valid_path_format(""), 0); +} + +static void test_invalid_path(void **state) +{ + (void)state; + /* 没有分隔符、没有环境变量、不是 UNC 路径 */ + assert_int_equal(is_valid_path_format("justaname"), 0); +} + +static void test_drive_only(void **state) +{ + (void)state; + /* C: 后面没有路径分隔符 */ + assert_int_equal(is_valid_path_format("C:"), 1); /* colon后是 \0 */ +} + +/* ==================== export/import 文件测试 ==================== */ + +static void test_export_json(void **state) +{ + (void)state; + ExportData data; + init_string_list(&data.system); + init_string_list(&data.user); + add_string_list(&data.system, "C:\\Windows"); + add_string_list(&data.user, "C:\\Users\\test"); + + const char *tmpfile = "test_export.json"; + ErrorCode result = export_paths_to_file(&data, tmpfile); + assert_int_equal(result, ERR_OK); + + /* 验证文件内容 */ + FILE *fp = fopen(tmpfile, "r"); + assert_non_null(fp); + char buffer[4096]; + fread(buffer, 1, sizeof(buffer) - 1, fp); + fclose(fp); + + assert_non_null(strstr(buffer, "\"version\"")); + assert_non_null(strstr(buffer, "\"system\"")); + assert_non_null(strstr(buffer, "\"user\"")); + assert_non_null(strstr(buffer, "C:\\\\Windows")); + + remove(tmpfile); + clear_string_list(&data.system); + clear_string_list(&data.user); +} + +static void test_export_csv(void **state) +{ + (void)state; + ExportData data; + init_string_list(&data.system); + init_string_list(&data.user); + add_string_list(&data.system, "C:\\Windows"); + + const char *tmpfile = "test_export.csv"; + ErrorCode result = export_paths_to_format(&data, tmpfile, EXPORT_CSV); + assert_int_equal(result, ERR_OK); + + FILE *fp = fopen(tmpfile, "r"); + assert_non_null(fp); + char buffer[4096]; + fread(buffer, 1, sizeof(buffer) - 1, fp); + fclose(fp); + + assert_non_null(strstr(buffer, "type,path")); + assert_non_null(strstr(buffer, "system,")); + + remove(tmpfile); + clear_string_list(&data.system); + clear_string_list(&data.user); +} + +static void test_export_null_data(void **state) +{ + (void)state; + ErrorCode result = export_paths_to_file(NULL, "test.json"); + assert_int_equal(result, ERR_NULL_PTR); +} + +static void test_export_null_path(void **state) +{ + (void)state; + ExportData data; + init_string_list(&data.system); + ErrorCode result = export_paths_to_file(&data, NULL); + assert_int_equal(result, ERR_NULL_PTR); + clear_string_list(&data.system); +} + +static void test_import_txt(void **state) +{ + (void)state; + /* 创建临时 TXT 文件(二进制模式避免 \r\n 转换) */ + const char *tmpfile = "test_import.txt"; + FILE *fp = fopen(tmpfile, "wb"); + assert_non_null(fp); + fprintf(fp, "C:\\Path1\n"); + fprintf(fp, "C:\\Path2\n"); + fprintf(fp, "# comment\n"); + fprintf(fp, "\n"); + fprintf(fp, "C:\\Path3\n"); + fclose(fp); + + ExportData data; + ErrorCode result = import_paths_from_file(tmpfile, &data); + assert_int_equal(result, ERR_OK); + assert_int_equal(data.system.count, 3); + assert_string_equal(string_list_get(&data.system, 0), "C:\\Path1"); + assert_string_equal(string_list_get(&data.system, 1), "C:\\Path2"); + assert_string_equal(string_list_get(&data.system, 2), "C:\\Path3"); + + remove(tmpfile); + clear_string_list(&data.system); +} + +static void test_import_null_filepath(void **state) +{ + (void)state; + ExportData data; + ErrorCode result = import_paths_from_file(NULL, &data); + assert_int_equal(result, ERR_NULL_PTR); +} + +static void test_import_null_data(void **state) +{ + (void)state; + ErrorCode result = import_paths_from_file("test.txt", NULL); + assert_int_equal(result, ERR_NULL_PTR); +} + +static void test_import_nonexistent_file(void **state) +{ + (void)state; + ExportData data; + ErrorCode result = import_paths_from_file("nonexistent_file_12345.txt", &data); + assert_int_equal(result, ERR_FILE_NOT_FOUND); +} + +static void test_import_csv(void **state) +{ + (void)state; + /* 创建临时 CSV 文件 */ + const char *tmpfile = "test_import.csv"; + FILE *fp = fopen(tmpfile, "wb"); + assert_non_null(fp); + fprintf(fp, "\xEF\xBB\xBF"); /* UTF-8 BOM */ + fprintf(fp, "type,path\n"); + fprintf(fp, "system,C:\\Windows\n"); + fprintf(fp, "system,C:\\Program Files\n"); + fprintf(fp, "user,C:\\Users\\test\n"); + fclose(fp); + + ExportData data; + ErrorCode result = import_paths_from_file(tmpfile, &data); + assert_int_equal(result, ERR_OK); + assert_int_equal(data.system.count, 2); + assert_int_equal(data.user.count, 1); + assert_string_equal(string_list_get(&data.system, 0), "C:\\Windows"); + assert_string_equal(string_list_get(&data.system, 1), "C:\\Program Files"); + assert_string_equal(string_list_get(&data.user, 0), "C:\\Users\\test"); + + remove(tmpfile); + clear_string_list(&data.system); + clear_string_list(&data.user); +} + +static void test_import_csv_quoted(void **state) +{ + (void)state; + /* 创建带引号字段的 CSV 文件 */ + const char *tmpfile = "test_import_quoted.csv"; + FILE *fp = fopen(tmpfile, "wb"); + assert_non_null(fp); + fprintf(fp, "type,path\n"); + fprintf(fp, "system,\"C:\\Program Files\"\n"); + fprintf(fp, "user,\"C:\\My\"\"Path\"\n"); /* 转义引号 */ + fclose(fp); + + ExportData data; + ErrorCode result = import_paths_from_file(tmpfile, &data); + assert_int_equal(result, ERR_OK); + assert_int_equal(data.system.count, 1); + assert_int_equal(data.user.count, 1); + assert_string_equal(string_list_get(&data.system, 0), "C:\\Program Files"); + assert_string_equal(string_list_get(&data.user, 0), "C:\\My\"Path"); + + remove(tmpfile); + clear_string_list(&data.system); + clear_string_list(&data.user); +} + +static void test_export_csv_format(void **state) +{ + (void)state; + ExportData data; + init_string_list(&data.system); + init_string_list(&data.user); + add_string_list(&data.system, "C:\\Windows"); + add_string_list(&data.user, "C:\\Users"); + + const char *tmpfile = "test_csv_format.csv"; + ErrorCode result = export_paths_to_format(&data, tmpfile, EXPORT_CSV); + assert_int_equal(result, ERR_OK); + + /* 验证 CSV 格式:type,path(不带双重引号) */ + FILE *fp = fopen(tmpfile, "rb"); + assert_non_null(fp); + char buffer[4096]; + fread(buffer, 1, sizeof(buffer) - 1, fp); + fclose(fp); + + assert_non_null(strstr(buffer, "system,\"C:\\Windows\"")); + assert_non_null(strstr(buffer, "user,\"C:\\Users\"")); + /* 不应该有双重引号 */ + assert_null(strstr(buffer, "\"\"")); + + remove(tmpfile); + clear_string_list(&data.system); + clear_string_list(&data.user); +} + +/* ==================== 主函数 ==================== */ + +int main(void) +{ + const struct CMUnitTest tests[] = { + /* is_valid_path_format */ + cmocka_unit_test(test_valid_drive_path), + cmocka_unit_test(test_valid_unc_path), + cmocka_unit_test(test_valid_env_var), + cmocka_unit_test(test_valid_relative_path), + cmocka_unit_test(test_null_path), + cmocka_unit_test(test_empty_path), + cmocka_unit_test(test_invalid_path), + cmocka_unit_test(test_drive_only), + + /* export */ + cmocka_unit_test(test_export_json), + cmocka_unit_test(test_export_csv), + cmocka_unit_test(test_export_null_data), + cmocka_unit_test(test_export_null_path), + + /* import */ + cmocka_unit_test(test_import_txt), + cmocka_unit_test(test_import_null_filepath), + cmocka_unit_test(test_import_null_data), + cmocka_unit_test(test_import_nonexistent_file), + cmocka_unit_test(test_import_csv), + cmocka_unit_test(test_import_csv_quoted), + + /* CSV format */ + cmocka_unit_test(test_export_csv_format), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/tests/unit/string_ext/test_string_ext.c b/tests/unit/string_ext/test_string_ext.c index 1edb428..761bdcf 100644 --- a/tests/unit/string_ext/test_string_ext.c +++ b/tests/unit/string_ext/test_string_ext.c @@ -214,6 +214,150 @@ static void test_clear_string_list(void **state) assert_null(list.items); } +/* ==================== string_list_insert_at 测试 ==================== */ + +static void test_insert_at_beginning(void **state) +{ + (void)state; + StringList list; + init_string_list(&list); + + add_string_list(&list, "B"); + add_string_list(&list, "C"); + + int result = string_list_insert_at(&list, 0, "A"); + + assert_int_equal(result, 0); + assert_int_equal(list.count, 3); + assert_string_equal(string_list_get(&list, 0), "A"); + assert_string_equal(string_list_get(&list, 1), "B"); + assert_string_equal(string_list_get(&list, 2), "C"); + + clear_string_list(&list); +} + +static void test_insert_at_middle(void **state) +{ + (void)state; + StringList list; + init_string_list(&list); + + add_string_list(&list, "A"); + add_string_list(&list, "C"); + + int result = string_list_insert_at(&list, 1, "B"); + + assert_int_equal(result, 0); + assert_int_equal(list.count, 3); + assert_string_equal(string_list_get(&list, 0), "A"); + assert_string_equal(string_list_get(&list, 1), "B"); + assert_string_equal(string_list_get(&list, 2), "C"); + + clear_string_list(&list); +} + +static void test_insert_at_end(void **state) +{ + (void)state; + StringList list; + init_string_list(&list); + + add_string_list(&list, "A"); + + int result = string_list_insert_at(&list, 1, "B"); + + assert_int_equal(result, 0); + assert_int_equal(list.count, 2); + assert_string_equal(string_list_get(&list, 0), "A"); + assert_string_equal(string_list_get(&list, 1), "B"); + + clear_string_list(&list); +} + +static void test_insert_at_empty_list(void **state) +{ + (void)state; + StringList list; + init_string_list(&list); + + int result = string_list_insert_at(&list, 0, "A"); + + assert_int_equal(result, 0); + assert_int_equal(list.count, 1); + assert_string_equal(string_list_get(&list, 0), "A"); + + clear_string_list(&list); +} + +static void test_insert_at_invalid_index(void **state) +{ + (void)state; + StringList list; + init_string_list(&list); + + add_string_list(&list, "A"); + + assert_int_equal(string_list_insert_at(&list, -1, "B"), -1); + assert_int_equal(string_list_insert_at(&list, 5, "B"), -1); + assert_int_equal(list.count, 1); + + clear_string_list(&list); +} + +static void test_insert_at_null(void **state) +{ + (void)state; + assert_int_equal(string_list_insert_at(NULL, 0, "A"), -1); + + StringList list; + init_string_list(&list); + assert_int_equal(string_list_insert_at(&list, 0, NULL), -1); + + clear_string_list(&list); +} + +/* ==================== string_list_contains 测试 ==================== */ + +static void test_contains_found(void **state) +{ + (void)state; + StringList list; + init_string_list(&list); + + add_string_list(&list, "C:\\Windows"); + add_string_list(&list, "C:\\Program Files"); + + assert_int_equal(string_list_contains(&list, "C:\\Windows"), 1); + assert_int_equal(string_list_contains(&list, "c:\\windows"), 1); /* 不区分大小写 */ + + clear_string_list(&list); +} + +static void test_contains_not_found(void **state) +{ + (void)state; + StringList list; + init_string_list(&list); + + add_string_list(&list, "C:\\Windows"); + + assert_int_equal(string_list_contains(&list, "D:\\Tools"), 0); + + clear_string_list(&list); +} + +static void test_contains_null(void **state) +{ + (void)state; + assert_int_equal(string_list_contains(NULL, "test"), 0); + + StringList list; + init_string_list(&list); + assert_int_equal(string_list_contains(&list, NULL), 0); + + clear_string_list(&list); +} + /* ==================== 编码转换测试 ==================== */ static void test_utf8_to_wide_normal(void **state) @@ -335,6 +479,19 @@ int main(void) cmocka_unit_test(test_string_list_set_null_list), cmocka_unit_test(test_clear_string_list), + /* insert_at 测试 */ + cmocka_unit_test(test_insert_at_beginning), + cmocka_unit_test(test_insert_at_middle), + cmocka_unit_test(test_insert_at_end), + cmocka_unit_test(test_insert_at_empty_list), + cmocka_unit_test(test_insert_at_invalid_index), + cmocka_unit_test(test_insert_at_null), + + /* contains 测试 */ + cmocka_unit_test(test_contains_found), + cmocka_unit_test(test_contains_not_found), + cmocka_unit_test(test_contains_null), + /* 编码转换测试 */ cmocka_unit_test(test_utf8_to_wide_normal), cmocka_unit_test(test_utf8_to_wide_null), diff --git a/tests/unit/undo_redo/CMakeLists.txt b/tests/unit/undo_redo/CMakeLists.txt new file mode 100644 index 0000000..b75b718 --- /dev/null +++ b/tests/unit/undo_redo/CMakeLists.txt @@ -0,0 +1,24 @@ +# undo_redo 单元测试 +add_executable(test_undo_redo test_undo_redo.c + ${CMAKE_SOURCE_DIR}/src/core/undo_redo.c + ${CMAKE_SOURCE_DIR}/src/core/path_manager.c + ${CMAKE_SOURCE_DIR}/src/utils/string_ext.c + ${CMAKE_SOURCE_DIR}/src/utils/safe_string.c + ${CMAKE_SOURCE_DIR}/src/utils/error_code.c +) + +target_link_libraries(test_undo_redo cmocka) + +target_include_directories(test_undo_redo PRIVATE + ${CMAKE_SOURCE_DIR}/include +) + +target_compile_definitions(test_undo_redo PRIVATE TESTING) + +add_custom_command(TARGET test_undo_redo POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_BINARY_DIR}/_deps/cmocka-build/src/cmocka.dll + $ +) + +add_test(NAME undo_redo_test COMMAND test_undo_redo) diff --git a/tests/unit/undo_redo/test_undo_redo.c b/tests/unit/undo_redo/test_undo_redo.c new file mode 100644 index 0000000..d32f89f --- /dev/null +++ b/tests/unit/undo_redo/test_undo_redo.c @@ -0,0 +1,637 @@ +/* + * undo_redo.c 单元测试 + * 测试撤销/重做管理器 + */ +#include +#include +#include +#include +#include +#include +#include "core/undo_redo.h" +#include "core/path_manager.h" +#include "utils/string_ext.h" + +/* ==================== Mock 函数 ==================== */ + +#ifdef TESTING + +int is_path_valid(const char *path) +{ + (void)path; + return 1; +} + +void log_info(const char *fmt, ...) { (void)fmt; } +void log_debug(const char *fmt, ...) { (void)fmt; } +void log_warn(const char *fmt, ...) { (void)fmt; } +void log_error(const char *fmt, ...) { (void)fmt; } + +#endif + +/* ==================== 辅助函数 ==================== */ + +static OpRecord make_add_record(TargetType target, const char *path) +{ + OpRecord rec; + memset(&rec, 0, sizeof(rec)); + rec.type = OP_ADD; + rec.target = target; + rec.index = -1; + rec.count = 1; + rec.old_paths = NULL; + char **np = (char **)malloc(sizeof(char *)); + np[0] = _strdup(path); + rec.new_paths = np; + return rec; +} + +static OpRecord make_delete_record(TargetType target, int index, const char *path) +{ + OpRecord rec; + memset(&rec, 0, sizeof(rec)); + rec.type = OP_DELETE; + rec.target = target; + rec.index = index; + rec.count = 1; + char **op = (char **)malloc(sizeof(char *)); + op[0] = _strdup(path); + rec.old_paths = op; + rec.new_paths = NULL; + return rec; +} + +static OpRecord make_edit_record(TargetType target, int index, const char *old_path, const char *new_path) +{ + OpRecord rec; + memset(&rec, 0, sizeof(rec)); + rec.type = OP_EDIT; + rec.target = target; + rec.index = index; + rec.count = 1; + char **op = (char **)malloc(sizeof(char *)); + op[0] = _strdup(old_path); + rec.old_paths = op; + char **np = (char **)malloc(sizeof(char *)); + np[0] = _strdup(new_path); + rec.new_paths = np; + return rec; +} + +static OpRecord make_move_record(OperationType type, TargetType target, int index) +{ + OpRecord rec; + memset(&rec, 0, sizeof(rec)); + rec.type = type; + rec.target = target; + rec.index = index; + rec.count = 1; + rec.old_paths = NULL; + rec.new_paths = NULL; + return rec; +} + +static OpRecord make_clean_record(TargetType target, StringList *old_list) +{ + OpRecord rec; + memset(&rec, 0, sizeof(rec)); + rec.type = OP_CLEAN; + rec.target = target; + rec.index = -1; + rec.count = old_list->count; + if (old_list->count > 0) + { + char **op = (char **)malloc(old_list->count * sizeof(char *)); + for (int i = 0; i < old_list->count; i++) + op[i] = _strdup(old_list->items[i]); + rec.old_paths = op; + } + else + { + rec.old_paths = NULL; + } + rec.new_paths = NULL; + return rec; +} + +/* ==================== 创建/销毁测试 ==================== */ + +static void test_create_manager(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + assert_non_null(mgr); + assert_int_equal(mgr->max_size, 10); + assert_int_equal(mgr->current, -1); + assert_int_equal(mgr->count, 0); + destroy_undo_redo_manager(mgr); +} + +static void test_create_manager_default_size(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(0); + assert_non_null(mgr); + assert_int_equal(mgr->max_size, 50); /* DEFAULT_MAX_UNDO_RECORDS */ + destroy_undo_redo_manager(mgr); +} + +static void test_destroy_null(void **state) +{ + (void)state; + destroy_undo_redo_manager(NULL); /* 不应崩溃 */ +} + +/* ==================== can_undo/can_redo 测试 ==================== */ + +static void test_can_undo_redo_empty(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + assert_int_equal(can_undo(mgr), 0); + assert_int_equal(can_redo(mgr), 0); + destroy_undo_redo_manager(mgr); +} + +static void test_can_undo_null(void **state) +{ + (void)state; + assert_int_equal(can_undo(NULL), 0); + assert_int_equal(can_redo(NULL), 0); +} + +/* ==================== push_undo_record 测试 ==================== */ + +static void test_push_record(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + OpRecord rec = make_add_record(TARGET_USER, "C:\\Test"); + + int result = push_undo_record(mgr, &rec); + + assert_int_equal(result, 0); + assert_int_equal(mgr->count, 1); + assert_int_equal(mgr->current, 0); + assert_int_equal(can_undo(mgr), 1); + assert_int_equal(can_redo(mgr), 0); + + /* 清理 */ + free(rec.new_paths[0]); + free(rec.new_paths); + destroy_undo_redo_manager(mgr); +} + +static void test_push_null_mgr(void **state) +{ + (void)state; + OpRecord rec = make_add_record(TARGET_USER, "C:\\Test"); + int result = push_undo_record(NULL, &rec); + assert_int_equal(result, -1); + free(rec.new_paths[0]); + free(rec.new_paths); +} + +static void test_push_null_record(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + int result = push_undo_record(mgr, NULL); + assert_int_equal(result, -1); + destroy_undo_redo_manager(mgr); +} + +/* ==================== OP_ADD undo/redo 测试 ==================== */ + +static void test_undo_add(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + /* 添加路径 */ + add_string_list(&user, "C:\\Test"); + OpRecord rec = make_add_record(TARGET_USER, "C:\\Test"); + push_undo_record(mgr, &rec); + + /* 撤销添加 */ + int result = undo(mgr, &sys, &user); + assert_int_equal(result, 0); + assert_int_equal(user.count, 0); + assert_int_equal(can_redo(mgr), 1); + + free(rec.new_paths[0]); + free(rec.new_paths); + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +static void test_redo_add(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + add_string_list(&user, "C:\\Test"); + OpRecord rec = make_add_record(TARGET_USER, "C:\\Test"); + push_undo_record(mgr, &rec); + + undo(mgr, &sys, &user); + assert_int_equal(user.count, 0); + + /* 重做添加 */ + int result = redo(mgr, &sys, &user); + assert_int_equal(result, 0); + assert_int_equal(user.count, 1); + assert_string_equal(string_list_get(&user, 0), "C:\\Test"); + + free(rec.new_paths[0]); + free(rec.new_paths); + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +/* ==================== OP_DELETE undo/redo 测试 ==================== */ + +static void test_undo_delete(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + add_string_list(&user, "C:\\Path1"); + add_string_list(&user, "C:\\Path2"); + + /* 记录删除操作 */ + OpRecord rec = make_delete_record(TARGET_USER, 0, "C:\\Path1"); + push_undo_record(mgr, &rec); + + /* 模拟删除 */ + path_manager_remove_at(&user, 0); + assert_int_equal(user.count, 1); + + /* 撤销删除 */ + int result = undo(mgr, &sys, &user); + assert_int_equal(result, 0); + assert_int_equal(user.count, 2); + + free(rec.old_paths[0]); + free(rec.old_paths); + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +static void test_redo_delete(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + add_string_list(&user, "C:\\Path1"); + add_string_list(&user, "C:\\Path2"); + + OpRecord rec = make_delete_record(TARGET_USER, 0, "C:\\Path1"); + push_undo_record(mgr, &rec); + + path_manager_remove_at(&user, 0); + undo(mgr, &sys, &user); + assert_int_equal(user.count, 2); + + /* 重做删除 */ + int result = redo(mgr, &sys, &user); + assert_int_equal(result, 0); + assert_int_equal(user.count, 1); + assert_string_equal(string_list_get(&user, 0), "C:\\Path2"); + + free(rec.old_paths[0]); + free(rec.old_paths); + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +/* ==================== OP_EDIT undo/redo 测试 ==================== */ + +static void test_undo_edit(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + add_string_list(&user, "C:\\Old"); + + OpRecord rec = make_edit_record(TARGET_USER, 0, "C:\\Old", "C:\\New"); + push_undo_record(mgr, &rec); + + /* 模拟编辑 */ + string_list_set(&user, 0, "C:\\New"); + assert_string_equal(string_list_get(&user, 0), "C:\\New"); + + /* 撤销编辑 */ + undo(mgr, &sys, &user); + assert_string_equal(string_list_get(&user, 0), "C:\\Old"); + + free(rec.old_paths[0]); + free(rec.old_paths); + free(rec.new_paths[0]); + free(rec.new_paths); + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +static void test_redo_edit(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + add_string_list(&user, "C:\\Old"); + + OpRecord rec = make_edit_record(TARGET_USER, 0, "C:\\Old", "C:\\New"); + push_undo_record(mgr, &rec); + + string_list_set(&user, 0, "C:\\New"); + undo(mgr, &sys, &user); + + /* 重做编辑 */ + redo(mgr, &sys, &user); + assert_string_equal(string_list_get(&user, 0), "C:\\New"); + + free(rec.old_paths[0]); + free(rec.old_paths); + free(rec.new_paths[0]); + free(rec.new_paths); + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +/* ==================== OP_MOVE undo/redo 测试 ==================== */ + +static void test_undo_move_up(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + add_string_list(&user, "A"); + add_string_list(&user, "B"); + add_string_list(&user, "C"); + + /* 记录上移操作 (index=2, C 上移到 B 前面) */ + OpRecord rec = make_move_record(OP_MOVE_UP, TARGET_USER, 2); + push_undo_record(mgr, &rec); + + /* 模拟上移 */ + path_manager_move_up(&user, 2); + assert_string_equal(string_list_get(&user, 0), "A"); + assert_string_equal(string_list_get(&user, 1), "C"); + assert_string_equal(string_list_get(&user, 2), "B"); + + /* 撤销上移 */ + undo(mgr, &sys, &user); + assert_string_equal(string_list_get(&user, 0), "A"); + assert_string_equal(string_list_get(&user, 1), "B"); + assert_string_equal(string_list_get(&user, 2), "C"); + + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +static void test_redo_move_up(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + add_string_list(&user, "A"); + add_string_list(&user, "B"); + add_string_list(&user, "C"); + + OpRecord rec = make_move_record(OP_MOVE_UP, TARGET_USER, 2); + push_undo_record(mgr, &rec); + + path_manager_move_up(&user, 2); + undo(mgr, &sys, &user); + + /* 重做上移 */ + redo(mgr, &sys, &user); + assert_string_equal(string_list_get(&user, 1), "C"); + assert_string_equal(string_list_get(&user, 2), "B"); + + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +/* ==================== OP_CLEAN undo/redo 测试 ==================== */ + +static void test_undo_clean(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + add_string_list(&user, "C:\\Valid"); + add_string_list(&user, "C:\\Invalid"); + add_string_list(&user, "C:\\AlsoValid"); + + /* 记录清理前的列表 */ + OpRecord rec = make_clean_record(TARGET_USER, &user); + push_undo_record(mgr, &rec); + + /* 模拟清理(清空) */ + clear_string_list(&user); + assert_int_equal(user.count, 0); + + /* 撤销清理 */ + undo(mgr, &sys, &user); + assert_int_equal(user.count, 3); + assert_string_equal(string_list_get(&user, 0), "C:\\Valid"); + assert_string_equal(string_list_get(&user, 1), "C:\\Invalid"); + assert_string_equal(string_list_get(&user, 2), "C:\\AlsoValid"); + + /* 清理 OpRecord 中的 old_paths */ + for (int i = 0; i < rec.count; i++) + free(rec.old_paths[i]); + free(rec.old_paths); + + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +/* ==================== 连续 undo/redo 测试 ==================== */ + +static void test_multiple_undo_redo(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + /* 3 次添加 */ + OpRecord r1 = make_add_record(TARGET_USER, "A"); + OpRecord r2 = make_add_record(TARGET_USER, "B"); + OpRecord r3 = make_add_record(TARGET_USER, "C"); + + add_string_list(&user, "A"); + push_undo_record(mgr, &r1); + add_string_list(&user, "B"); + push_undo_record(mgr, &r2); + add_string_list(&user, "C"); + push_undo_record(mgr, &r3); + + assert_int_equal(user.count, 3); + + /* 连续撤销 3 次 */ + undo(mgr, &sys, &user); + undo(mgr, &sys, &user); + undo(mgr, &sys, &user); + assert_int_equal(user.count, 0); + assert_int_equal(can_undo(mgr), 0); + assert_int_equal(can_redo(mgr), 1); + + /* 连续重做 3 次 */ + redo(mgr, &sys, &user); + redo(mgr, &sys, &user); + redo(mgr, &sys, &user); + assert_int_equal(user.count, 3); + assert_int_equal(can_redo(mgr), 0); + + free(r1.new_paths[0]); free(r1.new_paths); + free(r2.new_paths[0]); free(r2.new_paths); + free(r3.new_paths[0]); free(r3.new_paths); + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +/* ==================== 空栈 undo/redo 测试 ==================== */ + +static void test_undo_empty_stack(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + int result = undo(mgr, &sys, &user); + assert_int_equal(result, -1); + + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +static void test_redo_empty_stack(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + StringList sys, user; + init_string_list(&sys); + init_string_list(&user); + + int result = redo(mgr, &sys, &user); + assert_int_equal(result, -1); + + clear_string_list(&sys); + clear_string_list(&user); + destroy_undo_redo_manager(mgr); +} + +/* ==================== clear_undo_redo_history 测试 ==================== */ + +static void test_clear_history(void **state) +{ + (void)state; + UndoRedoManager *mgr = create_undo_redo_manager(10); + + OpRecord rec = make_add_record(TARGET_USER, "C:\\Test"); + push_undo_record(mgr, &rec); + assert_int_equal(mgr->count, 1); + + clear_undo_redo_history(mgr); + assert_int_equal(mgr->count, 0); + assert_int_equal(mgr->current, -1); + assert_int_equal(can_undo(mgr), 0); + + free(rec.new_paths[0]); + free(rec.new_paths); + destroy_undo_redo_manager(mgr); +} + +/* ==================== 主函数 ==================== */ + +int main(void) +{ + const struct CMUnitTest tests[] = { + /* 创建/销毁 */ + cmocka_unit_test(test_create_manager), + cmocka_unit_test(test_create_manager_default_size), + cmocka_unit_test(test_destroy_null), + + /* can_undo/can_redo */ + cmocka_unit_test(test_can_undo_redo_empty), + cmocka_unit_test(test_can_undo_null), + + /* push_undo_record */ + cmocka_unit_test(test_push_record), + cmocka_unit_test(test_push_null_mgr), + cmocka_unit_test(test_push_null_record), + + /* OP_ADD */ + cmocka_unit_test(test_undo_add), + cmocka_unit_test(test_redo_add), + + /* OP_DELETE */ + cmocka_unit_test(test_undo_delete), + cmocka_unit_test(test_redo_delete), + + /* OP_EDIT */ + cmocka_unit_test(test_undo_edit), + cmocka_unit_test(test_redo_edit), + + /* OP_MOVE */ + cmocka_unit_test(test_undo_move_up), + cmocka_unit_test(test_redo_move_up), + + /* OP_CLEAN */ + cmocka_unit_test(test_undo_clean), + + /* 连续操作 */ + cmocka_unit_test(test_multiple_undo_redo), + + /* 边界情况 */ + cmocka_unit_test(test_undo_empty_stack), + cmocka_unit_test(test_redo_empty_stack), + cmocka_unit_test(test_clear_history), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +}