diff --git a/crates/app-iced/src/pages/settings/download.rs b/crates/app-iced/src/pages/settings/download.rs index 3e732ba..87454b6 100644 --- a/crates/app-iced/src/pages/settings/download.rs +++ b/crates/app-iced/src/pages/settings/download.rs @@ -167,11 +167,23 @@ fn msg(message: Message) -> PageMessage { /// 整组正文:快照里按 [`GROUP_ID`] 找到本组,一行一个控件。 /// /// 返回 `None` = 这一帧没有本组的快照(harness / 首帧)⇒ `settings/mod.rs` 退回 `GROUP.rows`。 +/// +/// **行间距 = `Space::X3`(M201 r1 视觉复审 P2-1)**:分隔线的对比度只有 1.55:1(`OutlineVariant` +/// 本色,正常),「这是新的一行」这件事全靠**间距**承担。r1 复审实测这一组原本是 0 间距:说明末行 +/// 墨迹 → 分隔线只有 2–3px、分隔线 → 下一行标题 4–5px,100% 缩放下「地区判定」「镜像偏好」被读成 +/// 上一段说明的续行。 +/// +/// 为什么是 X3(12)而不是兄弟组的 X2(8):本组每一行都是「标题 + 控件 + 多行说明」三段,比 Java +/// 组的行**密得多**,沿用 8 实测只到 8–9px(文本盒里 12–13px 的墨迹落在 16px 行盒中,盒间距 8 会被 +/// 吃掉 1px);X3 把两侧都推到 12px 以上,落在「同族页 9–18px」的中段(实测见报告的表)。 +/// +/// 为什么不给 [`block`] 加 padding:那会把卡片的**第一行顶距**也撑开(卡内边距 20 + 12),与其它四组 +/// 的第一行不再对齐;`spacing` 只作用在子节点之间,首行顶距与末行底距仍是卡片的 20。 fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option> { let group = state_group(&states)?; let drafts = &states.groups.download; let catalog = lang.catalog(); - let mut stacked = column![].spacing(0.0).width(Length::Fill); + let mut stacked = column![].spacing(ui.space(Space::X3)).width(Length::Fill); for (index, item) in group.rows.iter().enumerate() { if index > 0 { diff --git a/crates/presenter/src/host/settings/download.rs b/crates/presenter/src/host/settings/download.rs index b96dec3..41977ee 100644 --- a/crates/presenter/src/host/settings/download.rs +++ b/crates/presenter/src/host/settings/download.rs @@ -51,12 +51,12 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView { let concurrency = (tuning.max_concurrency as i64).to_string(); let retry = i64::from(tuning.max_attempts_per_source).to_string(); let concurrency_note = applied_note( - raw_int(env, unlucky_core::download::config::KEY_CONCURRENCY), + raw_text(env, unlucky_core::download::config::KEY_CONCURRENCY), tuning.max_concurrency as i64, DEFAULT_MAX_CONCURRENCY as i64, ); let retry_note = applied_note( - raw_int(env, unlucky_core::download::config::KEY_RETRY_LIMIT), + raw_text(env, unlucky_core::download::config::KEY_RETRY_LIMIT), i64::from(tuning.max_attempts_per_source), i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE), ); @@ -174,19 +174,40 @@ fn ladder(ladder: &[&str], current: &str) -> Vec { options } -/// 文件里**显式写过**的原值(`None` = 文件没写这个键)。 +/// 文件里**显式写过**的原值,转成**上屏文本**(`None` = 文件没写这个键)。 /// /// 与 `super::int` 的区别:那个走 `effective()`(文件值 → schema 默认值),本函数只问文件本身。 /// 「原值 X 已按出厂 N 生效」这句话的两个数必须现读(一个来自文件、一个来自 core 的翻译层), /// **不许**拿常量或请求参数冒充——那是 U1 明令禁止的假证据。 -fn raw_int(env: &HostEnv, key: &str) -> Option { - env.store().doc().get(key).and_then(|value| value.as_i64()) +/// +/// ## 为什么返回字符串而不是 `i64`(r1 代码复审 p2) +/// +/// 手改文件可以把一个整数键写成 `"auto"` / `8.5` / `true`:`document::load` 对坏值只记 +/// `LoadProblem`、原样留在 `values` 里,所以 `doc().get(key)` 真的能拿到 `Some(String("auto"))`。 +/// 早先这里用 `Value::as_i64`,非整数一律 `None` ⇒ 行上画着生效值 8,却**不告诉用户他写的 +/// `auto` 被忽略了**——「设置项说假话」的最后一个洞。改成上屏文本后,越界整数(`9999` / `-4`)、 +/// 非整数 JSON(`8.5`)、别的类型(`true`)、字符串(`auto`)**都能进那句提示**; +/// 数字 `to_string()` 出来仍是 `9999`,所以旧断言与旧措辞一字未变。 +fn raw_text(env: &HostEnv, key: &str) -> Option { + use serde_json::Value; + let store = env.store(); + match store.doc().get(key)? { + // `null` 等于「没写」:`effective()` 也是这么当的(`filter(|value| !value.is_null())`)。 + Value::Null => None, + // 字符串照原文上屏(不要 serde 的引号):用户写的是 `auto`,提示里就该是 `auto`。 + Value::String(text) => Some(text.clone()), + other => Some(other.to_string()), + } } /// 文件原值 ≠ 生效值时的说明;`None` = 没什么可说的(没写过,或写的就是生效值)。 -fn applied_note(raw: Option, effective: i64, factory: i64) -> Option { +/// +/// `raw` 是**上屏文本**(见 [`raw_text`]),`effective` / `factory` 是 core 翻译层的结论。 +fn applied_note(raw: Option, effective: i64, factory: i64) -> Option { let raw = raw?; - if raw == effective { + // 比的是「上屏文本」:文件写 `8`、生效也是 8 ⇒ 没什么可说的(`8.0` 会照实提示,因为那是一次 + // 类型不一致的坏值)。 + if raw == effective.to_string() { return None; } Some(if effective == factory { @@ -197,9 +218,15 @@ fn applied_note(raw: Option, effective: i64, factory: i64) -> Option) -> String { match note { - Some(note) => format!("{base};{note}"), + Some(note) => format!("{base}\n{note}"), None => base, } } diff --git a/crates/presenter/src/host/settings/download_tests.rs b/crates/presenter/src/host/settings/download_tests.rs index 3d4d19f..3fd7b2a 100644 --- a/crates/presenter/src/host/settings/download_tests.rs +++ b/crates/presenter/src/host/settings/download_tests.rs @@ -70,6 +70,14 @@ fn an_out_of_range_file_value_shows_the_engine_value_and_names_the_raw_one() { "文件值与生效值不一致时必须写明原值:{}", 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, @@ -102,11 +110,106 @@ fn a_file_value_that_matches_the_engine_value_gets_no_note() { "没有差异就不该有那句话:{}", 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 —— 又一个「设置项说假话」);