test(core): 「哪些键真的交给日志」补自动化证据(M244 r2 P2-1)

复审 P2-1:旧用例只断言 `collect_fresh`(纯函数)与 `unknown_keys()`(表级清单),
没人断言 `report` **真的**只把「新键」交给 sink;把 `for key in fresh` 改成
`for key in unknown`(每次渲染重复写日志)当时 6 条用例全绿,而「同键只报一次」正是判据 ③。

- `report_with(&self, unknown, sink: impl FnMut(&str))`:`report` 变成
  `report_with(unknown, emit)`,`emit` 仍是 `cfg(test)` 空实现(不碰进程级单例,flake 不回潮);
- 新用例 `report_emits_only_fresh_keys`:假 sink 收集,断言一次渲染内重复只交一次、
  跨渲染只有新键才交、顺序与 `collect_fresh` 一致;
- 顺手收掉自己列的边角:`unknown_log_line` 先把键名里的 `\r`/`\n` 换成空格
  (`launcher.log` 是行导向的,恶意清单塞换行能把一个键变成两行假日志),
  新用例 `log_lines_never_break_into_two_lines`。

变异自证(各自 exit 101,均已还原、文件 sha256 复原):
- `for key in &fresh` → `for key in unknown` ⇒ `report_emits_only_fresh_keys` 红,
  `left: ["a", "a"] right: ["a"]`;
- 删掉 `key.replace(['\r','\n'], " ")` 那一行 ⇒ `log_lines_never_break_into_two_lines` 红,
  `left: 2 right: 1`。

门禁:fmt / check -p unlucky-core --all-targets / test -p unlucky-core -p unlucky-cli
(1678 passed / 0 failed)/ clippy --workspace --all-targets -- -D warnings 全绿。
This commit is contained in:
AskaEth committed 2026-11-11 11:32:27 +08:00
1 parent 60607ae7b3
commit c97ec293c3
2 files changed
+69 -8

No files matched your search

