diff --git a/TeXmacs/misc/themes/liii-night.css b/TeXmacs/misc/themes/liii-night.css index 3cda0e16cf..fc15a25d54 100644 --- a/TeXmacs/misc/themes/liii-night.css +++ b/TeXmacs/misc/themes/liii-night.css @@ -1018,42 +1018,17 @@ QWidget#centralWidget QWidget#startup-tab-content { } QWidget#centralWidget QWidget#startup-tab-file-cards, -QWidget#centralWidget QWidget#startup-tab-category-bar, QWidget#centralWidget QWidget#startup-tab-template-item, QWidget#centralWidget QLabel#startup-tab-template-name, QWidget#centralWidget QLabel#startup-tab-template-info { background-color: #2c2c2c; } -/* 页面标题 */ -QWidget#centralWidget QLabel#startup-tab-page-title { - font-weight: bold; - color: #ffffff; - background: #2c2c2c; -} - /* 页面描述文字 */ QLabel#startup-tab-page-desc { color: #aaaaaa; } -/* 分类按钮 */ -QPushButton#startup-tab-category-btn { - background: transparent; - border: none; - color: #aaaaaa; -} - -QPushButton#startup-tab-category-btn:hover { - background: #4a4f57; - color: #ffffff; -} - -QPushButton#startup-tab-category-btn:checked { - background: #215a6a; - color: white; -} - /* 模板网格容器 */ QWidget#centralWidget QWidget#startup-tab-grid { background-color: #2c2c2c; diff --git a/TeXmacs/misc/themes/liii.css b/TeXmacs/misc/themes/liii.css index cd3990976c..0ff28cc019 100644 --- a/TeXmacs/misc/themes/liii.css +++ b/TeXmacs/misc/themes/liii.css @@ -1021,34 +1021,11 @@ QWidget#startup-tab-content { background-color: #f3f3f3; } -/* 页面标题 */ -QLabel#startup-tab-page-title { - font-weight: bold; - color: #215a6a; -} - /* 页面描述文字 */ QLabel#startup-tab-page-desc { color: #666666; } -/* 分类按钮 */ -QPushButton#startup-tab-category-btn { - background: transparent; - border: none; - color: #666666; -} - -QPushButton#startup-tab-category-btn:hover { - background: #dfdfdf; - color: #333333; -} - -QPushButton#startup-tab-category-btn:checked { - background: #215a6a; - color: white; -} - /* 模板网格容器 */ QWidget#startup-tab-grid { background-color: #f3f3f3; diff --git a/TeXmacs/templates/categories.scm b/TeXmacs/templates/categories.scm index 53befaf290..4723b77be0 100644 --- a/TeXmacs/templates/categories.scm +++ b/TeXmacs/templates/categories.scm @@ -14,25 +14,32 @@ (texmacs-module (templates categories)) (tm-define template-default-categories - '(((id . "university-thesis") - (name . "University Thesis") - (icon . "🎓") - (order . 1)) + '(((categoryKey . "university-thesis") + (name . "高校论文") + (nameEn . "University Thesis") + (description . "各高校学位论文模板") + (order . 1) + (templateCount . 15)) - ((id . "lab-report") - (name . "Lab Report") - (icon . "📊") - (order . 2)) + ((categoryKey . "lab-report") + (name . "实验报告") + (nameEn . "Lab Report") + (description . "各类实验报告模板") + (order . 2) + (templateCount . 10)) - ((id . "math-modeling") - (name . "Math Modeling") - (icon . "🧪") - (order . 3)))) + ((categoryKey . "math-modeling") + (name . "数学建模") + (nameEn . "Math Modeling") + (description . "数学建模竞赛论文模板") + (order . 3) + (templateCount . 8)))) (tm-define (template-get-category-name category-id) (:synopsis "Get the display name for a category") (let ((cat (list-find template-default-categories - (lambda (c) (equal? (assoc-ref c 'id) category-id))))) + (lambda (c) + (equal? (assoc-ref c 'categoryKey) category-id))))) (if cat (assoc-ref cat 'name) category-id))) diff --git a/devel/1012.md b/devel/1012.md new file mode 100644 index 0000000000..88158eec8b --- /dev/null +++ b/devel/1012.md @@ -0,0 +1,267 @@ +# [1012] TemplateCenter 启动流程重构 + +## 1 相关文档 +- [dddd.md](dddd.md) - 任务文档模板 + +## 2 任务相关的代码文件 +- `src/Mogan/TemplateCenter/template_api.cpp` - API 客户端,解析 liiistem.cn 响应 +- `src/Mogan/TemplateCenter/template_api.hpp` - API 客户端头文件 +- `src/Mogan/TemplateCenter/template_cache.cpp` - 缓存读写逻辑 +- `src/Mogan/TemplateCenter/template_cache.hpp` - 缓存头文件 +- `src/Mogan/TemplateCenter/template_manager.cpp` - 模板管理器主逻辑 +- `src/Mogan/TemplateCenter/template_manager.hpp` - 模板管理器头文件 +- `src/Plugins/Qt/QTMStartupTabWidget.cpp` - 启动页左侧导航栏(动态分类按钮) +- `src/Plugins/Qt/QTMTemplatePage.cpp` - 模板页(模板卡片网格) +- `src/Plugins/Qt/QTMTemplateOpener.cpp` - 模板打开器(MD5 校验入口) +- `TeXmacs/templates/categories.scm` - 默认分类 Scheme 配置 + +## 3 如何测试 + +### 3.1 确定性测试(单元测试) + +#### TemplateCache 测试 + +```bash +./bin/test_only template_cache_test +``` + +测试覆盖: +- 分类缓存的保存与加载(含字段映射) +- 损坏缓存文件自动清理 +- 模板注册、查询、删除 +- 缓存大小计算 +- 清空缓存 + +#### TemplateAPI 解析测试 + +```bash +./bin/test_only template_api_parser_test +``` + +测试覆盖: +- categories 响应解析与排序 +- templates 字段映射(categoryKey、url、pdfUrl、fileMd5 等) +- created_at/updated_at 字段回退兼容 +- 空数组/非数组异常处理 + +#### TemplateAPI 集成测试 + +```bash +./bin/test_only template_api_integration_test +``` + +测试覆盖: +- 分类获取成功/失败/离线阻断 +- 模板获取成功/失败 +- 下载成功/失败/取消 +- 下载进度信号 +- HTTP 错误码处理(404 等) +- 并发下载 +- 析构时清理活跃请求 + +#### StartupTabWidget 测试 + +```bash +./bin/test_only startup_tab_widget_test +``` + +测试覆盖: +- 页面构造与初始化 +- templatesLoaded 信号触发网格刷新 +- setCategory 不崩溃 +- refreshGrid 不崩溃 +- setCategory 带 displayName +- resizeEvent 不崩溃 + +### 3.2 非确定性测试(文档验证) + +#### 首次启动(无缓存) + +**前置条件**:删除缓存目录 +位于`$TEXMACS_HOME_PATH/system/template_cache` + +**验证步骤**: +1. 启动 Mogan STEM +2. 进入启动页左侧"模板"分类导航 +3. 预期显示默认分类:高校论文、实验报告、数学建模 +4. 点击"高校论文" +5. 右侧模板卡片网格为空或显示加载状态 +6. 等待网络响应后显示该分类模板 + +#### 二次启动(有缓存) + +**前置条件**:正常退出后再次启动 + +**验证步骤**: +1. 启动 Mogan STEM +2. 进入启动页左侧"模板"分类导航 +3. 预期分类列表立即可见(毫秒级,从缓存加载) +4. 点击已缓存过的分类 +5. 预期模板卡片立即显示(从缓存加载) + +#### 网络恢复自动刷新 + +**前置条件**:断开网络后启动,再恢复网络 + +**验证步骤**: +1. 断开网络,启动 Mogan STEM +2. 显示缓存的分类和模板(如有) +3. 恢复网络连接 +4. 预期后台自动请求远程分类 +5. 如有更新,左侧导航栏自动刷新 + +#### MD5 校验 + +**前置条件**:已下载过某个模板 + +**验证步骤**: +1. 找到已下载模板的缓存文件(`$TEXMACS_HOME_PATH/system/templates/xxx.tmu`) +2. 用文本编辑器修改文件内容(破坏 MD5) +3. 再次点击该模板 +4. 预期自动清理损坏缓存,重新下载 +5. 终端日志 + - 校验结果一致: + ```bash + [Template] "elegantbook" MD5 verified: "fc2ad78a8bd9f84617ccf224baf72503" + ``` + - 校验结果不一致: + ```bash + [Template] "elegantbook" MD5 mismatch (expected: "fc2ad78a8bd9f84617ccf224baf72503" actual: "ac101be348f706af9f63835f6da17632" ), clearing cache + [Template] Removed cached template file: "C:/Users/26221/AppData/Roaming/moganlab/system/templates/elegantbook.tmu" + ``` + +## 4 提交说明 + +提交前执行以下最少步骤: + +```bash +xmake b stem +./bin/test_only template_cache_test +./bin/test_only template_api_parser_test +./bin/test_only template_api_integration_test +./bin/test_only startup_tab_widget_test +``` + +## 5 What + +1. **API 从静态 JSON 改为 POST 分页接口**:liiistem.cn 原提供 `templates.json` 静态文件(GET 请求),现改为 `/api/v1/doc/template/categories` + `/api/v1/doc/template/list` 两个 POST 接口,支持分类列表和分页模板列表 +2. **UI 分类导航重构**:将分类按钮从 TemplatePage 内部移到 StartupTabWidget 左侧边栏,实现动态加载 +3. **按需加载模板**:点击分类后才请求该分类的模板列表,避免一次性加载全部模板 +4. **MD5 文件校验**:下载后计算 MD5,`QTMTemplateOpener` 打开模板时通过 `verifyLocalTemplate` 验证文件完整性 +5. **代码审查修复**: + - `computeFileMd5` 改为 64KB 分块读取,避免大文件 OOM + - `isTemplateAvailableLocally` 恢复为纯查询(const),MD5 校验拆分到 `verifyLocalTemplate` + - `QTMTemplateOpener` 使用 `verifyLocalTemplate` 触发 MD5 校验 + - 增量更新检测改为 `pendingIncrementalCategoryId_` 显式标记 + - `updatedAt` 字段支持 `updateTime`/`updated_at` 回退 + - 补充中文注释和单元测试 + +## 6 Why + +1. **静态 JSON 无法扩展**:`templates.json` 随着模板增多体积越来越大,改为 POST 分页接口可按需加载 +2. **启动性能优化**:首次打开模板页时不需要等待全部模板加载,分类按需加载 +3. **离线可用**:缓存机制确保无网络时仍能展示分类和已下载模板 +4. **代码质量**:review 中发现 `const_cast`、脆弱的增量检测逻辑、大文件 MD5 OOM 风险等问题需要修复 + +## 7 How + +### 7.1 启动流程 + +``` +[启动] + | + v +[TemplateManager::initialize] + | + +-- 初始化缓存 + +-- 加载分类(关键分支) + | 有缓存? 从 cache 加载 + | 无缓存? 从 Scheme 文件加载默认分类 + +-- 加载模板缓存(有缓存时恢复) + +-- 后台请求远程分类 + | + v +[UI 显示左侧分类导航] +``` + +### 7.2 有缓存 vs 无缓存 + +| 阶段 | 有缓存 | 无缓存 | +|---|---|---| +| 分类来源 | `cache/categories.json` | `TeXmacs/templates/categories.scm` | +| 模板来源 | `cache/metadata.json` + `cache/index.json` | 无,点击分类后网络加载 | +| 显示速度 | 毫秒级 | 毫秒级(分类),模板需网络请求 | +| 网络请求 | 后台刷新分类 | 后台刷新分类 | + +### 7.3 点击分类后的流程 + +``` +[点击分类按钮] + | + v +[StartupTabWidget] + 记录当前分类 + 通知 TemplatePage 显示该分类 + 通知 TemplateManager 请求该分类模板 + | + +-->[TemplatePage] 显示分类标题 + | 如果已缓存模板,直接显示 + | + +-->[TemplateManager] + 检查是否已获取过(避免重复请求) + POST /api/v1/doc/template/list + 参数: { "categoryKey": "xxx" } + 响应成功后更新模板列表 + 通知 TemplatePage 刷新 +``` + +### 7.4 增量更新检测 + +旧方案通过分析返回数据推断是否为增量请求,逻辑脆弱。 + +新方案显式标记: + +```cpp +// 发起请求时记录 +pendingIncrementalCategoryId_ = categoryId; +api_->fetchTemplates(categoryId); + +// 响应处理时直接使用 +bool incremental = !pendingIncrementalCategoryId_.isEmpty(); +mergeMetadata(remoteMetadata, incremental); +``` + +### 7.5 MD5 校验 + +``` +[下载完成] + | + v +[计算文件 MD5] + | + v +[写入缓存索引] + | + v +[后续检查] + MD5 匹配 -> 可用 + MD5 不匹配 -> 删除缓存,重新下载 +``` + +### 7.6 关键状态变量 + +| 变量 | 含义 | +|---|---| +| `categoriesFetched_` | 是否已成功获取过远程分类 | +| `fetchedCategories_` | 本会话中已获取过模板的分类 ID 集合 | +| `pendingIncrementalCategoryId_` | 当前正在请求的分类 ID(用于增量更新标记) | +| `isRefreshingCategories_` | 是否正在请求分类(防重复) | +| `isRefreshingTemplates_` | 是否正在请求模板(防重复) | + +### 7.7 接口设计 + +| 方法 | 语义 | 副作用 | +|---|---|---| +| `isTemplateAvailableLocally()` | 纯查询:检查文件是否存在 | 无(const) | +| `verifyLocalTemplate()` | 完整校验:MD5 匹配 + 损坏清理 | 清理缓存、重置状态 | +| `localTemplatePath()` | 获取本地路径 | 无 | diff --git a/src/Mogan/TemplateCenter/template_api.cpp b/src/Mogan/TemplateCenter/template_api.cpp index 22468e849c..6834beee8a 100644 --- a/src/Mogan/TemplateCenter/template_api.cpp +++ b/src/Mogan/TemplateCenter/template_api.cpp @@ -1,7 +1,7 @@ /****************************************************************************** * MODULE : template_api.cpp - * DESCRIPTION: Gitee Releases API client implementation + * DESCRIPTION: liiistem.cn API client implementation * COPYRIGHT : (C) 2026 Yuki Lu ******************************************************************************* * This software falls under the GNU general public license version 3 or later. @@ -20,18 +20,12 @@ #include #include +#include "qt_utilities.hpp" + TemplateAPI::TemplateAPI (QObject* parent) - : QObject (parent), networkManager_ (nullptr), offlineMode_ (false), - metadataReply_ (nullptr) { + : QObject (parent), networkManager_ (nullptr), offlineMode_ (false) { networkManager_= new QNetworkAccessManager (this); - - // Set default API endpoint - apiBaseUrl_= QString (DEFAULT_API_BASE_URL); -} - -void -TemplateAPI::setMetadataEtag (const QString& etag) { - metadataEtag_= etag; + apiBaseUrl_ = QString (DEFAULT_API_BASE_URL); } TemplateAPI::~TemplateAPI () { @@ -40,11 +34,17 @@ TemplateAPI::~TemplateAPI () { abortDownload (downloadReplies_.begin ().key ()); } - if (metadataReply_) { - disconnect (metadataReply_, nullptr, this, nullptr); - metadataReply_->abort (); - metadataReply_->deleteLater (); - metadataReply_= nullptr; + if (categoriesReply_) { + disconnect (categoriesReply_, nullptr, this, nullptr); + categoriesReply_->abort (); + categoriesReply_->deleteLater (); + categoriesReply_= nullptr; + } + if (templatesReply_) { + disconnect (templatesReply_, nullptr, this, nullptr); + templatesReply_->abort (); + templatesReply_->deleteLater (); + templatesReply_= nullptr; } } @@ -54,32 +54,56 @@ TemplateAPI::setApiBaseUrl (const QString& baseUrl) { } void -TemplateAPI::fetchMetadata () { +TemplateAPI::fetchCategories () { if (offlineMode_) { - emit metadataLoadFailed (tr ("Offline mode")); + emit categoriesLoadFailed (qt_translate ("Offline mode")); return; } - // Cancel any existing request - if (metadataReply_) { - disconnect (metadataReply_, nullptr, this, nullptr); - metadataReply_->abort (); - metadataReply_->deleteLater (); - metadataReply_= nullptr; + if (categoriesReply_) { + disconnect (categoriesReply_, nullptr, this, nullptr); + categoriesReply_->abort (); + categoriesReply_->deleteLater (); + categoriesReply_= nullptr; } - QNetworkRequest request{metadataUrl ()}; + QNetworkRequest request{categoriesUrl ()}; setupRequestHeaders (request); - // Send conditional request if we have a cached ETag - if (!metadataEtag_.isEmpty ()) { - request.setRawHeader ("If-None-Match", metadataEtag_.toUtf8 ()); + QJsonObject bodyObj; + QByteArray bodyData= QJsonDocument (bodyObj).toJson (); + + categoriesReply_= networkManager_->post (request, bodyData); + connect (categoriesReply_, &QNetworkReply::finished, this, + &TemplateAPI::onCategoriesReplyFinished); +} + +void +TemplateAPI::fetchTemplates (const QString& categoryId) { + if (offlineMode_) { + emit templatesLoadFailed (qt_translate ("Offline mode")); + return; + } + + if (templatesReply_) { + disconnect (templatesReply_, nullptr, this, nullptr); + templatesReply_->abort (); + templatesReply_->deleteLater (); + templatesReply_= nullptr; } - metadataReply_= networkManager_->get (request); + QNetworkRequest request{templatesUrl ()}; + setupRequestHeaders (request); + + QJsonObject bodyObj; + if (!categoryId.isEmpty ()) { + bodyObj.insert ("categoryKey", categoryId); + } + QByteArray bodyData= QJsonDocument (bodyObj).toJson (); - connect (metadataReply_, &QNetworkReply::finished, this, - &TemplateAPI::onMetadataReplyFinished); + templatesReply_= networkManager_->post (request, bodyData); + connect (templatesReply_, &QNetworkReply::finished, this, + &TemplateAPI::onTemplatesReplyFinished); } void @@ -87,7 +111,7 @@ TemplateAPI::downloadTemplate (const QString& templateId, const QString& downloadUrl, const QString& targetPath) { if (offlineMode_) { - emit downloadFailed (templateId, tr ("Offline mode")); + emit downloadFailed (templateId, qt_translate ("Offline mode")); return; } @@ -100,7 +124,6 @@ TemplateAPI::downloadTemplate (const QString& templateId, QNetworkReply* reply = networkManager_->get (request); downloadReplies_[templateId]= reply; - // Store target path as property reply->setProperty ("templateId", templateId); reply->setProperty ("targetPath", targetPath); @@ -146,46 +169,95 @@ TemplateAPI::setOfflineMode (bool offline) { emit networkStateChanged (!offline); } +static bool +extractApiData (const QByteArray& data, QJsonValue& outData, + QString& outError) { + QJsonDocument doc= QJsonDocument::fromJson (data); + if (doc.isNull () || !doc.isObject ()) { + outError= qt_translate ("Invalid JSON response"); + return false; + } + + QJsonObject root= doc.object (); + int code= root.value ("code").toInt (-1); + if (code != 0) { + outError= root.value ("message").toString (); + if (outError.isEmpty ()) { + outError= qt_translate ("API error: code %1").arg (code); + } + return false; + } + + if (!root.value ("success").toBool (false)) { + outError= root.value ("message").toString (); + if (outError.isEmpty ()) { + outError= qt_translate ("API returned failure"); + } + return false; + } + + outData= root.value ("data"); + return true; +} + void -TemplateAPI::onMetadataReplyFinished () { +TemplateAPI::onCategoriesReplyFinished () { QNetworkReply* reply= qobject_cast (sender ()); if (!reply) return; - metadataReply_= nullptr; + categoriesReply_= nullptr; - // Check HTTP status code first for 304 Not Modified - // (some Qt versions report 304 as a network error, so check before error()) - int statusCode= - reply->attribute (QNetworkRequest::HttpStatusCodeAttribute).toInt (); - if (statusCode == 304) { - emit metadataNotModified (); + if (reply->error () != QNetworkReply::NoError) { + emit categoriesLoadFailed ( + qt_translate ("Network error: %1").arg (reply->errorString ())); reply->deleteLater (); return; } - if (reply->error () != QNetworkReply::NoError) { - QString error= tr ("Network error: %1").arg (reply->errorString ()); - emit metadataLoadFailed (error); - reply->deleteLater (); + QByteArray response= reply->readAll (); + reply->deleteLater (); + + QJsonValue data; + QString error; + if (!extractApiData (response, data, error)) { + emit categoriesLoadFailed (error); return; } - // Extract ETag from 2xx responses for future conditional requests - if (statusCode >= 200 && statusCode < 300) { - lastMetadataEtag_= QString::fromUtf8 (reply->rawHeader ("ETag")); + auto categories= parseCategoriesResponse (data); + if (categories.isEmpty ()) { + emit categoriesLoadFailed (qt_translate ("Empty categories list")); + return; + } + emit categoriesLoaded (categories); +} + +void +TemplateAPI::onTemplatesReplyFinished () { + QNetworkReply* reply= qobject_cast (sender ()); + if (!reply) return; + + templatesReply_= nullptr; + + if (reply->error () != QNetworkReply::NoError) { + emit templatesLoadFailed ( + qt_translate ("Network error: %1").arg (reply->errorString ())); + reply->deleteLater (); + return; } QByteArray response= reply->readAll (); reply->deleteLater (); - QList categories; - bool isValidResponse= false; - auto metadata= parseMetadataResponse (response, categories, &isValidResponse); - if (!isValidResponse) { - emit metadataLoadFailed (tr ("Invalid metadata response")); + QJsonValue data; + QString error; + if (!extractApiData (response, data, error)) { + emit templatesLoadFailed (error); return; } - emit metadataLoaded (metadata, categories); + + auto metadata= parseTemplatesResponse (data); + emit templatesLoaded (metadata); } void @@ -210,7 +282,8 @@ TemplateAPI::onDownloadFinished () { if (reply->error () != QNetworkReply::NoError) { emit downloadFailed ( - templateId, tr ("Download failed: %1").arg (reply->errorString ())); + templateId, + qt_translate ("Download failed: %1").arg (reply->errorString ())); reply->deleteLater (); return; } @@ -234,8 +307,9 @@ TemplateAPI::onDownloadFinished () { // Save file QFile file (targetPath); if (!file.open (QIODevice::WriteOnly)) { - emit downloadFailed (templateId, - tr ("Cannot save file: %1").arg (file.errorString ())); + emit downloadFailed ( + templateId, + qt_translate ("Cannot save file: %1").arg (file.errorString ())); reply->deleteLater (); return; } @@ -244,7 +318,8 @@ TemplateAPI::onDownloadFinished () { qint64 written= file.write (data); file.close (); if (written != data.size ()) { - emit downloadFailed (templateId, tr ("Failed to write complete file")); + emit downloadFailed (templateId, + qt_translate ("Failed to write complete file")); reply->deleteLater (); return; } @@ -253,118 +328,113 @@ TemplateAPI::onDownloadFinished () { reply->deleteLater (); } -void -TemplateAPI::onNetworkError (QNetworkReply::NetworkError error) { - Q_UNUSED (error); - QNetworkReply* reply= qobject_cast (sender ()); - if (!reply) return; - - // Only handle metadata reply errors here - // Download errors are handled in onDownloadFinished - if (reply == metadataReply_) { - metadataReply_= nullptr; - emit metadataLoadFailed ( - tr ("Network error: %1").arg (reply->errorString ())); - reply->deleteLater (); - } +QString +TemplateAPI::categoriesUrl () const { + return QString ("%1/api/v1/doc/template/categories").arg (apiBaseUrl_); } QString -TemplateAPI::metadataUrl () const { - // Fetch templates.json from liiistem.cn API - return QString ("%1/templates.json").arg (apiBaseUrl_); +TemplateAPI::templatesUrl () const { + return QString ("%1/api/v1/doc/template/list").arg (apiBaseUrl_); } -QHash -TemplateAPI::parseMetadataResponse (const QByteArray& data, - QList& outCategories, - bool* isValidResponse) { - QHash metadata; - if (isValidResponse) { - *isValidResponse= false; - } +QList +TemplateAPI::parseCategoriesResponse (const QJsonValue& data) { + QList categories; - QJsonDocument doc= QJsonDocument::fromJson (data); - if (doc.isNull () || !doc.isObject ()) { - qWarning () << "Invalid JSON response"; - return metadata; + QJsonArray array; + if (data.isArray ()) { + array= data.toArray (); + } + else { + qWarning () << "[Template] Categories data is not an array"; + return categories; } - QJsonObject root= doc.object (); - - // Check if this is the nested categories format (liiistem.cn API v2) - bool hasSchemaField= - (root.contains ("categories") && root.value ("categories").isArray ()) || - (root.contains ("templates") && root.value ("templates").isArray ()); - if (!hasSchemaField) { - qWarning () << "Invalid metadata schema"; - return metadata; + for (const auto& val : array) { + QJsonObject obj= val.toObject (); + TemplateCategory cat; + cat.id = obj.value ("categoryKey").toString (); + cat.name = obj.value ("name").toString (); + cat.nameEn = obj.value ("nameEn").toString (); + cat.description = obj.value ("description").toString (); + cat.order = obj.value ("order").toInt (); + cat.templateCount= obj.value ("templateCount").toInt (); + if (!cat.id.isEmpty () && !cat.name.isEmpty ()) { + categories.append (cat); + } } - QJsonArray categories= root.value ("categories").toArray (); - if (!categories.isEmpty ()) { - // Parse categories array with nested templates - for (const auto& catValue : categories) { - QJsonObject catObj= catValue.toObject (); + std::sort (categories.begin (), categories.end (), + [] (const TemplateCategory& a, const TemplateCategory& b) { + return a.order < b.order; + }); - // Parse category info - TemplateCategory category; - category.id = catObj.value ("id").toString (); - category.name = catObj.value ("name").toString (); - category.description= catObj.value ("description").toString (); - category.icon = catObj.value ("icon").toString (); - category.order = catObj.value ("order").toInt (); - outCategories.append (category); + return categories; +} - QString categoryId= category.id; +QHash +TemplateAPI::parseTemplatesResponse (const QJsonValue& data) { + QHash metadata; - QJsonArray templates= catObj.value ("templates").toArray (); - for (const auto& tmplValue : templates) { - parseTemplateObject (tmplValue.toObject (), categoryId, metadata); - } - } - } - else { - // Fallback: flat templates array format (legacy/Gitee style) - QJsonArray templates= root.value ("templates").toArray (); - for (const auto& tmplValue : templates) { - parseTemplateObject (tmplValue.toObject (), QString (), metadata); - } + if (!data.isObject ()) { + qWarning () << "[Template] Templates data is not an object"; + return metadata; } - if (isValidResponse) { - *isValidResponse= true; + QJsonObject dataObj= data.toObject (); + QJsonArray array = dataObj.value ("items").toArray (); + + for (const auto& val : array) { + parseTemplateObject (val.toObject (), metadata); } + return metadata; } void TemplateAPI::parseTemplateObject ( - const QJsonObject& tmplObj, const QString& defaultCategoryId, - QHash& metadata) { + const QJsonObject& tmplObj, QHash& metadata) { TemplateMetadataPtr tmpl= QSharedPointer::create (); - tmpl->id = tmplObj.value ("id").toString (); + tmpl->id = tmplObj.value ("templateKey").toString (); tmpl->name = tmplObj.value ("name").toString (); tmpl->description = tmplObj.value ("description").toString (); - // Use category field if present, otherwise use parent category - tmpl->category = tmplObj.value ("category").toString (defaultCategoryId); - tmpl->author = tmplObj.value ("author").toString (); - tmpl->version = tmplObj.value ("version").toString (); - tmpl->license = tmplObj.value ("license").toString (); - tmpl->thumbnailUrl= tmplObj.value ("thumbnail_url").toString (); - tmpl->previewUrl = tmplObj.value ("preview_url").toString (); - // Support both download_url (new) and file_url (legacy) - tmpl->fileUrl= tmplObj.value ("download_url") - .toString (tmplObj.value ("file_url").toString ()); - tmpl->fileSize = tmplObj.value ("file_size").toVariant ().toLongLong (); - tmpl->fileMd5 = tmplObj.value ("file_md5").toString (); - tmpl->createdAt= QDateTime::fromString ( - tmplObj.value ("created_at").toString (), Qt::ISODate); - tmpl->updatedAt= QDateTime::fromString ( - tmplObj.value ("updated_at").toString (), Qt::ISODate); + tmpl->author = tmplObj.value ("author").toString (); + tmpl->version = tmplObj.value ("version").toString (); + tmpl->license = tmplObj.value ("license").toString (); + tmpl->thumbnailUrl = tmplObj.value ("thumbnailUrl").toString (); + tmpl->fileSize= tmplObj.value ("fileSize").toVariant ().toLongLong (); + tmpl->fileMd5 = tmplObj.value ("fileMd5").toString (); tmpl->language= tmplObj.value ("language").toString (); - // Parse tags array + // category is an object: {"categoryKey", "name"} + QJsonObject catObj= tmplObj.value ("category").toObject (); + tmpl->category = catObj.value ("categoryKey").toString (); + + // url → fileUrl, pdfUrl → previewUrl + tmpl->fileUrl = tmplObj.value ("url").toString (); + tmpl->previewUrl= tmplObj.value ("pdfUrl").toString (); + + // createTime 优先,回退到 created_at + QString createTime= tmplObj.value ("createTime").toString (); + if (createTime.isEmpty ()) { + createTime= tmplObj.value ("created_at").toString (); + } + tmpl->createdAt= QDateTime::fromString (createTime, Qt::ISODate); + + // updateTime 优先,回退到 updated_at,最后回退到 createdAt + QString updateTime= tmplObj.value ("updateTime").toString (); + if (updateTime.isEmpty ()) { + updateTime= tmplObj.value ("updated_at").toString (); + } + if (!updateTime.isEmpty ()) { + tmpl->updatedAt= QDateTime::fromString (updateTime, Qt::ISODate); + } + else { + tmpl->updatedAt= tmpl->createdAt; + } + + // tags array QJsonArray tagsArray= tmplObj.value ("tags").toArray (); QStringList tags; for (const auto& tag : tagsArray) { @@ -372,11 +442,11 @@ TemplateAPI::parseTemplateObject ( } tmpl->tags= tags; - // Parse compatibility info + // compatibility QJsonObject compatObj= tmplObj.value ("compatibility").toObject (); tmpl->moganMinVersion= compatObj.value ("mogan_min_version").toString (); - // Parse statistics + // statistics QJsonObject statsObj= tmplObj.value ("statistics").toObject (); tmpl->downloadCount = statsObj.value ("downloads").toInt (); tmpl->rating = statsObj.value ("rating").toDouble (); @@ -388,6 +458,7 @@ TemplateAPI::parseTemplateObject ( void TemplateAPI::setupRequestHeaders (QNetworkRequest& request) { + request.setHeader (QNetworkRequest::ContentTypeHeader, "application/json"); request.setHeader (QNetworkRequest::UserAgentHeader, "Mogan-TemplateCenter/1.0"); request.setRawHeader ("Accept", "application/json"); diff --git a/src/Mogan/TemplateCenter/template_api.hpp b/src/Mogan/TemplateCenter/template_api.hpp index e46f159266..bf5e76325b 100644 --- a/src/Mogan/TemplateCenter/template_api.hpp +++ b/src/Mogan/TemplateCenter/template_api.hpp @@ -1,7 +1,7 @@ /****************************************************************************** * MODULE : template_api.hpp - * DESCRIPTION: Gitee Releases API client for template metadata and downloads + * DESCRIPTION: liiistem.cn API client for template metadata and downloads * COPYRIGHT : (C) 2026 Yuki Lu ******************************************************************************* * This software falls under the GNU general public license version 3 or later. @@ -29,7 +29,7 @@ class QJsonObject; * @brief liiistem.cn API client * * Responsibilities: - * - Fetch template metadata from liiistem.cn API + * - Fetch template categories and templates from liiistem.cn API via POST * - Download template files (.tm) * - Handle network errors and retries * - Support offline fallback @@ -41,12 +41,14 @@ class TemplateAPI : public QObject { explicit TemplateAPI (QObject* parent= nullptr); ~TemplateAPI (); - // Configuration (liiistem.cn API - no repository config needed) + // Configuration void setApiBaseUrl (const QString& baseUrl); QString apiBaseUrl () const { return apiBaseUrl_; } - // API operations - void fetchMetadata (); + // API operations (POST based) + void fetchCategories (); + void fetchTemplates (const QString& categoryId= QString ()); + void downloadTemplate (const QString& templateId, const QString& downloadUrl, const QString& targetPath); @@ -59,20 +61,18 @@ class TemplateAPI : public QObject { */ void cancelDownload (const QString& templateId); - // Metadata ETag for conditional requests - void setMetadataEtag (const QString& etag); - QString lastMetadataEtag () const { return lastMetadataEtag_; } - // Network state bool isOnline () const; void setOfflineMode (bool offline); signals: - // Metadata fetch results (liiistem.cn API format) - void metadataLoaded (const QHash& metadata, - const QList& categories); - void metadataLoadFailed (const QString& error); - void metadataNotModified (); + // Categories fetch results + void categoriesLoaded (const QList& categories); + void categoriesLoadFailed (const QString& error); + + // Templates fetch results + void templatesLoaded (const QHash& metadata); + void templatesLoadFailed (const QString& error); // Download progress void downloadProgress (const QString& templateId, qint64 bytesReceived, @@ -83,25 +83,25 @@ class TemplateAPI : public QObject { // Network state void networkStateChanged (bool isOnline); +public: + // Response parsing (exposed for unit testing) + QList parseCategoriesResponse (const QJsonValue& data); + QHash + parseTemplatesResponse (const QJsonValue& data); + private slots: - void onMetadataReplyFinished (); + void onCategoriesReplyFinished (); + void onTemplatesReplyFinished (); void onDownloadProgress (qint64 bytesReceived, qint64 bytesTotal); void onDownloadFinished (); - void onNetworkError (QNetworkReply::NetworkError error); private: // API URL construction - QString metadataUrl () const; - - // Response parsing (liiistem.cn API format with nested categories) - QHash - parseMetadataResponse (const QByteArray& data, - QList& outCategories, - bool* isValidResponse= nullptr); + QString categoriesUrl () const; + QString templatesUrl () const; // Helper to parse individual template objects - void parseTemplateObject (const QJsonObject& tmplObj, - const QString& defaultCategoryId, + void parseTemplateObject (const QJsonObject& tmplObj, QHash& metadata); // Request management @@ -129,15 +129,11 @@ private slots: // Active requests QHash> downloadReplies_; - QPointer metadataReply_; - - // Metadata ETag for conditional requests - QString metadataEtag_; // ETag sent in If-None-Match - QString lastMetadataEtag_; // ETag received in last 200 response + QPointer categoriesReply_; + QPointer templatesReply_; // Default API endpoint - static constexpr const char* DEFAULT_API_BASE_URL= - "https://liiistem.cn/template-api"; + static constexpr const char* DEFAULT_API_BASE_URL= "https://liiistem.cn"; }; #endif // TEMPLATE_API_HPP diff --git a/src/Mogan/TemplateCenter/template_cache.cpp b/src/Mogan/TemplateCenter/template_cache.cpp index 7fbc926c62..8eb4803946 100644 --- a/src/Mogan/TemplateCenter/template_cache.cpp +++ b/src/Mogan/TemplateCenter/template_cache.cpp @@ -55,14 +55,14 @@ TemplateCache::loadMetadataCache () { QFile file (cachePath); if (!file.open (QIODevice::ReadOnly)) { - qWarning () << "Failed to open metadata cache:" << cachePath; + qWarning () << "[Template] Failed to open metadata cache:" << cachePath; return metadata; } QByteArray data= file.readAll (); QJsonDocument doc = QJsonDocument::fromJson (data); if (doc.isNull () || !doc.isObject ()) { - qWarning () << "Invalid metadata cache format"; + qWarning () << "[Template] Invalid metadata cache format"; return metadata; } @@ -158,7 +158,7 @@ TemplateCache::saveMetadataCache ( QString cachePath= metadataCachePath (); QFile file (cachePath); if (!file.open (QIODevice::WriteOnly)) { - qWarning () << "Failed to write metadata cache:" << cachePath; + qWarning () << "[Template] Failed to write metadata cache:" << cachePath; return; } @@ -189,11 +189,13 @@ TemplateCache::cachedTemplatePath (const QString& templateId) const { void TemplateCache::registerCachedTemplate (const QString& templateId, const QString& localPath, - qint64 fileSize) { + qint64 fileSize, + const QString& fileMd5) { CacheEntry entry; entry.templateId= templateId; entry.localPath = localPath; entry.fileSize = fileSize; + entry.fileMd5 = fileMd5; entry.cachedAt = QDateTime::currentDateTime (); cacheIndex_[templateId]= entry; @@ -207,10 +209,11 @@ TemplateCache::removeCachedTemplate (const QString& templateId) { // Remove file bool removed= QFile::remove (it->localPath); if (!removed) { - qWarning () << "Failed to remove cached template file:" << it->localPath; + qWarning () << "[Template] Failed to remove cached template file:" + << it->localPath; } else { - qDebug () << "Removed cached template file:" << it->localPath; + qDebug () << "[Template] Removed cached template file:" << it->localPath; } cacheIndex_.erase (it); @@ -239,10 +242,6 @@ TemplateCache::clearCache () { QString metadataPath= metadataCachePath (); QFile::remove (metadataPath); - // Clear metadata ETag - QFile::remove (metadataEtagPath ()); - metadataEtag_.clear (); - emit cacheCleared (); } @@ -271,41 +270,6 @@ TemplateCache::metadataCachePath () const { return QDir (cacheDirectory ()).filePath ("metadata.json"); } -QString -TemplateCache::metadataEtagPath () const { - return QDir (cacheDirectory ()).filePath ("metadata_etag.txt"); -} - -QString -TemplateCache::metadataEtag () const { - if (!metadataEtag_.isEmpty ()) { - return metadataEtag_; - } - - QFile file (metadataEtagPath ()); - if (file.open (QIODevice::ReadOnly)) { - QString etag= QString::fromUtf8 (file.readAll ()).trimmed (); - // Cache in memory for subsequent reads - metadataEtag_= etag; - return etag; - } - return QString (); -} - -void -TemplateCache::setMetadataEtag (const QString& etag) { - metadataEtag_= etag; - - QFile file (metadataEtagPath ()); - if (file.open (QIODevice::WriteOnly | QIODevice::Truncate)) { - file.write (etag.toUtf8 ()); - file.close (); - } - else { - qWarning () << "Failed to write metadata ETag:" << metadataEtagPath (); - } -} - QString TemplateCache::categoriesCachePath () const { return QDir (cacheDirectory ()).filePath ("categories.json"); @@ -324,14 +288,15 @@ TemplateCache::loadCategoriesCache () { QString lockPath= cachePath + ".lock"; QLockFile lockFile (lockPath); if (!lockFile.tryLock (5000)) { // Wait up to 5 seconds - qWarning () << "Could not acquire lock for categories cache read:" - << lockPath; + qWarning () + << "[Template] Could not acquire lock for categories cache read:" + << lockPath; return categories; } QFile file (cachePath); if (!file.open (QIODevice::ReadOnly)) { - qWarning () << "Failed to open categories cache:" << cachePath + qWarning () << "[Template] Failed to open categories cache:" << cachePath << "Error:" << file.errorString (); return categories; } @@ -340,7 +305,7 @@ TemplateCache::loadCategoriesCache () { QJsonParseError parseError; QJsonDocument doc= QJsonDocument::fromJson (data, &parseError); if (doc.isNull () || !doc.isObject ()) { - qWarning () << "Invalid categories cache format:" + qWarning () << "[Template] Invalid categories cache format:" << parseError.errorString () << "at offset" << parseError.offset; // Remove corrupted cache file to trigger regeneration @@ -356,18 +321,20 @@ TemplateCache::loadCategoriesCache () { QJsonObject catObj= catValue.toObject (); TemplateCategory category; - category.id = catObj.value ("id").toString (); - category.name = catObj.value ("name").toString (); - category.description= catObj.value ("description").toString (); - category.icon = catObj.value ("icon").toString (); - category.order = catObj.value ("order").toInt (); + category.id = catObj.value ("id").toString (); + category.name = catObj.value ("name").toString (); + category.nameEn = catObj.value ("nameEn").toString (); + category.description = catObj.value ("description").toString (); + category.order = catObj.value ("order").toInt (); + category.templateCount= catObj.value ("templateCount").toInt (); if (!category.id.isEmpty () && !category.name.isEmpty ()) { categories.append (category); } else { - qWarning () << "Skipping invalid category: missing id or name. ID:" - << category.id << "Name:" << category.name; + qWarning () + << "[Template] Skipping invalid category: missing id or name. ID:" + << category.id << "Name:" << category.name; } } @@ -377,7 +344,8 @@ TemplateCache::loadCategoriesCache () { return a.order < b.order; }); - qDebug () << "Loaded" << categories.size () << "categories from cache"; + qDebug () << "[Template] Loaded" << categories.size () + << "categories from cache"; return categories; } @@ -391,9 +359,10 @@ TemplateCache::saveCategoriesCache (const QList& categories) { QJsonObject catObj; catObj.insert ("id", cat.id); catObj.insert ("name", cat.name); + catObj.insert ("nameEn", cat.nameEn); catObj.insert ("description", cat.description); - catObj.insert ("icon", cat.icon); catObj.insert ("order", cat.order); + catObj.insert ("templateCount", cat.templateCount); categoriesArray.append (catObj); } root.insert ("categories", categoriesArray); @@ -406,27 +375,29 @@ TemplateCache::saveCategoriesCache (const QList& categories) { QString lockPath= cachePath + ".lock"; QLockFile lockFile (lockPath); if (!lockFile.tryLock (5000)) { // Wait up to 5 seconds - qWarning () << "Could not acquire lock for categories cache write:" - << lockPath; + qWarning () + << "[Template] Could not acquire lock for categories cache write:" + << lockPath; return; } QFile file (cachePath); if (!file.open (QIODevice::WriteOnly | QIODevice::Truncate)) { - qWarning () << "Failed to write categories cache:" << cachePath + qWarning () << "[Template] Failed to write categories cache:" << cachePath << "Error:" << file.errorString (); return; } qint64 bytesWritten= file.write (doc.toJson (QJsonDocument::Compact)); if (bytesWritten == -1) { - qWarning () << "Failed to write categories cache data:" + qWarning () << "[Template] Failed to write categories cache data:" << file.errorString (); file.close (); QFile::remove (cachePath); } else { - qDebug () << "Saved" << categories.size () << "categories to cache"; + qDebug () << "[Template] Saved" << categories.size () + << "categories to cache"; } } @@ -468,6 +439,7 @@ TemplateCache::loadCacheIndex () { entry.templateId= entryObj.value ("templateId").toString (); entry.localPath = entryObj.value ("localPath").toString (); entry.fileSize = entryObj.value ("fileSize").toVariant ().toLongLong (); + entry.fileMd5 = entryObj.value ("fileMd5").toString (); entry.cachedAt = QDateTime::fromString ( entryObj.value ("cachedAt").toString (), Qt::ISODate); @@ -489,6 +461,7 @@ TemplateCache::saveCacheIndex () { entryObj.insert ("templateId", entry.templateId); entryObj.insert ("localPath", entry.localPath); entryObj.insert ("fileSize", entry.fileSize); + entryObj.insert ("fileMd5", entry.fileMd5); entryObj.insert ("cachedAt", entry.cachedAt.toString (Qt::ISODate)); entries.append (entryObj); } @@ -499,7 +472,7 @@ TemplateCache::saveCacheIndex () { QString indexPath= cacheIndexPath (); QFile file (indexPath); if (!file.open (QIODevice::WriteOnly)) { - qWarning () << "Failed to write cache index:" << indexPath; + qWarning () << "[Template] Failed to write cache index:" << indexPath; return; } diff --git a/src/Mogan/TemplateCenter/template_cache.hpp b/src/Mogan/TemplateCenter/template_cache.hpp index bbe3f9f824..11fbdebbeb 100644 --- a/src/Mogan/TemplateCenter/template_cache.hpp +++ b/src/Mogan/TemplateCenter/template_cache.hpp @@ -27,6 +27,7 @@ struct CacheEntry { QString localPath; QDateTime cachedAt; qint64 fileSize; + QString fileMd5; CacheEntry () : fileSize (0) {} }; @@ -46,31 +47,28 @@ class TemplateCache : public QObject { explicit TemplateCache (QObject* parent= nullptr); ~TemplateCache (); - // Initialization + // 初始化 bool initialize (); bool isInitialized () const { return initialized_; } - // Metadata cache operations + // 元数据缓存 QHash loadMetadataCache (); void saveMetadataCache (const QHash& metadata); - // Category cache operations + // 分类缓存 QList loadCategoriesCache (); void saveCategoriesCache (const QList& categories); - // Template file operations + // 模板文件缓存 bool isTemplateCached (const QString& templateId) const; QString cachedTemplatePath (const QString& templateId) const; void registerCachedTemplate (const QString& templateId, - const QString& localPath, qint64 fileSize); + const QString& localPath, qint64 fileSize, + const QString& fileMd5= QString ()); void removeCachedTemplate (const QString& templateId); QList cachedTemplates () const; - // Metadata ETag for HTTP conditional requests - QString metadataEtag () const; - void setMetadataEtag (const QString& etag); - - // Cache management + // 缓存管理 void clearCache (); qint64 cacheSize () const; @@ -87,7 +85,6 @@ class TemplateCache : public QObject { QString categoriesCachePath () const; QString templatesCacheDir () const; QString cacheIndexPath () const; - QString metadataEtagPath () const; // Cache index management void loadCacheIndex (); @@ -101,7 +98,6 @@ class TemplateCache : public QObject { // Cache storage QHash cacheIndex_; - mutable QString metadataEtag_; }; #endif // TEMPLATE_CACHE_HPP diff --git a/src/Mogan/TemplateCenter/template_manager.cpp b/src/Mogan/TemplateCenter/template_manager.cpp index 266902c442..55ce275381 100644 --- a/src/Mogan/TemplateCenter/template_manager.cpp +++ b/src/Mogan/TemplateCenter/template_manager.cpp @@ -13,6 +13,7 @@ #include "template_api.hpp" #include "template_cache.hpp" +#include #include #include #include @@ -30,23 +31,27 @@ #include "tm_file.hpp" #include "image_cache_base.hpp" +#include "qt_utilities.hpp" // Singleton instance static TemplateManager* g_instance= nullptr; TemplateManager::TemplateManager (QObject* parent) : QObject (parent), initialized_ (false), cache_ (nullptr), api_ (nullptr), - isOnline_ (true), isRefreshing_ (false), isRetryingWithoutEtag_ (false) { + isOnline_ (true), isRefreshingCategories_ (false), + isRefreshingTemplates_ (false), categoriesFetched_ (false) { cache_= new TemplateCache (this); api_ = new TemplateAPI (this); // Connect API signals (liiistem.cn API format) - connect (api_, &TemplateAPI::metadataLoaded, this, - &TemplateManager::onRemoteMetadataLoaded); - connect (api_, &TemplateAPI::metadataLoadFailed, this, - &TemplateManager::onRemoteMetadataFailed); - connect (api_, &TemplateAPI::metadataNotModified, this, - &TemplateManager::onMetadataNotModified); + connect (api_, &TemplateAPI::categoriesLoaded, this, + &TemplateManager::onRemoteCategoriesLoaded); + connect (api_, &TemplateAPI::categoriesLoadFailed, this, + &TemplateManager::onRemoteCategoriesFailed); + connect (api_, &TemplateAPI::templatesLoaded, this, + &TemplateManager::onRemoteTemplatesLoaded); + connect (api_, &TemplateAPI::templatesLoadFailed, this, + &TemplateManager::onRemoteTemplatesFailed); connect (api_, &TemplateAPI::downloadCompleted, this, &TemplateManager::onTemplateDownloaded); connect (api_, &TemplateAPI::downloadFailed, this, @@ -76,14 +81,14 @@ TemplateManager::initialize () { // Initialize cache if (!cache_->initialize ()) { - qWarning () << "Failed to initialize template cache"; + qWarning () << "[Template] Failed to initialize template cache"; // Continue without cache - will work in degraded mode } // Load local templates first (offline fallback) loadLocalTemplates (); - // Try to load categories from cache first, fallback to Scheme file + // Load categories from cache or Scheme file for immediate UI display loadCachedCategories (); // Load cached metadata if available @@ -91,14 +96,10 @@ TemplateManager::initialize () { cache_->loadMetadataCache (); if (!cachedMetadata.isEmpty ()) { mergeMetadata (cachedMetadata); - // Don't emit templatesLoaded here - wait for remote data or emit after - // checking } - // Always try to refresh remote metadata in the background. - // Cached data is already available for fast initial rendering and offline - // use. - refreshTemplates (); + // Always try to refresh categories in the background + refreshCategories (); initialized_= true; emit initialized (true); @@ -108,7 +109,20 @@ void TemplateManager::loadLocalTemplates () { // Load templates from TeXmacs/templates/metadata.scm // TODO: Parse Scheme file and populate templates_ - // For now, we'll rely on the cache and remote fetch +} + +static QString +computeFileMd5 (const QString& filePath) { + QFile file (filePath); + if (!file.open (QIODevice::ReadOnly)) { + return QString (); + } + QCryptographicHash hasher (QCryptographicHash::Md5); + while (!file.atEnd ()) { + hasher.addData ( + file.read (64 * 1024)); // 64KB chunks, avoid OOM on large files + } + return hasher.result ().toHex (); } void @@ -164,14 +178,14 @@ TemplateManager::loadCategoriesFromScheme (const string& filePath) { // Check if Scheme interpreter is available if (!tm_s7) { - qWarning () << "Scheme interpreter not available"; + qWarning () << "[Template] Scheme interpreter not available"; return categories; } // Load and evaluate the Scheme file tmscm result= eval_scheme_file (filePath); if (tmscm_is_null (result)) { - qWarning () << "Failed to load categories from Scheme file:" + qWarning () << "[Template] Failed to load categories from Scheme file:" << QString::fromUtf8 (as_charp (filePath)); return categories; } @@ -179,14 +193,14 @@ TemplateManager::loadCategoriesFromScheme (const string& filePath) { // Call (template-get-categories) to get the category list tmscm categoriesFunc= s7_name_to_value (tm_s7, "template-get-categories"); if (categoriesFunc == s7_undefined (tm_s7)) { - qWarning () << "template-get-categories function not found"; + qWarning () << "[Template] template-get-categories function not found"; return categories; } // Use eval_scheme with string expression to call the function tmscm categoriesList= eval_scheme ("(template-get-categories)"); if (tmscm_is_null (categoriesList) || !tmscm_is_list (categoriesList)) { - qWarning () << "Invalid categories list from Scheme"; + qWarning () << "[Template] Invalid categories list from Scheme"; return categories; } @@ -212,7 +226,7 @@ TemplateManager::loadCategoriesFromScheme (const string& filePath) { if (tmscm_is_symbol (key)) { string keyStr= tmscm_to_symbol (key); - if (keyStr == "id" && tmscm_is_string (value)) { + if (keyStr == "categoryKey" && tmscm_is_string (value)) { category.id= QString::fromUtf8 (as_charp (tmscm_to_string (value))); } @@ -220,17 +234,20 @@ TemplateManager::loadCategoriesFromScheme (const string& filePath) { category.name= QString::fromUtf8 (as_charp (tmscm_to_string (value))); } - else if (keyStr == "description" && tmscm_is_string (value)) { - category.description= + else if (keyStr == "nameEn" && tmscm_is_string (value)) { + category.nameEn= QString::fromUtf8 (as_charp (tmscm_to_string (value))); } - else if (keyStr == "icon" && tmscm_is_string (value)) { - category.icon= + else if (keyStr == "description" && tmscm_is_string (value)) { + category.description= QString::fromUtf8 (as_charp (tmscm_to_string (value))); } else if (keyStr == "order" && tmscm_is_int (value)) { category.order= tmscm_to_int (value); } + else if (keyStr == "templateCount" && tmscm_is_int (value)) { + category.templateCount= tmscm_to_int (value); + } } } } @@ -290,14 +307,43 @@ TemplateManager::templateById (const QString& templateId) const { bool TemplateManager::isTemplateAvailableLocally (const QString& templateId) const { auto tmpl= templates_.value (templateId); - if (tmpl) { - // Always check actual file existence, not just cached flags - if (!tmpl->localPath.isEmpty () && QFile::exists (tmpl->localPath)) { - return true; + if (!tmpl) { + return false; + } + + if (!tmpl->localPath.isEmpty () && QFile::exists (tmpl->localPath)) { + return true; + } + + return cache_->isTemplateCached (templateId); +} + +bool +TemplateManager::verifyLocalTemplate (const QString& templateId) { + auto tmpl= templates_.value (templateId); + if (!tmpl) { + return false; + } + + if (!tmpl->localPath.isEmpty () && QFile::exists (tmpl->localPath)) { + if (!tmpl->fileMd5.isEmpty ()) { + QString actualMd5= computeFileMd5 (tmpl->localPath); + if (actualMd5 == tmpl->fileMd5) { + qDebug () << "[Template]" << templateId << "MD5 verified:" << actualMd5; + return true; + } + qWarning () << "[Template]" << templateId + << "MD5 mismatch (expected:" << tmpl->fileMd5 + << "actual:" << actualMd5 << "), clearing cache"; + cache_->removeCachedTemplate (templateId); + tmpl->localPath.clear (); + tmpl->isLocal= false; + return false; } - return cache_->isTemplateCached (templateId); + return true; } - return false; + + return cache_->isTemplateCached (templateId); } QString @@ -313,37 +359,51 @@ TemplateManager::localTemplatePath (const QString& templateId) const { } void -TemplateManager::refreshTemplates () { - if (isRefreshing_) { +TemplateManager::refreshCategories () { + if (isRefreshingCategories_ || categoriesFetched_) { return; } + isRefreshingCategories_= true; + api_->fetchCategories (); +} - isRefreshing_= true; - - // Set ETag for conditional request to avoid re-downloading unchanged metadata - if (cache_->isInitialized ()) { - api_->setMetadataEtag (cache_->metadataEtag ()); +void +TemplateManager::refreshTemplates () { + if (isRefreshingTemplates_) { + return; } + isRefreshingTemplates_= true; + api_->fetchTemplates (); +} - api_->fetchMetadata (); +void +TemplateManager::refreshTemplatesByCategory (const QString& categoryId) { + // 防重复:正在请求中或本会话已获取过该分类 + if (isRefreshingTemplates_ || fetchedCategories_.contains (categoryId)) { + return; + } + isRefreshingTemplates_ = true; + pendingIncrementalCategoryId_= categoryId; // 显式标记增量更新 + api_->fetchTemplates (categoryId); } void TemplateManager::downloadTemplate (const QString& templateId) { auto tmpl= templates_.value (templateId); if (!tmpl) { - emit downloadFailed (templateId, tr ("Template not found")); + emit downloadFailed (templateId, qt_translate ("Template not found")); return; } if (tmpl->fileUrl.isEmpty ()) { - emit downloadFailed (templateId, tr ("No download URL available")); + emit downloadFailed (templateId, + qt_translate ("No download URL available")); return; } QString targetPath= templateFilePath (templateId); if (targetPath.isEmpty ()) { - emit downloadFailed (templateId, tr ("Invalid template ID")); + emit downloadFailed (templateId, qt_translate ("Invalid template ID")); return; } @@ -358,7 +418,7 @@ TemplateManager::cancelDownload (const QString& templateId) { QString TemplateManager::downloadTemplateSync (const QString& templateId, int timeoutMs, QString* errorMessage) { - if (isTemplateAvailableLocally (templateId)) { + if (verifyLocalTemplate (templateId)) { return localTemplatePath (templateId); } @@ -389,7 +449,7 @@ TemplateManager::downloadTemplateSync (const QString& templateId, int timeoutMs, timer.setSingleShot (true); connect (&timer, &QTimer::timeout, [&] () { if (finished) return; - errorStr= tr ("Download timed out"); + errorStr= qt_translate ("Download timed out"); finished= true; cancelDownload (templateId); loop.quit (); @@ -405,7 +465,8 @@ TemplateManager::downloadTemplateSync (const QString& templateId, int timeoutMs, if (!finished || resultPath.isEmpty ()) { if (errorMessage) { - *errorMessage= errorStr.isEmpty () ? tr ("Download failed") : errorStr; + *errorMessage= + errorStr.isEmpty () ? qt_translate ("Download failed") : errorStr; } return QString (); } @@ -417,35 +478,54 @@ void TemplateManager::onNetworkStateChanged (bool isOnline) { isOnline_= isOnline; if (isOnline && initialized_) { - // Refresh immediately when connectivity is restored. - refreshTemplates (); + refreshCategories (); } } void -TemplateManager::onRemoteMetadataLoaded ( - const QHash& remoteMetadata, - const QList& remoteCategories) { - isRefreshing_ = false; - isRetryingWithoutEtag_= false; +TemplateManager::onRemoteCategoriesLoaded ( + const QList& remoteCategories) { + isRefreshingCategories_= false; + categoriesFetched_ = true; - // Save ETag from successful response for future conditional requests - // Clear ETag if server no longer sends it (server may have disabled caching) - QString etag= api_->lastMetadataEtag (); - cache_->setMetadataEtag (etag); + if (!remoteCategories.isEmpty ()) { + categories_= remoteCategories; + categoryMap_.clear (); + for (const auto& cat : categories_) { + categoryMap_[cat.id]= cat; + } + cache_->saveCategoriesCache (categories_); + emit categoriesLoaded (); + } +} +void +TemplateManager::onRemoteCategoriesFailed (const QString& error) { + isRefreshingCategories_= false; + qWarning () << "[Template] Failed to load remote categories:" << error; +} + +void +TemplateManager::onRemoteTemplatesLoaded ( + const QHash& remoteMetadata) { + isRefreshingTemplates_= false; + + // 空数据保护:本地已有数据时,空响应视为异常 if (remoteMetadata.isEmpty () && !templates_.isEmpty ()) { - QString error= tr ("Remote metadata is empty"); - qWarning () << "Skip metadata merge:" << error; + QString error= qt_translate ("Remote templates list is empty"); + qWarning () << "[Template] Skip templates merge:" << error; + pendingIncrementalCategoryId_.clear (); emit templatesLoaded (); emit templatesLoadFailed (error); return; } + // 增量检测:通过显式标记判断,替代旧方案的数据推断 + bool incremental= !pendingIncrementalCategoryId_.isEmpty (); + int newCount = 0; int updatedCount= 0; - // Count new and updated templates for (auto it= remoteMetadata.constBegin (); it != remoteMetadata.constEnd (); ++it) { const QString& id = it.key (); @@ -460,63 +540,31 @@ TemplateManager::onRemoteMetadataLoaded ( } } - // Update categories from remote (liiistem.cn API format) - if (!remoteCategories.isEmpty ()) { - categories_= remoteCategories; - categoryMap_.clear (); - for (const auto& cat : categories_) { - categoryMap_[cat.id]= cat; - } - // Save categories to cache for offline use - cache_->saveCategoriesCache (categories_); - emit categoriesLoaded (); - } - - // Merge with existing data - mergeMetadata (remoteMetadata); - - // Save to cache + mergeMetadata (remoteMetadata, incremental); cache_->saveMetadataCache (templates_); - - // Notify UI emit templatesLoaded (); if (newCount > 0 || updatedCount > 0) { emit updateAvailable (newCount, updatedCount); } -} - -void -TemplateManager::onRemoteMetadataFailed (const QString& error) { - isRefreshing_ = false; - isRetryingWithoutEtag_= false; - qWarning () << "Failed to load remote metadata:" << error; - // We still have local/cache data, so emit success for cached data - emit templatesLoaded (); - emit templatesLoadFailed (error); + // 记录该分类已获取,避免重复请求 + if (incremental && !remoteMetadata.isEmpty ()) { + QString categoryId= remoteMetadata.constBegin ().value ()->category; + if (!categoryId.isEmpty ()) { + fetchedCategories_.insert (categoryId); + } + } + pendingIncrementalCategoryId_.clear (); } void -TemplateManager::onMetadataNotModified () { - isRefreshing_= false; - if (templates_.isEmpty ()) { - if (isRetryingWithoutEtag_) { - qWarning () << "Server returned 304 even without If-None-Match, aborting"; - isRetryingWithoutEtag_= false; - emit templatesLoadFailed (tr ("Server returned unexpected 304")); - return; - } - qWarning () - << "304 received but no local cache available, retrying without ETag"; - cache_->setMetadataEtag (QString ()); - isRetryingWithoutEtag_= true; - refreshTemplates (); - return; - } - isRetryingWithoutEtag_= false; - qDebug () << "Metadata not modified (304), using cached data"; +TemplateManager::onRemoteTemplatesFailed (const QString& error) { + isRefreshingTemplates_= false; + pendingIncrementalCategoryId_.clear (); + qWarning () << "[Template] Failed to load remote templates:" << error; emit templatesLoaded (); + emit templatesLoadFailed (error); } void @@ -529,9 +577,10 @@ TemplateManager::onTemplateDownloaded (const QString& templateId, tmpl->isLocal = true; } - // Register in cache QFileInfo fileInfo (localPath); - cache_->registerCachedTemplate (templateId, localPath, fileInfo.size ()); + QString fileMd5= computeFileMd5 (localPath); + cache_->registerCachedTemplate (templateId, localPath, fileInfo.size (), + fileMd5); emit downloadCompleted (templateId, localPath); } @@ -544,16 +593,20 @@ TemplateManager::onTemplateDownloadFailed (const QString& templateId, void TemplateManager::mergeMetadata ( - const QHash& remoteMetadata) { - // Remove templates that are no longer in the remote list - QList toRemove; - for (auto it= templates_.constBegin (); it != templates_.constEnd (); ++it) { - if (!remoteMetadata.contains (it.key ())) { - toRemove.append (it.key ()); + const QHash& remoteMetadata, + bool incremental) { + if (!incremental) { + // Full refresh: remove templates that are no longer in the remote list + QList toRemove; + for (auto it= templates_.constBegin (); it != templates_.constEnd (); + ++it) { + if (!remoteMetadata.contains (it.key ())) { + toRemove.append (it.key ()); + } + } + for (const QString& id : toRemove) { + templates_.remove (id); } - } - for (const QString& id : toRemove) { - templates_.remove (id); } for (auto it= remoteMetadata.constBegin (); it != remoteMetadata.constEnd (); @@ -580,7 +633,7 @@ TemplateManager::mergeMetadata ( if (isUpdated && existing->isLocal) { // Remote template has been updated, clear local cache to force // re-download - qDebug () << "Template" << id << "updated, clearing cache"; + qDebug () << "[Template]" << id << "updated, clearing cache"; cache_->removeCachedTemplate (id); existing->localPath.clear (); existing->isLocal= false; @@ -621,7 +674,7 @@ TemplateManager::mergeMetadata ( if (!tmpl->isLocal && cache_->isTemplateCached (tmpl->id)) { tmpl->isLocal = true; tmpl->localPath= cache_->cachedTemplatePath (tmpl->id); - qDebug () << "Template" << tmpl->id << "found in cache"; + qDebug () << "[Template]" << tmpl->id << "found in cache"; } } } @@ -643,8 +696,9 @@ TemplateManager::templateFilePath (const QString& templateId) const { // Only allow alphanumeric characters, hyphens, underscores, and dots static const QRegularExpression validIdRegex ("^[a-zA-Z0-9._-]+$"); if (!validIdRegex.match (templateId).hasMatch ()) { - qWarning () << "Invalid templateId (potential path traversal attempt):" - << templateId; + qWarning () + << "[Template] Invalid templateId (potential path traversal attempt):" + << templateId; return QString (); } diff --git a/src/Mogan/TemplateCenter/template_manager.hpp b/src/Mogan/TemplateCenter/template_manager.hpp index 33a3c1590f..ed54e8261e 100644 --- a/src/Mogan/TemplateCenter/template_manager.hpp +++ b/src/Mogan/TemplateCenter/template_manager.hpp @@ -49,22 +49,28 @@ class TemplateManager : public QObject { void initialize (); bool isInitialized () const { return initialized_; } - // Category operations + // 分类操作 QList categories () const; QString categoryName (const QString& categoryId) const; - // Template queries + // 模板查询 QList templates () const; QList templatesByCategory (const QString& categoryId) const; TemplateMetadataPtr templateById (const QString& templateId) const; - // Template availability + // 本地模板可用性(纯查询,不验证 MD5) bool isTemplateAvailableLocally (const QString& templateId) const; QString localTemplatePath (const QString& templateId) const; - // Operations - void refreshTemplates (); // Force refresh from remote + // 验证本地模板完整性(MD5 校验),损坏时自动清理缓存 + bool verifyLocalTemplate (const QString& templateId); + + // 刷新操作 + void refreshCategories (); // 强制刷新分类列表 + void refreshTemplates (); // 强制刷新全部模板 + void + refreshTemplatesByCategory (const QString& categoryId); // 按分类增量刷新模板 // Template download void downloadTemplate (const QString& templateId); @@ -109,12 +115,11 @@ class TemplateManager : public QObject { void updateAvailable (int newTemplatesCount, int updatedTemplatesCount); private slots: - // liiistem.cn API format with categories + void onRemoteCategoriesLoaded (const QList& categories); + void onRemoteCategoriesFailed (const QString& error); void - onRemoteMetadataLoaded (const QHash& metadata, - const QList& categories); - void onRemoteMetadataFailed (const QString& error); - void onMetadataNotModified (); + onRemoteTemplatesLoaded (const QHash& metadata); + void onRemoteTemplatesFailed (const QString& error); void onTemplateDownloaded (const QString& templateId, const QString& localPath); void onTemplateDownloadFailed (const QString& templateId, @@ -129,8 +134,8 @@ private slots: QList loadLocalCategoriesFromScheme (); // Merge remote metadata with local cache - void - mergeMetadata (const QHash& remoteMetadata); + void mergeMetadata (const QHash& remoteMetadata, + bool incremental= false); // Utility functions QString localTemplatesDir () const; @@ -150,8 +155,13 @@ private slots: // State bool isOnline_; - bool isRefreshing_; - bool isRetryingWithoutEtag_; + bool isRefreshingCategories_; + bool isRefreshingTemplates_; + bool categoriesFetched_; // true after first successful categories fetch + QSet fetchedCategories_; // categories whose templates have been + // fetched this session + QString + pendingIncrementalCategoryId_; // 当前正在请求的分类 ID,用于增量更新标记 }; #endif // TEMPLATE_MANAGER_HPP diff --git a/src/Mogan/TemplateCenter/template_types.hpp b/src/Mogan/TemplateCenter/template_types.hpp index 4957c2c9b7..bddb2f807d 100644 --- a/src/Mogan/TemplateCenter/template_types.hpp +++ b/src/Mogan/TemplateCenter/template_types.hpp @@ -21,13 +21,15 @@ * @brief Template category structure (liiistem.cn API format) */ struct TemplateCategory { - QString id; // Unique category identifier - QString name; // Display name (localized) - QString description; // Category description - QString icon; // Icon emoji or name - int order; // Display order + QString id; // Unique category identifier (categoryKey from API) + QString name; // Display name (localized) + QString nameEn; // English display name + QString description; // Category description + QString icon; // Icon emoji or name + int order; // Display order + int templateCount; // Number of templates in this category - TemplateCategory () : order (0) {} + TemplateCategory () : order (0), templateCount (0) {} }; /** diff --git a/src/Plugins/Qt/QTMStartupTabWidget.cpp b/src/Plugins/Qt/QTMStartupTabWidget.cpp index ff3096da15..db6118eb94 100644 --- a/src/Plugins/Qt/QTMStartupTabWidget.cpp +++ b/src/Plugins/Qt/QTMStartupTabWidget.cpp @@ -14,6 +14,7 @@ #include "QTMTemplatePage.hpp" #include "qt_dpi_utils.hpp" #include "qt_utilities.hpp" +#include "template_manager.hpp" #include #include @@ -56,8 +57,9 @@ constexpr int kQuitButtonFontPx= 13; // Quit 按钮字号 */ QTMStartupTabWidget::QTMStartupTabWidget (QWidget* parent) : QWidget (parent), currentEntry_ (Entry::Home), navHomeBtn_ (nullptr), - navTemplateBtn_ (nullptr), navQuitBtn_ (nullptr), - navButtonGroup_ (nullptr), homePage_ (nullptr), templatePage_ (nullptr) { + navQuitBtn_ (nullptr), categoryLayout_ (nullptr), + navButtonGroup_ (nullptr), homePage_ (nullptr), templatePage_ (nullptr), + templateManager_ (nullptr) { setMinimumSize (DpiUtils::scaled (kMinWidth), DpiUtils::scaled (kMinHeight)); setFocusPolicy (Qt::StrongFocus); @@ -144,25 +146,22 @@ QTMStartupTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) { navButtonGroup_= new QButtonGroup (this); navButtonGroup_->setExclusive (true); - // 导航按钮(2个入口) - navHomeBtn_ = create_nav_button (qt_translate ("Home")); - navTemplateBtn_= create_nav_button (qt_translate ("Template")); - - // 添加到按钮组 + navHomeBtn_= create_nav_button (qt_translate ("Home")); navButtonGroup_->addButton (navHomeBtn_, static_cast (Entry::Home)); - navButtonGroup_->addButton (navTemplateBtn_, - static_cast (Entry::Template)); - sidebarLayout->addWidget (navHomeBtn_); - sidebarLayout->addWidget (navTemplateBtn_); // 导航按钮点击事件:切换到对应页面 connect (navHomeBtn_, &QPushButton::clicked, this, [this] () { set_current_entry (Entry::Home); }); - connect (navTemplateBtn_, &QPushButton::clicked, this, - [this] () { set_current_entry (Entry::Template); }); - // 弹性空间,将 Quit 按钮推到底部 + // Category buttons container (populated when categories load) + QWidget* categoryContainer= new QWidget (this); + categoryContainer->setObjectName ("startup-tab-category-container"); + categoryLayout_= new QVBoxLayout (categoryContainer); + categoryLayout_->setContentsMargins (0, 0, 0, 0); + categoryLayout_->setSpacing (DpiUtils::scaled (kSidebarSpacing)); + sidebarLayout->addWidget (categoryContainer); + sidebarLayout->addStretch (); sidebarLayout->addSpacing (DpiUtils::scaled (kQuitTopSpacing)); @@ -184,6 +183,17 @@ QTMStartupTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) { // 默认选中 Home navHomeBtn_->setChecked (true); + + // Connect to TemplateManager for dynamic categories + templateManager_= TemplateManager::instance (); + connect (templateManager_, &TemplateManager::categoriesLoaded, this, + &QTMStartupTabWidget::onCategoriesLoaded, Qt::UniqueConnection); + + // If categories already loaded, set up immediately + if (templateManager_->isInitialized () && + !templateManager_->categories ().isEmpty ()) { + setupCategoryNavButtons (); + } } /** @@ -191,6 +201,64 @@ QTMStartupTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) { * @param text 按钮文字 * @return 配置好的 QPushButton */ +void +QTMStartupTabWidget::setupCategoryNavButtons () { + if (!templateManager_ || !categoryLayout_) return; + + clearCategoryNavButtons (); + + auto categories= templateManager_->categories (); + for (const auto& cat : categories) { + QPushButton* btn= + create_nav_button (qt_translate (from_qstring (cat.nameEn))); + btn->setProperty ("categoryId", cat.id); + btn->setProperty ("nameEn", cat.nameEn); + navButtonGroup_->addButton (btn); + categoryLayout_->addWidget (btn); + navCategoryBtns_.append (btn); + + connect (btn, &QPushButton::clicked, this, + &QTMStartupTabWidget::onCategoryClicked); + } +} + +void +QTMStartupTabWidget::clearCategoryNavButtons () { + for (QPushButton* btn : navCategoryBtns_) { + if (btn) { + navButtonGroup_->removeButton (btn); + btn->deleteLater (); + } + } + navCategoryBtns_.clear (); +} + +void +QTMStartupTabWidget::onCategoryClicked () { + QPushButton* btn= qobject_cast (sender ()); + if (!btn) return; + + QString categoryId= btn->property ("categoryId").toString (); + if (categoryId.isEmpty ()) return; + + currentCategory_= categoryId; + if (templatePage_) { + QString nameEn= btn->property ("nameEn").toString (); + templatePage_->setCategory (categoryId, nameEn); + } + + if (templateManager_) { + templateManager_->refreshTemplatesByCategory (categoryId); + } + + set_current_entry (Entry::Template); +} + +void +QTMStartupTabWidget::onCategoriesLoaded () { + setupCategoryNavButtons (); +} + QPushButton* QTMStartupTabWidget::create_nav_button (const QString& text) { QPushButton* btn= new QPushButton (text, this); @@ -269,6 +337,18 @@ QTMStartupTabWidget::set_active_nav_button (Entry entry) { QAbstractButton* btn= navButtonGroup_->button (static_cast (entry)); if (btn) { btn->setChecked (true); + return; + } + + // For Template entry, activate the matching category button + if (entry == Entry::Template) { + for (QPushButton* catBtn : navCategoryBtns_) { + if (catBtn && + catBtn->property ("categoryId").toString () == currentCategory_) { + catBtn->setChecked (true); + return; + } + } } } diff --git a/src/Plugins/Qt/QTMStartupTabWidget.hpp b/src/Plugins/Qt/QTMStartupTabWidget.hpp index c0a4957b0e..ddde159fab 100644 --- a/src/Plugins/Qt/QTMStartupTabWidget.hpp +++ b/src/Plugins/Qt/QTMStartupTabWidget.hpp @@ -12,6 +12,7 @@ #ifndef QTMSTARTUPTABWIDGET_HPP #define QTMSTARTUPTABWIDGET_HPP +#include #include class QKeyEvent; @@ -22,6 +23,7 @@ class QStackedWidget; class QButtonGroup; class QTMHomePage; class QTMTemplatePage; +class TemplateManager; class QTMStartupTabWidget : public QWidget { Q_OBJECT @@ -41,6 +43,8 @@ class QTMStartupTabWidget : public QWidget { private slots: // Application operation void on_app_quit (); + void onCategoryClicked (); + void onCategoriesLoaded (); protected: void keyPressEvent (QKeyEvent* event) override; @@ -50,6 +54,8 @@ private slots: // 界面构建辅助函数 void setup_left_sidebar (QVBoxLayout* sidebarLayout); void setup_right_content (QStackedWidget* stackedWidget); + void setupCategoryNavButtons (); + void clearCategoryNavButtons (); QPushButton* create_nav_button (const QString& text); // 页面创建函数 @@ -61,21 +67,21 @@ private slots: void refresh_recent_docs_on_file_entry (Entry entry); private: - Entry currentEntry_; + Entry currentEntry_; + QString currentCategory_; // Navigation buttons - QPushButton* navHomeBtn_; - QPushButton* navTemplateBtn_; - QPushButton* navQuitBtn_; + QPushButton* navHomeBtn_; + QPushButton* navQuitBtn_; + QList navCategoryBtns_; + QVBoxLayout* categoryLayout_; // 互斥按钮组 QButtonGroup* navButtonGroup_; - // 各页面实例 - QTMHomePage* homePage_; - - // Template page (separate widget) + QTMHomePage* homePage_; QTMTemplatePage* templatePage_; + TemplateManager* templateManager_; }; #endif diff --git a/src/Plugins/Qt/QTMTemplateOpener.cpp b/src/Plugins/Qt/QTMTemplateOpener.cpp index 89a70f02bf..ae6f4fb5e7 100644 --- a/src/Plugins/Qt/QTMTemplateOpener.cpp +++ b/src/Plugins/Qt/QTMTemplateOpener.cpp @@ -3,6 +3,10 @@ * MODULE : QTMTemplateOpener.cpp * DESCRIPTION: Unified template opener implementation * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . ******************************************************************************/ #include "QTMTemplateOpener.hpp" @@ -20,9 +24,8 @@ QTMTemplateOpener::QTMTemplateOpener (QWidget* parent) QTMTemplateOpener::~QTMTemplateOpener () { cleanupProgressDialog_ (); } bool -QTMTemplateOpener::isAvailableLocally (const QString& templateId) const { - return templateManager_ && - templateManager_->isTemplateAvailableLocally (templateId); +QTMTemplateOpener::isAvailableLocally (const QString& templateId) { + return templateManager_ && templateManager_->verifyLocalTemplate (templateId); } bool diff --git a/src/Plugins/Qt/QTMTemplateOpener.hpp b/src/Plugins/Qt/QTMTemplateOpener.hpp index 14fdddd951..6ed5553bb5 100644 --- a/src/Plugins/Qt/QTMTemplateOpener.hpp +++ b/src/Plugins/Qt/QTMTemplateOpener.hpp @@ -3,6 +3,10 @@ * MODULE : QTMTemplateOpener.hpp * DESCRIPTION: Unified template opener for HomePage and TemplatePage * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . ******************************************************************************/ #ifndef QTMTEMPLATEOPENER_HPP @@ -63,7 +67,7 @@ class QTMTemplateOpener : public QObject { /** * @brief 检查模板是否在本地可用 */ - bool isAvailableLocally (const QString& templateId) const; + bool isAvailableLocally (const QString& templateId); signals: /** diff --git a/src/Plugins/Qt/QTMTemplatePage.cpp b/src/Plugins/Qt/QTMTemplatePage.cpp index 7c76ba5711..dc7d76c91c 100644 --- a/src/Plugins/Qt/QTMTemplatePage.cpp +++ b/src/Plugins/Qt/QTMTemplatePage.cpp @@ -3,6 +3,10 @@ * MODULE : QTMTemplatePage.cpp * DESCRIPTION: Template page implementation for startup tab * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . ******************************************************************************/ #include "QTMTemplatePage.hpp" @@ -43,7 +47,6 @@ constexpr int THUMBNAIL_HEIGHT= 227; constexpr int kPageMargin = 16; // 页面边距(减小边白) constexpr int kPageSpacing = 24; // 页面主布局间距 -constexpr int kCategorySpacing = 8; // 分类按钮间距 constexpr int kGridSpacing = 16; // 模板网格间距 constexpr int kCardWidth = 176; // 模板卡片宽度 constexpr int kCardHeight = 243; // 模板卡片高度(仅缩略图区域) @@ -55,7 +58,7 @@ constexpr int kPreviewDialogMinW = 700; // 预览弹窗最小宽度 constexpr int kPreviewDialogMinH = 800; // 预览弹窗最小高度 constexpr int kPreviewLayoutSpacing= 16; // 预览弹窗布局间距 constexpr int kPreviewLayoutMargin = 24; // 预览弹窗布局边距 -constexpr int kPageTitleFontPx = 24; // 页面标题字号 +constexpr int kSectionTitleFontPx = 16; // 分区标题字号 constexpr int kLoadingFontPx = 14; // Loading 文案字号 constexpr int kTemplateNameFontPx = 11; // 模板名称字号 constexpr int kPreviewTitleFontPx = 18; // 预览标题字号 @@ -67,18 +70,14 @@ constexpr int kUseButtonPadYPx = 8; // Use Template 按钮纵向内边距 constexpr int kUseButtonPadXPx = 24; // Use Template 按钮横向内边距 constexpr int kGridMarginYPx = 5; // 网格布局上下边距 constexpr int kGridMarginXPx = 10; // 网格布局左右边距 -constexpr int kCategoryBtnRadiusPx = 12; // 分类按钮圆角 -constexpr int kCategoryBtnPadYPx = 6; // 分类按钮纵向内边距 -constexpr int kCategoryBtnPadXPx = 14; // 分类按钮横向内边距 constexpr int kCardRadiusPx = 8; // 模板卡片圆角 } // namespace QTMTemplatePage::QTMTemplatePage (QWidget* parent) - : QWidget (parent), titleLabel_ (nullptr), categoryBar_ (nullptr), - scrollArea_ (nullptr), gridWidget_ (nullptr), gridLayout_ (nullptr), - templateManager_ (nullptr), currentCategory_ (""), - activeCategoryBtn_ (nullptr), resizeDebounceTimer_ (nullptr) { + : QWidget (parent), titleLabel_ (nullptr), scrollArea_ (nullptr), + gridWidget_ (nullptr), gridLayout_ (nullptr), templateManager_ (nullptr), + resizeDebounceTimer_ (nullptr) { resizeDebounceTimer_= new QTimer (this); resizeDebounceTimer_->setSingleShot (true); @@ -87,7 +86,7 @@ QTMTemplatePage::QTMTemplatePage (QWidget* parent) if (templateManager_ && templateManager_->isInitialized ()) { int newColumnCount= calculateColumnCount (); if (newColumnCount != currentColumnCount_) { - refreshTemplateGrid (currentCategory_); + refreshTemplateGrid (); } } }); @@ -103,8 +102,6 @@ QTMTemplatePage::initialize () { connect (templateManager_, &TemplateManager::templatesLoaded, this, &QTMTemplatePage::onTemplatesLoaded, Qt::UniqueConnection); - connect (templateManager_, &TemplateManager::categoriesLoaded, this, - &QTMTemplatePage::onCategoriesLoaded, Qt::UniqueConnection); // Check if already initialized with data if (templateManager_->isInitialized () && @@ -119,6 +116,27 @@ QTMTemplatePage::initialize () { } } +void +QTMTemplatePage::setCategory (const QString& categoryId, + const QString& displayName) { + if (currentCategory_ != categoryId) { + currentCategory_ = categoryId; + gridNeedsRefresh_= true; + if (isVisible ()) { + refreshTemplateGrid (); + } + } + if (titleLabel_ && !displayName.isEmpty ()) { + titleLabel_->setText (qt_translate (from_qstring (displayName))); + } +} + +void +QTMTemplatePage::refreshGrid () { + gridNeedsRefresh_= true; + refreshTemplateGrid (); +} + void QTMTemplatePage::setupUI () { QVBoxLayout* layout= new QVBoxLayout (this); @@ -127,20 +145,11 @@ QTMTemplatePage::setupUI () { DpiUtils::scaled (kPageMargin), DpiUtils::scaled (kPageMargin)); layout->setSpacing (DpiUtils::scaled (kPageSpacing)); - // Title titleLabel_= new QLabel (qt_translate ("Template Center"), this); - titleLabel_->setObjectName ("startup-tab-page-title"); - DpiUtils::applyScaledFont (titleLabel_, kPageTitleFontPx); + titleLabel_->setObjectName ("startup-tab-section-title"); + DpiUtils::applyScaledFont (titleLabel_, kSectionTitleFontPx); layout->addWidget (titleLabel_); - // Category bar - categoryBar_= new QWidget (this); - categoryBar_->setObjectName ("startup-tab-category-bar"); - QHBoxLayout* categoryLayout= new QHBoxLayout (categoryBar_); - categoryLayout->setContentsMargins (0, 0, 0, 0); - categoryLayout->setSpacing (DpiUtils::scaled (kCategorySpacing)); - layout->addWidget (categoryBar_); - // Scroll area for templates scrollArea_= new QScrollArea (this); scrollArea_->setWidgetResizable (true); @@ -167,90 +176,6 @@ QTMTemplatePage::setupUI () { gridLayout_->addWidget (loadingLabel, 0, 0, 1, 1); } -void -QTMTemplatePage::setupCategoryBar () { - if (!categoryBar_) return; - activeCategoryBtn_= nullptr; - - // Clear existing buttons - QLayout* layout= categoryBar_->layout (); - if (layout) { - QLayoutItem* item; - while ((item= layout->takeAt (0)) != nullptr) { - if (item->widget ()) { - delete item->widget (); - } - delete item; - } - } - - if (!templateManager_) return; - - QHBoxLayout* categoryLayout= qobject_cast (layout); - if (!categoryLayout) return; - - // Helper: apply category button style - auto styleCategoryBtn= [] (QPushButton* btn) { - btn->setStyleSheet (QString ("QPushButton#startup-tab-category-btn {" - " border-radius: %1px;" - " padding: %2px %3px;" - "}") - .arg (DpiUtils::scaled (kCategoryBtnRadiusPx)) - .arg (DpiUtils::scaled (kCategoryBtnPadYPx)) - .arg (DpiUtils::scaled (kCategoryBtnPadXPx))); - btn->setCursor (Qt::PointingHandCursor); - }; - - // Add "All" button - QPushButton* allBtn= new QPushButton (qt_translate ("All"), categoryBar_); - allBtn->setObjectName ("startup-tab-category-btn"); - allBtn->setCheckable (true); - allBtn->setChecked (currentCategory_.isEmpty ()); - allBtn->setProperty ("categoryId", QString ()); - styleCategoryBtn (allBtn); - connect (allBtn, &QPushButton::clicked, this, - &QTMTemplatePage::onCategoryClicked); - categoryLayout->addWidget (allBtn); - - if (currentCategory_.isEmpty ()) { - activeCategoryBtn_= allBtn; - } - - // Add category buttons - QList categories= templateManager_->categories (); - bool hasMatchedCurrentCategory = currentCategory_.isEmpty (); - for (const auto& cat : categories) { - QPushButton* btn= - new QPushButton (qt_translate (from_qstring (cat.name)), categoryBar_); - btn->setObjectName ("startup-tab-category-btn"); - btn->setCheckable (true); - btn->setChecked (cat.id == currentCategory_); - btn->setProperty ("categoryId", cat.id); - styleCategoryBtn (btn); - connect (btn, &QPushButton::clicked, this, - &QTMTemplatePage::onCategoryClicked); - categoryLayout->addWidget (btn); - - if (cat.id == currentCategory_) { - activeCategoryBtn_ = btn; - hasMatchedCurrentCategory= true; - } - } - - if (!hasMatchedCurrentCategory) { - currentCategory_.clear (); - allBtn->setChecked (true); - activeCategoryBtn_= allBtn; - } - - categoryLayout->addStretch (); -} - -void -QTMTemplatePage::onCategoriesLoaded () { - setupCategoryBar (); -} - int QTMTemplatePage::calculateColumnCount () const { if (!scrollArea_) return 4; @@ -269,27 +194,7 @@ QTMTemplatePage::calculateColumnCount () const { } void -QTMTemplatePage::onCategoryClicked () { - QPushButton* btn= qobject_cast (sender ()); - if (!btn) return; - - // Uncheck previous button - if (activeCategoryBtn_ && activeCategoryBtn_ != btn) { - activeCategoryBtn_->setChecked (false); - } - - // Check current button - btn->setChecked (true); - activeCategoryBtn_= btn; - - // Update current category and refresh - currentCategory_= btn->property ("categoryId").toString (); - refreshTemplateGrid (currentCategory_); -} - -void -QTMTemplatePage::refreshTemplateGrid (const QString& category) { - // Clear existing content +QTMTemplatePage::refreshTemplateGrid () { QLayoutItem* item; while ((item= gridLayout_->takeAt (0)) != nullptr) { if (item->widget ()) { @@ -311,11 +216,11 @@ QTMTemplatePage::refreshTemplateGrid (const QString& category) { // Get templates by category or all templates QList templates; - if (category.isEmpty ()) { + if (currentCategory_.isEmpty ()) { templates= templateManager_->templates (); } else { - templates= templateManager_->templatesByCategory (category); + templates= templateManager_->templatesByCategory (currentCategory_); } if (templates.isEmpty ()) { @@ -341,7 +246,6 @@ QTMTemplatePage::refreshTemplateGrid (const QString& category) { } gridLayout_->setRowStretch (row + 1, 1); - gridNeedsRefresh_= false; } @@ -573,13 +477,8 @@ QTMTemplatePage::showTemplatePreview (const QString& templateId) { void QTMTemplatePage::onTemplatesLoaded () { - // Initialize category bar if not already done - if (categoryBar_ && categoryBar_->layout () && - categoryBar_->layout ()->count () == 0) { - setupCategoryBar (); - } gridNeedsRefresh_= true; - refreshTemplateGrid (currentCategory_); + refreshTemplateGrid (); } void @@ -589,11 +488,14 @@ QTMTemplatePage::showEvent (QShowEvent* event) { // Refresh grid when page becomes visible. If onTemplatesLoaded already // refreshed while the widget had no proper size, recalculate now that // the viewport has its final width to avoid showing the wrong column count. - if (templateManager_ && templateManager_->isInitialized () && - !templateManager_->templates ().isEmpty ()) { + if (gridNeedsRefresh_) { + refreshTemplateGrid (); + } + else if (templateManager_ && templateManager_->isInitialized () && + !templateManager_->templates ().isEmpty ()) { int newColumnCount= calculateColumnCount (); - if (gridNeedsRefresh_ || newColumnCount != currentColumnCount_) { - refreshTemplateGrid (currentCategory_); + if (newColumnCount != currentColumnCount_) { + refreshTemplateGrid (); } } } diff --git a/src/Plugins/Qt/QTMTemplatePage.hpp b/src/Plugins/Qt/QTMTemplatePage.hpp index 2163cf13f8..1bf758e924 100644 --- a/src/Plugins/Qt/QTMTemplatePage.hpp +++ b/src/Plugins/Qt/QTMTemplatePage.hpp @@ -1,21 +1,22 @@ /****************************************************************************** * MODULE : QTMTemplatePage.hpp - * DESCRIPTION: Template page widget for startup tab + * DESCRIPTION: Template page implementation for startup tab * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . ******************************************************************************/ #ifndef QTMTEMPLATEPAGE_HPP #define QTMTEMPLATEPAGE_HPP -#include -#include #include #include class QGridLayout; class QLabel; -class QPushButton; class QResizeEvent; class QScrollArea; class QTimer; @@ -26,7 +27,7 @@ using TemplateMetadataPtr= QSharedPointer; /** * @brief Template page widget for startup tab * - * Displays template categories and grid of template cards. + * Displays a grid of template cards for the selected category. * Handles template download and opening. */ class QTMTemplatePage : public QWidget { @@ -36,8 +37,15 @@ class QTMTemplatePage : public QWidget { explicit QTMTemplatePage (QWidget* parent= nullptr); ~QTMTemplatePage (); + // 初始化:连接 TemplateManager 信号 void initialize (); + // 设置当前显示的分类 + void setCategory (const QString& categoryId, + const QString& displayName= QString ()); + QString currentCategory () const { return currentCategory_; } + void refreshGrid (); + protected: bool eventFilter (QObject* watched, QEvent* event) override; void showEvent (QShowEvent* event) override; @@ -45,36 +53,31 @@ class QTMTemplatePage : public QWidget { private slots: void onTemplatesLoaded (); - void onCategoriesLoaded (); - void onCategoryClicked (); private: void setupUI (); - void setupCategoryBar (); QWidget* createTemplateCard (const TemplateMetadataPtr& tmpl); - void refreshTemplateGrid (const QString& category); + void refreshTemplateGrid (); int calculateColumnCount () const; void showTemplatePreview (const QString& templateId); - // UI components + // UI 组件 QLabel* titleLabel_; - QWidget* categoryBar_; QScrollArea* scrollArea_; QWidget* gridWidget_; QGridLayout* gridLayout_; - // Data + // 数据 TemplateManager* templateManager_; QString currentCategory_; - QPushButton* activeCategoryBtn_; - // Responsive grid + // 响应式网格:当前列数 int currentColumnCount_= 4; - // Avoid duplicate refresh when onTemplatesLoaded and showEvent both fire + // 避免 onTemplatesLoaded 和 showEvent 重复刷新 bool gridNeedsRefresh_= true; - // Debounce timer for resize events to avoid frequent grid rebuilds + // resize 防抖定时器,避免拖拽窗口时频繁重建网格 QTimer* resizeDebounceTimer_; }; diff --git a/tests/Mogan/Startup/startup_tab_widget_test.cpp b/tests/Mogan/Startup/startup_tab_widget_test.cpp index 92e3c79c44..653e548b66 100644 --- a/tests/Mogan/Startup/startup_tab_widget_test.cpp +++ b/tests/Mogan/Startup/startup_tab_widget_test.cpp @@ -29,6 +29,8 @@ private slots: qInstallMessageHandler (oldHandler); } + // --- 构造与初始化 --- + // QTMTemplatePage 应能正常构造和初始化 void test_template_page_construct_and_initialize () { QTMTemplatePage page; @@ -41,6 +43,8 @@ private slots: QVERIFY (page.findChild ("startup-tab-grid") != nullptr); } + // --- 信号响应 --- + // 手动发射 TemplateManager::templatesLoaded 后,网格应被刷新 void test_templates_loaded_signal_refreshes_grid () { QTMTemplatePage page; @@ -73,27 +77,44 @@ private slots: "templatesLoaded signal with empty template list"); } - // 手动发射 categoriesLoaded 后,分类栏应被刷新 - void test_categories_loaded_signal_refreshes_bar () { + // --- 分类操作 --- + + // setCategory 不应导致崩溃 + void test_set_category_does_not_crash () { QTMTemplatePage page; page.initialize (); - TemplateManager* mgr= TemplateManager::instance (); - emit mgr->categoriesLoaded (); + page.setCategory ("test-category"); QCoreApplication::processEvents (); - // 分类栏存在但可能为空(无本地分类数据) - QWidget* bar= page.findChild ("startup-tab-category-bar"); - QVERIFY (bar != nullptr); + QVERIFY (page.currentCategory () == "test-category"); } - // resizeEvent 不应导致崩溃(不等待 debounce 定时器) + void test_refresh_grid_does_not_crash () { + QTMTemplatePage page; + page.initialize (); + page.setCategory ("cat1"); + page.refreshGrid (); + QCoreApplication::processEvents (); + QVERIFY (true); + } + + void test_set_category_with_display_name () { + QTMTemplatePage page; + page.initialize (); + page.setCategory ("thesis", "Thesis"); + QCoreApplication::processEvents (); + + QVERIFY (page.currentCategory () == "thesis"); + } + + // --- 事件处理 --- + void test_resize_event_does_not_crash () { QTMTemplatePage page; page.initialize (); page.resize (800, 600); page.resize (400, 300); - // 如果到这里没有崩溃,测试通过 QVERIFY (true); } }; diff --git a/tests/Mogan/Startup/template_api_test.cpp b/tests/Mogan/TemplateCenter/template_api_integration_test.cpp similarity index 71% rename from tests/Mogan/Startup/template_api_test.cpp rename to tests/Mogan/TemplateCenter/template_api_integration_test.cpp index e39d17f73e..b4340c1bac 100644 --- a/tests/Mogan/Startup/template_api_test.cpp +++ b/tests/Mogan/TemplateCenter/template_api_integration_test.cpp @@ -1,6 +1,6 @@ /****************************************************************************** - * MODULE : template_api_test.cpp + * MODULE : template_api_integration_test.cpp * DESCRIPTION: Full regression tests for TemplateAPI * COPYRIGHT : (C) 2026 Yuki Lu ******************************************************************************/ @@ -70,7 +70,7 @@ private slots: void test_api_base_url () { TemplateAPI api; - QCOMPARE (api.apiBaseUrl (), QString ("https://liiistem.cn/template-api")); + QCOMPARE (api.apiBaseUrl (), QString ("https://liiistem.cn")); api.setApiBaseUrl ("http://example.com/api"); QCOMPARE (api.apiBaseUrl (), QString ("http://example.com/api")); } @@ -115,13 +115,26 @@ private slots: QVERIFY (args[1].toString ().contains ("Offline")); } - void test_offline_mode_blocks_metadata () { + void test_offline_mode_blocks_categories () { TemplateAPI api; api.setOfflineMode (true); - QSignalSpy spy (&api, &TemplateAPI::metadataLoadFailed); + QSignalSpy spy (&api, &TemplateAPI::categoriesLoadFailed); QVERIFY (spy.isValid ()); - api.fetchMetadata (); + api.fetchCategories (); + QCoreApplication::processEvents (); + + QCOMPARE (spy.count (), 1); + QVERIFY (spy.takeFirst ()[0].toString ().contains ("Offline")); + } + + void test_offline_mode_blocks_templates () { + TemplateAPI api; + api.setOfflineMode (true); + QSignalSpy spy (&api, &TemplateAPI::templatesLoadFailed); + QVERIFY (spy.isValid ()); + + api.fetchTemplates ("cat1"); QCoreApplication::processEvents (); QCOMPARE (spy.count (), 1); @@ -193,7 +206,6 @@ private slots: // --- 下载失败场景 --- void test_download_network_error () { - // 空响应:服务器收到请求后直接断开,触发网络错误 MiniHttpServer server (""); TemplateAPI api; @@ -386,11 +398,9 @@ private slots: QTemporaryDir tempDir; QString targetPath= tempDir.filePath ("reuse.tmu"); - // 启动一个会 hang 的旧下载 api.downloadTemplate ("test-tmpl", hangUrl, targetPath); QCoreApplication::processEvents (); - // 再次下载同一个 templateId,内部 abort 旧请求,新请求应成功 QByteArray body= "Template Reuse Success"; QByteArray response= QByteArray ("HTTP/1.1 200 OK\r\n") + @@ -402,7 +412,6 @@ private slots: api.downloadTemplate ("test-tmpl", server.url () + "/file", newPath); QVERIFY (completedSpy.wait (1000)); - // 旧请求不应触发 downloadFailed,新请求应成功完成 QCOMPARE (failedSpy.count (), 0); QCOMPARE (completedSpy.count (), 1); QList args= completedSpy.takeFirst (); @@ -414,7 +423,6 @@ private slots: QCOMPARE (file.readAll (), body); } - // 测试取消不存在的 templateId:验证不会崩溃也不会误发射 downloadFailed 信号 void test_cancel_nonexistent_download () { TemplateAPI api; QSignalSpy spy (&api, &TemplateAPI::downloadFailed); @@ -426,132 +434,78 @@ private slots: QCOMPARE (spy.count (), 0); } - // --- Metadata 获取 --- - - void test_fetch_metadata_success () { - QJsonObject stats; - stats["downloads"]= 42; - stats["rating"] = 4.5; - - QJsonObject compat; - compat["mogan_min_version"]= "1.0"; - - QJsonArray tags; - tags.append ("math"); - tags.append ("physics"); - - QJsonObject tmplObj; - tmplObj["id"] = "tmpl1"; - tmplObj["name"] = "Template 1"; - tmplObj["description"] = "A template"; - tmplObj["category"] = "cat1"; - tmplObj["author"] = "Author"; - tmplObj["version"] = "1.0"; - tmplObj["license"] = "MIT"; - tmplObj["thumbnail_url"]= ""; - tmplObj["preview_url"] = ""; - tmplObj["download_url"] = "http://example.com/file"; - tmplObj["file_size"] = 100; - tmplObj["file_md5"] = "abc123"; - tmplObj["created_at"] = "2024-01-01T00:00:00Z"; - tmplObj["updated_at"] = "2024-06-01T00:00:00Z"; - tmplObj["language"] = "zh-CN"; - tmplObj["tags"] = tags; - tmplObj["compatibility"]= compat; - tmplObj["statistics"] = stats; - - QJsonArray templates; - templates.append (tmplObj); - - QJsonObject category; - category["id"] = "cat1"; - category["name"] = "Category 1"; - category["description"]= "Desc"; - category["icon"] = "icon"; - category["order"] = 1; - category["templates"] = templates; - - QJsonArray categories; - categories.append (category); + // --- 分类获取 --- + + void test_fetch_categories_success () { + QJsonArray categories; + QJsonObject cat1; + cat1["categoryKey"] = "thesis"; + cat1["name"] = u8"论文"; + cat1["nameEn"] = "Thesis"; + cat1["description"] = u8"学位论文模板"; + cat1["order"] = 1; + cat1["templateCount"]= 15; + categories.append (cat1); + + QJsonObject cat2; + cat2["categoryKey"] = "report"; + cat2["name"] = u8"报告"; + cat2["nameEn"] = "Report"; + cat2["description"] = u8"实验报告模板"; + cat2["order"] = 2; + cat2["templateCount"]= 10; + categories.append (cat2); QJsonObject root; - root["categories"]= categories; + root["code"] = 0; + root["success"]= true; + root["message"]= "ok"; + root["data"] = categories; QByteArray body= QJsonDocument (root).toJson (QJsonDocument::Compact); QByteArray response= QByteArray ("HTTP/1.1 200 OK\r\n") + "Content-Length: " + QByteArray::number (body.size ()) + "\r\n" + - "ETag: \"test-etag-123\"\r\n" + "\r\n" + body; + "\r\n" + body; MiniHttpServer server (response); TemplateAPI api; api.setApiBaseUrl (server.url ()); - QHash receivedMetadata; - QList receivedCategories; - connect (&api, &TemplateAPI::metadataLoaded, - [&] (const QHash& m, - const QList& c) { - receivedMetadata = m; - receivedCategories= c; - }); + QList receivedCategories; + connect (&api, &TemplateAPI::categoriesLoaded, + [&] (const QList& c) { receivedCategories= c; }); - QSignalSpy failedSpy (&api, &TemplateAPI::metadataLoadFailed); + QSignalSpy failedSpy (&api, &TemplateAPI::categoriesLoadFailed); QVERIFY (failedSpy.isValid ()); - api.fetchMetadata (); - QVERIFY (QSignalSpy (&api, &TemplateAPI::metadataLoaded).wait (1000)); + api.fetchCategories (); + QVERIFY (QSignalSpy (&api, &TemplateAPI::categoriesLoaded).wait (1000)); QCOMPARE (failedSpy.count (), 0); - QCOMPARE (receivedCategories.size (), 1); - QCOMPARE (receivedCategories[0].id, QString ("cat1")); - QCOMPARE (receivedCategories[0].name, QString ("Category 1")); + QCOMPARE (receivedCategories.size (), 2); + QCOMPARE (receivedCategories[0].id, QString ("thesis")); + QCOMPARE (receivedCategories[0].nameEn, QString ("Thesis")); QCOMPARE (receivedCategories[0].order, 1); - - QVERIFY (receivedMetadata.contains ("tmpl1")); - auto tmpl= receivedMetadata["tmpl1"]; - QVERIFY (tmpl); - QCOMPARE (tmpl->id, QString ("tmpl1")); - QCOMPARE (tmpl->name, QString ("Template 1")); - QCOMPARE (tmpl->author, QString ("Author")); - QCOMPARE (tmpl->fileSize, qint64 (100)); - QCOMPARE (tmpl->downloadCount, 42); - QCOMPARE (tmpl->rating, 4.5); - QCOMPARE (tmpl->tags, QStringList ({"math", "physics"})); - QCOMPARE (tmpl->moganMinVersion, QString ("1.0")); - - QCOMPARE (api.lastMetadataEtag (), QString ("\"test-etag-123\"")); + QCOMPARE (receivedCategories[1].id, QString ("report")); + QCOMPARE (receivedCategories[1].order, 2); } - void test_fetch_metadata_304_not_modified () { - QByteArray response= "HTTP/1.1 304 Not Modified\r\n\r\n"; - MiniHttpServer server (response); - TemplateAPI api; - api.setApiBaseUrl (server.url ()); - - QSignalSpy spy (&api, &TemplateAPI::metadataNotModified); - QVERIFY (spy.isValid ()); - - api.fetchMetadata (); - QVERIFY (spy.wait (1000)); - QCOMPARE (spy.count (), 1); - } - - void test_fetch_metadata_network_error () { + void test_fetch_categories_network_error () { MiniHttpServer server (""); TemplateAPI api; api.setApiBaseUrl (server.url ()); - QSignalSpy spy (&api, &TemplateAPI::metadataLoadFailed); + QSignalSpy spy (&api, &TemplateAPI::categoriesLoadFailed); QVERIFY (spy.isValid ()); - api.fetchMetadata (); + api.fetchCategories (); QVERIFY (spy.wait (1000)); QCOMPARE (spy.count (), 1); } - void test_fetch_metadata_invalid_json () { + void test_fetch_categories_invalid_json () { QByteArray body= "not json"; QByteArray response= QByteArray ("HTTP/1.1 200 OK\r\n") + @@ -562,14 +516,13 @@ private slots: TemplateAPI api; api.setApiBaseUrl (server.url ()); - QSignalSpy spy (&api, &TemplateAPI::metadataLoadFailed); + QSignalSpy spy (&api, &TemplateAPI::categoriesLoadFailed); QVERIFY (spy.isValid ()); - // 抑制 parseMetadataResponse 中预期内的 qWarning QtMessageHandler oldHandler= qInstallMessageHandler ( [] (QtMsgType, const QMessageLogContext&, const QString&) {}); - api.fetchMetadata (); + api.fetchCategories (); QVERIFY (spy.wait (1000)); qInstallMessageHandler (oldHandler); @@ -578,83 +531,56 @@ private slots: QVERIFY (spy.takeFirst ()[0].toString ().contains ("Invalid")); } - void test_fetch_metadata_conditional_request () { - QByteArray body= R"({"categories":[],"templates":[]})"; - QByteArray response= - QByteArray ("HTTP/1.1 200 OK\r\n") + - "Content-Length: " + QByteArray::number (body.size ()) + "\r\n" + - "ETag: \"etag-456\"\r\n" + "\r\n" + body; - - MiniHttpServer server (response); - TemplateAPI api; - api.setApiBaseUrl (server.url ()); - api.setMetadataEtag ("\"prev-etag\""); - - QSignalSpy spy (&api, &TemplateAPI::metadataLoaded); - QVERIFY (spy.isValid ()); - - api.fetchMetadata (); - QVERIFY (spy.wait (1000)); - - QCOMPARE (spy.count (), 1); - QCOMPARE (api.lastMetadataEtag (), QString ("\"etag-456\"")); - QVERIFY (server.lastRequest ().contains ("if-none-match")); - } - - // 测试 API 返回空的 categories 和 - // templates:验证正常解析为空的元数据和分类列表 - void test_fetch_metadata_empty_result () { - QByteArray body= R"({"categories":[],"templates":[]})"; - QByteArray response= - QByteArray ("HTTP/1.1 200 OK\r\n") + - "Content-Length: " + QByteArray::number (body.size ()) + "\r\n" + - "ETag: \"empty-etag\"\r\n" + "\r\n" + body; - - MiniHttpServer server (response); - TemplateAPI api; - api.setApiBaseUrl (server.url ()); + // --- 模板获取 --- + + void test_fetch_templates_success () { + QJsonArray items; + QJsonObject tmpl; + tmpl["templateKey"] = "nsfc-ysf-c"; + tmpl["name"] = u8"国自然青年C类"; + tmpl["description"] = u8"申请书模板"; + tmpl["author"] = "Liii Network"; + tmpl["version"] = "20260424"; + tmpl["license"] = "GPL-3.0"; + tmpl["thumbnailUrl"]= "https://cdn.liiistem.cn/images/thumb.png"; + tmpl["fileSize"] = 1024; + tmpl["fileMd5"] = "53213b7dd8736afbf9a927cccac16533"; + tmpl["language"] = "zh-CN"; + + QJsonObject categoryObj; + categoryObj["categoryKey"]= "resume-report-application"; + categoryObj["name"] = u8"简历报告申请"; + tmpl["category"] = categoryObj; + + tmpl["url"] = "https://cdn.liiistem.cn/library/file.tmu"; + tmpl["pdfUrl"] = "https://cdn.liiistem.cn/library/file.pdf"; + tmpl["createTime"]= "2026-04-24T00:00:00Z"; + tmpl["updateTime"]= "2026-04-25T12:00:00Z"; - QHash receivedMetadata; - QList receivedCategories; - connect (&api, &TemplateAPI::metadataLoaded, - [&] (const QHash& m, - const QList& c) { - receivedMetadata = m; - receivedCategories= c; - }); - - QSignalSpy failedSpy (&api, &TemplateAPI::metadataLoadFailed); - QVERIFY (failedSpy.isValid ()); - - api.fetchMetadata (); - QVERIFY (QSignalSpy (&api, &TemplateAPI::metadataLoaded).wait (1000)); - - QCOMPARE (failedSpy.count (), 0); - QCOMPARE (receivedCategories.size (), 0); - QCOMPARE (receivedMetadata.size (), 0); - QCOMPARE (api.lastMetadataEtag (), QString ("\"empty-etag\"")); - } + QJsonArray tags; + tags.append ("NSFC"); + tags.append (u8"国自然"); + tmpl["tags"]= tags; - // 测试模板字段缺失或 id 为空:验证空 id 模板被忽略且解析过程不会崩溃 - void test_fetch_metadata_missing_required_fields () { - QJsonObject tmplObj; - tmplObj["id"] = ""; - tmplObj["name"] = ""; - tmplObj["download_url"]= ""; + QJsonObject compat; + compat["mogan_min_version"]= "1.2.0"; + tmpl["compatibility"] = compat; - QJsonArray templates; - templates.append (tmplObj); + QJsonObject stats; + stats["downloads"]= 100; + stats["rating"] = 4.5; + tmpl["statistics"]= stats; - QJsonObject category; - category["id"] = "cat1"; - category["name"] = ""; - category["templates"]= templates; + items.append (tmpl); - QJsonArray categories; - categories.append (category); + QJsonObject dataObj; + dataObj["items"]= items; QJsonObject root; - root["categories"]= categories; + root["code"] = 0; + root["success"]= true; + root["message"]= "ok"; + root["data"] = dataObj; QByteArray body= QJsonDocument (root).toJson (QJsonDocument::Compact); @@ -668,26 +594,39 @@ private slots: api.setApiBaseUrl (server.url ()); QHash receivedMetadata; - QList receivedCategories; - connect (&api, &TemplateAPI::metadataLoaded, - [&] (const QHash& m, - const QList& c) { - receivedMetadata = m; - receivedCategories= c; + connect (&api, &TemplateAPI::templatesLoaded, + [&] (const QHash& m) { + receivedMetadata= m; }); - QSignalSpy failedSpy (&api, &TemplateAPI::metadataLoadFailed); + QSignalSpy failedSpy (&api, &TemplateAPI::templatesLoadFailed); QVERIFY (failedSpy.isValid ()); - api.fetchMetadata (); - QVERIFY (QSignalSpy (&api, &TemplateAPI::metadataLoaded).wait (1000)); + api.fetchTemplates ("resume-report-application"); + QVERIFY (QSignalSpy (&api, &TemplateAPI::templatesLoaded).wait (1000)); QCOMPARE (failedSpy.count (), 0); - QCOMPARE (receivedCategories.size (), 1); - QCOMPARE (receivedCategories[0].id, QString ("cat1")); + QCOMPARE (receivedMetadata.size (), 1); + + auto ptr= receivedMetadata["nsfc-ysf-c"]; + QVERIFY (!ptr.isNull ()); + QCOMPARE (ptr->id, QString ("nsfc-ysf-c")); + QCOMPARE (ptr->category, QString ("resume-report-application")); + QCOMPARE (ptr->fileSize, qint64 (1024)); + QCOMPARE (ptr->downloadCount, 100); + } - QVERIFY (!receivedMetadata.contains ("")); - QCOMPARE (receivedMetadata.size (), 0); + void test_fetch_templates_network_error () { + MiniHttpServer server (""); + TemplateAPI api; + api.setApiBaseUrl (server.url ()); + + QSignalSpy spy (&api, &TemplateAPI::templatesLoadFailed); + QVERIFY (spy.isValid ()); + + api.fetchTemplates ("cat1"); + QVERIFY (spy.wait (1000)); + QCOMPARE (spy.count (), 1); } // --- 生命周期安全 --- @@ -701,20 +640,30 @@ private slots: QTemporaryDir tempDir; QString targetPath= tempDir.filePath ("destructor.tmu"); api.downloadTemplate ("test-tmpl", url, targetPath); - // api 离开作用域,destructor 被调用 } QVERIFY (true); } - void test_destructor_with_active_metadata_fetch () { + void test_destructor_with_active_categories_fetch () { + QString url= + QString ("http://127.0.0.1:%1/hang").arg (hangServer_.serverPort ()); + + { + TemplateAPI api; + api.setApiBaseUrl (url); + api.fetchCategories (); + } + QVERIFY (true); + } + + void test_destructor_with_active_templates_fetch () { QString url= QString ("http://127.0.0.1:%1/hang").arg (hangServer_.serverPort ()); { TemplateAPI api; api.setApiBaseUrl (url); - api.fetchMetadata (); - // api 离开作用域,destructor 被调用 + api.fetchTemplates ("cat1"); } QVERIFY (true); } @@ -792,4 +741,4 @@ MiniHttpServer::lastRequest () const { } QTEST_MAIN (TestTemplateAPI) -#include "template_api_test.moc" +#include "template_api_integration_test.moc" diff --git a/tests/Mogan/TemplateCenter/template_api_parser_test.cpp b/tests/Mogan/TemplateCenter/template_api_parser_test.cpp new file mode 100644 index 0000000000..fa8e589206 --- /dev/null +++ b/tests/Mogan/TemplateCenter/template_api_parser_test.cpp @@ -0,0 +1,173 @@ + +/****************************************************************************** + * MODULE : template_api_parser_test.cpp + * DESCRIPTION: Unit tests for TemplateAPI response parsing + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************/ + +#include "template_api.hpp" + +#include + +#include +#include +#include + +class TestTemplateAPI : public QObject { + Q_OBJECT + +private slots: + // 测试分类列表解析:验证字段映射和按 order 排序 + void test_categories_response_parsing () { + TemplateAPI api; + + QJsonArray cats; + { + QJsonObject o; + o.insert ("categoryKey", "thesis"); + o.insert ("name", u8"论文"); + o.insert ("nameEn", "Thesis"); + o.insert ("description", u8"学位论文"); + o.insert ("order", 2); + o.insert ("templateCount", 10); + cats.append (o); + } + { + QJsonObject o; + o.insert ("categoryKey", "report"); + o.insert ("name", u8"报告"); + o.insert ("nameEn", "Report"); + o.insert ("description", u8"实验报告"); + o.insert ("order", 1); + o.insert ("templateCount", 5); + cats.append (o); + } + + auto result= api.parseCategoriesResponse (QJsonValue (cats)); + QCOMPARE (result.size (), 2); + QCOMPARE (result[0].id, QString ("report")); + QCOMPARE (result[0].order, 1); + QCOMPARE (result[1].id, QString ("thesis")); + QCOMPARE (result[1].order, 2); + QCOMPARE (result[1].templateCount, 10); + } + + // 测试模板详情解析:验证所有字段映射(包括 category + // 嵌套对象、tags、statistics) + void test_templates_response_field_mapping () { + TemplateAPI api; + + QJsonArray items; + QJsonObject tmpl; + tmpl.insert ("templateKey", "nsfc-ysf-c"); + tmpl.insert ("name", u8"国自然青年C类"); + tmpl.insert ("description", u8"申请书模板"); + tmpl.insert ("author", "Liii Network"); + tmpl.insert ("version", "20260424"); + tmpl.insert ("license", "GPL-3.0"); + tmpl.insert ("thumbnailUrl", "https://cdn.liiistem.cn/images/thumb.png"); + tmpl.insert ("fileSize", 1024); + tmpl.insert ("fileMd5", "53213b7dd8736afbf9a927cccac16533"); + tmpl.insert ("language", "zh-CN"); + + QJsonObject categoryObj; + categoryObj.insert ("categoryKey", "resume-report-application"); + categoryObj.insert ("name", u8"简历报告申请"); + tmpl.insert ("category", categoryObj); + + tmpl.insert ("url", "https://cdn.liiistem.cn/library/file.tmu"); + tmpl.insert ("pdfUrl", "https://cdn.liiistem.cn/library/file.pdf"); + tmpl.insert ("createTime", "2026-04-24T00:00:00Z"); + tmpl.insert ("updateTime", "2026-04-25T12:00:00Z"); + + QJsonArray tags; + tags.append ("NSFC"); + tags.append (u8"国自然"); + tmpl.insert ("tags", tags); + + QJsonObject compat; + compat.insert ("mogan_min_version", "1.2.0"); + tmpl.insert ("compatibility", compat); + + QJsonObject stats; + stats.insert ("downloads", 100); + stats.insert ("rating", 4.5); + tmpl.insert ("statistics", stats); + + items.append (tmpl); + + QJsonObject root; + root.insert ("items", items); + + auto result= api.parseTemplatesResponse (QJsonValue (root)); + QCOMPARE (result.size (), 1); + + auto ptr= result.value ("nsfc-ysf-c"); + QVERIFY (!ptr.isNull ()); + QCOMPARE (ptr->id, QString ("nsfc-ysf-c")); + QCOMPARE (ptr->name, QString (u8"国自然青年C类")); + QCOMPARE (ptr->category, QString ("resume-report-application")); + QCOMPARE (ptr->fileUrl, + QString ("https://cdn.liiistem.cn/library/file.tmu")); + QCOMPARE (ptr->previewUrl, + QString ("https://cdn.liiistem.cn/library/file.pdf")); + QCOMPARE (ptr->fileSize, qint64 (1024)); + QCOMPARE (ptr->fileMd5, QString ("53213b7dd8736afbf9a927cccac16533")); + QCOMPARE (ptr->tags.size (), 2); + QCOMPARE (ptr->downloadCount, 100); + QCOMPARE (ptr->rating, 4.5); + QCOMPARE (ptr->createdAt, + QDateTime::fromString ("2026-04-24T00:00:00Z", Qt::ISODate)); + QCOMPARE (ptr->updatedAt, + QDateTime::fromString ("2026-04-25T12:00:00Z", Qt::ISODate)); + } + + // 测试 createTime 缺失时回退到 created_at(兼容旧 API 格式) + void test_created_at_fallback_when_createTime_missing () { + TemplateAPI api; + + QJsonArray items; + QJsonObject tmpl; + tmpl.insert ("templateKey", "legacy"); + tmpl.insert ("name", "Legacy"); + + QJsonObject categoryObj; + categoryObj.insert ("categoryKey", "test"); + tmpl.insert ("category", categoryObj); + + tmpl.insert ("url", "http://example.com/file.tmu"); + tmpl.insert ("created_at", "2026-01-01T00:00:00Z"); + tmpl.insert ("updated_at", "2026-06-01T00:00:00Z"); + items.append (tmpl); + + QJsonObject root; + root.insert ("items", items); + + auto result= api.parseTemplatesResponse (QJsonValue (root)); + QCOMPARE (result.size (), 1); + + auto ptr= result.value ("legacy"); + QVERIFY (!ptr.isNull ()); + QCOMPARE (ptr->createdAt, + QDateTime::fromString ("2026-01-01T00:00:00Z", Qt::ISODate)); + QCOMPARE (ptr->updatedAt, + QDateTime::fromString ("2026-06-01T00:00:00Z", Qt::ISODate)); + } + + // 测试空数组返回空列表而非崩溃 + void test_empty_categories_returns_empty_list () { + TemplateAPI api; + auto result= api.parseCategoriesResponse (QJsonValue (QJsonArray ())); + QVERIFY (result.isEmpty ()); + } + + // 测试非数组输入返回空列表(容错) + void test_non_array_categories_returns_empty () { + TemplateAPI api; + auto result= api.parseCategoriesResponse (QJsonValue (QJsonObject ())); + QVERIFY (result.isEmpty ()); + } +}; + +QTEST_MAIN (TestTemplateAPI) +#include "template_api_parser_test.moc" diff --git a/tests/Mogan/TemplateCenter/template_cache_test.cpp b/tests/Mogan/TemplateCenter/template_cache_test.cpp new file mode 100644 index 0000000000..072e565ab4 --- /dev/null +++ b/tests/Mogan/TemplateCenter/template_cache_test.cpp @@ -0,0 +1,183 @@ + +/****************************************************************************** + * MODULE : template_cache_test.cpp + * DESCRIPTION: Unit tests for TemplateCache + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************/ + +#include "template_cache.hpp" +#include + +#include +#include +#include +#include +#include + +class TestTemplateCache : public QObject { + Q_OBJECT + +private: + QString cacheDir_; + + void clearCacheDir () { + QDir dir (cacheDir_); + if (dir.exists ()) { + dir.removeRecursively (); + } + } + +private slots: + void initTestCase () { + QStandardPaths::setTestModeEnabled (true); + QString appData= + QStandardPaths::writableLocation (QStandardPaths::AppDataLocation); + cacheDir_= QDir (appData).filePath ("system/template_cache"); + } + + void init () { + clearCacheDir (); + QDir ().mkpath (cacheDir_); + } + + void cleanup () { clearCacheDir (); } + + // 测试分类缓存的 save/load 往返:验证字段完整性和顺序保持 + void test_save_and_load_categories_cache () { + TemplateCache cache; + cache.initialize (); + + QList categories; + TemplateCategory cat1; + cat1.id = "thesis"; + cat1.name = u8"论文"; + cat1.nameEn = "Thesis"; + cat1.description = u8"学位论文模板"; + cat1.order = 1; + cat1.templateCount= 15; + categories.append (cat1); + + TemplateCategory cat2; + cat2.id = "report"; + cat2.name = u8"报告"; + cat2.nameEn = "Report"; + cat2.description = u8"实验报告模板"; + cat2.order = 2; + cat2.templateCount= 10; + categories.append (cat2); + + cache.saveCategoriesCache (categories); + + QList loaded= cache.loadCategoriesCache (); + QCOMPARE (loaded.size (), 2); + QCOMPARE (loaded[0].id, QString ("thesis")); + QCOMPARE (loaded[0].nameEn, QString ("Thesis")); + QCOMPARE (loaded[0].templateCount, 15); + QCOMPARE (loaded[1].id, QString ("report")); + QCOMPARE (loaded[1].order, 2); + } + + // 测试缓存文件损坏时返回空列表并自动清理 + void test_load_categories_cache_returns_empty_on_corruption () { + TemplateCache cache; + cache.initialize (); + + QString cachePath= QDir (cacheDir_).filePath ("categories.json"); + QFile file (cachePath); + QVERIFY (file.open (QIODevice::WriteOnly)); + file.write ("not valid json"); + file.close (); + + QList loaded= cache.loadCategoriesCache (); + QVERIFY (loaded.isEmpty ()); + QVERIFY (!QFile::exists (cachePath)); + } + + // 测试注册缓存模板后能通过 ID 查询路径和 MD5 + void test_register_and_query_cached_template () { + TemplateCache cache; + cache.initialize (); + + QString templatePath= QDir (cacheDir_).filePath ("test.tmu"); + QFile tmplFile (templatePath); + QVERIFY (tmplFile.open (QIODevice::WriteOnly)); + tmplFile.write ("template content"); + tmplFile.close (); + + cache.registerCachedTemplate ("test-id", templatePath, tmplFile.size (), + "abc123"); + + QVERIFY (cache.isTemplateCached ("test-id")); + QCOMPARE (cache.cachedTemplatePath ("test-id"), templatePath); + + QList entries= cache.cachedTemplates (); + QCOMPARE (entries.size (), 1); + QCOMPARE (entries[0].fileMd5, QString ("abc123")); + } + + // 测试移除缓存:索引和物理文件均被删除 + void test_remove_cached_template () { + TemplateCache cache; + cache.initialize (); + + QString templatePath= QDir (cacheDir_).filePath ("removal.tmu"); + QFile tmplFile (templatePath); + QVERIFY (tmplFile.open (QIODevice::WriteOnly)); + tmplFile.write ("data"); + tmplFile.close (); + + cache.registerCachedTemplate ("removal-id", templatePath, 4, "md5"); + QVERIFY (cache.isTemplateCached ("removal-id")); + + cache.removeCachedTemplate ("removal-id"); + QVERIFY (!cache.isTemplateCached ("removal-id")); + QVERIFY (!QFile::exists (templatePath)); + } + + // 测试缓存总大小计算(累加所有模板文件大小) + void test_cache_size_computation () { + TemplateCache cache; + cache.initialize (); + + QString path1= QDir (cacheDir_).filePath ("a.tmu"); + QFile f1 (path1); + f1.open (QIODevice::WriteOnly); + f1.write (QByteArray (100, 'a')); + f1.close (); + + QString path2= QDir (cacheDir_).filePath ("b.tmu"); + QFile f2 (path2); + f2.open (QIODevice::WriteOnly); + f2.write (QByteArray (200, 'b')); + f2.close (); + + cache.registerCachedTemplate ("id-a", path1, 100, "m1"); + cache.registerCachedTemplate ("id-b", path2, 200, "m2"); + + QCOMPARE (cache.cacheSize (), qint64 (300)); + } + + // 测试清空缓存:索引、分类缓存、物理文件全部清除 + void test_clear_cache_removes_all () { + TemplateCache cache; + cache.initialize (); + + QString path= QDir (cacheDir_).filePath ("clear.tmu"); + QFile f (path); + f.open (QIODevice::WriteOnly); + f.write ("x"); + f.close (); + + cache.registerCachedTemplate ("clear-id", path, 1, "m"); + cache.saveCategoriesCache (QList ()); + + QSignalSpy spy (&cache, &TemplateCache::cacheCleared); + cache.clearCache (); + + QVERIFY (cache.cachedTemplates ().isEmpty ()); + QCOMPARE (spy.count (), 1); + } +}; + +QTEST_MAIN (TestTemplateCache) +#include "template_cache_test.moc"