feat(app-iced): Java 页「下载一个 JDK」+ 三个设置键(M197)
用户原话:「我们内置了那么多镜像源 甚至用户不能手动选择指定版本下载。」
「自动下载 jdk17 作为基准版本,后续在设置中提供多版本可供用户下载。」
- core:新增 `game::java::catalog`(8/11/17/21/25 五档 × 能承接的发行版 × 「该装哪档」),
数据全部来自 `JdkVendor::supports` 与 `vendor::order`,不复制第二份真相。
- progress:`SourceFailed` 补一个结构化的 `kind`(在这之前只有中文 `reason`,双语界面
要么放弃语言、要么放弃「哪个源为什么退避」这条用户可见信息)。
- app-iced:`downloads` 模块真下(后台线程 + `ProgressEmitter` + 快照),三态(选择列表 /
下载中 / 失败)都能说出「命中的源 / 退避原因 / 试过哪些源」;成功后重扫并(在
`java.auto_install` 开着且没有默认 JDK 时)把新装的那份设为默认。
- 三个设置键做成可改 UI:`java.installRoot` / `java.vendorPriority` / `java.auto_install`,
各自带「现在生效什么、从哪一层来」(数据目录模式 / 地区默认序),改完回读落盘值。
- `State::satisfied` 换成带方向的 `Fit`:1.12.2 + Java 21 不再被说成「低于要求」。
- i18n:删掉 `SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的 markdown 反引号;
`SettingsJavaNeverScannedBody` 改成不与首屏自动扫矛盾的表述。
- 出图三张(选择列表 / 下载中 / 失败)在 DevDocs/artifacts/m197/。
顺带拆文件守 400 行:`golden/tail.rs` 的 M174 段搬到 `golden/tail_ai.rs`,
`pages/settings/java/{context,prefs,prefs_store,downloads,download_view}` 各自成模块。
This commit is contained in:
1 parent
329c529bea
commit
ce463cc9b5
33 files changed
+3839
-322
No files matched your search
@@ -0,0 +1,297 @@
|
||||
//! 「下载一个 JDK」的作业与状态(M197):纯事件 → 快照的映射守卫。
|
||||
//!
|
||||
//! 从 `downloads.rs` 拆出来守 400 行硬线(与 `tests/runtime.rs` 同一种拆法:断言跟着它守的
|
||||
//! 东西走,模块文件只留实现)。这些用例**不联网**:喂的是引擎会吐的那几种 `ProgressEvent`。
|
||||
use super::{Event, Failure, Job, Phase, Report, Slot};
|
||||
use unlucky_core::download::types::DownloadId;
|
||||
use unlucky_core::download::{DownloadErrorKind, ProgressEvent};
|
||||
use unlucky_core::game::java::JavaErrorKind;
|
||||
|
||||
/// 一条下载事件链 → 界面能看到「百分比 + 命中哪个源」。
|
||||
///
|
||||
/// 变异证明:把 `apply` 里 `ProgressEvent::Progress` 那一臂的 `job.downloaded = downloaded`
|
||||
/// 删掉 ⇒ 本用例红(百分比恒为 0)。
|
||||
#[test]
|
||||
fn progress_events_fill_the_percent_and_the_source() {
|
||||
let slot = Slot::default();
|
||||
slot.seed_report(Report {
|
||||
job: Some(Job::new(21)),
|
||||
..Report::default()
|
||||
});
|
||||
let id = DownloadId::next();
|
||||
slot.apply(ProgressEvent::Started {
|
||||
id,
|
||||
kind: unlucky_core::download::ResourceKind::JavaRuntime,
|
||||
url: "https://example.invalid/jdk.zip".to_string(),
|
||||
dest: "jdk.zip".to_string(),
|
||||
candidates: 2,
|
||||
total_bytes: Some(200),
|
||||
});
|
||||
slot.apply(ProgressEvent::Progress {
|
||||
id,
|
||||
downloaded: 50,
|
||||
total_bytes: Some(200),
|
||||
});
|
||||
let job = slot.report().job.expect("作业还在");
|
||||
assert_eq!(job.phase, Phase::Downloading);
|
||||
assert_eq!(job.percent(), Some(25), "50/200 = 25%");
|
||||
slot.apply(ProgressEvent::Completed {
|
||||
id,
|
||||
bytes: 200,
|
||||
from_cache: false,
|
||||
source: "tuna".to_string(),
|
||||
elapsed_ms: 10,
|
||||
});
|
||||
let job = slot.report().job.expect("作业还在");
|
||||
assert_eq!(job.phase, Phase::Extracting, "下完就该进解压阶段");
|
||||
assert_eq!(job.source.as_deref(), Some("tuna"), "命中哪个源要说得出");
|
||||
}
|
||||
|
||||
/// 分母未知时**不做假进度**(`percent()` 给 `None`)。
|
||||
#[test]
|
||||
fn an_unknown_total_shows_no_percent() {
|
||||
let slot = Slot::default();
|
||||
slot.seed_report(Report {
|
||||
job: Some(Job::new(17)),
|
||||
..Report::default()
|
||||
});
|
||||
slot.apply(ProgressEvent::Progress {
|
||||
id: DownloadId::next(),
|
||||
downloaded: 1024,
|
||||
total_bytes: None,
|
||||
});
|
||||
assert_eq!(slot.report().job.expect("作业").percent(), None);
|
||||
}
|
||||
|
||||
/// 退避与换源要留在快照里(界面那一行「A 失败;2 秒后重试第 2 次」/「换源」)。
|
||||
///
|
||||
/// 变异证明:把 `SourceFailed` 那一臂的 `job.last = Some(Event::Retry { .. })` 删掉
|
||||
/// ⇒ 本用例红。
|
||||
#[test]
|
||||
fn retries_and_switches_are_kept_for_the_ui() {
|
||||
let slot = Slot::default();
|
||||
slot.seed_report(Report {
|
||||
job: Some(Job::new(8)),
|
||||
..Report::default()
|
||||
});
|
||||
let id = DownloadId::next();
|
||||
slot.apply(ProgressEvent::SourceFailed {
|
||||
id,
|
||||
source: "tuna".to_string(),
|
||||
kind: DownloadErrorKind::ServerError,
|
||||
reason: "服务器 503".to_string(),
|
||||
attempt: 1,
|
||||
will_retry: true,
|
||||
retry_in_ms: 2_000,
|
||||
});
|
||||
assert_eq!(
|
||||
slot.report().job.expect("作业").last,
|
||||
Some(Event::Retry {
|
||||
source: "tuna".to_string(),
|
||||
kind: DownloadErrorKind::ServerError,
|
||||
attempt: 2,
|
||||
retry_in_ms: 2_000,
|
||||
})
|
||||
);
|
||||
slot.apply(ProgressEvent::SourceSwitched {
|
||||
id,
|
||||
from: "tuna".to_string(),
|
||||
to: "ustc".to_string(),
|
||||
});
|
||||
let job = slot.report().job.expect("作业");
|
||||
assert_eq!(job.source.as_deref(), Some("ustc"));
|
||||
assert_eq!(
|
||||
job.last,
|
||||
Some(Event::Switched {
|
||||
from: "tuna".to_string(),
|
||||
to: "ustc".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// 「该推荐哪一档」:`java.auto_install` 的真实消费者(界面上那行「为当前实例推荐这一档」)。
|
||||
///
|
||||
/// 变异证明:把 `recommend` 开头 `if !auto_install { return None; }` 删掉 ⇒ 关掉那一档红;
|
||||
/// 把 `satisfied` 判据写成恒 `false` ⇒ 「已满足就不推荐」那一档红。
|
||||
#[test]
|
||||
fn auto_install_decides_whether_a_major_is_recommended() {
|
||||
use super::recommend;
|
||||
use crate::pages::settings::java::context::Requirement;
|
||||
use crate::pages::settings::java::row::RuntimeRow;
|
||||
|
||||
fn row(major: u32) -> RuntimeRow {
|
||||
RuntimeRow {
|
||||
binary: format!("C:/jdk-{major}/bin/java.exe"),
|
||||
version: format!("openjdk version \"{major}.0.1\""),
|
||||
major: Some(major),
|
||||
vendor: Some("Temurin".to_string()),
|
||||
source: "system",
|
||||
}
|
||||
}
|
||||
|
||||
let required = Requirement {
|
||||
instance: "生存服".to_string(),
|
||||
version: "1.20.5".to_string(),
|
||||
minimum: 21,
|
||||
maximum: None,
|
||||
};
|
||||
let none: &[RuntimeRow] = &[];
|
||||
assert_eq!(recommend(Some(&required), none, true), Some(21));
|
||||
assert_eq!(recommend(Some(&required), none, false), None);
|
||||
let satisfied = [row(21)];
|
||||
assert_eq!(recommend(Some(&required), &satisfied, true), None);
|
||||
let too_old = [row(17)];
|
||||
assert_eq!(
|
||||
recommend(Some(&required), &too_old, true),
|
||||
Some(21),
|
||||
"手里只有 17、实例要 21 时照样推荐 21"
|
||||
);
|
||||
assert_eq!(
|
||||
recommend(None, none, true),
|
||||
Some(17),
|
||||
"没有实例时推荐基准 17"
|
||||
);
|
||||
assert_eq!(recommend(None, &satisfied, true), None);
|
||||
}
|
||||
|
||||
/// 真下载一次(`--ignored`,需要网络):装到临时目录并确认 `bin/java` 存在。
|
||||
///
|
||||
/// 这是「真入口」的端到端证据:`Slot::start` → `engine_with`(镜像表 / 地区 / 健康度)→
|
||||
/// `java::install`(查版本 → 回退链 → 下载 → 解压 → 探测)。默认不跑:它会下 ~180 MB。
|
||||
#[test]
|
||||
#[ignore = "真下载(网络 + 数百 MB),用 cargo test -- --ignored 手动跑"]
|
||||
fn the_real_download_installs_a_usable_jdk() {
|
||||
use super::{Request, Slot};
|
||||
use unlucky_core::game::java::JavaInstallRoot;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ull-java-install-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("cache")).expect("temp");
|
||||
let slot = Slot::default();
|
||||
assert!(slot.start(Request {
|
||||
major: 21,
|
||||
cache_root: dir.join("cache"),
|
||||
install_root: JavaInstallRoot::custom(dir.join("jdks")),
|
||||
priority: Vec::new(),
|
||||
}));
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(900);
|
||||
while slot.busy() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
assert!(!slot.busy(), "作业应在 15 分钟内结束");
|
||||
let report = slot.report();
|
||||
if let Some(failure) = &report.failure {
|
||||
panic!("安装失败:{failure:?}");
|
||||
}
|
||||
let success = report.success.expect("应成功");
|
||||
assert!(
|
||||
std::path::Path::new(&success.binary).is_file(),
|
||||
"bin/java 应存在:{}",
|
||||
success.binary
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 失败要留档:分类 + 大版本 + 最后试到的源(界面靠这三样说清「为什么没装成」)。
|
||||
///
|
||||
/// 变异证明:把 `finish` 的 `Err` 分支里 `report.failure = Some(...)` 删掉 ⇒ 本用例红。
|
||||
#[test]
|
||||
fn a_failure_is_kept_with_its_kind_and_source() {
|
||||
let slot = Slot::default();
|
||||
slot.seed_report(Report {
|
||||
job: Some(Job {
|
||||
source: Some("tuna".to_string()),
|
||||
..Job::new(21)
|
||||
}),
|
||||
..Report::default()
|
||||
});
|
||||
slot.finish(
|
||||
Err(unlucky_core::game::java::JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
"没有装成".to_string(),
|
||||
)),
|
||||
21,
|
||||
);
|
||||
let report = slot.report();
|
||||
assert!(report.success.is_none());
|
||||
assert!(report.job.is_none(), "失败之后不该还挂着作业");
|
||||
let failure: Failure = report.failure.expect("失败要留档");
|
||||
assert_eq!(failure.kind, JavaErrorKind::Metadata);
|
||||
assert_eq!(failure.major, 21);
|
||||
assert_eq!(failure.source.as_deref(), Some("tuna"));
|
||||
}
|
||||
|
||||
/// **占位符不许漏**:`Catalog::render` 缺参数时会把 `{name}` 留下 `(name)`,而那张图已经能看见
|
||||
/// (M197 出图时实测到 `试过 (tried) 家发行版`——模板里有一个调用点没给的占位符)。
|
||||
///
|
||||
/// 这条对**本页自己渲染的每一条下载文案**做同一件事:拿真实参数渲一遍,断言输出里没有
|
||||
/// 「`(` + 标识符 + `)`」这种残留。ASCII 括号是判据:本页这些模板里没有合法的 ASCII 括号短语
|
||||
/// (中文文案用全角「()」),所以命中即漏参。
|
||||
///
|
||||
/// 变异证明:把 `SettingsJavaDownloadFailed` 的模板改回「`(tried {n} vendors…)`」那种带
|
||||
/// `{tried}` 的写法 ⇒ 本用例红(渲出 `(tried)`)。M197 就是这么发现它的。
|
||||
#[test]
|
||||
fn every_download_template_renders_without_leftover_placeholders() {
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
|
||||
let catalog = Lang::En.catalog();
|
||||
let cases: [(&str, Key, &[(&str, &str)]); 6] = [
|
||||
(
|
||||
"failed",
|
||||
Key::SettingsJavaDownloadFailed,
|
||||
&[("major", "21"), ("source", "tuna")],
|
||||
),
|
||||
(
|
||||
"failed-no-source",
|
||||
Key::SettingsJavaDownloadFailedNoSource,
|
||||
&[("major", "21")],
|
||||
),
|
||||
(
|
||||
"done",
|
||||
Key::SettingsJavaDownloadDone,
|
||||
&[
|
||||
("major", "21"),
|
||||
("vendor", "Eclipse Temurin"),
|
||||
("root", "D:/jdks/21"),
|
||||
("source", "tuna"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"done-reused",
|
||||
Key::SettingsJavaDownloadDoneReused,
|
||||
&[
|
||||
("major", "21"),
|
||||
("vendor", "Eclipse Temurin"),
|
||||
("root", "D:/jdks/21"),
|
||||
],
|
||||
),
|
||||
("busy", Key::SettingsJavaDownloadBusy, &[("major", "21")]),
|
||||
(
|
||||
"retry",
|
||||
Key::SettingsJavaDownloadRetry,
|
||||
&[
|
||||
("source", "tuna"),
|
||||
("reason", "server error"),
|
||||
("attempt", "2"),
|
||||
("seconds", "2.0"),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (tag, key, args) in cases {
|
||||
let text = catalog.render(key, args);
|
||||
let mut rest = text.as_str();
|
||||
while let Some(open) = rest.find('(') {
|
||||
let after = &rest[open + 1..];
|
||||
let close = after.find(')');
|
||||
if let Some(close) = close {
|
||||
let inner = &after[..close];
|
||||
assert!(
|
||||
inner.is_empty()
|
||||
|| !inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
"{tag} 渲染后还留着占位符 `({inner})`:{text}"
|
||||
);
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user