Files
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

296 lines
9.7 KiB
Rust
Raw Permalink 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.
//! 迁移链:N→N+1 顺序校验、真实迁移跑通、收据闸门防重放、store 接线。
use std::path::PathBuf;
use serde_json::{json, Map, Value};
use unlucky_core::platform::receipt::{
decide, fingerprint_bytes, fingerprint_file, read as read_receipt, MigrationReceipt,
ReceiptKind, ReplayDecision,
};
use unlucky_core::setting::catalog::SettingSchema;
use unlucky_core::setting::migrate::{
MigrationChain, MigrationGate, MigrationReport, SettingMigration,
};
use unlucky_core::setting::schema::{
SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY,
};
use unlucky_core::setting::spec::{AiPolicy, SettingSpec};
use unlucky_core::setting::store::SettingStore;
use crate::common::TempDir;
/// 1.0.0 → 2.0.0:`download.concurrency` 改名 `download.threads`。
struct RenameThreads;
impl SettingMigration for RenameThreads {
fn from(&self) -> SchemaVersion {
SchemaVersion::new(1, 0, 0)
}
fn to(&self) -> SchemaVersion {
SchemaVersion::new(2, 0, 0)
}
fn describe(&self) -> &'static str {
"download.concurrency → download.threads"
}
fn apply(&self, root: &mut Map<String, Value>) -> Result<(), String> {
let Some(download) = root.get_mut("download").and_then(Value::as_object_mut) else {
return Ok(());
};
if let Some(value) = download.remove("concurrency") {
download.insert("threads".to_owned(), value);
}
Ok(())
}
}
/// 2.0.0 → 3.0.0:补一个镜像偏好默认值。
struct AddMirrorPreference;
impl SettingMigration for AddMirrorPreference {
fn from(&self) -> SchemaVersion {
SchemaVersion::new(2, 0, 0)
}
fn to(&self) -> SchemaVersion {
SchemaVersion::new(3, 0, 0)
}
fn describe(&self) -> &'static str {
"补齐 download.mirror_preference"
}
fn apply(&self, root: &mut Map<String, Value>) -> Result<(), String> {
let Some(download) = root.get_mut("download").and_then(Value::as_object_mut) else {
return Err("缺少 download 对象".to_owned());
};
download
.entry("mirror_preference".to_owned())
.or_insert_with(|| json!("auto"));
Ok(())
}
}
fn two_step_chain() -> MigrationChain {
let mut chain = MigrationChain::new();
chain.push(Box::new(RenameThreads)).expect("第一环");
chain.push(Box::new(AddMirrorPreference)).expect("第二环");
chain
}
#[test]
fn chain_requires_contiguous_major_steps() {
let mut chain = MigrationChain::new();
chain
.push(Box::new(AddMirrorPreference))
.expect("首环随意起点");
assert_eq!(chain.len(), 1);
let err = chain.push(Box::new(RenameThreads)).expect_err("逆序要报错");
assert!(err.to_string().contains("顺序错误"), "{err}");
assert_eq!(chain.latest(), Some(SchemaVersion::new(3, 0, 0)));
assert_eq!(chain.describe().len(), 1);
assert!(!chain.is_empty());
}
#[test]
fn duplicate_and_skipping_steps_are_rejected() {
let mut chain = MigrationChain::new();
chain.push(Box::new(RenameThreads)).expect("首环");
assert!(chain.push(Box::new(RenameThreads)).is_err(), "重复起始版本");
assert!(chain.push(Box::new(AddMirrorPreference)).is_ok());
struct SkipMajor;
impl SettingMigration for SkipMajor {
fn from(&self) -> SchemaVersion {
SchemaVersion::new(3, 0, 0)
}
fn to(&self) -> SchemaVersion {
SchemaVersion::new(5, 0, 0)
}
fn describe(&self) -> &'static str {
"跳版本(非法)"
}
fn apply(&self, _root: &mut Map<String, Value>) -> Result<(), String> {
Ok(())
}
}
let err = chain
.push(Box::new(SkipMajor))
.expect_err("跨两个 major 要报错");
assert!(err.to_string().contains("major"), "{err}");
}
#[test]
fn migrates_sequentially_and_bumps_schema_version() {
let chain = two_step_chain();
let mut root = json!({ "download": { "concurrency": 32 } })
.as_object()
.cloned()
.expect("对象");
root.insert(SCHEMA_VERSION_KEY.to_owned(), json!(1));
let report = chain
.migrate(SchemaVersion::new(1, 0, 0), &mut root)
.expect("迁移");
assert_eq!(
report,
MigrationReport {
from: SchemaVersion::new(1, 0, 0),
to: SchemaVersion::new(3, 0, 0),
applied: vec![
"download.concurrency → download.threads",
"补齐 download.mirror_preference",
],
}
);
assert!(!report.is_noop());
assert_eq!(root["download"]["threads"], json!(32));
assert_eq!(root["download"]["mirror_preference"], json!("auto"));
assert!(root["download"].get("concurrency").is_none());
assert_eq!(root[SCHEMA_VERSION_KEY], json!(3));
}
#[test]
fn unknown_start_version_breaks_the_chain() {
let chain = two_step_chain();
let mut root = Map::new();
let err = chain
.migrate(SchemaVersion::new(9, 0, 0), &mut root)
.expect_err("没有 9.0.0 起始步");
assert!(err.to_string().contains("链里没有"), "{err}");
assert!(chain.plan_from(SchemaVersion::new(2, 0, 0)).is_ok());
}
#[test]
fn failing_step_reports_migration_error() {
let mut chain = MigrationChain::new();
chain.push(Box::new(RenameThreads)).expect("第一环");
chain.push(Box::new(AddMirrorPreference)).expect("第二环");
let mut root = json!({ "download": 3 }).as_object().cloned().expect("对象");
let err = chain
.migrate(SchemaVersion::new(1, 0, 0), &mut root)
.expect_err("download 不是对象");
assert!(err.to_string().contains("迁移"), "{err}");
}
#[test]
fn receipt_gate_prevents_replay() {
let temp = TempDir::new("migration-gate");
let source = temp.write("legacy/hmcl.json", "{\"x\":1}");
let target = temp.child("config/launcher.json");
let fingerprint = fingerprint_file(&source).expect("算指纹").expect("文件在");
let receipt_path = temp.child("config/migration-receipt.json");
let gate = MigrationGate::new(&receipt_path, 1_700_000_000_000);
assert_eq!(gate.now_ms(), 1_700_000_000_000);
assert!(gate.should_run(&fingerprint).expect("无收据"));
let receipt =
MigrationReceipt::legacy_config(source, target, fingerprint.clone(), gate.now_ms());
gate.record(&receipt).expect("写收据");
assert!(!gate.should_run(&fingerprint).expect("同源指纹应跳过"));
assert_eq!(
gate.evaluate(&fingerprint).expect("评估"),
ReplayDecision::SkipSameSource
);
assert_eq!(
gate.evaluate("其他指纹").expect("评估"),
ReplayDecision::ReplayChangedSource
);
let stored = read_receipt(&receipt_path)
.expect("读收据")
.expect("有收据");
assert_eq!(stored.kind, ReceiptKind::LegacyConfig);
assert_eq!(stored.source_fingerprint, fingerprint);
}
#[test]
fn receipt_decisions_and_fingerprints() {
let receipt = MigrationReceipt::legacy_config(
PathBuf::from("/a"),
PathBuf::from("/b"),
fingerprint_bytes(b"abc"),
7,
);
assert_eq!(decide(None, "x"), ReplayDecision::ReplayNoReceipt);
assert_eq!(
decide(Some(&receipt), &fingerprint_bytes(b"abc")),
ReplayDecision::SkipSameSource
);
assert_eq!(
decide(Some(&receipt), "changed"),
ReplayDecision::ReplayChangedSource
);
assert_eq!(ReplayDecision::SkipSameSource.as_str(), "skip_same_source");
assert!(!ReplayDecision::SkipSameSource.should_replay());
assert_eq!(fingerprint_bytes(b"abc").len(), 64);
assert_ne!(
unlucky_core::platform::receipt::fingerprint_data_dir_move(
&PathBuf::from("/a"),
&PathBuf::from("/b")
),
unlucky_core::platform::receipt::fingerprint_data_dir_move(
&PathBuf::from("/a"),
&PathBuf::from("/c")
)
);
}
fn schema_v2() -> SettingSchema {
SettingSchema::new(SchemaId::LAUNCHER, SchemaVersion::new(2, 0, 0)).with(SettingSpec::int(
"download.threads",
8,
(1, 256),
AiPolicy::silent(),
"下载并发数(2.0.0 起的键名)",
))
}
#[test]
fn store_applies_chain_then_saves_new_version() {
let temp = TempDir::new("migration-store");
let old_url = SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url();
temp.write(
"config/launcher.json",
&json!({
SCHEMA_KEY: old_url,
SCHEMA_VERSION_KEY: 1,
"download": { "concurrency": 32 }
})
.to_string(),
);
let mut store = SettingStore::load(temp.child("config/launcher.json"), schema_v2());
assert_eq!(store.doc().version(), SchemaVersion::new(1, 0, 0));
assert!(!store.is_dirty(), "加载不是改动");
let mut chain = MigrationChain::new();
chain.push(Box::new(RenameThreads)).expect("一环足矣");
let report = store.apply_chain(&chain).expect("迁移").expect("有迁移");
assert_eq!(report.to, SchemaVersion::new(2, 0, 0));
assert_eq!(store.doc().version(), SchemaVersion::new(2, 0, 0));
assert_eq!(
store.effective("download.threads"),
Some(&json!(32)),
"值要跟着键名一起搬"
);
assert!(store.is_dirty(), "迁移结果必须回写");
store.save().expect("保存");
let saved: Value = serde_json::from_str(&temp.read("config/launcher.json")).expect("JSON");
assert_eq!(saved[SCHEMA_VERSION_KEY], json!(2));
assert!(saved[SCHEMA_KEY]
.as_str()
.expect("$schema")
.ends_with("launcher/2.0.0"));
assert_eq!(saved["download"]["threads"], json!(32));
let mut up_to_date = SettingStore::load(temp.child("config/launcher.json"), schema_v2());
assert!(up_to_date.apply_chain(&chain).expect("已最新").is_none());
}