diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index 5a923b8..1def781 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -1,60 +1,62 @@ #include "DatasetDetailController.hpp" -#include -#include "repo/IDatasetRepository.hpp" +#include "repo/IAsyncDatasetRepository.hpp" +#include "api/DatasetLoadHandles.hpp" namespace geopro::controller { -DatasetDetailController::DatasetDetailController(data::IDatasetRepository& repo, QObject* parent) +DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent) : QObject(parent), repo_(repo) {} void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { - if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入) - busy_ = true; if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演 - busy_ = false; emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); return; } - const std::string id = dsId.toStdString(); - try { + if (chartLoad_) chartLoad_->abort(); // abort-and-replace + data::ChartLoad* load = repo_.loadChartAsync(dsId.toStdString()); + chartLoad_ = load; + emit loadStarted(dsId, LoadPhase::Chart); + QObject::connect(load, &data::ChartLoad::done, this, + [this, load, dsId, ddCode](const data::ChartParts& parts) { + if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 + chartLoad_.clear(); ChartData d; d.dsId = dsId; d.ddCode = ddCode; - // 严格对齐原版「原数据」页:只拉 scatter + 散点色阶(type1) 两个。 - // 网格数据(inversion/rows)与异常(queryException)随「网格数据」页按需懒加载。 - d.scatter = repo_.loadScatter(id); - d.scatterScale = repo_.loadScatterColorScale(id); - busy_ = false; + d.scatter = parts.scatter; + d.scatterScale = parts.scatterScale; emit chartReady(d); - } catch (const std::exception& e) { - busy_ = false; - emit loadFailed(dsId, QString::fromStdString(e.what())); - } catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_,否则永久拒绝后续加载 - busy_ = false; - emit loadFailed(dsId, QStringLiteral("未知错误")); - } + }); + QObject::connect(load, &data::ChartLoad::failed, this, + [this, load, dsId](const QString& msg) { + if (load != chartLoad_) return; + chartLoad_.clear(); + emit loadFailed(dsId, msg); + }); } void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { - if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入) if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据 - busy_ = true; - const std::string id = dsId.toStdString(); - try { + if (gridLoad_) gridLoad_->abort(); // abort-and-replace + data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString()); + gridLoad_ = load; + emit loadStarted(dsId, LoadPhase::Grid); + QObject::connect(load, &data::GridLoad::done, this, + [this, load, dsId](const data::GridParts& parts) { + if (load != gridLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 + gridLoad_.clear(); GridData d; d.dsId = dsId; - // 网格数据:rows(服务端网格化,慢) + 色阶 type2 + 异常 queryException。 - d.grid = repo_.loadGrid(id); - d.gridScale = repo_.loadColorScale(id); - d.anomalies = repo_.loadAnomalies(id); - busy_ = false; + d.grid = parts.grid; + d.gridScale = parts.gridScale; + d.anomalies = parts.anomalies; emit gridReady(d); - } catch (const std::exception& e) { - busy_ = false; - emit loadFailed(dsId, QString::fromStdString(e.what())); - } catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_,否则永久拒绝后续加载 - busy_ = false; - emit loadFailed(dsId, QStringLiteral("未知错误")); - } + }); + QObject::connect(load, &data::GridLoad::failed, this, + [this, load, dsId](const QString& msg) { + if (load != gridLoad_) return; + gridLoad_.clear(); + emit loadFailed(dsId, msg); + }); } void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); } diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index a870647..e55a954 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -1,18 +1,21 @@ #pragma once #include #include +#include #include #include "model/Field.hpp" #include "model/ColorScale.hpp" #include "model/Anomaly.hpp" -namespace geopro::data { class IDatasetRepository; } +namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; } namespace geopro::controller { -// 数据详情编排:单击/双击数据集 → 拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 -// 仅服务图表,不与 WorkbenchNavController(项目/结构导航)耦合。 +// 数据详情编排:双击/网格页签 → 异步拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 class DatasetDetailController : public QObject { Q_OBJECT public: + enum class LoadPhase { Chart, Grid }; + Q_ENUM(LoadPhase) + struct ChartData { QString dsId, ddCode; geopro::core::ScatterField scatter; @@ -29,19 +32,20 @@ public: std::vector anomalies; }; - explicit DatasetDetailController(data::IDatasetRepository& repo, QObject* parent = nullptr); + explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr); public slots: - void openDataset(const QString& dsId, const QString& ddCode); // 双击=新建/聚焦页 - void focusDataset(const QString& dsId); // 单击=聚焦已开页 - // 网格数据懒加载:网格页首次激活时调用(rows 服务端网格化 1-4s,故不随 openDataset 拉)。 + void openDataset(const QString& dsId, const QString& ddCode); + void focusDataset(const QString& dsId); void loadGridData(const QString& dsId, const QString& ddCode); signals: + void loadStarted(const QString& dsId, LoadPhase phase); void chartReady(const ChartData& data); void gridReady(const GridData& data); void focusRequested(const QString& dsId); void loadFailed(const QString& dsId, const QString& message); private: - data::IDatasetRepository& repo_; - bool busy_ = false; + data::IAsyncDatasetRepository& repo_; + QPointer chartLoad_; + QPointer gridLoad_; }; } // namespace geopro::controller diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index 94ebe8a..ad9ee39 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -1,51 +1,62 @@ #include "api/ApiDatasetRepository.hpp" -#include #include #include #include #include "ApiClient.hpp" +#include "ApiBatch.hpp" +#include "api/DatasetLoadHandles.hpp" #include "dto/DatasetChartDto.hpp" + namespace geopro::data { namespace { + QString enc(const std::string& s) { return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); } -void must(const net::ApiResponse& r, const char* what) { - if (r.code != 200) throw std::runtime_error(std::string(what) + " failed: " + - (r.msg.isEmpty() ? r.rawError.toStdString() : r.msg.toStdString())); + +// 失败判定(原 must() 口径):业务码 != 200 或传输错误。 +bool isFailure(const geopro::net::ApiResponse& r) { + return r.code != 200 || !r.rawError.isEmpty(); } -geopro::core::ColorScale colorScale(net::ApiClient& api, const std::string& dsId, int type) { - QJsonObject body{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; - const net::ApiResponse r = api.postJson(QStringLiteral("/business/lvl/colorGradation/getDetail"), body); - must(r, "colorGradation"); - return dto::parseColorBar(r.data); + +QJsonObject colorBody(const std::string& dsId, int type) { + return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; } + } // namespace ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} -geopro::core::Grid ApiDatasetRepository::loadGrid(const std::string& dsId) { - const net::ApiResponse r = api_.get( - QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))); - must(r, "inversion/rows"); - return dto::parseInversionGrid(r.data); +ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) { + // index 0 = scatter(GET),index 1 = 散点色阶 type1(POST) + QList calls{ + api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)), + }; + auto* batch = new net::ApiBatch(calls, &isFailure); + return new ApiChartLoad(batch, [](const QList& r) { + ChartParts p; + p.scatter = dto::parseScatterGraph(r[0].data); + p.scatterScale = dto::parseColorBar(r[1].data); + return p; + }); } -geopro::core::ScatterField ApiDatasetRepository::loadScatter(const std::string& dsId) { - const net::ApiResponse r = api_.get( - QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))); - must(r, "scatterGraph"); - return dto::parseScatterGraph(r.data); -} -geopro::core::ColorScale ApiDatasetRepository::loadColorScale(const std::string& dsId) { - return colorScale(api_, dsId, 2); -} -geopro::core::ColorScale ApiDatasetRepository::loadScatterColorScale(const std::string& dsId) { - return colorScale(api_, dsId, 1); -} -std::vector ApiDatasetRepository::loadAnomalies(const std::string& dsId) { - const net::ApiResponse r = api_.get( - QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))); - must(r, "queryException"); - return dto::parseDatasetAnomalies(r.data.value(QStringLiteral("value")).toArray()); + +GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) { + // index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET) + QList calls{ + api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)), + api_.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))), + }; + auto* batch = new net::ApiBatch(calls, &isFailure); + return new ApiGridLoad(batch, [](const QList& r) { + GridParts p; + p.grid = dto::parseInversionGrid(r[0].data); + p.gridScale = dto::parseColorBar(r[1].data); + p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray()); + return p; + }); } + } // namespace geopro::data diff --git a/src/data/api/ApiDatasetRepository.hpp b/src/data/api/ApiDatasetRepository.hpp index 617aaad..c341fca 100644 --- a/src/data/api/ApiDatasetRepository.hpp +++ b/src/data/api/ApiDatasetRepository.hpp @@ -1,18 +1,14 @@ #pragma once -#include "repo/IDatasetRepository.hpp" +#include "repo/IAsyncDatasetRepository.hpp" namespace geopro::net { class ApiClient; } namespace geopro::data { -// 真实 API 实现 IDatasetRepository(ERT 反演相关)。失败抛 std::runtime_error。 -class ApiDatasetRepository : public IDatasetRepository { +// 真实 API 实现 IAsyncDatasetRepository(ERT 反演)。每次加载返回自管理句柄。 +class ApiDatasetRepository : public IAsyncDatasetRepository { public: explicit ApiDatasetRepository(net::ApiClient& api); - std::vector loadStructure() override { return {}; } // 不经此仓储取结构 - geopro::core::Grid loadGrid(const std::string& dsId) override; - geopro::core::ScatterField loadScatter(const std::string& dsId) override; - geopro::core::ColorScale loadColorScale(const std::string& dsId) override; // type2 网格 - geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) override; // type1 原数据 - std::vector loadAnomalies(const std::string& dsId) override; + ChartLoad* loadChartAsync(const std::string& dsId) override; + GridLoad* loadGridAsync(const std::string& dsId) override; private: net::ApiClient& api_; }; diff --git a/tests/controller/test_dataset_detail_controller.cpp b/tests/controller/test_dataset_detail_controller.cpp index f1b1b01..25d85ac 100644 --- a/tests/controller/test_dataset_detail_controller.cpp +++ b/tests/controller/test_dataset_detail_controller.cpp @@ -1,31 +1,81 @@ #include #include #include "DatasetDetailController.hpp" -#include "repo/IDatasetRepository.hpp" +#include "repo/IAsyncDatasetRepository.hpp" +#include "api/DatasetLoadHandles.hpp" using namespace geopro; + namespace { -struct StubRepo : data::IDatasetRepository { - bool fail = false; - std::vector loadStructure() override { return {}; } - core::Grid loadGrid(const std::string&) override { core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; } - // openDataset 现只拉 scatter/scatterScale/anomalies(网格懒加载),失败路径由 loadScatter 抛出触发。 - core::ScatterField loadScatter(const std::string&) override { if (fail) throw std::runtime_error("x"); return {}; } - core::ColorScale loadColorScale(const std::string&) override { return {}; } - core::ColorScale loadScatterColorScale(const std::string&) override { return {}; } - std::vector loadAnomalies(const std::string&) override { return {}; } +// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。 +struct StubChartLoad : data::ChartLoad { + bool aborted = false; + void abort() override { aborted = true; } + void fireDone() { emit done(data::ChartParts{}); } + void fireFailed() { emit failed(QStringLiteral("x")); } +}; +struct StubGridLoad : data::GridLoad { + bool aborted = false; + void abort() override { aborted = true; } + void fireDone() { emit done(data::GridParts{}); } +}; +struct StubAsyncRepo : data::IAsyncDatasetRepository { + StubChartLoad* lastChart = nullptr; + StubGridLoad* lastGrid = nullptr; + data::ChartLoad* loadChartAsync(const std::string&) override { + lastChart = new StubChartLoad; return lastChart; + } + data::GridLoad* loadGridAsync(const std::string&) override { + lastGrid = new StubGridLoad; return lastGrid; + } }; } -TEST(DatasetDetailController, EmitsChartReadyOnSuccess) { - StubRepo repo; + +TEST(DatasetDetailController, EmitsChartReadyOnDone) { + StubAsyncRepo repo; controller::DatasetDetailController c(repo); QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); c.openDataset("ds1", "dd_inversion_data"); + repo.lastChart->fireDone(); EXPECT_EQ(spy.count(), 1); } -TEST(DatasetDetailController, EmitsLoadFailedOnThrow) { - StubRepo repo; repo.fail = true; + +TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { + StubAsyncRepo repo; controller::DatasetDetailController c(repo); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_inversion_data"); + repo.lastChart->fireFailed(); + EXPECT_EQ(spy.count(), 1); +} + +TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); + c.openDataset("ds1", "dd_other"); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载 +} + +TEST(DatasetDetailController, AbortsPreviousOnReopen) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + c.openDataset("dsA", "dd_inversion_data"); + StubChartLoad* a = repo.lastChart; + c.openDataset("dsB", "dd_inversion_data"); // 替换 + EXPECT_TRUE(a->aborted); // 旧句柄被 abort +} + +TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { + StubAsyncRepo repo; + controller::DatasetDetailController c(repo); + QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); + c.openDataset("dsA", "dd_inversion_data"); + StubChartLoad* a = repo.lastChart; + c.openDataset("dsB", "dd_inversion_data"); + StubChartLoad* b = repo.lastChart; + a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 + EXPECT_EQ(spy.count(), 0); + b->fireDone(); // 当前句柄 → 正常 EXPECT_EQ(spy.count(), 1); }