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
5 changed files with 48 additions and 4 deletions
Showing only changes of commit 6b4267d78a - Show all commits

View File

@ -35,7 +35,7 @@ public:
private: private:
void refreshCaptcha(); // 拉新验证码并重绘图片 void refreshCaptcha(); // 拉新验证码并重绘图片
void attemptLogin(); // 校验输入并发起阻塞登录 void attemptLogin(); // 校验输入并发起异步登录
void showError(const QString& msg); void showError(const QString& msg);
geopro::net::AuthService& auth_; geopro::net::AuthService& auth_;

View File

@ -7,6 +7,9 @@ ApiChain::ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* paren
: QObject(parent), steps_(std::move(steps)), isFailure_(std::move(isFailure)) { : QObject(parent), steps_(std::move(steps)), isFailure_(std::move(isFailure)) {
Q_ASSERT(!steps_.isEmpty()); // 契约:至少一步(空链永不发 succeeded Q_ASSERT(!steps_.isEmpty()); // 契约:至少一步(空链永不发 succeeded
Q_ASSERT(isFailure_); Q_ASSERT(isFailure_);
// 首步同步契约:此处同步执行首个 step 工厂。调用方须在 new ApiChain 之前连接
// succeeded/failed且首个工厂不得同步抛出、其 IApiCall 不得同步 emit finished。
// 详见 ApiChain.hpp 首步同步契约说明。
runNext(); runNext();
} }

View File

