Unlucky Launcher v0.2.1
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-06 20:47:19 +08:00
commit 5046f96f7e
1092 files changed
+211068

No files matched your search

@@ -0,0 +1,233 @@
//! 五类信号采集:环形缓冲语义 / 退出码分类 / 磁盘采集器 / 引擎输入拼装。
use std::fs;
use std::time::{Duration, SystemTime};
use crate::prelude::*;
#[test]
fn log_buffer_keeps_key_lines_when_evicted() {
let mut buffer = LogBuffer::new(2);
buffer.push(SignalOrigin::Stdout, "java.lang.Exception: boom");
buffer.push(SignalOrigin::Stdout, "line 2");
buffer.push(SignalOrigin::Stdout, "line 3");
buffer.push(SignalOrigin::Stderr, "line 4");
assert_eq!(buffer.pushed(), 4);
assert_eq!(buffer.dropped(), 2);
assert_eq!(buffer.kept_key_lines(), 1, "关键行被淘汰后仍应保留");
assert_eq!(buffer.len(), 3, "保留 1 行关键历史 + 环形区 2 行");
let text = buffer.text();
assert!(text.contains("boom"), "根因行不该被挤出缓冲区");
assert!(text.contains("line 4"));
assert!(!text.contains("line 2"));
assert!(!buffer.is_empty());
}
#[test]
fn exit_types_match_launch_contract() {
// 字符串值与 launch-接口约定.md §3 exit.exit_type 完全一致
let kill = ExitSignal::classify(Some(137), Some(60_000), 0, "");
assert_eq!(kill.exit_type, ExitType::Sigkill);
assert_eq!(kill.exit_type.as_str(), "sigkill");
assert_eq!(ExitType::from_name("sigkill"), ExitType::Sigkill);
let term = ExitSignal::classify(Some(143), Some(60_000), 1, "");
assert_eq!(term.exit_type, ExitType::Sigterm);
let jvm = ExitSignal::classify(
Some(1),
Some(800),
2,
"Error occurred during initialization of VM\nCould not create the Java Virtual Machine",
);
assert_eq!(jvm.exit_type, ExitType::JvmError);
assert!(!jvm.silent_failure);
let normal = ExitSignal::classify(Some(0), Some(600_000), 0, "Game started fine");
assert_eq!(normal.exit_type, ExitType::Normal);
assert!(!normal.silent_failure);
assert!(normal.status_hex().is_none());
// exit 0 + 短命 + 有错误行 → 静默失败(HMCL 会判 NORMAL 放过)
let silent = ExitSignal::classify(Some(0), Some(5_000), 4, "java.lang.Exception: boom");
assert_eq!(silent.exit_type, ExitType::Normal);
assert!(silent.silent_failure);
// Windows 访问违例:负的 i32 → 十六进制状态码
let access = ExitSignal::classify(Some(-1_073_741_819), Some(90_000), 0, "");
assert!(access.is_access_violation());
assert_eq!(access.status_hex().as_deref(), Some("0xc0000005"));
for exit_type in [
ExitType::Normal,
ExitType::ApplicationError,
ExitType::JvmError,
ExitType::Sigkill,
ExitType::Sigterm,
ExitType::Killed,
ExitType::Interrupted,
] {
assert_eq!(ExitType::from_name(exit_type.as_str()), exit_type);
}
}
#[test]
fn crash_report_collector_reads_newest_and_honours_started_at() {
let run_dir = temp_dir("collect-crash");
let reports = run_dir.join("crash-reports");
fs::create_dir_all(&reports).expect("建目录");
fs::write(
reports.join("crash-2026-10-29_12.00.00-client.txt"),
"---- Minecraft Crash Report ----\nDescription: Example\n",
)
.expect("写崩溃报告");
let collector = CrashReportCollector::new();
let collectors: [&dyn SignalCollector; 1] = [&collector];
let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir));
assert!(errors.is_empty());
let captured = bundle.crash_report.expect("应采到崩溃报告");
assert!(captured.text.contains("Description: Example"));
assert_eq!(bundle.sources.len(), 1);
// 只要「本次会话之后」的文件:把下界设到未来 → 不采
let future =
CollectionContext::new(&run_dir).since(SystemTime::now() + Duration::from_secs(3_600));
let (bundle, errors) = collect_all(&collectors, &future);
assert!(errors.is_empty());
assert!(bundle.crash_report.is_none(), "历史崩溃不该算进本次");
}
#[test]
fn hs_err_collector_parses_newest_file() {
let run_dir = temp_dir("collect-hs-err");
fs::write(
run_dir.join("hs_err_pid4242.log"),
"#\n# A fatal error has been detected by the Java Runtime Environment:\n#\n# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x1, pid=4242, tid=0x2\n# Problematic frame:\n# C [nvoglv64.dll+0x10]\n",
)
.expect("写 hs_err");
let collector = HsErrCollector::new();
let collectors: [&dyn SignalCollector; 1] = [&collector];
let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir));
assert!(errors.is_empty());
let report = bundle.hs_err.expect("应解析 hs_err");
assert_eq!(report.kind, HsErrKind::AccessViolation);
assert_eq!(report.pid, Some(4242));
assert_eq!(bundle.sources.len(), 1);
}
#[test]
fn latest_log_collector_tails_n_lines() {
let run_dir = temp_dir("collect-latest");
let logs = run_dir.join("logs");
fs::create_dir_all(&logs).expect("建目录");
let mut text = String::new();
for index in 0..10 {
text.push_str(&format!("line {index}\n"));
}
fs::write(logs.join("latest.log"), text).expect("写日志");
let collector = LatestLogCollector::new().with_max_lines(4);
let collectors: [&dyn SignalCollector; 1] = [&collector];
let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir));
assert!(errors.is_empty());
assert_eq!(bundle.log.pushed(), 4);
assert!(bundle.log.text().contains("line 9"));
assert!(!bundle.log.text().contains("line 5"));
}
#[test]
fn collect_all_aggregates_pushed_and_disk_signals() {
let run_dir = temp_dir("collect-all");
let reports = run_dir.join("crash-reports");
fs::create_dir_all(&reports).expect("建目录");
fs::write(
reports.join("crash-a.txt"),
"---- Minecraft Crash Report ----\n",
)
.expect("写报告");
let snapshot = EnvironmentSnapshot {
java_major: Some(17),
java_bits: Some(64),
xmx_mb: Some(4096),
physical_memory_mb: Some(16_384),
mods: vec![ModRecord::from_file_name("sodium-fabric-0.5.8.jar")],
components: vec![unlucky_core::diagnose::collectors::ComponentVersion::new(
"fabric", "0.15.0",
)],
..EnvironmentSnapshot::default()
};
let exit = ExitCollector::new(ExitSignal::classify(Some(1), Some(9_000), 1, "boom"));
let environment = EnvironmentCollector::new(snapshot);
let crash = CrashReportCollector::new();
let hs_err = HsErrCollector::new();
let collectors: [&dyn SignalCollector; 4] = [&exit, &environment, &crash, &hs_err];
let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir));
assert!(errors.is_empty(), "单源缺失不应报错:{errors:?}");
assert!(bundle.exit.is_some());
assert!(bundle.crash_report.is_some());
assert!(
bundle.hs_err.is_none(),
"没有 hs_err 文件时是 None(不是错误)"
);
let snapshot = bundle.environment.as_ref().expect("环境快照");
assert_eq!(snapshot.java_major, Some(17));
assert!(snapshot.summary().contains("Java 17"));
assert_eq!(bundle.mods.len(), 1);
assert!(bundle.mods[0].enabled);
assert!(!bundle.is_empty());
}
#[test]
fn mod_record_parses_file_name() {
let record = ModRecord::from_file_name("jei-1.20.1-15.2.0.27.jar");
assert_eq!(record.id, "jei-1.20.1");
assert_eq!(record.version.as_deref(), Some("15.2.0.27"));
assert!(record.enabled);
let disabled = ModRecord::from_file_name("optifine.jar.disabled");
assert!(!disabled.enabled);
assert_eq!(disabled.id, "optifine");
}
#[test]
fn bundle_input_groups_log_by_origin_and_carries_hs_err() {
let mut bundle = SignalBundle::new();
bundle.log.push(SignalOrigin::Stdout, "stdout line");
bundle.log.push(SignalOrigin::Stderr, "stderr line");
bundle.exit = Some(ExitSignal::classify(Some(1), Some(1_000), 1, "boom"));
let input = bundle.diagnosis_input();
assert!(input.exit.is_some());
let origins = input
.haystacks
.iter()
.map(|haystack| haystack.origin)
.collect::<Vec<_>>();
assert!(origins.contains(&SignalOrigin::Stdout));
assert!(origins.contains(&SignalOrigin::Stderr));
assert!(!input.is_empty());
let mut with_hs_err = SignalBundle::new();
with_hs_err.hs_err = Some(hs_err::parse(&read_sample(
"samples/hs_err/access_violation.log",
)));
let input = with_hs_err.diagnosis_input();
assert!(input.hs_err.is_some());
assert!(input
.haystacks
.iter()
.any(|haystack| haystack.origin == SignalOrigin::HsErr));
}
#[test]
fn empty_bundle_produces_empty_input() {
let bundle = SignalBundle::new();
assert!(bundle.is_empty());
assert!(bundle.diagnosis_input().is_empty());
}
+233
View File
@@ -0,0 +1,233 @@
//! 引擎行为:主因排序 / 抑制关系 / 去重 / 内置结论 / 动作物化。
use crate::prelude::*;
#[test]
fn root_cause_outranks_secondary_error() {
// 一条日志里既 OOM 又 NoClassDefFoundError:OOM 是根因(priority 80 vs 40)。
let text = "\
java.lang.OutOfMemoryError: Java heap space
java.lang.NoClassDefFoundError: net/minecraftforge/fml/loading/FMLEnvironment
";
let report = analyze_text(SignalOrigin::Stdout, text);
assert_eq!(
report.primary().expect("命中").rule_id,
"memory.out_of_memory"
);
assert!(
ids(&report).contains(&"integrity.no_class_def_found"),
"低级错误也应给出(哪怕是次生):{:?}",
ids(&report)
);
}
#[test]
fn suppress_moves_rule_into_secondary() {
// 32 位 Java 分配失败 + 次生 OutOfMemoryError
let text = "\
Could not reserve enough space for 4194304KB object heap
java.lang.OutOfMemoryError: Java heap space
";
let report = analyze_text(SignalOrigin::Stdout, text);
assert_eq!(report.primary().expect("命中").rule_id, "jvm.32bit");
let secondary = report
.secondary
.iter()
.find(|finding| finding.rule_id == "memory.out_of_memory")
.expect("OOM 应被降级为次生结论而不是丢弃");
assert_eq!(secondary.suppressed_by.as_deref(), Some("jvm.32bit"));
assert!(
!ids(&report).contains(&"memory.out_of_memory"),
"被抑制的结论不应出现在主因列表"
);
}
#[test]
fn same_rule_keeps_best_evidence_source() {
// 同一条文本在两个来源出现:只留一条结论,且优先崩溃报告。
let mut input = DiagnosisInput::new();
input.push_text(
SignalOrigin::Stdout,
"java.lang.OutOfMemoryError: Java heap space",
);
input.push_text(
SignalOrigin::CrashReport,
"---- Minecraft Crash Report ----\njava.lang.OutOfMemoryError: Java heap space",
);
let report = diagnoser().analyze(&input);
let hits = report
.findings
.iter()
.filter(|finding| finding.rule_id == "memory.out_of_memory")
.count();
assert_eq!(hits, 1, "同一规则只应报一次");
let finding = &report.findings[0];
assert_eq!(finding.evidence[0].origin, SignalOrigin::CrashReport);
}
#[test]
fn access_violation_exit_is_diagnosed() {
let exit = ExitSignal::classify(Some(-1_073_741_819), Some(90_000), 0, "");
let mut input = DiagnosisInput::new();
input.exit = Some(exit);
let report = diagnoser().analyze(&input);
let finding = report
.findings
.iter()
.find(|finding| finding.rule_id == "signal.access_violation")
.expect("0xC0000005 应产出内置结论");
assert_eq!(finding.category, RuleCategory::Graphics);
let action = &finding.materialized_actions()[0];
assert_eq!(action.kind, FixActionKind::ChangeGraphics);
assert_eq!(action.tool, "graphics.set");
assert_eq!(
action.args.get("update_driver").map(String::as_str),
Some("true")
);
}
#[test]
fn silent_failure_is_marked_on_exit_zero() {
// analyse/02 §7.10-10:exit 0 + 存活 <30s + 有错误行 → 静默失败,HMCL 会判 NORMAL 放过。
let exit = ExitSignal::classify(Some(0), Some(4_200), 3, "java.lang.Exception: boom");
assert_eq!(exit.exit_type, ExitType::Normal);
assert!(exit.silent_failure);
let mut input = DiagnosisInput::new();
input.exit = Some(exit);
let report = diagnoser().analyze(&input);
assert!(
ids(&report).contains(&"signal.silent_failure"),
"静默失败应单独打标:{:?}",
ids(&report)
);
// 低置信 → 交 AI 深化(PlanDoc/06 §2.3)
assert!(report.needs_more_info());
}
#[test]
fn empty_input_asks_for_more_information() {
let report = diagnoser().analyze(&DiagnosisInput::new());
assert!(report.findings.is_empty());
assert!(report.needs_more_info());
}
#[test]
fn conflict_rule_materializes_capture_groups() {
let text = read_sample("samples/fabric/mod_resolution_conflict.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
let primary = report.primary().expect("命中");
assert_eq!(primary.rule_id, "fabric.mod_resolution_conflict");
let action = &primary.materialized_actions()[0];
assert_eq!(action.kind, FixActionKind::RemoveMod);
assert_eq!(action.tool, "mod.remove");
assert_eq!(
action.args.get("mod_ids").map(String::as_str),
Some("sodium,sodium-extra"),
"mod_ids 应已代入命名捕获组"
);
}
/// 回归(review P2-1):OptiFine/Forge 兼容规则最后一个 alternative 必须是**前缀语义**,
/// 既收裸 `(Reflector.java)` 也收真实的带行号形态 `(Reflector.java:455)`。
#[test]
fn optifine_forge_matches_both_frame_forms() {
for frame in [
"\tTRANSFORMER/net.optifine/net.optifine.reflect.Reflector.<clinit>(Reflector.java:455)",
"\tTRANSFORMER/net.optifine/net.optifine.reflect.Reflector.<clinit>(Reflector.java)",
] {
let report = analyze_text(SignalOrigin::Stdout, frame);
assert!(
ids(&report).contains(&"conflict.optifine_forge"),
"真实栈帧形态应命中:{frame}(实际命中:{:?})",
ids(&report)
);
}
}
#[test]
fn too_old_java_converts_class_file_version() {
let text = read_sample("samples/jvm/too_old_java.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
let primary = report.primary().expect("命中");
assert_eq!(primary.rule_id, "jvm.too_old_java");
let action = &primary.materialized_actions()[0];
assert_eq!(
action.args.get("required_major").map(String::as_str),
Some("17"),
"class file version 61 应换算成 Java 17"
);
assert_eq!(class_file_to_java_major(61), Some(17));
assert_eq!(class_file_to_java_major(52), Some(8));
assert_eq!(class_file_to_java_major(40), None);
}
#[test]
fn fix_actions_are_deduplicated_across_findings() {
// 一条日志同时命中 OOM(adjust_memory)与 32bit(adjust_memory + change_java)
let text = "\
Could not reserve enough space for 4194304KB object heap
java.lang.OutOfMemoryError: Java heap space
";
let report = analyze_text(SignalOrigin::Stdout, text);
let actions = report.fix_actions();
assert!(!actions.is_empty());
let mut unique = actions.clone();
unique.sort_by(|a, b| a.tool.cmp(b.tool).then(a.args.len().cmp(&b.args.len())));
unique.dedup();
assert_eq!(unique.len(), actions.len(), "fix_actions 应当已去重");
}
#[test]
fn record_matches_launch_diagnosis_payload() {
let text = read_sample("samples/memory/out_of_memory.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
let record = report.to_record();
assert_eq!(record.source, "rules");
assert!(!record.candidates.is_empty());
let candidate = &record.candidates[0];
assert_eq!(candidate.id, "memory.out_of_memory");
assert_eq!(candidate.severity, "critical");
assert_eq!(candidate.category, "memory");
assert_eq!(candidate.message_key, "diagnose.cause.memory.out_of_memory");
assert!(!candidate.message.is_empty(), "M16 需要渲染后的中文结论");
assert!(!candidate.evidence.is_empty());
assert_eq!(candidate.fix_actions[0].tool, "memory.set");
}
#[test]
fn hs_err_report_feeds_builtin_findings() {
let report = hs_err::parse(&read_sample("samples/hs_err/access_violation.log"));
let mut input = DiagnosisInput::new();
input.push_hs_err(report);
let analysis = diagnoser().analyze(&input);
assert_eq!(
analysis.primary().expect("命中").rule_id,
"hs_err.access_violation"
);
// graphics.graphics_driver 会被内置结论 suppress 成次生(同一条证据、不同粒度)
assert!(
analysis
.secondary
.iter()
.any(|finding| finding.rule_id == "graphics.graphics_driver"),
"hs_err 原文也应命中图形规则,但被降级:{:?}",
analysis
.secondary
.iter()
.map(|finding| finding.rule_id.as_str())
.collect::<Vec<_>>()
);
}
#[test]
fn file_scope_rules_survive_very_long_logs() {
// >1 MiB:首尾窗口扫描,尾部窗口里的签名仍应命中。
let mut text = String::from("noise line\n").repeat(120_000);
text.push_str("java.lang.OutOfMemoryError: Java heap space\n");
assert!(text.len() > 1024 * 1024);
let report = analyze_text(SignalOrigin::LatestLog, &text);
assert!(
ids(&report).contains(&"memory.out_of_memory"),
"超长日志的尾部窗口应被扫描"
);
}
+143
View File
@@ -0,0 +1,143 @@
//! `hs_err_pid*.log` 解析器:标题行 / 线程段 / 内存段 / 失败帧。
use crate::prelude::*;
#[test]
fn parses_access_violation_report() {
let text = read_sample("samples/hs_err/access_violation.log");
let report = hs_err::parse(&text);
assert_eq!(report.kind, HsErrKind::AccessViolation);
assert_eq!(
report.header.as_deref(),
Some("A fatal error has been detected by the Java Runtime Environment:")
);
assert_eq!(
report.exception.as_deref(),
Some("EXCEPTION_ACCESS_VIOLATION (0xc0000005)")
);
assert_eq!(report.pc.as_deref(), Some("0x00007ffb1a2b3c4d"));
assert_eq!(report.pid, Some(12344));
assert_eq!(report.tid.as_deref(), Some("0x0000000000002a1c"));
let frame = report.failed_frame.as_ref().expect("失败帧");
assert_eq!(frame.kind, FrameKind::Native);
assert_eq!(frame.module.as_deref(), Some("ig8d64.dll"));
assert_eq!(frame.offset.as_deref(), Some("0x9b3c4d"));
let thread = report.current_thread.as_deref().expect("当前线程");
assert!(thread.contains("Render thread"), "{thread}");
assert!(report
.jre_version
.as_deref()
.is_some_and(|line| line.contains("8.0_51")));
assert!(report
.java_vm
.as_deref()
.is_some_and(|line| line.contains("HotSpot")));
let memory = report.memory.as_ref().expect("内存段");
assert_eq!(memory.page_size.as_deref(), Some("4k"));
assert_eq!(memory.physical_kb, Some(16_741_480));
assert_eq!(memory.physical_free_kb, Some(4_716_856));
assert_eq!(memory.swap_kb, Some(18_672_384));
assert_eq!(memory.swap_free_kb, Some(9_433_608));
assert!(memory.summary().contains("GB"));
assert!(!report.heap.is_empty(), "Heap 段应被抽取");
assert!(report
.vm_arguments
.iter()
.any(|arg| arg.contains("-Xmx4096m")));
assert!(report
.command_line
.as_deref()
.is_some_and(|line| line.contains("net.minecraft.launchwrapper.Launch")));
}
#[test]
fn digest_keeps_header_block_for_cross_line_rules() {
let report = hs_err::parse(&read_sample("samples/hs_err/access_violation.log"));
let digest = report.digest();
// 跨行图形规则依赖「EXCEPTION_ACCESS_VIOLATION … # C [ig…」的相对位置
assert!(digest.contains("EXCEPTION_ACCESS_VIOLATION"));
assert!(digest.contains("# C [ig8d64.dll"));
assert!(digest.contains("Current thread"));
assert!(digest.contains("Memory:"));
}
#[test]
fn parses_out_of_memory_report() {
let report = hs_err::parse(&read_sample("samples/hs_err/out_of_memory.log"));
assert_eq!(report.kind, HsErrKind::OutOfMemory);
assert_eq!(report.pid, Some(9876));
assert_eq!(
report.exception, None,
"OOM 型 hs_err 没有 EXCEPTION_* 行,不该瞎猜"
);
assert!(report
.header
.as_deref()
.is_some_and(|header| header.contains("insufficient memory")));
let memory = report.memory.as_ref().expect("内存段");
assert_eq!(memory.physical_kb, Some(8_134_564));
assert!(report.summary().contains("out_of_memory"));
}
#[test]
fn parse_never_fails_on_garbage() {
for text in ["", "\n\n", "随便一段中文", "# EXCEPTION_ACCESS_VIOLATION"] {
let report = hs_err::parse(text);
// 不 panic 即可;字段可以全空
let _ = report.digest();
let _ = report.summary();
}
assert_eq!(hs_err::parse("").kind, HsErrKind::Other);
}
#[test]
fn parse_file_attaches_path() {
let path = rules_dir().join("samples/hs_err/access_violation.log");
let report = hs_err::parse_file(&path).expect("读文件");
assert_eq!(report.path.as_deref(), Some(path.as_path()));
assert_eq!(report.kind, HsErrKind::AccessViolation);
}
#[test]
fn missing_file_is_an_io_error() {
let path = temp_dir("hs-err-missing").join("hs_err_pid1.log");
assert!(hs_err::parse_file(&path).is_err());
}
#[test]
fn java_frame_is_classified() {
let text = "\
# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x0000000000000000, pid=1, tid=0x2
# Problematic frame:
# J 123 c1 com.example.mod.Foo.bar(Ljava/lang/String;)V (12 bytes) @ 0x00007ffb
";
let report = hs_err::parse(text);
let frame = report.failed_frame.as_ref().expect("失败帧");
assert_eq!(frame.kind, FrameKind::Java);
assert_eq!(frame.module, None);
}
#[test]
fn engine_reports_hs_err_out_of_memory() {
let report = hs_err::parse(&read_sample("samples/hs_err/out_of_memory.log"));
let mut input = DiagnosisInput::new();
input.push_hs_err(report);
let analysis = diagnoser().analyze(&input);
let finding = analysis
.findings
.iter()
.find(|finding| finding.rule_id == "hs_err.out_of_memory")
.expect("hs_err OOM 应产出内置结论");
assert_eq!(finding.category, RuleCategory::Memory);
assert!(finding.message.contains("内存不足"));
assert_eq!(
finding.materialized_actions()[0].tool,
"memory.set",
"M16 需要能直接执行的工具 id"
);
}
+171
View File
@@ -0,0 +1,171 @@
//! 规则库加载 / 热重载 / 坏文件的降级行为。
use std::fs;
use crate::prelude::*;
const CUSTOM_MEMORY: &str = "\
version: 1
category: memory
rules:
- id: memory.custom_only
severity: warning
pattern: 'CUSTOM TEST SIGNATURE'
confidence: 0.9
priority: 70
cause:
key: diagnose.cause.memory.custom_only
summary: '测试用自定义规则'
fix_action:
- kind: seek_help
sample: samples/memory/out_of_memory.log
";
#[test]
fn embedded_library_is_complete() {
let loader = RuleLoader::embedded();
assert_eq!(EMBEDDED_RULES.len(), 10, "10 个根因分组各一个 YAML");
// 64 = HMCL 59 条全量平移 + 5 条 Vulkan 组(graphics.yaml 末尾的 4 个原因码
// + no_graphics_backend)。加规则要同时加样本并同步这个数。
assert_eq!(loader.rules().len(), 64);
assert!(loader.last_errors().is_empty());
assert!(loader.dir().is_none());
}
#[test]
fn missing_directory_falls_back_to_embedded() {
let dir = temp_dir("loader-missing").join("nope");
let loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 64);
assert!(loader.last_errors().is_empty());
}
#[test]
fn disk_file_replaces_embedded_group() {
let dir = temp_dir("loader-override");
fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件");
let loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 63, "memory.yaml 的 2 条被 1 条替换");
assert!(loader.last_errors().is_empty());
assert!(loader.rule_set().get("memory.custom_only").is_some());
assert!(
loader.rule_set().get("memory.out_of_memory").is_none(),
"同组文件被整份替换"
);
}
#[test]
fn refresh_picks_up_edits() {
let dir = temp_dir("loader-refresh");
let mut loader = RuleLoader::from_dir(&dir);
assert_eq!(
loader.refresh(),
ReloadOutcome::Unchanged,
"没变就不该重解析"
);
fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件");
assert_eq!(
loader.refresh(),
ReloadOutcome::Reloaded { rules: 63 },
"指纹变了应重新加载"
);
assert!(loader.rule_set().get("memory.custom_only").is_some());
}
#[test]
fn broken_yaml_keeps_last_good_rules() {
let dir = temp_dir("loader-broken");
fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件");
let mut loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 63);
// 用户手改坏了 YAML:保留上一份好规则 + 报错,而不是让诊断能力归零
fs::write(
dir.join("memory.yaml"),
"version: 1\ncategory: memory\nrules: [ oops\n",
)
.expect("写坏文件");
match loader.refresh() {
ReloadOutcome::Rejected { errors } => {
assert!(!errors.is_empty());
assert_eq!(
errors[0].kind,
unlucky_core::diagnose::loader::LoadErrorKind::Yaml
);
}
other => panic!("应被拒绝:{other:?}"),
}
assert_eq!(loader.rules().len(), 63, "坏文件不该清空规则库");
assert!(loader.rule_set().get("memory.custom_only").is_some());
assert!(!loader.last_errors().is_empty());
}
#[test]
fn duplicate_rule_id_across_files_is_rejected() {
let dir = temp_dir("loader-duplicate");
let duplicated = "\
version: 1
category: other
rules:
- id: memory.out_of_memory
pattern: 'dup'
cause:
key: diagnose.cause.dup
summary: '重复 id'
";
fs::write(dir.join("other.yaml"), duplicated).expect("写规则文件");
let loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 64, "整体失败后回退内嵌规则集");
assert!(loader
.last_errors()
.iter()
.any(|error| error.kind == unlucky_core::diagnose::loader::LoadErrorKind::DuplicateId));
}
#[test]
fn unknown_field_is_rejected() {
let dir = temp_dir("loader-typo");
let typo = "\
version: 1
category: other
rules:
- id: other.typo
patternn: 'typo in field name'
cause:
key: diagnose.cause.typo
summary: '字段拼错'
";
fs::write(dir.join("other.yaml"), typo).expect("写规则文件");
let loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 64);
assert!(
!loader.last_errors().is_empty(),
"deny_unknown_fields 应把拼错的字段报出来"
);
}
#[test]
fn rule_set_rejects_invalid_regex() {
let sources = vec![(
"bad.yaml".to_owned(),
"\
version: 1
category: other
rules:
- id: other.bad_regex
pattern: '(unclosed'
cause:
key: diagnose.cause.bad
summary: '坏正则'
"
.to_owned(),
)];
let errors = RuleSet::from_sources(&sources).expect_err("坏正则应失败");
assert_eq!(
errors[0].kind,
unlucky_core::diagnose::loader::LoadErrorKind::Regex
);
}
+83
View File
@@ -0,0 +1,83 @@
//! `core::diagnose` 集成测试。
//!
//! 结构对齐 HMCL 的 `CrashReportAnalyzerTest` + `resources/logs`(analyse/02 §7.9):
//! **每条规则一个样本**(`src/diagnose/rules.d/samples/`),断言「样本命中它自己」。
//! 规则库是数据不是代码,样本就是规则的验收标准——新增规则必须同时提交样本。
//!
//! 跑法:`cargo test -p unlucky-core --test diagnose`
mod collectors_probe;
mod engine_rank;
mod hs_err_parse;
mod loader_reload;
mod rules_samples;
/// 测试共用导入与辅助函数。
pub mod prelude {
use std::path::{Path, PathBuf};
pub use unlucky_core::diagnose::collectors::{
collect_all, CollectionContext, CrashReportCollector, EnvironmentCollector,
EnvironmentSnapshot, ExitCollector, ExitSignal, ExitType, HsErrCollector,
LatestLogCollector, LogBuffer, ModRecord, SignalBundle, SignalCollector, SignalKind,
SignalOrigin,
};
pub use unlucky_core::diagnose::engine::{
Diagnoser, DiagnosisInput, DiagnosisReport, Evidence, Finding, CONCLUSIVE_CONFIDENCE,
};
pub use unlucky_core::diagnose::hs_err::{self, FrameKind, HsErrKind, HsErrReport};
pub use unlucky_core::diagnose::loader::{ReloadOutcome, RuleLoader, RuleSet, EMBEDDED_RULES};
pub use unlucky_core::diagnose::rules::{
class_file_to_java_major, ComponentKind, FixAction, FixActionKind, GraphicsSetting,
JavaVendor, ModAction, RuleCategory, Severity,
};
/// 规则目录(`crates/core/src/diagnose/rules.d`)。
#[must_use]
pub fn rules_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/diagnose/rules.d")
}
/// 读样本(路径相对 `rules.d/`)。
pub fn read_sample(relative: &str) -> String {
let path = rules_dir().join(relative);
std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("样本 {} 读不到:{error}", path.display()))
}
/// 用内嵌规则库跑一段文本。
pub fn analyze_text(origin: SignalOrigin, text: &str) -> DiagnosisReport {
let mut input = DiagnosisInput::new();
input.push_text(origin, text);
diagnoser().analyze(&input)
}
/// 内嵌规则库构造的引擎。
#[must_use]
pub fn diagnoser() -> Diagnoser {
Diagnoser::new(RuleLoader::embedded().rules().to_vec())
}
/// 结论 id 列表(断言失败时打印用)。
#[must_use]
pub fn ids(report: &DiagnosisReport) -> Vec<&str> {
report
.findings
.iter()
.map(|finding| finding.rule_id.as_str())
.collect()
}
/// 临时目录(进程 id + 计数器,不引 tempfile 依赖)。
#[must_use]
pub fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let index = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("ull-diagnose-{}-{tag}-{index}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录");
dir
}
}
+267
View File
@@ -0,0 +1,267 @@
//! 规则库完整性 + 每条规则的样本回归(HMCL `CrashReportAnalyzerTest` 同构)。
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use crate::prelude::*;
#[test]
fn every_rule_has_a_sample_that_hits_itself() {
let loader = RuleLoader::embedded();
let rules = loader.rules();
// 64 = HMCL 59 条全量平移 + 5 条 Vulkan 组(rules.d/graphics.yaml 末尾:
// vulkan_loader_missing / vulkan_device_version_too_low / vulkan_missing_extension /
// vulkan_missing_feature / no_graphics_backend)。加规则记得同时加样本并改这个数。
assert_eq!(rules.len(), 64, "HMCL 59 条 + Vulkan 5 条规则应全量在位");
for rule in rules {
let sample = rule
.rule()
.sample
.as_deref()
.unwrap_or_else(|| panic!("规则 {} 没有 sample 字段", rule.id()));
let text = read_sample(sample);
let report = analyze_text(SignalOrigin::Stdout, &text);
let hit = report
.findings
.iter()
.chain(report.secondary.iter())
.any(|finding| finding.rule_id == rule.id());
assert!(
hit,
"规则 {} 的样本 {} 没有命中它自己(实际命中:{:?})",
rule.id(),
sample,
ids(&report)
);
}
}
#[test]
fn rule_ids_are_unique_and_namespaced() {
let loader = RuleLoader::embedded();
let mut seen = BTreeSet::new();
for rule in loader.rules() {
assert!(seen.insert(rule.id()), "规则 id 重复:{}", rule.id());
let (namespace, name) = rule
.id()
.split_once('.')
.unwrap_or_else(|| panic!("规则 id 必须带命名空间:{}", rule.id()));
assert!(
!namespace.is_empty() && !name.is_empty(),
"规则 id 不合法:{}",
rule.id()
);
}
}
#[test]
fn rules_carry_cause_actions_and_provenance() {
for rule in RuleLoader::embedded().rules() {
let rule = rule.rule();
assert!(
rule.cause.key.starts_with("diagnose.cause."),
"规则 {} 的 cause.key 应以 diagnose.cause. 开头",
rule.id
);
assert!(
!rule.cause.summary.is_empty(),
"规则 {} 缺结论文案",
rule.id
);
assert!(
!rule.fix_action.is_empty(),
"规则 {} 没有绑定六维修复动作(M16 一键修复要靠它)",
rule.id
);
assert!(
rule.source.is_some(),
"规则 {} 缺 source(HMCL 出处)",
rule.id
);
assert!(
(0.0..=1.0).contains(&rule.confidence),
"规则 {} 的 confidence 超出 0~1",
rule.id
);
}
}
#[test]
fn every_reported_category_has_rules() {
let mut categories = BTreeSet::new();
for rule in RuleLoader::embedded().rules() {
categories.insert(rule.category());
}
for category in RuleCategory::all() {
assert!(
categories.contains(&category),
"分类 {category} 一条规则都没有(rules.d 里缺这个文件或为空)"
);
}
}
#[test]
fn no_orphan_samples() {
let samples_root = rules_dir().join("samples");
let declared = RuleLoader::embedded()
.rules()
.iter()
.filter_map(|rule| rule.rule().sample.clone())
.collect::<BTreeSet<_>>();
let mut on_disk = BTreeSet::new();
for group in std::fs::read_dir(&samples_root).expect("samples 目录") {
let group = group.expect("目录项").path();
if !group.is_dir() {
continue;
}
collect(&samples_root, &group, &mut on_disk);
}
for path in &on_disk {
assert!(
declared.contains(path),
"样本 {path} 没有规则引用它(孤儿样本,或 sample: 路径写错)"
);
}
}
/// 递归收集 `.log` 样本,返回相对 `rules.d/` 的路径。
fn collect(root: &Path, dir: &Path, out: &mut BTreeSet<String>) {
for entry in std::fs::read_dir(dir).expect("目录项") {
let path = entry.expect("目录项").path();
if path.is_dir() {
collect(root, &path, out);
} else if path.extension().is_some_and(|ext| ext == "log") {
let relative = path
.strip_prefix(root)
.expect("相对路径")
.to_string_lossy()
.replace('\\', "/");
// hs_err 样本是解析器测试素材,不对应规则。
if !relative.starts_with("hs_err/") {
out.insert(format!("samples/{relative}"));
}
}
}
}
#[test]
fn decisive_rule_gives_evidence_message_and_actions() {
let text = read_sample("samples/integrity/mod_files_decompressed.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
let primary = report.primary().expect("应当命中");
assert_eq!(primary.rule_id, "integrity.mod_files_decompressed");
assert!(!primary.message.is_empty());
assert_eq!(primary.severity, Severity::Critical);
let evidence = primary.evidence.first().expect("证据");
assert_eq!(evidence.origin, SignalOrigin::Stdout);
assert!(evidence.render().starts_with("stdout:"));
assert!(!primary.fix_actions.is_empty());
// 高置信结论不该再要求 AI 深化
assert!(
!report.needs_more_info(),
"0.95 置信度的结论被判成「需要更多信息」"
);
assert_eq!(report.rules_evaluated, 64);
}
/// Vulkan 组:语义是「Vulkan 后端起不来」,所以**成功的** Vulkan 日志不该命中任何一条。
#[test]
fn healthy_vulkan_startup_hits_no_vulkan_rule() {
let text = "\
[12:00:21] [Render thread/INFO]: Backend library: LWJGL version 3.4.3+13
[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: AMD proprietary driver
[12:00:21] [Render thread/INFO]: OpenGL debug message callback registered
";
let report = analyze_text(SignalOrigin::Stdout, text);
let hit = report
.findings
.iter()
.chain(report.secondary.iter())
.map(|finding| finding.rule_id.as_str())
.filter(|id| id.starts_with("graphics.vulkan") || *id == "graphics.no_graphics_backend")
.collect::<Vec<_>>();
assert!(hit.is_empty(), "正常的 Vulkan 启动日志被判成故障:{hit:?}");
}
/// Vulkan 规则必须落在 `graphics_api` 这个新维度上(否则 agent 侧不知道该改哪一项)。
#[test]
fn vulkan_rules_point_at_the_graphics_api_setting() {
let loader = RuleLoader::embedded();
for id in [
"graphics.vulkan_loader_missing",
"graphics.vulkan_device_version_too_low",
"graphics.vulkan_missing_extension",
"graphics.vulkan_missing_feature",
"graphics.no_graphics_backend",
] {
let compiled = loader
.rule_set()
.get(id)
.unwrap_or_else(|| panic!("规则 {id} 不存在"));
let rule = compiled.rule();
let settings = rule
.fix_action
.iter()
.map(|action| action.materialize(&BTreeMap::new()))
.filter(|action| {
action
.args
.get("setting")
.is_some_and(|setting| setting == "graphics_api")
})
.count();
assert!(
settings > 0,
"规则 {id} 没有 change_graphics/graphics_api 动作"
);
assert!(
rule.fix_action
.iter()
.any(|action| action.kind().as_str() == "change_graphics"),
"规则 {id} 的修复动作应属于 change_graphics 维度"
);
}
}
/// 后端全挂(两个都失败)应当压过单条 vulkan_* 原因,避免同一个故障报五遍。
#[test]
fn no_graphics_backend_suppresses_single_reason_rules() {
let text = read_sample("samples/graphics/no_graphics_backend.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
assert_eq!(
report.primary().expect("命中").rule_id,
"graphics.no_graphics_backend"
);
}
/// SDL 变体必须保持 macOS 特异性:`cocoa` 是 SDL 在 macOS 的视频驱动名,两者同现才算
/// 本规则命中。否则 Windows/Linux 的 SDL_Init 失败会被误归因成「macOS 显示器问题」。
#[test]
fn sdl_display_failure_is_attributed_to_macos_only_with_cocoa() {
let hit = |text: &str| {
let report = analyze_text(SignalOrigin::Stdout, text);
report
.findings
.iter()
.chain(report.secondary.iter())
.any(|finding| finding.rule_id == "graphics.macos_display_port")
};
let macos = "\
[12:00:21] [Render thread/ERROR]: java.lang.IllegalStateException: SDL_Init(SDL_INIT_VIDEO) failed: cocoa: Failed to create window
";
assert!(hit(macos), "macOS(cocoa)的 SDL 初始化失败应命中原规则");
for other in [
// Windows:SDL 的 windows 驱动,文本里没有 cocoa。
"[12:00:21] [Render thread/ERROR]: SDL_Init(SDL_INIT_VIDEO) failed: No available video device\n",
// Linux:x11/wayland 驱动。
"[12:00:21] [Render thread/ERROR]: SDL_Init(SDL_INIT_VIDEO) failed: x11: Could not connect to display\n",
] {
assert!(
!hit(other),
"非 macOS 的 SDL 初始化失败不该归因到 macOS 显示器规则:{other}"
);
}
}