feat(m175): Java 设置页接真运行时(扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数)
用户实测四条抱怨逐条对应: - 「没有自动检测本机 Java」:首屏自动扫一次盘(托管根 + JAVA_HOME + PATH + 标准安装位置 + 注册表),之后由「立即扫描」手动触发;扫描在后台线程里跑,带 done/total 进度与取消 - 「显示 Temurin 21.0.4 不知道是不是硬编码」:演示行与绊线测试一起删掉,列表来自真读盘 - 「没一个能改」:默认内存(MB,512~65536,范围与 schema 同源)与 JVM 参数可真改、真落盘、读回 - 「不能手动指定 Java 路径 / 不能改默认版本」:手动添加走严格校验(真跑 java -version), 设为默认写 java.selected_path 并给出「为什么选它」(当前实例的版本门槛 + 每行满足与否) core:java::manual(严格探测 + 厂商识别 + 名单持久化)、java::staged(候选收集 + 带进度与 取消的扫描)、java::proc(带超时且可打断的子进程执行:卡住的 java -version 会被 kill)。 i18n:catalog/settings/java.rs 55 条中英 + 金表按装配顺序插入。 复审 r1(p2-5items)全修: - P2-1 「设为默认」不再清脏:只写 java.selected_path,内存/JVM 草稿一个字节都不动 - P2-2 错误文案全部走 i18n(三档「写不进去」/「路径太长」/「名单写不进去」各自一个 key), 不再把 Rust 枚举名当人话 - P2-3 手动名单落盘有正面断言(加一条 → 从盘上新读一份 → 在) - P2-4 版本号显示到小版本(17.0.13 / 17.0.9 可区分),解析不出来时给原始版本串 - P2-5 每候选 8 秒超时并 kill、取消打断正在跑的探测、扫描槽挂在 State 上、线程活着期间 不许起第二个(界面显示「正在取消…」) 复审 r2(p2-2items)全修: - P2-1 空态不再指向不存在的「Java 页」,改成真实存在的 `ull java install 21` 命令行入口 - P2-2 `auto_scan_due` 增加 `!report.cancelled`:取消过一次之后本进程不再自动扫, 配置文件再变(存内存 / 改主题)也不会把取消无声撤销;手动「立即扫描」不受影响 - 顺带:proc 的 kill+wait 补「子进程真死了」的回归守卫(标记文件从未出现)、 core 的 Schema 错误改成页面自己的 key(不再把中文 Display 直出给英文界面)、 默认态 chip 用新的 `Chip::badge`(不再挂一条永远收不到的消息)
This commit is contained in:
1 parent
30b61e2684
commit
df331f6a60
26 files changed
+4030
-85
No files matched your search
@@ -151,6 +151,8 @@ impl App {
|
||||
app.rebuild_ui();
|
||||
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
|
||||
app.hydrate_ai_settings();
|
||||
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
|
||||
app.hydrate_java_settings();
|
||||
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
|
||||
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
||||
app.lang.log();
|
||||
|
||||
@@ -87,6 +87,8 @@ impl App {
|
||||
self.has_screenshot()
|
||||
|| !self.state.toasts.is_empty()
|
||||
|| !self.state.running().is_empty()
|
||||
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|
||||
|| self.settings.java.scanning.is_some()
|
||||
|| self
|
||||
.watch_until
|
||||
.is_some_and(|deadline| Instant::now() < deadline)
|
||||
|
||||
@@ -49,6 +49,9 @@ impl App {
|
||||
self.font_seen = font_status;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面,
|
||||
// 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。
|
||||
self.hydrate_java_scan();
|
||||
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
|
||||
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
|
||||
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
|
||||
@@ -77,6 +80,8 @@ impl App {
|
||||
self.reload_appearance();
|
||||
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
|
||||
self.hydrate_ai_settings();
|
||||
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
|
||||
self.hydrate_java_settings();
|
||||
}
|
||||
|
||||
if self.view_dirty {
|
||||
|
||||
@@ -1,33 +1,291 @@
|
||||
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
|
||||
//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
|
||||
//!
|
||||
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
|
||||
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
|
||||
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
|
||||
//! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
|
||||
//!
|
||||
//! 消息进来的通道有两条,都不经过共享文件:
|
||||
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
|
||||
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
|
||||
//! (`app::Message::Page` 是既有通道,不用加新变体)。
|
||||
//! | 消息 | 做什么 |
|
||||
//! |---|---|
|
||||
//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
|
||||
//! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
|
||||
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
|
||||
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
|
||||
//!
|
||||
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
|
||||
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
|
||||
//! `the_group_dispatch_has_no_catch_all_arm` 会红。
|
||||
//! ## 为什么「设为默认」不攒到「保存」里
|
||||
//!
|
||||
//! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
|
||||
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
|
||||
//! 界面还标着旧的默认值,只会以为没生效。
|
||||
//!
|
||||
//! ## 版本门槛(「为什么选它」那句话的数据)
|
||||
//!
|
||||
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
|
||||
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
|
||||
|
||||
use super::super::*;
|
||||
|
||||
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
|
||||
|
||||
impl App {
|
||||
/// Java 分组的一条消息。
|
||||
pub(super) fn on_settings_java(
|
||||
&mut self,
|
||||
message: pages::settings::java::Message,
|
||||
) -> Task<Message> {
|
||||
use pages::settings::java::Message;
|
||||
|
||||
match message {
|
||||
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
|
||||
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
|
||||
#[cfg(test)]
|
||||
pages::settings::java::Message::Ping => {
|
||||
self.view_dirty = true;
|
||||
Task::none()
|
||||
Message::Scan => return self.start_java_scan(),
|
||||
Message::CancelScan => {
|
||||
self.settings.java.scan.cancel();
|
||||
self.hydrate_java_scan();
|
||||
}
|
||||
Message::AddDraft(value) => {
|
||||
self.settings.java.add_draft = value;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Message::Add => self.add_java_manual(),
|
||||
Message::SelectRuntime(binary) => self.select_java_default(&binary),
|
||||
Message::EditMemory(value) => {
|
||||
self.settings.java.memory = value;
|
||||
self.touch_java();
|
||||
}
|
||||
Message::EditJvm(value) => {
|
||||
self.settings.java.jvm = value;
|
||||
self.touch_java();
|
||||
}
|
||||
Message::Save => self.save_java_settings(),
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
|
||||
fn touch_java(&mut self) {
|
||||
self.settings.java.dirty = true;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。
|
||||
///
|
||||
/// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs`
|
||||
/// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。
|
||||
///
|
||||
/// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已
|
||||
/// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。
|
||||
///
|
||||
/// 截图通道的两档:`ULL_JAVA_STATE=<empty|two|editing>` 用种子(D30 可复现);
|
||||
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
|
||||
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
|
||||
pub(in crate::app) fn hydrate_java_settings(&mut self) {
|
||||
let seeded = self.options.screenshot.is_some();
|
||||
let mut live = false;
|
||||
if seeded {
|
||||
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
|
||||
if raw.trim() == "live" {
|
||||
live = true;
|
||||
} else {
|
||||
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
|
||||
self.settings.java = state;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java = pages::settings::java::State::default();
|
||||
return;
|
||||
};
|
||||
let snapshot = pages::settings::java::store::load(&config);
|
||||
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
|
||||
let changed = self.settings.java.hydrate(snapshot);
|
||||
let scan_changed = self.hydrate_java_scan();
|
||||
self.settings.java.context = self.java_context();
|
||||
if changed || scan_changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起**
|
||||
// (单测不该真去查注册表 / 跑 PATH 上的 java);判据与发起本身由
|
||||
// [`App::auto_scan_java_if_due`] 承载,测试直接调它。
|
||||
if !cfg!(test) {
|
||||
self.auto_scan_java_if_due(seeded && !live);
|
||||
}
|
||||
}
|
||||
|
||||
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
|
||||
///
|
||||
/// 单独抽出来是为了可测:`hydrate_java_settings` 的自动发起被 `cfg!(test)` 关掉了,
|
||||
/// 而「取消过就不再自动扫」这条语义必须有一条能红的用例(r2 复审 P2-2)。
|
||||
pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool {
|
||||
let report = self.settings.java.scan.report();
|
||||
if pages::settings::java::store::auto_scan_due(&report, screenshot) {
|
||||
let _ = self.start_java_scan();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 把后台扫描的快照灌进状态。返回是否变了。
|
||||
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
|
||||
let report = self.settings.java.scan.report();
|
||||
let changed = self.settings.java.apply_scan(&report);
|
||||
if changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
|
||||
fn start_java_scan(&mut self) -> Task<Message> {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
};
|
||||
let manual = pages::settings::java::store::load_manual(&config)
|
||||
.into_iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
let roots = pages::settings::java::store::managed_roots(&config);
|
||||
self.settings.java.notice = None;
|
||||
if self.settings.java.scan.start(roots, manual, true) {
|
||||
self.hydrate_java_scan();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
|
||||
fn add_java_manual(&mut self) {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
let draft = self.settings.java.add_draft.clone();
|
||||
match pages::settings::java::store::add_manual(&config, &draft) {
|
||||
Ok((binary, row)) => {
|
||||
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
|
||||
if !self
|
||||
.settings
|
||||
.java
|
||||
.runtimes
|
||||
.iter()
|
||||
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
|
||||
{
|
||||
self.settings.java.runtimes.push(row);
|
||||
self.settings
|
||||
.java
|
||||
.runtimes
|
||||
.sort_by_key(|runtime| runtime.major);
|
||||
}
|
||||
self.settings.java.add_draft = String::new();
|
||||
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
|
||||
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
|
||||
self.settings.java.scanned = true;
|
||||
self.settings.java.notice = Some(Notice::Added(binary));
|
||||
}
|
||||
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
|
||||
fn select_java_default(&mut self, binary: &str) {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::store::set_selected(&config, binary) {
|
||||
Ok(snapshot) => {
|
||||
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
|
||||
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
|
||||
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
|
||||
self.settings.java.hydrate(snapshot);
|
||||
self.settings.java.context = self.java_context();
|
||||
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
|
||||
fn save_java_settings(&mut self) {
|
||||
let state = &self.settings.java;
|
||||
if let Err(refusal) = state.validate() {
|
||||
self.settings.java.notice = Some(Notice::Refused(refusal));
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
}
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::store::save(&config, &self.settings.java) {
|
||||
Ok(snapshot) => {
|
||||
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
|
||||
self.settings.java.dirty = false;
|
||||
self.settings.java.hydrate(snapshot);
|
||||
self.settings.java.context = self.java_context();
|
||||
self.settings.java.notice = Some(Notice::Saved);
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
|
||||
///
|
||||
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
|
||||
/// (页面就不画门槛那一行,也不假装有要求)。
|
||||
fn java_context(&self) -> Context {
|
||||
let tiles = self.state.tiles();
|
||||
let routed = match self.state.route() {
|
||||
unlucky_presenter::router::Route::Detail(id) => {
|
||||
tiles.iter().find(|cell| &cell.instance.id == id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(cell) = routed.or_else(|| tiles.first()) else {
|
||||
return Context::default();
|
||||
};
|
||||
let instance = cell.instance;
|
||||
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
|
||||
Context {
|
||||
required: Some(Requirement {
|
||||
instance: instance.name.clone(),
|
||||
version: instance.mc_version.clone(),
|
||||
minimum: requirement.minimum,
|
||||
maximum: requirement.maximum,
|
||||
}),
|
||||
layer: Some(match instance.launch.memory_mb {
|
||||
Some(mb) => Layer::Instance {
|
||||
instance: instance.name.clone(),
|
||||
mb,
|
||||
},
|
||||
None => Layer::Global,
|
||||
}),
|
||||
effective_memory_mb: Some(instance.launch.memory_effective_mb),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写盘失败 → 回执:三档都走 i18n key。
|
||||
///
|
||||
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
|
||||
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
|
||||
/// 界面上给一句页面自己的「写不进去」。
|
||||
fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
|
||||
use pages::settings::java::store::WriteError;
|
||||
match error {
|
||||
WriteError::Refused(refusal) => Notice::Refused(refusal),
|
||||
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
|
||||
WriteError::Schema(detail) => {
|
||||
eprintln!("[java] 设置写盘失败:{detail}");
|
||||
Notice::WriteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
|
||||
let mut app = headless();
|
||||
app.view_dirty = false;
|
||||
|
||||
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
|
||||
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
|
||||
let _ = app.update(Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
|
||||
pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory(
|
||||
"4096".to_string(),
|
||||
)),
|
||||
)));
|
||||
|
||||
assert!(
|
||||
@@ -269,7 +273,7 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
|
||||
// --------------------------------------------------------------- M174:助手配置的活路径
|
||||
|
||||
/// 一个指向临时目录的设置桥(真文件,不是内存态)。
|
||||
fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
|
||||
pub(super) fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-ai-app-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
@@ -386,3 +390,7 @@ fn testing_without_configuration_does_nothing() {
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// M175 r1 复审的用例(拆出来守 400 行硬线;`gate::every_source_file_stays_under_the_line_limit` 会红)。
|
||||
#[path = "tests/java.rs"]
|
||||
mod java;
|
||||
@@ -0,0 +1,163 @@
|
||||
//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。
|
||||
//!
|
||||
//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线
|
||||
//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。
|
||||
|
||||
use super::bridged_app;
|
||||
use crate::app::Message;
|
||||
use crate::pages;
|
||||
|
||||
// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿
|
||||
|
||||
/// 一条 Java 分组消息 → 页面消息。
|
||||
fn java(message: pages::settings::java::Message) -> Message {
|
||||
Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::Java(message),
|
||||
))
|
||||
}
|
||||
|
||||
/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。
|
||||
///
|
||||
/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`,
|
||||
/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉,
|
||||
/// 一个字节都没落盘、也没有任何提示。
|
||||
///
|
||||
/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、
|
||||
/// JVM 回到空、`dirty` 变假)。
|
||||
#[test]
|
||||
fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
|
||||
let (mut app, path) = bridged_app("java-select");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::EditMemory(
|
||||
"6144".to_string(),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::EditJvm(
|
||||
"-XX:+UseG1GC".to_string(),
|
||||
)));
|
||||
assert!(app.settings.java.dirty, "改过草稿就该是脏的");
|
||||
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::SelectRuntime(
|
||||
"C:/jdk-21/bin/java.exe".to_string(),
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
app.settings.java.selected, "C:/jdk-21/bin/java.exe",
|
||||
"设为默认必须真的写进去"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.memory, "6144",
|
||||
"内存草稿被「设为默认」清掉了(P2-1 的数据丢失)"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.jvm, "-XX:+UseG1GC",
|
||||
"JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)"
|
||||
);
|
||||
assert!(
|
||||
app.settings.java.dirty,
|
||||
"内存 / JVM 参数还没保存,不许标成已保存"
|
||||
);
|
||||
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来
|
||||
|
||||
static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
|
||||
/// 一个会一直卡住的扫描作业(直到被取消)。
|
||||
fn held_job(
|
||||
_candidates: &[unlucky_core::game::java::staged::Candidate],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
_progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Vec<pages::settings::java::RuntimeRow> {
|
||||
while !HOLD.load(std::sync::atomic::Ordering::SeqCst) && !cancelled() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 取消过一次之后,**配置文件再变(心跳重读)也不会把扫描自己拉起来**,状态行仍是「已取消」。
|
||||
///
|
||||
/// 现场(r2 复审 P2-2):被取消的扫描 `running=false` / `cancelled=true` / `finished` 仍是
|
||||
/// `false`,而 `auto_scan_due` 不看 `cancelled` ⇒ `clock.rs` 的 `reload_if_changed` 每变一次
|
||||
/// `launcher.json`(存内存、改主题、改语言都算)就重新起一个扫描线程,用户的取消被无声撤销。
|
||||
///
|
||||
/// 变异证明:把 `auto_scan_due` 里的 `!report.cancelled` 去掉 ⇒ 这里 `auto_scan_java_if_due`
|
||||
/// 会真的起第二个线程,`scanning` 变 `Some`、状态行不再是「已取消」⇒ 本条红。
|
||||
#[test]
|
||||
fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
HOLD.store(false, Ordering::SeqCst);
|
||||
let (mut app, path) = bridged_app("java-cancel");
|
||||
|
||||
// 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成
|
||||
// cancelled=true / finished=false —— 正是复审指认的那个形状。
|
||||
let slot = app.settings.java.scan.clone();
|
||||
assert!(slot.start_with(Vec::new(), Vec::new(), false, held_job));
|
||||
slot.cancel();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while slot.report().running && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(!slot.report().running, "取消之后线程必须退出");
|
||||
assert!(slot.report().cancelled);
|
||||
assert!(!slot.report().finished);
|
||||
|
||||
// 心跳那条路:把状态灌一次(`hydrate_java_scan`),再走「该自动扫就扫」的判据。
|
||||
app.hydrate_java_scan();
|
||||
assert!(app.settings.java.cancelled, "状态行要留着「已取消」");
|
||||
assert!(app.settings.java.scanning.is_none());
|
||||
|
||||
let started = app.auto_scan_java_if_due(false);
|
||||
assert!(
|
||||
!started,
|
||||
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销)"
|
||||
);
|
||||
assert!(
|
||||
!slot.report().running,
|
||||
"不许起第二个扫描线程:{:?}",
|
||||
slot.report()
|
||||
);
|
||||
app.hydrate_java_scan();
|
||||
assert!(
|
||||
app.settings.java.scanning.is_none(),
|
||||
"状态行不许回到「正在扫描」"
|
||||
);
|
||||
assert!(
|
||||
app.settings.java.cancelled,
|
||||
"取消标记要保持到下一次手动扫描为止"
|
||||
);
|
||||
|
||||
HOLD.store(true, Ordering::SeqCst);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- r2 P3-3:core 的中文串不许直出
|
||||
|
||||
/// 写盘被 schema 拒时,界面拿到的是**页面自己的 key**,而不是 `StoreError` 的中文 `Display`。
|
||||
///
|
||||
/// 现场(r2 复审 P3-3):`WriteError::Schema(format!("{error}"))` 里 `core::setting::StoreError`
|
||||
/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是
|
||||
/// `Not saved: IO 失败 @ …`。
|
||||
///
|
||||
/// 变异证明:`write_notice` 里把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 本条红。
|
||||
#[test]
|
||||
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
|
||||
let (mut app, path) = bridged_app("java-schema");
|
||||
|
||||
// `java.extra_args` 的 schema 是 `Text{max_len: 1024}`:本地校验(内存/引号)不管长度,
|
||||
// 所以这一条会走到 `store.set` 的 schema 拒绝那一档。
|
||||
let too_long = "-D".to_string() + &"x".repeat(2000);
|
||||
let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long)));
|
||||
let _ = app.update(java(pages::settings::java::Message::Save));
|
||||
|
||||
assert_eq!(
|
||||
app.settings.java.notice,
|
||||
Some(pages::settings::java::Notice::WriteFailed),
|
||||
"schema 拒绝要走页面的「写不进去」key,不能把 core 的中文句子原样上屏"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
Reference in new issue
Block a user