Compare commits

..

10 Commits

Author SHA1 Message Date
gaozheng 8b3bc50f58 harden(app): setData 重置遮罩 + loadFailed context=detailPanel(防野指针) + LoadingOverlay Q_ASSERT(parent) + 删未用 include(评审 I-1/I-2/M-3/4/5) 2026-06-11 21:06:35 +08:00
gaozheng dc9506c260 feat(app): 网格懒加载「加载中」遮罩(LoadingOverlay) 接 loadStarted/隐藏于就绪或失败 2026-06-11 20:59:32 +08:00
gaozheng eaa3770f93 feat(app): qRegisterMetaType<ApiResponse> 注册(异步信号元类型,防御性) 2026-06-11 20:53:58 +08:00
gaozheng 350f46060d harden(controller): 析构 abort 在飞句柄(退出契约 spec §7) + Grid 路径回灌/失败用例对称覆盖(评审 I-1/M-3/M-4) 2026-06-11 20:51:45 +08:00
gaozheng e57985c057 feat(data+controller): ApiDatasetRepository 改异步 + DatasetDetailController abort-and-replace+句柄身份比对+loadStarted(移除 busy_/catch)+ 回灌防护测试 2026-06-11 20:37:10 +08:00
gaozheng 8cdd6679a9 harden(data): 句柄 emit done 移出 try + catch(...) 兜底 + parse 抛异常测试(评审 I-1/M-5) 2026-06-11 20:31:07 +08:00
gaozheng bb602e2011 feat(data): 异步仓储接口 + ChartLoad/GridLoad 句柄(抽象基+Api实现) + 离线单测 2026-06-11 20:19:32 +08:00
gaozheng e980ddd346 harden(net): ApiBatch 契约断言(非空calls/非空谓词)+fail-fast注释+单元素测试(评审 I-1/I-2/M-1/M-2) 2026-06-11 20:13:48 +08:00
gaozheng 72b300d722 feat(net): ApiBatch 并发汇聚+fail-fast+abort闸门 + 离线单测 2026-06-11 20:05:53 +08:00
gaozheng f74d47e62e harden(net): ApiCall onFinished reply 快照防御 + 构造 Q_ASSERT(reply)(评审 H3/L1) 2026-06-11 19:59:23 +08:00
26 changed files with 697 additions and 107 deletions

View File

@ -35,6 +35,7 @@ add_executable(geopro_desktop WIN32
panels/chart/ContourPlotItem.cpp panels/chart/ContourPlotItem.cpp
panels/chart/LivePanner.cpp panels/chart/LivePanner.cpp
panels/AnomalyTablePanel.cpp panels/AnomalyTablePanel.cpp
panels/LoadingOverlay.cpp
panels/DatasetDetailPage.cpp panels/DatasetDetailPage.cpp
panels/DatasetDetailPanel.cpp panels/DatasetDetailPanel.cpp
CentralScene.cpp CentralScene.cpp

View File

@ -520,8 +520,19 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
[detailPanel](const geopro::controller::DatasetDetailController::GridData& d) { [detailPanel](const geopro::controller::DatasetDetailController::GridData& d) {
detailPanel->setGridData(d); detailPanel->setGridData(d);
}); });
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, &window, QObject::connect(
[&window](const QString& dsId, const QString& msg) { &detailCtrl, &geopro::controller::DatasetDetailController::loadStarted, detailPanel,
[detailPanel](const QString& dsId,
geopro::controller::DatasetDetailController::LoadPhase phase) {
if (phase == geopro::controller::DatasetDetailController::LoadPhase::Grid)
detailPanel->setGridLoading(dsId, true);
});
// context 用 detailPanel与 loadStarted 一致detailPanel 析构即自动断连,避免野指针。
// window 比 detailPanel 活得久lambda 捕 &window 取状态栏安全。loadFailed 无 phase
// Chart 失败时 gridOverlay 本已隐藏setGridLoading(false) 幂等无害Grid 失败时正确清遮罩。
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel,
[&window, detailPanel](const QString& dsId, const QString& msg) {
detailPanel->setGridLoading(dsId, false);
window.statusBar()->showMessage( window.statusBar()->showMessage(
QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000); QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000);
}); });
@ -788,6 +799,10 @@ int main(int argc, char* argv[])
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
QApplication app(argc, argv); QApplication app(argc, argv);
// 异步 ApiCall::finished 等信号携带 ApiResponse注册元类型以支持跨 QueuedConnection 传递
// (当前详情链路为同线程 DirectConnection非严格必需但作防御性注册见 spec §5.1)。
qRegisterMetaType<geopro::net::ApiResponse>();
// 组织/应用名QSettings 持久化dock 布局、登录记忆等)按此定位存储位置。 // 组织/应用名QSettings 持久化dock 布局、登录记忆等)按此定位存储位置。
QCoreApplication::setOrganizationName(QStringLiteral("Geomative")); QCoreApplication::setOrganizationName(QStringLiteral("Geomative"));
QCoreApplication::setApplicationName(QStringLiteral("Geopro3")); QCoreApplication::setApplicationName(QStringLiteral("Geopro3"));

View File

