Unlucky Launcher v0.0.1-alpha
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
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 开发中。
This commit is contained in:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -0,0 +1,196 @@
|
||||
//! 「外观(高级)」键簇(`setting::appearance` + `keys::launcher_schema` 的 appearance.* 段)。
|
||||
//!
|
||||
//! 守三条设计约束(09-ui-layout §111):
|
||||
//! 1. 一切高级特性**默认关闭**(出厂 Metro 纯色)——默认值即出厂态;
|
||||
//! 2. 全部走 D6 schema 且 **AI 可写**(白名单内,可被 agent 调节);
|
||||
//! 3. 值域有硬边界(滑条夹取),坏值/手改配置不崩(回落默认)。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::setting::appearance::{
|
||||
hex_to_rgb, normalize_hex, relative_luminance, Appearance, BgFill, DEFAULT_ACCENT,
|
||||
PANEL_ALPHA_MIN,
|
||||
};
|
||||
use unlucky_core::setting::document::SettingDocument;
|
||||
use unlucky_core::setting::keys::{launcher_schema, launcher_version};
|
||||
use unlucky_core::setting::spec::ValueKind;
|
||||
|
||||
fn doc(raw: serde_json::Value) -> SettingDocument {
|
||||
SettingDocument::load(&launcher_schema(), &raw).0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_defaults_are_all_advanced_features_off() {
|
||||
let appearance = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load(
|
||||
std::env::temp_dir().join(format!("ull-appearance-none-{}.json", std::process::id())),
|
||||
launcher_schema(),
|
||||
));
|
||||
assert_eq!(appearance.theme_mode, "auto", "D17:出厂跟随系统");
|
||||
assert!(!appearance.bg_image_enabled, "背景图默认关");
|
||||
assert!(!appearance.bg_image_active());
|
||||
assert_eq!(appearance.bg_fill, BgFill::Cover);
|
||||
assert_eq!(appearance.panel_alpha, 1.0, "出厂完全不透明(Metro 纯色)");
|
||||
assert!(!appearance.glass, "玻璃拟态默认关");
|
||||
assert_eq!(appearance.accent_color, "", "空串 = 未自定义");
|
||||
assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_a_configured_document_reflects_each_key() {
|
||||
let document = doc(json!({
|
||||
"appearance": {
|
||||
"theme_mode": "dark",
|
||||
"bg_image_enabled": true,
|
||||
"bg_image": "D:/wall/paper.png",
|
||||
"bg_fill": "tile",
|
||||
"bg_dim": 0.6,
|
||||
"panel_alpha": 0.8,
|
||||
"glass": true,
|
||||
"accent_color": "#ff5722",
|
||||
}
|
||||
}));
|
||||
let appearance = Appearance::read(&document);
|
||||
assert_eq!(appearance.theme_mode, "dark");
|
||||
assert!(appearance.bg_image_active());
|
||||
assert_eq!(appearance.bg_image, "D:/wall/paper.png");
|
||||
assert_eq!(appearance.bg_fill, BgFill::Tile);
|
||||
assert!((appearance.bg_dim - 0.6).abs() < 1e-9);
|
||||
assert!((appearance.panel_alpha - 0.8).abs() < 1e-9);
|
||||
assert!(appearance.glass);
|
||||
assert_eq!(appearance.accent_color, "#FF5722", "读入即规范化大写");
|
||||
assert_eq!(appearance.accent_or_default(), "#FF5722");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values_fall_back_instead_of_panicking() {
|
||||
// 手改坏的配置:枚举拼错 / 开关类型错 / accent 不是 #RRGGBB。
|
||||
let document = doc(json!({
|
||||
"appearance": {
|
||||
"bg_fill": "stretch",
|
||||
"bg_image_enabled": "yes",
|
||||
"accent_color": "rebeccapurple",
|
||||
}
|
||||
}));
|
||||
let appearance = Appearance::read(&document);
|
||||
assert_eq!(appearance.bg_fill, BgFill::Cover);
|
||||
assert!(!appearance.bg_image_enabled);
|
||||
assert_eq!(appearance.accent_color, "");
|
||||
assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_alpha_is_clamped_to_the_contrast_floor() {
|
||||
// schema 下限 0.4;读侧再夹一次(挡手改文件绕过 schema 的情况)。
|
||||
let document = doc(json!({ "appearance": { "panel_alpha": 0.05 } }));
|
||||
assert!((Appearance::read(&document).panel_alpha - PANEL_ALPHA_MIN).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_keys_are_all_ai_writable_and_registered() {
|
||||
let schema = launcher_schema();
|
||||
// 主题三态是 D17 已定案的最轻量键(silent/Low),其余高级键留审计痕迹(audited/Medium)。
|
||||
let audited_keys = [
|
||||
"appearance.bg_image_enabled",
|
||||
"appearance.bg_image",
|
||||
"appearance.bg_fill",
|
||||
"appearance.bg_dim",
|
||||
"appearance.panel_alpha",
|
||||
"appearance.glass",
|
||||
"appearance.accent_color",
|
||||
];
|
||||
for key in audited_keys {
|
||||
let spec = schema.spec(key).unwrap_or_else(|| panic!("{key} 必须登记"));
|
||||
assert!(spec.ai.writable, "{key} 必须在 AI 可写白名单内");
|
||||
// 纯视觉、可一键复原 → 不要求弹窗确认(High 级留给凭据/数据根/代理)。
|
||||
assert!(
|
||||
!spec.ai.sensitivity.requires_confirmation(),
|
||||
"{key} 不该要确认"
|
||||
);
|
||||
assert_eq!(
|
||||
spec.ai.sensitivity.as_str(),
|
||||
"medium",
|
||||
"{key} 要留审计痕迹(AI 改过什么可回看)"
|
||||
);
|
||||
}
|
||||
// 主题三态仍是 silent(D17 已定案的最轻量键)。
|
||||
assert_eq!(
|
||||
schema
|
||||
.spec("appearance.theme_mode")
|
||||
.unwrap()
|
||||
.ai
|
||||
.sensitivity
|
||||
.as_str(),
|
||||
"low"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_registration_shape_is_right() {
|
||||
assert_eq!(launcher_version().minor, 3, "1.3.0 登记外观键");
|
||||
let schema = launcher_schema();
|
||||
assert_eq!(
|
||||
schema.spec("appearance.bg_fill").unwrap().kind,
|
||||
ValueKind::Enum(&["cover", "contain", "tile"])
|
||||
);
|
||||
assert!(matches!(
|
||||
schema.spec("appearance.panel_alpha").unwrap().kind,
|
||||
ValueKind::Float { min, max } if (min - PANEL_ALPHA_MIN).abs() < 1e-9 && (max - 1.0).abs() < 1e-9
|
||||
));
|
||||
assert_eq!(
|
||||
schema.spec("appearance.accent_color").unwrap().kind,
|
||||
ValueKind::Text { max_len: 9 },
|
||||
"#RRGGBB 最多 7 字符,留 9 容错带井号与空白"
|
||||
);
|
||||
// 默认值 = 出厂态
|
||||
assert_eq!(
|
||||
schema.spec("appearance.panel_alpha").unwrap().default,
|
||||
serde_json::Value::from(1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
schema.spec("appearance.glass").unwrap().default,
|
||||
serde_json::Value::from(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_round_trips_through_the_store() {
|
||||
let temp =
|
||||
std::env::temp_dir().join(format!("ull-appearance-{}-{}", std::process::id(), line!()));
|
||||
std::fs::create_dir_all(temp.join("config")).expect("临时目录");
|
||||
let path = temp.join("config/launcher.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let mut store = unlucky_core::setting::store::SettingStore::load(&path, launcher_schema());
|
||||
let mut appearance = Appearance::from_store(&store);
|
||||
appearance.theme_mode = "dark".to_owned();
|
||||
appearance.panel_alpha = 0.8;
|
||||
appearance.bg_image_enabled = true;
|
||||
appearance.bg_image = "D:/wall/p.png".to_owned();
|
||||
appearance.accent_color = "#0f6cbd".to_owned();
|
||||
appearance.write(&mut store).expect("写外观键");
|
||||
store.save().expect("落盘");
|
||||
|
||||
let reloaded = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load(
|
||||
&path,
|
||||
launcher_schema(),
|
||||
));
|
||||
assert_eq!(reloaded.theme_mode, "dark");
|
||||
assert!((reloaded.panel_alpha - 0.8).abs() < 1e-9);
|
||||
assert!(reloaded.bg_image_active());
|
||||
assert_eq!(reloaded.accent_color, "#0F6CBD");
|
||||
|
||||
std::fs::remove_dir_all(&temp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_helpers_validate_and_convert() {
|
||||
assert_eq!(normalize_hex(" 0f6cbd "), Some("#0F6CBD".to_owned()));
|
||||
assert_eq!(normalize_hex("#0F6CBD"), Some("#0F6CBD".to_owned()));
|
||||
assert_eq!(normalize_hex("#0F6CB"), None);
|
||||
assert_eq!(normalize_hex("#GGGGGG"), None);
|
||||
assert_eq!(hex_to_rgb("#0078D4"), Some((0x00, 0x78, 0xD4)));
|
||||
// WCAG 相对亮度:纯黑 0、纯白 1、Metro 蓝居中偏暗。
|
||||
assert!(relative_luminance("#000000").unwrap().abs() < 1e-9);
|
||||
assert!((relative_luminance("#FFFFFF").unwrap() - 1.0).abs() < 1e-9);
|
||||
let metro = relative_luminance(DEFAULT_ACCENT).unwrap();
|
||||
assert!(metro > 0.05 && metro < 0.3, "Metro 蓝亮度 {metro}");
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 原子写:覆盖语义、父目录创建、不留临时文件、可选读。
|
||||
|
||||
use unlucky_core::platform::atomic::{read_optional, tmp_path, write_atomic};
|
||||
|
||||
use crate::common::{dir_entries, TempDir};
|
||||
|
||||
#[test]
|
||||
fn writes_and_replaces_in_place() {
|
||||
let temp = TempDir::new("atomic");
|
||||
let path = temp.child("config/launcher.json");
|
||||
|
||||
write_atomic(&path, b"{\"a\":1}").expect("首次写");
|
||||
assert_eq!(temp.read("config/launcher.json"), "{\"a\":1}");
|
||||
|
||||
write_atomic(&path, b"{\"a\":2}").expect("覆盖写");
|
||||
assert_eq!(temp.read("config/launcher.json"), "{\"a\":2}");
|
||||
|
||||
// 目录里只该有目标文件,临时文件必须被改名掉。
|
||||
assert_eq!(dir_entries(&temp.child("config")), vec!["launcher.json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_optional_distinguishes_missing_from_failure() {
|
||||
let temp = TempDir::new("atomic-read");
|
||||
let path = temp.child("nope.json");
|
||||
assert!(read_optional(&path).expect("读缺失文件").is_none());
|
||||
|
||||
temp.write("here.json", "内容");
|
||||
let bytes = read_optional(&temp.child("here.json"))
|
||||
.expect("读文件")
|
||||
.expect("有内容");
|
||||
assert_eq!(bytes, "内容".as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmp_name_stays_in_same_directory() {
|
||||
let temp = TempDir::new("atomic-tmp");
|
||||
let path = temp.child("config/profiles.json");
|
||||
let tmp = tmp_path(&path);
|
||||
assert_eq!(
|
||||
tmp.parent(),
|
||||
path.parent(),
|
||||
"临时文件必须同目录(rename 才原子)"
|
||||
);
|
||||
assert_ne!(tmp, path);
|
||||
let first = tmp_path(&path);
|
||||
let second = tmp_path(&path);
|
||||
assert_ne!(first, second, "并发写要能拿到不同的临时名");
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! 测试公用小工具(临时目录 + 文件读写)。
|
||||
//!
|
||||
//! 不引 `tempfile`:M11 的依赖面只有 serde/serde_json,测试自己管临时目录。
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// 进程内唯一的临时目录,`Drop` 时递归删除。
|
||||
pub struct TempDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempDir {
|
||||
/// 建一个临时目录。
|
||||
pub fn new(label: &str) -> Self {
|
||||
let index = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ull-setting-{label}-{}-{index}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::remove_dir_all(&path).ok();
|
||||
std::fs::create_dir_all(&path).expect("创建临时目录");
|
||||
Self { path }
|
||||
}
|
||||
|
||||
/// 根路径。
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 拼接子路径(不创建)。
|
||||
pub fn child(&self, name: &str) -> PathBuf {
|
||||
self.path.join(name)
|
||||
}
|
||||
|
||||
/// 写一个文件(自动建父目录)。
|
||||
pub fn write(&self, name: &str, contents: &str) -> PathBuf {
|
||||
let path = self.child(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("创建父目录");
|
||||
}
|
||||
std::fs::write(&path, contents).expect("写文件");
|
||||
path
|
||||
}
|
||||
|
||||
/// 读一个文件。
|
||||
pub fn read(&self, name: &str) -> String {
|
||||
std::fs::read_to_string(self.child(name)).expect("读文件")
|
||||
}
|
||||
|
||||
/// 子路径是否存在。
|
||||
pub fn exists(&self, name: &str) -> bool {
|
||||
self.child(name).exists()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.path).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录里的文件名(排序后返回,便于断言没留下临时文件)。
|
||||
pub fn dir_entries(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||
.expect("读目录")
|
||||
.map(|entry| {
|
||||
entry
|
||||
.expect("目录项")
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 保存通道:200ms 去抖、同路径覆盖、攒批落盘、后台线程 flush/关停。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use unlucky_core::setting::error::SettingResult;
|
||||
use unlucky_core::setting::save::{CoalescingWriter, DebounceQueue, JobSink, DEFAULT_DEBOUNCE};
|
||||
use unlucky_core::setting::save_worker::SaveWorker;
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
/// 记录下来的 (路径, 内容) 列表。
|
||||
type Written = Vec<(PathBuf, Vec<u8>)>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingSink {
|
||||
written: Arc<Mutex<Written>>,
|
||||
}
|
||||
|
||||
impl RecordingSink {
|
||||
fn entries(&self) -> Vec<(PathBuf, Vec<u8>)> {
|
||||
self.written.lock().expect("锁").clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl JobSink for RecordingSink {
|
||||
fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()> {
|
||||
self.written
|
||||
.lock()
|
||||
.expect("锁")
|
||||
.push((path.to_path_buf(), payload.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_debounce_is_200ms() {
|
||||
assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waits_for_the_window_before_writing() {
|
||||
let start = Instant::now();
|
||||
let mut queue = DebounceQueue::<String>::new(DEFAULT_DEBOUNCE);
|
||||
assert!(queue.is_empty());
|
||||
|
||||
queue.enqueue("a".to_owned(), b"1".to_vec(), start);
|
||||
queue.enqueue("b".to_owned(), b"2".to_vec(), start);
|
||||
assert_eq!(queue.len(), 2);
|
||||
assert!(queue.contains(&"a".to_owned()));
|
||||
assert_eq!(queue.next_due(), Some(start + DEFAULT_DEBOUNCE));
|
||||
|
||||
assert!(queue
|
||||
.drain_due(start + Duration::from_millis(100))
|
||||
.is_empty());
|
||||
let due = queue.drain_due(start + DEFAULT_DEBOUNCE);
|
||||
assert_eq!(due.len(), 2);
|
||||
assert_eq!(due[0].0, "a", "同一批按键排序,写盘顺序稳定");
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_key_collapses_and_keeps_first_deadline() {
|
||||
let start = Instant::now();
|
||||
let mut queue = DebounceQueue::<String>::new(DEFAULT_DEBOUNCE);
|
||||
queue.enqueue("a".to_owned(), b"1".to_vec(), start);
|
||||
queue.enqueue(
|
||||
"a".to_owned(),
|
||||
b"2".to_vec(),
|
||||
start + Duration::from_millis(150),
|
||||
);
|
||||
|
||||
assert_eq!(queue.len(), 1);
|
||||
assert_eq!(
|
||||
queue.next_due(),
|
||||
Some(start + DEFAULT_DEBOUNCE),
|
||||
"窗口从首次改动开始算,避免连续改动无限推迟写盘"
|
||||
);
|
||||
assert_eq!(
|
||||
queue.drain_due(start + DEFAULT_DEBOUNCE),
|
||||
vec![("a".to_owned(), b"2".to_vec())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalescing_writer_batches_into_one_flush() {
|
||||
let sink = RecordingSink::default();
|
||||
let mut writer = CoalescingWriter::new(sink.clone(), DEFAULT_DEBOUNCE);
|
||||
let start = Instant::now();
|
||||
|
||||
writer.queue("/tmp/a.json", b"1".to_vec(), start);
|
||||
writer.queue(
|
||||
"/tmp/a.json",
|
||||
b"2".to_vec(),
|
||||
start + Duration::from_millis(50),
|
||||
);
|
||||
writer.queue("/tmp/b.json", b"3".to_vec(), start);
|
||||
assert_eq!(writer.pending_len(), 2);
|
||||
assert!(!writer.is_empty());
|
||||
|
||||
assert_eq!(writer.flush_due(start).expect("未到窗口"), 0);
|
||||
assert_eq!(
|
||||
writer.flush_due(start + DEFAULT_DEBOUNCE).expect("到窗口"),
|
||||
2
|
||||
);
|
||||
assert!(writer.is_empty());
|
||||
assert_eq!(writer.flush_all().expect("空队列兜底"), 0);
|
||||
|
||||
let written = sink.entries();
|
||||
assert_eq!(written.len(), 2);
|
||||
assert_eq!(written[0].0, PathBuf::from("/tmp/a.json"));
|
||||
assert_eq!(written[0].1, b"2".to_vec(), "只写最新内容");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_worker_flushes_and_shuts_down() {
|
||||
let temp = TempDir::new("save-worker");
|
||||
let target = temp.child("config/launcher.json");
|
||||
let mut worker = SaveWorker::spawn(Duration::from_millis(30)).expect("起线程");
|
||||
|
||||
worker.queue(&target, b"{\"a\":1}").expect("入队");
|
||||
worker.queue(&target, b"{\"a\":2}").expect("入队");
|
||||
worker.flush().expect("同步落盘");
|
||||
|
||||
assert_eq!(temp.read("config/launcher.json"), "{\"a\":2}");
|
||||
worker.shutdown().expect("关停");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! 键废弃机制:`ui.theme` → `appearance.theme_mode`(tower 键冲突裁决)。
|
||||
//!
|
||||
//! 规则:**读兼容**(旧值按 `THEME_ALIASES` 映射,新键已在则新键优先)+ **写永远走新键**
|
||||
//! (`set` 旧键直接报错,落盘不含旧键,AI 白名单与工具 JSON Schema 也不含旧键)。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::setting::document::{LoadProblem, SettingDocument};
|
||||
use unlucky_core::setting::keys::{launcher_schema, LEGACY_THEMES, THEME_ALIASES, THEME_MODES};
|
||||
use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY};
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
fn legacy_file(raw_ui: Value) -> Value {
|
||||
json!({
|
||||
SCHEMA_KEY: SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(),
|
||||
"ui": raw_ui
|
||||
})
|
||||
}
|
||||
|
||||
fn deprecated_problem(problems: &[LoadProblem]) -> Option<&'static str> {
|
||||
problems.iter().find_map(|problem| match problem {
|
||||
LoadProblem::DeprecatedKey {
|
||||
key, replaced_by, ..
|
||||
} if key == "ui.theme" => Some(*replaced_by),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_theme_values_are_translated_on_read() {
|
||||
assert_eq!(THEME_MODES.len(), 3);
|
||||
assert!(THEME_MODES.contains(&"auto") && THEME_MODES.contains(&"light"));
|
||||
assert!(THEME_MODES.contains(&"dark"));
|
||||
assert_eq!(
|
||||
LEGACY_THEMES.len(),
|
||||
THEME_ALIASES.len(),
|
||||
"三个旧值都要有映射"
|
||||
);
|
||||
|
||||
for (legacy, expected) in [
|
||||
("system", "auto"),
|
||||
("metro_light", "light"),
|
||||
("metro_dark", "dark"),
|
||||
] {
|
||||
let (doc, problems) =
|
||||
SettingDocument::load(&launcher_schema(), &legacy_file(json!({ "theme": legacy })));
|
||||
|
||||
assert_eq!(
|
||||
doc.effective_str("appearance.theme_mode"),
|
||||
Some(expected),
|
||||
"旧值 {legacy} 应映射成 {expected}"
|
||||
);
|
||||
assert_eq!(
|
||||
doc.legacy_value("ui.theme").and_then(Value::as_str),
|
||||
Some(legacy),
|
||||
"旧键原文要留着给 UI 提示/回滚"
|
||||
);
|
||||
assert_eq!(deprecated_problem(&problems), Some("appearance.theme_mode"));
|
||||
assert!(!doc.is_dirty(), "读兼容不算用户改动");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_key_wins_when_both_present() {
|
||||
let raw = json!({
|
||||
SCHEMA_KEY: SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(),
|
||||
"ui": { "theme": "metro_dark" },
|
||||
"appearance": { "theme_mode": "light" }
|
||||
});
|
||||
let (doc, _) = SettingDocument::load(&launcher_schema(), &raw);
|
||||
assert_eq!(doc.effective_str("appearance.theme_mode"), Some("light"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmapped_legacy_value_is_reported() {
|
||||
let (doc, problems) =
|
||||
SettingDocument::load(&launcher_schema(), &legacy_file(json!({ "theme": "neon" })));
|
||||
assert_eq!(doc.effective_str("appearance.theme_mode"), Some("neon"));
|
||||
assert!(
|
||||
problems.iter().any(|problem| matches!(
|
||||
problem,
|
||||
LoadProblem::InvalidValue { key, .. } if key == "appearance.theme_mode"
|
||||
)),
|
||||
"映射不过去的旧值要报出来: {problems:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_the_deprecated_key_is_refused() {
|
||||
let (mut doc, _) = SettingDocument::load(&launcher_schema(), &json!({}));
|
||||
let err = doc
|
||||
.set("ui.theme", json!("metro_dark"))
|
||||
.expect_err("旧键禁止写");
|
||||
assert!(err.to_string().contains("appearance.theme_mode"), "{err}");
|
||||
assert!(!doc.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_drops_the_old_key_and_writes_the_new_one() {
|
||||
let temp = TempDir::new("deprecated-save");
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&legacy_file(json!({ "theme": "metro_dark", "language": "en_us" })).to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert_eq!(
|
||||
store.effective("appearance.theme_mode"),
|
||||
Some(&json!("dark"))
|
||||
);
|
||||
|
||||
// 随便改一个别的键触发一次保存:旧键消失、新键(映射值)落盘。
|
||||
store.set("ui.scale", json!(1.25)).expect("改缩放");
|
||||
store.save().expect("保存");
|
||||
|
||||
let saved: Value = serde_json::from_str(&temp.read("config/launcher.json")).expect("JSON 合法");
|
||||
assert_eq!(saved["appearance"]["theme_mode"], json!("dark"));
|
||||
assert!(
|
||||
saved["ui"].get("theme").is_none(),
|
||||
"废弃键不再落盘: {saved}"
|
||||
);
|
||||
assert_eq!(saved["ui"]["language"], json!("en_us"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitelist_and_tool_schema_exclude_the_old_key() {
|
||||
let schema = launcher_schema();
|
||||
assert_eq!(schema.deprecated_keys(), vec!["ui.theme"]);
|
||||
assert_eq!(
|
||||
schema.replacement_of("ui.theme"),
|
||||
Some("appearance.theme_mode")
|
||||
);
|
||||
assert_eq!(schema.replacement_of("appearance.theme_mode"), None);
|
||||
assert!(!schema.accepts_ai_write("ui.theme"), "废弃键不许 AI 写");
|
||||
assert!(!schema.ai_writable_keys().contains(&"ui.theme"));
|
||||
|
||||
let projected = schema.to_json_schema();
|
||||
let properties = projected["properties"].as_object().expect("properties");
|
||||
assert!(properties.contains_key("appearance.theme_mode"));
|
||||
assert!(!properties.contains_key("ui.theme"));
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 数据目录迁移:计划校验、逐分区搬动、收据 + 指针、失败逆序回滚。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::platform::data_dir::{DataMode, ResolvedData};
|
||||
use unlucky_core::platform::dir_migration::{self, DirMigrationOptions};
|
||||
use unlucky_core::platform::error::PlatformError;
|
||||
use unlucky_core::platform::layout::{LauncherLayout, Partition};
|
||||
use unlucky_core::platform::receipt::{self, ReceiptKind};
|
||||
use unlucky_core::platform::redirect;
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
fn resolved(home: &Path) -> ResolvedData {
|
||||
ResolvedData {
|
||||
mode: DataMode::Custom,
|
||||
launcher_home: home.to_path_buf(),
|
||||
game_home: home.join(".minecraft"),
|
||||
redirected_from: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn seed(home: &Path) {
|
||||
let layout = LauncherLayout::new(home.to_path_buf());
|
||||
layout.ensure().expect("建四分区");
|
||||
std::fs::create_dir_all(layout.auth_data()).expect("auth 目录");
|
||||
std::fs::create_dir_all(layout.sha1_cache("ab")).expect("缓存分片");
|
||||
std::fs::write(layout.launcher_config(), b"{\"x\":1}").expect("写配置");
|
||||
std::fs::write(layout.auth_data().join("token.json"), b"{}").expect("写凭据");
|
||||
std::fs::write(layout.sha1_cache("ab").join("hash"), b"payload").expect("写缓存");
|
||||
std::fs::write(layout.logs().join("launcher.log"), b"log").expect("写日志");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_orders_partitions_and_skips_missing_ones() {
|
||||
let temp = TempDir::new("dir-migration-plan");
|
||||
seed(&temp.child("old/.ULL"));
|
||||
let from = resolved(&temp.child("old/.ULL"));
|
||||
let to = resolved(&temp.child("new/.ULL"));
|
||||
|
||||
let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划");
|
||||
let partitions: Vec<Partition> = plan.steps.iter().map(|step| step.partition).collect();
|
||||
assert_eq!(
|
||||
partitions,
|
||||
vec![
|
||||
Partition::Config,
|
||||
Partition::Data,
|
||||
Partition::Logs,
|
||||
Partition::Cache
|
||||
],
|
||||
"配置最先、缓存最后"
|
||||
);
|
||||
|
||||
let cache_off = DirMigrationOptions {
|
||||
migrate_cache: false,
|
||||
};
|
||||
let plan = dir_migration::plan(&from, &to, &cache_off).expect("计划");
|
||||
assert_eq!(plan.steps.len(), 3);
|
||||
assert!(plan
|
||||
.steps
|
||||
.iter()
|
||||
.all(|step| step.partition != Partition::Cache));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_moves_everything_and_leaves_a_pointer() {
|
||||
let temp = TempDir::new("dir-migration-run");
|
||||
let old_home = temp.child("old/.ULL");
|
||||
let new_home = temp.child("new/.ULL");
|
||||
seed(&old_home);
|
||||
let from = resolved(&old_home);
|
||||
let to = resolved(&new_home);
|
||||
|
||||
let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划");
|
||||
let outcome = dir_migration::execute(&plan, 1_700_000_000_000).expect("执行");
|
||||
|
||||
assert_eq!(
|
||||
outcome.moved,
|
||||
vec![
|
||||
Partition::Config,
|
||||
Partition::Data,
|
||||
Partition::Logs,
|
||||
Partition::Cache
|
||||
]
|
||||
);
|
||||
|
||||
let moved = LauncherLayout::new(new_home.clone());
|
||||
assert!(moved.launcher_config().is_file(), "配置要搬过去");
|
||||
assert!(moved.auth_data().join("token.json").is_file());
|
||||
assert!(moved.logs().join("launcher.log").is_file());
|
||||
assert!(moved.sha1_cache("ab").join("hash").is_file());
|
||||
assert!(!LauncherLayout::new(old_home.clone()).config().exists());
|
||||
|
||||
let stored = receipt::read(&outcome.receipt_path)
|
||||
.expect("读收据")
|
||||
.expect("有收据");
|
||||
assert_eq!(stored.kind, ReceiptKind::DataDirMove);
|
||||
assert_eq!(stored.source, old_home);
|
||||
assert_eq!(stored.target, new_home);
|
||||
assert_eq!(stored.applied_at_ms, 1_700_000_000_000);
|
||||
|
||||
let pointer = redirect::read(&old_home)
|
||||
.expect("读指针")
|
||||
.expect("旧目录要留指针");
|
||||
assert_eq!(pointer.launcher_home, new_home);
|
||||
assert_eq!(pointer.mode, DataMode::Custom);
|
||||
assert_eq!(outcome.pointer_path, old_home.join("redirect.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_same_source_and_target() {
|
||||
let temp = TempDir::new("dir-migration-same");
|
||||
seed(&temp.child("only/.ULL"));
|
||||
let home = resolved(&temp.child("only/.ULL"));
|
||||
let err = dir_migration::plan(&home, &home, &DirMigrationOptions::default())
|
||||
.expect_err("源目标相同要拒绝");
|
||||
assert!(
|
||||
matches!(err, PlatformError::MigrationRefused { .. }),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_missing_source_and_non_empty_target() {
|
||||
let temp = TempDir::new("dir-migration-refuse");
|
||||
let from = resolved(&temp.child("gone/.ULL"));
|
||||
let to = resolved(&temp.child("new/.ULL"));
|
||||
assert!(matches!(
|
||||
dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect_err("源不存在"),
|
||||
PlatformError::MigrationRefused { .. }
|
||||
));
|
||||
|
||||
seed(&temp.child("old/.ULL"));
|
||||
let from = resolved(&temp.child("old/.ULL"));
|
||||
std::fs::create_dir_all(to.launcher_home.clone()).expect("建目标");
|
||||
std::fs::write(to.launcher_home.join("something"), b"x").expect("占位");
|
||||
let err = dir_migration::plan(&from, &to, &DirMigrationOptions::default())
|
||||
.expect_err("目标非空要拒绝");
|
||||
assert!(err.to_string().contains("已有数据"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolls_back_moved_partitions_when_a_later_step_fails() {
|
||||
let temp = TempDir::new("dir-migration-rollback");
|
||||
let old_home = temp.child("old/.ULL");
|
||||
let new_home = temp.child("new/.ULL");
|
||||
seed(&old_home);
|
||||
let from = resolved(&old_home);
|
||||
let to = resolved(&new_home);
|
||||
|
||||
let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划");
|
||||
// 计划之后在目标侧造障碍:data 位置先占成普通文件 → 搬 data 必失败。
|
||||
std::fs::create_dir_all(&new_home).expect("建目标根");
|
||||
std::fs::write(new_home.join("data"), b"obstruction").expect("造障碍");
|
||||
|
||||
let err = dir_migration::execute(&plan, 42).expect_err("应当失败");
|
||||
assert!(
|
||||
matches!(err, PlatformError::MigrationRefused { .. }),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let old = LauncherLayout::new(old_home);
|
||||
assert!(old.launcher_config().is_file(), "config 必须被回滚搬回来");
|
||||
assert!(old.auth_data().join("token.json").is_file());
|
||||
assert!(!old.redirect_path().exists(), "失败不该留指针");
|
||||
assert!(!new_home.join("config").exists(), "目标侧不该残留已搬分区");
|
||||
assert!(!new_home.join("data").is_dir(), "障碍文件保持原样");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_anti_replay_uses_source_target_signature() {
|
||||
let temp = TempDir::new("dir-migration-receipt");
|
||||
let receipt_path: PathBuf = temp.child("config/migration-receipt.json");
|
||||
let receipt = receipt::MigrationReceipt::data_dir_move(
|
||||
PathBuf::from("/old/.ULL"),
|
||||
PathBuf::from("/new/.ULL"),
|
||||
1_700_000_000_000,
|
||||
);
|
||||
receipt::write(&receipt_path, &receipt).expect("写收据");
|
||||
let loaded = receipt::read(&receipt_path).expect("读").expect("有");
|
||||
assert_eq!(
|
||||
receipt::decide(Some(&loaded), &receipt.source_fingerprint),
|
||||
receipt::ReplayDecision::SkipSameSource
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 三模式数据目录解析:Portable / Standard / Custom + 平台落点 + 高级覆盖 + redirect 跟随。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
|
||||
use unlucky_core::platform::error::PlatformError;
|
||||
use unlucky_core::platform::paths::{standard_game_home, standard_launcher_home, MapEnv, Platform};
|
||||
use unlucky_core::platform::redirect::{self, RedirectPointer, REDIRECT_SCHEMA_VERSION};
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
#[test]
|
||||
fn portable_puts_dot_ull_and_minecraft_beside_exe() {
|
||||
let env = MapEnv::new().with("APPDATA", "/roaming");
|
||||
let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games/ULL")), &env);
|
||||
assert_eq!(resolver.default_mode(), DataMode::Portable);
|
||||
|
||||
let resolved = resolver.resolve(&DataDirRequest::default()).expect("解析");
|
||||
assert_eq!(resolved.mode, DataMode::Portable);
|
||||
assert_eq!(resolved.launcher_home, PathBuf::from("/games/ULL/.ULL"));
|
||||
assert_eq!(resolved.game_home, PathBuf::from("/games/ULL/.minecraft"));
|
||||
assert!(resolved.redirected_from.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_without_exe_dir_errors() {
|
||||
let env = MapEnv::new();
|
||||
let resolver = Resolver::with_env(Platform::Linux, None, &env);
|
||||
let err = resolver
|
||||
.resolve(&DataDirRequest::default())
|
||||
.expect_err("应当报错");
|
||||
assert!(matches!(err, PlatformError::NoExecutableDir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_uses_per_os_locations() {
|
||||
let windows = MapEnv::new().with("APPDATA", "/roaming");
|
||||
assert_eq!(
|
||||
standard_launcher_home(Platform::Windows, &windows),
|
||||
Some(PathBuf::from("/roaming/ULL"))
|
||||
);
|
||||
assert_eq!(
|
||||
standard_game_home(Platform::Windows, &windows),
|
||||
Some(PathBuf::from("/roaming/.minecraft"))
|
||||
);
|
||||
|
||||
let mac = MapEnv::new().with("HOME", "/Users/u");
|
||||
assert_eq!(
|
||||
standard_launcher_home(Platform::MacOs, &mac),
|
||||
Some(PathBuf::from("/Users/u/Library/Application Support/ULL"))
|
||||
);
|
||||
assert_eq!(
|
||||
standard_game_home(Platform::MacOs, &mac),
|
||||
Some(PathBuf::from(
|
||||
"/Users/u/Library/Application Support/minecraft"
|
||||
))
|
||||
);
|
||||
|
||||
// XDG 规范要求绝对路径;测试用 temp_dir(两种平台都算绝对路径)。
|
||||
let xdg_root = std::env::temp_dir().join("ull-xdg");
|
||||
let xdg_text = xdg_root.to_string_lossy().into_owned();
|
||||
let linux = MapEnv::new()
|
||||
.with("HOME", "/home/u")
|
||||
.with("XDG_DATA_HOME", &xdg_text);
|
||||
assert_eq!(
|
||||
standard_launcher_home(Platform::Linux, &linux),
|
||||
Some(xdg_root.join("ull"))
|
||||
);
|
||||
assert_eq!(
|
||||
standard_game_home(Platform::Linux, &linux),
|
||||
Some(PathBuf::from("/home/u/.minecraft"))
|
||||
);
|
||||
|
||||
// 相对 XDG_DATA_HOME 不合法 → 回落 ~/.local/share/ull。
|
||||
let relative_xdg = MapEnv::new()
|
||||
.with("HOME", "/home/u")
|
||||
.with("XDG_DATA_HOME", "relative/xdg");
|
||||
assert_eq!(
|
||||
standard_launcher_home(Platform::Linux, &relative_xdg),
|
||||
Some(PathBuf::from("/home/u/.local/share/ull"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_falls_back_when_env_missing() {
|
||||
let env = MapEnv::new()
|
||||
.with("HOME", "/home/u")
|
||||
.with("USERPROFILE", "/home/u");
|
||||
assert_eq!(
|
||||
standard_launcher_home(Platform::Linux, &env),
|
||||
Some(PathBuf::from("/home/u/.local/share/ull"))
|
||||
);
|
||||
assert_eq!(
|
||||
standard_launcher_home(Platform::Windows, &env),
|
||||
Some(PathBuf::from("/home/u/AppData/Roaming/ULL"))
|
||||
);
|
||||
|
||||
let empty = MapEnv::new();
|
||||
assert_eq!(standard_launcher_home(Platform::Windows, &empty), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_mode_resolution_and_macos_default() {
|
||||
let env = MapEnv::new().with("HOME", "/Users/u");
|
||||
let resolver = Resolver::with_env(Platform::MacOs, None, &env);
|
||||
assert_eq!(resolver.default_mode(), DataMode::Standard);
|
||||
|
||||
let request = DataDirRequest {
|
||||
mode: DataMode::Standard,
|
||||
..DataDirRequest::default()
|
||||
};
|
||||
let resolved = resolver.resolve(&request).expect("解析");
|
||||
assert_eq!(
|
||||
resolved.launcher_home,
|
||||
PathBuf::from("/Users/u/Library/Application Support/ULL")
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.game_home,
|
||||
PathBuf::from("/Users/u/Library/Application Support/minecraft")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_requires_absolute_existing_root_choice() {
|
||||
let env = MapEnv::new();
|
||||
let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games")), &env);
|
||||
let root = std::env::temp_dir().join("ull-custom-root");
|
||||
|
||||
let ok = DataDirRequest {
|
||||
mode: DataMode::Custom,
|
||||
custom_root: Some(root.clone()),
|
||||
..DataDirRequest::default()
|
||||
};
|
||||
let resolved = resolver.resolve(&ok).expect("解析");
|
||||
assert_eq!(resolved.launcher_home, root.join(".ULL"));
|
||||
assert_eq!(resolved.game_home, root.join(".minecraft"));
|
||||
|
||||
let relative = DataDirRequest {
|
||||
mode: DataMode::Custom,
|
||||
custom_root: Some(PathBuf::from("relative/dir")),
|
||||
..DataDirRequest::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
resolver.resolve(&relative).expect_err("相对路径应报错"),
|
||||
PlatformError::InvalidCustomRoot { .. }
|
||||
));
|
||||
|
||||
let missing = DataDirRequest {
|
||||
mode: DataMode::Custom,
|
||||
custom_root: None,
|
||||
..DataDirRequest::default()
|
||||
};
|
||||
assert!(resolver.resolve(&missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_overrides_split_launcher_and_game_home() {
|
||||
let env = MapEnv::new();
|
||||
let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games")), &env);
|
||||
let request = DataDirRequest {
|
||||
launcher_home: Some(PathBuf::from("/config/ULL")),
|
||||
game_home: Some(PathBuf::from("/bigdisk/.minecraft")),
|
||||
..DataDirRequest::default()
|
||||
};
|
||||
let resolved = resolver.resolve(&request).expect("解析");
|
||||
assert_eq!(resolved.launcher_home, PathBuf::from("/config/ULL"));
|
||||
assert_eq!(resolved.game_home, PathBuf::from("/bigdisk/.minecraft"));
|
||||
assert!(!resolved.same_target(&ResolvedData {
|
||||
mode: DataMode::Portable,
|
||||
launcher_home: PathBuf::from("/other"),
|
||||
game_home: PathBuf::from("/bigdisk/.minecraft"),
|
||||
redirected_from: None,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_resolution_follows_redirect_pointer() {
|
||||
let temp = TempDir::new("data-dir-redirect");
|
||||
let exe_dir = temp.child("portable");
|
||||
let moved_to = temp.child("after-move");
|
||||
std::fs::create_dir_all(&exe_dir).expect("建目录");
|
||||
|
||||
let pointer = RedirectPointer {
|
||||
schema_version: REDIRECT_SCHEMA_VERSION,
|
||||
mode: DataMode::Custom,
|
||||
launcher_home: moved_to.clone(),
|
||||
game_home: moved_to.join(".minecraft"),
|
||||
migrated_at_ms: 1_700_000_000_000,
|
||||
};
|
||||
redirect::write(&exe_dir.join(".ULL"), &pointer).expect("写指针");
|
||||
|
||||
let env = MapEnv::new();
|
||||
let resolver = Resolver::with_env(Platform::Windows, Some(exe_dir.clone()), &env);
|
||||
let resolved = resolver
|
||||
.resolve_default(&DataDirRequest::default())
|
||||
.expect("解析");
|
||||
|
||||
assert_eq!(resolved.launcher_home, moved_to);
|
||||
assert_eq!(resolved.mode, DataMode::Portable);
|
||||
assert_eq!(
|
||||
resolved.redirected_from,
|
||||
Some(exe_dir.join(".ULL")),
|
||||
"旧目录要留在记录里,便于 UI 提示「数据已迁走」"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! 文档模型:脏字段、未知字段保留、默认值不落盘、嵌套点路径、坏值容错、`$schema` 保留。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::setting::document::{LoadProblem, RenderMode, SettingDocument};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::schema::{SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY};
|
||||
|
||||
fn schema_url(version: SchemaVersion) -> String {
|
||||
SchemaRef::new(unlucky_core::setting::schema::SchemaId::LAUNCHER, version).url()
|
||||
}
|
||||
|
||||
fn loaded(raw: Value) -> (SettingDocument, Vec<LoadProblem>) {
|
||||
SettingDocument::load(&launcher_schema(), &raw)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_nested_keys_and_keeps_unknown_subtrees() {
|
||||
let raw = json!({
|
||||
SCHEMA_KEY: schema_url(launcher_version()),
|
||||
SCHEMA_VERSION_KEY: 1,
|
||||
"download": { "concurrency": 16 },
|
||||
"ui": { "language": "en_us", "unknown_ui": true },
|
||||
"future_section": { "nested": { "flag": true } }
|
||||
});
|
||||
|
||||
let (doc, problems) = loaded(raw);
|
||||
assert_eq!(doc.effective_i64("download.concurrency"), Some(16));
|
||||
assert_eq!(doc.effective_str("ui.language"), Some("en_us"));
|
||||
|
||||
let unknown: Vec<&str> = doc.unknown_keys().collect();
|
||||
assert!(unknown.contains(&"ui.unknown_ui"), "{unknown:?}");
|
||||
assert!(
|
||||
unknown.contains(&"future_section.nested.flag"),
|
||||
"未知键清单是叶子粒度: {unknown:?}"
|
||||
);
|
||||
|
||||
let reported: Vec<&str> = problems
|
||||
.iter()
|
||||
.filter_map(|problem| match problem {
|
||||
LoadProblem::UnknownKey { key } => Some(key.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(reported.contains(&"ui.unknown_ui"), "{problems:?}");
|
||||
assert!(
|
||||
reported.contains(&"future_section"),
|
||||
"陌生子树按最上层节点报一次,不做逐叶展开: {problems:?}"
|
||||
);
|
||||
|
||||
let rendered = doc.to_value();
|
||||
assert_eq!(rendered[SCHEMA_VERSION_KEY], json!(1));
|
||||
assert_eq!(
|
||||
rendered["future_section"]["nested"]["flag"],
|
||||
json!(true),
|
||||
"未知字段必须原样回写"
|
||||
);
|
||||
assert_eq!(rendered["ui"]["unknown_ui"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_never_reach_the_file() {
|
||||
let (doc, _) = loaded(json!({ SCHEMA_KEY: schema_url(launcher_version()) }));
|
||||
// 生效值有默认,但落盘视图里没有这些键。
|
||||
assert_eq!(doc.effective_i64("download.concurrency"), Some(8));
|
||||
assert_eq!(doc.effective_bool("ai.enabled"), Some(true));
|
||||
assert!(doc.get("download.concurrency").is_none());
|
||||
let rendered = doc.to_value();
|
||||
assert!(rendered.get("download").is_none(), "{rendered}");
|
||||
// 无默认值的路径键 → None。
|
||||
assert!(doc.effective_str("java.selected_path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_dirty_keys_and_renders_patches() {
|
||||
let raw = json!({
|
||||
SCHEMA_KEY: schema_url(launcher_version()),
|
||||
"download": { "concurrency": 8, "retry_limit": 2 }
|
||||
});
|
||||
let (mut doc, _) = loaded(raw);
|
||||
assert!(!doc.is_dirty());
|
||||
|
||||
doc.set("download.concurrency", json!(32)).expect("写");
|
||||
doc.set("appearance.theme_mode", json!("dark")).expect("写");
|
||||
|
||||
let mut dirty: Vec<&str> = doc.dirty_keys().collect();
|
||||
dirty.sort_unstable();
|
||||
assert_eq!(dirty, vec!["appearance.theme_mode", "download.concurrency"]);
|
||||
|
||||
let patch = doc.dirty_patch();
|
||||
assert_eq!(patch["download"]["concurrency"], json!(32));
|
||||
assert_eq!(patch["appearance"]["theme_mode"], json!("dark"));
|
||||
assert!(
|
||||
patch["download"].get("retry_limit").is_none(),
|
||||
"非脏字段不进补丁"
|
||||
);
|
||||
|
||||
let dirty_view = doc.render(RenderMode::DirtyOnly);
|
||||
assert_eq!(dirty_view["download"]["concurrency"], json!(32));
|
||||
assert!(dirty_view["download"].get("retry_limit").is_none());
|
||||
|
||||
doc.mark_clean();
|
||||
assert!(!doc.is_dirty());
|
||||
assert_eq!(doc.dirty_patch(), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_values_are_preserved_with_a_problem() {
|
||||
let raw = json!({
|
||||
SCHEMA_KEY: schema_url(launcher_version()),
|
||||
"download": { "concurrency": 9999, "retry_limit": "many" }
|
||||
});
|
||||
let (doc, problems) = loaded(raw);
|
||||
assert_eq!(
|
||||
doc.get("download.concurrency").and_then(Value::as_i64),
|
||||
Some(9999),
|
||||
"坏值原样保留(用户能看见自己写坏了什么)"
|
||||
);
|
||||
let invalid: Vec<&str> = problems
|
||||
.iter()
|
||||
.filter_map(|problem| match problem {
|
||||
LoadProblem::InvalidValue { key, .. } => Some(key.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(invalid.contains(&"download.concurrency"), "{problems:?}");
|
||||
assert!(invalid.contains(&"download.retry_limit"), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_version_mirror_mismatch_is_reported_but_schema_wins() {
|
||||
let raw = json!({
|
||||
SCHEMA_KEY: schema_url(launcher_version()),
|
||||
SCHEMA_VERSION_KEY: 7
|
||||
});
|
||||
let (doc, problems) = loaded(raw);
|
||||
assert_eq!(doc.version(), launcher_version());
|
||||
assert!(problems.iter().any(|problem| matches!(
|
||||
problem,
|
||||
LoadProblem::SchemaVersionMismatch {
|
||||
declared: 7,
|
||||
expected: 1
|
||||
}
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_patch_preserves_original_schema_string() {
|
||||
// 同 minor、更高 patch(launcher schema 当前 1.3.0)。
|
||||
let newer = SchemaVersion::new(1, 3, 7);
|
||||
let url = schema_url(newer);
|
||||
let (doc, _) = loaded(json!({ SCHEMA_KEY: url.clone() }));
|
||||
assert_eq!(
|
||||
doc.preserve_schema(),
|
||||
Some(url.as_str()),
|
||||
"同 minor 更高 patch 要保留原 $schema"
|
||||
);
|
||||
assert_eq!(doc.to_value()[SCHEMA_KEY], json!(url));
|
||||
assert_eq!(doc.version(), newer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_shape_for_known_prefix_is_reported() {
|
||||
let raw = json!({
|
||||
SCHEMA_KEY: schema_url(launcher_version()),
|
||||
"download": "oops"
|
||||
});
|
||||
let (doc, problems) = loaded(raw);
|
||||
assert!(
|
||||
problems.iter().any(|problem| matches!(
|
||||
problem,
|
||||
LoadProblem::InvalidShape { key, .. } if key == "download"
|
||||
)),
|
||||
"{problems:?}"
|
||||
);
|
||||
assert!(doc.get("download").is_none());
|
||||
// 原始坏结构不进 values,也不会被当成未知键回写。
|
||||
assert!(doc.to_value().get("download").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_object_json_yields_problem_and_empty_doc() {
|
||||
let (doc, problems) = loaded(json!([1, 2, 3]));
|
||||
assert!(!problems.is_empty());
|
||||
assert_eq!(doc.effective_i64("download.concurrency"), Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rejects_unknown_keys_and_out_of_range_values() {
|
||||
let (mut doc, _) = loaded(json!({}));
|
||||
assert!(doc.set("nope.key", json!(1)).is_err());
|
||||
assert!(doc.set("download.concurrency", json!(0)).is_err());
|
||||
assert!(doc
|
||||
.set("appearance.theme_mode", json!("solarized"))
|
||||
.is_err());
|
||||
assert!(!doc.is_dirty(), "校验失败不能标脏");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_prefix_reports_problem_and_keeps_values() {
|
||||
let raw = json!({
|
||||
SCHEMA_KEY: "https://unlucky-launcher.local/schema/launcher/1.2.0.json",
|
||||
"core": { "data_mode": "standard" },
|
||||
});
|
||||
let (doc, problems) = loaded(raw);
|
||||
// 值照读(旧实现按「没有声明」整份装默认值)。
|
||||
assert_eq!(doc.effective_str("core.data_mode"), Some("standard"));
|
||||
let found = problems.iter().find_map(|problem| match problem {
|
||||
LoadProblem::UnknownSchema { url } => Some(url.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
found,
|
||||
Some("https://unlucky-launcher.local/schema/launcher/1.2.0.json"),
|
||||
"{problems:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn launcher_version() -> SchemaVersion {
|
||||
unlucky_core::setting::keys::launcher_version()
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! D20 隔离键的 schema 契约:全局 `instances.mode`、实例 `isolation`、`lastModeChange`、
|
||||
//! 恒共享层不可配、旧配置缺键即默认值(不需要迁移步骤)。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::game::install::instance::{InstanceConfig, Isolation, OverrideKey};
|
||||
use unlucky_core::game::isolation::{observe_mode, IsolationMode};
|
||||
use unlucky_core::game::GameLayout;
|
||||
use unlucky_core::setting::document::{LoadProblem, RenderMode, SettingDocument};
|
||||
use unlucky_core::setting::instance_keys::{
|
||||
instance_schema, instance_version, ISOLATION_OVERRIDES, ISOLATION_RESOLVED, OVERRIDE_KEYS,
|
||||
};
|
||||
use unlucky_core::setting::keys::{launcher_schema, launcher_version, INSTANCE_MODES};
|
||||
use unlucky_core::setting::schema::{SchemaId, SchemaVersion};
|
||||
use unlucky_core::setting::spec::{Sensitivity, ValueKind};
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use super::common::TempDir;
|
||||
|
||||
#[test]
|
||||
fn global_mode_key_defaults_to_isolated() {
|
||||
let schema = launcher_schema();
|
||||
let spec = schema.spec("instances.mode").expect("instances.mode 键");
|
||||
assert_eq!(spec.default, json!("isolated"), "D20 默认隔离");
|
||||
assert_eq!(spec.kind, ValueKind::Enum(INSTANCE_MODES));
|
||||
assert_eq!(
|
||||
spec.ai.sensitivity,
|
||||
Sensitivity::High,
|
||||
"切档会改变存档可见性,AI 改必须弹窗确认"
|
||||
);
|
||||
assert!(schema.validate("instances.mode", &json!("shared")).is_ok());
|
||||
assert!(schema
|
||||
.validate("instances.mode", &json!("isolated"))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("instances.mode", &json!("per_version"))
|
||||
.is_err());
|
||||
assert!(schema.validate("instances.mode", &json!("custom")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_layers_are_not_configurable() {
|
||||
let schema = launcher_schema();
|
||||
for key in ["shared.versions", "shared.assets", "shared.libraries"] {
|
||||
assert!(
|
||||
!schema.is_known(key),
|
||||
"{key} 是恒共享层,不该出现在可配键表里"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_isolation_defaults_to_inherit() {
|
||||
let schema = instance_schema();
|
||||
let spec = schema.spec("isolation").expect("isolation 键");
|
||||
assert_eq!(spec.default, json!("inherit"));
|
||||
assert_eq!(spec.kind, ValueKind::Enum(ISOLATION_OVERRIDES));
|
||||
for value in ["inherit", "isolated", "shared", "custom"] {
|
||||
assert!(
|
||||
schema.validate("isolation", &json!(value)).is_ok(),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
assert!(schema.validate("isolation", &json!("per_version")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherit_never_reaches_the_file() {
|
||||
// 默认值不落盘:空文档渲染出来只应该有 $schema / schemaVersion。
|
||||
let schema = instance_schema();
|
||||
let (document, problems) = SettingDocument::load(&schema, &json!({}));
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
let rendered = document.render(RenderMode::Full);
|
||||
assert!(rendered.get("isolation").is_none(), "{rendered}");
|
||||
assert!(rendered.get("lastModeChange").is_none(), "{rendered}");
|
||||
let dirty = document.render(RenderMode::DirtyOnly);
|
||||
assert!(dirty.get("isolation").is_none(), "{dirty}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_mode_change_keys_are_bookkeeping_only() {
|
||||
let schema = instance_schema();
|
||||
for key in [
|
||||
"lastModeChange.at",
|
||||
"lastModeChange.from",
|
||||
"lastModeChange.to",
|
||||
"lastModeChange.probe.isolatedSide",
|
||||
"lastModeChange.probe.sharedSide",
|
||||
"lastModeChange.warned",
|
||||
] {
|
||||
assert!(schema.is_known(key), "{key} 必须登记");
|
||||
assert!(!schema.accepts_ai_write(key), "{key} 不许 AI 写");
|
||||
}
|
||||
assert!(schema
|
||||
.validate("lastModeChange.probe.isolatedSide", &json!(12))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("lastModeChange.probe.isolatedSide", &json!(-1))
|
||||
.is_err());
|
||||
assert!(schema
|
||||
.validate("lastModeChange.warned", &json!(true))
|
||||
.is_ok());
|
||||
assert!(schema.validate("lastModeChange.warned", &json!(1)).is_err());
|
||||
assert!(schema
|
||||
.validate("lastModeChange.from", &json!("shared"))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("lastModeChange.from", &json!("inherit"))
|
||||
.is_err());
|
||||
assert_eq!(ISOLATION_RESOLVED, ["isolated", "shared", "custom"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_key_is_an_enum_whitelist() {
|
||||
let schema = instance_schema();
|
||||
assert!(schema.validate("overrides", &json!([])).is_ok());
|
||||
assert!(schema
|
||||
.validate("overrides", &json!(["maxMemory", "runningDirectory"]))
|
||||
.is_ok());
|
||||
assert!(
|
||||
schema
|
||||
.validate("overrides", &json!(["max_memory"]))
|
||||
.is_err(),
|
||||
"旧 snake_case 拼写不许过(拼错会静默失效)"
|
||||
);
|
||||
assert!(schema.validate("overrides", &json!([1])).is_err());
|
||||
assert_eq!(OVERRIDE_KEYS, ["maxMemory", "javaType", "runningDirectory"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_versions_register_the_new_keys_without_migration() {
|
||||
// 1.3.0 登记 D17 的「外观(高级)」键(1.2.0 是 D23 的 JDK 落点/优先级键)。
|
||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0));
|
||||
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
|
||||
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
|
||||
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
||||
assert_eq!(launcher_version().major, 1);
|
||||
assert_eq!(instance_version().major, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_launcher_file_without_new_keys_loads_with_defaults() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"ull-setting-d20-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos())
|
||||
));
|
||||
std::fs::create_dir_all(&temp).expect("临时目录");
|
||||
let path = temp.join("launcher.json");
|
||||
// 旧版本写的文件:只有 $schema 1.0.0 和一个老键,没有 instances.mode。
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.0.0",
|
||||
"schemaVersion": 1,
|
||||
"ui": { "scale": 1.25 }
|
||||
}))
|
||||
.expect("序列化"),
|
||||
)
|
||||
.expect("写文件");
|
||||
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
assert!(store.access().can_read(), "1.0.0 文件必须可读");
|
||||
assert!(store.problems().is_empty(), "{:?}", store.problems());
|
||||
assert_eq!(
|
||||
store.doc().effective_str("instances.mode"),
|
||||
Some("isolated"),
|
||||
"缺键取 D20 默认值"
|
||||
);
|
||||
assert_eq!(store.doc().effective_f64("ui.scale"), Some(1.25));
|
||||
let _ = std::fs::remove_dir_all(&temp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_lists_agree_with_the_game_enums() {
|
||||
// schema 白名单与 game 侧枚举必须一一对应(两处字面量漂移就是静默不生效)。
|
||||
let mut modes: Vec<&str> = [IsolationMode::Isolated, IsolationMode::Shared]
|
||||
.iter()
|
||||
.map(|mode| mode.as_str())
|
||||
.collect();
|
||||
modes.sort_unstable();
|
||||
let mut declared = INSTANCE_MODES.to_vec();
|
||||
declared.sort_unstable();
|
||||
assert_eq!(modes, declared);
|
||||
|
||||
for value in ISOLATION_OVERRIDES
|
||||
.iter()
|
||||
.filter(|value| **value != "inherit")
|
||||
{
|
||||
assert!(
|
||||
Isolation::parse(value).is_some(),
|
||||
"{value} 在 game 侧解析不出来"
|
||||
);
|
||||
assert_eq!(Isolation::parse(value).map(Isolation::as_str), Some(*value));
|
||||
}
|
||||
for value in ISOLATION_RESOLVED {
|
||||
assert!(Isolation::parse(value).is_some(), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_output_loads_without_unknown_or_invalid_keys() {
|
||||
// 回归 P2-1(review r1):写入者(InstanceConfig + observe_mode)产出的键必须被 schema 全认。
|
||||
// 之前 `lastModeChange.warned` 没登记,document 层会把真实文件标成 UnknownKey——
|
||||
// 这正是「schema 只做写入者白名单视图」这条契约的裂缝。
|
||||
let temp = TempDir::new("d20-writer-shape");
|
||||
let game = GameLayout::new(temp.child("game"));
|
||||
let mut config = InstanceConfig::new("11111111-2222-4333-8444-555555555555", "1.20.1");
|
||||
config.isolation = Some(Isolation::Custom);
|
||||
config.game_dir = Some(temp.child("elsewhere"));
|
||||
config.overrides = vec![OverrideKey::MaxMemory];
|
||||
observe_mode(
|
||||
&mut config,
|
||||
&game,
|
||||
"2026-10-30T00:00:00Z",
|
||||
Isolation::Custom,
|
||||
)
|
||||
.expect("观察");
|
||||
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_str(&config.to_json_string().expect("序列化")).expect("合法 JSON");
|
||||
// 写入者确实输出了 warned(P2-1 的前提)。
|
||||
assert_eq!(raw["lastModeChange"]["warned"], json!(true));
|
||||
|
||||
let (document, problems) = SettingDocument::load(&instance_schema(), &raw);
|
||||
let mut unknown: Vec<String> = Vec::new();
|
||||
let mut invalid: Vec<String> = Vec::new();
|
||||
for problem in &problems {
|
||||
match problem {
|
||||
LoadProblem::UnknownKey { key } => unknown.push(key.clone()),
|
||||
LoadProblem::InvalidValue { key, reason } => invalid.push(format!("{key}: {reason}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(unknown.is_empty(), "写入者的键没登记进 schema:{unknown:?}");
|
||||
assert!(
|
||||
invalid.is_empty(),
|
||||
"写入者的值过不了 schema 校验:{invalid:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
document.get("lastModeChange.warned"),
|
||||
Some(&json!(true)),
|
||||
"记账键要能读回"
|
||||
);
|
||||
// 落盘视图不能把它当「未知键」重复输出。
|
||||
let rendered = document.render(RenderMode::Full);
|
||||
assert_eq!(rendered["lastModeChange"]["warned"], json!(true));
|
||||
assert_eq!(rendered["overrides"], json!(["maxMemory"]));
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! D23 新增的两个设置键:`java.installRoot` / `java.vendorPriority`。
|
||||
//!
|
||||
//! 三件事要说清:
|
||||
//! 1. 两个键都登记在 launcher schema 里(`ull config get` 能看到、AI 白名单受策略约束);
|
||||
//! 2. `java.vendorPriority` 的允许值集合与 `game::java::vendor::VENDOR_IDS` **同源**
|
||||
//! (setting 层不依赖 game 层,靠这条契约测试固定,避免两处漂移);
|
||||
//! 3. schema 版本推到 1.2.0 但 major 仍为 1 → 不需要迁移步骤(默认值不落盘)。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::game::java::vendor::VENDOR_IDS;
|
||||
use unlucky_core::setting::keys::{
|
||||
launcher_schema, launcher_version, JAVA_INSTALL_ROOT_UNSET, JDK_VENDORS,
|
||||
};
|
||||
use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion};
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
#[test]
|
||||
fn both_d23_keys_are_registered_with_their_defaults() {
|
||||
let schema = launcher_schema();
|
||||
assert_eq!(
|
||||
schema.default_value("java.installRoot"),
|
||||
Some(&json!(JAVA_INSTALL_ROOT_UNSET))
|
||||
);
|
||||
assert_eq!(
|
||||
schema.default_value("java.vendorPriority"),
|
||||
Some(&json!([]))
|
||||
);
|
||||
// 空串/空数组都有明确语义:installRoot 空 = 按数据目录模式派生;
|
||||
// vendorPriority 空 = 按地区默认序(国内 Temurin→Oracle→MS→Zulu)。
|
||||
assert!(schema.spec("java.installRoot").is_some());
|
||||
assert!(schema.spec("java.vendorPriority").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_priority_whitelist_matches_the_vendor_module() {
|
||||
assert_eq!(JDK_VENDORS, VENDOR_IDS, "两处必须同一份集合");
|
||||
let schema = launcher_schema();
|
||||
assert!(schema
|
||||
.validate("java.vendorPriority", &json!(["oracle"]))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("java.vendorPriority", &json!(["temurin", "zulu"]))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("java.vendorPriority", &json!(["graalvm"]))
|
||||
.is_err());
|
||||
assert!(schema
|
||||
.validate("java.vendorPriority", &json!([1, 2]))
|
||||
.is_err());
|
||||
assert!(schema
|
||||
.validate("java.vendorPriority", &json!("microsoft"))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_root_accepts_a_path_and_keeps_empty_as_unset() {
|
||||
let schema = launcher_schema();
|
||||
assert!(schema
|
||||
.validate("java.installRoot", &json!("D:/jdk"))
|
||||
.is_ok());
|
||||
// 空串就是默认值,语义是「没设覆盖键」(不是错误):所以类型是 text 而不是 path
|
||||
// (Path 类型不接受空串,而这里需要一个合法的「未设置」值)。
|
||||
assert!(schema.validate("java.installRoot", &json!("")).is_ok());
|
||||
assert!(schema.validate("java.installRoot", &json!(5)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
|
||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0));
|
||||
assert_eq!(
|
||||
launcher_version().major,
|
||||
1,
|
||||
"major 不变 → 迁移链(按 major 走)不需要新步骤"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_config_file_without_the_new_keys_still_loads() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-java-keys-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |elapsed| elapsed.as_nanos())
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
let path = dir.join("launcher.json");
|
||||
// 1.1.0 的文件(真实 $schema 前缀):没有 java.installRoot / java.vendorPriority,
|
||||
// 且 core.data_mode 故意写非默认值 standard —— 证明值是「读出来的」不是「默认值撞上的」。
|
||||
let old_url = SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 1, 0)).url();
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(r#"{{"$schema":"{old_url}","schemaVersion":1,"core":{{"data_mode":"standard"}}}}"#),
|
||||
)
|
||||
.expect("写旧配置");
|
||||
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
assert_eq!(
|
||||
store.doc().effective_str("core.data_mode"),
|
||||
Some("standard"),
|
||||
"旧文件的值必须照读(不是碰巧等于默认值)"
|
||||
);
|
||||
// 缺键取默认值,不报错、不写盘(「默认值不落盘」)。
|
||||
assert_eq!(
|
||||
store.doc().effective_str("java.installRoot"),
|
||||
Some(JAVA_INSTALL_ROOT_UNSET)
|
||||
);
|
||||
assert_eq!(
|
||||
store.effective("java.vendorPriority"),
|
||||
Some(&json!([])),
|
||||
"缺键 → 空数组 → 按地区默认序"
|
||||
);
|
||||
assert!(
|
||||
store.problems().is_empty(),
|
||||
"只缺新键不算问题:{:?}",
|
||||
store.problems()
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 四分区布局、路径拼接、实例 id 白名单、可搬运路径字符串。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::platform::layout::{
|
||||
is_valid_instance_id, LauncherLayout, Partition, CONFIG_DIR, GAME_HOME_DIR, INSTANCES_DIR,
|
||||
LAUNCHER_CONFIG_FILE, LAUNCHER_HOME_DIR, MIGRATION_RECEIPT_FILE, PROFILES_CONFIG_FILE,
|
||||
REDIRECT_FILE,
|
||||
};
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
#[test]
|
||||
fn home_dir_names_match_design() {
|
||||
assert_eq!(LAUNCHER_HOME_DIR, ".ULL");
|
||||
assert_eq!(GAME_HOME_DIR, ".minecraft");
|
||||
assert_eq!(CONFIG_DIR, "config");
|
||||
assert_eq!(INSTANCES_DIR, "instances");
|
||||
assert_eq!(LAUNCHER_CONFIG_FILE, "launcher.json");
|
||||
assert_eq!(PROFILES_CONFIG_FILE, "profiles.json");
|
||||
assert_eq!(REDIRECT_FILE, "redirect.json");
|
||||
assert_eq!(MIGRATION_RECEIPT_FILE, "migration-receipt.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposes_four_partitions() {
|
||||
let layout = LauncherLayout::new("/home/u/.ULL");
|
||||
assert_eq!(layout.home(), std::path::Path::new("/home/u/.ULL"));
|
||||
assert_eq!(layout.config(), PathBuf::from("/home/u/.ULL/config"));
|
||||
assert_eq!(layout.data(), PathBuf::from("/home/u/.ULL/data"));
|
||||
assert_eq!(layout.cache(), PathBuf::from("/home/u/.ULL/cache"));
|
||||
assert_eq!(layout.logs(), PathBuf::from("/home/u/.ULL/logs"));
|
||||
|
||||
assert_eq!(Partition::ALL.len(), 4);
|
||||
assert_eq!(Partition::Config.dir_name(), "config");
|
||||
assert!(Partition::Cache.is_disposable());
|
||||
assert!(!Partition::Config.is_disposable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_config_and_cache_paths() {
|
||||
let layout = LauncherLayout::new("/home/u/.ULL");
|
||||
assert_eq!(
|
||||
layout.launcher_config(),
|
||||
PathBuf::from("/home/u/.ULL/config/launcher.json")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.profiles_config(),
|
||||
PathBuf::from("/home/u/.ULL/config/profiles.json")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.sha1_cache("ab"),
|
||||
PathBuf::from("/home/u/.ULL/cache/sha1/ab")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.etag_cache(),
|
||||
PathBuf::from("/home/u/.ULL/cache/etag.json")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.meta_cache(),
|
||||
PathBuf::from("/home/u/.ULL/cache/meta")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.java_managed(),
|
||||
PathBuf::from("/home/u/.ULL/data/java-managed")
|
||||
);
|
||||
assert_eq!(layout.auth_data(), PathBuf::from("/home/u/.ULL/data/auth"));
|
||||
assert_eq!(
|
||||
layout.agent_sessions(),
|
||||
PathBuf::from("/home/u/.ULL/data/agent/sessions")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.redirect_path(),
|
||||
PathBuf::from("/home/u/.ULL/redirect.json")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.migration_receipt(),
|
||||
PathBuf::from("/home/u/.ULL/config/migration-receipt.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_config_rejects_path_traversal() {
|
||||
let layout = LauncherLayout::new("/home/u/.ULL");
|
||||
let id = "6f9619ff-8b86-d011-b42d-00c04fc964ff";
|
||||
assert!(is_valid_instance_id(id));
|
||||
assert_eq!(
|
||||
layout.instance_config(id).expect("合法 id"),
|
||||
PathBuf::from(format!("/home/u/.ULL/config/instances/{id}.json"))
|
||||
);
|
||||
assert!(layout.instance_config("../escape").is_err());
|
||||
assert!(layout.instance_config("").is_err());
|
||||
assert!(layout.instance_config("a/b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_all_partitions() {
|
||||
let temp = TempDir::new("layout");
|
||||
let layout = LauncherLayout::new(temp.child(".ULL"));
|
||||
layout.ensure().expect("建目录");
|
||||
for partition in Partition::ALL {
|
||||
assert!(
|
||||
layout.partition(partition).is_dir(),
|
||||
"{}",
|
||||
partition.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_path_strings_keep_absolute_and_normalise_relative() {
|
||||
use unlucky_core::platform::paths::{from_portable_string, to_portable_string};
|
||||
|
||||
assert_eq!(
|
||||
to_portable_string(std::path::Path::new(".minecraft")),
|
||||
".minecraft"
|
||||
);
|
||||
assert_eq!(
|
||||
to_portable_string(std::path::Path::new("versions\\1.20.1")),
|
||||
"versions/1.20.1"
|
||||
);
|
||||
let absolute = std::env::temp_dir();
|
||||
assert_eq!(to_portable_string(&absolute), absolute.to_string_lossy());
|
||||
assert_eq!(
|
||||
from_portable_string("versions/1.20.1"),
|
||||
PathBuf::from("versions/1.20.1")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! M12(core::setting + platform)集成测试入口。
|
||||
//!
|
||||
//! cargo 把 `tests/<name>/main.rs` 识别为独立的 test target(target 名 = `setting`),
|
||||
//! 因此本目录下每个子模块就是一个测试主题,避免单文件超过 400 行。
|
||||
|
||||
mod appearance_keys;
|
||||
mod atomic_write;
|
||||
mod common;
|
||||
mod debounce;
|
||||
mod deprecated_keys;
|
||||
mod dir_migration;
|
||||
mod dirs;
|
||||
mod document;
|
||||
mod isolation_keys;
|
||||
mod java_install_keys;
|
||||
mod layout;
|
||||
mod migration_chain;
|
||||
mod redirect;
|
||||
mod schema_compat;
|
||||
mod spec_ai;
|
||||
mod store;
|
||||
@@ -0,0 +1,295 @@
|
||||
//! 迁移链: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());
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! `redirect.json`:写/读/跟随/成环/损坏/版本更高。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::platform::data_dir::DataMode;
|
||||
use unlucky_core::platform::error::PlatformError;
|
||||
use unlucky_core::platform::redirect::{
|
||||
follow, read, redirect_path, remove, write, RedirectPointer, REDIRECT_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
fn pointer(target: &str) -> RedirectPointer {
|
||||
RedirectPointer {
|
||||
schema_version: REDIRECT_SCHEMA_VERSION,
|
||||
mode: DataMode::Standard,
|
||||
launcher_home: PathBuf::from(target),
|
||||
game_home: PathBuf::from(target).join(".minecraft"),
|
||||
migrated_at_ms: 1_700_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_pointer_file() {
|
||||
let temp = TempDir::new("redirect");
|
||||
let origin = temp.child(".ULL");
|
||||
assert_eq!(read(&origin).expect("读空目录"), None);
|
||||
|
||||
let written = write(&origin, &pointer("/elsewhere/.ULL")).expect("写指针");
|
||||
assert_eq!(written, redirect_path(&origin));
|
||||
assert!(origin.join("redirect.json").is_file());
|
||||
|
||||
let loaded = read(&origin).expect("读指针").expect("有指针");
|
||||
assert_eq!(loaded, pointer("/elsewhere/.ULL"));
|
||||
assert!(remove(&origin).expect("删指针"));
|
||||
assert!(!remove(&origin).expect("再删一次"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_chain_to_final_target() {
|
||||
let temp = TempDir::new("redirect-chain");
|
||||
let first = temp.child("a");
|
||||
let second = temp.child("b");
|
||||
let third = temp.child("c");
|
||||
|
||||
write(&first, &pointer(&second.to_string_lossy())).expect("写 a");
|
||||
write(&second, &pointer(&third.to_string_lossy())).expect("写 b");
|
||||
|
||||
let followed = follow(&first, 8).expect("跟随").expect("有指针");
|
||||
assert_eq!(followed.hops, 2);
|
||||
assert_eq!(followed.pointer.launcher_home, third);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_redirect_cycle() {
|
||||
let temp = TempDir::new("redirect-cycle");
|
||||
let first = temp.child("a");
|
||||
let second = temp.child("b");
|
||||
write(&first, &pointer(&second.to_string_lossy())).expect("写 a");
|
||||
write(&second, &pointer(&first.to_string_lossy())).expect("写 b");
|
||||
|
||||
let err = follow(&first, 8).expect_err("应当成环报错");
|
||||
assert!(matches!(err, PlatformError::RedirectCycle { .. }), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corrupt_and_newer_pointer() {
|
||||
let temp = TempDir::new("redirect-corrupt");
|
||||
let dir = temp.child(".ULL");
|
||||
std::fs::create_dir_all(&dir).expect("建目录");
|
||||
std::fs::write(dir.join("redirect.json"), b"{not json").expect("写坏文件");
|
||||
assert!(matches!(
|
||||
read(&dir).expect_err("坏文件应报错"),
|
||||
PlatformError::RedirectCorrupt { .. }
|
||||
));
|
||||
|
||||
let mut newer = pointer("/newer/.ULL");
|
||||
newer.schema_version = REDIRECT_SCHEMA_VERSION + 1;
|
||||
std::fs::write(
|
||||
dir.join("redirect.json"),
|
||||
serde_json::to_vec(&newer).expect("序列化"),
|
||||
)
|
||||
.expect("写文件");
|
||||
assert!(matches!(
|
||||
read(&dir).expect_err("新版本指针应报错"),
|
||||
PlatformError::RedirectCorrupt { .. }
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! schema 版本标识、8 态兼容判定、三档访问权映射。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::schema::{
|
||||
FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY,
|
||||
};
|
||||
|
||||
fn expected() -> SchemaRef {
|
||||
SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 2, 3))
|
||||
}
|
||||
|
||||
fn compat_of(version: SchemaVersion) -> SchemaCompat {
|
||||
let url = SchemaRef::new(SchemaId::LAUNCHER, version).url();
|
||||
SchemaCompat::check(&json!({ SCHEMA_KEY: url }), expected())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_parsing_and_ordering() {
|
||||
let version = SchemaVersion::parse("1.2.3").expect("解析");
|
||||
assert_eq!(version, SchemaVersion::new(1, 2, 3));
|
||||
assert_eq!(version.to_string(), "1.2.3");
|
||||
assert_eq!(version.next_major(), SchemaVersion::new(2, 0, 0));
|
||||
assert!(SchemaVersion::parse("1.2").is_none());
|
||||
assert!(SchemaVersion::parse("1.2.3.4").is_none());
|
||||
assert!(SchemaVersion::new(1, 2, 3) < SchemaVersion::new(1, 3, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_url_round_trip() {
|
||||
let reference = launcher_schema().schema_ref();
|
||||
let url = reference.url();
|
||||
assert!(
|
||||
url.ends_with(&format!("launcher/{}", reference.version)),
|
||||
"{url}"
|
||||
);
|
||||
assert_eq!(SchemaRef::parse_url(&url), Some(reference));
|
||||
assert!(SchemaRef::parse_url("https://example.com/x").is_none());
|
||||
assert!(SchemaRef::parse_url("garbage").is_none());
|
||||
assert_eq!(SchemaId::parse("instance"), Some(SchemaId::INSTANCE));
|
||||
assert!(SchemaId::parse("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_or_older_versions_are_read_write() {
|
||||
assert_eq!(
|
||||
compat_of(SchemaVersion::new(1, 2, 3)),
|
||||
SchemaCompat::ReadWrite
|
||||
);
|
||||
assert_eq!(
|
||||
compat_of(SchemaVersion::new(1, 2, 2)),
|
||||
SchemaCompat::ReadWrite
|
||||
);
|
||||
assert_eq!(
|
||||
compat_of(SchemaVersion::new(1, 0, 0)),
|
||||
SchemaCompat::ReadWrite
|
||||
);
|
||||
assert_eq!(
|
||||
compat_of(SchemaVersion::new(0, 9, 0)),
|
||||
SchemaCompat::ReadWrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_patch_keeps_original_schema() {
|
||||
let compat = compat_of(SchemaVersion::new(1, 2, 4));
|
||||
assert_eq!(compat, SchemaCompat::ReadWritePreserveSchema);
|
||||
assert!(compat.preserve_schema());
|
||||
assert_eq!(compat.access(), FileAccess::ReadWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_minor_is_read_only() {
|
||||
let compat = compat_of(SchemaVersion::new(1, 3, 0));
|
||||
assert_eq!(compat, SchemaCompat::ReadOnlyPreserveSchema);
|
||||
assert!(compat.preserve_schema());
|
||||
assert_eq!(compat.access(), FileAccess::ReadOnly);
|
||||
assert!(!compat.access().can_save());
|
||||
assert!(compat.access().blocks_editing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_major_is_unreadable() {
|
||||
let compat = compat_of(SchemaVersion::new(2, 0, 0));
|
||||
assert_eq!(compat, SchemaCompat::UnsupportedMajor);
|
||||
assert!(!compat.readable());
|
||||
assert_eq!(compat.access(), FileAccess::Unreadable);
|
||||
assert!(!compat.access().can_read());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_ids_and_broken_urls_are_rejected() {
|
||||
let instance_url = SchemaRef::new(SchemaId::INSTANCE, SchemaVersion::new(1, 0, 0)).url();
|
||||
assert_eq!(
|
||||
SchemaCompat::check(&json!({ SCHEMA_KEY: instance_url }), expected()),
|
||||
SchemaCompat::UnexpectedId
|
||||
);
|
||||
assert_eq!(
|
||||
SchemaCompat::check(&json!({ SCHEMA_KEY: "not-a-url" }), expected()),
|
||||
SchemaCompat::Invalid
|
||||
);
|
||||
assert_eq!(
|
||||
SchemaCompat::check(&json!({ "schemaVersion": 1 }), expected()),
|
||||
SchemaCompat::Missing
|
||||
);
|
||||
assert_eq!(SchemaCompat::Unparseable.access(), FileAccess::Unreadable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_three_tiers() {
|
||||
assert!(FileAccess::ReadWrite.can_read() && FileAccess::ReadWrite.can_save());
|
||||
assert!(FileAccess::ReadOnly.can_read() && !FileAccess::ReadOnly.can_save());
|
||||
assert!(!FileAccess::Unreadable.can_read());
|
||||
assert!(FileAccess::Unreadable.blocks_editing());
|
||||
assert_eq!(FileAccess::ReadOnly.as_str(), "read_only");
|
||||
assert_eq!(
|
||||
SchemaCompat::ReadOnlyPreserveSchema.as_str(),
|
||||
"read_only_preserve_schema"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 键元数据:AI 可写白名单、敏感分级、类型/范围校验、工具 JSON Schema。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::setting::catalog::SettingSchema;
|
||||
use unlucky_core::setting::instance_keys::{instance_schema, profiles_schema};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::schema::{SchemaId, SchemaVersion};
|
||||
use unlucky_core::setting::spec::{AiPolicy, Sensitivity, SettingSpec, ValueKind};
|
||||
|
||||
#[test]
|
||||
fn ai_whitelist_excludes_credentials() {
|
||||
let schema = launcher_schema();
|
||||
let writable = schema.ai_writable_keys();
|
||||
assert!(writable.contains(&"download.concurrency"));
|
||||
assert!(writable.contains(&"appearance.theme_mode"));
|
||||
assert!(!writable.contains(&"ai.api_key"), "凭据永不可写(D9)");
|
||||
|
||||
assert!(schema.accepts_ai_write("java.selected_path"));
|
||||
assert!(!schema.accepts_ai_write("ai.api_key"));
|
||||
assert!(!schema.accepts_ai_write("not.a.key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_grades_are_flagged() {
|
||||
let schema = launcher_schema();
|
||||
assert_eq!(
|
||||
schema.sensitivity("core.data_mode"),
|
||||
Some(Sensitivity::High)
|
||||
);
|
||||
assert_eq!(
|
||||
schema.sensitivity("download.concurrency"),
|
||||
Some(Sensitivity::Low)
|
||||
);
|
||||
assert_eq!(schema.sensitivity("ai.model"), Some(Sensitivity::Medium));
|
||||
assert_eq!(schema.sensitivity("ai.api_key"), Some(Sensitivity::High));
|
||||
assert!(Sensitivity::High.requires_confirmation());
|
||||
assert!(!Sensitivity::Low.requires_confirmation());
|
||||
assert_eq!(Sensitivity::Medium.as_str(), "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_types_ranges_and_enums() {
|
||||
let schema = launcher_schema();
|
||||
assert!(schema.validate("download.concurrency", &json!(8)).is_ok());
|
||||
assert!(schema.validate("download.concurrency", &json!(0)).is_err());
|
||||
assert!(schema
|
||||
.validate("download.concurrency", &json!(257))
|
||||
.is_err());
|
||||
assert!(schema
|
||||
.validate("download.concurrency", &json!("8"))
|
||||
.is_err());
|
||||
assert!(schema
|
||||
.validate("appearance.theme_mode", &json!("dark"))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("appearance.theme_mode", &json!("solarized"))
|
||||
.is_err());
|
||||
// 废弃键仍然「读兼容合法」——旧文件里的值不该被判成非法。
|
||||
assert!(schema.validate("ui.theme", &json!("metro_dark")).is_ok());
|
||||
assert!(schema.validate("ui.theme", &json!("auto")).is_err());
|
||||
assert!(schema.validate("ui.scale", &json!(1.5)).is_ok());
|
||||
assert!(schema.validate("ui.scale", &json!(9.0)).is_err());
|
||||
|
||||
let err = schema.validate("nope.key", &json!(1)).expect_err("未知键");
|
||||
assert!(err.to_string().contains("nope.key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_kinds_cover_all_variants() {
|
||||
assert!(ValueKind::Int { min: 1, max: 2 }.check(&json!(2)).is_ok());
|
||||
assert!(ValueKind::Int { min: 1, max: 2 }.check(&json!(3)).is_err());
|
||||
assert!(ValueKind::Float { min: 0.0, max: 1.0 }
|
||||
.check(&json!(0.5))
|
||||
.is_ok());
|
||||
assert!(ValueKind::Text { max_len: 2 }.check(&json!("ab")).is_ok());
|
||||
assert!(ValueKind::Text { max_len: 2 }.check(&json!("abc")).is_err());
|
||||
assert!(ValueKind::Path.check(&json!(" ")).is_err());
|
||||
assert!(ValueKind::Path
|
||||
.check(&json!("C:/java/bin/java.exe"))
|
||||
.is_ok());
|
||||
assert!(ValueKind::TextList.check(&json!(["a", "b"])).is_ok());
|
||||
assert!(ValueKind::TextList.check(&json!([1])).is_err());
|
||||
assert!(ValueKind::Any.check(&json!({"whatever": 1})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_schema_projection_feeds_tool_specs() {
|
||||
let schema = launcher_schema();
|
||||
let projected = schema.to_json_schema();
|
||||
assert_eq!(projected["type"], json!("object"));
|
||||
assert_eq!(projected["additionalProperties"], json!(false));
|
||||
|
||||
let properties = projected["properties"].as_object().expect("properties");
|
||||
assert!(properties.contains_key("download.concurrency"));
|
||||
assert!(!properties.contains_key("ai.api_key"), "锁定键不进工具参数");
|
||||
assert_eq!(properties["download.concurrency"]["type"], json!("integer"));
|
||||
assert_eq!(properties["download.concurrency"]["default"], json!(8));
|
||||
assert_eq!(
|
||||
properties["download.concurrency"]["x-ai-sensitivity"],
|
||||
json!("low")
|
||||
);
|
||||
assert_eq!(properties["appearance.theme_mode"]["type"], json!("string"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_keys_and_defaults() {
|
||||
let schema = launcher_schema();
|
||||
let tops = schema.top_level_keys();
|
||||
for expected in ["core", "download", "java", "ai", "ui", "log"] {
|
||||
assert!(tops.contains(expected), "缺少顶层键组 {expected}");
|
||||
}
|
||||
assert_eq!(
|
||||
schema.default_value("download.concurrency"),
|
||||
Some(&json!(8))
|
||||
);
|
||||
assert_eq!(
|
||||
schema.default_value("java.selected_path"),
|
||||
Some(&json!(null))
|
||||
);
|
||||
assert!(schema.spec("download.concurrency").is_some());
|
||||
assert!(schema.has_prefix(&["download"]));
|
||||
assert!(!schema.has_prefix(&["nope"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_and_profiles_schemas_are_usable() {
|
||||
let instance = instance_schema();
|
||||
assert_eq!(instance.id(), SchemaId::INSTANCE);
|
||||
// D20:实例键表统一成 camelCase 扁平形态(与 game::install::instance 的 serde 逐键一致)。
|
||||
assert!(instance.is_known("versionId"));
|
||||
assert!(instance.is_known("isolation"));
|
||||
assert!(instance.is_known("lastModeChange.probe.isolatedSide"));
|
||||
assert!(instance.accepts_ai_write("isolation"));
|
||||
assert!(
|
||||
!instance.accepts_ai_write("lastModeChange.at"),
|
||||
"切换记录由启动器写,AI 不许改"
|
||||
);
|
||||
assert!(instance.validate("isolation", &json!("isolated")).is_ok());
|
||||
assert!(instance.validate("isolation", &json!("inherit")).is_ok());
|
||||
assert!(instance.validate("isolation", &json!("maybe")).is_err());
|
||||
assert!(
|
||||
instance
|
||||
.validate("isolation", &json!("per_version"))
|
||||
.is_err(),
|
||||
"旧字面量由 game 侧读兼容,schema 白名单只认新值"
|
||||
);
|
||||
|
||||
let profiles = profiles_schema();
|
||||
assert_eq!(profiles.id(), SchemaId::PROFILES);
|
||||
assert_eq!(
|
||||
profiles.spec("profiles").expect("profiles 键").kind,
|
||||
ValueKind::Any
|
||||
);
|
||||
assert!(
|
||||
!profiles.accepts_ai_write("profiles"),
|
||||
"列表本体不许 AI 整体改写"
|
||||
);
|
||||
assert!(profiles.accepts_ai_write("selected_profile_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "重复的配置键")]
|
||||
fn duplicate_key_is_a_table_bug() {
|
||||
let schema = SettingSchema::new(SchemaId::LAUNCHER, SchemaVersion::new(9, 0, 0))
|
||||
.with(SettingSpec::bool(
|
||||
"dup.key",
|
||||
true,
|
||||
AiPolicy::silent(),
|
||||
"第一次",
|
||||
))
|
||||
.with(SettingSpec::bool(
|
||||
"dup.key",
|
||||
false,
|
||||
AiPolicy::silent(),
|
||||
"第二次",
|
||||
));
|
||||
assert_eq!(schema.specs().len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 配置文件的加载降级、只写脏字段、坏文件备份、只读/不可读三档行为。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::setting::document::LoadProblem;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::schema::{
|
||||
FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY,
|
||||
};
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
fn schema_url(version: SchemaVersion) -> String {
|
||||
SchemaRef::new(SchemaId::LAUNCHER, version).url()
|
||||
}
|
||||
|
||||
fn read_json(temp: &TempDir, name: &str) -> Value {
|
||||
serde_json::from_str(&temp.read(name)).expect("配置文件是合法 JSON")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_run_starts_writable_with_defaults() {
|
||||
let temp = TempDir::new("store-new");
|
||||
let path = temp.child("config/launcher.json");
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
|
||||
assert_eq!(store.access(), FileAccess::ReadWrite);
|
||||
assert_eq!(store.compat(), SchemaCompat::Missing);
|
||||
assert!(store.problems().is_empty());
|
||||
assert_eq!(
|
||||
store.effective("download.concurrency"),
|
||||
Some(&json!(8)),
|
||||
"首次运行用默认值"
|
||||
);
|
||||
assert!(!path.exists(), "只读加载不该创建文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_only_when_dirty_and_writes_schema_markers() {
|
||||
let temp = TempDir::new("store-save");
|
||||
let path = temp.child("config/launcher.json");
|
||||
let mut store = SettingStore::load(&path, launcher_schema());
|
||||
|
||||
assert_eq!(
|
||||
store.save().expect("空保存"),
|
||||
SaveOutcome::Skipped(SkipReason::NotDirty)
|
||||
);
|
||||
|
||||
store
|
||||
.set("download.concurrency", json!(16))
|
||||
.expect("改配置");
|
||||
let outcome = store.save().expect("保存");
|
||||
assert!(outcome.wrote());
|
||||
|
||||
let raw = read_json(&temp, "config/launcher.json");
|
||||
assert_eq!(raw["download"]["concurrency"], json!(16));
|
||||
assert_eq!(raw[SCHEMA_VERSION_KEY], json!(1));
|
||||
assert!(
|
||||
raw[SCHEMA_KEY]
|
||||
.as_str()
|
||||
.expect("$schema")
|
||||
.ends_with(&format!("launcher/{}", launcher_schema().version())),
|
||||
"保存写当前 schema 版本"
|
||||
);
|
||||
assert!(raw.get("ai").is_none(), "默认值不进文件");
|
||||
assert!(!store.is_dirty(), "保存后清脏");
|
||||
|
||||
let reloaded = SettingStore::load(&path, launcher_schema());
|
||||
assert_eq!(reloaded.effective("download.concurrency"), Some(&json!(16)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unknown_keys_across_save() {
|
||||
let temp = TempDir::new("store-unknown");
|
||||
let raw = json!({
|
||||
SCHEMA_KEY: schema_url(SchemaVersion::new(1, 0, 0)),
|
||||
SCHEMA_VERSION_KEY: 1,
|
||||
"download": { "concurrency": 4 },
|
||||
"from_the_future": { "flag": true }
|
||||
});
|
||||
temp.write("config/launcher.json", &raw.to_string());
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert!(
|
||||
store.problems().iter().any(
|
||||
|problem| matches!(problem, LoadProblem::UnknownKey { key } if key == "from_the_future")
|
||||
),
|
||||
"陌生顶层子树按最上层节点报一次: {:?}",
|
||||
store.problems()
|
||||
);
|
||||
|
||||
store
|
||||
.set("download.concurrency", json!(12))
|
||||
.expect("改配置");
|
||||
store.save().expect("保存");
|
||||
|
||||
let after = read_json(&temp, "config/launcher.json");
|
||||
assert_eq!(after["download"]["concurrency"], json!(12));
|
||||
assert_eq!(after["from_the_future"]["flag"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_backed_up_before_overwrite() {
|
||||
let temp = TempDir::new("store-corrupt");
|
||||
temp.write("config/launcher.json", "{ definitely not json");
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert_eq!(store.compat(), SchemaCompat::Unparseable);
|
||||
assert_eq!(
|
||||
store.access(),
|
||||
FileAccess::ReadWrite,
|
||||
"坏文件要能修,否则用户被永久卡住"
|
||||
);
|
||||
assert_eq!(store.effective("download.concurrency"), Some(&json!(8)));
|
||||
|
||||
store.set("ui.language", json!("en_us")).expect("改配置");
|
||||
store.save().expect("保存");
|
||||
|
||||
assert!(temp.exists("config/launcher.json.1"), "坏文件先备份成 .1");
|
||||
let raw = read_json(&temp, "config/launcher.json");
|
||||
assert_eq!(raw["ui"]["language"], json!("en_us"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_minor_is_read_only_and_keeps_its_schema() {
|
||||
let temp = TempDir::new("store-readonly");
|
||||
// 比当前 schema(1.3.0)更高的 minor:只读且保留原 $schema。
|
||||
let url = schema_url(SchemaVersion::new(1, 4, 0));
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({ SCHEMA_KEY: url, "download": { "concurrency": 32 } }).to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert_eq!(store.compat(), SchemaCompat::ReadOnlyPreserveSchema);
|
||||
assert_eq!(store.access(), FileAccess::ReadOnly);
|
||||
assert_eq!(
|
||||
store.effective("download.concurrency"),
|
||||
Some(&json!(32)),
|
||||
"只读档的值照读"
|
||||
);
|
||||
assert!(store.set("download.concurrency", json!(1)).is_err());
|
||||
assert_eq!(
|
||||
store.save().expect("只读保存"),
|
||||
SaveOutcome::Skipped(SkipReason::ReadOnly)
|
||||
);
|
||||
|
||||
store.save_forced().expect("用户确认强写");
|
||||
assert_eq!(store.access(), FileAccess::ReadWrite);
|
||||
let raw = read_json(&temp, "config/launcher.json");
|
||||
assert!(
|
||||
raw[SCHEMA_KEY]
|
||||
.as_str()
|
||||
.expect("$schema")
|
||||
.ends_with("launcher/1.4.0"),
|
||||
"更 minor 的文件强写后也要保留原 $schema"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_major_is_unreadable_until_forced() {
|
||||
let temp = TempDir::new("store-unreadable");
|
||||
let url = schema_url(SchemaVersion::new(2, 0, 0));
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({ SCHEMA_KEY: url, "brand_new": { "thing": 1 } }).to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert_eq!(store.compat(), SchemaCompat::UnsupportedMajor);
|
||||
assert_eq!(store.access(), FileAccess::Unreadable);
|
||||
assert!(
|
||||
store.set("appearance.theme_mode", json!("dark")).is_err(),
|
||||
"不可读档全程不写"
|
||||
);
|
||||
assert_eq!(
|
||||
store.save().expect("不可读保存"),
|
||||
SaveOutcome::Skipped(SkipReason::Unreadable)
|
||||
);
|
||||
|
||||
store.save_forced().expect("用户确认强写");
|
||||
assert!(temp.exists("config/launcher.json.1"));
|
||||
assert_eq!(store.access(), FileAccess::ReadWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_discards_in_memory_changes() {
|
||||
let temp = TempDir::new("store-reload");
|
||||
let path = temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({ SCHEMA_KEY: schema_url(SchemaVersion::new(1, 0, 0)), "appearance": { "theme_mode": "light" } })
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(&path, launcher_schema());
|
||||
store
|
||||
.set("appearance.theme_mode", json!("dark"))
|
||||
.expect("改配置");
|
||||
assert!(store.is_dirty());
|
||||
store.reload();
|
||||
assert!(!store.is_dirty());
|
||||
assert_eq!(
|
||||
store.effective("appearance.theme_mode"),
|
||||
Some(&json!("light"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_paths_increment() {
|
||||
let temp = TempDir::new("store-backup");
|
||||
temp.write("config/launcher.json.1", "old");
|
||||
temp.write("config/launcher.json.2", "older");
|
||||
let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
let next = store.next_backup_path();
|
||||
assert!(next.to_string_lossy().ends_with(".3"), "{next:?}");
|
||||
}
|
||||
|
||||
/// finding 20261030-worker-java2-bug-schema-launcher-json-loadproblem:
|
||||
/// `$schema` 前缀写错(手写/脚本生成的配置最常见的错)曾让整份文件静默回落默认值。
|
||||
/// M40 起:值照读 + `UnknownSchema` 告警 + 文件只读(不悄悄改写我们看不懂的文件)。
|
||||
#[test]
|
||||
fn unknown_schema_prefix_warns_but_keeps_file_values() {
|
||||
let temp = TempDir::new("store-unknown-schema");
|
||||
let url = "https://unlucky-launcher.local/schema/launcher/1.2.0.json";
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({
|
||||
SCHEMA_KEY: url,
|
||||
"core": { "data_mode": "standard" },
|
||||
"download": { "concurrency": 32 },
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert_eq!(store.compat(), SchemaCompat::Invalid);
|
||||
assert_eq!(store.access(), FileAccess::ReadOnly);
|
||||
assert_eq!(
|
||||
store.effective("download.concurrency"),
|
||||
Some(&json!(32)),
|
||||
"认不出 $schema 也要读文件里的值"
|
||||
);
|
||||
assert!(
|
||||
store.problems().iter().any(
|
||||
|problem| matches!(problem, LoadProblem::UnknownSchema { url: found } if found == url)
|
||||
),
|
||||
"{:?}",
|
||||
store.problems()
|
||||
);
|
||||
assert!(
|
||||
store.problems()[0].to_string().contains("$schema"),
|
||||
"{}",
|
||||
store.problems()[0]
|
||||
);
|
||||
assert!(store.set("download.concurrency", json!(1)).is_err());
|
||||
assert_eq!(
|
||||
store.save().expect("只读保存"),
|
||||
SaveOutcome::Skipped(SkipReason::ReadOnly)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user