Files
Unlucky-Launcher/crates/presenter/src/host/settings/download_tests.rs
T
AskaEth 5dda9ac31d M201 r2: 修视觉 r1 的两条 P2 + 代码复审的坏值提示 p2
视觉 r1(fix-then-merge):
- P2-1 分隔线贴字:downloaded 组的行间距从 0 改成 `Space::X3`(12dp)。实测
  说明末行→分隔线 1–3px → **12–13px**、分隔线→下一标题 4–5px → **12–17px**
  (同族页 9–18px;同一把尺子量演示回退卡 16–19px)。不动 `frame::divider` /
  `demo_card`;`spacing` 只作用于子节点之间,卡片首行顶距与末行底距仍是 20。
- P2-2 孤字「效」:`with_note` 把「原值 X 已按出厂 N 生效」从说明段落里挪到
  **独立一行**(`\n` 分隔,渲染器按行断)。浅/深/窄三张复验:无 <20px 窄行。
  两种措辞(出厂 8 / 已按 1)一字未改,presenter 侧新增 `ends_with("\n原值 …")`
  断言把「独占一行」钉住。

代码复审 p2(非整数坏值被静默丢掉):
- `raw_int`(`Value::as_i64`)→ `raw_text`:文件里显式写过的原值转**上屏文本**
  (字符串取原文不带 serde 引号、数字/布尔 `to_string`、`null` 当没写)。
  于是 `"auto"` / `8.5` / `true` / `-4` 都能进那句提示,不再「画着 8 却说不出
  用户写的 auto 被忽略」。
- 新用例 `every_bad_value_shape_says_what_the_raw_value_was`:负整数 / 字符串 /
  浮点 / 布尔 / null 五种形态。

验证:fmt --all --check / check -p unlucky-app-iced --all-targets /
test -p unlucky-app-iced(845+6) / test -p unlucky-presenter(316) /
test -p unlucky-i18n(36),全部 EXIT=0。
2026-11-09 10:51:16 +08:00

