feat/dataset-detail-chart #5
|
|
@ -43,7 +43,8 @@ geopro 现网络栈三层全同步阻塞:
|
||||||
| 取消语义 | 主动 abort 过期请求 | 体验最好;句柄对象使 abort 成为一等公民 |
|
| 取消语义 | 主动 abort 过期请求 | 体验最好;句柄对象使 abort 成为一等公民 |
|
||||||
| 加载反馈 | 轻量"加载中"遮罩 + 禁用交互 | 不阻塞后必须给反馈;先跑通机制 |
|
| 加载反馈 | 轻量"加载中"遮罩 + 禁用交互 | 不阻塞后必须给反馈;先跑通机制 |
|
||||||
| 异步机制 | 方案 A:每请求一个 QObject 句柄(信号 + abort) | 纯 Qt 信号、贴合现有信号驱动代码库;abort 用具体句柄最干净(QFuture 的 cancel 不中断底层 reply,反而要再加句柄);测试用 QSignalSpy 顺手 |
|
| 异步机制 | 方案 A:每请求一个 QObject 句柄(信号 + abort) | 纯 Qt 信号、贴合现有信号驱动代码库;abort 用具体句柄最干净(QFuture 的 cancel 不中断底层 reply,反而要再加句柄);测试用 QSignalSpy 顺手 |
|
||||||
| 多请求汇聚 | gather-all(等全部完成再回报,校验留 repo) | 避免 fail-fast 的部分-abort 竞态;与现有"任一失败→整体失败"语义对齐(校验在 repo 逐项判定) |
|
| 多请求汇聚 | **fail-fast with abort**(任一项失败立即回报并 abort 其余在飞 call) | 慢的 rows 1–4s,若 colorScale 200ms 先失败,gather-all 会让用户干等 1–4s 才看到失败(失败比成功还慢,违背"及时反馈")。fail-fast 依赖下方「abort 闸门」机制杜绝迟到回灌,原"避免部分-abort 竞态"的顾虑已被该机制消解。成功路径仍需全部到齐 |
|
||||||
|
| **abort 闸门(核心安全机制)** | 链路级 `aborted_` 标志 + 句柄身份比对 | 见 §5.1/§5.3:仅靠 disconnect 挡不住「已 emit、正在事件队列等待派发」的迟到信号;必须每层入口判 `aborted_` + 控制器比对句柄身份,才能兑现"abort 后绝不回灌"的核心承诺 |
|
||||||
|
|
||||||
## 4. 架构
|
## 4. 架构
|
||||||
|
|
||||||
|
|
@ -64,6 +65,14 @@ ApiClient.getAsync(path) → ApiCall(包一个 QNetworkReply,实现 IApiCall
|
||||||
|
|
||||||
## 5. 组件设计
|
## 5. 组件设计
|
||||||
|
|
||||||
|
### 5.0 核心安全不变量:abort 后绝不回灌(贯穿全链路)
|
||||||
|
|
||||||
|
> 这是本设计成败的关键。`disconnect` 只能阻止「将来才发」的信号,**挡不住「已经 emit、已转成 `QMetaCallEvent` 在事件队列中等待派发」的迟到信号**(尤其在共享 NAM 同步嵌套 `QEventLoop` 期间,见 5.1)。因此 disconnect 仅作"尽力而为",真正的权威闸门是下面两条:
|
||||||
|
|
||||||
|
1. **每层 `aborted_` 标志 + 入口守卫**:`ApiCall` / `ApiBatch` / `ChartLoad` / `GridLoad` 各持一个 `bool aborted_`。所有 `finished`/`done`/`failed` 槽**入口第一行** `if (aborted_) return;`。`abort()` 先置 `aborted_=true` 再断连/中断。聚合 emit 前同样判 `aborted_`。
|
||||||
|
2. **控制器句柄身份比对**:`DatasetDetailController` 在 `done`/`failed` 槽内校验「发信号的句柄 == 当前持有的 `chartLoad_`/`gridLoad_`」(用 `sender()` 或 lambda 捕获句柄指针比对),过期句柄的迟到信号直接丢弃。与 abort-and-replace 形成纵深防御。
|
||||||
|
3. **销毁一律 `deleteLater`**:`ApiCall`/`ApiBatch`/`*Load`/`QNetworkReply` 在 abort 或完成后**一律 `deleteLater`,禁止任何同步 `delete`**——避免「abort 一个正在自己回调栈中的对象」导致 use-after-free。
|
||||||
|
|
||||||
### 5.1 net 层
|
### 5.1 net 层
|
||||||
|
|
||||||
#### `IApiCall`(新增,抽象,可测试缝)
|
#### `IApiCall`(新增,抽象,可测试缝)
|
||||||
|
|
@ -81,9 +90,10 @@ signals:
|
||||||
存在意义:`ApiBatch` 只依赖 `IApiCall`,单测可注入假 call(不碰真实网络),离线测 gather/abort。
|
存在意义:`ApiBatch` 只依赖 `IApiCall`,单测可注入假 call(不碰真实网络),离线测 gather/abort。
|
||||||
|
|
||||||
#### `ApiCall`(新增,实现 `IApiCall`)
|
#### `ApiCall`(新增,实现 `IApiCall`)
|
||||||
- 构造接管一个 `QNetworkReply*`;连接 `reply->finished` → 解析为 `ApiResponse`(复用现 `parseBody`)→ emit `finished` → `deleteLater()` 自删。
|
- 持 `bool aborted_=false`(见 §5.0)。
|
||||||
- `abort()`:断开自身与 reply 的连接、`reply->abort()`、`reply->deleteLater()`、`this->deleteLater()`;**保证 abort 后不再 emit `finished`**(杜绝旧结果回灌)。
|
- 构造接管一个 `QNetworkReply*`;连接 `reply->finished` → 槽入口判 `if (aborted_) return;` → 解析为 `ApiResponse`(复用现 `parseBody`)→ emit `finished` → `deleteLater()` 自删。
|
||||||
- 一处微妙:abort 与 reply 即将 finished 的竞态——先 `disconnect` 再 `abort`,确保 finished 不触达本对象的 emit。
|
- `abort()`:`aborted_=true` → `disconnect` 自身与 reply 的连接 → `reply->abort()` → `reply->deleteLater()` → `this->deleteLater()`;**禁止同步 delete**。`reply->abort()` 本身会再触发一次 `finished`(OperationCanceledError),已被 disconnect + `aborted_` 双重挡掉。
|
||||||
|
- 跨 `QueuedConnection` 传 `ApiResponse` 需 `qRegisterMetaType<geopro::net::ApiResponse>()`(应用启动时注册一次)。
|
||||||
|
|
||||||
#### `ApiClient`(扩展,不破坏现有)
|
#### `ApiClient`(扩展,不破坏现有)
|
||||||
```cpp
|
```cpp
|
||||||
|
|
@ -92,23 +102,28 @@ net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);
|
||||||
// 保留:ApiResponse get(path); ApiResponse postJson(path, body); // Nav/登录继续用
|
// 保留:ApiResponse get(path); ApiResponse postJson(path, body); // Nav/登录继续用
|
||||||
```
|
```
|
||||||
- `getAsync/postJsonAsync` 用同一 `nam_` 发起,返回 `new ApiCall(reply)`。token 注入、`buildRequest` 复用。
|
- `getAsync/postJsonAsync` 用同一 `nam_` 发起,返回 `new ApiCall(reply)`。token 注入、`buildRequest` 复用。
|
||||||
- **已知交互(记录为可接受)**:Nav 的同步嵌套 `QEventLoop` 期间,详情的异步 reply 若完成会被一并泵出、其槽重入执行——无害(详情切换会 abort,且互不写同一状态)。全异步后该同步路径消失。
|
- **已知限制(非"无害",受 §5.0 三条约束兜底)**:Nav 的同步嵌套 `QEventLoop`(`ApiClient::get` 的 `loop.exec()`)期间,详情的异步 reply 若完成会被一并泵出,其整条槽链(ApiCall→ApiBatch→*Load→Controller→`chartReady`→`detailPanel->openOrUpdate`)会**在 Nav 同步调用栈、嵌套循环里重入执行**,即详情 UI 重建可能发生在 Nav 网络调用返回前。这不是"互不写状态"能涵盖的,但有 §5.0 兜底:(a) 一律 `deleteLater` 杜绝栈内同步释放;(b) 各层 `aborted_` 入口守卫;(c) 控制器句柄身份比对。本期 Nav 仍同步、窗口期有限;**全异步后该同步路径与本限制一并消失**。可选缓解(YAGNI,暂不做):详情完成回调改 `QueuedConnection` 投递,避免在嵌套栈内驱动 UI。
|
||||||
|
|
||||||
#### `ApiBatch`(新增,汇聚原语)
|
#### `ApiBatch`(新增,汇聚原语,fail-fast)
|
||||||
```cpp
|
```cpp
|
||||||
// src/net/ApiBatch.hpp
|
// src/net/ApiBatch.hpp
|
||||||
class ApiBatch : public QObject {
|
class ApiBatch : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit ApiBatch(QList<net::IApiCall*> calls, QObject* parent=nullptr); // 接管 calls
|
// calls 接管所有权;predicate 由 repo 注入,判定单个 ApiResponse 是否业务失败
|
||||||
void abort(); // abort 全部子 call,self deleteLater,不 emit
|
ApiBatch(QList<net::IApiCall*> calls,
|
||||||
|
std::function<bool(const net::ApiResponse&)> isFailure,
|
||||||
|
QObject* parent=nullptr);
|
||||||
|
void abort(); // aborted_=true,abort 全部未完成子 call,self deleteLater,不 emit
|
||||||
signals:
|
signals:
|
||||||
void finished(const QList<geopro::net::ApiResponse>& responses); // 按 calls 下标对齐
|
void succeeded(const QList<geopro::net::ApiResponse>& responses); // 全部成功,按下标对齐
|
||||||
|
void failed(int index, const geopro::net::ApiResponse& resp); // 首个失败项
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
- gather-all:记录每个 call 的 `finished` 到 `responses_[i]`,计数到齐后 emit `finished` 并 `deleteLater`。
|
- **所有权契约**:内部用 `QList<QPointer<net::IApiCall>>` 持有子 call;子 call 正常 `finished` 后自删(`QPointer` 自动置空)。`abort()` 遍历时跳过空指针。子 call parent 不设为 batch(各自 `deleteLater` 自管),靠 `QPointer` + `aborted_` 防悬垂。
|
||||||
- `abort()`:对每个未完成子 call 调 `abort()`,自删,不 emit。
|
- 持 `bool aborted_`(§5.0)。每个子 call `finished` 槽入口判 `aborted_`。
|
||||||
- 不做成功/失败判定(留 repo):传输/HTTP 错误体现在对应 `ApiResponse.rawError`/`httpStatus`。
|
- **fail-fast**:每个子 call `finished` 到达时立即用 `isFailure(resp)` 判定——失败则 `emit failed(i, resp)` + `aborted_=true` + abort 其余在飞子 call + `deleteLater`;成功则记入 `responses_[i]`,全部到齐 → `emit succeeded(responses)` + `deleteLater`。
|
||||||
|
- 业务/传输/HTTP 三类失败统一由 `isFailure` 判定(见 §7)。
|
||||||
|
|
||||||
### 5.2 data 层
|
### 5.2 data 层
|
||||||
|
|
||||||
|
|
@ -124,16 +139,27 @@ struct GridParts { core::Grid grid{1,1}; core::ColorScale gridScale; std::vecto
|
||||||
class ChartLoad : public QObject {
|
class ChartLoad : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
void abort(); // 转发给内部 ApiBatch
|
void abort(); // aborted_=true,转发给内部 ApiBatch
|
||||||
signals:
|
signals:
|
||||||
void done(const geopro::data::ChartParts& parts);
|
void done(const geopro::data::ChartParts& parts);
|
||||||
void failed(const QString& message);
|
void failed(const QString& message);
|
||||||
};
|
};
|
||||||
class GridLoad : public QObject { /* done(GridParts) / failed(QString) / abort() */ };
|
class GridLoad : public QObject { /* done(GridParts) / failed(QString) / abort() */ };
|
||||||
```
|
```
|
||||||
- 由 repo 创建并持有内部 `ApiBatch`;batch 完成时逐项校验+解析→ emit `done`/`failed`→ self `deleteLater`。
|
- 持 `bool aborted_`(§5.0);连 `ApiBatch::succeeded` 槽入口判 `aborted_` 后解析→ emit `done`→ `deleteLater`;连 `ApiBatch::failed` → 取 `msg`(见 §7)→ emit `failed`→ `deleteLater`。`abort()` 置 `aborted_` 并转发给内部 `QPointer<ApiBatch>`。
|
||||||
- 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。
|
- 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。
|
||||||
|
|
||||||
|
#### 旧同步方法去向(接口改形审计)
|
||||||
|
`ApiDatasetRepository` 不再继承 `IDatasetRepository`。旧 6 个同步方法去向:
|
||||||
|
|
||||||
|
| 旧方法 | 去向 |
|
||||||
|
|---|---|
|
||||||
|
| `loadStructure()` | **丢弃**(详情路径不用;现实现即返回 `{}` 占位;结构树走 `LocalSampleRepository`/Nav) |
|
||||||
|
| `loadScatter` + `loadScatterColorScale` | 合并进 `loadChartAsync` |
|
||||||
|
| `loadGrid` + `loadColorScale` + `loadAnomalies` | 合并进 `loadGridAsync` |
|
||||||
|
|
||||||
|
`LocalSampleRepository` **继续实现同步 `IDatasetRepository`**,二接口并存;`buildWorkbench` 的 `repo` 形参类型不变。已核对:无任何处把 `ApiDatasetRepository` 当 `IDatasetRepository*` 用于 `loadStructure`,丢弃安全。
|
||||||
|
|
||||||
#### `IAsyncDatasetRepository`(新增抽象,详情控制器依赖它)
|
#### `IAsyncDatasetRepository`(新增抽象,详情控制器依赖它)
|
||||||
```cpp
|
```cpp
|
||||||
// src/data/repo/IAsyncDatasetRepository.hpp
|
// src/data/repo/IAsyncDatasetRepository.hpp
|
||||||
|
|
@ -148,9 +174,10 @@ public:
|
||||||
|
|
||||||
#### `ApiDatasetRepository`(改造)
|
#### `ApiDatasetRepository`(改造)
|
||||||
- 不再继承同步 `IDatasetRepository`;改实现 `IAsyncDatasetRepository`。
|
- 不再继承同步 `IDatasetRepository`;改实现 `IAsyncDatasetRepository`。
|
||||||
- `loadChartAsync`:`ApiBatch{ getScatter, getScatterScale }` → 完成后复用现有 DTO 解析(`DatasetChartDto` 等)填 `ChartParts`;任一项错误 → `failed`。
|
- `loadChartAsync`:建 `ApiBatch{ getScatter, getScatterScale }`,注入 `isFailure`(见 §7)→ `succeeded` 时复用现有 DTO 解析(`DatasetChartDto` 等)填 `ChartParts` 并 `done`;`failed` → 取错误原因并 `failed`。
|
||||||
- `loadGridAsync`:`ApiBatch{ getRows, getColorScale, getException }` → 解析填 `GridParts`;同上。
|
- `loadGridAsync`:`ApiBatch{ getRows, getColorScale, getException }` → 同构填 `GridParts`。
|
||||||
- 现有同步解析/校验逻辑(DTO、v 行数校验、markType 钳制)原样搬入异步完成回调,不重写。
|
- 现有同步解析/校验逻辑(DTO、v 行数校验、markType 钳制)与 `must()` 的判定(`code==200`)**原样搬入**,不重写——`must()` 的判定提炼为 batch 的 `isFailure` 谓词。
|
||||||
|
- 空数据非失败:异常列表可能为空——业务 `code==200` 且 data 为空走成功(空 `GridParts.anomalies`),与现状一致。
|
||||||
|
|
||||||
### 5.3 controller 层
|
### 5.3 controller 层
|
||||||
|
|
||||||
|
|
@ -165,10 +192,13 @@ private:
|
||||||
QPointer<data::ChartLoad> chartLoad_; // 当前在飞(QPointer 防悬垂)
|
QPointer<data::ChartLoad> chartLoad_; // 当前在飞(QPointer 防悬垂)
|
||||||
QPointer<data::GridLoad> gridLoad_;
|
QPointer<data::GridLoad> gridLoad_;
|
||||||
```
|
```
|
||||||
- `openDataset(dsId, ddCode)`:ddCode 非 `dd_inversion_data` → `loadFailed`;否则 **若 `chartLoad_` 在飞先 abort**,`repo_.loadChartAsync` 取新句柄,连 `done`→组 `ChartData`→`chartReady`、`failed`→`loadFailed`,emit `loadStarted(dsId, Chart)`。
|
- `openDataset(dsId, ddCode)`:ddCode 非 `dd_inversion_data` → `loadFailed`;否则 **若 `chartLoad_` 在飞先 `abort()`**,`repo_.loadChartAsync` 取新句柄存入 `chartLoad_`,emit `loadStarted(dsId, Chart)`,连:
|
||||||
|
- `done` → **句柄身份比对**(捕获的句柄指针 == 当前 `chartLoad_`?否则丢弃迟到信号,见 §5.0)→ 组 `ChartData` → `chartReady` → 清空 `chartLoad_`。
|
||||||
|
- `failed` → 同样身份比对 → `loadFailed` → 清空 `chartLoad_`。
|
||||||
- `loadGridData(dsId, ddCode)`:同构,针对 `gridLoad_` 与 `gridReady`。
|
- `loadGridData(dsId, ddCode)`:同构,针对 `gridLoad_` 与 `gridReady`。
|
||||||
- **移除 `busy_` 守卫**:由 abort-and-replace 取代(新请求来→abort 旧→发新)。重入不再是危险(无嵌套事件循环)。
|
- **移除 `busy_` 守卫**:由 abort-and-replace + 句柄身份比对取代(新请求来→abort 旧→发新;旧句柄的迟到信号被身份比对丢弃)。
|
||||||
- 句柄完成/失败后清空对应 `QPointer`。控制器析构时 abort 在飞句柄。
|
- 句柄完成/失败后清空对应 `QPointer`。**控制器析构时 abort 所有在飞句柄**(见 §7 退出契约)。
|
||||||
|
- 现有 `catch(...)` 兜底不再需要(无同步抛出路径);错误统一经 `failed` 信号传递。
|
||||||
|
|
||||||
### 5.4 UI 层(加载反馈)
|
### 5.4 UI 层(加载反馈)
|
||||||
|
|
||||||
|
|
@ -184,39 +214,38 @@ private:
|
||||||
1. `detailCtrl.openDataset(id, "dd_inversion_data")` → emit `loadStarted(id,Chart)`(UI 置 busy)。
|
1. `detailCtrl.openDataset(id, "dd_inversion_data")` → emit `loadStarted(id,Chart)`(UI 置 busy)。
|
||||||
2. abort 旧 `chartLoad_`(若有)→ `repo.loadChartAsync(id)` 返回 `ChartLoad*`。
|
2. abort 旧 `chartLoad_`(若有)→ `repo.loadChartAsync(id)` 返回 `ChartLoad*`。
|
||||||
3. repo 内 `ApiBatch{getScatter, getScatterScale}` 并发;UI 线程**保持响应**。
|
3. repo 内 `ApiBatch{getScatter, getScatterScale}` 并发;UI 线程**保持响应**。
|
||||||
4. 两 reply 到齐 → batch.finished → repo 解析 → `ChartLoad::done(ChartParts)`。
|
4. 两 reply 全成功到齐 → `batch.succeeded` → repo 解析 → `ChartLoad::done(ChartParts)`。
|
||||||
5. controller 组 `ChartData` → `chartReady` → `detailPanel->openOrUpdate`(清 busy)。
|
5. controller 身份比对通过 → 组 `ChartData` → `chartReady` → `detailPanel->openOrUpdate`(清 busy)。
|
||||||
- 任一错误 → `ChartLoad::failed` → `loadFailed` → 状态栏提示(清 busy)。
|
- **任一项失败(fail-fast)**:首个失败 → `batch.failed` + abort 其余在飞 → `ChartLoad::failed` → `loadFailed` → 状态栏提示(清 busy),不等其余请求。
|
||||||
|
|
||||||
**网格数据(首次切到网格页签)**
|
**网格数据(首次切到网格页签)**
|
||||||
1. `gridDataNeeded` → `loadGridData(id, ...)` → emit `loadStarted(id,Grid)`(网格视图遮罩)。
|
1. `gridDataNeeded` → `loadGridData(id, ...)` → emit `loadStarted(id,Grid)`(网格视图遮罩)。
|
||||||
2. abort 旧 `gridLoad_` → `repo.loadGridAsync(id)`。
|
2. abort 旧 `gridLoad_` → `repo.loadGridAsync(id)`。
|
||||||
3. `ApiBatch{getRows(慢1–4s), getColorScale, getException}` 并发。
|
3. `ApiBatch{getRows(慢1–4s), getColorScale, getException}` 并发。
|
||||||
4. 到齐 → 解析 → `done(GridParts)` → `gridReady` → `setGridData`(隐遮罩)。
|
4. 全成功到齐 → 解析 → `done(GridParts)` → `gridReady` → `setGridData`(隐遮罩)。若 colorScale/exception 先失败 → fail-fast 立即 `failed` + abort rows,不干等 1–4s。
|
||||||
5. 期间用户切走/关页 → controller abort `gridLoad_` → 子 reply 全 abort,无回灌。
|
5. 期间用户切走/关页 → controller abort `gridLoad_` → 子 reply 全 abort + `aborted_` 闸门 → 即便有迟到信号也被丢弃,无回灌。
|
||||||
|
|
||||||
## 7. 错误处理与边界
|
## 7. 错误处理与边界
|
||||||
|
|
||||||
- **传输/HTTP 错误**:体现在 `ApiResponse.rawError`/`httpStatus`;repo 在解析处判定并 `failed(msg)`,控制器转 `loadFailed` → 状态栏(沿用现有 UX)。
|
- **失败判定三要素(`isFailure` 谓词,由现 `must()` 提炼)**:一个 `ApiResponse` 视为失败当:(1) 传输错误 `!rawError.isEmpty()`;或 (2) HTTP 异常(必要时查 `httpStatus`);或 (3) **业务码 `code != 200`**(服务端常返回 HTTP 200 但 `code=500`,这是现 `must()` 的判定口径,**不能只看 httpStatus**)。失败原因取 `msg`(空则 `rawError`)。
|
||||||
- **abort 竞态**:`ApiCall::abort` 先 `disconnect` 再 `abort`,确保 abort 后不 emit。`*Load` 与 `ApiBatch` abort 后 `deleteLater` 且不 emit。控制器用 `QPointer` 防悬垂。
|
- **abort 竞态(核心,见 §5.0)**:单靠 disconnect 不足以挡「已入队的迟到信号」。权威闸门 = 各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 `deleteLater`。`ApiCall::abort` 置 `aborted_` 后 disconnect+`reply->abort()`。
|
||||||
- **重复触发**:abort-and-replace 天然幂等(新覆旧)。
|
- **重复触发**:abort-and-replace + 身份比对天然幂等(新覆旧,旧迟到信号丢弃)。
|
||||||
- **控制器析构**:abort 所有在飞句柄,避免回调打到已析构对象。
|
- **控制器析构(退出契约)**:abort 所有在飞句柄。`ApiCall`/`ApiBatch`/`*Load` **不得在析构或 `deleteLater` 回调里访问 `nam`/`reply`**(reply 在 abort 时已 `deleteLater`)。main.cpp 栈对象析构逆序为 `detailCtrl` → `datasetRepo` → `api`,确保控制器先 abort、ApiClient 最后毁;句柄不持有对 `nam` 的长期引用。
|
||||||
- **共享 NAM 同步/异步并存**:见 5.1,记录为可接受,全异步后消除。
|
- **共享 NAM 同步/异步并存**:见 §5.1,**已知限制**(非"无害"),受 §5.0 三约束兜底,全异步后消除。
|
||||||
- **空/缺数据**:异常列表可能为空——repo 判定"业务成功但无数据"应走 `done`(空),非 `failed`,与现状一致。
|
- **空/缺数据**:异常列表可能为空——业务 `code==200` 走 `done`(空),非 `failed`,与现状一致。
|
||||||
|
|
||||||
## 8. 测试策略
|
## 8. 测试策略
|
||||||
|
|
||||||
- **`ApiBatch`(离线单测)**:注入假 `IApiCall`(受测可控 emit `finished`/记录 `abort`)。
|
- **`ApiBatch`(离线单测)**:注入假 `IApiCall`(受测可控 emit `finished`/记录 `abort`)。
|
||||||
- gather-all:全部 finished 后 emit 一次、下标对齐。
|
- 全成功 → `succeeded` 一次、下标对齐。
|
||||||
- abort:未完成子 call 均被 abort、不 emit。
|
- **fail-fast**:某子 call 先返回失败 → `failed(i,resp)` 一次 + 其余在飞子 call 被 `abort`(且不再等待)。
|
||||||
- 含错误响应仍 gather-all(不短路)。
|
- **abort 闸门**:batch `abort()` 后,未完成子 call 均被 abort;之后即便假 call 延迟 emit `finished`,batch 也不 emit(`aborted_` 守卫)。
|
||||||
- **`DatasetDetailController`(离线单测,QSignalSpy)**:用 `StubAsyncRepo` 返回假 `*Load`,可控 emit `done`/`failed`(即时或 `QMetaObject::invokeMethod` 排队)。
|
- **`DatasetDetailController`(离线单测,QSignalSpy + 事件循环)**:用 `StubAsyncRepo` 返回假 `*Load`,可控 emit `done`/`failed`(即时或 `QMetaObject::invokeMethod(..., QueuedConnection)` 模拟迟到)。异步桩非即时返回,断言需 `spy.wait()` / `QTest::qWait` spin 事件循环。
|
||||||
- `done` → 一次 `chartReady`/`gridReady`。
|
- `done` → 一次 `chartReady`/`gridReady`;`failed` → 一次 `loadFailed`;非 `dd_inversion_data` → 直接 `loadFailed`。
|
||||||
- `failed` → 一次 `loadFailed`。
|
|
||||||
- 在飞时再 `openDataset` → 旧句柄被 `abort`(stub 记录)。
|
- 在飞时再 `openDataset` → 旧句柄被 `abort`(stub 记录)。
|
||||||
- 非 `dd_inversion_data` → 直接 `loadFailed`。
|
- **回灌防护(回归原 bug 的核心用例)**:① 句柄 abort 后延迟 emit `done` → 控制器**零** `chartReady`(身份比对 + aborted_ 生效);② `openDataset(A)` 在飞 → `openDataset(B)` → A 句柄迟到 emit `done` → **仅 B 的数据被 `chartReady`,A 丢弃**。
|
||||||
- **`ApiCall`/`getAsync`(可选 live test)**:依现有 `AuthLiveTest` 先例,标记为 live,不计入离线覆盖门槛。
|
- **`ApiCall`/`getAsync`(可选 live test)**:依现有 `AuthLiveTest` 先例,标记为 live,不计入离线覆盖门槛。
|
||||||
- 现有 75 个测试中,`DatasetDetailController.*` 两个改为异步桩版本;其余不动。`LocalRepo.*` 不受影响(同步接口保留)。
|
- 现有 75 个测试中,`DatasetDetailController.*` 两个改为异步桩版本(加事件循环 spin);其余不动。`LocalRepo.*` 不受影响(同步接口保留)。
|
||||||
- 目标:异步新增逻辑(batch/controller)离线单测覆盖 ≥ 80%。
|
- 目标:异步新增逻辑(batch/controller)离线单测覆盖 ≥ 80%。
|
||||||
|
|
||||||
## 9. 迁移步骤(供 writing-plans 细化)
|
## 9. 迁移步骤(供 writing-plans 细化)
|
||||||
|
|
@ -226,18 +255,22 @@ private:
|
||||||
3. data:`DatasetLoads.hpp`(ChartParts/GridParts)+ `ChartLoad`/`GridLoad` + `IAsyncDatasetRepository`。
|
3. data:`DatasetLoads.hpp`(ChartParts/GridParts)+ `ChartLoad`/`GridLoad` + `IAsyncDatasetRepository`。
|
||||||
4. data:`ApiDatasetRepository` 改实现异步接口(搬入现有 DTO 解析)。
|
4. data:`ApiDatasetRepository` 改实现异步接口(搬入现有 DTO 解析)。
|
||||||
5. controller:`DatasetDetailController` 改异步 + abort-and-replace + `loadStarted`;改其单测。
|
5. controller:`DatasetDetailController` 改异步 + abort-and-replace + `loadStarted`;改其单测。
|
||||||
6. UI:`LoadingOverlay` + `DatasetDetailPanel` 接 `loadStarted`;`main.cpp` 装配换 `IAsyncDatasetRepository`。
|
6. `main.cpp` 装配换 `IAsyncDatasetRepository` + 启动注册 `qRegisterMetaType<ApiResponse>()`。
|
||||||
7. 全量构建 + 测试(dev-build / dev-test);手动验证:切换/关页 abort、网格遮罩、并发加载、UI 不冻。
|
7. UI:`LoadingOverlay` + `DatasetDetailPanel` 接 `loadStarted`。**此步与异步内核无依赖,可在步骤 1–6 通过测试后再做/并行**,以减小试点 PR 体积(surgical)。
|
||||||
|
8. 全量构建 + 测试(dev-build / dev-test);手动验证:切换/关页 abort、网格遮罩、并发加载、UI 不冻、失败 fail-fast 即时报错。
|
||||||
|
|
||||||
## 10. 风险
|
## 10. 风险
|
||||||
|
|
||||||
| 风险 | 缓解 |
|
| 风险 | 缓解 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| abort 后回调悬垂/二次释放 | `QPointer` + abort 先 disconnect + 仅 `deleteLater` |
|
| **已入队的迟到信号回灌(最危险)** | §5.0 闸门:各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 `deleteLater`;§8 专项回归用例 |
|
||||||
| 句柄/batch 生命周期泄漏 | 完成或 abort 后统一 `deleteLater`;父子 QObject 归属清晰 |
|
| abort 后回调悬垂/二次释放 | `QPointer` + abort 先置 `aborted_`+disconnect + **禁止同步 delete,仅 `deleteLater`** |
|
||||||
| 同步/异步共用 NAM 重入 | 记录为可接受;详情 abort 隔离;全异步后消除 |
|
| 句柄/batch 生命周期泄漏 | 完成或 abort 后统一 `deleteLater`;`ApiBatch` 用 `QPointer` 持子 call |
|
||||||
| DTO 解析逻辑搬迁出错 | 原样搬入、不重写;保留现有解析单测 |
|
| 同步/异步共用 NAM 嵌套重入 | **已知限制**(非无害);§5.0 三约束兜底;全异步后消除 |
|
||||||
| 接口改形(原子→复合)波及面 | 仅详情控制器消费该接口;`LocalSampleRepository` 不碰 |
|
| 退出期 UAF(句柄 deleteLater 晚于 ApiClient 析构) | 退出契约:句柄不在析构/回调访问 nam/reply;栈析构逆序保证 ctrl→repo→api |
|
||||||
|
| 失败比成功慢(gather-all 缺陷) | 改 fail-fast + abort 其余在飞 call |
|
||||||
|
| DTO 解析逻辑搬迁出错 | 原样搬入、不重写;`must()`→`isFailure` 谓词;保留现有解析单测 |
|
||||||
|
| 接口改形(原子→复合)波及面 | 仅详情控制器消费该接口;§5.2 旧方法去向表;`LocalSampleRepository` 不碰 |
|
||||||
|
|
||||||
## 11. 相关文件
|
## 11. 相关文件
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue