282 lines
12 KiB
C++
282 lines
12 KiB
C++
#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, 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;
|
||
}
|
||
data::NavRequest* deleteObjectAsync(const std::string&, int) override {
|
||
return lastMutate = new StubNavRequest;
|
||
}
|
||
data::NavRequest* deleteDatasetAsync(const std::string&) override {
|
||
return lastMutate = new StubNavRequest;
|
||
}
|
||
data::NavRequest* loadEditableFormAsync(const std::string&, const std::string&, int,
|
||
const std::string&) override {
|
||
return new StubNavRequest;
|
||
}
|
||
data::NavRequest* queryTmTypesAsync(const std::string&, const std::string&) override {
|
||
return new StubNavRequest;
|
||
}
|
||
data::NavRequest* submitObjectAsync(int, bool, const std::string&) override {
|
||
return lastMutate = new StubNavRequest;
|
||
}
|
||
data::NavRequest* listModelsAsync() override { return new StubNavRequest; }
|
||
StubNavRequest* lastMutate = nullptr;
|
||
};
|
||
|
||
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);
|
||
}
|
||
|
||
// selectObject 三并发部分失败:data 失败、file/detail 成功 → loadFailed×1,filesLoaded×1,objectDetailLoaded×1,datasetsLoaded×0。
|
||
TEST(WorkbenchNavController, SelectObjectOneFailureEmitsPartialResults) {
|
||
StubAsyncRepo repo;
|
||
controller::WorkbenchNavController c(repo);
|
||
QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded);
|
||
QSignalSpy flSpy(&c, &controller::WorkbenchNavController::filesLoaded);
|
||
QSignalSpy dtSpy(&c, &controller::WorkbenchNavController::objectDetailLoaded);
|
||
QSignalSpy failSpy(&c, &controller::WorkbenchNavController::loadFailed);
|
||
c.selectObject("obj2", 1);
|
||
// data 路失败,file/detail 路成功(三路独立,互不影响)
|
||
repo.lastData->fireFailed();
|
||
repo.lastFile->fireDone(dsPageVar());
|
||
repo.lastDetail->fireDone(formVar());
|
||
EXPECT_EQ(dsSpy.count(), 0); // data 失败,无 datasetsLoaded
|
||
EXPECT_EQ(flSpy.count(), 1); // file 成功,有 filesLoaded
|
||
EXPECT_EQ(dtSpy.count(), 1); // detail 成功,有 objectDetailLoaded
|
||
EXPECT_EQ(failSpy.count(), 1); // 只有 data 路触发 loadFailed
|
||
EXPECT_EQ(failSpy.first().at(0).toString(), QStringLiteral("datasets"));
|
||
}
|
||
|
||
namespace {
|
||
// 构造一棵树:6 个根(parentId="src" 不在集合内) + r1 两个子(c1a/c1b)。扁平 8 行。
|
||
QVariant treePageVar() {
|
||
auto mk = [](const std::string& id, const std::string& parent) {
|
||
data::DsRow d; d.id = id; d.dsName = id; d.ddCode = "dd"; d.parentId = parent; return d;
|
||
};
|
||
std::vector<data::DsRow> rows;
|
||
for (int i = 1; i <= 6; ++i) rows.push_back(mk("r" + std::to_string(i), "src"));
|
||
rows.push_back(mk("c1a", "r1"));
|
||
rows.push_back(mk("c1b", "r1"));
|
||
return QVariant::fromValue(data::DsPage{rows, static_cast<int>(rows.size())});
|
||
}
|
||
} // namespace
|
||
|
||
// 数据页树形分页:按「第一层节点(根)」分页(每页 5 根),total=根总数,子树随根整棵带出;
|
||
// loadMoreData 同步续切下一页根。
|
||
TEST(WorkbenchNavController, DataPaginatesByRootNodeNotFlatCount) {
|
||
qRegisterMetaType<std::vector<geopro::data::DsRow>>();
|
||
StubAsyncRepo repo;
|
||
controller::WorkbenchNavController c(repo);
|
||
QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded);
|
||
c.selectObject("tm1", 2);
|
||
repo.lastData->fireDone(treePageVar()); // 一次取全 8 行
|
||
|
||
ASSERT_EQ(dsSpy.count(), 1);
|
||
const auto rows0 = qvariant_cast<std::vector<data::DsRow>>(dsSpy.at(0).at(1));
|
||
EXPECT_EQ(rows0.size(), 7u); // 首页 5 根(r1..r5) + r1 两子 = 7 行
|
||
EXPECT_EQ(dsSpy.at(0).at(2).toInt(), 6); // total = 根总数 6(非扁平 8)
|
||
EXPECT_FALSE(dsSpy.at(0).at(3).toBool()); // append=false
|
||
|
||
c.loadMoreData(); // 同步切下一页(无新请求)
|
||
ASSERT_EQ(dsSpy.count(), 2);
|
||
const auto rows1 = qvariant_cast<std::vector<data::DsRow>>(dsSpy.at(1).at(1));
|
||
EXPECT_EQ(rows1.size(), 1u); // 第二页第 6 个根 r6
|
||
EXPECT_EQ(rows1[0].id, "r6");
|
||
EXPECT_EQ(dsSpy.at(1).at(2).toInt(), 6);
|
||
EXPECT_TRUE(dsSpy.at(1).at(3).toBool()); // append=true
|
||
|
||
c.loadMoreData(); // 已无更多根 → 不再 emit
|
||
EXPECT_EQ(dsSpy.count(), 2);
|
||
}
|
||
|
||
// 失败路径: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());
|
||
}
|