@ -5,6 +5,7 @@
#include "Glyphs.hpp" #include "Glyphs.hpp"
#include "PanelHeader.hpp" #include "PanelHeader.hpp"
#include "panels/LoadingOverlay.hpp"
#include "panels/chart/GridDataChartView.hpp" #include "panels/chart/GridDataChartView.hpp"
#include "panels/chart/RawDataChartView.hpp" #include "panels/chart/RawDataChartView.hpp"
@ -21,6 +22,7 @@ DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) {
rawView_ = new RawDataChartView(this); rawView_ = new RawDataChartView(this);
gridView_ = new GridDataChartView(this); gridView_ = new GridDataChartView(this);
gridOverlay_ = new LoadingOverlay(gridView_); // 父为 gridView_随其尺寸覆盖网格图区
const QVector<PanelTab> tabs = { const QVector<PanelTab> tabs = {
{Glyph::Detail, QStringLiteral("原数据"), rawView_, false}, {Glyph::Detail, QStringLiteral("原数据"), rawView_, false},
@ -49,12 +51,18 @@ void DatasetDetailPage::setData(const geopro::controller::DatasetDetailControlle
gridRequested_ = false; // 新数据集 → 网格数据需重新按需加载 gridRequested_ = false; // 新数据集 → 网格数据需重新按需加载
rawView_->setData(d); rawView_->setData(d);
gridView_->setData(d); gridView_->setData(d);
setGridLoading(false); // 重开/换 ds重置遮罩状态避免上次的「加载中」残留
} }
void DatasetDetailPage::setGridData( void DatasetDetailPage::setGridData(
const geopro::controller::DatasetDetailController::GridData& d) { const geopro::controller::DatasetDetailController::GridData& d) {
gridRequested_ = true; // 已加载,切回网格页不再重复请求 gridRequested_ = true; // 已加载,切回网格页不再重复请求
gridView_->setGridData(d.grid, d.gridScale, d.anomalies); gridView_->setGridData(d.grid, d.gridScale, d.anomalies);
setGridLoading(false); // 数据到达,隐藏遮罩
}
void DatasetDetailPage::setGridLoading(bool on) {
if (on) gridOverlay_->showOver(); else gridOverlay_->hide();
} }
} // namespace geopro::app } // namespace geopro::app

View File

@ -6,6 +6,7 @@ namespace geopro::app {
class RawDataChartView; class RawDataChartView;
class GridDataChartView; class GridDataChartView;
class LoadingOverlay;
// 单个数据集详情页:下划线页签「原数据 / 网格数据」+ 右侧「导出」操作。 // 单个数据集详情页:下划线页签「原数据 / 网格数据」+ 右侧「导出」操作。
// 内部分别由 RawDataChartView / GridDataChartView 实现各自三层布局。 // 内部分别由 RawDataChartView / GridDataChartView 实现各自三层布局。
@ -16,6 +17,8 @@ public:
void setData(const geopro::controller::DatasetDetailController::ChartData& d); void setData(const geopro::controller::DatasetDetailController::ChartData& d);
// 网格数据到达(懒加载结果)→ 下发给 GridDataChartView 并标记已加载。 // 网格数据到达(懒加载结果)→ 下发给 GridDataChartView 并标记已加载。
void setGridData(const geopro::controller::DatasetDetailController::GridData& d); void setGridData(const geopro::controller::DatasetDetailController::GridData& d);
// 网格懒加载进行中(true)/结束(false)时切换遮罩显隐。
void setGridLoading(bool on);
QString dsId() const { return dsId_; } QString dsId() const { return dsId_; }
signals: signals:
// 「网格数据」页签首次激活且本页网格数据未加载 → 请求懒加载。 // 「网格数据」页签首次激活且本页网格数据未加载 → 请求懒加载。
@ -26,6 +29,7 @@ private:
bool gridRequested_ = false; // 已请求过(避免重复发信号) bool gridRequested_ = false; // 已请求过(避免重复发信号)
RawDataChartView* rawView_; RawDataChartView* rawView_;
GridDataChartView* gridView_; GridDataChartView* gridView_;
LoadingOverlay* gridOverlay_; // 网格懒加载期间覆盖 gridView_ 的遮罩
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -33,6 +33,9 @@ void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailCon
void DatasetDetailPanel::setGridData(const geopro::controller::DatasetDetailController::GridData& d) { void DatasetDetailPanel::setGridData(const geopro::controller::DatasetDetailController::GridData& d) {
if (auto* p = pageFor(d.dsId)) p->setGridData(d); if (auto* p = pageFor(d.dsId)) p->setGridData(d);
} }
void DatasetDetailPanel::setGridLoading(const QString& dsId, bool on) {
if (auto* p = pageFor(dsId)) p->setGridLoading(on);
}
void DatasetDetailPanel::focusDataset(const QString& dsId) { void DatasetDetailPanel::focusDataset(const QString& dsId) {
if (auto* p = pageFor(dsId)) setCurrentWidget(p); if (auto* p = pageFor(dsId)) setCurrentWidget(p);
} }

View File

@ -11,6 +11,7 @@ public:
explicit DatasetDetailPanel(QWidget* parent = nullptr); explicit DatasetDetailPanel(QWidget* parent = nullptr);
void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达 void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达
void setGridData(const geopro::controller::DatasetDetailController::GridData& d); // 网格数据懒加载到达 void setGridData(const geopro::controller::DatasetDetailController::GridData& d); // 网格数据懒加载到达
void setGridLoading(const QString& dsId, bool on); // 网格懒加载进行中/结束 → 转发给对应页
void focusDataset(const QString& dsId); // 单击聚焦已开页 void focusDataset(const QString& dsId); // 单击聚焦已开页
signals: signals:
void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表 void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表

View File

@ -0,0 +1,33 @@
#include "panels/LoadingOverlay.hpp"
#include <QEvent>
#include <QLabel>
#include <QVBoxLayout>
namespace geopro::app {
LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) {
Q_ASSERT(parent); // 契约:必须有父(遮罩几何跟随父,无父无法工作)
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral("background: rgba(255,255,255,160);"));
label_->setText(QStringLiteral("加载中…"));
label_->setAlignment(Qt::AlignCenter);
auto* lay = new QVBoxLayout(this);
lay->addWidget(label_);
if (parent) parent->installEventFilter(this);
hide();
}
void LoadingOverlay::showOver() {
if (parentWidget()) setGeometry(parentWidget()->rect());
raise();
show();
}
bool LoadingOverlay::eventFilter(QObject* obj, QEvent* ev) {
if (obj == parentWidget() && ev->type() == QEvent::Resize && isVisible()) {
setGeometry(parentWidget()->rect());
}
return QWidget::eventFilter(obj, ev);
}
} // namespace geopro::app