@ -13,6 +13,18 @@ namespace geopro::net {
// 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。 // 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。
// 注意Part A导航暂未接入生产调用首个生产使用见 Part B 登录AuthService 串行链); // 注意Part A导航暂未接入生产调用首个生产使用见 Part B 登录AuthService 串行链);
// 已由 tests/net/test_api_chain.cpp 覆盖,请勿当死代码删除。 // 已由 tests/net/test_api_chain.cpp 覆盖,请勿当死代码删除。
//
// ── 首步同步契约(调用方必读)──────────────────────────────────────────────────
// ctor 内同步调用 runNext(),立即执行首个 step 工厂。
// 因此调用方须在 new ApiChain(...) 之前连接 succeeded/failed 信号——否则若首步工厂
// 同步抛异常catch 后 emit failed信号会在连接建立前发出而丢失。
// 契约:
// 1. 首个 step 工厂不得同步抛异常生产路径verifyCodeCheck 不抛,满足)。
// 2. 首个 step 工厂返回的 IApiCall 不得同步 emit finished生产路径真实网络请求满足
// 若将来需要可同步完成的首步,应将 ctor 内 runNext() 改为
// QMetaObject::invokeMethod(this, &ApiChain::runNext, Qt::QueuedConnection)
// 以推迟首拍到事件循环,届时调用方已完成信号连接。
// ────────────────────────────────────────────────────────────────────────────────
class ApiChain : public QObject { class ApiChain : public QObject {
Q_OBJECT Q_OBJECT
public: public:

View File

@ -18,9 +18,10 @@ QString reasonOf(const ApiResponse& resp, const QString& fallback) {
} // namespace } // namespace
CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), call_(call) { CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), call_(call) {
QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) { QObject::connect(call_, &IApiCall::finished, this, [this](const ApiResponse& resp) {
if (aborted_) return; // §5.0 入口守卫 if (aborted_) return; // §5.0 入口守卫
if (resp.code != 200 || !resp.rawError.isEmpty()) { if (resp.code != 200 || !resp.rawError.isEmpty()) {
aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退
emit failed(reasonOf(resp, QStringLiteral("获取验证码失败"))); emit failed(reasonOf(resp, QStringLiteral("获取验证码失败")));
deleteLater(); deleteLater();
return; return;
@ -28,6 +29,7 @@ CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), cal
AuthService::Captcha cap; AuthService::Captcha cap;
cap.codeId = resp.data.value(QStringLiteral("id")).toString(); cap.codeId = resp.data.value(QStringLiteral("id")).toString();
cap.code = resp.data.value(QStringLiteral("code")).toString(); cap.code = resp.data.value(QStringLiteral("code")).toString();
aborted_ = true; // 终态置位:到达 done 终态后 abort() 早退
emit done(cap); emit done(cap);
deleteLater(); deleteLater();
}); });
@ -41,7 +43,7 @@ void CaptchaLoad::abort() {
} }
LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_(chain) { LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_(chain) {
QObject::connect(chain, &ApiChain::succeeded, this, QObject::connect(chain_, &ApiChain::succeeded, this,
[this](const QList<ApiResponse>& responses) { [this](const QList<ApiResponse>& responses) {
if (aborted_) return; // §5.0 入口守卫 if (aborted_) return; // §5.0 入口守卫
const QString token = const QString token =
@ -49,15 +51,18 @@ LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_
? QString() ? QString()
: responses.last().data.value(QStringLiteral("accessToken")).toString(); : responses.last().data.value(QStringLiteral("accessToken")).toString();
if (token.isEmpty()) { if (token.isEmpty()) {
aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退
emit failed(QStringLiteral("登录成功但缺少 accessToken")); emit failed(QStringLiteral("登录成功但缺少 accessToken"));
deleteLater(); deleteLater();
return; return;
} }
aborted_ = true; // 终态置位:到达 done 终态后 abort() 早退
emit done(token); emit done(token);
deleteLater(); deleteLater();
}); });
QObject::connect(chain, &ApiChain::failed, this, [this](int, const ApiResponse& resp) { QObject::connect(chain_, &ApiChain::failed, this, [this](int, const ApiResponse& resp) {
if (aborted_) return; // §5.0 入口守卫 if (aborted_) return; // §5.0 入口守卫
aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退
emit failed(reasonOf(resp, QStringLiteral("登录失败"))); emit failed(reasonOf(resp, QStringLiteral("登录失败")));
deleteLater(); deleteLater();
}); });

View File

@ -2,6 +2,8 @@
// 用 FakeApiCall + 真 ApiChain 离线驱动不联网。QSignalSpy 需 Qt6::Test。 // 用 FakeApiCall + 真 ApiChain 离线驱动不联网。QSignalSpy 需 Qt6::Test。
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <stdexcept>
#include <QSignalSpy> #include <QSignalSpy>
#include "ApiChain.hpp" #include "ApiChain.hpp"
@ -127,3 +129,25 @@ TEST(LoginLoad, EmitsFailedWhenTokenMissing) {
EXPECT_EQ(doneSpy.count(), 0); EXPECT_EQ(doneSpy.count(), 0);
ASSERT_EQ(failSpy.count(), 1); ASSERT_EQ(failSpy.count(), 1);
} }
// M-2step 工厂抛异常(模拟 RSA 失败)时 LoginLoad 应 emit failed。
// 第一步用 FakeApiCall不同步 fire由测试手动触发第二步工厂抛异常。
// 构造顺序new ApiChain同步执行 step1 工厂s1 已建立但未 fire
// → new LoginLoad连接 chain 的 succeeded/failed
// → s1->fire(plainOk())step1 成功 → step2 工厂抛 → ApiChain emit failed
// → LoginLoad 已连接,收到 failed
TEST(LoginLoad, EmitsFailedWhenStepFactoryThrows) {
auto* s1 = new FakeApiCall;
QList<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
[&](const QList<ApiResponse>&) -> IApiCall* {
throw std::runtime_error("rsa fail");
}};
auto* chain = new ApiChain(steps, isFailure);
auto* load = new LoginLoad(chain);
QSignalSpy doneSpy(load, &LoginLoad::done);
QSignalSpy failSpy(load, &LoginLoad::failed);
s1->fire(plainOk()); // step1 成功 → step2 工厂抛异常 → ApiChain emit failed
ASSERT_EQ(failSpy.count(), 1);
EXPECT_EQ(doneSpy.count(), 0);
}