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 空转没有意义。缓存成字段是为了让
|
||||
//! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::window;
|
||||
|
||||
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::prefs::ThemeMode;
|
||||
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 实例」这个条件续订。
|
||||
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
|
||||
pub(super) static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
/// 首帧日志只打一次。
|
||||
pub(super) static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
mod boot_probe;
|
||||
|
||||
use boot_probe::app_options;
|
||||
pub(crate) use boot_probe::{log_first_frame, STARTED};
|
||||
|
||||
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
|
||||
pub struct App {
|
||||
@@ -376,26 +374,3 @@ impl App {
|
||||
&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)]
|
||||
mod tests {
|
||||
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 页不高亮任何导航项");
|
||||
}
|
||||
}
|
||||
#[path = "view/tests.rs"]
|
||||
mod tests;
|
||||
@@ -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::dialogs::DialogQueue;
|
||||
use crate::dto::{
|
||||
AccountView, AgentEvent, AppEvent, ContentQuery, ContentResults, CoreEvent, DeviceLoginView,
|
||||
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView,
|
||||
LoaderKind, LogLine, LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
|
||||
AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport,
|
||||
InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine,
|
||||
LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
|
||||
};
|
||||
use crate::prefs::UiPrefs;
|
||||
// 内容库的视图态与其 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`])。
|
||||
///
|
||||
/// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;
|
||||
@@ -423,5 +388,7 @@ impl AppState {
|
||||
#[path = "state_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
mod content;
|
||||
|
||||
#[cfg(test)]
|
||||
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