View File

@ -0,0 +1,18 @@
#pragma once
#include <QWidget>
class QLabel;
namespace geopro::app {
// 半透明「加载中…」遮罩。贴在目标视图上层showOver()/hide() 切换,几何随父 resize 跟随。
class LoadingOverlay : public QWidget {
Q_OBJECT
public:
explicit LoadingOverlay(QWidget* parent);
void showOver(); // 铺满父尺寸、置顶、显示
protected:
bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize
private:
QLabel* label_;
};
} // namespace geopro::app

View File

@ -1,60 +1,67 @@
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include <stdexcept> #include "repo/IAsyncDatasetRepository.hpp"
#include "repo/IDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp"
namespace geopro::controller { namespace geopro::controller {
DatasetDetailController::DatasetDetailController(data::IDatasetRepository& repo, QObject* parent) DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent)
: QObject(parent), repo_(repo) {} : QObject(parent), repo_(repo) {}
DatasetDetailController::~DatasetDetailController() {
if (chartLoad_) chartLoad_->abort(); // 退出契约abort 在飞句柄,不依赖外部析构顺序兜底
if (gridLoad_) gridLoad_->abort();
}
void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) {
if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入)
busy_ = true;
if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演 if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演
busy_ = false;
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
return; return;
} }
const std::string id = dsId.toStdString(); if (chartLoad_) chartLoad_->abort(); // abort-and-replace
try { 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; ChartData d;
d.dsId = dsId; d.dsId = dsId;
d.ddCode = ddCode; d.ddCode = ddCode;
// 严格对齐原版「原数据」页:只拉 scatter + 散点色阶(type1) 两个。 d.scatter = parts.scatter;
// 网格数据(inversion/rows)与异常(queryException)随「网格数据」页按需懒加载。 d.scatterScale = parts.scatterScale;
d.scatter = repo_.loadScatter(id);
d.scatterScale = repo_.loadScatterColorScale(id);
busy_ = false;
emit chartReady(d); emit chartReady(d);
} catch (const std::exception& e) { });
busy_ = false; QObject::connect(load, &data::ChartLoad::failed, this,
emit loadFailed(dsId, QString::fromStdString(e.what())); [this, load, dsId](const QString& msg) {
} catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_否则永久拒绝后续加载 if (load != chartLoad_) return;
busy_ = false; chartLoad_.clear();
emit loadFailed(dsId, QStringLiteral("未知错误")); emit loadFailed(dsId, msg);
} });
} }
void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) {
if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入)
if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据 if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据
busy_ = true; if (gridLoad_) gridLoad_->abort(); // abort-and-replace
const std::string id = dsId.toStdString(); data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString());
try { 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; GridData d;
d.dsId = dsId; d.dsId = dsId;
// 网格数据rows(服务端网格化,慢) + 色阶 type2 + 异常 queryException。 d.grid = parts.grid;
d.grid = repo_.loadGrid(id); d.gridScale = parts.gridScale;
d.gridScale = repo_.loadColorScale(id); d.anomalies = parts.anomalies;
d.anomalies = repo_.loadAnomalies(id);
busy_ = false;
emit gridReady(d); emit gridReady(d);
} catch (const std::exception& e) { });
busy_ = false; QObject::connect(load, &data::GridLoad::failed, this,
emit loadFailed(dsId, QString::fromStdString(e.what())); [this, load, dsId](const QString& msg) {
} catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_否则永久拒绝后续加载 if (load != gridLoad_) return;
busy_ = false; gridLoad_.clear();
emit loadFailed(dsId, QStringLiteral("未知错误")); emit loadFailed(dsId, msg);
} });
} }
void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); } void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); }

View File