+20 -7
View File
@@ -177,14 +177,22 @@ impl PlaceholderMap {
outcome outcome
} }
/// 登记本次遇到的未登记键,只把**新**见到的那些写成日志行。 /// 登记本次遇到的未登记键,只把**新**见到的那些写进日志。
fn report(&self, unknown: &[String]) { fn report(&self, unknown: &[String]) {
self.report_with(unknown, emit);
}
/// [`Self::report`] 的可注入版本:`sink` 拿到的是「本次**新**见到的键」(去重、保序)。
///
/// 留这道缝只为一件事:让「哪些键真的交给了日志」有自动化证据(用例用假 sink 断言),
/// 而不是只能靠读代码——进程级的 `launcher_log` 在 lib 测试里动不得(见 [`emit`] 的注释)。
fn report_with(&self, unknown: &[String], mut sink: impl FnMut(&str)) {
let fresh = { let fresh = {
let mut seen = self.seen(); let mut seen = self.seen();
collect_fresh(&mut seen, unknown) collect_fresh(&mut seen, unknown)
}; };
for key in fresh { for key in &fresh {
emit(&key); sink(key);
} }
} }
@@ -266,8 +274,12 @@ fn collect_fresh(seen: &mut Vec<String>, keys: &[String]) -> Vec<String> {
} }
/// 未登记键的 `launcher.log` 行(scope 与时间戳由 `launcher_log` 加)。 /// 未登记键的 `launcher.log` 行(scope 与时间戳由 `launcher_log` 加)。
///
/// 键名先做换行消毒:`launcher.log` 是**行导向**的,第三方清单完全可以在 JSON 里塞
/// `${evil\n2026-… [launch] 伪造行}`,把一个键变成两行假日志。这里换成一个空格。
fn unknown_log_line(key: &str) -> String { fn unknown_log_line(key: &str) -> String {
match near_miss(key) { let key = key.replace(['\r', '\n'], " ");
match near_miss(&key) {
Some(known) => format!( Some(known) => format!(
"占位符 ${{{key}}} 未登记:已按原样保留;疑似 ${{{known}}} 的笔误\ "占位符 ${{{key}}} 未登记:已按原样保留;疑似 ${{{known}}} 的笔误\
(替换是精确匹配,大小写/空白都算不同的键)" (替换是精确匹配,大小写/空白都算不同的键)"
@@ -297,9 +309,10 @@ fn near_miss(key: &str) -> Option<&'static str> {
/// 测试构建里是空实现:`launcher_log` 是**进程级单例**(目录 + 200 行缓冲),lib 的几百条 /// 测试构建里是空实现:`launcher_log` 是**进程级单例**(目录 + 200 行缓冲),lib 的几百条
/// 用例在同一个进程里并行跑,本模块记的几行会落进 `launcher_log` 自己 /// 用例在同一个进程里并行跑,本模块记的几行会落进 `launcher_log` 自己
/// `buffer_is_bounded_before_the_dir_is_known` 的窗口里(实测:`缓冲没有上限:205 行`)。 /// `buffer_is_bounded_before_the_dir_is_known` 的窗口里(实测:`缓冲没有上限:205 行`)。
/// 让每个用例都去抢那个单例,等于把 flake 转嫁给别人。行文本与「哪些键该记」分别由 /// 让每个用例都去抢那个单例,等于把 flake 转嫁给别人。三段各有自动化证据:行文本
/// [`unknown_log_line`] / [`collect_fresh`] 的用例覆盖;落盘这一环由 /// [`unknown_log_line`]、「哪些键算新」[`collect_fresh`]、「新键怎么交给 sink」
/// `M244` 的一次性探针(`--test-threads=1`,跑完即删)手工验过。 /// [`PlaceholderMap::report_with`](用例用假 sink 断言);只剩最后一句
/// `launcher_log::record` 由 `M244` 的一次性探针(`--test-threads=1`,跑完即删)手工验过。
#[cfg(not(test))] #[cfg(not(test))]
fn emit(key: &str) { fn emit(key: &str) {
crate::launch::launcher_log::record("launch", &unknown_log_line(key)); crate::launch::launcher_log::record("launch", &unknown_log_line(key));
+49 -1
View File
@@ -8,7 +8,8 @@
//! flake——实测过一次:本模块的几行把 `launcher_log` 的 `缓冲没有上限:205 行` 顶红了。 //! flake——实测过一次:本模块的几行把 `launcher_log` 的 `缓冲没有上限:205 行` 顶红了。
//! 所以 [`emit`](super::emit) 在测试构建里是空实现,落盘那一环用一次性探针 //! 所以 [`emit`](super::emit) 在测试构建里是空实现,落盘那一环用一次性探针
//! (`--test-threads=1`,跑完即删)手工验过。这里断言的是它的上游——行文本 //! (`--test-threads=1`,跑完即删)手工验过。这里断言的是它的上游——行文本
//! ([`unknown_log_line`])与「哪些键该记」([`collect_fresh`])——两段合起来就是「记什么」。 //! ([`unknown_log_line`])、「哪些键算新」([`collect_fresh`])、「新键怎么交给 sink」
//! ([`PlaceholderMap::report_with`],用假 sink)——三段合起来就是「记什么、记几次」。
use super::{collect_fresh, near_miss, unknown_log_line, PlaceholderMap}; use super::{collect_fresh, near_miss, unknown_log_line, PlaceholderMap};
use crate::launch::command_builder::CommandBuilder; use crate::launch::command_builder::CommandBuilder;
@@ -135,3 +136,50 @@ fn rendering_semantics_are_unchanged() {
"空键也是未登记键(原样保留)" "空键也是未登记键(原样保留)"
); );
} }
/// ③ 的「真的交给了日志」这一层:`report_with` 只把**本次新**见到的键递给你的 sink。
///
/// 变异证明:把 `report_with` 里的 `for key in &fresh` 换成 `for key in unknown`(每次渲染都
/// 重复写日志)⇒ 本条红(`left: ["a", "a", …]`)——「一个键只报一次、不刷屏」被守住的地方
/// 就在这里,而不是在 `collect_fresh` 的用例里。
#[test]
fn report_emits_only_fresh_keys() {
let map = PlaceholderMap::new();
let mut delivered: Vec<String> = Vec::new();
map.report_with(&strings(&["a", "a"]), |key| delivered.push(key.to_string()));
assert_eq!(delivered, strings(&["a"]), "一次渲染内的重复只交一次");
map.report_with(&strings(&["a", "b"]), |key| delivered.push(key.to_string()));
assert_eq!(
delivered,
strings(&["a", "b"]),
"跨渲染只有新键才交,且保序"
);
assert_eq!(
map.unknown_keys(),
strings(&["a", "b"]),
"表级清单与 sink 同一份"
);
// 顺序口径与 `collect_fresh` 一致(同一份规则的两处实现不能漂)。
let mut fresh_seen = Vec::new();
assert_eq!(
collect_fresh(&mut fresh_seen, &strings(&["a", "a"])),
strings(&["a"])
);
assert_eq!(
collect_fresh(&mut fresh_seen, &strings(&["a", "b"])),
strings(&["b"])
);
}
/// 日志是**行导向**的:恶意/畸形清单可以在 JSON 里塞换行,一个键不能变成两行假日志。
#[test]
fn log_lines_never_break_into_two_lines() {
let line = unknown_log_line("evil\n2026-01-01T00:00:00Z [launch] 伪造行\r");
assert_eq!(line.lines().count(), 1, "{line}");
assert!(!line.contains('\n') && !line.contains('\r'), "{line}");
assert!(
line.contains("evil 2026-01-01T00:00:00Z [launch] 伪造行"),
"{line}"
);
}