refactor(m109): rebase 到 5adac23 后按功能拆两条超 400 行的文件
rebase 到 M107/M108 之后的 main(5adac23),并集解冲突后 `app/state.rs`(401)与 `app/view.rs`(402)双双顶破 400 硬线。按功能拆,不压注释: - `app/state/boot_probe.rs`:启动期取证两件——`app_options`(启动参数 → 后端选项)与 `log_first_frame`(首帧耗时一行)+ 它们的两个全局量 `STARTED` / `FIRST_FRAME_LOGGED`; `state.rs` 用 `pub(crate) use` 再导出,`app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的路径一字未变。state.rs 401 → 376。 - `app/view/tests.rs`:`view.rs` 的守门测试独立成文件(`#[path] mod tests;`)。**内容取自 当前 main 的测试体**(M106 的 `Lang` 形参 / `at()` 入口都在),不是旧分支那一份;view.rs 402 → 255。 - `presenter/src/state/content.rs` 与 `presenter/src/state/content_tests.rs`:上一轮已拆,本轮不变。 门禁(新 tip,`-j 2`,逐条前台跑并落盘退出码):fmt 0 / clippy -D warnings 0 / `cargo test -p unlucky-app-iced --lib` 0(522 passed)/ `cargo test --workspace --exclude unlucky-app` 0。
This commit is contained in:
1 parent
42099d4336
commit
1c5283058b
6 files changed
+246
-217
No files matched your search
@@ -13,14 +13,12 @@
|
|||||||
//! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让
|
//! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让
|
||||||
//! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。
|
//! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use iced::window;
|
use iced::window;
|
||||||
|
|
||||||
use unlucky_design::color::SchemeFlavor;
|
use unlucky_design::color::SchemeFlavor;
|
||||||
use unlucky_presenter::backend::{build_backend, config_path, AppOptions};
|
use unlucky_presenter::backend::{build_backend, config_path};
|
||||||
use unlucky_presenter::dto::Backend;
|
use unlucky_presenter::dto::Backend;
|
||||||
use unlucky_presenter::prefs::ThemeMode;
|
use unlucky_presenter::prefs::ThemeMode;
|
||||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||||
@@ -59,10 +57,10 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
|
|||||||
/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。
|
/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。
|
||||||
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
|
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
|
|
||||||
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
|
mod boot_probe;
|
||||||
pub(super) static STARTED: OnceLock<Instant> = OnceLock::new();
|
|
||||||
/// 首帧日志只打一次。
|
use boot_probe::app_options;
|
||||||
pub(super) static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
|
pub(crate) use boot_probe::{log_first_frame, STARTED};
|
||||||
|
|
||||||
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
|
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -376,26 +374,3 @@ impl App {
|
|||||||
&self.ui
|
&self.ui
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动参数 → presenter 的后端选项。
|
|
||||||
fn app_options(options: &Options) -> AppOptions {
|
|
||||||
AppOptions {
|
|
||||||
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
|
|
||||||
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
|
|
||||||
data_root: options.data_dir.clone(),
|
|
||||||
mock: options.screenshot.is_some(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
|
|
||||||
pub(super) fn log_first_frame() {
|
|
||||||
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(started) = STARTED.get() {
|
|
||||||
eprintln!(
|
|
||||||
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
|
|
||||||
started.elapsed().as_secs_f64() * 1000.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//! 启动期取证:后端选项的换算 + 首帧耗时那一行日志。
|
||||||
|
//!
|
||||||
|
//! 从 `app/state.rs` 拆出(rebase 到 5adac23 之后 `state.rs` 顶到 401 行)。这两件事都属于
|
||||||
|
//! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出,
|
||||||
|
//! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::cli::Options;
|
||||||
|
use unlucky_presenter::backend::AppOptions;
|
||||||
|
|
||||||
|
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
|
||||||
|
pub(crate) static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||||
|
/// 首帧日志只打一次。
|
||||||
|
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 启动参数 → presenter 的后端选项。
|
||||||
|
pub(super) fn app_options(options: &Options) -> AppOptions {
|
||||||
|
AppOptions {
|
||||||
|
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
|
||||||
|
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
|
||||||
|
data_root: options.data_dir.clone(),
|
||||||
|
mock: options.screenshot.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
|
||||||
|
pub(crate) fn log_first_frame() {
|
||||||
|
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(started) = STARTED.get() {
|
||||||
|
eprintln!(
|
||||||
|
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
|
||||||
|
started.elapsed().as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -251,152 +251,5 @@ pub(super) fn last_played(raw: Option<&str>) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "view/tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress};
|
|
||||||
use unlucky_presenter::mock_data::mock_instances;
|
|
||||||
|
|
||||||
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
|
||||||
InstanceSummary {
|
|
||||||
id: "survival".to_string(),
|
|
||||||
name: "Survival".to_string(),
|
|
||||||
mc_version: "1.20.4".to_string(),
|
|
||||||
loader: LoaderInfo {
|
|
||||||
kind: LoaderKind::Fabric,
|
|
||||||
version: "0.16.5".to_string(),
|
|
||||||
},
|
|
||||||
last_played: Some("2026-10-29T19:22:00Z".to_string()),
|
|
||||||
runtime,
|
|
||||||
icon: None,
|
|
||||||
isolation: "isolated".to_string(),
|
|
||||||
version_id: "1.20.4".to_string(),
|
|
||||||
version_present: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。
|
|
||||||
///
|
|
||||||
/// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层,
|
|
||||||
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
|
|
||||||
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
|
|
||||||
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
|
|
||||||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
|
|
||||||
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
|
|
||||||
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
|
|
||||||
let page = pages::content::PageState::default();
|
|
||||||
model(
|
|
||||||
state,
|
|
||||||
1180.0,
|
|
||||||
forced,
|
|
||||||
pages::Category::All,
|
|
||||||
&draft,
|
|
||||||
lang,
|
|
||||||
&page,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_forced_state_maps_to_the_expected_interaction() {
|
|
||||||
let state = AppState::new();
|
|
||||||
let forced = |forced| at(&state, Some(forced)).forced;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
forced(crate::cli::ForcedState::Rest),
|
|
||||||
Some(Interaction::Enabled),
|
|
||||||
"Rest = 静息态(压掉系统光标 hover)"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
forced(crate::cli::ForcedState::Hover),
|
|
||||||
Some(Interaction::Hovered)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
forced(crate::cli::ForcedState::Pressed),
|
|
||||||
Some(Interaction::Pressed)
|
|
||||||
);
|
|
||||||
assert_eq!(at(&state, None).forced, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_snapshot_becomes_the_page_model() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
|
||||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
|
|
||||||
));
|
|
||||||
state.navigate(Route::Detail("survival".to_string()));
|
|
||||||
|
|
||||||
let model = at(&state, None);
|
|
||||||
assert_eq!(model.screen, pages::Screen::Instances);
|
|
||||||
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
|
|
||||||
assert_eq!(model.window_width, 1180.0);
|
|
||||||
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
|
|
||||||
assert_eq!(model.instances.rows[0].id, "survival");
|
|
||||||
|
|
||||||
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
|
|
||||||
assert_eq!(detail.id, "survival");
|
|
||||||
assert_eq!(detail.meta.len(), 6);
|
|
||||||
assert!(detail.can_launch, "空闲实例可启动");
|
|
||||||
assert!(!detail.can_stop);
|
|
||||||
assert!(detail.note.is_none(), "真快照没有正文段");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_busy_instance_offers_stop_instead_of_play() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
|
||||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
|
|
||||||
InstanceRuntime::Busy {
|
|
||||||
stage: LaunchStage::Download,
|
|
||||||
progress: Progress {
|
|
||||||
done: 750,
|
|
||||||
total: 1_500,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)]),
|
|
||||||
));
|
|
||||||
state.navigate(Route::Detail("survival".to_string()));
|
|
||||||
|
|
||||||
let model = at(&state, None);
|
|
||||||
assert_eq!(
|
|
||||||
model.instances.rows[0].status.as_deref(),
|
|
||||||
Some("Downloading · 50%")
|
|
||||||
);
|
|
||||||
let detail = model.instances.detail.expect("有详情");
|
|
||||||
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
|
|
||||||
assert!(detail.can_stop, "启动中要能 Stop");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn failed_instances_can_be_retried() {
|
|
||||||
let failed = instance(InstanceRuntime::Failed {
|
|
||||||
reason: "exit 1".to_string(),
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
runtime_status(&failed.runtime).as_deref(),
|
|
||||||
Some("Launch failed · exit 1")
|
|
||||||
);
|
|
||||||
assert!(detail_of(&failed).can_launch, "失败后要能重试");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn timestamps_are_sliced_not_guessed() {
|
|
||||||
assert_eq!(
|
|
||||||
last_played(Some("2026-10-29T19:22:00Z")),
|
|
||||||
"2026-10-29 19:22 UTC"
|
|
||||||
);
|
|
||||||
assert_eq!(last_played(None), "Never");
|
|
||||||
// 短串 / 非 ASCII 原样返回(不 panic)
|
|
||||||
assert_eq!(last_played(Some("2026-10")), "2026-10");
|
|
||||||
assert_eq!(last_played(Some("昨天")), "昨天");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_agent_route_has_no_page() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
state.navigate(Route::Agent);
|
|
||||||
let model = at(&state, None);
|
|
||||||
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
|
|
||||||
assert_eq!(pages::Screen::of(state.route()), None);
|
|
||||||
assert!(model.instances.detail.is_none());
|
|
||||||
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! `app/view.rs` 的守门测试(rebase 到 5adac23 之后 view.rs 贴 400 行上限,按「测试独立成文件」拆出)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress};
|
||||||
|
use unlucky_presenter::mock_data::mock_instances;
|
||||||
|
|
||||||
|
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||||
|
InstanceSummary {
|
||||||
|
id: "survival".to_string(),
|
||||||
|
name: "Survival".to_string(),
|
||||||
|
mc_version: "1.20.4".to_string(),
|
||||||
|
loader: LoaderInfo {
|
||||||
|
kind: LoaderKind::Fabric,
|
||||||
|
version: "0.16.5".to_string(),
|
||||||
|
},
|
||||||
|
last_played: Some("2026-10-29T19:22:00Z".to_string()),
|
||||||
|
runtime,
|
||||||
|
icon: None,
|
||||||
|
isolation: "isolated".to_string(),
|
||||||
|
version_id: "1.20.4".to_string(),
|
||||||
|
version_present: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。
|
||||||
|
///
|
||||||
|
/// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层,
|
||||||
|
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
|
||||||
|
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
|
||||||
|
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
|
||||||
|
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
|
||||||
|
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
|
||||||
|
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
|
||||||
|
let page = pages::content::PageState::default();
|
||||||
|
model(
|
||||||
|
state,
|
||||||
|
1180.0,
|
||||||
|
forced,
|
||||||
|
pages::Category::All,
|
||||||
|
&draft,
|
||||||
|
lang,
|
||||||
|
&page,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_forced_state_maps_to_the_expected_interaction() {
|
||||||
|
let state = AppState::new();
|
||||||
|
let forced = |forced| at(&state, Some(forced)).forced;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
forced(crate::cli::ForcedState::Rest),
|
||||||
|
Some(Interaction::Enabled),
|
||||||
|
"Rest = 静息态(压掉系统光标 hover)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
forced(crate::cli::ForcedState::Hover),
|
||||||
|
Some(Interaction::Hovered)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
forced(crate::cli::ForcedState::Pressed),
|
||||||
|
Some(Interaction::Pressed)
|
||||||
|
);
|
||||||
|
assert_eq!(at(&state, None).forced, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_snapshot_becomes_the_page_model() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||||
|
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
|
||||||
|
));
|
||||||
|
state.navigate(Route::Detail("survival".to_string()));
|
||||||
|
|
||||||
|
let model = at(&state, None);
|
||||||
|
assert_eq!(model.screen, pages::Screen::Instances);
|
||||||
|
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
|
||||||
|
assert_eq!(model.window_width, 1180.0);
|
||||||
|
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
|
||||||
|
assert_eq!(model.instances.rows[0].id, "survival");
|
||||||
|
|
||||||
|
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
|
||||||
|
assert_eq!(detail.id, "survival");
|
||||||
|
assert_eq!(detail.meta.len(), 6);
|
||||||
|
assert!(detail.can_launch, "空闲实例可启动");
|
||||||
|
assert!(!detail.can_stop);
|
||||||
|
assert!(detail.note.is_none(), "真快照没有正文段");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_busy_instance_offers_stop_instead_of_play() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||||
|
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
|
||||||
|
InstanceRuntime::Busy {
|
||||||
|
stage: LaunchStage::Download,
|
||||||
|
progress: Progress {
|
||||||
|
done: 750,
|
||||||
|
total: 1_500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
));
|
||||||
|
state.navigate(Route::Detail("survival".to_string()));
|
||||||
|
|
||||||
|
let model = at(&state, None);
|
||||||
|
assert_eq!(
|
||||||
|
model.instances.rows[0].status.as_deref(),
|
||||||
|
Some("Downloading · 50%")
|
||||||
|
);
|
||||||
|
let detail = model.instances.detail.expect("有详情");
|
||||||
|
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
|
||||||
|
assert!(detail.can_stop, "启动中要能 Stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_instances_can_be_retried() {
|
||||||
|
let failed = instance(InstanceRuntime::Failed {
|
||||||
|
reason: "exit 1".to_string(),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
runtime_status(&failed.runtime).as_deref(),
|
||||||
|
Some("Launch failed · exit 1")
|
||||||
|
);
|
||||||
|
assert!(detail_of(&failed).can_launch, "失败后要能重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timestamps_are_sliced_not_guessed() {
|
||||||
|
assert_eq!(
|
||||||
|
last_played(Some("2026-10-29T19:22:00Z")),
|
||||||
|
"2026-10-29 19:22 UTC"
|
||||||
|
);
|
||||||
|
assert_eq!(last_played(None), "Never");
|
||||||
|
// 短串 / 非 ASCII 原样返回(不 panic)
|
||||||
|
assert_eq!(last_played(Some("2026-10")), "2026-10");
|
||||||
|
assert_eq!(last_played(Some("昨天")), "昨天");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_agent_route_has_no_page() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.navigate(Route::Agent);
|
||||||
|
let model = at(&state, None);
|
||||||
|
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
|
||||||
|
assert_eq!(pages::Screen::of(state.route()), None);
|
||||||
|
assert!(model.instances.detail.is_none());
|
||||||
|
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
use crate::agent::AgentRail;
|
use crate::agent::AgentRail;
|
||||||
use crate::dialogs::DialogQueue;
|
use crate::dialogs::DialogQueue;
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
AccountView, AgentEvent, AppEvent, ContentQuery, ContentResults, CoreEvent, DeviceLoginView,
|
AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport,
|
||||||
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView,
|
InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine,
|
||||||
LoaderKind, LogLine, LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
|
LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
|
||||||
};
|
};
|
||||||
use crate::prefs::UiPrefs;
|
use crate::prefs::UiPrefs;
|
||||||
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
|
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
|
||||||
@@ -279,41 +279,6 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 内容库结果落地(M109):同条件换页 ⇒ **追加**;换条件 ⇒ **整批替换**;迟到 ⇒ 丢掉。
|
|
||||||
///
|
|
||||||
/// 这三条就是「列表画的永远是当前筛选条件」的全部保证——页面自己没有过滤逻辑,也没有第二份真相。
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。
|
/// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。
|
||||||
///
|
///
|
||||||
/// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;
|
/// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;
|
||||||
@@ -423,5 +388,7 @@ impl AppState {
|
|||||||
#[path = "state_tests.rs"]
|
#[path = "state_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
mod content;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod content_tests;
|
mod content_tests;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! 内容库的状态机(M109):结果落地与条件提交两件事。
|
||||||
|
//!
|
||||||
|
//! 从 `state.rs` 拆出(那个文件重新合流 M105 之后贴着 400 行上限)。放在 `impl AppState` 的
|
||||||
|
//! **另一个 impl 块**里,路径与可见性都不变:`apply_core` 直接调 `self.apply_content(..)`。
|
||||||
|
|
||||||
|
use crate::dto::{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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user