@ -1,18 +1,21 @@
#pragma once #pragma once
#include <string> #include <string>
#include <QObject> #include <QObject>
#include <QPointer>
#include <QString> #include <QString>
#include "model/Field.hpp" #include "model/Field.hpp"
#include "model/ColorScale.hpp" #include "model/ColorScale.hpp"
#include "model/Anomaly.hpp" #include "model/Anomaly.hpp"
namespace geopro::data { class IDatasetRepository; } namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; }
namespace geopro::controller { namespace geopro::controller {
// 数据详情编排:单击/双击数据集 → 拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 // 数据详情编排:双击/网格页签 → 异步拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。
// 仅服务图表,不与 WorkbenchNavController项目/结构导航)耦合。
class DatasetDetailController : public QObject { class DatasetDetailController : public QObject {
Q_OBJECT Q_OBJECT
public: public:
enum class LoadPhase { Chart, Grid };
Q_ENUM(LoadPhase)
struct ChartData { struct ChartData {
QString dsId, ddCode; QString dsId, ddCode;
geopro::core::ScatterField scatter; geopro::core::ScatterField scatter;
@ -29,19 +32,21 @@ public:
std::vector<geopro::core::Anomaly> anomalies; std::vector<geopro::core::Anomaly> anomalies;
}; };
explicit DatasetDetailController(data::IDatasetRepository& repo, QObject* parent = nullptr); explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr);
~DatasetDetailController() override; // 退出契约(spec §7)abort 在飞句柄,避免迟到信号打到已析构 this
public slots: public slots:
void openDataset(const QString& dsId, const QString& ddCode); // 双击=新建/聚焦页 void openDataset(const QString& dsId, const QString& ddCode);
void focusDataset(const QString& dsId); // 单击=聚焦已开页 void focusDataset(const QString& dsId);
// 网格数据懒加载网格页首次激活时调用rows 服务端网格化 1-4s故不随 openDataset 拉)。
void loadGridData(const QString& dsId, const QString& ddCode); void loadGridData(const QString& dsId, const QString& ddCode);
signals: signals:
void loadStarted(const QString& dsId, LoadPhase phase);
void chartReady(const ChartData& data); void chartReady(const ChartData& data);
void gridReady(const GridData& data); void gridReady(const GridData& data);
void focusRequested(const QString& dsId); void focusRequested(const QString& dsId);
void loadFailed(const QString& dsId, const QString& message); void loadFailed(const QString& dsId, const QString& message);
private: private:
data::IDatasetRepository& repo_; data::IAsyncDatasetRepository& repo_;
bool busy_ = false; QPointer<data::ChartLoad> chartLoad_;
QPointer<data::GridLoad> gridLoad_;
}; };
} // namespace geopro::controller } // namespace geopro::controller

View File

@ -6,8 +6,9 @@ add_library(geopro_data STATIC
dto/NavDto.cpp dto/NavDto.cpp
dto/DatasetChartDto.cpp dto/DatasetChartDto.cpp
api/ApiProjectRepository.cpp api/ApiProjectRepository.cpp
api/ApiDatasetRepository.cpp) api/ApiDatasetRepository.cpp
api/DatasetLoadHandles.cpp)
target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json) target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json)
target_compile_features(geopro_data PUBLIC cxx_std_17) target_compile_features(geopro_data PUBLIC cxx_std_17)
set_target_properties(geopro_data PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) set_target_properties(geopro_data PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF)

View File

@ -1,51 +1,62 @@
#include "api/ApiDatasetRepository.hpp" #include "api/ApiDatasetRepository.hpp"
#include <stdexcept>
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
#include <QUrl> #include <QUrl>
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "ApiBatch.hpp"
#include "api/DatasetLoadHandles.hpp"
#include "dto/DatasetChartDto.hpp" #include "dto/DatasetChartDto.hpp"
namespace geopro::data { namespace geopro::data {
namespace { namespace {
QString enc(const std::string& s) { QString enc(const std::string& s) {
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(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: " + // 失败判定(原 must() 口径):业务码 != 200 或传输错误。
(r.msg.isEmpty() ? r.rawError.toStdString() : r.msg.toStdString())); 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}}; QJsonObject colorBody(const std::string& dsId, int type) {
const net::ApiResponse r = api.postJson(QStringLiteral("/business/lvl/colorGradation/getDetail"), body); return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}};
must(r, "colorGradation");
return dto::parseColorBar(r.data);
} }
} // namespace } // namespace
ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {}
geopro::core::Grid ApiDatasetRepository::loadGrid(const std::string& dsId) { ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) {
const net::ApiResponse r = api_.get( // index 0 = scatter(GET)index 1 = 散点色阶 type1(POST)
QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))); QList<net::IApiCall*> calls{
must(r, "inversion/rows"); api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))),
return dto::parseInversionGrid(r.data); api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)),
};
auto* batch = new net::ApiBatch(calls, &isFailure);
return new ApiChartLoad(batch, [](const QList<net::ApiResponse>& 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( GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) {
QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))); // index 0 = rows(GET,慢)1 = 色阶 type2(POST)2 = 异常(GET)
must(r, "scatterGraph"); QList<net::IApiCall*> calls{
return dto::parseScatterGraph(r.data); api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))),
} api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)),
geopro::core::ColorScale ApiDatasetRepository::loadColorScale(const std::string& dsId) { api_.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))),
return colorScale(api_, dsId, 2); };
} auto* batch = new net::ApiBatch(calls, &isFailure);
geopro::core::ColorScale ApiDatasetRepository::loadScatterColorScale(const std::string& dsId) { return new ApiGridLoad(batch, [](const QList<net::ApiResponse>& r) {
return colorScale(api_, dsId, 1); GridParts p;
} p.grid = dto::parseInversionGrid(r[0].data);
std::vector<geopro::core::Anomaly> ApiDatasetRepository::loadAnomalies(const std::string& dsId) { p.gridScale = dto::parseColorBar(r[1].data);
const net::ApiResponse r = api_.get( p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray());
QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))); return p;
must(r, "queryException"); });
return dto::parseDatasetAnomalies(r.data.value(QStringLiteral("value")).toArray());
} }
} // namespace geopro::data } // namespace geopro::data