313 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 下载设置组的「**生效值**」守门(P3-5 / P3-3)。
//!
//! 这一组画的是引擎真的会用的那个数,不是文件里那串字节。两者的判定在 core
//! (`DownloadTuning::from_settings`:越界/坏值回落出厂,`0` 落成「只试一次」),这里守的是
//! 「宿主把哪个数交给页面」以及「文件原值与生效值不一致时那句话有没有出现」。
//!
//! 构造真文件:手改坏值绕不过 `store.set` 的 schema 校验,所以测试**直接写 `launcher.json`**
//! ——这正是用户手改文件的现场(`document::load` 对坏值只记 `LoadProblem`、原样保留)。
use super::*;
use crate::dto::find_row;
use std::path::PathBuf;
/// 一个装好「手写 `launcher.json`」的数据根。`json` 是整个文件的顶层对象。
fn env_with(tag: &str, json: serde_json::Value) -> (PathBuf, HostEnv) {
let dir = std::env::temp_dir().join(format!("ull-download-group-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
std::fs::write(
dir.join(".ULL/config/launcher.json"),
serde_json::to_string_pretty(&json).expect("序列化"),
)
.expect("写 launcher.json");
let env = HostEnv::at_root(&dir);
(dir, env)
}
/// 某一行(走页面同一条 `find_row`)。
fn row<'a>(group: &'a SettingGroupView, key: &str) -> &'a SettingRowView {
find_row(std::slice::from_ref(group), key).unwrap_or_else(|| panic!("{key} 这一行不在组里"))
}
/// 选项表里被选中的那一项(页面正是按 `selected` 下标画选中的 chip)。
fn chosen<'a>(row: &'a SettingRowView) -> &'a str {
row.options
.get(usize::try_from(row.selected).expect("下标非负"))
.map_or("<越界下标>", String::as_str)
}
/// **P3-5 主用例**:文件里是坏值 9999,行上画的是引擎真在用的 8,并把原值写明白。
///
/// 抓得住:① 宿主把文件值当生效值画(改回 `int(env, ..)`)⇒ 选中的 chip 变成 `9999`,红;
/// ② 那句「原值 9999 已按出厂 8 生效」被删掉或写成常量 ⇒ 文案断言红;
/// ③ core 的回落口径被改成「夹到边界 64」⇒ 选中项变成 64,红。
/// 抓不住:界面上这行是否真的画出来(那是 `pages/settings/download.rs` 的 body 与截图的事),
/// 也抓不住用户是否看得懂那句话。
#[test]
fn an_out_of_range_file_value_shows_the_engine_value_and_names_the_raw_one() {
let (dir, env) = env_with(
"concurrency",
serde_json::json!({ "download": { "concurrency": 9999 } }),
);
let group = group(&env);
let concurrency = row(&group, unlucky_core::download::config::KEY_CONCURRENCY);
assert_eq!(
group.id, "download",
"设置页按这个 id 找组(app-iced `pages/settings/download.rs` 的 GROUP_ID);改名会静默退回演示行"
);
assert_eq!(
chosen(concurrency),
"8",
"行上画的必须是引擎生效值(出厂 8),不是文件里的 9999"
);
assert!(
concurrency
.description
.contains("原值 9999 已按出厂 8 生效"),
"文件值与生效值不一致时必须写明原值:{}",
concurrency.description
);
// r1 视觉复审 P2-2:那句结论必须**独占一行**(接在说明末尾时,最后一个「效」会被挤成孤字)。
assert!(
concurrency
.description
.ends_with("\n原值 9999 已按出厂 8 生效"),
"结论必须自成一行(`\\n` 分隔),否则 424 列的说明会把它折出单字孤行:{:?}",
concurrency.description
);
assert_eq!(
tuning_of(&env).max_concurrency,
8,
"同一份文件经 core 的翻译层也必须是 8(两处口径不许漂)"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 文件里写的就是生效值 ⇒ **不许**冒出「原值 …」那句话(否则它是常量而不是判决)。
///
/// 抓得住:`applied_note` 不管三七二十一都返回 `Some`(把那句话变成装饰)。
/// 抓不住:句子出现的**位置**(埋在哪一段文案里)。
#[test]
fn a_file_value_that_matches_the_engine_value_gets_no_note() {
let (dir, env) = env_with(
"in-range",
serde_json::json!({ "download": { "concurrency": 2, "retry_limit": 5 } }),
);
let group = group(&env);
let concurrency = row(&group, unlucky_core::download::config::KEY_CONCURRENCY);
let retry = row(&group, unlucky_core::download::config::KEY_RETRY_LIMIT);
assert_eq!(chosen(concurrency), "2");
assert_eq!(chosen(retry), "5");
for row in [concurrency, retry] {
assert!(
!row.description.contains("原值"),
"没有差异就不该有那句话:{}",
row.description
);
// 没有结论行 ⇒ 说明里也不该有换行(那会让页面多出一行空白)。
assert!(
!row.description.contains('\n'),
"没有差异就不该有第二段:{:?}",
row.description
);
}
std::fs::remove_dir_all(&dir).ok();
}
/// **每一种坏值形态都要说清原值**(r1 代码复审 p2:非整数坏值曾被静默丢掉)。
///
/// 任务书 P3-5 是无条件的「文件里的值与生效值不一致时,行上要写明原值 X 已按出厂 N 生效」,
/// 而「手改坏值」不止越界整数这一种。四种形态各验一遍:
///
/// | 文件里写的 | 生效值 | 行上必须出现 |
/// |---|---|---|
/// | `9999`(越界整数) | 8 | `原值 9999 已按出厂 8 生效`(上面那条用例) |
/// | `-4`(负整数) | 8 | `原值 -4 已按出厂 8 生效` |
/// | `"auto"`(类型不对) | 8 | `原值 auto 已按出厂 8 生效` ← **本用例补的洞** |
/// | `8.5` / `true`(类型不对) | 3 | `原值 8.5 / true 已按出厂 3 生效` |
/// | `0`(retry 特例) | 1 | `原值 0 已按 1 生效`(上面那条用例) |
///
/// 抓得住:① 原值退回 `Value::as_i64`(非整数 → `None` → `let raw = raw?` 短路 ⇒ 一个字都不说);
/// ② 字符串原值被 serde 加上引号(上屏成 `"auto"`);③ `null` 被当成「写过」(会冒出
/// `原值 null …`)。
/// 抓不住:别的键(region / mirror / proxy_mode)同族缺口——那三条不在 P3-5 点名范围,
/// 已由 tower 登记为另一个 follow-up。
#[test]
fn every_bad_value_shape_says_what_the_raw_value_was() {
// 负整数
let (dir, env) = env_with(
"bad-negative",
serde_json::json!({ "download": { "concurrency": -4 } }),
);
let group_view = group(&env);
let concurrency = row(&group_view, unlucky_core::download::config::KEY_CONCURRENCY);
assert_eq!(chosen(concurrency), "8", "-4 越界 ⇒ 生效值是出厂 8");
assert!(
concurrency.description.contains("原值 -4 已按出厂 8 生效"),
"负整数也要说清原值:{}",
concurrency.description
);
std::fs::remove_dir_all(&dir).ok();
// 类型不对:字符串 + 浮点(一次覆盖两个)
let (dir, env) = env_with(
"bad-types",
serde_json::json!({ "download": { "concurrency": "auto", "retry_limit": 8.5 } }),
);
let group_view = group(&env);
let concurrency = row(&group_view, unlucky_core::download::config::KEY_CONCURRENCY);
let retry = row(&group_view, unlucky_core::download::config::KEY_RETRY_LIMIT);
assert_eq!(
chosen(concurrency),
"8",
"\"auto\" 不是整数 ⇒ 生效值是出厂 8"
);
assert!(
concurrency
.description
.contains("原值 auto 已按出厂 8 生效"),
"字符串坏值必须原样上屏(不带引号)并说清它被忽略了:{}",
concurrency.description
);
assert!(
!concurrency.description.contains("\"auto\""),
"上屏的是用户写的那个词,不是 serde 的转义形式:{:?}",
concurrency.description
);
assert_eq!(chosen(retry), "3", "8.5 不是整数 ⇒ 生效值是出厂 3");
assert!(
retry.description.contains("原值 8.5 已按出厂 3 生效"),
"浮点坏值也要说清:{}",
retry.description
);
std::fs::remove_dir_all(&dir).ok();
// 布尔 / null:`true` 要说;`null` 等于没写(不该冒出「原值 null …」)
let (dir, env) = env_with(
"bad-bool-null",
serde_json::json!({ "download": { "retry_limit": true, "concurrency": null } }),
);
let group_view = group(&env);
let retry = row(&group_view, unlucky_core::download::config::KEY_RETRY_LIMIT);
let concurrency = row(&group_view, unlucky_core::download::config::KEY_CONCURRENCY);
assert!(
retry.description.contains("原值 true 已按出厂 3 生效"),
"布尔坏值也要说清:{}",
retry.description
);
assert!(
!concurrency.description.contains("原值"),
"`null` 等于没写,不该有提示:{}",
concurrency.description
);
std::fs::remove_dir_all(&dir).ok();
}
/// `retry_limit = 0`(旧版档位)落成「只试一次」:行上画 1,并说清原值是 0。
///
/// 抓得住:把 `0` 原样画上屏(那时引擎按 1 跑,界面说 0 —— 又一个「设置项说假话」);
/// 或者把这句话写成「按出厂 3 生效」(实际生效值是 1,不是出厂 3)。
/// 抓不住:用户是否理解 0/1 同义(那是文案的事,本行 base 段已有说明)。
#[test]
fn a_zero_retry_in_the_file_is_shown_as_one_and_says_so() {
let (dir, env) = env_with(
"retry-zero",
serde_json::json!({ "download": { "retry_limit": 0 } }),
);
let group = group(&env);
let retry = row(&group, unlucky_core::download::config::KEY_RETRY_LIMIT);
assert_eq!(chosen(retry), "1", "0 生效为 1(只试一次)");
assert!(
retry.description.contains("原值 0 已按 1 生效"),
"必须写明 0 落成了 1(不是出厂 3):{}",
retry.description
);
assert!(
!retry.description.contains("原值 0 已按出厂"),
"1 不是出厂值(出厂 3),措辞不许混"
);
std::fs::remove_dir_all(&dir).ok();
}
/// **P3-3(选了「在下载设置页补一条 gh_proxy 端到端链」这一支)**:
/// 文件里的 `download.gh_proxy` → `store` → [`tuning_of`] → core 的三态 → 行上的值与那句
/// 「当前生效:…」。三档各走一遍。
///
/// 抓得住:① 键名读错(例如 `download.gh-proxy`)⇒ 三档全部退回 `Builtin`,`off`/`prefix` 两档红;
/// ② 三态翻译被拍平(`off` 也当 `Builtin`)⇒ 第二档红;③ 行上只画文件原文、不画生效档位
/// ⇒ 那一句文案红(页面上「填了 off 却没关」的那种谎正是它兜的)。
/// 抓不住:候选链里到底生成了什么(那是 `shared.rs` 的 `expand()`,core 侧已有用例),
/// 也抓不住真下载时是否走那条链(要网络)。
///
/// 为什么选这一支而不是拆 core 的 `gh_proxy_override_reaches_the_profile_and_the_chain`:
/// 那个文件不在本 mission 的 scope(`crates/core/**`),拆它得由 core 线另立 mission;
/// 而「设置文件 → 宿主 → 三态」这一段的缺口正是本 mission 要接的路,补在这里能证明整条链
/// 从用户手写的那串字节开始就是通的。
#[test]
fn the_gh_proxy_key_travels_from_the_file_to_the_core_tri_state() {
use unlucky_core::download::config::KEY_GH_PROXY;
// ① 留空 = 跟随内置默认(空串不是「关」)。
let (empty_dir, empty_env) = env_with(
"gh-empty",
serde_json::json!({ "download": { "gh_proxy": "" } }),
);
let empty_group = group(&empty_env);
let empty_row = row(&empty_group, KEY_GH_PROXY);
assert_eq!(tuning_of(&empty_env).gh_proxy, GhProxyOverride::Builtin);
assert_eq!(empty_row.value_text, "", "文件里的原文照旧回填");
assert!(
empty_row.description.contains("当前生效:跟随内置默认"),
"空串必须说成「跟随内置默认」而不是「关闭」:{}",
empty_row.description
);
std::fs::remove_dir_all(&empty_dir).ok();
// ② 填 off = 关(不生成任何 gh-proxy 候选)。
let (off_dir, off_env) = env_with(
"gh-off",
serde_json::json!({ "download": { "gh_proxy": "off" } }),
);
let off_group = group(&off_env);
let off_row = row(&off_group, KEY_GH_PROXY);
assert_eq!(tuning_of(&off_env).gh_proxy, GhProxyOverride::Off);
assert_eq!(off_row.value_text, "off");
assert!(
off_row.description.contains("当前生效:已关闭(off)"),
"填了 off 就必须说「已关闭」:{}",
off_row.description
);
std::fs::remove_dir_all(&off_dir).ok();
// ③ 填 URL 前缀 = 覆盖它。
let (prefix_dir, prefix_env) = env_with(
"gh-prefix",
serde_json::json!({ "download": { "gh_proxy": "https://gh.example/" } }),
);
let prefix_group = group(&prefix_env);
let prefix_row = row(&prefix_group, KEY_GH_PROXY);
assert_eq!(
tuning_of(&prefix_env).gh_proxy,
GhProxyOverride::Prefix("https://gh.example/".to_string())
);
assert_eq!(prefix_row.value_text, "https://gh.example/");
assert!(
prefix_row
.description
.contains("当前生效:覆盖为 https://gh.example/"),
"覆盖档必须把生效的前缀写出来:{}",
prefix_row.description
);
std::fs::remove_dir_all(&prefix_dir).ok();
}