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:
AskaEth committed 2026-11-08 19:52:04 +08:00
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();
+2
View File
@@ -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)
+5
View File
@@ -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 {
+274 -16
View File
@@ -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();
}