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
@@ -23,12 +23,19 @@ use super::super::state::App;
|
|||||||
impl App {
|
impl App {
|
||||||
/// 实例页详情列的一条消息(启动参数表单 + MODS 区)。
|
/// 实例页详情列的一条消息(启动参数表单 + MODS 区)。
|
||||||
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
|
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
|
||||||
|
// M302:扫描超时那一屏的「重新扫描」。**必须在下面那道守卫之前**:那一刻列表还是空的、
|
||||||
|
// 根本没有选中实例,掉进 `selected_instance_id()` 守卫就永远派不发——按钮点了没反应。
|
||||||
|
if matches!(message, InstanceMessage::RetryScan) {
|
||||||
|
self.dispatch(AppCommand::RefreshInstances);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
let Some(instance) = self.selected_instance_id() else {
|
let Some(instance) = self.selected_instance_id() else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
|
|
||||||
match message {
|
match message {
|
||||||
// M224:启停一个 mod。**不做乐观翻面**——先把那个开关标成「请求在飞」(置灰),
|
// 上面那条提前返回已经处理过了;这里留一支只为保持 match 穷尽(漏一支会编不过)
|
||||||
|
InstanceMessage::RetryScan => {}
|
||||||
// 真状态等 host 回一份新快照(与 Save 分支同一条纪律)。
|
// 真状态等 host 回一份新快照(与 Save 分支同一条纪律)。
|
||||||
//
|
//
|
||||||
// 这一支在 `bound_to` 守卫**之前**:MODS 区的启停只依赖「当前选中实例」,
|
// 这一支在 `bound_to` 守卫**之前**:MODS 区的启停只依赖「当前选中实例」,
|
||||||
@@ -276,8 +283,13 @@ mod tests {
|
|||||||
/// 第二件事是 M298 的型态:`view_dirty` 一旦永久为真,`advance` 就每拍无条件 `refresh_view`
|
/// 第二件事是 M298 的型态:`view_dirty` 一旦永久为真,`advance` 就每拍无条件 `refresh_view`
|
||||||
/// (整页视图模型重建),而「快照落地」这条路最容易被写成「只置脏不重建」。
|
/// (整页视图模型重建),而「快照落地」这条路最容易被写成「只置脏不重建」。
|
||||||
///
|
///
|
||||||
/// 抓得住:① 谁在派发刷新的那一刻清空 `state.instances`;② 谁把 `InstancesChanged` 的落地
|
/// 抓得住:① **没有事件的一拍** `pump` 不把 `state.instances` 清空(用例走的是
|
||||||
/// 路径写成「置脏不落地」或者让 `refresh_view` 收尾不再复位 `view_dirty`。
|
/// `backend.dispatch + pump`,所以要防的是「事件落地路径顺手清表」这一类错);
|
||||||
|
/// ② 谁把 `InstancesChanged` 的落地路径写成「置脏不落地」或者让 `refresh_view` 收尾不再复位
|
||||||
|
/// `view_dirty`。
|
||||||
|
/// 抓不住:**`App::dispatch` 那 5 行**(它置 `watch_until`、再 pump)本身——本用例故意绕过它,
|
||||||
|
/// 因为观察窗会让 `needs_frames()` 在 3 s 内为真,与「刷新有没有多订阅帧」这件事混在一起。
|
||||||
|
/// 换句话说:这里守的是「一拍无事件 = 列表不动 + 不烧帧」,不是「派发那一刻」。
|
||||||
/// 抓不住:真线程的时序与竞争(那在 presenter 的 `host::game::refresh::tests` 交错用例里);
|
/// 抓不住:真线程的时序与竞争(那在 presenter 的 `host::game::refresh::tests` 交错用例里);
|
||||||
/// 也抓不住「扫盘真要多久」(那是真实测量,不是单测)。
|
/// 也抓不住「扫盘真要多久」(那是真实测量,不是单测)。
|
||||||
#[test]
|
#[test]
|
||||||
@@ -380,3 +392,8 @@ mod tests {
|
|||||||
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
|
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ M302 首屏三态
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod scan_phase;
|
||||||
@@ -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),
|
||||||
|
"重试不该重拉版本清单(那是一条网络请求)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -215,6 +215,25 @@ impl App {
|
|||||||
|
|
||||||
// --------------------------------------------------------------- 快照 → 视图模型
|
// --------------------------------------------------------------- 快照 → 视图模型
|
||||||
|
|
||||||
|
/// 首屏三态的出图夹具(`ULL_INSTANCE_PHASE=scanning|stalled`)。
|
||||||
|
///
|
||||||
|
/// **只给出图**:截图通道在第 3 帧落盘(`CAPTURE_AT_FRAME`),而真快照要走一条后台线程 +
|
||||||
|
/// 至少一个 `tick`,来不及;所以「正在扫描 / 超时」这两屏必须能**注入**状态。注入点选在
|
||||||
|
/// 「快照 → 页面模型」这一处转换(与 `ULL_SAVES_FIXTURE` / `ULL_MODS_FIXTURE` 同一条纪律:
|
||||||
|
/// 只在环境变量存在时生效,产品路径读不到它 ⇒ 一帧都不影响;三态判据本身仍由
|
||||||
|
/// `pages/instances/tests/cold_boot.rs` 与 `app/update/instances.rs` 守着)。
|
||||||
|
fn scan_phase(state: &AppState) -> pages::model::ScanPhase {
|
||||||
|
match std::env::var("ULL_INSTANCE_PHASE")
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
{
|
||||||
|
Some("scanning") => pages::model::ScanPhase::Scanning,
|
||||||
|
Some("stalled") => pages::model::ScanPhase::Stalled,
|
||||||
|
_ => pages::model::ScanPhase::of(state.instances_phase),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
|
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
|
||||||
///
|
///
|
||||||
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
|
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
|
||||||
@@ -262,7 +281,12 @@ pub(super) fn model(
|
|||||||
window_width: width,
|
window_width: width,
|
||||||
window_height: height,
|
window_height: height,
|
||||||
active: route.nav(),
|
active: route.nav(),
|
||||||
instances: pages::Instances { rows, detail },
|
instances: pages::Instances {
|
||||||
|
rows,
|
||||||
|
detail,
|
||||||
|
// M302:首屏三态由 presenter 的快照位决定(**不**从 `rows.is_empty()` 猜)
|
||||||
|
scan: scan_phase(state),
|
||||||
|
},
|
||||||
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
||||||
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
|
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
|
||||||
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
|
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ use unlucky_presenter::router::Route;
|
|||||||
|
|
||||||
use super::frame::{self, Header};
|
use super::frame::{self, Header};
|
||||||
use super::layout::{FrameMetrics, PageLayout};
|
use super::layout::{FrameMetrics, PageLayout};
|
||||||
use super::model::{Instances, Model, Row};
|
use super::model::{Instances, Model, Row, ScanPhase};
|
||||||
use super::{Action, Message};
|
use super::{Action, Message};
|
||||||
|
|
||||||
/// 列表行距:**`None` = 这一层不加间距**(页面与护栏**共用这一个真源**)。
|
/// 列表行距:**`None` = 这一层不加间距**(页面与护栏**共用这一个真源**)。
|
||||||
@@ -187,7 +187,7 @@ pub fn view<'a>(
|
|||||||
forced,
|
forced,
|
||||||
texts,
|
texts,
|
||||||
),
|
),
|
||||||
None => detail::detail_empty(ui, texts, !block.rows.is_empty()),
|
None => detail::detail_side(ui, block, texts),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_ => match &block.detail {
|
_ => match &block.detail {
|
||||||
@@ -201,10 +201,7 @@ pub fn view<'a>(
|
|||||||
forced,
|
forced,
|
||||||
texts,
|
texts,
|
||||||
),
|
),
|
||||||
None => frame::body(
|
None => frame::body(metrics, detail::detail_side(ui, block, texts)),
|
||||||
metrics,
|
|
||||||
detail::detail_empty(ui, texts, !block.rows.is_empty()),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,30 +225,29 @@ fn list_column<'a>(
|
|||||||
forced: Option<Interaction>,
|
forced: Option<Interaction>,
|
||||||
texts: Texts,
|
texts: Texts,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let head = container(
|
let mut head_row = row![container(frame::text_role(
|
||||||
row![
|
ui,
|
||||||
container(frame::text_role(
|
TypeRole::LabelMedium,
|
||||||
ui,
|
false,
|
||||||
TypeRole::LabelMedium,
|
texts.all_title(),
|
||||||
false,
|
ui.color(Role::OnSurfaceVariant),
|
||||||
texts.all_title(),
|
))
|
||||||
ui.color(Role::OnSurfaceVariant),
|
|
||||||
))
|
|
||||||
.width(Length::Fill)
|
|
||||||
.align_y(Alignment::Center),
|
|
||||||
frame::text_role_owned(
|
|
||||||
ui,
|
|
||||||
TypeRole::LabelMedium,
|
|
||||||
false,
|
|
||||||
block.rows.len().to_string(),
|
|
||||||
ui.color(Role::OnSurfaceVariant),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
.width(Length::Fill)
|
|
||||||
.align_y(Alignment::Center),
|
|
||||||
)
|
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.padding(Padding {
|
.align_y(Alignment::Center)]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
// M302:**未就绪时不许写 0**(复审 r1 P2-1:计数 0 与「还没有实例」是同一句假话的两半)。
|
||||||
|
// 藏起来而不是写「—」:破折号读起来像「零个」,而这一刻根本没有数可给。
|
||||||
|
if block.scan == ScanPhase::Ready {
|
||||||
|
head_row = head_row.push(frame::text_role_owned(
|
||||||
|
ui,
|
||||||
|
TypeRole::LabelMedium,
|
||||||
|
false,
|
||||||
|
block.rows.len().to_string(),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let head = container(head_row).width(Length::Fill).padding(Padding {
|
||||||
top: ui.space(Space::X3),
|
top: ui.space(Space::X3),
|
||||||
right: ui.space(Space::X3),
|
right: ui.space(Space::X3),
|
||||||
bottom: ui.space(Space::X1),
|
bottom: ui.space(Space::X1),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
use unlucky_i18n::{Catalog, Key, Lang};
|
use unlucky_i18n::{Catalog, Key, Lang};
|
||||||
use unlucky_presenter::dto::LaunchArgsView;
|
use unlucky_presenter::dto::LaunchArgsView;
|
||||||
|
|
||||||
use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row};
|
use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row, ScanPhase};
|
||||||
use super::data::{DEMO, NOTE, SELECTED};
|
use super::data::{DEMO, NOTE, SELECTED};
|
||||||
use super::state::LaunchForm;
|
use super::state::LaunchForm;
|
||||||
|
|
||||||
@@ -59,6 +59,9 @@ pub(crate) fn demo() -> Instances {
|
|||||||
Instances {
|
Instances {
|
||||||
rows,
|
rows,
|
||||||
detail: Some(detail),
|
detail: Some(detail),
|
||||||
|
// 演示数据就是「已就绪」的那一屏(M302 的三态里只有它适合出图:
|
||||||
|
// 演示目录里没有后台线程,画「正在扫描」会是假的)
|
||||||
|
scan: ScanPhase::Ready,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ use super::forced_state;
|
|||||||
use super::state::LaunchForm;
|
use super::state::LaunchForm;
|
||||||
use super::Texts;
|
use super::Texts;
|
||||||
|
|
||||||
|
mod scan;
|
||||||
|
|
||||||
|
pub(in crate::pages) use scan::detail_side;
|
||||||
|
|
||||||
// --------------------------------------------------------------- 详情列
|
// --------------------------------------------------------------- 详情列
|
||||||
|
|
||||||
pub(super) fn detail_column<'a>(
|
pub(super) fn detail_column<'a>(
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//! 首屏三态的分派(M302;从 `detail.rs` 拆出来守 400 行硬线)。
|
||||||
|
//!
|
||||||
|
//! `detail_side` 是**唯一**分派点:`pages/instances.rs` 的宽窗与窄窗两条调用都走它,
|
||||||
|
//! 于是「未就绪」不可能只在一个版面上生效。
|
||||||
|
|
||||||
|
use iced::Element;
|
||||||
|
|
||||||
|
use unlucky_design::icons::IconKind;
|
||||||
|
|
||||||
|
use crate::app::components::buttons::{self, Button};
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
use super::super::super::frame;
|
||||||
|
use super::super::super::model::{Instances, ScanPhase};
|
||||||
|
use super::super::state::Message as InstanceMessage;
|
||||||
|
use super::super::{Message, Texts};
|
||||||
|
use super::detail_empty;
|
||||||
|
|
||||||
|
/// 没选中实例时,详情列画什么(**M302 的三态唯一分派点**)。
|
||||||
|
///
|
||||||
|
/// | `block.scan` | 画什么 | 抓的是什么 |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | `Scanning` | [`scanning`]:一行「正在扫描实例…」 | 首个快照还没到 |
|
||||||
|
/// | `Stalled` | [`scan_stalled`]:一句话 + 可点的「重新扫描」 | 扫描线程没回来(`host::game::SCAN_STALL`) |
|
||||||
|
/// | `Ready` | [`detail_empty`]:两种空态(没选中 / 一个都没有) | 数据是真的 |
|
||||||
|
///
|
||||||
|
/// 顺序是判据:**先看 `scan` 再看 `rows`**。反过来的话,冷启动首帧(`scan = Scanning`、
|
||||||
|
/// `rows` 空)就会被 `!rows.is_empty()` 判成「一个实例都没有」,画出那句假话(复审 r1 P2-1)。
|
||||||
|
pub(in crate::pages) fn detail_side<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
block: &'a Instances,
|
||||||
|
texts: Texts,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
match block.scan {
|
||||||
|
ScanPhase::Scanning => scan_placeholder(ui, texts.scanning(), None),
|
||||||
|
ScanPhase::Stalled => scan_placeholder(
|
||||||
|
ui,
|
||||||
|
texts.scan_stalled(),
|
||||||
|
Some(buttons::filled(
|
||||||
|
ui,
|
||||||
|
Button::new(
|
||||||
|
texts.scan_retry(),
|
||||||
|
Message::Instances(InstanceMessage::RetryScan),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
ScanPhase::Ready => detail_empty(ui, texts, !block.rows.is_empty()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首屏占位(M302):只画标题 + 可选的一条动作,**不**带空态 CTA。
|
||||||
|
///
|
||||||
|
/// 为什么单独一个函数而不是复用 [`empty_no_instances`]:那条支的语义是「盘上真的没有实例,
|
||||||
|
/// 所以请去导入」。「还在读盘」这件事跟它相反——用户可能什么都有,只是还没读完。
|
||||||
|
fn scan_placeholder<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
title: &'a str,
|
||||||
|
action: Option<Element<'a, Message>>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
frame::empty_state(
|
||||||
|
ui,
|
||||||
|
IconKind::Instances,
|
||||||
|
title,
|
||||||
|
"",
|
||||||
|
action.into_iter().collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -170,6 +170,11 @@ pub enum Message {
|
|||||||
ReloadSaves,
|
ReloadSaves,
|
||||||
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
|
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
|
||||||
OpenSavesFolder,
|
OpenSavesFolder,
|
||||||
|
/// 重新扫描实例列表(M302):扫描超时那一屏的「重新扫描」。
|
||||||
|
///
|
||||||
|
/// 落点是 `AppCommand::RefreshInstances`(只重扫实例目录,不重拉版本清单)。这是**非模态**
|
||||||
|
/// 的恢复路径——没有它,线程真挂住时用户只能重启启动器。
|
||||||
|
RetryScan,
|
||||||
/// 开始**行内重命名**一个世界(M247):那一行就地换成输入框 + 确定/取消。
|
/// 开始**行内重命名**一个世界(M247):那一行就地换成输入框 + 确定/取消。
|
||||||
///
|
///
|
||||||
/// 为什么是行内而不是模态输入框:现成的模态队列有 `DialogKind::Input`,但 app 侧的
|
/// 为什么是行内而不是模态输入框:现成的模态队列有 `DialogKind::Input`,但 app 侧的
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ mod rename;
|
|||||||
/// M273 的**入口接线**(空态的「导入已有版本」⇒ `AdoptMessage::Open`)。
|
/// M273 的**入口接线**(空态的「导入已有版本」⇒ `AdoptMessage::Open`)。
|
||||||
mod empty_entry;
|
mod empty_entry;
|
||||||
|
|
||||||
|
/// M302 的**首屏三态**(占位 / 真空态 / 有实例:真控件树 + 文案 key 的源码断言)。
|
||||||
|
mod cold_boot;
|
||||||
|
|
||||||
use super::super::layout::source;
|
use super::super::layout::source;
|
||||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
//! M302 · 冷启动首帧的**三态**(复审 r1 P2-1 的判据)。
|
||||||
|
//!
|
||||||
|
//! ## 背景(为什么这组不是「措辞」而是缺陷)
|
||||||
|
//!
|
||||||
|
//! 扫盘搬进后台线程之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、
|
||||||
|
//! 不 `tick`),于是**首帧** `AppState::instances` 必然为空。改前那一帧画出的是:
|
||||||
|
//! 列头 `全部` + 计数 **0**、右栏「**还没有实例**」+「导入一个已经装在盘上的版本」**实心 CTA**
|
||||||
|
//! ——一句假话,而且那枚 CTA 是能点的。改后必须分成三态:**首个快照未到 / 确实零实例 / 有实例**。
|
||||||
|
//!
|
||||||
|
//! ## 判据怎么落在「真控件树」上(iced 0.14 的 `Element` 没有文本内省)
|
||||||
|
//!
|
||||||
|
//! 真控件树能数的是**节点**,不是文字内容,所以这组按两条腿走:
|
||||||
|
//!
|
||||||
|
//! | 要断的事 | 用什么 | 抓不住什么 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 未就绪时**没有**空态 CTA | 真树里 `Button` 节点的条数:未就绪 = 有实例那一屏,只有「确实零实例」多一枚 | 哪一枚(按条数判,不按文案判) |
|
||||||
|
//! | 超时那支**有**重试按钮 | 同上:比未就绪多一枚 | 点下去派了什么(`app/update/instances.rs` 的用例管) |
|
||||||
|
//! | 列表项 = N | 每多一行多 2 条文字节点(名字 + 版本),用两条测量做差分 | 行里写的是什么(模型断言 / 出图) |
|
||||||
|
//! | 占位在、且**不是**「还没有实例」 | 源码断言:`Scanning` 那一支调 `scan_placeholder(.., texts.scanning(), ..)`,不碰 `empty_none_*` / `adopt_cta` | 屏幕上的那句字(同上) |
|
||||||
|
//!
|
||||||
|
//! 基线数字(实测,`ScanPhase` 三态 × 1180×760):未就绪 12 条文字 / 7 枚按钮;超时 13 / 8;
|
||||||
|
//! 已就绪零实例 14 / 8;已就绪三行 19 / 7。按钮 7 里有 5 枚来自 `scrollable` 自带的滚动条,
|
||||||
|
//! 所以断言一律写成**态与态之间的差分**,不钉绝对值——加一条页头动作不该让这组红。
|
||||||
|
|
||||||
|
use iced::advanced::widget::tree::Tag;
|
||||||
|
use iced::advanced::widget::Tree;
|
||||||
|
use iced::widget::text;
|
||||||
|
use iced::Element;
|
||||||
|
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
use unlucky_design::typography::Platform;
|
||||||
|
use unlucky_presenter::dto::LaunchArgsView;
|
||||||
|
use unlucky_presenter::router::NavSection;
|
||||||
|
|
||||||
|
use crate::app::components::buttons::{self, Button};
|
||||||
|
use crate::pages::instances::LaunchForm;
|
||||||
|
use crate::pages::layout::source;
|
||||||
|
use crate::pages::model::{Instances, Row, ScanPhase};
|
||||||
|
use crate::pages::{app, Action, Message, Model, Screen};
|
||||||
|
use crate::theme::{Families, Ui};
|
||||||
|
|
||||||
|
const FAMILIES: Families = Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 一行实例在真控件树里贡献的**文字节点数**(headline 名字 + supporting 版本;`status: None` ⇒ 2)。
|
||||||
|
///
|
||||||
|
/// 为什么用「行的文字节点」数行数:`navigation::list_item` 的根是**通用容器**,它的 tree tag
|
||||||
|
/// 与页面上每个卡片相同(实测空列表也能数出 8 个),所以不能按 tag 数行。
|
||||||
|
/// 这条不变式本身也是判据:谁给一行加/减一段文字,下面的差分断言立刻红,逼着一起改这里。
|
||||||
|
const TEXTS_PER_ROW: usize = 2;
|
||||||
|
|
||||||
|
fn ui() -> Ui {
|
||||||
|
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form() -> LaunchForm {
|
||||||
|
LaunchForm::from_view(None, &LaunchArgsView::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一屏实例页模型(宽窗 = list-detail,右侧没有选中项)。
|
||||||
|
fn model(scan: ScanPhase, rows: Vec<Row>) -> Model {
|
||||||
|
let mut model = Model::new(
|
||||||
|
Screen::Instances,
|
||||||
|
1180.0,
|
||||||
|
760.0,
|
||||||
|
Some(NavSection::Instances),
|
||||||
|
);
|
||||||
|
model.instances = Instances {
|
||||||
|
rows,
|
||||||
|
detail: None,
|
||||||
|
scan,
|
||||||
|
};
|
||||||
|
model
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rows(count: usize) -> Vec<Row> {
|
||||||
|
(0..count)
|
||||||
|
.map(|index| Row {
|
||||||
|
id: format!("i{index}"),
|
||||||
|
name: format!("demo-{index}"),
|
||||||
|
supporting: "1.20.4".to_string(),
|
||||||
|
status: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 真控件树取样
|
||||||
|
|
||||||
|
fn tree_of(el: &Element<'_, Message>) -> Tree {
|
||||||
|
Tree::new(el.as_widget())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文字节点的标签(`text` 与包着它的 `container` 同 tag,见 iced 的 `container::tag` 委托)。
|
||||||
|
fn text_tag() -> Tag {
|
||||||
|
let probe: Element<'static, Message> = text("probe").into();
|
||||||
|
tree_of(&probe).tag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按钮节点的标签(页头两枚 `buttons::text`、空态那枚 `buttons::filled`、滚动条自带的按钮
|
||||||
|
/// 都算:判据要的正是「这一屏一共有几枚可点的按钮」)。
|
||||||
|
fn button_tag(ui: &Ui) -> Tag {
|
||||||
|
let probe = buttons::filled(ui, Button::new("probe", Message::Action(Action::Back)));
|
||||||
|
tree_of(&probe).tag
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_tag(tree: &Tree, tag: Tag) -> usize {
|
||||||
|
usize::from(tree.tag == tag)
|
||||||
|
+ tree
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.map(|child| count_tag(child, tag))
|
||||||
|
.sum::<usize>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一屏的 `(文字节点数, 按钮数)`。
|
||||||
|
fn counts(ui: &Ui, form: &LaunchForm, model: &Model) -> (usize, usize) {
|
||||||
|
let el = app(ui, model, form);
|
||||||
|
let tree = tree_of(&el);
|
||||||
|
(
|
||||||
|
count_tag(&tree, text_tag()),
|
||||||
|
count_tag(&tree, button_tag(ui)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ ① 首帧(无快照)
|
||||||
|
|
||||||
|
/// **首帧画的是占位,不是假空态**:没有空态 CTA、没有列表行、文案走 `scanning()`。
|
||||||
|
///
|
||||||
|
/// 抓得住:① `ScanPhase::Scanning` 那一支被删掉 / 落回 `detail_empty(.., false)`(复审判定的
|
||||||
|
/// 那屏假空态:「0 个实例」+「还没有实例」+ 实心 CTA);② 未就绪时被塞了行(每条实例多 2 条
|
||||||
|
/// 文字节点);③ 未就绪时计数位写了数字(多一条文字节点)。
|
||||||
|
/// 抓不住:屏幕上那句字长什么样(`Element` 无内省 ⇒ 文案 key 由 [`the_scanning_branch_never_touches_the_empty_state_copy`] 钉)。
|
||||||
|
#[test]
|
||||||
|
fn the_first_frame_is_a_placeholder_not_the_empty_state() {
|
||||||
|
let ui = ui();
|
||||||
|
let form = form();
|
||||||
|
|
||||||
|
let (scanning_texts, scanning_buttons) =
|
||||||
|
counts(&ui, &form, &model(ScanPhase::Scanning, Vec::new()));
|
||||||
|
let (empty_texts, empty_buttons) = counts(&ui, &form, &model(ScanPhase::Ready, Vec::new()));
|
||||||
|
let (rows_texts, rows_buttons) = counts(&ui, &form, &model(ScanPhase::Ready, rows(3)));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
scanning_buttons, rows_buttons,
|
||||||
|
"未就绪那一屏必须与「有实例」同构:**没有**空态那枚 CTA(按钮数相同 = 少的那枚不存在)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
empty_buttons,
|
||||||
|
scanning_buttons + 1,
|
||||||
|
"「确实零实例」比未就绪恰好多一枚按钮——那枚就是「导入已有版本」的 CTA"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
empty_texts,
|
||||||
|
scanning_texts + 2,
|
||||||
|
"已就绪空态比未就绪多两条文字(计数位 + 空态副文案),同时少了占位那两条里的… —— \
|
||||||
|
实际组合见文件头;差得不是 2 说明有一屏多/少了文字节点"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
scanning_texts < rows_texts,
|
||||||
|
"未就绪那屏比「有三条实例」少(每行两条文字:{} vs {})",
|
||||||
|
scanning_texts,
|
||||||
|
rows_texts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ ② 快照到达
|
||||||
|
|
||||||
|
/// **快照到达后占位消失、列表项 = N**。
|
||||||
|
///
|
||||||
|
/// 抓得住:`Ready` 分支没把行画出来;行被画成别的节点(`list_item` 一条 2 条文字的不变式被破坏)。
|
||||||
|
/// 抓不住:行的文字内容与排序(模型断言 / 出图)。
|
||||||
|
#[test]
|
||||||
|
fn a_landed_snapshot_replaces_the_placeholder_with_the_rows() {
|
||||||
|
let ui = ui();
|
||||||
|
let form = form();
|
||||||
|
|
||||||
|
let (one, _) = counts(&ui, &form, &model(ScanPhase::Ready, rows(1)));
|
||||||
|
let (three, _) = counts(&ui, &form, &model(ScanPhase::Ready, rows(3)));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
three - one,
|
||||||
|
2 * TEXTS_PER_ROW,
|
||||||
|
"三行比一行多 {} 条文字节点 ⇒ 每行 {TEXTS_PER_ROW} 条(名字 + 版本)。\
|
||||||
|
少了说明行没画出来,多了说明行里塞了别的东西",
|
||||||
|
2 * TEXTS_PER_ROW
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ ③ 确实零实例
|
||||||
|
|
||||||
|
/// **真·空数据根(扫完、零实例)画的是空态引导,不是「扫描中」**。
|
||||||
|
///
|
||||||
|
/// 与 ① 的区别只在 `scan` 一位:`Ready + 零行` 才是「还没有实例」那句话的合法场合。
|
||||||
|
/// 抓得住:把 `Ready` 也画成占位(用户永远看不到「去导入一个」的出路);把占位的文案接到空态 key 上
|
||||||
|
/// (① 的文字差分与本条的按钮差分一起红)。
|
||||||
|
/// 抓不住:`scan` 这一位在真后端里什么时候变成 `Ready`——那由 `app/update/instances.rs` 的
|
||||||
|
/// app 级用例(快照/超时/空快照三种事件)与 presenter 的 `refresh_tests` 用真空盘钉。
|
||||||
|
#[test]
|
||||||
|
fn a_finished_scan_with_zero_instances_shows_the_empty_guide() {
|
||||||
|
let ui = ui();
|
||||||
|
let form = form();
|
||||||
|
|
||||||
|
let (_, scanning_buttons) = counts(&ui, &form, &model(ScanPhase::Scanning, Vec::new()));
|
||||||
|
let (_, empty_buttons) = counts(&ui, &form, &model(ScanPhase::Ready, Vec::new()));
|
||||||
|
let (_, rows_buttons) = counts(&ui, &form, &model(ScanPhase::Ready, rows(2)));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
empty_buttons,
|
||||||
|
rows_buttons + 1,
|
||||||
|
"零实例 + 已就绪 ⇒ 比「有实例」多一枚按钮:那枚「导入已有版本」的 CTA 是此刻唯一的出路"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
empty_buttons,
|
||||||
|
scanning_buttons + 1,
|
||||||
|
"同一枚 CTA 在未就绪时**不**存在(那是复审 P2-1 的假话:还没扫完就劝你去导入)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ ④ 超时那支(有界兜底)
|
||||||
|
|
||||||
|
/// **超时那屏有一枚可点的「重新扫描」**(有界兜底:线程 panic / 卡在慢盘时用户还有出路)。
|
||||||
|
///
|
||||||
|
/// 抓得住:`Stalled` 那一支退化成普通占位(没有重试 ⇒ 用户只能重启启动器)。
|
||||||
|
/// 抓不住:点下去派了什么(`app/update/instances.rs` 的 `retry_scan_dispatches_a_scan_only_refresh`)。
|
||||||
|
#[test]
|
||||||
|
fn the_stalled_screen_offers_a_retry() {
|
||||||
|
let ui = ui();
|
||||||
|
let form = form();
|
||||||
|
|
||||||
|
let (scanning_texts, scanning_buttons) =
|
||||||
|
counts(&ui, &form, &model(ScanPhase::Scanning, Vec::new()));
|
||||||
|
let (stalled_texts, stalled_buttons) =
|
||||||
|
counts(&ui, &form, &model(ScanPhase::Stalled, Vec::new()));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
stalled_buttons,
|
||||||
|
scanning_buttons + 1,
|
||||||
|
"超时那一屏必须多一枚按钮(「重新扫描」)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stalled_texts,
|
||||||
|
scanning_texts + 1,
|
||||||
|
"那枚按钮自带一条文字(「重新扫描」),其余与占位一致"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ ⑤ 源码结构(文案 key)
|
||||||
|
|
||||||
|
/// **扫描中那一支不许碰空态的文案与动作**(`Element` 无内省 ⇒ 文案身份只能读源码)。
|
||||||
|
///
|
||||||
|
/// 抓得住:`scan_placeholder` 里混进 `empty_none_*` / `adopt_cta` / `adopt_action`(把假话搬回来);
|
||||||
|
/// `detail_side` 把 `Ready` 以外的东西交给 `detail_empty`(未就绪又走回假空态);
|
||||||
|
/// 取词换到别的 key(占位会显示成空行或错文案)。
|
||||||
|
/// 抓不住:真的画出来的像素(出图那张)。
|
||||||
|
#[test]
|
||||||
|
fn the_scanning_branch_never_touches_the_empty_state_copy() {
|
||||||
|
// 分派与占位在 `detail/scan.rs`(从 `detail.rs` 拆出去守 400 行硬线)
|
||||||
|
let scan = include_str!("../detail/scan.rs");
|
||||||
|
|
||||||
|
let dispatch = source::code_text(source::function_body(scan, "fn detail_side"));
|
||||||
|
let scanning_at = dispatch.find("ScanPhase::Scanning").expect("扫描中那一支");
|
||||||
|
let stalled_at = dispatch.find("ScanPhase::Stalled").expect("超时那一支");
|
||||||
|
let ready_at = dispatch.find("ScanPhase::Ready").expect("就绪那一支");
|
||||||
|
assert!(
|
||||||
|
scanning_at < stalled_at && stalled_at < ready_at,
|
||||||
|
"三态必须按 `scan` 分派(顺序 = 判据:未就绪先于「有没有行」):\n{dispatch}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!dispatch[..ready_at].contains("detail_empty"),
|
||||||
|
"未就绪的两支**不许**落到 `detail_empty`(那会把「还没扫完」画成「还没有实例」):\n{dispatch}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dispatch[ready_at..].contains("detail_empty"),
|
||||||
|
"`Ready` 那一支必须落到 `detail_empty`(零实例的引导在那里):\n{dispatch}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dispatch[..ready_at].contains("InstanceMessage::RetryScan"),
|
||||||
|
"超时那一支必须接上「重新扫描」(否则那枚按钮点了没反应):\n{dispatch}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let placeholder = source::code_text(source::function_body(scan, "fn scan_placeholder"));
|
||||||
|
for forbidden in [
|
||||||
|
"empty_none_title",
|
||||||
|
"empty_none_body",
|
||||||
|
"adopt_cta",
|
||||||
|
"adopt_action",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!placeholder.contains(forbidden),
|
||||||
|
"占位里混进了 `{forbidden}`(那属于「确实零实例」那一支):\n{placeholder}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取词入口:扫描中/超时读的是 M302 新加的三个 key,不是空态那两个
|
||||||
|
let texts = include_str!("../text.rs");
|
||||||
|
for (method, key) in [
|
||||||
|
("fn scanning", "Key::InstancesScanning"),
|
||||||
|
("fn scan_stalled", "Key::InstancesScanStalled"),
|
||||||
|
("fn scan_retry", "Key::InstancesScanRetry"),
|
||||||
|
] {
|
||||||
|
let body = source::code_text(source::function_body(texts, method));
|
||||||
|
assert!(
|
||||||
|
body.contains(key),
|
||||||
|
"`{method}` 没读 `{key}`(占位会显示成空行或错文案):\n{body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,35 +121,46 @@ fn the_cta_lives_only_in_the_no_instances_branch() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **调用点**:实例页量「有没有实例」的那一行不许传反。
|
/// **调用点**:实例页把「没选中时画什么」交给三态分派,宽窗 / 窄窗各一处。
|
||||||
///
|
///
|
||||||
/// 这是 r1 复审的变异原样:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`,上面的
|
/// 历史:M273 r1 复审的变异是把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`——分支
|
||||||
/// 分支断言照样绿(两个分支各自都还对),但**用户看到的是错的那一支**。所以判据必须落在
|
/// 断言照样绿,但**用户看到的是错的那一支**,所以判据必须落在调用点上。M302 之后「有没有实例」
|
||||||
/// 调用点上——宽窗与窄窗各有一条,两条都要有 `!`。
|
/// 那一句从调用点挪进了 [`detail_side`],于是这条判据一分为二:**调用点**(两处、都传 `block`)
|
||||||
|
/// 与**分派点**(`Ready` 那一支自己带 `!`)。
|
||||||
///
|
///
|
||||||
/// 抓得住:传参取反(`!` 被删);只改掉一处(宽窗对了、窄窗忘了);新增第三条调用点却传了别的
|
/// 抓得住:只改掉一处(宽窗接了、窄窗忘了);新增第三条调用点却传了别的值;`!` 从分派点丢掉。
|
||||||
/// 值(条数断言只认 2 条,多出来就红)。
|
|
||||||
/// 抓不住:等价写法(`block.rows.len() == 0`)——本仓构建链一行一条,判据按行首取。
|
/// 抓不住:等价写法(`block.rows.len() == 0`)——本仓构建链一行一条,判据按行首取。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_page_asks_the_empty_state_with_the_negated_row_count() {
|
fn the_page_asks_the_three_state_dispatch_from_both_call_sites() {
|
||||||
let page = include_str!("../../instances.rs");
|
let page = include_str!("../../instances.rs");
|
||||||
let calls: Vec<String> = source::code_lines(page)
|
let calls: Vec<String> = source::code_lines(page)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|line| line.contains("detail_empty("))
|
.filter(|line| line.contains("detail_side("))
|
||||||
.map(|line| line.trim().to_string())
|
.map(|line| line.trim().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
calls.len(),
|
calls.len(),
|
||||||
2,
|
2,
|
||||||
"实例页应当只有两条空态调用点(宽窗 + 窄窗),实际:{calls:?}"
|
"实例页应当只有两条「没选中时画什么」的调用点(宽窗 + 窄窗),实际:{calls:?}"
|
||||||
);
|
);
|
||||||
for line in &calls {
|
for line in &calls {
|
||||||
assert!(
|
assert!(
|
||||||
line.contains("!block.rows.is_empty()"),
|
line.contains("(ui, block, texts)"),
|
||||||
"调用点把「有没有实例」传反了(`!` 丢了 ⇒ 一个都没有时反而没有按钮):{line}"
|
"调用点要把整块模型交给三态分派(自己判 `rows` 就又把三态塌成两态了):{line}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分派点:「有没有实例」那一位带着 `!`,且只在 `Ready` 那一支
|
||||||
|
let detail = source::code_text(source::function_body(
|
||||||
|
include_str!("../detail/scan.rs"),
|
||||||
|
"fn detail_side",
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
detail.contains("!block.rows.is_empty()"),
|
||||||
|
"`detail_side` 的 `Ready` 那一支必须带 `!`(取反丢了 ⇒ 一个都没有时反而没有按钮):
|
||||||
|
{detail}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `pages::Action::Import` → `AdoptMessage::Open`:跨层的这一跳不许被拆。
|
/// `pages::Action::Import` → `AdoptMessage::Open`:跨层的这一跳不许被拆。
|
||||||
|
|||||||
@@ -54,9 +54,14 @@ const ALLOWED: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||||
const PAGE_SOURCES: [(&str, &str); 12] = [
|
const PAGE_SOURCES: [(&str, &str); 13] = [
|
||||||
("instances.rs", include_str!("../../instances.rs")),
|
("instances.rs", include_str!("../../instances.rs")),
|
||||||
("instances/detail.rs", include_str!("../detail.rs")),
|
("instances/detail.rs", include_str!("../detail.rs")),
|
||||||
|
// M302:首屏三态的分派与占位从 detail.rs 拆出(那边越过 400 行)——新文件必须一起扫
|
||||||
|
(
|
||||||
|
"instances/detail/scan.rs",
|
||||||
|
include_str!("../detail/scan.rs"),
|
||||||
|
),
|
||||||
("instances/demo.rs", include_str!("../demo.rs")),
|
("instances/demo.rs", include_str!("../demo.rs")),
|
||||||
("instances/text.rs", include_str!("../text.rs")),
|
("instances/text.rs", include_str!("../text.rs")),
|
||||||
// M251:存档 / 截图一族从 text.rs 拆出(那边越过 400 行)——新文件必须一起扫,
|
// M251:存档 / 截图一族从 text.rs 拆出(那边越过 400 行)——新文件必须一起扫,
|
||||||
|
|||||||
@@ -97,6 +97,20 @@ impl Texts {
|
|||||||
pub(super) fn adopt_cta(self) -> &'static str {
|
pub(super) fn adopt_cta(self) -> &'static str {
|
||||||
self.text(Key::InstancesAdoptCta)
|
self.text(Key::InstancesAdoptCta)
|
||||||
}
|
}
|
||||||
|
/// 首屏占位(M302):首个实例快照还没到。
|
||||||
|
///
|
||||||
|
/// 与 [`Self::empty_none_title`] 是**两句话**:那句是「确实一个都没有」,这句是「还在读盘」。
|
||||||
|
pub(super) fn scanning(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesScanning)
|
||||||
|
}
|
||||||
|
/// 首屏占位(M302):扫描超时(线程没回来),配一个可点的重试。
|
||||||
|
pub(super) fn scan_stalled(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesScanStalled)
|
||||||
|
}
|
||||||
|
/// 超时那支的主行动(重新扫一遍实例目录)。
|
||||||
|
pub(super) fn scan_retry(self) -> &'static str {
|
||||||
|
self.text(Key::InstancesScanRetry)
|
||||||
|
}
|
||||||
/// 自动发现实例的运行目录规则(详情页常驻的一句话)。
|
/// 自动发现实例的运行目录规则(详情页常驻的一句话)。
|
||||||
pub(super) fn discovered_note(self) -> &'static str {
|
pub(super) fn discovered_note(self) -> &'static str {
|
||||||
self.text(Key::InstancesDiscoveredNote)
|
self.text(Key::InstancesDiscoveredNote)
|
||||||
|
|||||||
@@ -91,6 +91,37 @@ pub struct LaunchFacts {
|
|||||||
pub options: Vec<String>,
|
pub options: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 实例列表的**首屏三态**(M302):区分「还没扫完」和「确实一个都没有」。
|
||||||
|
///
|
||||||
|
/// 页面**不许**用 `rows.is_empty()` 猜状态(复审 r1 P2-1 的假空态就是这么来的:冷启动首帧
|
||||||
|
/// 列表必然为空,猜出来就是「还没有实例」+ 一个导入 CTA,而那句话是假的)。
|
||||||
|
/// 真源是 presenter 的 `dto::InstancePhase`,转换只有 [`ScanPhase::of`] 一处。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum ScanPhase {
|
||||||
|
/// 首个快照还没到 ⇒ 画占位(不写计数、不画空态 CTA)
|
||||||
|
Scanning,
|
||||||
|
/// 超时兜底 ⇒ 占位 + 可点的「重新扫描」
|
||||||
|
Stalled,
|
||||||
|
/// 收到过快照 ⇒ `rows` 就是真数据(可能是空,那才轮到「还没有实例」)
|
||||||
|
#[default]
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScanPhase {
|
||||||
|
/// presenter 的快照位 → 页面三态(**唯一**转换点,`app/view.rs` 只调它)。
|
||||||
|
///
|
||||||
|
/// 默认值 `Ready` 是故意的:演示数据 / 空壳模型(`Model::new`)没有「正在扫」这回事,
|
||||||
|
/// 它们必须保持改前的行为(`pages/tests.rs` 的构图用例都从空壳起步)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn of(phase: unlucky_presenter::dto::InstancePhase) -> Self {
|
||||||
|
match phase {
|
||||||
|
unlucky_presenter::dto::InstancePhase::Scanning => Self::Scanning,
|
||||||
|
unlucky_presenter::dto::InstancePhase::Stalled => Self::Stalled,
|
||||||
|
unlucky_presenter::dto::InstancePhase::Ready => Self::Ready,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 实例页要画的东西。
|
/// 实例页要画的东西。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct Instances {
|
pub struct Instances {
|
||||||
@@ -98,6 +129,8 @@ pub struct Instances {
|
|||||||
pub rows: Vec<Row>,
|
pub rows: Vec<Row>,
|
||||||
/// 当前选中的实例详情(`None` = 没选中 → 空态)
|
/// 当前选中的实例详情(`None` = 没选中 → 空态)
|
||||||
pub detail: Option<Detail>,
|
pub detail: Option<Detail>,
|
||||||
|
/// 首屏三态(M302):只有 `Ready` 才允许把「零行」解释成「还没有实例」
|
||||||
|
pub scan: ScanPhase,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一屏的视图模型。
|
/// 一屏的视图模型。
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
|||||||
("InstancesDiscoveredRunShared", "Detected instance: it has no mods/ or saves/ of its own, so this launch uses the .minecraft folder (shared).", "自动发现的实例:它的版本目录里没有 mods/ 或 saves/,这次启动按共用目录运行(.minecraft 根目录)。"),
|
("InstancesDiscoveredRunShared", "Detected instance: it has no mods/ or saves/ of its own, so this launch uses the .minecraft folder (shared).", "自动发现的实例:它的版本目录里没有 mods/ 或 saves/,这次启动按共用目录运行(.minecraft 根目录)。"),
|
||||||
("InstancesDiscoveredRunIsolated", "Detected instance: it already has its own mods/ or saves/, so this launch uses its version folder (isolated).", "自动发现的实例:它的版本目录里已经有 mods/ 或 saves/,这次启动按独立目录运行(它自己的版本文件夹)。"),
|
("InstancesDiscoveredRunIsolated", "Detected instance: it already has its own mods/ or saves/, so this launch uses its version folder (isolated).", "自动发现的实例:它的版本目录里已经有 mods/ 或 saves/,这次启动按独立目录运行(它自己的版本文件夹)。"),
|
||||||
("InstancesSavesSource", "Saves folder: {dir}", "存档目录:{dir}"),
|
("InstancesSavesSource", "Saves folder: {dir}", "存档目录:{dir}"),
|
||||||
|
("InstancesScanning", "Scanning instances…", "正在扫描实例…"),
|
||||||
|
("InstancesScanStalled", "The instance scan is taking longer than usual.", "扫描实例比平时久。"),
|
||||||
|
("InstancesScanRetry", "Scan again", "重新扫描"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||||
|
|||||||
@@ -236,6 +236,10 @@ macro_rules! instances_entries {
|
|||||||
// 两个根都可能有一个叫 `A` 的实例、各有一批世界。世界名本身分辨不出来源,
|
// 两个根都可能有一个叫 `A` 的实例、各有一批世界。世界名本身分辨不出来源,
|
||||||
// 所以这份列表把**绝对路径**摆出来:那是唯一不会说谎的来源标识(也是排障要的东西)。
|
// 所以这份列表把**绝对路径**摆出来:那是唯一不会说谎的来源标识(也是排障要的东西)。
|
||||||
InstancesSavesSource => ("Saves folder: {dir}", "存档目录:{dir}"),
|
InstancesSavesSource => ("Saves folder: {dir}", "存档目录:{dir}"),
|
||||||
|
// ── 首屏三态(M302):首个实例快照未到 / 超时 / 已就绪(就绪态沿用上面几条空态与列表文案)
|
||||||
|
InstancesScanning => ("Scanning instances…", "正在扫描实例…"),
|
||||||
|
InstancesScanStalled => ("The instance scan is taking longer than usual.", "扫描实例比平时久。"),
|
||||||
|
InstancesScanRetry => ("Scan again", "重新扫描"),
|
||||||
} }
|
} }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use crate::dto::{
|
|||||||
DiagnosisReport,
|
DiagnosisReport,
|
||||||
InstallProgressView,
|
InstallProgressView,
|
||||||
InstanceId,
|
InstanceId,
|
||||||
|
InstancePhase,
|
||||||
InstanceRuntime,
|
InstanceRuntime,
|
||||||
InstanceSummary,
|
InstanceSummary,
|
||||||
JavaPageView,
|
JavaPageView,
|
||||||
@@ -93,6 +94,13 @@ pub struct LaunchFinished {
|
|||||||
pub enum CoreEvent {
|
pub enum CoreEvent {
|
||||||
/// 实例列表整体刷新
|
/// 实例列表整体刷新
|
||||||
InstancesChanged(Vec<InstanceSummary>),
|
InstancesChanged(Vec<InstanceSummary>),
|
||||||
|
/// 实例列表的首屏三态变化(M302)。
|
||||||
|
///
|
||||||
|
/// 只发「没就绪」的两态:`Scanning` = 新一轮扫描起跑(冷启动 / 点了「重新扫描」),
|
||||||
|
/// `Stalled` = 起跑后超过 `host::game::SCAN_STALL`(15 s)仍没快照。
|
||||||
|
/// **`Ready` 不从这里发**:收到 [`CoreEvent::InstancesChanged`] 本身就是就绪的判据
|
||||||
|
/// (一条通道一个含义,免得「快照到了但没就绪」这种不可能态被写出来)。
|
||||||
|
InstancePhaseChanged(InstancePhase),
|
||||||
/// 单个实例运行时状态变化(启动进度/运行中/失败)
|
/// 单个实例运行时状态变化(启动进度/运行中/失败)
|
||||||
InstanceRuntimeChanged {
|
InstanceRuntimeChanged {
|
||||||
/// 实例 id
|
/// 实例 id
|
||||||
@@ -198,6 +206,11 @@ pub enum AppEvent {
|
|||||||
pub enum AppCommand {
|
pub enum AppCommand {
|
||||||
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
|
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
|
||||||
RefreshAll,
|
RefreshAll,
|
||||||
|
/// **只**重扫实例目录(M302:「扫描超时 · 重新扫描」按钮)。
|
||||||
|
///
|
||||||
|
/// 与 [`AppCommand::RefreshAll`] 的区别:不重拉版本清单(一条网络请求)、不重读账户 /
|
||||||
|
/// 设置 / Java / 更新快照——用户点的只是「把列表再扫一遍」。
|
||||||
|
RefreshInstances,
|
||||||
/// 启动实例
|
/// 启动实例
|
||||||
LaunchInstance(InstanceId),
|
LaunchInstance(InstanceId),
|
||||||
/// 停止实例(含取消启动中的管线)
|
/// 停止实例(含取消启动中的管线)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
//! 路径对外**一个字没变**(`unlucky_presenter::dto::InstanceSummary` 等仍从这里取),
|
//! 路径对外**一个字没变**(`unlucky_presenter::dto::InstanceSummary` 等仍从这里取),
|
||||||
//! 拆的是文件不是 API。
|
//! 拆的是文件不是 API。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
mod create;
|
mod create;
|
||||||
mod loader;
|
mod loader;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
@@ -41,3 +43,33 @@ pub const DISCOVERED_ID_PREFIX: &str = "ext-";
|
|||||||
pub fn is_discovered_instance(id: &str) -> bool {
|
pub fn is_discovered_instance(id: &str) -> bool {
|
||||||
id.starts_with(DISCOVERED_ID_PREFIX)
|
id.starts_with(DISCOVERED_ID_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 实例列表的**首屏三态**(M302):区分「还没扫完」和「确实一个都没有」。
|
||||||
|
///
|
||||||
|
/// ## 为什么必须要这一位(复审 r1 P2-1)
|
||||||
|
///
|
||||||
|
/// 扫盘搬进后台线程之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、
|
||||||
|
/// 不 `tick`),所以**首帧**的 `AppState::instances` 是空的。用「`instances.is_empty()`」去猜
|
||||||
|
/// 状态就必然把「还没扫完」画成「还没有实例」——一句假话,还配一个「导入已有版本」的实心 CTA
|
||||||
|
/// (用户可能真去点)。真源必须是 host 自己知道的那一位,不是列表长度。
|
||||||
|
///
|
||||||
|
/// ## 语义(三态都要能一句话说清)
|
||||||
|
///
|
||||||
|
/// | 值 | 什么时候是它 | 界面 |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | [`Self::Scanning`] | **初值**:这一进程还没有任何一份实例快照落地 | 占位「正在扫描实例…」,不写计数、不画空态 CTA |
|
||||||
|
/// | [`Self::Stalled`] | 扫描起跑后超过 `host::game::SCAN_STALL` 仍没有快照(线程 panic / 卡在慢盘) | 占位 + 「重新扫描」按钮 |
|
||||||
|
/// | [`Self::Ready`] | 收到过至少一份 `CoreEvent::InstancesChanged` | 列表(空的也行——那就是真数据) |
|
||||||
|
///
|
||||||
|
/// **`Ready` 不表示「列表非空」**,也**不表示「扫描成功」**(没有失败态:扫描不返回错误,
|
||||||
|
/// 只有「还没回来」):空列表 + `Ready` = 确实没有实例,那时才画「还没有实例」。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
|
||||||
|
pub enum InstancePhase {
|
||||||
|
/// 首个快照还没到(进程刚起来,或刚点过「重新扫描」)
|
||||||
|
#[default]
|
||||||
|
Scanning,
|
||||||
|
/// 首个快照迟迟没到(有界兜底,见 `host::game::SCAN_STALL`)
|
||||||
|
Stalled,
|
||||||
|
/// 收到过快照:列表内容就是真数据(可能是空)
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
@@ -61,9 +61,9 @@ pub use install::{
|
|||||||
InstallStage,
|
InstallStage,
|
||||||
};
|
};
|
||||||
pub use instances::{
|
pub use instances::{
|
||||||
is_discovered_instance, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary,
|
is_discovered_instance, CreateRequest, InstanceId, InstancePhase, InstanceRuntime,
|
||||||
LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind,
|
InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage,
|
||||||
Progress, DISCOVERED_ID_PREFIX,
|
LoaderInfo, LoaderKind, Progress, DISCOVERED_ID_PREFIX,
|
||||||
};
|
};
|
||||||
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||||
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
|
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ impl CoreHost {
|
|||||||
fn dispatch_inner(&mut self, command: AppCommand) {
|
fn dispatch_inner(&mut self, command: AppCommand) {
|
||||||
match command {
|
match command {
|
||||||
AppCommand::RefreshAll => self.refresh_all(),
|
AppCommand::RefreshAll => self.refresh_all(),
|
||||||
|
// M302:「扫描超时 · 重新扫描」只重扫实例目录(不重拉版本清单、不重读账户/设置)
|
||||||
|
AppCommand::RefreshInstances => self.refresh_instances(),
|
||||||
AppCommand::LaunchInstance(id) => self.start_launch(&id),
|
AppCommand::LaunchInstance(id) => self.start_launch(&id),
|
||||||
AppCommand::StopInstance(id) => match self.running_pid(&id) {
|
AppCommand::StopInstance(id) => match self.running_pid(&id) {
|
||||||
Some(pid) => match launch::stop(pid) {
|
Some(pid) => match launch::stop(pid) {
|
||||||
|
|||||||
@@ -17,11 +17,29 @@
|
|||||||
//! 提前掐断它省下的只是它自己那点 CPU,而掐断的代价是一个可能永远不回来的快照。
|
//! 提前掐断它省下的只是它自己那点 CPU,而掐断的代价是一个可能永远不回来的快照。
|
||||||
//! 于是一条路口:**不取消线程,只丢弃过期结果**(丢掉的是那一份,不是一个状态)。
|
//! 于是一条路口:**不取消线程,只丢弃过期结果**(丢掉的是那一份,不是一个状态)。
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::dto::{CoreEvent, InstancePhase};
|
||||||
|
|
||||||
use super::super::job::{spawn, HostTask, JobSender};
|
use super::super::job::{spawn, HostTask, JobSender};
|
||||||
use super::super::HostEnv;
|
use super::super::HostEnv;
|
||||||
|
|
||||||
|
/// 首个实例快照的等待上限(M302 的**有界兜底**)。
|
||||||
|
///
|
||||||
|
/// ## 取值依据(不是随手挑的)
|
||||||
|
///
|
||||||
|
/// 仓库内最坏的一次实测扫盘是 **1.35 s**(官方根 200 个版本 + 10 份外部档案,M297/M274 r1);
|
||||||
|
/// 15 s ≈ 它的 11 倍,足够盖住慢盘 / 网络盘上的一次 `read_dir` 抖动,又不至于让用户在
|
||||||
|
/// 「正在扫描实例…」上等到放弃。真正该防的不是「慢一点」,而是**线程 panic / 永远不回来**——
|
||||||
|
/// 那种情况下没有兜底,占位永远不会消失。
|
||||||
|
///
|
||||||
|
/// ## 超时之后发生什么
|
||||||
|
///
|
||||||
|
/// **不取消扫描线程**(理由见模块文档):面板先翻到「扫描实例比平时久」+ 可点的「重新扫描」,
|
||||||
|
/// 而那一轮的线程仍在跑、迟到的结果照样落地(代次没变,`admit` 照放)⇒ 占位自己消失。
|
||||||
|
/// 只有「永远不回来」那种才需要用户点重试。
|
||||||
|
pub const SCAN_STALL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// 一次刷新领到的代次(`0` 是「还没刷新过」的初值,第一次刷新从 `1` 开始)。
|
/// 一次刷新领到的代次(`0` 是「还没刷新过」的初值,第一次刷新从 `1` 开始)。
|
||||||
pub type Epoch = u64;
|
pub type Epoch = u64;
|
||||||
|
|
||||||
@@ -35,8 +53,19 @@ pub struct Scan {
|
|||||||
epoch: Epoch,
|
epoch: Epoch,
|
||||||
/// 最近一次被采纳的扫描
|
/// 最近一次被采纳的扫描
|
||||||
adopted: Option<Adopted>,
|
adopted: Option<Adopted>,
|
||||||
/// 被丢掉的**旧代次**结果条数(> 0 就证明「先发后到」真的发生过)
|
/// 被丢掉的**旧代次**结果条数(> 0 就说明「先发后到」真的发生过)
|
||||||
|
///
|
||||||
|
/// **已知噪音(复审 N3)**:`CoreHost::build` 的 `refresh_all` 与 `on_boot` 的
|
||||||
|
/// `AppCommand::RefreshAll` 各领一代 ⇒ **每次冷启动必然丢 1 份**。所以这个计数在人读日志上
|
||||||
|
/// 必须配合「此前有没有快照落地过」一起看(`handle::apply_instances` 的丢弃日志已经把两种
|
||||||
|
/// 成因分开写),单看「> 0」会误判成交错。
|
||||||
dropped: u64,
|
dropped: u64,
|
||||||
|
/// 本轮扫描的起跑时刻(超时兜底的唯一时钟来源;`None` = 结果已经落地 / 还没起跑)
|
||||||
|
started: Option<Instant>,
|
||||||
|
/// 本轮是否已经报过超时(`tick` 每拍都会问,只报一次,否则面板/日志会被刷成噪音)
|
||||||
|
stalled: bool,
|
||||||
|
/// 本轮的超时上限(生产恒为 [`SCAN_STALL`];单测可以调小,不必等真实的 15 s)
|
||||||
|
budget: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一次被采纳的扫描:代次、实例数、纯扫盘耗时(毫秒)。
|
/// 一次被采纳的扫描:代次、实例数、纯扫盘耗时(毫秒)。
|
||||||
@@ -56,10 +85,43 @@ impl Scan {
|
|||||||
/// 只增不减(`wrapping_add` 与 `install_epoch` 同款):**代次相等才是当代次**,
|
/// 只增不减(`wrapping_add` 与 `install_epoch` 同款):**代次相等才是当代次**,
|
||||||
/// 于是「上一轮还在飞的那份」无论什么时候回来都进不了门。
|
/// 于是「上一轮还在飞的那份」无论什么时候回来都进不了门。
|
||||||
pub fn begin(&mut self) -> Epoch {
|
pub fn begin(&mut self) -> Epoch {
|
||||||
|
self.begin_with(SCAN_STALL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`Scan::begin`],但显式给上限(单测用;生产只有上面那一个入口)。
|
||||||
|
pub fn begin_with(&mut self, budget: Duration) -> Epoch {
|
||||||
self.epoch = self.epoch.wrapping_add(1);
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
|
self.started = Some(Instant::now());
|
||||||
|
self.stalled = false;
|
||||||
|
self.budget = budget;
|
||||||
self.epoch
|
self.epoch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 单测专用:把**当前这一轮**的上限调小(生产路径的上限只由 [`SCAN_STALL`] 定)。
|
||||||
|
///
|
||||||
|
/// 存在的理由:超时路径要能在单测里真跑一遍(事件发出去 / 只发一次 / 迟到结果清掉它),
|
||||||
|
/// 而等 15 s 会让整个门禁慢十几秒。**调小不等于换阈值**:阈值本身仍只有 [`SCAN_STALL`]。
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn shorten_budget(&mut self, budget: Duration) {
|
||||||
|
self.budget = budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一轮**还要不要**报超时(有界兜底,判据是 [`SCAN_STALL`])。
|
||||||
|
///
|
||||||
|
/// `true` 只在「有扫描在飞 + 已经超过上限 + 这一轮还没报过」时返回一次;报过之后
|
||||||
|
/// 每拍再问都是 `false`(`tick` 每帧都会问,重复报会把面板刷成噪音)。
|
||||||
|
/// 结果落地([`Scan::adopt`])会停表,之后永远 `false`。
|
||||||
|
pub fn stall(&mut self) -> bool {
|
||||||
|
let Some(started) = self.started else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if self.stalled || started.elapsed() < self.budget {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.stalled = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// 这份结果还是不是当代次的?不是的话记一笔丢包(可观测)并返回 `false`。
|
/// 这份结果还是不是当代次的?不是的话记一笔丢包(可观测)并返回 `false`。
|
||||||
///
|
///
|
||||||
/// 判据是**相等**而不是「更大」:代次是唯一的命名,只有最新一轮发出去的那份作数。
|
/// 判据是**相等**而不是「更大」:代次是唯一的命名,只有最新一轮发出去的那份作数。
|
||||||
@@ -73,6 +135,8 @@ impl Scan {
|
|||||||
|
|
||||||
/// 记下这一份(只在 [`Scan::admit`] 放行之后调)。
|
/// 记下这一份(只在 [`Scan::admit`] 放行之后调)。
|
||||||
pub fn adopt(&mut self, epoch: Epoch, count: usize, elapsed_ms: u64) {
|
pub fn adopt(&mut self, epoch: Epoch, count: usize, elapsed_ms: u64) {
|
||||||
|
// 落地即停表:之后的 `tick` 不该再报超时
|
||||||
|
self.started = None;
|
||||||
self.adopted = Some(Adopted {
|
self.adopted = Some(Adopted {
|
||||||
epoch,
|
epoch,
|
||||||
count,
|
count,
|
||||||
@@ -114,8 +178,25 @@ pub fn spawn_scan(env: HostEnv, tx: JobSender, epoch: Epoch) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl super::super::CoreHost {
|
||||||
|
/// 每拍看一眼「首个快照」有没有超时(有界兜底)。
|
||||||
|
///
|
||||||
|
/// 为什么要兜底:扫描线程若 panic 或卡在慢盘上,`ready` 就永远是 `false` ⇒ 用户永远看着
|
||||||
|
/// 「正在扫描实例…」。这里只**报**超时(面板翻到可点的「重新扫描」),**不**掐线程——
|
||||||
|
/// 判据与取值理由在 [`Scan::stall`] / [`SCAN_STALL`]。
|
||||||
|
pub(crate) fn check_scan_stall(&mut self) {
|
||||||
|
if !self.instances_scan.stall() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"[instances] 首个快照 {SCAN_STALL:?} 内没回来:报超时(扫描线程仍在跑,迟到的结果照样落地)"
|
||||||
|
);
|
||||||
|
self.send(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单测要 `tick` 抽通道,它的定义在 `crate::dto::Backend` 这条 trait 上。
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
// 单测要 `tick` 抽通道,它的定义在 `crate::dto::Backend` 这条 trait 上。
|
|
||||||
use crate::dto::Backend;
|
use crate::dto::Backend;
|
||||||
|
|
||||||
/// 单测等待「这一代快照落地」的上限。
|
/// 单测等待「这一代快照落地」的上限。
|
||||||
@@ -164,6 +245,10 @@ impl super::super::CoreHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "refresh_stall_tests.rs"]
|
||||||
|
mod stall_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "refresh_tests.rs"]
|
#[path = "refresh_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//! 空数据根 / 超时兜底(M302 的第二组:`Scan` 的落地与 stall 判据)。
|
||||||
|
//!
|
||||||
|
//! 从 `refresh_tests.rs` 拆出来守 400 行硬线(那边是三条**交错**用例 + 账本单测)。
|
||||||
|
//! 这一组验的是另外两件事:
|
||||||
|
//!
|
||||||
|
//! | 用例 | 抓得住 | 抓不住 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`an_empty_root_publishes_an_empty_snapshot`] | 把「零实例」当成「没扫完」(界面永远停在「正在扫描」),或者反过来在没扫完时发空快照 | 界面上这两态怎么画(`pages/instances/tests/cold_boot.rs`) |
|
||||||
|
//! | [`a_stalled_scan_reports_once_and_a_late_snapshot_still_lands`] | `tick` 不检查超时 / 每拍都报 / 报完把在飞的结果丢掉 | 真实 15 s 阈值合不合适(那要真盘压测) |
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::tests::{host_with, next_scan, snapshots};
|
||||||
|
use super::SCAN_STALL;
|
||||||
|
use crate::dto::{AppEvent, Backend, CoreEvent, InstancePhase};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 空数据根 / 超时兜底
|
||||||
|
|
||||||
|
/// **真·空数据根**:扫完就是一份**空快照**,而且这一代算「已经落地」。
|
||||||
|
///
|
||||||
|
/// 抓得住:把「零实例」误当成「没扫完」(`adopted()` 为空 ⇒ 界面永远停在「正在扫描」);
|
||||||
|
/// 或者反过来在没扫完时发一份空快照(那正是任务禁止的假空态)。
|
||||||
|
/// 抓不住:界面上那两种状态怎么画(`pages/instances/tests/cold_boot.rs` 的控件树 + 出图)。
|
||||||
|
#[test]
|
||||||
|
fn an_empty_root_publishes_an_empty_snapshot() {
|
||||||
|
let (mut host, _env, _ids, _guard) = host_with("empty-root", 0);
|
||||||
|
let _ = host.drain_events();
|
||||||
|
|
||||||
|
host.refresh_instances();
|
||||||
|
host.pump_instances();
|
||||||
|
|
||||||
|
let published = snapshots(host.drain_events());
|
||||||
|
assert_eq!(
|
||||||
|
published.len(),
|
||||||
|
1,
|
||||||
|
"空根也要发一份快照(不发就永远不知道扫完了)"
|
||||||
|
);
|
||||||
|
assert!(published[0].is_empty(), "零档案的根扫出来就是空");
|
||||||
|
assert_eq!(
|
||||||
|
host.instances_scan.adopted().map(|adopted| adopted.count),
|
||||||
|
Some(0),
|
||||||
|
"账本要记「这一代落地了、0 条」——不是「没有落地」"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **超时兜底真的会发一条「超时」**,且只发一次;迟到的结果仍然落地(线程没被掐)。
|
||||||
|
///
|
||||||
|
/// 判据用 `shorten_budget` 把上限压到 0,不等真实的 15 s(阈值本身仍是 `SCAN_STALL`)。
|
||||||
|
/// 抓得住:`tick` 没检查超时(用户永远看着「正在扫描实例…」);每拍都发(面板/日志被刷成噪音);
|
||||||
|
/// 报超时之后把在飞的结果丢掉(占位永远不消失)。
|
||||||
|
/// 抓不住:真实的 15 s 阈值合不合适(那要真盘上压测,见 `SCAN_STALL` 的取值说明)。
|
||||||
|
#[test]
|
||||||
|
fn a_stalled_scan_reports_once_and_a_late_snapshot_still_lands() {
|
||||||
|
// 阈值是**产品决定**(取值依据写在常量文档里):改它必须一起改那段话与这条断言
|
||||||
|
assert_eq!(SCAN_STALL, Duration::from_secs(15));
|
||||||
|
let (mut host, _env, _ids, _guard) = host_with("stall", 1);
|
||||||
|
let _ = host.drain_events();
|
||||||
|
|
||||||
|
host.refresh_instances();
|
||||||
|
// 把这一轮的上限压到 0:下一次 `tick` 就该判超时
|
||||||
|
host.instances_scan.shorten_budget(Duration::ZERO);
|
||||||
|
let task = next_scan(&host);
|
||||||
|
|
||||||
|
host.tick(Duration::from_millis(1));
|
||||||
|
let events = host.drain_events();
|
||||||
|
assert!(
|
||||||
|
events.iter().any(|event| matches!(
|
||||||
|
event,
|
||||||
|
AppEvent::Core(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled))
|
||||||
|
)),
|
||||||
|
"超过上限必须报一条「超时」(否则占位永远不消失):{events:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 再拍两下:不许重复报
|
||||||
|
host.tick(Duration::from_millis(1));
|
||||||
|
host.tick(Duration::from_millis(1));
|
||||||
|
assert!(
|
||||||
|
snapshots(host.drain_events()).is_empty()
|
||||||
|
&& !host.drain_events().iter().any(|event| matches!(
|
||||||
|
event,
|
||||||
|
AppEvent::Core(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled))
|
||||||
|
)),
|
||||||
|
"超时只许报一次(tick 每拍都会问)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 迟到的结果照样落地(代次没变 ⇒ 不掐线程的代价是零)
|
||||||
|
host.handle(task);
|
||||||
|
let published = snapshots(host.drain_events());
|
||||||
|
assert_eq!(published.len(), 1, "报过超时之后,迟到的快照仍然要落地");
|
||||||
|
assert!(!published[0].is_empty());
|
||||||
|
}
|
||||||
@@ -16,11 +16,10 @@
|
|||||||
//! | 启动收尾触发的第三次刷新 | [`a_launch_end_refresh_beats_the_two_that_were_in_flight`] | 收尾后列表不许被扫描期间的旧结果盖回「运行中」 |
|
//! | 启动收尾触发的第三次刷新 | [`a_launch_end_refresh_beats_the_two_that_were_in_flight`] | 收尾后列表不许被扫描期间的旧结果盖回「运行中」 |
|
||||||
//!
|
//!
|
||||||
//! 三份都是**真构造**:真临时数据根、真 `profiles.json` 写入、真后台线程跑真 `game::instances`。
|
//! 三份都是**真构造**:真临时数据根、真 `profiles.json` 写入、真后台线程跑真 `game::instances`。
|
||||||
//! 只有**交付顺序**是人为摆的(把 `rx` 里的任务按住、按想验的顺序喂给 host)——那正是要验的
|
//! 只有**交付顺序**是人为摆的(把 `rx` 里的任务按住、按想验的顺序喂给 host)——那正是要验的「先发后到」。
|
||||||
//! 「先发后到」,靠调度器碰运气是测不出确定结论的。
|
|
||||||
//!
|
//!
|
||||||
//! 每条用例的「抓不住」写在各自文档里;共同的一条:**它们不量真实耗时**(扫盘在进程内可能只有
|
//! 每条用例的「抓不住」写在各自文档里;共同的一条:**不量真实耗时**(进程内可能只有几百微秒),
|
||||||
//! 几百微秒),也不证明用户能感知的快慢——那要真盘上的 236 ms / 1.35 s 场景,见 M297 的实测口径。
|
//! 也不证明用户能感知的快慢——那要真盘上的 236 ms / 1.35 s 场景(M297 的实测口径)。
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -35,13 +34,12 @@ use crate::host::{CoreHost, HostEnv, HostTask, LiveLaunch};
|
|||||||
|
|
||||||
use super::Scan;
|
use super::Scan;
|
||||||
|
|
||||||
/// 取任务的等待上限(真线程 + 临时根,毫秒级就回来;超时说明真的坏了)。
|
/// 取任务的等待上限(真线程 + 临时根,毫秒级就回来;超时说明真的坏了)
|
||||||
const WAIT: Duration = Duration::from_secs(5);
|
const WAIT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 夹具
|
// ------------------------------------------------------------------ 夹具
|
||||||
|
|
||||||
/// 临时数据根守卫:测试结束(含断言 panic)时删掉目录。
|
/// 临时数据根守卫:测试结束(含断言 panic)时删掉目录。
|
||||||
struct TempRoot(std::path::PathBuf);
|
pub(super) struct TempRoot(std::path::PathBuf);
|
||||||
|
|
||||||
impl Drop for TempRoot {
|
impl Drop for TempRoot {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
@@ -97,7 +95,7 @@ fn ids(count: usize) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 建好 `count` 条档案的 host(headless:不跑 `refresh_all`,不起版本清单网络线程)。
|
/// 建好 `count` 条档案的 host(headless:不跑 `refresh_all`,不起版本清单网络线程)。
|
||||||
fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec<String>, TempRoot) {
|
pub(super) fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec<String>, TempRoot) {
|
||||||
let (env, guard) = root(label);
|
let (env, guard) = root(label);
|
||||||
let ids = ids(count);
|
let ids = ids(count);
|
||||||
seed(&env, &ids);
|
seed(&env, &ids);
|
||||||
@@ -107,7 +105,7 @@ fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec<String>, Temp
|
|||||||
// ------------------------------------------------------------------ 取件
|
// ------------------------------------------------------------------ 取件
|
||||||
|
|
||||||
/// 从通道里等下一份扫盘任务(**不经过 `tick`**:这里要自己按住顺序)。
|
/// 从通道里等下一份扫盘任务(**不经过 `tick`**:这里要自己按住顺序)。
|
||||||
fn next_scan(host: &CoreHost) -> HostTask {
|
pub(super) fn next_scan(host: &CoreHost) -> HostTask {
|
||||||
host.rx.recv_timeout(WAIT).expect("扫盘任务要在 5 s 内回来")
|
host.rx.recv_timeout(WAIT).expect("扫盘任务要在 5 s 内回来")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +130,7 @@ fn ids_of(task: &HostTask) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 事件队列里所有 `InstancesChanged` 快照(按先后)。
|
/// 事件队列里所有 `InstancesChanged` 快照(按先后)。
|
||||||
fn snapshots(events: Vec<AppEvent>) -> Vec<Vec<InstanceSummary>> {
|
pub(super) fn snapshots(events: Vec<AppEvent>) -> Vec<Vec<InstanceSummary>> {
|
||||||
events
|
events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|event| match event {
|
.filter_map(|event| match event {
|
||||||
@@ -240,8 +238,10 @@ fn two_refreshes_in_flight_publish_only_the_newest() {
|
|||||||
/// → #2 先到、#1 后到。没有代次判据时,`InstancesChanged` 的整体替换会把被删的磁贴**放回来**
|
/// → #2 先到、#1 后到。没有代次判据时,`InstancesChanged` 的整体替换会把被删的磁贴**放回来**
|
||||||
/// (点进去必然报错,用户以为删失败了)。
|
/// (点进去必然报错,用户以为删失败了)。
|
||||||
///
|
///
|
||||||
/// 抓得住:去掉/写错 `admit`(例如判成 `epoch >= self.epoch`),这条红;把丢掉旧代次那一步做成
|
/// 抓得住:把 `admit` 去掉(或改成恒真)——这条红,因为旧代次那份会被采纳、把被删的磁贴放回来;
|
||||||
/// 「发一条空快照」也红(那正是任务里禁止的「清空再重填」)。
|
/// 把丢掉旧代次那一步做成「发一条空快照」也红(那正是任务里禁止的「清空再重填」)。
|
||||||
|
/// **不**抓:把 `admit` 的判据从 `==` 改成 `>=` —— 代次只增不减、在飞任务不可能带着**未来**代次,
|
||||||
|
/// 两种写法行为等价(复审 r1 实测变异③全绿,这条 docstring 原先声称会红,是错的)。
|
||||||
/// 抓不住:刷新 #1 与删除动作**同时**发生的窄窗口——本用例把两份扫描都等回来了再摆交付顺序,
|
/// 抓不住:刷新 #1 与删除动作**同时**发生的窄窗口——本用例把两份扫描都等回来了再摆交付顺序,
|
||||||
/// 所以它验的是「顺序」,不是「磁盘并发」。
|
/// 所以它验的是「顺序」,不是「磁盘并发」。
|
||||||
#[test]
|
#[test]
|
||||||
@@ -350,7 +350,9 @@ fn a_launch_end_refresh_beats_the_two_that_were_in_flight() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
tile.runtime,
|
tile.runtime,
|
||||||
InstanceRuntime::Idle,
|
InstanceRuntime::Idle,
|
||||||
"收尾之后的快照必须是 Idle(旧结果若盖回去会带着「运行中」的外观)"
|
"收尾之后磁贴必须是 Idle:运行态是**落地那刻**按 host 当前表盖的,收尾已清表,\
|
||||||
|
所以哪怕是过期快照落地也会是 Idle —— 这条断言只验「盖的结果对」,\
|
||||||
|
新旧之分靠上面的条数与代次断言(`published.len() == 1` / `adopted.epoch` / `dropped`)"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
host.instances_scan.adopted().map(|adopted| adopted.epoch),
|
host.instances_scan.adopted().map(|adopted| adopted.epoch),
|
||||||
@@ -364,7 +366,8 @@ fn a_launch_end_refresh_beats_the_two_that_were_in_flight() {
|
|||||||
|
|
||||||
/// `Scan` 的账目:换代 → 只认当代次 → 旧代次计数 → 记账。
|
/// `Scan` 的账目:换代 → 只认当代次 → 旧代次计数 → 记账。
|
||||||
///
|
///
|
||||||
/// 抓得住:把 `admit` 写成 `>=`、把丢弃计数漏掉、把 `adopt` 记到别的代次上。
|
/// 抓得住:把 `admit` 去掉(或改成恒真)、把丢弃计数漏掉、把 `adopt` 记到别的代次上。
|
||||||
|
/// **不**抓:把 `==` 写成 `>=`(代次只增不减 ⇒ 两种写法等价,复审 r1 变异③实测全绿)。
|
||||||
/// 抓不住:真实交错的时序(那是上面三条用例的事)。
|
/// 抓不住:真实交错的时序(那是上面三条用例的事)。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_ledger_only_admits_the_current_epoch() {
|
fn the_ledger_only_admits_the_current_epoch() {
|
||||||
|
|||||||
@@ -275,11 +275,20 @@ impl CoreHost {
|
|||||||
elapsed_ms: u64,
|
elapsed_ms: u64,
|
||||||
) {
|
) {
|
||||||
if !self.instances_scan.admit(epoch) {
|
if !self.instances_scan.admit(epoch) {
|
||||||
// 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的
|
// 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的。
|
||||||
|
//
|
||||||
|
// 两种成因必须分开写清楚(复审 N3):`CoreHost::build` 的 `refresh_all` 与 `on_boot`
|
||||||
|
// 的 `AppCommand::RefreshAll` 各领一代,所以**每次冷启动都必然丢 1 份**——那是既有
|
||||||
|
// 双刷新的产物,不是交错。判据是「此前有没有任何快照落地过」。
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[instances] 丢掉过期扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份)",
|
"[instances] 丢弃旧代次扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份{})",
|
||||||
self.instances_scan.epoch(),
|
self.instances_scan.epoch(),
|
||||||
self.instances_scan.dropped()
|
self.instances_scan.dropped(),
|
||||||
|
if self.instances_scan.adopted().is_none() {
|
||||||
|
";此前还没有任何快照落地 ⇒ 启动期双刷新,不是交错"
|
||||||
|
} else {
|
||||||
|
";此前已有快照落地 ⇒ 这是真交错"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,13 @@ impl CoreHost {
|
|||||||
/// 掐断它省不下什么,却可能让一份快照永远不回来。
|
/// 掐断它省不下什么,却可能让一份快照永远不回来。
|
||||||
fn refresh_instances(&mut self) {
|
fn refresh_instances(&mut self) {
|
||||||
let epoch = self.instances_scan.begin();
|
let epoch = self.instances_scan.begin();
|
||||||
|
// 还没有任何快照落地(冷启动 / 超时后点「重新扫描」)⇒ 面板回到「正在扫描实例…」。
|
||||||
|
// **已就绪时绝不发这条**:那会让列表在每次刷新时退回占位——把卡顿换成闪屏,正是本 mission 禁止的。
|
||||||
|
if self.instances_scan.adopted().is_none() {
|
||||||
|
self.send(CoreEvent::InstancePhaseChanged(
|
||||||
|
crate::dto::InstancePhase::Scanning,
|
||||||
|
));
|
||||||
|
}
|
||||||
game::spawn_scan(self.env.clone(), self.tx.clone(), epoch);
|
game::spawn_scan(self.env.clone(), self.tx.clone(), epoch);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +379,8 @@ impl Backend for CoreHost {
|
|||||||
self.handle(task);
|
self.handle(task);
|
||||||
}
|
}
|
||||||
self.ticks += 1;
|
self.ticks += 1;
|
||||||
|
// M302 的有界兜底:首个快照太久没到就报超时(面板翻到「重新扫描」,线程不掐)
|
||||||
|
self.check_scan_stall();
|
||||||
if self.ticks.is_multiple_of(TAIL_POLL_TICKS) {
|
if self.ticks.is_multiple_of(TAIL_POLL_TICKS) {
|
||||||
self.poll_tail();
|
self.poll_tail();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::dto::{
|
|||||||
DeviceLoginView,
|
DeviceLoginView,
|
||||||
DiagnosisReport,
|
DiagnosisReport,
|
||||||
InstallProgressView,
|
InstallProgressView,
|
||||||
|
InstancePhase,
|
||||||
InstanceRuntime,
|
InstanceRuntime,
|
||||||
InstanceSummary,
|
InstanceSummary,
|
||||||
JavaPageView,
|
JavaPageView,
|
||||||
@@ -68,6 +69,13 @@ pub struct AppState {
|
|||||||
pub router: Router,
|
pub router: Router,
|
||||||
/// 实例列表快照
|
/// 实例列表快照
|
||||||
pub instances: Vec<InstanceSummary>,
|
pub instances: Vec<InstanceSummary>,
|
||||||
|
/// 实例列表的**首屏三态**(M302):`Scanning`(初值,首个快照还没到)/ `Stalled`(超时兜底)/
|
||||||
|
/// `Ready`(收到过快照)。
|
||||||
|
///
|
||||||
|
/// 为什么不从 `instances.is_empty()` 推:扫盘搬进后台线程之后,首帧拿到的必然是一个空
|
||||||
|
/// `Vec`——那和「确实零实例」长得一模一样,用它去猜就会把「还没扫完」画成「还没有实例」
|
||||||
|
/// (复审 r1 P2-1 的假空态)。真源必须是 host 自己报的这一位。
|
||||||
|
pub instances_phase: InstancePhase,
|
||||||
/// 当前分类的版本列表
|
/// 当前分类的版本列表
|
||||||
pub versions: Vec<VersionSummary>,
|
pub versions: Vec<VersionSummary>,
|
||||||
/// 日志会话列表
|
/// 日志会话列表
|
||||||
@@ -161,6 +169,7 @@ impl AppState {
|
|||||||
prefs: UiPrefs::default(),
|
prefs: UiPrefs::default(),
|
||||||
router: Router::new(),
|
router: Router::new(),
|
||||||
instances: Vec::new(),
|
instances: Vec::new(),
|
||||||
|
instances_phase: InstancePhase::Scanning,
|
||||||
versions: Vec::new(),
|
versions: Vec::new(),
|
||||||
log_sessions: Vec::new(),
|
log_sessions: Vec::new(),
|
||||||
log_lines: Vec::new(),
|
log_lines: Vec::new(),
|
||||||
@@ -255,7 +264,13 @@ impl AppState {
|
|||||||
/// core 事件。
|
/// core 事件。
|
||||||
pub fn apply_core(&mut self, event: CoreEvent) {
|
pub fn apply_core(&mut self, event: CoreEvent) {
|
||||||
match event {
|
match event {
|
||||||
CoreEvent::InstancesChanged(instances) => self.instances = instances,
|
CoreEvent::InstancesChanged(instances) => {
|
||||||
|
self.instances = instances;
|
||||||
|
// 收到快照 = 就绪(M302 三态的唯一就绪判据,见 `InstancePhase`)
|
||||||
|
self.instances_phase = InstancePhase::Ready;
|
||||||
|
}
|
||||||
|
// M302:只发「没就绪」的两态(扫盘起跑 / 超时兜底);`Ready` 由上面那条隐含
|
||||||
|
CoreEvent::InstancePhaseChanged(phase) => self.instances_phase = phase,
|
||||||
CoreEvent::InstanceRuntimeChanged { id, runtime } => {
|
CoreEvent::InstanceRuntimeChanged { id, runtime } => {
|
||||||
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
|
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
|
||||||
instance.runtime = runtime;
|
instance.runtime = runtime;
|
||||||
|
|||||||
Reference in new issue
Block a user