diff --git a/crates/app-iced/src/fonts/tests.rs b/crates/app-iced/src/fonts/tests.rs index d516123..8d565ec 100644 --- a/crates/app-iced/src/fonts/tests.rs +++ b/crates/app-iced/src/fonts/tests.rs @@ -179,12 +179,59 @@ fn type_roles_pick_the_display_stack_for_big_text() { /// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有 /// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。 /// -/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法) -/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。 -/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。 -/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。 +/// ## 为什么这条现在要在**自己的进程**里量(M262,ubuntu CI 真红) +/// +/// `install::status()` 读的是**进程级**的静态槽:`install.rs:146` 的 +/// `static STATE: OnceLock>`。而同一个 test binary(`unlucky-app-iced --lib`) +/// 里还有别的用例会**写**它,路径如下: +/// +/// ```text +/// app/update/page_messages.rs:206 the_language_message_switches_the_catalog_and_rebuilds_the_view +/// → App::update(Message::Language(Fixed(ZhCn))) +/// → app/update/page_messages.rs:146 set_language → ui/lang.rs:135 log_cjk +/// → fonts.rs:266 cjk_for(Some("zh_cn")) → install.rs:311 after_probe +/// → install.rs:317-318 Step::Start → install/worker.rs:74 update(Downloading{bytes}) +/// ``` +/// +/// 触发条件:那个用例用 `Options::default()`(`screenshot: None` ⇒ `construct.rs:106` +/// `set_offline(false)`)构造 App,而机器上**没有**中文字体时 `zh_cn` 判出 `NeedsDownload`。 +/// ubuntu runner 正是这一档(Windows 开发机装了中文字体,策略停在 `System`,所以本地看不出来)。 +/// 之后那条后台线程会真去拉 16.9 MiB(`install/worker.rs:88`),槽在下载期间一直是 +/// `Downloading`。它和本用例**并行**跑(默认多线程 harness),于是本用例读到的 `after` +/// 完全可能是**别人**在飞的那条下载。 +/// +/// 上一版直接比 `install::status()` 的前后快照 = 拿一把全进程共享的尺子量,谁并行谁赢。 +/// 修法不是放宽断言,而是**把尺子放进只有本用例的进程**:`current_exe()` + `--exact` +/// 重新起一次本 test binary、只跑这一条(`ULL_FONT_PURITY_CHILD=1` 防递归)。子进程里槽的初值 +/// 就是 `Unknown`,没有谁跟它抢,判据因此是确定性的。 +/// +/// 抓得住: +/// - 把 `install::after_probe(cjk_report.strategy)` 加回求值路径(r2 判为 bug 的写法)—— +/// 子进程里槽从 `Unknown` 变成 `Downloading { bytes: 17772300 }`,`!after.is_downloading()` +/// 当场红(实测,见报告「变异证明」); +/// - 更宽的回归:求值路径**任何**写槽的动作(连 `Unknown → NotNeeded` 这种「无害改写」也算), +/// 由「前后必须逐值相等」那条守; +/// - 判据本身失效(这张脸表不再判出 `NeedsDownload`)—— 单独一条断言,免得上面两条变成假绿。 +/// +/// 抓不住: +/// - 别的用例会不会写槽(本用例主动换进程,**不再**替全进程背书); +/// - `App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发); +/// - 起进程本身失败(`current_exe` 拿不到、禁 fork 的环境)——那时是**红**,不是软过; +/// - 子进程里跑了「几条」这件事只由 stdout 的 `1 passed` 兜着(改名会让它红,而不是变 0 条假绿)。 #[test] fn resolving_fonts_never_starts_a_download() { + if std::env::var_os(CHILD_ENV).is_some() { + evaluation_starts_no_download(); + return; + } + rerun_in_own_process(); +} + +/// 子进程标记(防递归:父进程起子进程,子进程直接跑判据)。 +const CHILD_ENV: &str = "ULL_FONT_PURITY_CHILD"; + +/// 判据本体(只在**没有别的用例抢全局槽**的进程里跑)。 +fn evaluation_starts_no_download() { use unlucky_i18n::font::Strategy; use crate::fonts::install; @@ -202,13 +249,48 @@ fn resolving_fonts_never_starts_a_download() { let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin)); let after = install::status(); + assert_eq!( + before, + install::CjkInstall::Unknown, + "隔离失效:这个进程里槽的初值已被谁改过({before:?}),下面的前后比较就没有意义" + ); assert_eq!( resolved.cjk.strategy, Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC), - "这张脸表必须判出「需要下载」,否则下面的断言证明不了什么" + "这张脸表必须判出「需要下载」,否则后面的断言证明不了什么" ); assert!( !after.is_downloading(), "求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})" ); + assert_eq!( + after, before, + "求值字体一次都不许写状态槽(比「不再是下载中」更强:任何改写都算越界)" + ); +} + +/// 用 `current_exe()` 把本 test binary 再起一次,只跑 [`resolving_fonts_never_starts_a_download`]。 +fn rerun_in_own_process() { + let exe = std::env::current_exe().expect("取本 test binary 的路径"); + let name = "fonts::tests::resolving_fonts_never_starts_a_download"; + let output = std::process::Command::new(&exe) + .args(["--exact", "--nocapture", "--test-threads=1", name]) + .env(CHILD_ENV, "1") + // 截图通道的钉子会直接替换 `status()` 的返回值(`install.rs:157` 先看它), + // 留着就等于把尺子换成别人的常数。 + .env_remove(crate::fonts::install::CAPTURE_ENV) + .output() + .unwrap_or_else(|error| panic!("起不了隔离子进程 {}:{error}", exe.display())); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "隔离子进程里的判据红了(退出码 {:?})\n--- stdout ---\n{stdout}\n--- stderr ---\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains("1 passed"), + "子进程里必须**真**跑了一条用例(`--exact` 名字对不上会 0 passed 退出 0,那是假绿):\n{stdout}" + ); } diff --git a/crates/app-iced/src/pages/settings/java/tests/prefs.rs b/crates/app-iced/src/pages/settings/java/tests/prefs.rs index e825376..479aa01 100644 --- a/crates/app-iced/src/pages/settings/java/tests/prefs.rs +++ b/crates/app-iced/src/pages/settings/java/tests/prefs.rs @@ -10,7 +10,7 @@ //! | 顺序层级 | [`an_empty_priority_follows_the_region`] | 让 `order_layer` 恒为 `Region` | //! | 超长路径不写盘 | [`an_overlong_install_root_is_refused_before_writing`] | 删掉 `Prefs::validate` 的长度判据 | -use std::path::Path; +use std::path::{Path, PathBuf}; use unlucky_core::setting::keys::launcher_schema; use unlucky_core::setting::store::SettingStore; @@ -65,7 +65,30 @@ fn the_three_keys_are_written_and_read_back() { /// `java.installRoot` 留空 ⇒ 生效落点来自 `core.data_mode`;填了 ⇒ 来自设置键。 /// -/// 变异证明:把 `prefs_store::effective` 里 `override_root` 那段删掉(当作永远没设)⇒ 后半段红。 +/// ## 为什么不再写死 `D:/JDKs`(M262) +/// +/// `D:/JDKs` 在 Windows 上是**绝对路径**、在 Linux 上是**相对路径**(`D:` 只是个普通目录名), +/// 而 `JavaInstallRoot::resolve`(`crates/core/src/game/java/location.rs:128`)对两者的处理是 +/// 两件事:绝对路径原样用,相对路径拼到数据根后面。于是同一条 `assert_eq!(root, "D:/JDKs")` +/// 在两个平台上验的是**两个不同的语义**:Windows 上验「设了就不拼数据根」(对),Linux 上验 +/// 「绝对路径被当成相对路径拼了」——ubuntu CI 因此必红。落点现在由临时目录给出,两个平台上都是 +/// 绝对路径,断言回到它本来的意思。 +/// +/// 抓得住: +/// - `effective` 忽略 `java.installRoot`(整段红:落点会变成数据根下的 `java-managed`, +/// `root_layer` 也退回 `DataMode`。实测:把 `override_root` 恒置空 ⇒ 红在 `root_layer`); +/// - 「设了却仍按数据根下的 `java-managed` 来」(实测:让 `JavaInstallRoot::resolve` 的 custom 分支 +/// 返回 `launcher.java_managed()` ⇒ 红在 `root` 那句,错误的落点被打出来); +/// - 相对路径的落点规则(第二段:相对路径**必须**拼数据根)。 +/// +/// 抓不住: +/// - **「绝对路径被当成相对路径拼」**:`Path::join` 对一个绝对路径返回的就是它自己,于是 +/// `is_absolute()` 那一支在两个平台上都**观测不到**——实测把那一支删成恒 `home().join(root)`, +/// 本用例仍然全绿。上一版写死 `D:/JDKs` 之所以在 Windows 上绿而在 Linux 上红,靠的不是 +/// 「拼不拼」这条分支,而是 `D:/JDKs` 在两个平台上**根本不是一类路径**。要让这一支有守卫, +/// 得换成一个「绝对路径 join 之后会变」的平台(没有),或者直接断言 `is_absolute` 的判据本身; +/// - 路径归一化(`D:/JDKs` → `D:\JDKs` 这类)与分隔符风格; +/// - Standard 数据模式下拿不到系统标准目录的那一臂(`root_unresolved`,本用例钉的是 portable)。 #[test] fn an_empty_install_root_follows_the_data_mode() { let dir = root("prefs-root"); @@ -87,15 +110,66 @@ fn an_empty_install_root_follows_the_data_mode() { effective.root ); + let custom_root = custom_install_root(); + assert!( + !custom_root.starts_with(&dir), + "custom 落点必须不在数据根里(否则「拼了数据根」这条也能过):{} vs {}", + custom_root.display(), + dir.display() + ); + let custom = Prefs { - install_root: "D:/JDKs".to_string(), - saved_install_root: "D:/JDKs".to_string(), + install_root: custom_root.display().to_string(), + saved_install_root: custom_root.display().to_string(), ..Prefs::default() }; let effective = prefs_store::effective(&file, &custom); assert_eq!(effective.root_layer, RootLayer::Setting); assert_eq!(effective.root_mode, "custom"); - assert_eq!(effective.root, "D:/JDKs", "设了就按它来,不再拼数据根"); + assert_eq!( + effective.root, + custom_root.display().to_string(), + "设了就按它来,不再拼数据根" + ); + assert!( + !effective.root_unresolved, + "custom 落点不该是「解析失败」:{effective:?}" + ); + + // 相对路径那一档(上一版写死的 `D:/JDKs` 在 Linux 上就落进这里):按数据根解析, + // 但来源层仍是设置键。两个平台上语义相同——这正是上一版抓不住的那一半。 + let relative = Prefs { + install_root: "jdks-relative".to_string(), + saved_install_root: "jdks-relative".to_string(), + ..Prefs::default() + }; + let effective = prefs_store::effective(&file, &relative); + assert_eq!(effective.root_layer, RootLayer::Setting); + assert_eq!(effective.root_mode, "custom"); + assert_eq!( + effective.root, + dir.join("jdks-relative").display().to_string(), + "相对路径按数据根解析(绝对路径才原样用)" + ); + + let _ = std::fs::remove_dir_all(&custom_root); +} + +/// 一个**平台无关的绝对路径**(且不在数据根里):custom 落点的替身。 +/// +/// 不能写死 `D:/JDKs` / `/opt/jdks`:前者在 Linux 上是相对路径,后者在 Windows 上是相对路径 +/// (见 [`an_empty_install_root_follows_the_data_mode`] 的文档)。临时目录两边都是绝对路径。 +fn custom_install_root() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-java-custom-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建 custom 落点"); + dir } /// `java.vendorPriority` 留空 ⇒ 地区默认序;排过 ⇒ 顺序与来源层都变。 diff --git a/crates/core/tests/adopt/fixture_cases.rs b/crates/core/tests/adopt/fixture_cases.rs index b915af1..59fffc1 100644 --- a/crates/core/tests/adopt/fixture_cases.rs +++ b/crates/core/tests/adopt/fixture_cases.rs @@ -5,6 +5,7 @@ //! 这里用**同一份** `snapshot.rs` 证明:目录节点、链接节点都进快照,读盘失败不吞。 use std::path::Path; +use std::time::{Duration, SystemTime}; use crate::fixture::{ link_guard, snapshot, snapshot_opt, try_link_dir, try_symlink_dir, Node, TempRoot, @@ -96,22 +97,111 @@ fn snapshot_catches_a_new_empty_directory() { /// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。 /// -/// 抓得住:目录节点只记路径不记 mtime(那样 `Node::Dir{mtime_ns}` 相同,第二条断言红)。 -/// 抓不住:某些文件系统对 mtime 的粗粒度(本机 NTFS 是 100ns 级;真粗到同一 tick 时这条会软过)。 +/// ## 为什么不再「写个文件然后赌 mtime 变了」(M262) +/// +/// 上一版只做「建目录 → 拍快照 → 写文件 → 再拍快照 → `assert_ne!(node)`」,靠的是**文件系统 +/// 恰好把两次操作记成不同的 mtime**。这一条在 windows CI 上真红(写文件与建目录落在同一 tick), +/// 而它自己的注释还写着「真粗到同一 tick 时会软过」——既不可靠也不软过,是个真红。 +/// +/// 现在两个 mtime 都由本用例**显式给定**([`set_dir_mtime`]),不来自时钟:t0 = 1_000_000_000s, +/// t1 = t0 + 3600s,两者都是整秒、都对任何常见粒度(NTFS 100ns / ext4 1ns / HFS+ 1s / FAT 2s) +/// 无余数,于是「快照读到的目录 mtime」与「我们钉的那个值」可以逐 ns 比对。 +/// +/// 抓得住: +/// - `Node::Dir` 不再携带 mtime(记 0 / 记常量)—— 逐 ns 断言当场红; +/// - 快照不再重新读元数据(缓存 / 只记路径)—— 同上; +/// - 目录节点整个丢失(`expect` 红)。 +/// +/// 抓不住: +/// - **文件系统会不会自己在条目增删时抬高目录 mtime**:本用例把这件事绕开了(两次 mtime 都是我们 +/// 钉的),所以「NTFS 真机上目录 mtime 会不会被延时更新」这件事依然**没有**用例守——它有自己 +/// 的用例(`snapshot_catches_a_new_empty_directory` 比的是路径集合,不靠 mtime); +/// - 粒度比 3600s 还粗的文件系统(常见 FS 里没有;真遇到会红在「逐 ns 相等」那句,不会软过); +/// - 「目录里多了一个文件」这件事本身(那由 [`snapshot_catches_a_new_empty_directory`] 与 +/// 下面 `watched/new.txt` 那一条单独守,不靠 mtime)。 #[test] fn snapshot_notices_a_directory_mtime_change() { let root = TempRoot::new("snap-dirmtime"); let dir = root.path().join("watched"); std::fs::create_dir_all(&dir).expect("建目录"); + + // 两个明确的时刻(整秒,任何常见 filetime 粒度都能原样存下)。 + let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000); + let t1 = t0 + Duration::from_secs(3_600); + + set_dir_mtime(&dir, t0); let before = snapshot(root.path()); + + // 场景照旧是真的:往目录里加了一个文件(条目增删是 mtime 变的**真实**来源)。 std::fs::write(dir.join("new.txt"), b"x").expect("写文件"); + // 但差异由本用例给出,而不是「期望这次写恰好落在另一个 tick」。 + set_dir_mtime(&dir, t1); + let after = snapshot(root.path()); + + // 场景本身要成立(否则下面比的是一个没变的世界)。 + assert!( + !before.contains_key("watched/new.txt") + && matches!(after.get("watched/new.txt"), Some(Node::File { .. })), + "先把「目录里真多了一个文件」这条落实:before={:?} after={:?}", + before.get("watched/new.txt"), + after.get("watched/new.txt") + ); + let before_node = before.get("watched").expect("有该目录节点"); let after_node = after.get("watched").expect("有该目录节点"); assert_ne!( before_node, after_node, - "目录里加了文件,目录节点(含 mtime)必须变" + "目录 mtime 变了,目录节点必须变(mtime 是我们钉的,不看时钟)" ); + assert_eq!( + dir_mtime_ns(after_node), + t1.duration_since(SystemTime::UNIX_EPOCH) + .expect("t1 在纪元之后") + .as_nanos(), + "快照里的目录 mtime 必须就是我们钉的那个值(逐 ns)" + ); + assert_eq!( + dir_mtime_ns(before_node), + t0.duration_since(SystemTime::UNIX_EPOCH) + .expect("t0 在纪元之后") + .as_nanos() + ); +} + +/// 给**目录**显式钉一个 mtime(std-only;两个平台的句柄打开方式不同)。 +#[cfg(windows)] +fn set_dir_mtime(dir: &Path, at: SystemTime) { + use std::os::windows::fs::OpenOptionsExt; + // 0x0100 = FILE_WRITE_ATTRIBUTES:`SetFileTime` 要的就是这个权限,而 GENERIC_WRITE + // (`OpenOptions::write(true)`)在**目录**上会被拒(os error 5)。 + // 0x0200_0000 = FILE_FLAG_BACKUP_SEMANTICS:不开它 `CreateFile` 根本打不开目录句柄。 + let handle = std::fs::OpenOptions::new() + .access_mode(0x0100) + .custom_flags(0x0200_0000) + .open(dir) + .unwrap_or_else(|error| panic!("打不开目录句柄 {}:{error}", dir.display())); + handle + .set_modified(at) + .unwrap_or_else(|error| panic!("给目录设 mtime 失败 {}:{error}", dir.display())); +} + +/// 同 [`set_dir_mtime`](`futimens` 走普通目录句柄,不需要额外权限)。 +#[cfg(not(windows))] +fn set_dir_mtime(dir: &Path, at: SystemTime) { + let handle = std::fs::File::open(dir) + .unwrap_or_else(|error| panic!("打不开目录句柄 {}:{error}", dir.display())); + handle + .set_modified(at) + .unwrap_or_else(|error| panic!("给目录设 mtime 失败 {}:{error}", dir.display())); +} + +/// 目录节点的 mtime(纳秒)。节点不是目录就直接红:本用例只谈目录。 +fn dir_mtime_ns(node: &Node) -> u128 { + match node { + Node::Dir { mtime_ns } => *mtime_ns, + other => panic!("watched 应当是目录节点:{other:?}"), + } } /// 目标根本不存在 ⇒ `snapshot_opt` 给 `None`(与「空树」区分开)。