release: v0.2.5
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run

This commit is contained in:
AskaEth committed 2026-11-12 12:06:59 +08:00
commit 0522a41a52
1587 files changed
+323674

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
//! 账户/登录状态:事件落地(含**上屏前再脱敏**)、设备码倒计时、当前账户查询。
//!
//! 从 `state.rs` 拆出来守 400 行硬线;`AppState` 的字段仍在那个文件里,
//! 这里只加它的 `impl`(同一个 crate 的固有 impl 可以拆在多个文件)。
use std::time::Instant;
use crate::dto::{AccountView, AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, REDACTED};
use super::AppState;
impl AppState {
/// 微软登录状态落地。
///
/// **凭据红线(M114)**:这里对 `message` 再做一次形状脱敏。DTO 的构造器已经脱过一遍,
/// 但状态层是**所有**事件都要过的唯一闸口——将来谁新加一个构造器忘了脱敏,也不会把
/// 服务端回显的 token 画到面板上。脱敏是幂等的,重复做没有代价。
pub fn apply_device_login(&mut self, mut view: DeviceLoginView) {
view.message = crate::dto::redact(&view.message);
view.account_name = crate::dto::redact(&view.account_name);
// 这里**不发 toast**:presenter 是共享宿主层,写什么语言都不对(复审 r1 P1-1)。
// 成败反馈由页面自己画(登录面板的 Succeeded/Failed 态 + 账户页横幅),文案走 i18n。
match view.phase {
DeviceLoginPhase::Succeeded => {
self.device_login_started = None;
}
DeviceLoginPhase::Failed => {
self.device_login_started = None;
}
DeviceLoginPhase::Waiting => {
// 每次进入等待态都重记起点:换流/重试之后倒计时必须从头算
if self.device_login.phase != DeviceLoginPhase::Waiting
|| self.device_login.user_code != view.user_code
{
self.device_login_started = Some(Instant::now());
}
}
DeviceLoginPhase::Idle | DeviceLoginPhase::Requesting => {
self.device_login_started = None;
}
}
self.device_login = view;
}
/// 第三方轨登录状态落地(同样在状态层再脱敏一遍)。
pub fn apply_authlib_login(&mut self, mut view: AuthlibLoginView) {
view.message = crate::dto::redact(&view.message);
view.server_name = crate::dto::redact(&view.server_name);
// 同 `apply_device_login`:不发 toast,成败由页面按 i18n 画。
self.authlib_login = view;
}
/// 设备码/授权码还剩几秒(`None` = 没有在等 / 不知道有效期)。
///
/// 纯计算 + 一个起点,UI 侧每秒重问一次就能画出倒计时——这也让「过期」成为**可观测**的
/// 状态(任务要求:设备码不得只转圈,过期要显示「已过期,重试」)。
pub fn device_code_remaining(&self) -> Option<u64> {
self.device_code_remaining_at(Instant::now())
}
/// [`Self::device_code_remaining`] 的可注入时钟版本(单测用)。
pub fn device_code_remaining_at(&self, now: Instant) -> Option<u64> {
if self.device_login.phase != DeviceLoginPhase::Waiting {
return None;
}
let expires = self.device_login.expires_in;
if expires == 0 {
return None;
}
let started = self.device_login_started?;
let elapsed = now.saturating_duration_since(started).as_secs();
Some(expires.saturating_sub(elapsed))
}
/// 当前启动账户(列表里 `active == true` 的那条)。
pub fn active_account(&self) -> Option<&AccountView> {
self.accounts.iter().find(|account| account.active)
}
/// 面板要显示的脱敏占位符(守门测试用:值不该出现在任何可见文案里)。
pub const REDACTED: &'static str = REDACTED;
}
#[cfg(test)]
mod tests {
use super::*;
const JWT: &str =
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
fn state() -> AppState {
AppState::new()
}
/// 凭据红线:**绕过构造器**直接塞一个带 JWT 的事件,状态层必须把它抹掉。
///
/// 抓得住:`apply_device_login` / `apply_authlib_login` 里的脱敏被删(那是「最后一个闸口」)。
/// 抓不住:恶意的 UI 层(如果有人绕过 `AppState` 直接画 DTO)。
#[test]
fn a_token_injected_into_events_never_reaches_the_state() {
let mut state = state();
state.apply_device_login(DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message: format!("登录失败:accessToken={JWT}"),
..DeviceLoginView::new()
});
assert!(
!state.device_login.message.contains(JWT),
"{}",
state.device_login.message
);
assert!(state.device_login.message.contains(REDACTED));
state.apply_authlib_login(AuthlibLoginView {
phase: DeviceLoginPhase::Failed,
message: format!("服务器回包 {JWT}"),
..AuthlibLoginView::new()
});
assert!(
!state.authlib_login.message.contains(JWT),
"{}",
state.authlib_login.message
);
}
#[test]
fn the_device_code_countdown_is_derived_from_a_start_instant() {
let mut state = state();
state.apply_device_login(DeviceLoginView::waiting(
"ABCD-EFGH",
"https://www.microsoft.com/link",
900,
"",
));
let started = state.device_login_started.expect("等待态要记起点");
assert_eq!(state.device_code_remaining_at(started), Some(900));
let later = started + std::time::Duration::from_secs(120);
assert_eq!(state.device_code_remaining_at(later), Some(780));
let expired = started + std::time::Duration::from_secs(901);
assert_eq!(
state.device_code_remaining_at(expired),
Some(0),
"过期后是 0,不是 None"
);
// 没有在等的时候不画倒计时
state.apply_device_login(DeviceLoginView::failed("网络超时"));
assert_eq!(state.device_code_remaining_at(started), None);
}
#[test]
fn a_new_waiting_view_restarts_the_countdown() {
let mut state = state();
state.apply_device_login(DeviceLoginView::waiting("AAAA-AAAA", "https://a", 900, ""));
let first = state.device_login_started.expect("起点");
state.apply_device_login(DeviceLoginView::waiting("BBBB-BBBB", "https://a", 900, ""));
let second = state.device_login_started.expect("起点");
assert!(second >= first, "换了一个用户码就要重算倒计时");
}
#[test]
fn the_active_account_is_the_one_marked_active() {
let mut state = state();
state.accounts = vec![
AccountView {
id: "account:a".to_string(),
name: "Steve".to_string(),
kind: crate::dto::AccountKind::Microsoft,
profile_id: "0f8f".to_string(),
login_name: String::new(),
created: String::new(),
active: false,
server_url: String::new(),
skin: None,
},
AccountView {
id: "account:b".to_string(),
name: "Alex".to_string(),
kind: crate::dto::AccountKind::Offline,
profile_id: "1a2b".to_string(),
login_name: String::new(),
created: String::new(),
active: true,
server_url: String::new(),
skin: None,
},
];
assert_eq!(
state.active_account().map(|a| a.name.as_str()),
Some("Alex")
);
}
}
+39
View File
@@ -0,0 +1,39 @@
//! AI 栏的事件投递与可见性判定(M223 从 `state.rs` **纯搬移**,一行未改)。
//!
//! 为什么搬:M223 要往 `AppState` 加「导入已有版本」的状态字段与一条 `CoreEvent::Adopt` 分支,
//! 而 `state.rs` 当时正好 400 行(零余量,AGENTS §3.9)。拆分口径与 [`super::tiles`] 一致:
//! **纯搬移、一行不改**;`impl AppState` 写在子模块里,方法路径
//! `AppState::apply_agent` / `AppState::agent_rail_visible` 对调用方完全不变
//! (子模块看得见父模块的私有字段,所以这里直接用 `self.ai` / `self.prefs`)。
use crate::dto::AgentEvent;
use crate::router::AiPanelMode;
use super::AppState;
impl AppState {
/// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。
///
/// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;
/// 用户自己说的话与纯增量不算。栏可见时到达的内容直接算已读(红点不该在眼前亮着)。
pub fn apply_agent(&mut self, event: AgentEvent) {
let visible = self.agent_rail_visible();
let fresh = self.ai.ingest(&event);
if fresh {
if visible {
self.ai.mark_read();
} else {
self.ai.unread = self.ai.unread.saturating_add(1);
}
}
}
/// AI 栏此刻是否「在用户眼前」。
///
/// 独立页形态(栈顶是 `Route::Agent`)必然可见;停靠形态下只有收成 icon 轨才算离开——
/// 右侧常驻栏在任何一级页面都挂着,用户扫一眼就能看到。
#[must_use]
pub fn agent_rail_visible(&self) -> bool {
self.ai_mode() == AiPanelMode::Page || !self.prefs.ai_collapsed
}
}
+64
View File
@@ -0,0 +1,64 @@
//! 内容库的状态机(M109):结果落地与条件提交两件事。
//!
//! 从 `state.rs` 拆出(那个文件重新合流 M105 之后贴着 400 行上限)。放在 `impl AppState` 的
//! **另一个 impl 块**里,路径与可见性都不变:`apply_core` 直接调 `self.apply_content(..)`。
use crate::dto::{ContentDetail, ContentFailure, ContentQuery, ContentResults};
use super::AppState;
impl AppState {
/// 内容库结果落地(M109):同条件换页 ⇒ **追加**;换条件 ⇒ **整批替换**;迟到 ⇒ 丢掉。
///
/// 这三条就是「列表画的永远是当前筛选条件」的全部保证——页面自己没有过滤逻辑,也没有第二份真相。
pub(super) fn apply_content(&mut self, results: ContentResults) {
self.content.pending = false;
self.content.failed = None;
if !self.content.accepts(&results) {
// 迟到的一批:用户已经改了条件,覆盖上去就会出现「筛选条说 A、列表是 B」。
return;
}
// **先**按「这一页装没装满」记一位,再把它并进累计列表(复审 P1-2:判据不能读累计值)。
self.content.last_page_filled = results.has_more();
match &mut self.content.results {
Some(current) if results.query.offset > 0 => {
current.hits.extend(results.hits);
current.total = results.total;
current.curseforge = results.curseforge;
current.query = results.query;
}
_ => self.content.results = Some(results),
}
}
/// 检索失败落地(M162 起带大类):清掉「在飞」并记下失败。
pub(super) fn apply_content_failure(&mut self, failure: ContentFailure) {
self.content.pending = false;
self.content.failed = Some(failure);
}
/// 详情事件落地(M162):`Ok` 走 `apply_detail`、`Err` 走 `fail_detail`,两者都按
/// **请求时的项目 id** 判迟到(用户可能已经切到别的项目了)。
pub(super) fn apply_content_detail(
&mut self,
project: &str,
result: Result<ContentDetail, ContentFailure>,
) {
match result {
Ok(detail) => self.content.apply_detail(project, detail),
Err(failure) => self.content.fail_detail(project, failure),
}
}
/// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 dispatch)。
pub fn set_content_query(&mut self, query: ContentQuery) {
self.content.pending = true;
self.content.failed = None;
if query.offset == 0 {
self.content.last_page_filled = false;
// 换条件(不是换页):先把旧结果清掉,否则加载态会画出上一批命中项。
self.content.results = None;
}
self.content.query = query;
}
}
+244
View File
@@ -0,0 +1,244 @@
//! 内容库状态机的守门测试(M109;复审 P1-2 的补课)。
//!
//! 从 `state.rs` 拆出(那个文件贴 400 行硬上限)。钉的是**翻页判据**:
//! `has_more` 必须读「最近一页是否装满」,不能读累计条数——否则第 2 页之后恒真。
use crate::dto::{
AppEvent, ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind,
ContentQuery, ContentResults, ContentSource, ContentVersionView, CoreEvent,
};
use super::AppState;
fn hit(index: usize) -> ContentHit {
ContentHit {
id: format!("p{index}"),
slug: format!("p{index}"),
title: format!("Project {index}"),
summary: "s".to_string(),
author: "a".to_string(),
downloads: 0,
updated: "2026-01-01".to_string(),
source: ContentSource::Modrinth,
game_versions: vec!["1.20.1".to_string()],
loaders: vec!["fabric".to_string()],
icon: None,
}
}
/// 造一页结果:`offset` 是游标,`count` 是这一页实际回来几条。
fn page(offset: u32, count: u32) -> ContentResults {
let mut query = ContentQuery::first(ContentKind::Mod);
query.offset = offset;
ContentResults {
query,
hits: (0..count)
.map(|index| hit(offset as usize + index as usize))
.collect(),
total: 999,
curseforge: false,
}
}
fn apply(state: &mut AppState, results: ContentResults) {
state.apply(AppEvent::Core(CoreEvent::ContentResults(Box::new(results))));
}
/// 复审 P1-2 的第一条:装满一页 ⇒ 还有更多;下一(末)页不满 ⇒ 没有了。
#[test]
fn a_short_last_page_clears_has_more() {
let mut state = AppState::new();
let limit = ContentQuery::first(ContentKind::Mod).limit;
apply(&mut state, page(0, limit));
assert!(state.content.last_page_filled, "首页装满 ⇒ 还有下一页");
assert_eq!(
state.content.results.as_ref().map_or(0, |r| r.hits.len()),
limit as usize
);
// 末页只回来 3 条:累计 23 条「够一页了」,但**这一页**没装满 ⇒ 没有更多
apply(&mut state, page(limit, 3));
assert!(
!state.content.last_page_filled,
"末页不满 ⇒ 不许说还有更多(判据不能读累计条数)"
);
assert_eq!(
state.content.results.as_ref().map_or(0, |r| r.hits.len()),
limit as usize + 3,
"命中项仍然要累加"
);
}
/// 复审 P1-2 的第二条:再点一次,回来的空页仍然是「没有更多」。
#[test]
fn an_empty_last_page_keeps_has_more_false() {
let mut state = AppState::new();
let limit = ContentQuery::first(ContentKind::Mod).limit;
apply(&mut state, page(0, limit));
apply(&mut state, page(limit, 0));
assert!(!state.content.last_page_filled, "空页 ⇒ 没有更多");
// 用户又点了一次「加载更多」:空页不会把状态翻回真
apply(&mut state, page(limit + limit, 0));
assert!(
!state.content.last_page_filled,
"再点一次空页 ⇒ 仍然没有更多"
);
assert_eq!(
state.content.results.as_ref().map_or(0, |r| r.hits.len()),
limit as usize,
"空页不改变列表内容"
);
}
/// 换条件(offset 回到 0)时把「最近一页装满」清掉:不然新条件的第一帧就会画「没有更多了」。
#[test]
fn changing_the_query_resets_the_page_flag() {
let mut state = AppState::new();
let limit = ContentQuery::first(ContentKind::Mod).limit;
apply(&mut state, page(0, limit));
assert!(state.content.last_page_filled);
let mut query = ContentQuery::first(ContentKind::Mod);
query.text = "sodium".to_string();
state.set_content_query(query);
assert!(!state.content.last_page_filled, "换条件 ⇒ 清掉上一批的判据");
assert!(state.content.pending);
assert!(
state.content.results.is_none(),
"换条件要清列表(不然加载态画旧命中项)"
);
}
/// 迟到的一批不许改写判据(用户已经换了条件)。
#[test]
fn a_stale_batch_does_not_touch_the_page_flag() {
let mut state = AppState::new();
let limit = ContentQuery::first(ContentKind::Mod).limit;
apply(&mut state, page(0, limit));
let mut query = ContentQuery::first(ContentKind::Mod);
query.text = "sodium".to_string();
state.set_content_query(query);
// 旧条件的一页这时才回来:既不改结果,也不改「还有更多」
apply(&mut state, page(limit, 5));
assert!(!state.content.last_page_filled);
assert!(state.content.results.is_none(), "迟到的一批被丢掉");
}
// ------------------------------------------------ M162:失败大类与详情通道
/// 事件 → 状态:`ContentFailed` 带大类,没联网时 `is_offline()` 为真(页面据此画离线态)。
#[test]
fn a_content_failure_keeps_its_kind() {
let mut state = AppState::new();
state.set_content_query(ContentQuery::first(ContentKind::Mod));
state.apply_core(CoreEvent::ContentFailed(Box::new(ContentFailure::offline(
"connect timed out",
))));
let failure = state.content.failed.as_ref().expect("要记住失败");
assert!(failure.is_offline(), "没联网必须能被页面认出来");
assert!(!state.content.pending);
state.apply_core(CoreEvent::ContentFailed(Box::new(ContentFailure::new(
ContentFailureKind::Server,
"HTTP 503",
))));
let failure = state.content.failed.as_ref().expect("要记住失败");
assert!(!failure.is_offline(), "站点 5xx 不是「没联网」");
}
/// 详情通道:请求态 → 结果态;换项目时旧详情先清掉。
#[test]
fn the_detail_channel_replaces_the_previous_project() {
let mut state = AppState::new();
state.content.request_detail("p1");
assert!(state.content.detail_pending);
state.apply_core(CoreEvent::ContentDetail {
project: "p1".to_string(),
result: Box::new(Ok(detail("p1"))),
});
assert!(!state.content.detail_pending);
assert_eq!(
state.content.detail.as_ref().map(|d| d.project.as_str()),
Some("p1")
);
state.content.request_detail("p2");
assert!(
state.content.detail.is_none(),
"换了项目就不能留着上一个的版本列表"
);
state.apply_core(CoreEvent::ContentDetail {
project: "p2".to_string(),
result: Box::new(Err(ContentFailure::offline("connect timed out"))),
});
assert!(state
.content
.detail_failed
.as_ref()
.is_some_and(ContentFailure::is_offline));
}
fn detail(project: &str) -> ContentDetail {
ContentDetail {
project: project.to_string(),
title: "Sodium".to_string(),
description: "fast".to_string(),
body: String::new(),
versions: vec![ContentVersionView {
id: "v1".to_string(),
name: "Sodium 0.6.0".to_string(),
number: "mc1.20.1-0.6.0".to_string(),
kind: "release".to_string(),
game_versions: vec!["1.20.1".to_string()],
loaders: vec!["fabric".to_string()],
published: "2026-02-01T00:00:00Z".to_string(),
file_name: Some("sodium.jar".to_string()),
file_size: Some(2048),
}],
}
}
/// r1 P2-3:迟到的详情结果(A)不许写进 B 的请求/面板。
#[test]
fn a_late_detail_result_for_the_previous_project_is_dropped() {
let mut state = AppState::new();
state.content.request_detail("p1");
// 用户立刻切到 p2:新的请求在飞
state.content.request_detail("p2");
// p1 的失败这时才回来
state.apply_core(CoreEvent::ContentDetail {
project: "p1".to_string(),
result: Box::new(Err(ContentFailure::offline("connect timed out"))),
});
assert!(
state.content.detail_failed.is_none(),
"A 的失败不许画在 B 的面板上"
);
assert!(state.content.detail_pending, "B 仍应在飞");
assert_eq!(state.content.detail_requested.as_deref(), Some("p2"));
// p1 的成功结果同样被丢掉(哪怕载荷里写着 p1)
state.apply_core(CoreEvent::ContentDetail {
project: "p1".to_string(),
result: Box::new(Ok(detail("p1"))),
});
assert!(state.content.detail.is_none());
assert!(state.content.detail_pending);
// 真正属于 B 的结果才落地
state.apply_core(CoreEvent::ContentDetail {
project: "p2".to_string(),
result: Box::new(Ok(detail("p2"))),
});
assert!(!state.content.detail_pending);
assert_eq!(
state.content.detail.as_ref().map(|d| d.project.as_str()),
Some("p2")
);
}
+30
View File
@@ -0,0 +1,30 @@
//! 实时日志的滚动窗口(从 `state.rs` 搬出,守 400 行硬线)。
//!
//! 纯搬移:常量与 `append_live_lines` 一字未改;`state.rs` 用 `pub use` 再导出
//! [`MAX_LIVE_LINES`],老路径 `crate::state::MAX_LIVE_LINES` 继续可用。
use super::AppState;
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
pub const MAX_LIVE_LINES: usize = 2_000;
impl AppState {
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
pub(super) fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
if self
.live_session
.as_deref()
.is_some_and(|session| session != batch.session)
{
// 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起
self.log_lines.clear();
}
self.live_session = batch.running.then(|| batch.session.clone());
self.selected_session = Some(batch.session.clone());
self.log_lines.extend(batch.lines.iter().cloned());
if self.log_lines.len() > MAX_LIVE_LINES {
let drop = self.log_lines.len() - MAX_LIVE_LINES;
self.log_lines.drain(..drop);
}
}
}
+188
View File
@@ -0,0 +1,188 @@
//! 本地 Mod 的快照落地与「请求在飞」标记(M224 · MODTOGGLE)。
//!
//! 从 `state.rs` 拆出(那个文件正好贴着 400 行硬线),形状与 [`super::accounts`] / [`super::live`]
//! 一致:**另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
//!
//! # 为什么快照住在这里而不是页面
//!
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
//!
//! - 快照走 [`crate::dto::CoreEvent::Mods`] 的 `Ok` 一支,**整体替换**(不做增量合并)——host 每次
//! 都是重新扫一遍目录才发的,合并只会两处真相;
//! - 失败走同一个事件的 `Err` 一支,记下一条**具名原因**,页面按 i18n key 现求文案
//! (presenter 不拼句子,M161 纪律);
//! - [`ModsState::pending`] 是**页面交互态**(哪个文件的请求在飞),只用来把那个开关先置灰
//! ——**不做乐观翻面**:`view` 里的 `enabled` 只跟磁盘快照走。
//!
//! # 迟到结果
//!
//! 启停是「后台线程 + 快照回来」:用户很可能点完就切到别的实例。所以 `view.instance` 与
//! [`ModsState::bound`] 不一致的结果**直接丢掉**(与内容库按 request id 判迟到同一条纪律)——
//! 否则 A 的列表会被 B 的启停结果顶掉。
use std::collections::BTreeSet;
use crate::dto::{ModFailureView, ModListView};
use super::AppState;
/// 本地 Mod 的视图态。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModsState {
/// 当前详情页的实例 id(`None` = 不在详情页)。
pub bound: Option<String>,
/// 最近一份快照(`None` = 还没请求过)。
pub view: Option<ModListView>,
/// 最近一条失败(具名原因;成功一次就清掉)。
pub failed: Option<ModFailureView>,
/// 正在飞的启停请求(磁盘文件名,含后缀)。
pub pending: BTreeSet<String>,
}
impl ModsState {
/// 切到另一个实例(换实例必须清空:A 的列表不能画在 B 的详情页上)。
pub(super) fn bind(&mut self, instance: Option<&str>) {
if self.bound.as_deref() == instance {
return;
}
self.bound = instance.map(str::to_string);
self.view = None;
self.failed = None;
self.pending.clear();
}
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
pub fn apply(&mut self, result: Result<ModListView, ModFailureView>) {
match result {
Ok(view) => self.changed(view),
Err(failure) => self.failed(failure),
}
}
/// 列表快照落地(整体替换;迟到/串台的丢掉)。
pub fn changed(&mut self, view: ModListView) {
if self.bound.as_deref() != Some(view.instance.as_str()) {
return;
}
// 这一批结果回来了 ⇒ 那个文件的"在飞"标记可以撤(不管成没成,另一条事件会说)。
self.pending.clear();
self.failed = None;
self.view = Some(view);
}
/// 失败落地(只记**当前绑定实例**的;列表那一份保留——用户还能看见旧列表,只是多一条红字)。
pub fn failed(&mut self, failure: ModFailureView) {
if self.bound.as_deref() != Some(failure.instance.as_str()) {
return;
}
self.pending.clear();
self.failed = Some(failure);
}
/// 标一个文件"请求在飞"(UI 把那个开关置灰,不做乐观翻面)。
pub fn mark_pending(&mut self, file: &str) {
self.pending.insert(file.to_string());
}
/// 某个文件的请求是否在飞。
#[must_use]
pub fn is_pending(&self, file: &str) -> bool {
self.pending.contains(file)
}
/// 当前绑定实例的列表是否已经有一份快照。
#[must_use]
pub fn has_view(&self) -> bool {
self.view.is_some()
}
}
impl AppState {
/// 进/出详情页时对齐绑定(`refresh_view` 调;与 `LaunchForm::sync` 同一处生命周期)。
pub fn bind_mods(&mut self, instance: Option<&str>) {
self.mods.bind(instance);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::{ModEntryView, ModFailureKind};
fn view(instance: &str) -> ModListView {
ModListView {
instance: instance.to_string(),
dir: "C:/mc/mods".to_string(),
exists: true,
entries: vec![ModEntryView {
file: "x.jar".to_string(),
name: "x.jar".to_string(),
enabled: true,
bytes: 1,
old: false,
}],
skipped: Vec::new(),
running: false,
known: true,
}
}
fn failure(instance: &str) -> ModFailureView {
ModFailureView {
instance: instance.to_string(),
file: "x.jar".to_string(),
kind: ModFailureKind::Collision,
detail: "exists".to_string(),
}
}
/// 换实例必须清空(A 的列表不许画在 B 的详情页上)。
#[test]
fn binding_another_instance_drops_the_old_view() {
let mut mods = ModsState::default();
mods.bind(Some("a"));
mods.changed(view("a"));
assert!(mods.has_view());
mods.bind(Some("b"));
assert!(!mods.has_view(), "换实例后还留着 A 的列表");
assert_eq!(mods.bound.as_deref(), Some("b"));
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
mods.changed(view("b"));
mods.bind(Some("b"));
assert!(mods.has_view());
}
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败。
#[test]
fn late_results_are_dropped() {
let mut mods = ModsState::default();
mods.bind(Some("a"));
mods.changed(view("b"));
assert!(!mods.has_view(), "B 的列表画到了 A 上");
mods.failed(failure("a"));
assert!(mods.failed.is_some());
mods.changed(view("a"));
assert!(mods.failed.is_none(), "成功后旧失败没清掉");
// 不在详情页(`bound = None`)时任何结果都不落地。
let mut idle = ModsState::default();
idle.changed(view("a"));
assert!(!idle.has_view());
}
/// 在飞标记:置位后能查,快照回来后自然清空。
#[test]
fn pending_tracks_in_flight_toggles() {
let mut mods = ModsState::default();
mods.bind(Some("a"));
mods.changed(view("a"));
mods.mark_pending("x.jar");
assert!(mods.is_pending("x.jar"));
assert!(!mods.is_pending("y.jar"));
mods.changed(view("a"));
assert!(!mods.is_pending("x.jar"), "快照回来后还挂着在飞标记");
}
}
+222
View File
@@ -0,0 +1,222 @@
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)+ 写操作的三样交互态(M247 · SAVESOPS)。
//!
//! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致:
//! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
//!
//! # 写操作的三样交互态(M247)
//!
//! | 字段 | 是什么 | 为什么必须住在这里 |
//! |---|---|---|
//! | [`SavesState::draft`] | 行内重命名的输入缓冲 | 页面模型每帧从快照重建(`from_state`),打字过程中的内容不在快照里 ⇒ 不留在这里就会每打一个字被事件冲掉 |
//! | [`SavesState::pending`] | 哪个世界的哪个写操作在飞 | 与 [`super::mods::ModsState::pending`] **同一条先例**:只用来把那一行的按钮置灰,**不做乐观回显**(列表只跟快照走) |
//! | [`SavesState::confirm`] | 删除确认框**正在等谁** | 模态队列只画框、不记得"确认之后要删哪个世界";而点主按钮的那一刻是 `app/update/dialog.rs`,那里只能从状态里取 |
//!
//! ## 生命周期(谁写、谁清、何时必须清)
//!
//! | 字段 | 谁写 | 谁清 | 必须清的时刻 |
//! |---|---|---|---|
//! | `draft` | `start_rename`(预填当前磁盘名)、`edit_rename`(每敲一个字) | `cancel_rename` / `take_rename`(提交时取走)/ `mark_pending`(请求已发出,草稿没用了)/ `bind` / `changed` | **换实例**、**请求发出**、**快照回来**:三者之后草稿都属于"上一次对话",留着会让「改名」按钮改到一个已经在别的实例上的名字 |
//! | `pending` | `mark_pending`(发请求之前,一跳都不等) | `changed`(成功)/ `failed`(失败也要清——**按钮不能永远灰着**,用户要能直接再点一次)/ `bind` | **任何一次后台往返收尾**、**换实例** |
//! | `confirm` | `ask_delete(dialog, dir)`(app 层先 `dialogs.push` 拿到 id 再登记) | [`SavesState::take_confirm(current)`](SavesState::take_confirm)(**只核销同一个框、取走即清**)/ [`SavesState::clear_confirm`](次按钮 / 遮罩 / `Esc` / 换实例)/ [`SavesState::changed`](**只在被确认的世界已从快照里消失时**) | **点主按钮那一刻**、**取消/关闭模态框**、**换实例**、**那个世界没了**(`closed_confirm` 会一并收框) |
//! | `closed_confirm` | `changed`(世界从快照里消失时登记作废的那个框) | app 层 `refresh_view` → `settle_saves_confirm`(收框后取走)/ `ask_delete` / `clear_confirm` / `bind` | **同一拍或下一拍**:留着会让"世界已经不在了"的确认框一直挡在屏幕中央 |
//!
//! ### `confirm` 是**绑定到具体对话框**的一条安全不变量
//!
//! 三条判据,缺一条就会出安全事故(复审 r1 P1-1 的事故形态就是第三条缺失):
//!
//! 1. **取走即清**:主按钮拿到目标的同时字段被置空 ⇒ 同一次确认连点两下只发一条删除。
//! 2. **认框不认人**:`take_confirm(current)` 只在 `current` 等于登记时那个框的 id 时才交付。
//! 「删存档 → `Esc` → 崩溃卡片 → 点『导出诊断包』」这条链上,第二个框的主按钮**拿不走**目标
//! (即使 `Esc` 那条关闭路径漏清——而它现在也清了)。
//! 3. **世界没了就把框收起来**:快照回来时被确认的世界已不在列表里 ⇒ 清 `confirm` 并登记
//! `closed_confirm`,app 层据此收框。否则主按钮会变成一次**静默空操作**(点了没删、也不说一句)。
//!
//! 用例(presenter 层):`the_delete_confirmation_hands_out_once`(取走即清 + 取消后不再有目标)、
//! `a_dialog_can_only_redeem_its_own_target`(别的框的主按钮拿不走)、
//! `an_unrelated_snapshot_no_longer_drops_the_confirmation`(P2-1:世界还在 ⇒ 目标还兑现得了)、
//! `the_confirmation_is_dropped_and_reported_when_the_world_vanishes`(世界没了 ⇒ 清 + 登记收框)。
//!
//! # 为什么快照住在这里而不是页面
//!
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
//!
//! - 快照走 [`crate::dto::CoreEvent::Saves`] 的 `Ok` 一支,**整体替换**(不做增量合并)——
//! host 每次都是重新扫一遍目录才发的,合并只会两处真相;
//! - 失败走同一个事件的 `Err` 一支,记下一条**具名原因**,页面按 i18n key 现求文案;
//! - **可重试**:失败时不清 `bound`,页面据此画"重试"按钮(再发一条
//! [`crate::dto::SavesCommand::List`] 就行,没有别的状态要复位)。
//!
//! # 迟到结果
//!
//! 用户很可能点开 A 的详情页、还没等后台回来就切到 B。所以 `view.instance` 与
//! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。
use crate::dialogs::DialogId;
use crate::dto::{SavesFailureView, SavesListView};
mod interaction;
use super::AppState;
/// 行内重命名的输入缓冲(见模块文档的三样交互态)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameDraft {
/// 哪个世界(磁盘名,提交时原样带回去当「源」)。
pub dir: String,
/// 输入框里的内容(**原样**:合不合法由 core 判,页面只管显示,不做前端过滤)。
pub text: String,
}
/// 一次在飞的写操作(只用来置灰那一行,列表仍然只跟快照)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SavesOp {
/// 重命名中。
Rename {
/// 磁盘名
dir: String,
},
/// 删除中。
Delete {
/// 磁盘名
dir: String,
},
/// 备份中。
Backup {
/// 磁盘名
dir: String,
},
}
impl SavesOp {
/// 这个操作动的是哪个世界。
#[must_use]
pub fn dir(&self) -> &str {
match self {
Self::Rename { dir } | Self::Delete { dir } | Self::Backup { dir } => dir,
}
}
}
/// 存档区的视图态。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SavesState {
/// 当前详情页的实例 id(`None` = 不在详情页)。
pub bound: Option<String>,
/// 最近一份快照(`None` = 还没请求过)。
pub view: Option<SavesListView>,
/// 最近一条失败(具名原因;成功一次就清掉)。
pub failed: Option<SavesFailureView>,
/// 行内重命名的输入缓冲(`None` = 没有在改名)。
pub draft: Option<RenameDraft>,
/// 在飞的写操作(`None` = 空闲)。
pub pending: Option<SavesOp>,
/// 删除确认框正在等谁:**(框的 id, 世界名)**(点「确定」时才真的删)。
///
/// 为什么带框 id(复审 r1 P1-1):`take_confirm(current)` 只核销**同一个框**。即使某条
/// 关闭路径漏清,目标也不会被下一个不相干的对话框(例如崩溃卡片的「导出诊断包」)兑成一次删除。
pub confirm: Option<(DialogId, String)>,
/// 刚刚因为「被确认的世界已经不在快照里」而作废的那个框(app 层据此收框,见
/// [`SavesState::take_closed_confirm`])。
pub closed_confirm: Option<DialogId>,
}
impl SavesState {
/// 切到另一个实例(换实例必须清空:A 的存档不能画在 B 的详情页上)。
///
/// 三样交互态一起清:换实例之后还留着「A 的行内草稿 / A 的删除确认」是错位状态
/// (点确定会去删 B 页上的 A,或者把 A 的草稿提交成 B 的改名)。
pub(super) fn bind(&mut self, instance: Option<&str>) {
if self.bound.as_deref() == instance {
return;
}
self.bound = instance.map(str::to_string);
self.view = None;
self.failed = None;
self.reset_interaction();
}
/// 清掉三样交互态(换实例 / 操作回程时用)。
fn reset_interaction(&mut self) {
self.draft = None;
self.pending = None;
self.confirm = None;
self.closed_confirm = None;
}
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
///
/// **成败对三样交互态的处理不一样**(复审 r1 P2-1;与模块文档的安全判据第 3 条同源):
///
/// | 支 | `draft` / `pending` | `confirm` |
/// |---|---|---|
/// | `Ok`(快照) | 清(这一拍写操作收尾了) | **只在「被确认的那个世界已不在快照里」时**清,并登记 [`SavesState::closed_confirm`] 让 app 收框 |
/// | `Err`(失败) | 只清 `pending`(按钮不能永远灰着) | **不动**:一条读盘失败不该把用户刚点出来的确认框变成"点了没反应" |
///
/// 换言之「无论成败都清确认中」是**旧的**说法,已经不成立:无条件清会让主按钮静默空操作。
pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
match result {
Ok(view) => self.changed(view),
Err(failure) => self.failed(failure),
}
}
/// 快照落地(整体替换;迟到/串台的丢掉)。
///
/// 三样交互态在这里**不是一起清**(复审 r1 P2-1):
///
/// | 字段 | 这一拍怎么处理 |
/// |---|---|
/// | `draft` / `pending` | **清**:写操作已经收尾,行按新快照画 |
/// | `confirm` | **只在「被确认的那个世界已经从快照里消失」时清**,并记下那个框([`Self::closed_confirm`])让 app 收框 |
///
/// 为什么不能无条件清 `confirm`:备份一个大世界还在飞、「打开存档文件夹」的回程还在路上时,
/// 用户点「删除 X」→ 框刚弹出来 → 一拍无关快照先到 → 目标被清 → 用户点「删除世界」只关框、
/// 不删、也不说一句。少删是安全的,但**静默就是不安全的另一种形式**(用户以为删了)。
pub fn changed(&mut self, view: SavesListView) {
if self.bound.as_deref() != Some(view.instance.as_str()) {
return;
}
self.failed = None;
// 目标世界还在不在这一份快照里(`dir` 是磁盘上的名字,与列表同一口径)。
let gone = self
.confirm
.as_ref()
.map(|(_, dir)| !view.worlds.iter().any(|world| &world.dir == dir));
self.view = Some(view);
self.draft = None;
self.pending = None;
if gone == Some(true) {
if let Some((dialog, _)) = self.confirm.take() {
self.closed_confirm = Some(dialog);
}
}
}
/// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
pub fn failed(&mut self, failure: SavesFailureView) {
if self.bound.as_deref() != Some(failure.instance.as_str()) {
return;
}
self.failed = Some(failure);
// 失败也收尾:按钮不能永远灰着(用户要能直接再点一次重试)。
// `confirm` **不动**(与 [`Self::changed`] 同一条理由):一条读盘失败不该把用户
// 刚点出来的删除确认框变成"点了没反应"。
self.pending = None;
}
/// 当前绑定实例的列表是否已经有一份快照。
#[must_use]
pub fn has_view(&self) -> bool {
self.view.is_some()
}
}
impl AppState {
/// 进/出详情页时对齐绑定(`refresh_view` 调;与 `bind_mods` 同一处生命周期)。
pub fn bind_saves(&mut self, instance: Option<&str>) {
self.saves.bind(instance);
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,101 @@
//! `SavesState` 的三样**交互态**:行内草稿 / 在飞标记 / 删除确认目标(M247)。
//!
//! 从 `state/saves.rs` 拆出(那个文件加完安全不变量文档之后越过 400 行硬线)。生命周期表在
//! 父模块的文档里一张写全——**这里只放实现**,不重复判据。
//!
//! 三条边界(父模块文档的展开):
//! 1. `draft` / `pending` 只跟"这一拍在编辑/在飞什么"有关,快照落地一律清;
//! 2. `confirm` **认框**:`take_confirm(current)` 只在 id 相等时交付(见 [`SavesState::take_confirm`]);
//! 3. `closed_confirm` 是"这个框该收起来了"的信号,app 层 `refresh_view` 取走并真的收框。
use crate::dialogs::DialogId;
use super::{RenameDraft, SavesOp, SavesState};
impl SavesState {
/// 开一个行内重命名(输入框预填当前磁盘名:改个后缀比从零打更常见)。
pub fn start_rename(&mut self, dir: &str) {
self.draft = Some(RenameDraft {
dir: dir.to_string(),
text: dir.to_string(),
});
}
/// 输入框变了(**照原样存**,不做前端过滤:非法名字由 core 拒并给出具名原因)。
pub fn edit_rename(&mut self, text: String) {
if let Some(draft) = &mut self.draft {
draft.text = text;
}
}
/// 取走草稿并收起来(提交时用;`None` = 当前没有草稿)。
pub fn take_rename(&mut self) -> Option<RenameDraft> {
self.draft.take()
}
/// 取消行内重命名。
pub fn cancel_rename(&mut self) {
self.draft = None;
}
/// 记下「删除确认框正在等谁」:**框的 id + 世界名**(`dialog` 由调用方 `dialogs.push` 拿到)。
pub fn ask_delete(&mut self, dialog: DialogId, dir: &str) {
self.confirm = Some((dialog, dir.to_string()));
self.closed_confirm = None;
}
/// 取走确认目标(点「确定」时用)——**只核销 `current` 那一个框**。
///
/// 「取走即清」+「认框不认人」两条合起来是**安全不变量**:
/// ①同一次确认连点两下只发一条删除(第二下拿不到目标);
/// ②别的框(崩溃卡片的「导出诊断包」、进度框……)点主按钮**拿不走**这个目标——
/// 哪怕某条关闭路径漏清(复审 r1 P1-1 的事故形态),也只能核销它自己那个框。
pub fn take_confirm(&mut self, current: DialogId) -> Option<String> {
match &self.confirm {
Some((dialog, _)) if *dialog == current => {
self.closed_confirm = None;
self.confirm.take().map(|(_, dir)| dir)
}
_ => None,
}
}
/// **某个**确认框被取消/关掉(次按钮、遮罩、`Esc` 三条路径都走它):**只清它自己那个目标**。
///
/// 为什么不无条件清(复审 r2 的加固):`clear_confirm` 不认框,理论上够得着"清掉别人的删除目标"
/// ——今天三条关闭路径关的都是当前框,够不着,但把判据写死在这里比依赖调用顺序稳。
pub fn close_confirm(&mut self, current: DialogId) {
if self.confirm_dialog() == Some(current) {
self.clear_confirm();
}
}
/// 清掉目标(**无条件**):换实例 / 复位时用——那时"哪个框"已经不重要了。
pub fn clear_confirm(&mut self) {
self.confirm = None;
self.closed_confirm = None;
}
/// 取走「因为世界已经不在快照里而作废」的那个框(app 层据此收框,避免主按钮空操作)。
pub fn take_closed_confirm(&mut self) -> Option<DialogId> {
self.closed_confirm.take()
}
/// 当前确认目标属于哪个框(`None` = 没有在等的目标)。
#[must_use]
pub fn confirm_dialog(&self) -> Option<DialogId> {
self.confirm.as_ref().map(|(dialog, _)| *dialog)
}
/// 标记一次写操作在飞(草稿同时收起来:请求已经发出去了)。
pub fn mark_pending(&mut self, op: SavesOp) {
self.pending = Some(op);
self.draft = None;
}
/// 这个世界的写操作在飞吗(用来把那一行置灰)。
#[must_use]
pub fn is_pending(&self, dir: &str) -> bool {
self.pending.as_ref().is_some_and(|op| op.dir() == dir)
}
}
+252
View File
@@ -0,0 +1,252 @@
//! `SavesState` 的判据:绑定 / 迟到丢弃 / 三样交互态(含删除确认的安全不变量)。
//!
//! 从 `state/saves.rs` 拆出(父模块守 400 行)。**判据一条没改**,另外补了复审 r1 P1-1 / P2-1
//! 那两条:认框不认人、无关快照不再吃掉确认目标。
use super::*;
use crate::dto::{SavesFailureKind, WorldView};
fn view(instance: &str) -> SavesListView {
SavesListView {
instance: instance.to_string(),
dir: "C:/mc/saves".to_string(),
exists: true,
worlds: vec![WorldView {
dir: "World".to_string(),
icon: false,
modified_ms: None,
}],
screenshots_dir: "C:/mc/screenshots".to_string(),
screenshots_exists: false,
screenshots: Vec::new(),
skipped: Vec::new(),
last_op: None,
}
}
fn failure(instance: &str) -> SavesFailureView {
SavesFailureView {
instance: instance.to_string(),
file: String::new(),
op: None,
kind: SavesFailureKind::DirUnusable,
detail: "Access is denied.".to_string(),
}
}
/// 换实例必须清空(A 的存档不许画在 B 的详情页上)。
#[test]
fn binding_another_instance_drops_the_old_view() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.changed(view("a"));
assert!(saves.has_view());
saves.bind(Some("b"));
assert!(!saves.has_view(), "换实例后还留着 A 的存档");
assert_eq!(saves.bound.as_deref(), Some("b"));
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
saves.changed(view("b"));
saves.bind(Some("b"));
assert!(saves.has_view());
}
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败(重试成功后红字要消失)。
#[test]
fn late_results_are_dropped_and_success_clears_the_failure() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.changed(view("b"));
assert!(!saves.has_view(), "B 的列表画到了 A 上");
saves.failed(failure("a"));
assert!(saves.failed.is_some());
assert!(saves.bound.is_some(), "失败不许把绑定清掉(要能重试)");
saves.changed(view("a"));
assert!(saves.failed.is_none(), "成功后旧失败没清掉");
// 不在详情页(`bound = None`)时任何结果都不落地。
let mut idle = SavesState::default();
idle.changed(view("a"));
idle.failed(failure("a"));
assert!(!idle.has_view());
assert!(idle.failed.is_none());
}
/// 换实例要把三样交互态一起清掉(草稿 / 在飞 / 确认目标)。
///
/// 抓得住:换到 B 之后「A 的草稿」还留着 ⇒ 用户点确定会去改 B 页上的一个不存在的名字。
#[test]
fn switching_instances_drops_draft_pending_and_confirm() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.changed(view("a"));
saves.start_rename("World");
saves.edit_rename("Renamed".to_string());
saves.ask_delete(7, "World");
saves.mark_pending(SavesOp::Backup {
dir: "World".to_string(),
});
assert!(saves.draft.is_none(), "发请求时草稿该收起来");
assert!(saves.pending.is_some());
saves.bind(Some("b"));
assert!(saves.draft.is_none(), "换实例后草稿还在");
assert!(saves.pending.is_none(), "换实例后在飞标记还在");
assert!(saves.confirm.is_none(), "换实例后删除确认目标还在");
}
/// 回程(成功或失败)都要把「在飞」收掉:按钮不能永远灰着。
#[test]
fn both_outcomes_clear_the_pending_marker() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.changed(view("a"));
saves.mark_pending(SavesOp::Delete {
dir: "World".to_string(),
});
assert!(saves.is_pending("World"));
saves.failed(failure("a"));
assert!(
!saves.is_pending("World"),
"失败后在飞标记没清:重试按钮会一直是灰的"
);
saves.mark_pending(SavesOp::Rename {
dir: "World".to_string(),
});
assert!(saves.is_pending("World"));
saves.changed(view("a"));
assert!(!saves.is_pending("World"), "成功后没清在飞标记");
}
/// 确认目标只发一次(同一次确认连点两下不该发两条删除)。
#[test]
fn the_delete_confirmation_hands_out_once() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.ask_delete(7, "Doomed");
assert_eq!(saves.take_confirm(7).as_deref(), Some("Doomed"));
assert!(
saves.take_confirm(7).is_none(),
"第二次取还拿得到:连点会删两次"
);
saves.ask_delete(8, "Other");
saves.clear_confirm();
assert!(saves.take_confirm(8).is_none(), "取消之后还留着目标");
}
/// **复审 r2 加固**:别人那个框被关掉时,不该清掉我们的删除目标。
///
/// `close_confirm(current)` 只在 `current` 等于登记的那个框时才清;`clear_confirm()` 仍然无条件
/// (换实例复位时用——那时"哪个框"已经不重要了)。
#[test]
fn closing_another_dialog_leaves_our_target_alone() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.ask_delete(7, "Doomed");
saves.close_confirm(99);
assert_eq!(
saves.take_confirm(7).as_deref(),
Some("Doomed"),
"别的框关掉时把我们的目标清掉了"
);
saves.ask_delete(8, "Other");
saves.close_confirm(8);
assert!(
saves.take_confirm(8).is_none(),
"自己的框关掉了,目标还在(下一次点确定会删上一个)"
);
}
/// **P1-1 的核心**:目标只属于登记它的那个框。别的框点主按钮**拿不走**它。
///
/// 事故形态:`Esc` 关掉删除确认框(某条关闭路径漏清)→ 崩溃卡片弹出来 →
/// 点「导出诊断包」(也是 `Confirm` 的主按钮)→ 若 `take_confirm` 无条件交付,
/// 那个世界就被静默删掉了。判据是 **id 不等就不交付、也不清**(目标还是那个框的)。
#[test]
fn a_dialog_can_only_redeem_its_own_target() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.ask_delete(7, "Doomed");
assert!(
saves.take_confirm(99).is_none(),
"别的框的主按钮拿走了删除目标"
);
assert_eq!(saves.confirm_dialog(), Some(7), "拿不走也不该被顺手清掉");
assert_eq!(
saves.take_confirm(7).as_deref(),
Some("Doomed"),
"自己的框反而兑不了"
);
}
/// **P2-1**:一拍无关快照(世界还在)不许把确认目标吃掉——否则主按钮就是静默空操作。
#[test]
fn an_unrelated_snapshot_no_longer_drops_the_confirmation() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.changed(view("a"));
saves.ask_delete(7, "World");
// 「打开存档文件夹」的回程 / 备份在飞时的列表刷新:世界还在快照里。
saves.changed(view("a"));
assert_eq!(
saves.take_confirm(7).as_deref(),
Some("World"),
"无关快照把确认目标吃掉了:主按钮会变成静默空操作"
);
// 读盘失败同理:一条失败不该把用户刚点出来的框弄成"点了没反应"。
saves.ask_delete(8, "World");
saves.failed(failure("a"));
assert_eq!(saves.take_confirm(8).as_deref(), Some("World"));
}
/// **P2-1 的另一半**:被确认的世界从快照里消失 ⇒ 目标清掉,并且**登记那个框该收了**。
///
/// 只清不登记就是"框还在、主按钮点了什么也不发生";只登记不清就是留着一个作废的目标。
#[test]
fn the_confirmation_is_dropped_and_reported_when_the_world_vanishes() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.changed(view("a"));
saves.ask_delete(7, "World");
// 快照回来时那个世界已经不在列表里(别的进程删掉了 / 改名了)。
let mut gone = view("a");
gone.worlds.clear();
saves.changed(gone);
assert!(saves.confirm.is_none(), "目标还留着一个已经不存在的世界");
assert_eq!(
saves.take_closed_confirm(),
Some(7),
"没有登记那个框 ⇒ 它会一直挡在屏幕中央、主按钮点了没反应"
);
assert!(saves.take_confirm(7).is_none(), "作废的目标还能被兑现");
assert!(saves.take_closed_confirm().is_none(), "信号只发一次");
}
/// 行内草稿照原样存:非法名字不在前端过滤(core 会拒并给出具名原因)。
#[test]
fn the_rename_draft_keeps_the_typed_text_verbatim() {
let mut saves = SavesState::default();
saves.bind(Some("a"));
saves.start_rename("World");
assert_eq!(
saves.draft.as_ref().expect("草稿").text,
"World",
"应预填当前名字"
);
saves.edit_rename("../escape".to_string());
let draft = saves.take_rename().expect("草稿");
assert_eq!(draft.dir, "World");
assert_eq!(draft.text, "../escape", "输入被前端偷偷改了");
assert!(saves.take_rename().is_none(), "取走之后草稿还在");
}
+50
View File
@@ -0,0 +1,50 @@
//! 磁贴网格的纯函数与单元类型(从 `state.rs` 原样搬出,守 400 行硬线)。
//!
//! **纯搬移**:类型、函数、文档一字未改,只是换了文件;`state.rs` 用 `pub use` 再导出,
//! 于是 `crate::state::{TileCell, grid_positions}` 这两个老路径继续可用。
//!
//! M224 又把 [`AppState::tiles`] 从 `state.rs` 搬到这里(同一个 400 行原因):筛选 + 排版是
//! 磁贴域自己的事,方法留在 `impl AppState` 的另一个块里,调用点 `state.tiles()` 不变。
use crate::dto::InstanceSummary;
use super::AppState;
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileCell<'a> {
/// 实例数据
pub instance: &'a InstanceSummary,
/// 网格行号(0 起)
pub row: usize,
/// 网格列号(0 起)
pub col: usize,
}
/// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。
///
/// 特意放在 Rust 侧:声明式 UI 的表达式里做整数除法容易引出隐式浮点,且这段逻辑可以单测。
pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
let columns = columns.max(1);
(0..count)
.map(|index| (index / columns, index % columns))
.collect()
}
impl AppState {
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase();
let filtered: Vec<&InstanceSummary> = self
.instances
.iter()
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
.collect();
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
filtered
.into_iter()
.zip(positions)
.map(|(instance, (row, col))| TileCell { instance, row, col })
.collect()
}
}