feat/dataset-detail-chart #5

Merged
gaozheng merged 74 commits from feat/dataset-detail-chart into main 2026-06-13 17:30:37 +08:00
6 changed files with 146 additions and 2 deletions
Showing only changes of commit 72b300d722 - Show all commits

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

@ -0,0 +1,41 @@
#include "ApiBatch.hpp"
namespace geopro::net {
ApiBatch::ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent)
: QObject(parent), isFailure_(std::move(isFailure)) {
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;
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,6 +6,7 @@ add_library(geopro_net STATIC
ApiResponseParse.cpp
IApiCall.cpp
ApiCall.cpp
ApiBatch.cpp
AuthService.cpp)
target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network)

View File

@ -43,11 +43,13 @@ target_link_libraries(geopro_tests PRIVATE geopro_data)
# net RSA OpenSSL / find_package
# OpenSSLgeopro_net PUBLIC
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)
# ApiClient + AuthService Qt6::Core/Network
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
# DLL app TARGET_RUNTIME_DLLS POST_BUILD
@ -87,6 +89,8 @@ if(TARGET qwt)
endif()
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)
# ColorMapService geopro_desktop
target_sources(geopro_tests PRIVATE

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,54 @@
#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, 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);
}