View File

@ -1,18 +1,14 @@
#pragma once #pragma once
#include "repo/IDatasetRepository.hpp" #include "repo/IAsyncDatasetRepository.hpp"
namespace geopro::net { class ApiClient; } namespace geopro::net { class ApiClient; }
namespace geopro::data { namespace geopro::data {
// 真实 API 实现 IDatasetRepositoryERT 反演相关)。失败抛 std::runtime_error // 真实 API 实现 IAsyncDatasetRepositoryERT 反演)。每次加载返回自管理句柄
class ApiDatasetRepository : public IDatasetRepository { class ApiDatasetRepository : public IAsyncDatasetRepository {
public: public:
explicit ApiDatasetRepository(net::ApiClient& api); explicit ApiDatasetRepository(net::ApiClient& api);
std::vector<GsNode> loadStructure() override { return {}; } // 不经此仓储取结构 ChartLoad* loadChartAsync(const std::string& dsId) override;
geopro::core::Grid loadGrid(const std::string& dsId) override; GridLoad* loadGridAsync(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<geopro::core::Anomaly> loadAnomalies(const std::string& dsId) override;
private: private:
net::ApiClient& api_; net::ApiClient& api_;
}; };

View File

@ -0,0 +1,82 @@
#include "api/DatasetLoadHandles.hpp"
#include <stdexcept>
namespace geopro::data {
namespace {
QString reasonOf(const geopro::net::ApiResponse& r) {
return r.msg.isEmpty() ? r.rawError : r.msg;
}
} // namespace
ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent)
: ChartLoad(parent), batch_(batch), parse_(std::move(parse)) {
QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this,
[this](const QList<geopro::net::ApiResponse>& resps) {
if (aborted_) return; // §5.0
ChartParts parts;
try {
parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败
} catch (const std::exception& e) {
emit failed(QString::fromUtf8(e.what()));
deleteLater();
return;
} catch (...) { // 非 std 异常跨信号槽会 terminate兜底转 failed
emit failed(QStringLiteral("解析失败:未知异常"));
deleteLater();
return;
}
emit done(parts);
deleteLater();
});
QObject::connect(batch, &geopro::net::ApiBatch::failed, this,
[this](int, const geopro::net::ApiResponse& r) {
if (aborted_) return;
emit failed(reasonOf(r));
deleteLater();
});
}
void ApiChartLoad::abort() {
if (aborted_) return;
aborted_ = true;
if (batch_) batch_->abort();
deleteLater();
}
ApiGridLoad::ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent)
: GridLoad(parent), batch_(batch), parse_(std::move(parse)) {
QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this,
[this](const QList<geopro::net::ApiResponse>& resps) {
if (aborted_) return;
GridParts parts;
try {
parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败
} catch (const std::exception& e) {
emit failed(QString::fromUtf8(e.what()));
deleteLater();
return;
} catch (...) {
emit failed(QStringLiteral("解析失败:未知异常"));
deleteLater();
return;
}
emit done(parts);
deleteLater();
});
QObject::connect(batch, &geopro::net::ApiBatch::failed, this,
[this](int, const geopro::net::ApiResponse& r) {
if (aborted_) return;
emit failed(reasonOf(r));
deleteLater();
});
}
void ApiGridLoad::abort() {
if (aborted_) return;
aborted_ = true;
if (batch_) batch_->abort();
deleteLater();
}
} // namespace geopro::data

View File

@ -0,0 +1,60 @@
#pragma once
#include <functional>
#include <QList>
#include <QObject>
#include <QPointer>
#include <QString>
#include "ApiBatch.hpp"
#include "DatasetLoads.hpp"
namespace geopro::data {
// ── 抽象句柄(可测试缝,类比 IApiCall仓储返回基类指针控制器/测试只依赖它 ──
class ChartLoad : public QObject {
Q_OBJECT
public:
using QObject::QObject;
~ChartLoad() override = default;
virtual void abort() = 0;
signals:
void done(const geopro::data::ChartParts& parts);
void failed(const QString& message);
};
class GridLoad : public QObject {
Q_OBJECT
public:
using QObject::QObject;
~GridLoad() override = default;
virtual void abort() = 0;
signals:
void done(const geopro::data::GridParts& parts);
void failed(const QString& message);
};
// ── Api 实现:包一个 ApiBatch + 注入的解析函数。batch.succeeded→解析→donefailed→failed ──
class ApiChartLoad : public ChartLoad {
Q_OBJECT
public:
using Parser = std::function<ChartParts(const QList<geopro::net::ApiResponse>&)>;
ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
void abort() override;
private:
QPointer<geopro::net::ApiBatch> batch_;
Parser parse_;
bool aborted_ = false;
};
class ApiGridLoad : public GridLoad {
Q_OBJECT
public:
using Parser = std::function<GridParts(const QList<geopro::net::ApiResponse>&)>;
ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
void abort() override;
private:
QPointer<geopro::net::ApiBatch> batch_;
Parser parse_;
bool aborted_ = false;
};
} // namespace geopro::data

View File

