Files
Unlucky-Launcher/crates/core/tests/setting/store.rs
T
AskaEth 7406785325 feat(appearance): M91 —— 外观下层三键 + win32 整窗 α FFI + unsafe 落点门禁
core(D36/D37/D38 的键与范围)
- appearance.window_opacity(0.08..=1.0,默认 1.0,越界钳位且不报错;下限 D36 定案)
- appearance.titlebar_mode(auto|always|never,默认 auto;判定逻辑不在本 mission)
- appearance.palette_source(background-image|wallpaper|preset,与 design 的 Level::code() 同名)
- schema 1.3.0 → 1.4.0:纯增量、不加迁移步骤(新键出厂值 = 老文件缺键)
- appearance 拆成目录 mod/modes/tests(单文件 ≤400 行),非法枚举由 diagnostics() 给可读回落说明

presenter(只读语义,不破 M89 零 UI 门禁)
- AppearanceBase { window_opacity, titlebar_mode, palette_source } + from_document()
- 单测:缺键→出厂值、越界→钳位、非法枚举→回落且合法值照读

app-iced(D36 唯一 unsafe 落点)
- platform::effective_window_alpha(backend, requested) 纯函数:≤0/非有限 → None(拒绝整窗穿透),
  其余夹到 0.08..=1.0;CPU 档同样允许窗口级 α(D36 对 D27 的修订),4 组真值表测试
- platform/win32/transparency.rs:SetLayeredWindowAttributes(LWA_ALPHA) 的 FFI +
  alpha_byte 量化 + apply_window_alpha;非 Windows 同签名空实现返回 Unsupported

CI
- guards.yml 新增 unsafe-scope job:全仓 allow(unsafe_code) 文件集合必须恰好等于
  crates/app-iced/src/platform/win32/transparency.rs(集合相等,不是黑名单命中)

版本号绊线
- isolation_keys / java_install_keys:字面量 1.3.0 → 1.4.0(绊线保留字面量)
- store / document 的两个关系夹具改成相对 launcher_version() 构造(「更高 minor 只读」/
  「同 minor 更高 patch 保留 $schema」),下次 bump 不再假绿或假红
2026-11-02 13:58:33 +08:00

260 lines
9.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 配置文件的加载降级、只写脏字段、坏文件备份、只读/不可读三档行为。
use serde_json::{json, Value};
use unlucky_core::setting::document::LoadProblem;
use unlucky_core::setting::keys::{launcher_schema, launcher_version};
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 高一个 minor(**相对构造**:版本号涨了这条也不会假绿):只读且保留原 $schema。
let current = launcher_version();
let url = schema_url(SchemaVersion::new(current.major, current.minor + 1, 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_eq!(
raw[SCHEMA_KEY].as_str().expect("$schema"),
url,
"更 minor 的文件强写后也要保留原 $schema(与构造出来的那个 url 逐字相同)"
);
}
#[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)
);
}