#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); } // 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")); } // 失败路径: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()); }