@ -0,0 +1,21 @@
#pragma once
#include <vector>
#include "model/Field.hpp"
#include "model/ColorScale.hpp"
#include "model/Anomaly.hpp"
namespace geopro::data {
// 原数据加载结果scatter + 散点色阶(type1)。
struct ChartParts {
geopro::core::ScatterField scatter;
geopro::core::ColorScale scatterScale;
};
// 网格数据加载结果grid(rows) + 网格色阶(type2) + 异常。
struct GridParts {
geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位
geopro::core::ColorScale gridScale;
std::vector<geopro::core::Anomaly> anomalies;
};
} // namespace geopro::data

View File

@ -0,0 +1,17 @@
#pragma once
#include <string>
namespace geopro::data {
class ChartLoad;
class GridLoad;
// 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLater
class IAsyncDatasetRepository {
public:
virtual ~IAsyncDatasetRepository() = default;
virtual ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + 散点色阶(type1)
virtual GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + 色阶(type2) + 异常
};
} // namespace geopro::data

43
src/net/ApiBatch.cpp Normal file
View File

@ -0,0 +1,43 @@
#include "ApiBatch.hpp"
namespace geopro::net {
ApiBatch::ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent)
: QObject(parent), isFailure_(std::move(isFailure)) {
Q_ASSERT(!calls.isEmpty()); // 契约:至少一个 call空 batch 永不发 succeeded调用方会挂起
Q_ASSERT(isFailure_); // 契约:必须提供失败谓词(否则首个 finished 即 bad_function_call
responses_.resize(calls.size());
remaining_ = static_cast<int>(calls.size());
for (int i = 0; i < calls.size(); ++i) {
IApiCall* c = calls[i];
calls_.append(c);
QObject::connect(c, &IApiCall::finished, this, [this, i](const ApiResponse& resp) {
if (aborted_) return; // §5.0 入口守卫
if (isFailure_(resp)) {
aborted_ = true; // 此后 remaining_ 不再维护:迟到 finished 全被入口守卫挡掉
for (const auto& other : calls_) { // fail-fastabort 其余在飞
if (other) other->abort();
}
emit failed(i, resp);
deleteLater();
return;
}
responses_[i] = resp;
if (--remaining_ == 0) {
emit succeeded(responses_);
deleteLater();
}
});
}
}
void ApiBatch::abort() {
if (aborted_) return;
aborted_ = true;
for (const auto& c : calls_) {
if (c) c->abort();
}
deleteLater();
}
} // namespace geopro::net

29
src/net/ApiBatch.hpp Normal file
View File

@ -0,0 +1,29 @@
#pragma once
#include <functional>
#include <QList>
#include <QPointer>
#include <QObject>
#include "IApiCall.hpp"
namespace geopro::net {
// 并发汇聚 N 个 IApiCall全成功 → succeeded(按下标对齐);任一失败 → fail-fast
// failed(index,resp) + abort 其余在飞 call。安全不变量见 spec §5.0。
class ApiBatch : public QObject {
Q_OBJECT
public:
using Predicate = std::function<bool(const ApiResponse&)>;
ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls
void abort();
signals:
void succeeded(const QList<geopro::net::ApiResponse>& responses);
void failed(int index, const geopro::net::ApiResponse& resp);
private:
QList<QPointer<IApiCall>> calls_;
QList<ApiResponse> responses_;
Predicate isFailure_;
int remaining_ = 0;
bool aborted_ = false;
};
} // namespace geopro::net

View File

@ -6,13 +6,16 @@
namespace geopro::net { namespace geopro::net {
ApiCall::ApiCall(QNetworkReply* reply, QObject* parent) : IApiCall(parent), reply_(reply) { ApiCall::ApiCall(QNetworkReply* reply, QObject* parent) : IApiCall(parent), reply_(reply) {
Q_ASSERT(reply); // 契约:不接受 null replyNAM get/post 正常不返回 null
QObject::connect(reply_, &QNetworkReply::finished, this, &ApiCall::onFinished); QObject::connect(reply_, &QNetworkReply::finished, this, &ApiCall::onFinished);
} }
void ApiCall::onFinished() { void ApiCall::onFinished() {
if (aborted_) return; // §5.0 入口守卫:迟到信号闸门 if (aborted_) return; // §5.0 入口守卫:迟到信号闸门
ApiResponse resp = buildResponse(reply_); QNetworkReply* reply = reply_.data(); // 快照:意图明确 + 防御 reply_ 中途被置空
if (reply_) reply_->deleteLater(); if (!reply) return;
ApiResponse resp = buildResponse(reply);
reply->deleteLater();
emit finished(resp); emit finished(resp);
deleteLater(); deleteLater();
} }

View File

@ -6,6 +6,7 @@ add_library(geopro_net STATIC
ApiResponseParse.cpp ApiResponseParse.cpp
IApiCall.cpp IApiCall.cpp
ApiCall.cpp ApiCall.cpp
ApiBatch.cpp
AuthService.cpp) AuthService.cpp)
target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network)

View File

