test(instances): M302 r2 —— 四条 P2:线程判据真能红 + 两处相位守卫 + 「只报一次」改成可数

- P2-1:`spawn_scan` 的闭包提成 `scan_body`,单测可装探针 + 闸门(只拦同一数据根的扫描)。
  新用例 `a_refresh_returns_while_the_scan_body_is_still_parked` 断言四件事:扫描体跑在
  **另一条线程**、返回时**一趟都没跑完**、闸门**还没放行**、返回耗时 < 闸门上限的一半。
  把 `spawn("instances", job)` 换成直接 `job()` ⇒ 该用例红(原文见报告);旧用例
  `a_refresh_returns_before_the_scan_is_done` 的 docstring 改成诚实口径(它证明不了不在调用方线程,
  inline 时它照样绿——正是复审 r2 点的那条)。
- P2-2(真缺陷):`check_scan_stall` 补上与 `refresh_instances` 同一个守卫——已就绪
  (`adopted().is_some()`)之后不再报 `Stalled`,否则一轮慢刷新会把有数据的一屏换成
  「扫描实例比平时久 + 重新扫描」。新用例 `a_slow_refresh_after_ready_never_reports_stalled`。
- P2-3:`a_stalled_scan_reports_once_and_a_late_snapshot_still_lands` 原来把 `drain_events` 调了
  两次(第二次必空 ⇒ 断言空转)。改成数实际发出的条数:第一拍恰好 1 条,之后三拍必须 0 条。
- P2-4:新用例 `a_refresh_after_ready_emits_no_scanning_phase`(真 host:已就绪后再刷新一条相位
  事件都不许发)+ app 级 `a_refresh_after_ready_keeps_the_list_and_the_tree`(相位仍 `Ready`、
  真控件树的文字/按钮节点数与刷新前逐项相等、列表没被清空)。

四条变异(inline / 去 P2-2 守卫 / 去「只报一次」锁 / 去 Scanning 守卫)都实测红,原文进报告。
This commit is contained in:
AskaEth committed 2026-11-13 19:36:33 +08:00
1 parent f03411d58b
commit 23c7beee8d
4 files changed
+372 -35

No files matched your search

@@ -180,3 +180,84 @@ fn retry_scan_dispatches_a_scan_only_refresh() {
"重试不该重拉版本清单(那是一条网络请求)"
);
}
/// **已就绪之后再刷新:相位仍是 `Ready`,真控件树里不出现扫描占位**(复审 r2 P2-4)。
///
/// 现场:列表已经有数据,用户手动刷新 / 别处触发刷新。若那一轮把相位打回 `Scanning`,界面就把
/// 有数据的一屏换成「正在扫描实例…」——把卡顿换成闪屏(任务 ⑤ 禁止)。
///
/// 抓得住:① 相位被打回 `Scanning`/`Stalled`;② 控件树变了(占位替换掉列表:文字/按钮节点数
/// 与刷新前不再相等)。前半条的直接来源是 host 的守卫(presenter 的
/// `a_refresh_after_ready_emits_no_scanning_phase` 用真 host 钉它),这一条验的是「守卫成立时
/// 界面确实不动」。
/// 抓不住:真 host 的那一轮扫描(这一条用只记账的后端:命令出去了、事件一个都没回)。
#[test]
fn a_refresh_after_ready_keeps_the_list_and_the_tree() {
use iced::advanced::widget::tree::Tag;
use iced::advanced::widget::Tree;
use iced::widget::text;
use iced::Element;
fn tree_of(el: &Element<'_, crate::pages::Message>) -> Tree {
Tree::new(el.as_widget())
}
fn count(tree: &Tree, tag: Tag) -> usize {
usize::from(tree.tag == tag)
+ tree
.children
.iter()
.map(|child| count(child, tag))
.sum::<usize>()
}
/// 一屏的 `(文字节点数, 按钮数)`。
fn counts(app: &App) -> (usize, usize) {
let el = crate::pages::app(&app.ui, &app.view, &app.launch_form);
let tree = tree_of(&el);
let text_tag = tree_of(&{
let probe: Element<'static, crate::pages::Message> = text("probe").into();
probe
})
.tag;
let button_tag = tree_of(&crate::app::components::buttons::filled(
&app.ui,
crate::app::components::buttons::Button::new(
"probe",
crate::pages::Message::Action(crate::pages::Action::Back),
),
))
.tag;
(count(&tree, text_tag), count(&tree, button_tag))
}
let (mut app, recorded) = app();
// 首份快照落地 ⇒ Ready + 有行
app.state.apply(AppEvent::Core(CoreEvent::InstancesChanged(
unlucky_presenter::mock_data::mock_instances(),
)));
app.refresh_view();
assert_eq!(app.view.instances.scan, ScanPhase::Ready);
assert!(!app.view.instances.rows.is_empty(), "前提:列表里有数据");
let before = counts(&app);
// 再触发一次刷新:命令出去了,后端这一拍**一个事件都不回**(= 扫描在飞)
let _ = app.on_instance_message(InstanceMessage::RetryScan);
app.pump();
app.refresh_view();
assert!(
recorded.borrow().contains(&AppCommand::RefreshInstances),
"前提:刷新命令真的发出去了({:?})",
recorded.borrow()
);
assert_eq!(
app.view.instances.scan,
ScanPhase::Ready,
"已就绪之后再刷新,相位不许被打回占位态"
);
assert_eq!(
counts(&app),
before,
"控件树必须与刷新前同构(占位/空态会多出文字与按钮节点)"
);
assert!(!app.view.instances.rows.is_empty(), "列表也不许被清空");
}