//! M161:`update.auto_check` / `update.channel` 的 schema 契约 + 旧 `cache/update/prefs.json` 的迁移。 //! //! 迁移的四条硬约束在这里逐条钉住(幂等 / 不丢 `false` / 新值胜出 / 旧文件退役), //! 另外还有一条 schema 层面的:`update.channel` 现在只允许 `stable`——清单里没有的渠道 //! 写进文件等于「设了但没生效」,宁可让 `config set` 当面拒绝。 use serde_json::json; use unlucky_core::setting::document::{RenderMode, SettingDocument}; use unlucky_core::setting::keys::launcher_schema; use unlucky_core::setting::keys::update::UPDATE_CHANNELS; use unlucky_core::setting::spec::{Sensitivity, ValueKind}; use unlucky_core::setting::store::SettingStore; use unlucky_core::setting::update_prefs::{ self, Migration, Targets, DEFAULT_AUTO_CHECK, DEFAULT_CHANNEL, KEY_AUTO_CHECK, KEY_CHANNEL, RECEIPT_FILE, }; use super::common::TempDir; /// 三个路径(`/config/launcher.json` + `/cache/update/prefs.json` + 收据)。 fn targets(_temp: &TempDir) -> (String, String, String) { ( "config/launcher.json".to_string(), "cache/update/prefs.json".to_string(), format!("config/{RECEIPT_FILE}"), ) } fn run(temp: &TempDir) -> Migration { let (launcher, legacy, receipt) = targets(temp); update_prefs::migrate( Targets { launcher: &temp.child(&launcher), legacy: &temp.child(&legacy), receipt: &temp.child(&receipt), }, 1_700_000_000_000, ) } /// 读主配置里的显式值(`None` = 文件里没这个键)。 fn explicit(temp: &TempDir, key: &str) -> Option { let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); store.doc().get(key).cloned() } #[test] fn the_two_keys_are_registered_with_the_expected_shape() { let schema = launcher_schema(); let auto = schema .spec(KEY_AUTO_CHECK) .expect("update.auto_check 必须登记"); assert_eq!(auto.default, json!(DEFAULT_AUTO_CHECK), "出厂是开"); assert_eq!(auto.kind, ValueKind::Bool); assert_eq!( auto.ai.sensitivity, Sensitivity::Medium, "改它会让启动器自己下载并替换 exe,AI 改过要留审计痕迹" ); let channel = schema.spec(KEY_CHANNEL).expect("update.channel 必须登记"); assert_eq!(channel.default, json!(DEFAULT_CHANNEL)); assert_eq!(channel.kind, ValueKind::Enum(UPDATE_CHANNELS)); assert!(schema.validate(KEY_CHANNEL, &json!("stable")).is_ok()); // 清单里还没有的渠道:**当面拒绝**(写进文件等于「设了但没生效」)。 assert!(schema.validate(KEY_CHANNEL, &json!("beta")).is_err()); assert!(schema.validate(KEY_CHANNEL, &json!("nightly")).is_err()); assert!(schema.validate(KEY_AUTO_CHECK, &json!(1)).is_err()); } #[test] fn the_keys_render_in_the_nested_shape() { let (mut document, problems) = SettingDocument::load(&launcher_schema(), &json!({})); assert!(problems.is_empty(), "{problems:?}"); // 默认值不落盘:空文档渲染出来不该有 update 段。 assert!( document.render(RenderMode::Full).get("update").is_none(), "出厂值不该写进文件" ); document.set(KEY_AUTO_CHECK, json!(false)).expect("写"); document.set(KEY_CHANNEL, json!("stable")).expect("写"); let rendered = document.render(RenderMode::Full); assert_eq!( rendered["update"], json!({ "auto_check": false, "channel": "stable" }), "点分键在文件里是嵌套对象(与既有键同一形状)" ); // 读回来也要是同一个值(IPC/CLI 都读这一份)。 let (reloaded, problems) = SettingDocument::load(&launcher_schema(), &rendered); assert!(problems.is_empty(), "{problems:?}"); assert!( !update_prefs::auto_check(&reloaded), "读回来的生效值必须是文件里的 false" ); assert_eq!(update_prefs::channel(&reloaded), "stable"); } #[test] fn a_false_switch_is_migrated_and_the_old_file_is_retired() { let temp = TempDir::new("m161-migrate-false"); temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#); let outcome = run(&temp); let Migration::Migrated { auto_check, retired, receipt, } = outcome.clone() else { panic!("应当迁入,实际 {outcome:?}"); }; assert!(!auto_check, "旧文件里的 false 不许被默认值盖掉"); // 主配置里是显式的 false(不是「缺键取默认」)。 assert_eq!(explicit(&temp, KEY_AUTO_CHECK), Some(json!(false))); // 旧文件不能再被读到。 assert!(!temp.exists("cache/update/prefs.json"), "旧文件必须退役"); assert!(temp.exists("cache/update/prefs.json.migrated"), "留着取证"); assert_eq!(retired, temp.child("cache/update/prefs.json.migrated")); // 收据可查:落盘、类型/指纹/端点齐全。 assert!(receipt.exists(), "收据要落盘:{}", receipt.display()); let text = temp.read(&format!("config/{RECEIPT_FILE}")); assert!(text.contains("\"legacy_config\""), "{text}"); assert!(text.contains("prefs.json"), "{text}"); assert!(text.contains("launcher.json"), "{text}"); assert!(text.contains("\"applied_at_ms\": 1700000000000"), "{text}"); } #[test] fn running_twice_is_idempotent() { let temp = TempDir::new("m161-migrate-twice"); temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#); assert!(run(&temp).touched(), "第一遍要真的动手"); let after_first = temp.read("config/launcher.json"); let receipt_first = temp.read(&format!("config/{RECEIPT_FILE}")); // 第二遍:旧文件已经改名 ⇒ 什么都没得做。 let second = run(&temp); assert_eq!(second, Migration::Nothing, "第二遍必须是空操作"); assert_eq!(temp.read("config/launcher.json"), after_first, "文件不许变"); assert_eq!( temp.read(&format!("config/{RECEIPT_FILE}")), receipt_first, "收据也不该被重写(源指纹相同 = 跳过)" ); } #[test] fn a_missing_legacy_file_is_a_silent_noop() { let temp = TempDir::new("m161-migrate-absent"); assert_eq!(run(&temp), Migration::Nothing); // 连主配置都不该建(没有值要写,就不该白建一个文件)。 assert!(!temp.exists("config/launcher.json"), "不该凭空建配置"); assert!( !temp.exists(&format!("config/{RECEIPT_FILE}")), "不该写收据" ); } #[test] fn an_explicit_new_value_wins_over_the_legacy_file() { let temp = TempDir::new("m161-migrate-new-wins"); temp.write( "config/launcher.json", &json!({ "$schema": schema_url(), "schemaVersion": 1, "update": { "auto_check": true } }) .to_string(), ); temp.write("cache/update/prefs.json", r#"{"auto_check": false}"#); let outcome = run(&temp); assert_eq!( outcome.as_str(), "new_value_wins", "主配置里有显式值时不许覆盖:{outcome:?}" ); assert_eq!( explicit(&temp, KEY_AUTO_CHECK), Some(json!(true)), "新值胜出" ); // 旧文件照样退役(它已经不是真相源),收据也写了。 assert!(!temp.exists("cache/update/prefs.json")); assert!(temp.exists(&format!("config/{RECEIPT_FILE}"))); } #[test] fn the_default_true_is_not_written_but_the_file_still_retires() { let temp = TempDir::new("m161-migrate-default"); temp.write("cache/update/prefs.json", r#"{"auto_check": true}"#); let outcome = run(&temp); assert_eq!(outcome.as_str(), "migrated"); // 「默认值不落盘」:写进去只是噪音,生效值本来就是 true。 assert_eq!(explicit(&temp, KEY_AUTO_CHECK), None, "出厂值不落盘"); assert!(!temp.exists("cache/update/prefs.json"), "旧文件仍要退役"); let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); assert!(update_prefs::auto_check(store.doc())); } #[test] fn a_corrupt_legacy_file_touches_nothing() { let temp = TempDir::new("m161-migrate-corrupt"); temp.write("cache/update/prefs.json", "{ not json"); let outcome = run(&temp); assert_eq!(outcome.as_str(), "unreadable", "{outcome:?}"); // 坏文件保持原样:下一次还能再试(我们既不猜值也不删证据)。 assert!(temp.exists("cache/update/prefs.json")); assert!(!temp.exists(&format!("config/{RECEIPT_FILE}"))); } #[test] fn an_empty_legacy_file_still_means_the_default() { // `serde(default)`:老文件里没有 auto_check 字段(或整份是 `{}`)⇒ 按出厂值处理, // 而不是把整份文件判成坏文件。 let temp = TempDir::new("m161-migrate-empty"); temp.write("cache/update/prefs.json", "{}"); let outcome = run(&temp); assert_eq!(outcome.as_str(), "migrated"); let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); assert!(update_prefs::auto_check(store.doc()), "缺字段取出厂值 true"); } /// 真 `$schema`(加载不降级才看得到显式值)。 fn schema_url() -> String { use unlucky_core::setting::keys::launcher_version; use unlucky_core::setting::schema::{SchemaId, SchemaRef}; SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url() }