Files
Unlucky-Launcher/crates/core/tests/adopt/fixture_cases.rs
T
AskaEth d4959fd3d3 test(m262): 三条平台/时序依赖的测试改成确定性判据
① prefs.rs:custom 落点不再写死 D:/JDKs(Linux 上它是相对路径,会被拼到数据根后,
   同一条断言在两个平台上验的是两件事)。改由临时目录给绝对路径;另加一条相对路径
   必须按数据根解析的断言,把上一版抓不住的那一半补上。
② fonts/tests.rs:install::status() 读的是进程级静态槽,同 binary 的 page_messages
   用例切 zh_cn 会真的发起下载并把它写成 Downloading ⇒ 前后快照比较与谁并行谁赢。
   改成用 current_exe() 在**只有本用例的进程**里量,并断言前后逐值相等(任何写槽都算越界)。
③ fixture_cases.rs:目录 mtime 不再赌「写文件恰好落在另一个 tick」,改为显式钉 mtime
   (t0/t1 整秒,对任何 filetime 粒度无余数)并逐 ns 比对。

三条都补了「抓得住什么 / 抓不住什么」。
2026-11-11 22:41:06 +08:00

232 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 快照工具自身的回归:它必须抓得住「凭空多出一个空目录」(复审 r1 的 R5)。
//!
//! 早先的快照只记文件:`scan` 或 `--dry-run` 若在外部树里建了一个空目录(例如
//! `<game>/versions/<v>/mods`),快照完全不变,于是「零写入」这条承诺可以是假绿。
//! 这里用**同一份** `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,
};
/// 目录 / 文件 / 链接三类节点都记,且链接**不跟随**(目标内容不会挂到链接路径下)。
///
/// 抓得住:
/// - 目录节点缺失(`dir` 断言红);
/// - 跟随链接(`link/inside.txt` 会同时出现在链接路径下,`is_none` 断言红);
/// - 链接被当成普通目录(`Node::Link` 断言红)。
///
/// 抓不住:链接目标本身的内容变化(不跟随就看不见——这正是本工具要的语义)。
#[test]
fn snapshot_records_dirs_files_and_links_without_following() {
let root = TempRoot::new("snap-shapes");
let real = root.path().join("real");
std::fs::create_dir_all(&real).expect("建真实目录");
std::fs::write(real.join("inside.txt"), b"x").expect("写文件");
let link = root.path().join("link");
if !link_guard(try_symlink_dir(&real, &link), "快照链接夹具") {
// 链接建不出来时仍要验目录/文件两类(guard 已显式记录跳过原因)。
let snap = snapshot(root.path());
assert!(matches!(snap.get("real"), Some(Node::Dir { .. })));
assert!(matches!(
snap.get("real/inside.txt"),
Some(Node::File { .. })
));
return;
}
let snap = snapshot(root.path());
assert!(
matches!(snap.get("."), Some(Node::Dir { .. })),
"根自身也要记"
);
assert!(matches!(snap.get("real"), Some(Node::Dir { .. })));
assert!(matches!(
snap.get("real/inside.txt"),
Some(Node::File { .. })
));
assert!(
matches!(snap.get("link"), Some(Node::Link { .. })),
"链接要记成 Link:{:?}",
snap.get("link")
);
assert!(
!snap.contains_key("link/inside.txt"),
"不许跟随链接(否则根外/别处内容会被算进这棵树)"
);
}
/// **新增一个空目录**必须让快照变化(R5 的核心判据)。
///
/// 抓得住:只记文件的旧快照(`before == after` 会让第一条断言红)。
/// 抓不住:目录 mtime 的精度(这条只比路径集合与节点形状;mtime 由 `assert_unchanged` 另外守)。
#[test]
fn snapshot_catches_a_new_empty_directory() {
let root = TempRoot::new("snap-newdir");
let game = root.game_root().join("versions").join("1.20.1");
std::fs::create_dir_all(&game).expect("建版本目录");
let before = snapshot(&root.game_root());
// 「登记过程凭空建了一个空目录」——正是旧快照看不见的那种改动。
let planted = game.join("mods");
std::fs::create_dir_all(&planted).expect("建空目录");
let after = snapshot(&root.game_root());
assert_ne!(before, after, "新增空目录必须改变快照");
assert!(
!before.contains_key("versions/1.20.1/mods"),
"改动前不该有这个节点"
);
assert!(
matches!(after.get("versions/1.20.1/mods"), Some(Node::Dir { .. })),
"新增的空目录要作为目录节点出现:{:?}",
after.get("versions/1.20.1/mods")
);
// 反向:删掉之后路径集合回到原样(目录 mtime 会变,所以只比键集合)。
std::fs::remove_dir(&planted).expect("删空目录");
let again = snapshot(&root.game_root());
assert_eq!(
before.keys().collect::<Vec<_>>(),
again.keys().collect::<Vec<_>>(),
"删掉空目录后路径集合应回到原样"
);
}
/// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。
///
/// ## 为什么不再「写个文件然后赌 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 是我们钉的,不看时钟)"
);
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`(与「空树」区分开)。
///
/// 抓得住:把「不存在」当成空快照(那样「dry-run 建了 .ULL」就看不出来)。
/// 抓不住:存在但权限不足(那一支直接 panic,见模块文档)。
#[test]
fn a_missing_target_is_none_not_an_empty_snapshot() {
let root = TempRoot::new("snap-missing");
let absent: &Path = &root.path().join("nope");
assert!(snapshot_opt(absent).is_none());
std::fs::create_dir_all(absent).expect("建空目录");
assert!(snapshot_opt(absent).is_some(), "空目录也算存在");
}
/// 链接建不出来时 `try_link_dir` 会退回联接(Windows);这条只验它不 panic。
#[test]
fn link_fixtures_are_total() {
let root = TempRoot::new("snap-link-total");
let target = root.path().join("target");
std::fs::create_dir_all(&target).expect("建目标");
let link = root.path().join("link");
let created = try_link_dir(&target, &link);
if created {
assert!(link.is_dir());
}
}