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
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
268 lines
9.4 KiB
Rust
268 lines
9.4 KiB
Rust
//! 规则库完整性 + 每条规则的样本回归(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}"
|
||
);
|
||
}
|
||
}
|