@ -38,16 +38,19 @@ target_sources(geopro_tests PRIVATE data/test_parsers.cpp)
target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) target_sources(geopro_tests PRIVATE data/test_local_repo.cpp)
target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp)
target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp)
target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_data) target_link_libraries(geopro_tests PRIVATE geopro_data)
# net RSA OpenSSL / find_package # net RSA OpenSSL / find_package
# OpenSSLgeopro_net PUBLIC # OpenSSLgeopro_net PUBLIC
find_package(OpenSSL REQUIRED) find_package(OpenSSL REQUIRED)
find_package(Qt6 COMPONENTS Core Network REQUIRED) find_package(Qt6 COMPONENTS Core Network Test REQUIRED)
target_sources(geopro_tests PRIVATE net/test_rsa.cpp) target_sources(geopro_tests PRIVATE net/test_rsa.cpp)
# ApiClient + AuthService Qt6::Core/Network # ApiClient + AuthService Qt6::Core/Network
target_sources(geopro_tests PRIVATE net/test_auth.cpp) target_sources(geopro_tests PRIVATE net/test_auth.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) # ApiBatch 线QSignalSpy Qt6::Test
target_sources(geopro_tests PRIVATE net/test_api_batch.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test)
# geopro_data Qt6::Core exe gtest Qt6Core.dll # geopro_data Qt6::Core exe gtest Qt6Core.dll
# DLL app TARGET_RUNTIME_DLLS POST_BUILD # DLL app TARGET_RUNTIME_DLLS POST_BUILD
@ -87,6 +90,8 @@ if(TARGET qwt)
endif() endif()
target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app) target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app)
# tests/ include net/test_api_batch.cpp #include "net/FakeApiCall.hpp"
target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests)
target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp) target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp)
# ColorMapService geopro_desktop # ColorMapService geopro_desktop
target_sources(geopro_tests PRIVATE target_sources(geopro_tests PRIVATE

View File

@ -1,31 +1,115 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <QSignalSpy> #include <QSignalSpy>
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include "repo/IDatasetRepository.hpp" #include "repo/IAsyncDatasetRepository.hpp"
#include "api/DatasetLoadHandles.hpp"
using namespace geopro; using namespace geopro;
namespace { namespace {
struct StubRepo : data::IDatasetRepository { // 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。
bool fail = false; struct StubChartLoad : data::ChartLoad {
std::vector<data::GsNode> loadStructure() override { return {}; } bool aborted = false;
core::Grid loadGrid(const std::string&) override { core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; } void abort() override { aborted = true; }
// openDataset 现只拉 scatter/scatterScale/anomalies网格懒加载失败路径由 loadScatter 抛出触发。 void fireDone() { emit done(data::ChartParts{}); }
core::ScatterField loadScatter(const std::string&) override { if (fail) throw std::runtime_error("x"); return {}; } void fireFailed() { emit failed(QStringLiteral("x")); }
core::ColorScale loadColorScale(const std::string&) override { return {}; } };
core::ColorScale loadScatterColorScale(const std::string&) override { return {}; } struct StubGridLoad : data::GridLoad {
std::vector<core::Anomaly> loadAnomalies(const std::string&) override { return {}; } bool aborted = false;
void abort() override { aborted = true; }
void fireDone() { emit done(data::GridParts{}); }
void fireFailed() { emit failed(QStringLiteral("x")); }
};
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); controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady);
c.openDataset("ds1", "dd_inversion_data"); c.openDataset("ds1", "dd_inversion_data");
repo.lastChart->fireDone();
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.count(), 1);
} }
TEST(DatasetDetailController, EmitsLoadFailedOnThrow) {
StubRepo repo; repo.fail = true; TEST(DatasetDetailController, EmitsLoadFailedOnFailed) {
StubAsyncRepo repo;
controller::DatasetDetailController c(repo); controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.openDataset("ds1", "dd_inversion_data"); 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);
}
TEST(DatasetDetailController, EmitsGridReadyOnDone) {
StubAsyncRepo repo;
controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady);
c.loadGridData("ds1", "dd_inversion_data");
repo.lastGrid->fireDone();
EXPECT_EQ(spy.count(), 1);
}
TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) {
StubAsyncRepo repo;
controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.loadGridData("ds1", "dd_inversion_data");
repo.lastGrid->fireFailed();
EXPECT_EQ(spy.count(), 1);
}
TEST(DatasetDetailController, DropsLateGridSignalFromAbortedLoad) {
StubAsyncRepo repo;
controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady);
c.loadGridData("dsA", "dd_inversion_data");
StubGridLoad* a = repo.lastGrid;
c.loadGridData("dsB", "dd_inversion_data"); // 替换 → 旧句柄被 abort
StubGridLoad* b = repo.lastGrid;
EXPECT_TRUE(a->aborted);
a->fireDone(); // 旧句柄迟到 → 身份比对丢弃
EXPECT_EQ(spy.count(), 0);
b->fireDone(); // 当前句柄 → 正常
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.count(), 1);
} }

View File

