fix(instances): M302 r1 P2 —— 冷启动首帧三态(占位 / 真空态 / 有实例)+ N1~N4
复审 P2-1:扫盘搬后台之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、
不 `tick`),**首帧必然**在快照之前。改前那一帧画的是「计数 0 + 还没有实例 + 实心导入 CTA」
——一句假话,而且 CTA 能点。改后三态分开:
- 真源放 presenter:`dto::InstancePhase { Scanning, Stalled, Ready }`,`AppState::instances_phase`
在 `CoreEvent::InstancesChanged` 落地时置 `Ready`;**不从 `instances.is_empty()` 推**(那就是 bug 形态)。
- `Scanning` = 首个快照未到:详情列画「正在扫描实例…」占位(新 key `InstancesScanning`,golden 两语),
列头**不写计数**(藏起来,不写 0 也不写「—」),**没有**空态 CTA。
- `Stalled` = 有界兜底:`host::game::SCAN_STALL = 15 s`(= 仓库内最坏实测 1.35 s 的约 11 倍,
理由在常量文档里)。超时只**报**一次、不掐线程;迟到的结果照样落地(代次没变)。
那一屏给一枚「重新扫描」→ `InstanceMessage::RetryScan` → 新命令 `AppCommand::RefreshInstances`
(只重扫实例目录,不重拉版本清单)。那一支在 `selected_instance_id()` 守卫**之前**——冷启动没有选中项。
- `Ready` = 收到过快照(空列表也算):这时才轮到 M273 的两种空态(没选中 / 一个都没有)。
- 已就绪时**不发** `Scanning`:否则每次刷新列表都退回占位(把卡顿换成闪屏)。
判据(都能红,变异原文见报告):
- `pages/instances/tests/cold_boot.rs`:真控件树(未就绪 7 枚按钮 = 有实例那一屏,零实例 = 8 枚,
多的那枚就是 CTA;每行 2 条文字节点的差分数行数)+ 源码结构(`Scanning`/`Stalled` 两支不碰
`detail_empty` 与空态文案、超时那支接 `RetryScan`、三个 key 真的被读)。
- `app/update/instances/scan_phase.rs`:首帧 = `Scanning`、空快照 = `Ready`、`Stalled` 经重试回到
`Scanning`、重试只派 `RefreshInstances`。夹具 `ULL_INSTANCE_PHASE` 只给出图(与 `ULL_SAVES_FIXTURE` 同一条纪律)。
顺手收 N1~N4(全是措辞 / 观测口径,不动行为):
- N1 两处 docstring 不再声称「`admit` 写成 `>=` 会红」(实测等价),改成「去掉或恒真才红」;
- N2 收尾用例那条 Idle 断言的失败消息改准(它不区分新旧快照,区分靠条数/代次断言);
- N3 boot 双刷新必然丢 1 份 ⇒ 丢弃日志分成「此前没有快照落地(启动期,非交错)」与「已有落地(真交错)」,
`Scan::dropped` 的文档登记这条已知噪音;
- N4 app 用例 docstring 收窄成「一拍无事件不清表、不钉时钟」(它绕过 `App::dispatch`,抓不住那 5 行)。
拆文件守 400 行:`detail/scan.rs`(分派 + 占位)、`refresh_stall_tests.rs`(空根 / 超时)、
`app/update/instances/scan_phase.rs`(app 级三态用例);`i18n` 的 `PAGE_SOURCES` 同步加上新页面文件。
This commit is contained in:
1 parent
a0413a5f94
commit
f03411d58b
26 files changed
+1010
-70
No files matched your search
@@ -0,0 +1,182 @@
|
||||
//! M302 的**首屏三态接线**:presenter 的 `InstancePhase` → 页面三态,以及「重新扫描」的落点。
|
||||
//!
|
||||
//! 从 `app/update/instances.rs` 拆出来只为守 400 行硬线(那边加了这一组会到 573 行)。
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`a_fresh_app_is_scanning_until_the_snapshot_lands`] | 首帧(还没有任何快照)被画成「已就绪 + 零实例」(= 复审 P2-1 的假空态) | 真的像素(出图) |
|
||||
//! | [`an_empty_snapshot_still_counts_as_ready`] | 空快照被当成「未就绪」(用户永远看着「正在扫描」) | 空快照从哪来(presenter 的 `refresh_tests` 用真空盘钉) |
|
||||
//! | [`a_stalled_scan_goes_back_to_scanning_on_retry`] | 超时之后没有出路(`Stalled` 不会被新一轮扫描收回 `Scanning`) | 15 s 的阈值本身(`host::game::SCAN_STALL`,单测不等真实时间) |
|
||||
//! | [`retry_scan_dispatches_a_scan_only_refresh`] | 重试按钮派错命令 / 被「必须有选中实例」的守卫挡住(冷启动时根本没有选中项) | 命令真的到达 host(`host::dispatch` 的 `RefreshInstances` 分支由 presenter 的穷尽 match 守) |
|
||||
|
||||
//! M302 的**首屏三态接线**:presenter 的 `InstancePhase` → 页面三态,以及「重新扫描」的落点。
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`a_fresh_app_is_scanning_until_the_snapshot_lands`] | 首帧(还没有任何快照)被画成「已就绪 + 零实例」(= 复审 P2-1 的假空态) | 真的像素(出图) |
|
||||
//! | [`an_empty_snapshot_still_counts_as_ready`] | 空快照被当成「未就绪」(用户永远看着「正在扫描」) | 空快照从哪来(presenter 的 `refresh_tests` 用真空盘钉) |
|
||||
//! | [`a_stalled_scan_goes_back_to_scanning_on_retry`] | 超时之后没有出路(`Stalled` 不会被新一轮扫描收回 `Scanning`) | 15 s 的阈值本身(`host::game::SCAN_STALL`,单测不等待真实时间) |
|
||||
//! | [`retry_scan_dispatches_a_scan_only_refresh`] | 重试按钮派错命令 / 被「必须有选中实例」的守卫挡住(冷启动时根本没有选中项) | 命令真的到达 host(`host::dispatch` 的 `RefreshInstances` 分支由 presenter 编译期穷尽 match 守) |
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent, InstancePhase};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages::model::ScanPhase;
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 只记命令、只在被喂料时回事件的后端(首帧测试要的是「什么都还没到」)。
|
||||
#[derive(Debug, Default)]
|
||||
struct Recorder {
|
||||
dispatched: Rc<RefCell<Vec<AppCommand>>>,
|
||||
queued: Vec<AppEvent>,
|
||||
}
|
||||
|
||||
impl Backend for Recorder {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
std::mem::take(&mut self.queued)
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.dispatched.borrow_mut().push(command);
|
||||
}
|
||||
}
|
||||
|
||||
/// 造一个「首帧还没收到任何快照」的 App(路由停在实例页,**没有**选中实例)。
|
||||
fn app() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
|
||||
let recorded = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder {
|
||||
dispatched: Rc::clone(&recorded),
|
||||
queued: Vec::new(),
|
||||
}),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
app.state
|
||||
.navigate(unlucky_presenter::router::Route::Instances);
|
||||
app.refresh_view();
|
||||
(app, recorded)
|
||||
}
|
||||
|
||||
/// 首帧:**没有快照 ⇒ 画占位**,不是「已就绪 + 零实例」。
|
||||
///
|
||||
/// 抓得住:谁把页面三态接成「`rows.is_empty()` ⇒ 空态」(复审 P2-1 的原样),这条立刻红。
|
||||
/// 抓不住:占位长什么样(`pages/instances/tests/cold_boot.rs` 的控件树 + 出图)。
|
||||
#[test]
|
||||
fn a_fresh_app_is_scanning_until_the_snapshot_lands() {
|
||||
let (mut app, _recorded) = app();
|
||||
|
||||
assert_eq!(
|
||||
app.view.instances.scan,
|
||||
ScanPhase::Scanning,
|
||||
"首帧(还没有任何实例快照)必须画占位,不许画「还没有实例」"
|
||||
);
|
||||
assert!(app.view.instances.rows.is_empty(), "首帧本来就没有行");
|
||||
|
||||
// 快照到了:三态翻到 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(),
|
||||
"快照里的实例必须出现在列表里"
|
||||
);
|
||||
}
|
||||
|
||||
/// **空快照也算就绪**:真·空数据根扫完就是「零实例 + Ready」,不是「一直在扫描」。
|
||||
///
|
||||
/// 抓得住:把「收到快照」判成「没收到」(例如拿空列表当未就绪)——那会让空数据根永远转圈。
|
||||
/// 抓不住:真空盘真的产出空快照这件事(presenter 的 `refresh_tests` 用真临时根钉)。
|
||||
#[test]
|
||||
fn an_empty_snapshot_still_counts_as_ready() {
|
||||
let (mut app, _recorded) = app();
|
||||
|
||||
app.state
|
||||
.apply(AppEvent::Core(CoreEvent::InstancesChanged(Vec::new())));
|
||||
app.refresh_view();
|
||||
|
||||
assert_eq!(
|
||||
app.view.instances.scan,
|
||||
ScanPhase::Ready,
|
||||
"扫完且零实例 = 就绪:这时才轮到「还没有实例」+ 导入 CTA"
|
||||
);
|
||||
assert!(app.view.instances.rows.is_empty());
|
||||
}
|
||||
|
||||
/// 超时那屏:`Stalled` 落地 ⇒ 页面翻到「重新扫描」;点了重试 ⇒ 回到 `Scanning`。
|
||||
///
|
||||
/// 抓得住:`InstancePhaseChanged` 没接进页面(超时对用户不可见);重试之后没有回到扫描态
|
||||
/// (按钮点完还停在错误态)。
|
||||
/// 抓不住:15 s 的阈值(`SCAN_STALL`,等真实时间不是单测该做的事)。
|
||||
#[test]
|
||||
fn a_stalled_scan_goes_back_to_scanning_on_retry() {
|
||||
let (mut app, recorded) = app();
|
||||
app.state
|
||||
.apply(AppEvent::Core(CoreEvent::InstancePhaseChanged(
|
||||
InstancePhase::Stalled,
|
||||
)));
|
||||
app.refresh_view();
|
||||
assert_eq!(app.view.instances.scan, ScanPhase::Stalled);
|
||||
|
||||
// 用户点「重新扫描」⇒ 派一条只重扫实例的命令
|
||||
let _ = app.on_instance_message(InstanceMessage::RetryScan);
|
||||
assert!(
|
||||
recorded.borrow().contains(&AppCommand::RefreshInstances),
|
||||
"重试必须派 `AppCommand::RefreshInstances`(实际 {:?})",
|
||||
recorded.borrow()
|
||||
);
|
||||
|
||||
// host 受理后会把三态打回 `Scanning`(新一轮扫描起跑)
|
||||
app.state
|
||||
.apply(AppEvent::Core(CoreEvent::InstancePhaseChanged(
|
||||
InstancePhase::Scanning,
|
||||
)));
|
||||
app.refresh_view();
|
||||
assert_eq!(app.view.instances.scan, ScanPhase::Scanning);
|
||||
}
|
||||
|
||||
/// 「重新扫描」派的是**只重扫实例**那条命令,而且冷启动(没有选中实例)也能派出去。
|
||||
///
|
||||
/// 抓得住:① 派成 `RefreshAll`(会顺带重拉版本清单网络请求);② 那一支被 `selected_instance_id()`
|
||||
/// 守卫挡住——冷启动时列表还是空的、根本没有选中项,按钮会点了没反应(`on_instance_message`
|
||||
/// 的第一条守卫就是这个形态)。
|
||||
/// 抓不住:host 收到之后真的重扫(`host::dispatch` 的 `RefreshInstances` 分支)。
|
||||
#[test]
|
||||
fn retry_scan_dispatches_a_scan_only_refresh() {
|
||||
let (mut app, recorded) = app();
|
||||
assert!(
|
||||
app.selected_instance_id().is_none(),
|
||||
"前提:冷启动没有选中实例(这正是守卫会挡住的情形)"
|
||||
);
|
||||
|
||||
let _ = app.on_instance_message(InstanceMessage::RetryScan);
|
||||
|
||||
let sent = recorded.borrow().clone();
|
||||
assert_eq!(
|
||||
sent,
|
||||
vec![AppCommand::RefreshInstances],
|
||||
"只许派一条 `RefreshInstances`(不许 `RefreshAll`,也不许什么都不派)"
|
||||
);
|
||||
assert!(
|
||||
!sent.contains(&AppCommand::RefreshAll),
|
||||
"重试不该重拉版本清单(那是一条网络请求)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user