fix(app-iced): M109 r1 —— CF 来源装入闸门、has_more 读最近一页、末行切半与文案/图标

复审 p1-2items 的两条 P1 与三条 P2,逐条收口。

`blockers_of` 只看版本/加载器/目标实例,不看来源;而 host 的 `resolve_file` 对 CF 直接
返回「未接通」⇒ 切到 CF 源后每条都必然失败,UI 一声不响。

- 新增 `Blocker::SourceNotWired` + 文案 `ContentSourceNotWired`(中英);
- `blockers_of` 的入参从 `Option<(&[String], &[String])>` 收成 `Option<HitFacts>`(版本 /
  加载器 / **来源**),两处调用点(产品路径、演示数据)一起改;
- 判据同构于整合包的 `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 张,全部重出)
This commit is contained in:
AskaEth committed 2026-11-05 03:20:22 +08:00
1 parent 6b161227d8
commit 42099d4336
13 files changed
+246 -40

No files matched your search

+7
View File
@@ -335,6 +335,12 @@ pub struct ContentState {
pub pending: bool,
/// 最近一次失败的原文(`None` = 没失败)
pub failed: Option<String>,
/// **最近一页**是否装满(`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,
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ pub(super) fn install(
fail(
tx,
progress,
"整合包安装管线未落地(M111);内容库对整合包只做检索",
"整合包安装尚未开放;内容库对整合包只做检索",
epoch,
);
return;
+6
View File
@@ -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;
+130
View File
@@ -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(), "迟到的一批被丢掉");
}