From 42099d4336f282ad50bb648db05264071a2015c5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:18:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M109=20r1=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20CF=20=E6=9D=A5=E6=BA=90=E8=A3=85=E5=85=A5=E9=97=B8=E9=97=A8?= =?UTF-8?q?=E3=80=81has=5Fmore=20=E8=AF=BB=E6=9C=80=E8=BF=91=E4=B8=80?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E6=9C=AB=E8=A1=8C=E5=88=87=E5=8D=8A=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=A1=88/=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 p1-2items 的两条 P1 与三条 P2,逐条收口。 `blockers_of` 只看版本/加载器/目标实例,不看来源;而 host 的 `resolve_file` 对 CF 直接 返回「未接通」⇒ 切到 CF 源后每条都必然失败,UI 一声不响。 - 新增 `Blocker::SourceNotWired` + 文案 `ContentSourceNotWired`(中英); - `blockers_of` 的入参从 `Option<(&[String], &[String])>` 收成 `Option`(版本 / 加载器 / **来源**),两处调用点(产品路径、演示数据)一起改; - 判据同构于整合包的 `NeedsPipeline`:**点之前**禁用并给一条原因;CF 文件端点接通后删掉这条。 DTO 的 `has_more()` 判「本页装满」是对的,但 reducer 在 `offset>0` 时先 extend 再判, 被判定的是**累计条数** ⇒ 首页满 20 条之后恒真:「加载更多」永不消失、「没有更多了」在 真实翻页路径上不可达,且每点一次白发一次网络请求。 - `ContentState` 新增 `last_page_filled`(**先**按这一页记一位,再并进累计列表); - 页面的 `has_more` 读它(`&& !pending`);换条件(offset 归零)时清位; - 新增 `crates/presenter/src/state/content_tests.rs` 四条用例(state.rs 贴 400 行上限,拆文件): `a_short_last_page_clears_has_more` / `an_empty_last_page_keeps_has_more_false` / `changing_the_query_resets_the_page_flag` / `a_stale_batch_does_not_touch_the_page_flag`; - 变异自证:把判据改回「读累计」后前两条红(`末页不满 ⇒ 不许说还有更多`、`空页 ⇒ 没有更多`), 改回绿。 - **末行被 footer 切半**(复审定位的真因):`view.rs` 的 body 列漏了 `.spacing(..)`,而列表 纵向内边距是 0 ⇒ 视口底边正好切在半行上。补 `Space::X2`,并说明为什么必须给。 - **文案去掉开发者术语与内部编号**:`ContentSourceUnavailable` 不再写 `ULL_CF_API_KEY` (改「CurseForge 需要在设置里配置密钥才能搜索」);`ContentModpackNotReady` 不再写 mission 编号(改「整合包安装尚未开放」);host 侧那条 `InstallProgress` 失败文案同步去编号。 - **图标**:四类改为 `Folder` / `Plus` / `Restore` / `Expand`——不借用导航五枚(同屏撞形, 复审 P2-3)、不用填充字形(`no_hardcoded_tokens` 门禁);偏离表按**实际取值**重写,并补一条 「CF 的装入也是禁用态」的登记。 - 演示数据加 `ULL_CONTENT_STATE=cf` 一屏,出图证明 P1-1 的禁用态(`12-cf-not-wired-light.png`)。 门禁(逐条落盘退出码,不走管道):fmt 0 / clippy `-D warnings`(app-iced+presenter+i18n)0 / `cargo test -p unlucky-app-iced --lib` 0(451 passed)/ `cargo test -p unlucky-presenter` 0 (174 passed)/ `cargo test --workspace --exclude unlucky-app` 0。改动文件最大 397 行。 取证:`DevDocs/artifacts/m109/`(12 张,全部重出) --- crates/app-iced/src/pages/content.rs | 5 +- crates/app-iced/src/pages/content/demo.rs | 16 ++- crates/app-iced/src/pages/content/model.rs | 27 ++-- .../app-iced/src/pages/content/model/items.rs | 19 ++- .../app-iced/src/pages/content/model/tests.rs | 21 ++- crates/app-iced/src/pages/content/parts.rs | 13 +- crates/app-iced/src/pages/content/view.rs | 23 ++-- crates/i18n/src/catalog/content.rs | 12 +- crates/i18n/src/catalog/golden.rs | 5 +- crates/presenter/src/dto/content.rs | 7 + crates/presenter/src/host/addon/install.rs | 2 +- crates/presenter/src/state.rs | 6 + crates/presenter/src/state/content_tests.rs | 130 ++++++++++++++++++ 13 files changed, 246 insertions(+), 40 deletions(-) create mode 100644 crates/presenter/src/state/content_tests.rs diff --git a/crates/app-iced/src/pages/content.rs b/crates/app-iced/src/pages/content.rs index e43c1fa..27bec5b 100644 --- a/crates/app-iced/src/pages/content.rs +++ b/crates/app-iced/src/pages/content.rs @@ -42,9 +42,10 @@ //! //! | # | 偏离 | 为什么 | 何时收口 | //! |---|---|---|---| -//! | 1 | 四种内容类型借用现有字形(`Folder`/`Instances`/`Download`/`Play`) | 设计令牌的图标集还没有这四枚 | 图标集补「整合包 / Mod / 资源包 / 光影」时 | +//! | 1 | 四种内容类型借用现有字形(`Folder` / `Plus` / `Restore` / `Expand`,见 `parts::kind_icon`) | 设计令牌的 28 枚图标里没有这四类内容,且**不许**借用导航那五枚(同屏撞形)、不许用填充字形(门禁) | 图标集补「整合包 / Mod / 资源包 / 光影」时 | //! | 2 | 本页自带一份栏几何(`content/layout.rs`) | M107 的 `pages/layout/rails.rs` 未并入本分支的 base | M107 合并后改为复用、删掉本文件 | -//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线(M111) | M111 落地后接同一入口 | +//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 | +//! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 | //! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 | mod demo; diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs index b6a263f..2bbbb44 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -18,7 +18,7 @@ use unlucky_i18n::{Catalog, Lang}; use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind}; -use super::model::{blockers_of, Content, ContentQueryView, Hit, Status, Target}; +use super::model::{blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target}; use crate::pages::download::Download; /// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。 @@ -155,11 +155,19 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { hits(kind, catalog, state != "incompatible") }; // 「不兼容」那一屏:选中的那条是 1.21.1 + Forge 的项目,而目标是 1.20.1 + Fabric - let selected_index = if state == "incompatible" { 2 } else { 0 }; + // 「CF 未接通」那一屏:选中的那条来自 CurseForge(文件端点还没接,按钮必须禁用) + let selected_index = match state.as_str() { + "incompatible" => 2, + "cf" => 1, + _ => 0, + }; let selected = list.get(selected_index).map(|hit| hit.id.clone()); let blockers = blockers_of( - list.get(selected_index) - .map(|hit| (hit.versions.as_slice(), hit.loaders.as_slice())), + list.get(selected_index).map(|hit| HitFacts { + versions: hit.versions.as_slice(), + loaders: hit.loaders.as_slice(), + source: hit.source, + }), kind, &targets, target.as_deref(), diff --git a/crates/app-iced/src/pages/content/model.rs b/crates/app-iced/src/pages/content/model.rs index 3d48429..0cb05b8 100644 --- a/crates/app-iced/src/pages/content/model.rs +++ b/crates/app-iced/src/pages/content/model.rs @@ -23,7 +23,7 @@ //! 它们的所有者是 `app/state.rs` 的 `App::content`,本文件只借用。 use unlucky_presenter::dto::{ - loader_facet, ContentKind, ContentResults, ContentSort, ContentSource, ContentState, LoaderKind, + loader_facet, ContentKind, ContentSort, ContentSource, ContentState, LoaderKind, }; use unlucky_presenter::state::AppState; @@ -33,6 +33,7 @@ use crate::pages::download::Download; mod items; +pub(crate) use items::HitFacts; pub(crate) use items::{capitalize, thousands}; pub use items::{loader_name, Blocker, Hit, PageState, Status, Target}; @@ -133,7 +134,11 @@ impl Content { .find(|hit| hit.id == id) }); let blockers = blockers_of( - selected_hit.map(|hit| (hit.game_versions.as_slice(), hit.loaders.as_slice())), + selected_hit.map(|hit| HitFacts { + versions: hit.game_versions.as_slice(), + loaders: hit.loaders.as_slice(), + source: hit.source, + }), page.kind, &targets, target.as_deref(), @@ -152,11 +157,8 @@ impl Content { selected: page.selected.clone(), status, total: content.results.as_ref().map_or(0, |results| results.total), - has_more: content - .results - .as_ref() - .is_some_and(ContentResults::has_more) - && !content.pending, + // 读「最近一页是否装满」(不是累计条数):见 `ContentState::last_page_filled` 的文档 + has_more: content.last_page_filled && !content.pending, curseforge: content .results .as_ref() @@ -227,7 +229,7 @@ fn content_install(state: &AppState, catalog: Catalog) -> Option { /// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。 /// **空列表 ≠ 不兼容**:站点没给数据时按「未知」处理,不许把「不知道」画成「不支持」。 pub(super) fn blockers_of( - hit: Option<(&[String], &[String])>, + hit: Option>, kind: ContentKind, targets: &[Target], target: Option<&str>, @@ -239,13 +241,18 @@ pub(super) fn blockers_of( return vec![Blocker::NoTarget]; }; let mut blockers = Vec::new(); - // 整合包:不是「兼容不兼容」的问题,是这条路根本没有(M111) + // 整合包:不是「兼容不兼容」的问题,是这条路根本没有(导入管线未落地) if kind.dir().is_none() { blockers.push(Blocker::NeedsPipeline); } - let Some((versions, loaders)) = hit else { + let Some(facts) = hit else { return blockers; }; + let (versions, loaders) = (facts.versions, facts.loaders); + // 来源的文件端点没通:**必须在点之前**说清楚。放行等于「点完必然失败」(复审 P1-1)。 + if facts.source == ContentSource::Curseforge { + blockers.push(Blocker::SourceNotWired); + } if !versions.is_empty() && !versions.iter().any(|item| item == &target.mc_version) { blockers.push(Blocker::Version { needs: join_or_unknown(versions), diff --git a/crates/app-iced/src/pages/content/model/items.rs b/crates/app-iced/src/pages/content/model/items.rs index 977b5f2..0eef4bf 100644 --- a/crates/app-iced/src/pages/content/model/items.rs +++ b/crates/app-iced/src/pages/content/model/items.rs @@ -178,11 +178,27 @@ pub const fn loader_name(loader: LoaderKind) -> &'static str { } } +/// 选中项的**判定事实**(版本 / 加载器 / 来源)。 +/// +/// 为什么另起一个结构:兼容性判据随着「宿主接通了什么」在长(M109 加了来源), +/// 每次加一条都改一次 `blockers_of` 的形参列表,而调用点有两处(产品路径与演示数据)。 +#[derive(Debug, Clone, Copy)] +pub(crate) struct HitFacts<'a> { + /// 支持的游戏版本(空 = 站点没给) + pub versions: &'a [String], + /// 支持的加载器(空 = 站点没给;**站点原样的小写 facet**) + pub loaders: &'a [String], + /// 来源(决定「文件端点通没通」) + pub source: ContentSource, +} + /// 装不上的原因(**一条一个原因**,可以有多条同时成立)。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum Blocker { - /// 整包类内容不走「单文件进目录」这条路(M111 才有导入管线) + /// 整包类内容不走「单文件进目录」这条路(导入管线还没落地) NeedsPipeline, + /// 这个来源的**文件端点**还没接通(检索通了、拿到下载地址那一跳没通) + SourceNotWired, /// 项目不支持目标实例的游戏版本 Version { /// 项目支持的游戏版本(人读,最多取三个) @@ -212,6 +228,7 @@ impl Blocker { Key::ContentIncompatibleLoader, &[("loader", loader_name(*loader))], ), + Self::SourceNotWired => catalog.text(Key::ContentSourceNotWired).to_string(), Self::NoTarget => catalog.text(Key::ContentNoInstances).to_string(), } } diff --git a/crates/app-iced/src/pages/content/model/tests.rs b/crates/app-iced/src/pages/content/model/tests.rs index cd9ab1b..c82d84f 100644 --- a/crates/app-iced/src/pages/content/model/tests.rs +++ b/crates/app-iced/src/pages/content/model/tests.rs @@ -45,7 +45,11 @@ fn targets() -> Vec { fn blockers_of_hit(hit: &ContentHit, kind: ContentKind, target: Option<&str>) -> Vec { blockers_of( - Some((hit.game_versions.as_slice(), hit.loaders.as_slice())), + Some(HitFacts { + versions: hit.game_versions.as_slice(), + loaders: hit.loaders.as_slice(), + source: hit.source, + }), kind, &targets(), target, @@ -89,6 +93,21 @@ fn site_data_that_is_missing_never_blocks() { assert!(blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1")).is_empty()); } +#[test] +fn a_curseforge_project_is_blocked_because_its_file_endpoint_is_not_wired() { + // 复审 P1-1:检索通了、文件端点没通 ⇒ 必须在**点之前**禁用并说明, + // 否则「切到 CF 源后每条都必然失败,UI 一点提示都没有」。 + let mut cf = hit(&["1.20.1"], &["fabric"]); + cf.source = ContentSource::Curseforge; + assert_eq!( + blockers_of_hit(&cf, ContentKind::Mod, Some("fabric-1.20.1")), + vec![Blocker::SourceNotWired] + ); + // 同样的版本/加载器,Modrinth 那条就没有这条原因(否则等于把整页禁死) + let modrinth = hit(&["1.20.1"], &["fabric"]); + assert!(blockers_of_hit(&modrinth, ContentKind::Mod, Some("fabric-1.20.1")).is_empty()); +} + #[test] fn modpacks_are_blocked_by_the_missing_pipeline_not_by_compatibility() { let hit = hit(&["1.20.1"], &["fabric"]); diff --git a/crates/app-iced/src/pages/content/parts.rs b/crates/app-iced/src/pages/content/parts.rs index 512cb92..45bebfb 100644 --- a/crates/app-iced/src/pages/content/parts.rs +++ b/crates/app-iced/src/pages/content/parts.rs @@ -299,12 +299,17 @@ fn centered<'a>(content: impl Into>) -> Element<'a, Pag /// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标, /// 已在报告里登记为偏离)。 pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind { + // 两条硬约束(都来自门禁与复审): + // ① `Play`/`Stop`/`More` 是**填充**字形,pages 里禁用(`tests/no_hardcoded_tokens`); + // ② **不许与全局导航撞形**:`Instances`/`Download`/`Logs`/`Settings`/`Ai` 五枚在同一个屏上 + // 就在左边那条轨里,页内四类再借用它们会让「同形不同义」(复审 P2-3)。 + // + // 剩下的描边字形里挑语义最近的: + // `Folder`(包=文件夹)/ `Plus`(加装内容=加号)/ `Restore`(双层方框≈贴图分层)/ `Expand`(四散光线)。 match kind { unlucky_presenter::dto::ContentKind::Modpack => IconKind::Folder, - unlucky_presenter::dto::ContentKind::Mod => IconKind::Instances, - unlucky_presenter::dto::ContentKind::Resourcepack => IconKind::Download, - // `Play`/`Stop`/`More` 是**填充**字形,pages 里禁用(tests/no_hardcoded_tokens): - // 光影借用 `Expand`(描边的四散光线),语义最近又不撞语汇 + unlucky_presenter::dto::ContentKind::Mod => IconKind::Plus, + unlucky_presenter::dto::ContentKind::Resourcepack => IconKind::Restore, unlucky_presenter::dto::ContentKind::Shader => IconKind::Expand, } } diff --git a/crates/app-iced/src/pages/content/view.rs b/crates/app-iced/src/pages/content/view.rs index 490d67b..c529b01 100644 --- a/crates/app-iced/src/pages/content/view.rs +++ b/crates/app-iced/src/pages/content/view.rs @@ -176,18 +176,19 @@ fn main_column<'a>( { state } else { - { - let mut list = column![parts::list(ui, catalog, model, rails)] - .width(Length::Fill) - .height(Length::Fill); - // 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高) - if !rails.tier.has_detail() { - list = list - .push(divider::full(ui)) - .push(parts::detail(ui, catalog, model)); - } - list.push(parts::more(ui, catalog, model)).into() + // **必须给间距**(复审 P2-1):这一列原先漏了 `.spacing(..)`,而列表自身的纵向内边距是 0, + // 于是「列表视口底边」与「加载更多」贴在一起,末行正好被切在半行上。 + let mut list = column![parts::list(ui, catalog, model, rails)] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .height(Length::Fill); + // 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高) + if !rails.tier.has_detail() { + list = list + .push(divider::full(ui)) + .push(parts::detail(ui, catalog, model)); } + list.push(parts::more(ui, catalog, model)).into() }; column![head, body] diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index f9140ab..105062b 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -72,12 +72,16 @@ macro_rules! content_entries { ), ContentIncompatibleLoader => ("Not built for {loader}", "不支持 {loader}"), ContentModpackNotReady => ( - "Modpack install comes with the M111 import pipeline", - "整合包安装要等 M111 的导入管线" + "Installing modpacks is not available yet", + "整合包安装尚未开放" ), ContentSourceUnavailable => ( - "Set ULL_CF_API_KEY to search CurseForge", - "要搜 CurseForge 请先设置 ULL_CF_API_KEY" + "CurseForge needs an API key: add one in Settings", + "CurseForge 需要在设置里配置密钥才能搜索" + ), + ContentSourceNotWired => ( + "Installing from CurseForge is not available yet", + "CurseForge 来源的安装尚未开放" ), } } }; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 8c55fb3..b95f360 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -216,6 +216,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("ContentNoInstances", "No instance to install into", "没有可装入的实例"), ("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"), ("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"), - ("ContentModpackNotReady", "Modpack install comes with the M111 import pipeline", "整合包安装要等 M111 的导入管线"), - ("ContentSourceUnavailable", "Set ULL_CF_API_KEY to search CurseForge", "要搜 CurseForge 请先设置 ULL_CF_API_KEY"), + ("ContentModpackNotReady", "Installing modpacks is not available yet", "整合包安装尚未开放"), + ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), + ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), ]; diff --git a/crates/presenter/src/dto/content.rs b/crates/presenter/src/dto/content.rs index 882c01f..8693d57 100644 --- a/crates/presenter/src/dto/content.rs +++ b/crates/presenter/src/dto/content.rs @@ -335,6 +335,12 @@ pub struct ContentState { pub pending: bool, /// 最近一次失败的原文(`None` = 没失败) pub failed: Option, + /// **最近一页**是否装满(`hits.len() >= limit`)。 + /// + /// 为什么不是「累计条数够一页」:分页是**累加**到 `results.hits` 里的,累计值在第 2 页之后 + /// 必然 ≥ limit ⇒ 用累计判会得到「has_more 恒真」:加载更多永不消失、「没有更多了」在真实 + /// 翻页路径上不可达,而且每点一次白发一次网络请求(复审 P1-2)。所以这一位单独记。 + pub last_page_filled: bool, } impl Default for ContentState { @@ -344,6 +350,7 @@ impl Default for ContentState { results: None, pending: false, failed: None, + last_page_filled: false, } } } diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index 5aea786..be4d5db 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -56,7 +56,7 @@ pub(super) fn install( fail( tx, progress, - "整合包安装管线未落地(M111);内容库对整合包只做检索", + "整合包安装尚未开放;内容库对整合包只做检索", epoch, ); return; diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index 2079ef5..f53ffea 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -289,6 +289,8 @@ impl AppState { // 迟到的一批:用户已经改了条件,覆盖上去就会出现「筛选条说 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); @@ -305,6 +307,7 @@ impl AppState { self.content.pending = true; self.content.failed = None; if query.offset == 0 { + self.content.last_page_filled = false; // 换条件(不是换页):先把旧结果清掉,否则加载态会画出上一批命中项。 self.content.results = None; } @@ -419,3 +422,6 @@ impl AppState { #[cfg(test)] #[path = "state_tests.rs"] mod tests; + +#[cfg(test)] +mod content_tests; diff --git a/crates/presenter/src/state/content_tests.rs b/crates/presenter/src/state/content_tests.rs new file mode 100644 index 0000000..e2c612b --- /dev/null +++ b/crates/presenter/src/state/content_tests.rs @@ -0,0 +1,130 @@ +//! 内容库状态机的守门测试(M109;复审 P1-2 的补课)。 +//! +//! 从 `state.rs` 拆出(那个文件贴 400 行硬上限)。钉的是**翻页判据**: +//! `has_more` 必须读「最近一页是否装满」,不能读累计条数——否则第 2 页之后恒真。 + +use crate::dto::{ + AppEvent, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, 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(), "迟到的一批被丢掉"); +}