@ -0,0 +1,65 @@
#include <gtest/gtest.h>
#include <stdexcept>
#include <QSignalSpy>
#include "api/DatasetLoadHandles.hpp"
#include "net/FakeApiCall.hpp"
using namespace geopro::data;
using geopro::net::ApiBatch;
using geopro::net::ApiResponse;
using geopro::net::test::FakeApiCall;
namespace {
ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; }
ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; }
auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); };
}
TEST(DatasetLoadHandles, ChartLoadEmitsDoneOnSuccess) {
auto* a = new FakeApiCall;
auto* b = new FakeApiCall;
auto* batch = new ApiBatch({a, b}, isFailure);
bool parsed = false;
auto* load = new ApiChartLoad(batch, [&](const QList<ApiResponse>& resps) {
parsed = (resps.size() == 2);
return ChartParts{};
});
QSignalSpy doneSpy(load, &ChartLoad::done);
a->fire(ok());
b->fire(ok());
EXPECT_EQ(doneSpy.count(), 1);
EXPECT_TRUE(parsed);
}
TEST(DatasetLoadHandles, ChartLoadEmitsFailedOnBatchFailure) {
auto* a = new FakeApiCall;
auto* b = new FakeApiCall;
auto* batch = new ApiBatch({a, b}, isFailure);
auto* load = new ApiChartLoad(batch, [](const QList<ApiResponse>&) { return ChartParts{}; });
QSignalSpy failSpy(load, &ChartLoad::failed);
a->fire(bad());
EXPECT_EQ(failSpy.count(), 1);
}
TEST(DatasetLoadHandles, ChartLoadEmitsFailedWhenParseThrows) {
auto* a = new FakeApiCall;
auto* batch = new ApiBatch({a}, isFailure);
auto* load = new ApiChartLoad(batch, [](const QList<ApiResponse>&) -> ChartParts {
throw std::runtime_error("parse boom");
});
QSignalSpy doneSpy(load, &ChartLoad::done);
QSignalSpy failSpy(load, &ChartLoad::failed);
a->fire(ok()); // batch 成功 → parse 抛异常 → failedemit done 已移出 try
EXPECT_EQ(doneSpy.count(), 0);
EXPECT_EQ(failSpy.count(), 1);
}
TEST(DatasetLoadHandles, GridLoadAbortSuppressesLateDone) {
auto* a = new FakeApiCall;
auto* batch = new ApiBatch({a}, isFailure);
auto* load = new ApiGridLoad(batch, [](const QList<ApiResponse>&) { return GridParts{}; });
QSignalSpy doneSpy(load, &GridLoad::done);
load->abort();
a->fire(ok()); // 迟到
EXPECT_EQ(doneSpy.count(), 0); // batch.aborted_ + load.aborted_ 双闸门
}

15
tests/net/FakeApiCall.hpp Normal file
View File

@ -0,0 +1,15 @@
#pragma once
#include "IApiCall.hpp"
namespace geopro::net::test {
// 测试假 call。不加 Q_OBJECT —— 仅发射继承自 IApiCall 的 finished 信号、override abort。
class FakeApiCall : public IApiCall {
public:
using IApiCall::IApiCall;
bool aborted = false;
void abort() override { aborted = true; }
void fire(const ApiResponse& r) { emit finished(r); } // 发射继承的信号(无需自身 moc
};
} // namespace geopro::net::test

View File

@ -0,0 +1,62 @@
#include <gtest/gtest.h>
#include <QSignalSpy>
#include "ApiBatch.hpp"
#include "net/FakeApiCall.hpp"
using namespace geopro::net;
using geopro::net::test::FakeApiCall;
namespace {
ApiResponse ok(int code = 200) { ApiResponse r; r.code = code; r.httpStatus = 200; return r; }
ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; }
// repo 注入的失败谓词:业务码 != 200 或传输错误。
auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); };
}
TEST(ApiBatch, SucceedsWhenAllOk) {
auto* a = new FakeApiCall;
auto* b = new FakeApiCall;
auto* batch = new ApiBatch({a, b}, isFailure);
QSignalSpy okSpy(batch, &ApiBatch::succeeded);
QSignalSpy failSpy(batch, &ApiBatch::failed);
a->fire(ok());
EXPECT_EQ(okSpy.count(), 0); // 还差 b
b->fire(ok());
EXPECT_EQ(okSpy.count(), 1); // 全到齐
EXPECT_EQ(failSpy.count(), 0);
}
TEST(ApiBatch, SucceedsWithSingleCall) {
auto* a = new FakeApiCall;
auto* batch = new ApiBatch({a}, isFailure);
QSignalSpy okSpy(batch, &ApiBatch::succeeded);
a->fire(ok()); // N=1一次 fire 即触发 --remaining_==0
EXPECT_EQ(okSpy.count(), 1);
}
TEST(ApiBatch, FailFastAbortsOthers) {
auto* a = new FakeApiCall;
auto* b = new FakeApiCall; // 慢的(永不 fire
auto* batch = new ApiBatch({a, b}, isFailure);
QSignalSpy failSpy(batch, &ApiBatch::failed);
QSignalSpy okSpy(batch, &ApiBatch::succeeded);
a->fire(bad()); // 首个失败
EXPECT_EQ(failSpy.count(), 1);
EXPECT_EQ(okSpy.count(), 0);
EXPECT_TRUE(b->aborted); // 其余在飞被 abort
}
TEST(ApiBatch, AbortGateSuppressesLateSignals) {
auto* a = new FakeApiCall;
auto* b = new FakeApiCall;
auto* batch = new ApiBatch({a, b}, isFailure);
QSignalSpy okSpy(batch, &ApiBatch::succeeded);
QSignalSpy failSpy(batch, &ApiBatch::failed);
batch->abort();
EXPECT_TRUE(a->aborted);
EXPECT_TRUE(b->aborted);
a->fire(ok()); // 迟到信号
b->fire(ok());
EXPECT_EQ(okSpy.count(), 0); // aborted_ 闸门:不 emit
EXPECT_EQ(failSpy.count(), 0);
}