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

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
//! D20 切换检测与稀疏落盘:observe/warn 语义、`inherit` 与 `lastModeChange` 不落盘、
//! 旧字面量读兼容(从 `isolation.rs` 拆出,保持单文件 ≤400 行)。
use std::path::PathBuf;
use unlucky_core::game::install::instance::{InstanceConfig, Isolation};
use unlucky_core::game::isolation::{
derive_game_dir, observe_mode, IsolationMode, IsolationSource,
};
use super::helpers::{layout, seed, TempDir};
// ------------------------------------------------- 切换记录(幂等 + 只提示一次)
#[test]
fn observe_mode_records_baseline_without_warning() {
let temp = TempDir::new("iso-observe-baseline");
let game = layout(temp.path());
seed(game.root(), "saves");
let mut config = InstanceConfig::new("id-5", "1.20.1");
let changed = observe_mode(
&mut config,
&game,
"2026-10-30T00:00:00Z",
Isolation::Isolated,
)
.expect("观察");
assert!(changed, "首次观察要建基线");
let change = config.last_mode_change.clone().expect("基线");
assert_eq!(change.from, change.to);
assert!(!change.is_real_change());
assert!(change.warned, "基线不该弹提示");
assert_eq!(change.probe.shared_side, 1);
assert!(
config.take_mode_change_warning().is_none(),
"基线即使两侧都有内容也不提示"
);
}
#[test]
fn observe_mode_is_idempotent() {
let temp = TempDir::new("iso-observe-idempotent");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-6", "1.20.1");
assert!(
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("观察"),
"首次写入"
);
assert!(
!observe_mode(&mut config, &game, "t2", Isolation::Isolated).expect("观察"),
"同一档位不重复记"
);
assert_eq!(
config.last_mode_change.expect("记录仍在").at,
"t1",
"时间戳不该被刷新"
);
}
#[test]
fn mode_change_with_both_sides_warns_exactly_once() {
let temp = TempDir::new("iso-warn-once");
let game = layout(temp.path());
seed(&game.version_dir("1.20.1"), "saves");
seed(&game.version_dir("1.20.1"), "mods");
seed(game.root(), "saves");
let mut config = InstanceConfig::new("id-7", "1.20.1");
config.isolation = Some(Isolation::Shared);
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
config.isolation = Some(Isolation::Isolated);
assert!(
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换"),
"档位变了要写记录"
);
let change = config.last_mode_change.clone().expect("记录");
assert_eq!(change.from, Isolation::Shared);
assert_eq!(change.to, Isolation::Isolated);
assert!(change.is_real_change());
assert_eq!(change.probe.isolated_side, 2);
assert_eq!(change.probe.shared_side, 1);
let warning = config
.take_mode_change_warning()
.expect("两侧都有内容要提示");
assert!(warning.probe.both_occupied());
let message = warning.message();
assert!(message.contains("isolated"), "{message}");
assert!(
message.contains("不会自动搬"),
"文案必须说清不迁移:{message}"
);
assert!(
config.take_mode_change_warning().is_none(),
"同一次切换只提示一次"
);
assert!(config.last_mode_change.expect("记录").warned);
}
#[test]
fn mode_change_with_one_empty_side_is_quiet() {
let temp = TempDir::new("iso-warn-single-side");
let game = layout(temp.path());
// 只有共享侧有内容:隔离侧空 → 不提示(切到共享还能看见存档,反之亦然)。
seed(game.root(), "saves");
let mut config = InstanceConfig::new("id-8", "1.20.1");
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换");
assert!(config.take_mode_change_warning().is_none());
// 两侧都空同样安静。
let empty = TempDir::new("iso-warn-both-empty");
let game = layout(empty.path());
let mut config = InstanceConfig::new("id-9", "1.20.1");
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换");
assert!(config.take_mode_change_warning().is_none());
}
// -------------------------------------------------------------- 稀疏落盘不变量
#[test]
fn inherit_and_no_change_stay_out_of_the_file() {
let config = InstanceConfig::new("id-10", "1.20.1");
let json = config.to_json_string().expect("序列化");
assert!(!json.contains("isolation"), "inherit 不许落盘:{json}");
assert!(!json.contains("lastModeChange"), "没切过就不写:{json}");
assert!(!json.contains("gameDir"), "没目录就不写:{json}");
let back = InstanceConfig::from_json_str(&json).expect("回读");
assert_eq!(back, config);
}
#[test]
fn last_mode_change_round_trips_in_camel_case() {
let temp = TempDir::new("iso-roundtrip");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-11", "1.20.1");
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
let json = config.to_json_string().expect("序列化");
assert!(json.contains("\"lastModeChange\""), "{json}");
assert!(json.contains("\"isolatedSide\""), "{json}");
assert!(json.contains("\"sharedSide\""), "{json}");
let back = InstanceConfig::from_json_str(&json).expect("回读");
assert_eq!(back, config);
}
#[test]
fn legacy_per_version_value_reads_as_isolated() {
let raw = r#"{
"id": "id-12",
"versionId": "1.20.1",
"isolation": "per_version"
}"#;
let config = InstanceConfig::from_json_str(raw).expect("旧字面量读兼容");
assert_eq!(config.isolation, Some(Isolation::Isolated));
assert_eq!(Isolation::parse("per_version"), Some(Isolation::Isolated));
assert_eq!(Isolation::parse("isolated"), Some(Isolation::Isolated));
assert_eq!(Isolation::parse("nonsense"), None);
}
#[test]
fn effective_isolation_resolves_inherit() {
let config = InstanceConfig::new("id-13", "1.20.1");
assert_eq!(
config.effective_isolation(IsolationMode::Shared),
Isolation::Shared
);
let mut config = InstanceConfig::new("id-14", "1.20.1");
config.isolation = Some(Isolation::Custom);
assert_eq!(
config.effective_isolation(IsolationMode::Isolated),
Isolation::Custom
);
}
#[test]
fn custom_dir_from_relative_path_is_kept_verbatim() {
// custom 目录不做规范化(可能是相对路径或系统符号链接),派生层只透传。
let temp = TempDir::new("iso-custom-relative");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-15", "1.20.1");
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(PathBuf::from("relative/mc"));
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect("派生");
assert_eq!(derived.game_dir, PathBuf::from("relative/mc"));
}
#[test]
fn explicit_inherit_reads_back_as_absent() {
// 回归 P2-2(review r1):schema 允许写 `"isolation": "inherit"`,
// 读路径必须把它折叠成「键不存在」,否则设置层写回默认值后启动会报 unknown variant。
let raw = r#"{
"id": "id-16",
"versionId": "1.20.1",
"isolation": "inherit"
}"#;
let config = InstanceConfig::from_json_str(raw).expect("显式 inherit 必须能读");
assert!(config.inherits_isolation());
assert_eq!(config.isolation, None);
let json = config.to_json_string().expect("序列化");
assert!(
!json.contains("isolation"),
"写回时把 inherit 落成键缺失:{json}"
);
// 走到派生层就是「跟随全局」,不再是错误路径。
let temp = TempDir::new("iso-inherit-explicit");
let game = layout(temp.path());
let derived =
derive_game_dir(&game, "1.20.1", IsolationMode::Shared, Some(&config), None).expect("派生");
assert_eq!(derived.isolation, Isolation::Shared);
assert_eq!(derived.source, IsolationSource::Global);
// 其他非法字面量仍然报错(不静默取默认值)。
let bad = r#"{ "id": "id-17", "versionId": "1.20.1", "isolation": "maybe" }"#;
assert!(InstanceConfig::from_json_str(bad).is_err());
}