feat(controller): WorkbenchNavController 异步化(NavRequest续延+并发计数, abort-and-replace+身份比对, 删busy_/drain/BusyGuard, busyChanged=在飞存在性) + 单测

- 控制器依赖切换到 IAsyncProjectRepository(异步句柄)
- 删除 busy_/BusyGuard/drainPendingCheckedTms/checkedTmsPending_/pendingCheckedTms_/friend struct BusyGuard
- start/switchWorkspace 用 NavRequest 续延依赖链(startStepReq_ 跟踪当前在飞级)
- switchProject/loadMore*/selectDataset 单请求 + abort-and-replace + 身份比对
- selectObject 三并发(data/file/detail), 各自身份比对独立 emit
- setCheckedTms 并发拉取未命中缓存项, 计数汇聚; 新勾选 abort 旧批(以最后一次为准); tmExceptionCache_ 命中不发请求
- busyChanged 由 anyInflight() 驱动(emitBusyIfChanged 去抖, 值变才发)
- 析构 abortAll() 退出契约
- 对外信号面零改动, main.cpp 接线据引用绑定自动切换(无需改)
- 新增 9 个控制器单测(依赖链/并发/abort-and-replace/busyChanged/缓存语义/回灌防护/失败路径)
- 测试 96 -> 105 全绿
This commit is contained in:
gaozheng 2026-06-12 07:51:35 +08:00
parent 05f0bf3d4f
commit b097fa6e56
4 changed files with 576 additions and 180 deletions

View File

@ -1,70 +1,126 @@
#include "WorkbenchNavController.hpp"
#include <QMetaObject>
#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);
WorkbenchNavController::~WorkbenchNavController() { abortAll(); }
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;
}
~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);
void WorkbenchNavController::emitBusyIfChanged() {
const bool now = anyInflight();
if (now != lastBusy_) {
lastBusy_ = now;
emit busyChanged(now);
}
};
}
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-replacestart/switchWorkspace 入口 abort 旧 startStepReq_ → 自然丢弃旧链迟到信号。
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;
}
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<std::vector<Workspace>>(v);
QString cur;
for (const auto& w : ws.value)
for (const auto& w : ws)
if (w.isCurrent) cur = QString::fromStdString(w.id);
if (cur.isEmpty() && !ws.value.empty()) cur = QString::fromStdString(ws.value.front().id);
if (cur.isEmpty() && !ws.empty()) cur = QString::fromStdString(ws.front().id);
currentWorkspaceId_ = cur.toStdString();
emit workspacesLoaded(ws.value, cur);
loadProjectsAndStructure();
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::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::switchWorkspace(const QString& tenantId) {
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();
});
}
lastProjects_ = ps.value.rows;
tmExceptionCache_.clear();
currentParentId_.clear(); // 切项目/工作空间重置选中态spec §6
currentParentConfType_ = 0;
checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放
pendingCheckedTms_.clear();
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<ProjectListPage>(v);
lastProjects_ = page.rows;
resetSelectionState();
QString curP;
if (!ps.value.rows.empty()) {
const auto& first = ps.value.rows.front();
if (!page.rows.empty()) {
const auto& first = page.rows.front();
curP = QString::fromStdString(first.id);
currentProjectId_ = first.id;
currentProjectName_ = first.name;
@ -74,119 +130,258 @@ void WorkbenchNavController::loadProjectsAndStructure() {
currentProjectName_.clear();
currentCrsCode_.clear();
}
emit projectsLoaded(ps.value.rows, curP, ps.value.total);
emit projectsLoaded(page.rows, curP, page.total);
if (curP.isEmpty()) {
lastStructNodes_.clear();
emit structureLoaded(QString(), {}); // 暂无项目 → 空树
emitBusyIfChanged();
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::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();
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<std::vector<StructNode>>(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<std::vector<StructNode>>(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<DsPage>(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<DsPage>(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<DynamicForm>(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<DsPage>(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<DsPage>(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<DynamicForm>(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<int>(missing.size());
auto failedFlag = std::make_shared<bool>(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<std::vector<ExceptionRow>>(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

View File

@ -1,30 +1,39 @@
#pragma once
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <map>
#include <string>
#include <vector>
#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,14 +54,28 @@ 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<data::NavRequest> startStepReq_; // start / switchWorkspace 依赖链当前在飞级
QPointer<data::NavRequest> structReq_; // switchProject
QPointer<data::NavRequest> selDataReq_; // selectObjectdata 行
QPointer<data::NavRequest> selFileReq_; // selectObjectfile 行
QPointer<data::NavRequest> selDetailReq_; // selectObject对象详情
QPointer<data::NavRequest> moreDataReq_;
QPointer<data::NavRequest> moreFilesReq_;
QPointer<data::NavRequest> datasetReq_;
std::vector<QPointer<data::NavRequest>> checkedInflight_; // setCheckedTms未命中缓存的并发批
data::IProjectRepository& repo_;
bool busy_ = false;
bool checkedTmsPending_ = false;
QStringList pendingCheckedTms_;
std::vector<data::ProjectSummary> lastProjects_;
std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
std::string currentParentId_;

View File

@ -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

View File

@ -0,0 +1,201 @@
#include <gtest/gtest.h>
#include <QSignalSpy>
#include <QVariant>
#include <vector>
#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<StubNavRequest*> 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<data::Workspace>{{"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<data::StructNode>{}); }
QVariant dsPageVar() { return QVariant::fromValue(data::DsPage{{}, 0}); }
QVariant formVar() { return QVariant::fromValue(data::DynamicForm{}); }
QVariant exVar() { return QVariant::fromValue(std::vector<data::ExceptionRow>{}); }
} // 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());
}