harden(controller+net): setCheckedTms 去重 + loadMore 失败回滚页号 + 非拥有所有权注释更正 + ApiChain 待用注释 + selectObject 部分失败测试(Part A 评审 I-2/I-3/I-4/M-1/M-4)
This commit is contained in:
parent
b097fa6e56
commit
62352395ba
|
|
@ -275,6 +275,7 @@ void WorkbenchNavController::loadMoreData() {
|
||||||
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
||||||
if (req != moreDataReq_) return;
|
if (req != moreDataReq_) return;
|
||||||
moreDataReq_.clear();
|
moreDataReq_.clear();
|
||||||
|
--dataPageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreData 跳页
|
||||||
emit loadFailed(QStringLiteral("datasets"), msg);
|
emit loadFailed(QStringLiteral("datasets"), msg);
|
||||||
emitBusyIfChanged();
|
emitBusyIfChanged();
|
||||||
});
|
});
|
||||||
|
|
@ -299,6 +300,7 @@ void WorkbenchNavController::loadMoreFiles() {
|
||||||
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
||||||
if (req != moreFilesReq_) return;
|
if (req != moreFilesReq_) return;
|
||||||
moreFilesReq_.clear();
|
moreFilesReq_.clear();
|
||||||
|
--filePageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreFiles 跳页
|
||||||
emit loadFailed(QStringLiteral("files"), msg);
|
emit loadFailed(QStringLiteral("files"), msg);
|
||||||
emitBusyIfChanged();
|
emitBusyIfChanged();
|
||||||
});
|
});
|
||||||
|
|
@ -331,13 +333,22 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
|
||||||
if (h) h->abort();
|
if (h) h->abort();
|
||||||
checkedInflight_.clear();
|
checkedInflight_.clear();
|
||||||
|
|
||||||
QStringList missing;
|
// 入口去重:保留首次出现顺序,剔除重复 id(避免重复请求 + 异常树重复 group)。
|
||||||
|
QStringList deduped;
|
||||||
|
QSet<QString> seen;
|
||||||
for (const QString& tmQ : tmObjectIds) {
|
for (const QString& tmQ : tmObjectIds) {
|
||||||
|
if (seen.contains(tmQ)) continue;
|
||||||
|
seen.insert(tmQ);
|
||||||
|
deduped.push_back(tmQ);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList missing;
|
||||||
|
for (const QString& tmQ : deduped) {
|
||||||
const std::string tm = tmQ.toStdString();
|
const std::string tm = tmQ.toStdString();
|
||||||
if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ);
|
if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ);
|
||||||
}
|
}
|
||||||
if (missing.isEmpty()) { // 全命中缓存:同步组装,busyChanged 不抖动
|
if (missing.isEmpty()) { // 全命中缓存:同步组装,busyChanged 不抖动
|
||||||
assembleAndEmitExceptionTree(tmObjectIds);
|
assembleAndEmitExceptionTree(deduped);
|
||||||
emitBusyIfChanged();
|
emitBusyIfChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +361,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
|
||||||
NavRequest* req = repo_.loadExceptionsByTmAsync(tm);
|
NavRequest* req = repo_.loadExceptionsByTmAsync(tm);
|
||||||
checkedInflight_.push_back(req);
|
checkedInflight_.push_back(req);
|
||||||
QObject::connect(req, &NavRequest::done, this,
|
QObject::connect(req, &NavRequest::done, this,
|
||||||
[this, req, tm, remaining, failedFlag, tmObjectIds](const QVariant& v) {
|
[this, req, tm, remaining, failedFlag, deduped](const QVariant& v) {
|
||||||
// 身份比对:req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。
|
// 身份比对:req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。
|
||||||
bool inCurrent = false;
|
bool inCurrent = false;
|
||||||
for (const auto& h : checkedInflight_)
|
for (const auto& h : checkedInflight_)
|
||||||
|
|
@ -359,7 +370,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
|
||||||
tmExceptionCache_[tm] = qvariant_cast<std::vector<ExceptionRow>>(v);
|
tmExceptionCache_[tm] = qvariant_cast<std::vector<ExceptionRow>>(v);
|
||||||
if (--(*remaining) == 0 && !*failedFlag) {
|
if (--(*remaining) == 0 && !*failedFlag) {
|
||||||
checkedInflight_.clear();
|
checkedInflight_.clear();
|
||||||
assembleAndEmitExceptionTree(tmObjectIds);
|
assembleAndEmitExceptionTree(deduped);
|
||||||
emitBusyIfChanged();
|
emitBusyIfChanged();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ public:
|
||||||
using Parser = std::function<QVariant(const geopro::net::ApiResponse&)>;
|
using Parser = std::function<QVariant(const geopro::net::ApiResponse&)>;
|
||||||
using Predicate = std::function<bool(const geopro::net::ApiResponse&)>;
|
using Predicate = std::function<bool(const geopro::net::ApiResponse&)>;
|
||||||
ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
|
ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
|
||||||
QObject* parent = nullptr); // 接管 call
|
QObject* parent = nullptr); // 持有非拥有引用(QPointer);call 完成(finished)或 abort 后自行 deleteLater 自管理生命周期,本类不得 delete 它
|
||||||
void abort() override;
|
void abort() override;
|
||||||
private:
|
private:
|
||||||
QPointer<geopro::net::IApiCall> call_;
|
QPointer<geopro::net::IApiCall> call_;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ class ApiBatch : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
using Predicate = std::function<bool(const ApiResponse&)>;
|
using Predicate = std::function<bool(const ApiResponse&)>;
|
||||||
ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls
|
ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent = nullptr); // 持有非拥有引用(QPointer 列表);各 call 完成或 abort 后自行 deleteLater,本类不得 delete 它们
|
||||||
void abort();
|
void abort();
|
||||||
signals:
|
signals:
|
||||||
void succeeded(const QList<geopro::net::ApiResponse>& responses);
|
void succeeded(const QList<geopro::net::ApiResponse>& responses);
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ namespace geopro::net {
|
||||||
// 任一步失败 → fail-fast:failed(index,resp) + abort 当前在飞 + deleteLater。
|
// 任一步失败 → fail-fast:failed(index,resp) + abort 当前在飞 + deleteLater。
|
||||||
// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0(aborted_ 闸门 + 一律 deleteLater)。
|
// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0(aborted_ 闸门 + 一律 deleteLater)。
|
||||||
// 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。
|
// 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。
|
||||||
|
// 注意:Part A(导航)暂未接入生产调用,首个生产使用见 Part B 登录(AuthService 串行链);
|
||||||
|
// 已由 tests/net/test_api_chain.cpp 覆盖,请勿当死代码删除。
|
||||||
class ApiChain : public QObject {
|
class ApiChain : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
// 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(接管所有权)。可抛 std::exception。
|
// 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(持有非拥有引用 QPointer;IApiCall 完成或 abort 后自行 deleteLater,本类不得 delete 它)。可抛 std::exception。
|
||||||
using StepFactory = std::function<IApiCall*(const QList<ApiResponse>& prior)>;
|
using StepFactory = std::function<IApiCall*(const QList<ApiResponse>& prior)>;
|
||||||
using Predicate = std::function<bool(const ApiResponse&)>;
|
using Predicate = std::function<bool(const ApiResponse&)>;
|
||||||
ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent = nullptr);
|
ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent = nullptr);
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,26 @@ TEST(WorkbenchNavController, SelectDatasetEmitsDetail) {
|
||||||
EXPECT_EQ(spy.count(), 1);
|
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 复位。
|
// 失败路径:start 首级失败 → loadFailed + busy 复位。
|
||||||
TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) {
|
TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) {
|
||||||
StubAsyncRepo repo;
|
StubAsyncRepo repo;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue