diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 2fabdcb..e9435af 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -1,192 +1,387 @@ #include "WorkbenchNavController.hpp" -#include - +#include "api/NavLoads.hpp" +#include "api/NavRequest.hpp" #include "dto/NavDto.hpp" +#include "repo/IAsyncProjectRepository.hpp" namespace geopro::controller { +using data::DsPage; +using data::DynamicForm; +using data::ExceptionRow; +using data::NavRequest; +using data::ProjectListPage; using data::ProjectSummary; +using data::StructNode; using data::Workspace; -WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent) +WorkbenchNavController::WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent) : QObject(parent), repo_(repo) {} -// RAII:进入公共导航操作时置忙(驱动等待光标),任何返回路径都复位——保证 busyChanged 配平。 -// 命名(非匿名)以匹配 controller 的 friend 声明,从而在析构时排空挂起的勾选请求。 -struct BusyGuard { - WorkbenchNavController* self; - bool* busy; - BusyGuard(WorkbenchNavController* s, bool* b) : self(s), busy(b) { - *busy = true; - emit self->busyChanged(true); - } - ~BusyGuard() { - WorkbenchNavController* ctrl = self; // 取本地副本:lambda 不能捕获成员 - *busy = false; - emit ctrl->busyChanged(false); - // 触发源是延迟合并发射,可能落在嵌套事件循环里:用队列调用在调用栈/嵌套循环展开后再排空, - // 那时 busy_ 已可靠为 false,重放才会真正执行(lambda 捕获的 ctrl 生命周期与应用一致,安全)。 - if (ctrl->checkedTmsPending_) - QMetaObject::invokeMethod( - ctrl, [ctrl] { ctrl->drainPendingCheckedTms(); }, Qt::QueuedConnection); - } -}; +WorkbenchNavController::~WorkbenchNavController() { abortAll(); } -void WorkbenchNavController::start() { - if (busy_) return; - BusyGuard guard(this, &busy_); - const auto ws = repo_.listWorkspaces(); - if (!ws.ok) { - emit loadFailed(QStringLiteral("workspaces"), QString::fromStdString(ws.error)); - return; - } - QString cur; - for (const auto& w : ws.value) - if (w.isCurrent) cur = QString::fromStdString(w.id); - if (cur.isEmpty() && !ws.value.empty()) cur = QString::fromStdString(ws.value.front().id); - currentWorkspaceId_ = cur.toStdString(); - emit workspacesLoaded(ws.value, cur); - loadProjectsAndStructure(); +bool WorkbenchNavController::anyInflight() const { + if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ || + moreDataReq_ || moreFilesReq_ || datasetReq_) + return true; + for (const auto& h : checkedInflight_) + if (h) return true; + return false; } -void WorkbenchNavController::loadProjectsAndStructure() { - const auto ps = repo_.pageProjects(std::string(), std::string(), 1, 10); // 下拉首页 10 - if (!ps.ok) { - emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error)); - return; +void WorkbenchNavController::emitBusyIfChanged() { + const bool now = anyInflight(); + if (now != lastBusy_) { + lastBusy_ = now; + emit busyChanged(now); } - lastProjects_ = ps.value.rows; - tmExceptionCache_.clear(); - currentParentId_.clear(); // 切项目/工作空间重置选中态(spec §6) - currentParentConfType_ = 0; - checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放 - pendingCheckedTms_.clear(); - QString curP; - if (!ps.value.rows.empty()) { - const auto& first = ps.value.rows.front(); - curP = QString::fromStdString(first.id); - currentProjectId_ = first.id; - currentProjectName_ = first.name; - currentCrsCode_ = first.crsCode; - } else { - currentProjectId_.clear(); - currentProjectName_.clear(); - currentCrsCode_.clear(); - } - emit projectsLoaded(ps.value.rows, curP, ps.value.total); +} - if (curP.isEmpty()) { - lastStructNodes_.clear(); - emit structureLoaded(QString(), {}); // 暂无项目 → 空树 - return; - } - const auto st = repo_.loadStructure(currentProjectId_); - if (!st.ok) { - emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error)); - return; - } - lastStructNodes_ = st.value; - emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); +void WorkbenchNavController::abortAll() { + if (startStepReq_) startStepReq_->abort(); + if (structReq_) structReq_->abort(); + if (selDataReq_) selDataReq_->abort(); + if (selFileReq_) selFileReq_->abort(); + if (selDetailReq_) selDetailReq_->abort(); + if (moreDataReq_) moreDataReq_->abort(); + if (moreFilesReq_) moreFilesReq_->abort(); + if (datasetReq_) datasetReq_->abort(); + for (const auto& h : checkedInflight_) + if (h) h->abort(); + checkedInflight_.clear(); +} + +void WorkbenchNavController::resetSelectionState() { + tmExceptionCache_.clear(); + currentParentId_.clear(); + currentParentConfType_ = 0; +} + +// ── start / switchWorkspace 依赖链:listWorkspaces → pageProjects → loadStructure ── +// 用 NavRequest 续延(每级 done 内用业务值构造下一级),startStepReq_ 跟踪当前在飞级。 +// abort-and-replace:start/switchWorkspace 入口 abort 旧 startStepReq_ → 自然丢弃旧链迟到信号。 + +void WorkbenchNavController::start() { + if (startStepReq_) startStepReq_->abort(); + NavRequest* req = repo_.listWorkspacesAsync(); + startStepReq_ = req; + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) { + if (req != startStepReq_) return; // §5.0 身份比对 + startStepReq_.clear(); + const auto ws = qvariant_cast>(v); + QString cur; + for (const auto& w : ws) + if (w.isCurrent) cur = QString::fromStdString(w.id); + if (cur.isEmpty() && !ws.empty()) cur = QString::fromStdString(ws.front().id); + currentWorkspaceId_ = cur.toStdString(); + emit workspacesLoaded(ws, cur); + runProjectsAndStructure(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != startStepReq_) return; + startStepReq_.clear(); + emit loadFailed(QStringLiteral("workspaces"), msg); + emitBusyIfChanged(); + }); } void WorkbenchNavController::switchWorkspace(const QString& tenantId) { - if (tenantId.isEmpty() || busy_) return; - BusyGuard guard(this, &busy_); - const auto r = repo_.switchWorkspace(tenantId.toStdString()); - if (!r.ok) { - emit loadFailed(QStringLiteral("switchWorkspace"), QString::fromStdString(r.error)); - return; - } - currentWorkspaceId_ = tenantId.toStdString(); - loadProjectsAndStructure(); + if (tenantId.isEmpty()) return; + if (startStepReq_) startStepReq_->abort(); + NavRequest* req = repo_.switchWorkspaceAsync(tenantId.toStdString()); + startStepReq_ = req; + emitBusyIfChanged(); + const std::string tid = tenantId.toStdString(); + QObject::connect(req, &NavRequest::done, this, [this, req, tid](const QVariant&) { + if (req != startStepReq_) return; + startStepReq_.clear(); + currentWorkspaceId_ = tid; + runProjectsAndStructure(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != startStepReq_) return; + startStepReq_.clear(); + emit loadFailed(QStringLiteral("switchWorkspace"), msg); + emitBusyIfChanged(); + }); } +void WorkbenchNavController::runProjectsAndStructure() { + NavRequest* req = repo_.pageProjectsAsync(std::string(), std::string(), 1, 10); // 下拉首页 10 + startStepReq_ = req; + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) { + if (req != startStepReq_) return; + startStepReq_.clear(); + const auto page = qvariant_cast(v); + lastProjects_ = page.rows; + resetSelectionState(); + QString curP; + if (!page.rows.empty()) { + const auto& first = page.rows.front(); + curP = QString::fromStdString(first.id); + currentProjectId_ = first.id; + currentProjectName_ = first.name; + currentCrsCode_ = first.crsCode; + } else { + currentProjectId_.clear(); + currentProjectName_.clear(); + currentCrsCode_.clear(); + } + emit projectsLoaded(page.rows, curP, page.total); + if (curP.isEmpty()) { + lastStructNodes_.clear(); + emit structureLoaded(QString(), {}); // 暂无项目 → 空树 + emitBusyIfChanged(); + return; + } + NavRequest* st = repo_.loadStructureAsync(currentProjectId_); + startStepReq_ = st; + emitBusyIfChanged(); + QObject::connect(st, &NavRequest::done, this, [this, st](const QVariant& sv) { + if (st != startStepReq_) return; + startStepReq_.clear(); + const auto nodes = qvariant_cast>(sv); + lastStructNodes_ = nodes; + emit structureLoaded(QString::fromStdString(currentProjectName_), nodes); + emitBusyIfChanged(); + }); + QObject::connect(st, &NavRequest::failed, this, [this, st](const QString& msg) { + if (st != startStepReq_) return; + startStepReq_.clear(); + emit loadFailed(QStringLiteral("structure"), msg); + emitBusyIfChanged(); + }); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != startStepReq_) return; + startStepReq_.clear(); + emit loadFailed(QStringLiteral("projects"), msg); + emitBusyIfChanged(); + }); +} + +// ── switchProject:单请求 loadStructure ── void WorkbenchNavController::switchProject(const QString& projectId) { - if (projectId.isEmpty() || busy_) return; - BusyGuard guard(this, &busy_); + if (projectId.isEmpty()) return; currentProjectId_ = projectId.toStdString(); for (const auto& p : lastProjects_) if (p.id == currentProjectId_) { currentProjectName_ = p.name; currentCrsCode_ = p.crsCode; } - const auto st = repo_.loadStructure(currentProjectId_); - if (!st.ok) { - emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error)); - return; - } - lastStructNodes_ = st.value; - tmExceptionCache_.clear(); - currentParentId_.clear(); // 切项目/工作空间重置选中态(spec §6) - currentParentConfType_ = 0; - checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放 - pendingCheckedTms_.clear(); - emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); + if (structReq_) structReq_->abort(); // abort-and-replace + NavRequest* req = repo_.loadStructureAsync(currentProjectId_); + structReq_ = req; + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) { + if (req != structReq_) return; // §5.0 身份比对 + structReq_.clear(); + const auto nodes = qvariant_cast>(v); + lastStructNodes_ = nodes; + resetSelectionState(); + emit structureLoaded(QString::fromStdString(currentProjectName_), nodes); + emitBusyIfChanged(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != structReq_) return; + structReq_.clear(); + emit loadFailed(QStringLiteral("structure"), msg); + emitBusyIfChanged(); + }); } +// ── selectObject:三并发(data 行 / file 行 / 对象详情),各自身份比对、独立 emit ── void WorkbenchNavController::selectObject(const QString& objectId, int confType) { - if (objectId.isEmpty() || busy_) return; - BusyGuard guard(this, &busy_); + if (objectId.isEmpty()) return; + if (selDataReq_) selDataReq_->abort(); // abort-and-replace 三路 + if (selFileReq_) selFileReq_->abort(); + if (selDetailReq_) selDetailReq_->abort(); currentParentId_ = objectId.toStdString(); currentParentConfType_ = confType; const std::string pid = currentProjectId_; dataPageNo_ = 1; filePageNo_ = 1; - const auto d = repo_.loadRows(pid, currentParentId_, confType, 3, dataPageNo_); - if (!d.ok) { - emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); - return; - } - dataTotal_ = d.value.total; - emit datasetsLoaded(objectId, d.value.rows, d.value.total, false); - const auto f = repo_.loadRows(pid, currentParentId_, confType, 1, filePageNo_); - if (!f.ok) { - emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); - return; - } - fileTotal_ = f.value.total; - emit filesLoaded(objectId, f.value.rows, f.value.total, false); - const auto detail = repo_.loadObjectDetail(currentParentId_, confType); - if (!detail.ok) { - emit loadFailed(QStringLiteral("objectDetail"), QString::fromStdString(detail.error)); - return; - } - emit objectDetailLoaded(objectId, detail.value); + NavRequest* dReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 3, dataPageNo_); + selDataReq_ = dReq; + NavRequest* fReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 1, filePageNo_); + selFileReq_ = fReq; + NavRequest* detReq = repo_.loadObjectDetailAsync(currentParentId_, confType); + selDetailReq_ = detReq; + emitBusyIfChanged(); + + QObject::connect(dReq, &NavRequest::done, this, [this, dReq, objectId](const QVariant& v) { + if (dReq != selDataReq_) return; + selDataReq_.clear(); + const auto page = qvariant_cast(v); + dataTotal_ = page.total; + emit datasetsLoaded(objectId, page.rows, page.total, false); + emitBusyIfChanged(); + }); + QObject::connect(dReq, &NavRequest::failed, this, [this, dReq](const QString& msg) { + if (dReq != selDataReq_) return; + selDataReq_.clear(); + emit loadFailed(QStringLiteral("datasets"), msg); + emitBusyIfChanged(); + }); + QObject::connect(fReq, &NavRequest::done, this, [this, fReq, objectId](const QVariant& v) { + if (fReq != selFileReq_) return; + selFileReq_.clear(); + const auto page = qvariant_cast(v); + fileTotal_ = page.total; + emit filesLoaded(objectId, page.rows, page.total, false); + emitBusyIfChanged(); + }); + QObject::connect(fReq, &NavRequest::failed, this, [this, fReq](const QString& msg) { + if (fReq != selFileReq_) return; + selFileReq_.clear(); + emit loadFailed(QStringLiteral("files"), msg); + emitBusyIfChanged(); + }); + QObject::connect(detReq, &NavRequest::done, this, [this, detReq, objectId](const QVariant& v) { + if (detReq != selDetailReq_) return; + selDetailReq_.clear(); + emit objectDetailLoaded(objectId, qvariant_cast(v)); + emitBusyIfChanged(); + }); + QObject::connect(detReq, &NavRequest::failed, this, [this, detReq](const QString& msg) { + if (detReq != selDetailReq_) return; + selDetailReq_.clear(); + emit loadFailed(QStringLiteral("objectDetail"), msg); + emitBusyIfChanged(); + }); } +// ── loadMoreData / loadMoreFiles:单请求,append=true ── void WorkbenchNavController::loadMoreData() { - if (currentParentId_.empty() || busy_) return; - BusyGuard guard(this, &busy_); - const auto d = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_); - if (!d.ok) { - emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); - return; - } - dataTotal_ = d.value.total; - emit datasetsLoaded(QString::fromStdString(currentParentId_), d.value.rows, d.value.total, true); + if (currentParentId_.empty()) return; + if (moreDataReq_) moreDataReq_->abort(); + NavRequest* req = + repo_.loadRowsAsync(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_); + moreDataReq_ = req; + const QString parent = QString::fromStdString(currentParentId_); + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req, parent](const QVariant& v) { + if (req != moreDataReq_) return; + moreDataReq_.clear(); + const auto page = qvariant_cast(v); + dataTotal_ = page.total; + emit datasetsLoaded(parent, page.rows, page.total, true); + emitBusyIfChanged(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != moreDataReq_) return; + moreDataReq_.clear(); + emit loadFailed(QStringLiteral("datasets"), msg); + emitBusyIfChanged(); + }); } void WorkbenchNavController::loadMoreFiles() { - if (currentParentId_.empty() || busy_) return; - BusyGuard guard(this, &busy_); - const auto f = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_); - if (!f.ok) { - emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); - return; - } - fileTotal_ = f.value.total; - emit filesLoaded(QString::fromStdString(currentParentId_), f.value.rows, f.value.total, true); + if (currentParentId_.empty()) return; + if (moreFilesReq_) moreFilesReq_->abort(); + NavRequest* req = + repo_.loadRowsAsync(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_); + moreFilesReq_ = req; + const QString parent = QString::fromStdString(currentParentId_); + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req, parent](const QVariant& v) { + if (req != moreFilesReq_) return; + moreFilesReq_.clear(); + const auto page = qvariant_cast(v); + fileTotal_ = page.total; + emit filesLoaded(parent, page.rows, page.total, true); + emitBusyIfChanged(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != moreFilesReq_) return; + moreFilesReq_.clear(); + emit loadFailed(QStringLiteral("files"), msg); + emitBusyIfChanged(); + }); } +// ── selectDataset:单请求 ── +void WorkbenchNavController::selectDataset(const QString& dsObjectId) { + if (dsObjectId.isEmpty()) return; + if (datasetReq_) datasetReq_->abort(); + NavRequest* req = repo_.loadDatasetFormAsync(dsObjectId.toStdString()); + datasetReq_ = req; + emitBusyIfChanged(); + QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) { + if (req != datasetReq_) return; + datasetReq_.clear(); + emit datasetDetailLoaded(qvariant_cast(v)); + emitBusyIfChanged(); + }); + QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { + if (req != datasetReq_) return; + datasetReq_.clear(); + emit loadFailed(QStringLiteral("datasetDetail"), msg); + emitBusyIfChanged(); + }); +} + +// ── setCheckedTms:未命中缓存项并发拉取,全到齐后组装;新勾选 abort 旧批(以最后一次为准)── void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { - if (busy_) { // 触发源是延迟合并发射,可能落在别的同步操作的嵌套事件循环里: - pendingCheckedTms_ = tmObjectIds; // 不丢弃,记下最新一次请求,待空闲重放 - checkedTmsPending_ = true; + for (const auto& h : checkedInflight_) // abort-and-replace 旧批 + if (h) h->abort(); + checkedInflight_.clear(); + + QStringList missing; + for (const QString& tmQ : tmObjectIds) { + const std::string tm = tmQ.toStdString(); + if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ); + } + if (missing.isEmpty()) { // 全命中缓存:同步组装,busyChanged 不抖动 + assembleAndEmitExceptionTree(tmObjectIds); + emitBusyIfChanged(); return; } - BusyGuard guard(this, &busy_); + + // 并发拉取未命中项;每个 done 写缓存、清自身槽,全到齐后组装。计数用 shared 计数器。 + auto remaining = std::make_shared(missing.size()); + auto failedFlag = std::make_shared(false); + for (const QString& tmQ : missing) { + const std::string tm = tmQ.toStdString(); + NavRequest* req = repo_.loadExceptionsByTmAsync(tm); + checkedInflight_.push_back(req); + QObject::connect(req, &NavRequest::done, this, + [this, req, tm, remaining, failedFlag, tmObjectIds](const QVariant& v) { + // 身份比对:req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。 + bool inCurrent = false; + for (const auto& h : checkedInflight_) + if (h == req) inCurrent = true; + if (!inCurrent) return; + tmExceptionCache_[tm] = qvariant_cast>(v); + if (--(*remaining) == 0 && !*failedFlag) { + checkedInflight_.clear(); + assembleAndEmitExceptionTree(tmObjectIds); + emitBusyIfChanged(); + } + }); + QObject::connect(req, &NavRequest::failed, this, + [this, req, remaining, failedFlag](const QString& msg) { + bool inCurrent = false; + for (const auto& h : checkedInflight_) + if (h == req) inCurrent = true; + if (!inCurrent) return; + if (*failedFlag) return; // 仅首个失败发一次 + *failedFlag = true; + for (const auto& h : checkedInflight_) // abort 其余在飞 + if (h && h != req) h->abort(); + checkedInflight_.clear(); + emit loadFailed(QStringLiteral("exceptions"), msg); + emitBusyIfChanged(); + }); + } + emitBusyIfChanged(); +} + +void WorkbenchNavController::assembleAndEmitExceptionTree(const QStringList& tmObjectIds) { auto nameOf = [this](const std::string& id) -> std::string { for (const auto& n : lastStructNodes_) if (n.id == id) return n.name; @@ -197,14 +392,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { for (const QString& tmQ : tmObjectIds) { const std::string tm = tmQ.toStdString(); auto it = tmExceptionCache_.find(tm); - if (it == tmExceptionCache_.end()) { - const auto ex = repo_.loadExceptionsByTm(tm); - if (!ex.ok) { - emit loadFailed(QStringLiteral("exceptions"), QString::fromStdString(ex.error)); - return; - } - it = tmExceptionCache_.emplace(tm, ex.value).first; - } + if (it == tmExceptionCache_.end()) continue; // 防御:理论上此时全命中 auto grouped = data::dto::groupExceptionsByConsortium(it->second); data::ObjectExceptionGroup g; g.objectId = tm; @@ -217,21 +405,4 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { emit exceptionTreeLoaded(groups, total); } -void WorkbenchNavController::drainPendingCheckedTms() { - if (busy_ || !checkedTmsPending_) return; - checkedTmsPending_ = false; // 先清标志再重放,避免重入自旋 - setCheckedTms(pendingCheckedTms_); // 此时 busy_=false,会正常执行 -} - -void WorkbenchNavController::selectDataset(const QString& dsObjectId) { - if (dsObjectId.isEmpty() || busy_) return; - BusyGuard guard(this, &busy_); - const auto form = repo_.loadDatasetForm(dsObjectId.toStdString()); - if (!form.ok) { - emit loadFailed(QStringLiteral("datasetDetail"), QString::fromStdString(form.error)); - return; - } - emit datasetDetailLoaded(form.value); -} - } // namespace geopro::controller diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index 136fe78..d2a9551 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -1,30 +1,39 @@ #pragma once #include +#include #include #include #include #include #include -#include "repo/IProjectRepository.hpp" +#include "repo/RepoTypes.hpp" + +namespace geopro::data { +class IAsyncProjectRepository; +class NavRequest; +} // namespace geopro::data namespace geopro::controller { -// 导航状态机:编排 IProjectRepository,持有当前 空间/项目 状态,经信号驱动 UI。不持有 widget。 +// 导航状态机:编排 IAsyncProjectRepository(异步句柄),持有当前 空间/项目 状态,经信号驱动 UI。 +// 不持有 widget。abort-and-replace + 句柄身份比对保证迟到信号被丢弃(spec §5.0)。 +// busyChanged 语义:「是否存在任一在飞句柄」(去抖:值变才发)。 class WorkbenchNavController : public QObject { Q_OBJECT public: - explicit WorkbenchNavController(data::IProjectRepository& repo, QObject* parent = nullptr); + explicit WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent = nullptr); + ~WorkbenchNavController() override; // 退出契约:abort 所有在飞句柄 - void start(); // 启动:拉空间 → 项目 → 结构 + void start(); // 启动:拉空间 → 项目 → 结构(依赖链) QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); } public slots: void switchWorkspace(const QString& tenantId); void switchProject(const QString& projectId); - void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情 - void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树 + void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情(并发) + void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树(并发,带缓存) void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单 void loadMoreData(); void loadMoreFiles(); @@ -45,19 +54,33 @@ signals: void loadFailed(const QString& stage, const QString& message); private: - friend struct BusyGuard; // 允许在 guard 析构时排空挂起的勾选请求 - void loadProjectsAndStructure(); // start + switchWorkspace 共用 - void drainPendingCheckedTms(); // 空闲后重放最近一次被挂起的勾选集 + // start / switchWorkspace 依赖链:拉项目 → 拉结构(续延,复用)。 + void runProjectsAndStructure(); + void abortAll(); // 退出/重置时 abort 所有在飞句柄 + void resetSelectionState(); // 切项目/工作空间重置选中态(spec §6) + void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged + bool anyInflight() const; // OR 所有在飞 QPointer / 集合 + void assembleAndEmitExceptionTree(const QStringList& tmObjectIds); // 缓存命中后组装异常树 + + data::IAsyncProjectRepository& repo_; + bool lastBusy_ = false; + + // 在飞句柄(QPointer 防悬垂;身份比对用): + QPointer startStepReq_; // start / switchWorkspace 依赖链当前在飞级 + QPointer structReq_; // switchProject + QPointer selDataReq_; // selectObject:data 行 + QPointer selFileReq_; // selectObject:file 行 + QPointer selDetailReq_; // selectObject:对象详情 + QPointer moreDataReq_; + QPointer moreFilesReq_; + QPointer datasetReq_; + std::vector> checkedInflight_; // setCheckedTms:未命中缓存的并发批 - data::IProjectRepository& repo_; - bool busy_ = false; - bool checkedTmsPending_ = false; - QStringList pendingCheckedTms_; std::vector lastProjects_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; std::string currentParentId_; int currentParentConfType_ = 0; - std::vector lastStructNodes_; // tmId→name 解析 + std::vector lastStructNodes_; // tmId→name 解析 std::map> tmExceptionCache_; int dataPageNo_ = 0; int filePageNo_ = 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dd4d1e9..4fb35c1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -106,6 +106,7 @@ target_sources(geopro_tests PRIVATE # controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 chartReady/loadFailed)。 find_package(Qt6 COMPONENTS Test REQUIRED) target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp) +target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp) target_link_libraries(geopro_tests PRIVATE geopro_controller Qt6::Test) add_subdirectory(spike) # spike S3: banded contour 渲染验证 diff --git a/tests/controller/test_workbench_nav_controller.cpp b/tests/controller/test_workbench_nav_controller.cpp new file mode 100644 index 0000000..5336266 --- /dev/null +++ b/tests/controller/test_workbench_nav_controller.cpp @@ -0,0 +1,201 @@ +#include +#include +#include +#include + +#include "WorkbenchNavController.hpp" +#include "api/NavLoads.hpp" +#include "api/NavRequest.hpp" +#include "repo/IAsyncProjectRepository.hpp" + +using namespace geopro; + +namespace { +// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::NavRequest 的 done/failed、override abort 记录。 +struct StubNavRequest : data::NavRequest { + bool aborted = false; + void abort() override { aborted = true; } + void fireDone(const QVariant& v) { emit done(v); } + void fireFailed() { emit failed(QStringLiteral("x")); } +}; + +struct StubAsyncRepo : data::IAsyncProjectRepository { + StubNavRequest* lastWorkspaces = nullptr; + StubNavRequest* lastSwitchWs = nullptr; + StubNavRequest* lastProjects = nullptr; + StubNavRequest* lastStructure = nullptr; + StubNavRequest* lastData = nullptr; + StubNavRequest* lastFile = nullptr; + StubNavRequest* lastDetail = nullptr; + StubNavRequest* lastDataset = nullptr; + std::vector exceptions; // setCheckedTms 并发批 + + data::NavRequest* listWorkspacesAsync() override { return lastWorkspaces = new StubNavRequest; } + data::NavRequest* switchWorkspaceAsync(const std::string&) override { + return lastSwitchWs = new StubNavRequest; + } + data::NavRequest* pageProjectsAsync(const std::string&, const std::string&, int, int) override { + return lastProjects = new StubNavRequest; + } + data::NavRequest* listProjectTypesAsync() override { return new StubNavRequest; } + data::NavRequest* loadStructureAsync(const std::string&) override { + return lastStructure = new StubNavRequest; + } + data::NavRequest* loadRowsAsync(const std::string&, const std::string&, int, int classifyType, + int) override { + auto* r = new StubNavRequest; + if (classifyType == 1) + lastFile = r; + else + lastData = r; + return r; + } + data::NavRequest* loadObjectDetailAsync(const std::string&, int) override { + return lastDetail = new StubNavRequest; + } + data::NavRequest* loadDatasetFormAsync(const std::string&) override { + return lastDataset = new StubNavRequest; + } + data::NavRequest* loadExceptionsByTmAsync(const std::string&) override { + auto* r = new StubNavRequest; + exceptions.push_back(r); + return r; + } +}; + +QVariant wsVar() { + return QVariant::fromValue(std::vector{{"w1", "WS", 2, true}}); +} +QVariant pageVar() { + data::ProjectSummary p; + p.id = "p1"; + p.name = "P1"; + return QVariant::fromValue(data::ProjectListPage{{p}, 1}); +} +QVariant emptyPageVar() { return QVariant::fromValue(data::ProjectListPage{{}, 0}); } +QVariant nodesVar() { return QVariant::fromValue(std::vector{}); } +QVariant dsPageVar() { return QVariant::fromValue(data::DsPage{{}, 0}); } +QVariant formVar() { return QVariant::fromValue(data::DynamicForm{}); } +QVariant exVar() { return QVariant::fromValue(std::vector{}); } +} // namespace + +// start() 依赖链:workspaces → projects → structure,逐级 emit 既有信号。 +TEST(WorkbenchNavController, StartChainEmitsWorkspacesThenProjectsThenStructure) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy wsSpy(&c, &controller::WorkbenchNavController::workspacesLoaded); + QSignalSpy psSpy(&c, &controller::WorkbenchNavController::projectsLoaded); + QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded); + c.start(); + repo.lastWorkspaces->fireDone(wsVar()); + EXPECT_EQ(wsSpy.count(), 1); + repo.lastProjects->fireDone(pageVar()); + EXPECT_EQ(psSpy.count(), 1); + repo.lastStructure->fireDone(nodesVar()); + EXPECT_EQ(stSpy.count(), 1); +} + +// busyChanged 反映在飞:发起→true,最后完成→false。 +TEST(WorkbenchNavController, BusyChangedReflectsInflight) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged); + c.start(); + ASSERT_GE(busySpy.count(), 1); + EXPECT_TRUE(busySpy.takeFirst().at(0).toBool()); // 首次 true + repo.lastWorkspaces->fireDone(wsVar()); + repo.lastProjects->fireDone(pageVar()); + repo.lastStructure->fireDone(nodesVar()); + EXPECT_FALSE(busySpy.last().at(0).toBool()); // 末尾 false +} + +// 空项目链:projects 空 → structure 发空树 → busy 复位(不发结构请求)。 +TEST(WorkbenchNavController, StartWithNoProjectsEmitsEmptyStructureAndClearsBusy) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded); + QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged); + c.start(); + repo.lastWorkspaces->fireDone(wsVar()); + repo.lastProjects->fireDone(emptyPageVar()); + EXPECT_EQ(stSpy.count(), 1); + EXPECT_FALSE(busySpy.last().at(0).toBool()); +} + +// setCheckedTms:新勾选 abort 旧异常批(以最后一次为准)。 +TEST(WorkbenchNavController, SetCheckedTmsAbortsPreviousBatch) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + c.setCheckedTms({"tmA"}); + StubNavRequest* a = repo.exceptions.back(); + c.setCheckedTms({"tmB"}); // 覆盖 + EXPECT_TRUE(a->aborted); +} + +// setCheckedTms:全命中缓存 → 不发新请求、直接组装 emit。 +TEST(WorkbenchNavController, SetCheckedTmsUsesCacheWithoutRequest) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy exSpy(&c, &controller::WorkbenchNavController::exceptionTreeLoaded); + c.setCheckedTms({"tmA"}); // 首次未命中 → 发请求 + ASSERT_EQ(repo.exceptions.size(), 1u); + repo.exceptions.back()->fireDone(exVar()); // 写缓存 + emit + EXPECT_EQ(exSpy.count(), 1); + c.setCheckedTms({"tmA"}); // 第二次命中缓存 → 不发新请求 + EXPECT_EQ(repo.exceptions.size(), 1u); + EXPECT_EQ(exSpy.count(), 2); // 仍 emit +} + +// 回灌防护:abort 后旧句柄迟到 done 被身份比对丢弃。 +TEST(WorkbenchNavController, DropsLateStructureAfterProjectSwitch) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded); + c.switchProject("pA"); + StubNavRequest* a = repo.lastStructure; + c.switchProject("pB"); + StubNavRequest* b = repo.lastStructure; + EXPECT_TRUE(a->aborted); // 旧句柄被 abort + a->fireDone(nodesVar()); // 旧 → 丢弃 + EXPECT_EQ(stSpy.count(), 0); + b->fireDone(nodesVar()); // 新 → 正常 + EXPECT_EQ(stSpy.count(), 1); +} + +// selectObject 三并发:data/file/detail 各自完成 → 各发对应信号。 +TEST(WorkbenchNavController, SelectObjectConcurrentEmitsAllThree) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded); + QSignalSpy flSpy(&c, &controller::WorkbenchNavController::filesLoaded); + QSignalSpy dtSpy(&c, &controller::WorkbenchNavController::objectDetailLoaded); + c.selectObject("obj1", 1); + repo.lastData->fireDone(dsPageVar()); + repo.lastFile->fireDone(dsPageVar()); + repo.lastDetail->fireDone(formVar()); + EXPECT_EQ(dsSpy.count(), 1); + EXPECT_EQ(flSpy.count(), 1); + EXPECT_EQ(dtSpy.count(), 1); +} + +// selectDataset 单请求 → datasetDetailLoaded。 +TEST(WorkbenchNavController, SelectDatasetEmitsDetail) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy spy(&c, &controller::WorkbenchNavController::datasetDetailLoaded); + c.selectDataset("ds1"); + repo.lastDataset->fireDone(formVar()); + EXPECT_EQ(spy.count(), 1); +} + +// 失败路径:start 首级失败 → loadFailed + busy 复位。 +TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy failSpy(&c, &controller::WorkbenchNavController::loadFailed); + QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged); + c.start(); + repo.lastWorkspaces->fireFailed(); + EXPECT_EQ(failSpy.count(), 1); + EXPECT_FALSE(busySpy.last().at(0).toBool()); +}