diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b148a0..ff2c4a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 本文件记录 `ShrinkDataSaver` 在当前工作区中的包内变更。 +## [2.5.0] - 2026-11-06 + +### Added + +- `LocalStorageProvider` 新增 `LocalStorageCommitStrategy`;`RenameOnly` 通过宿主注入的覆盖移动委托提交,适用于不支持原子替换的沙箱文件系统。 +- `ShrinkDataSaverPlatform.Initialize(IStorageProvider, ...)` 与两套 `ShrinkDataSaverRuntimeConfig.Storage` 支持宿主注入存储实现。 + +### Changed + +- `RenameOnly` 保留 `.bak1` / `.bak2` 两级备份,并在读取或下一次写入前从备份恢复中断的提交;孤立临时文件不会被当成完整存档。 +- `DeleteAsync` 会同时清理主文件、两级备份和临时文件。 + ## [2.2.2] - 2026-08-28 ### Changed diff --git a/DotNet~/Runtime/ShrinkDataSaverRuntime.cs b/DotNet~/Runtime/ShrinkDataSaverRuntime.cs index 8cc5d94..9601f90 100644 --- a/DotNet~/Runtime/ShrinkDataSaverRuntime.cs +++ b/DotNet~/Runtime/ShrinkDataSaverRuntime.cs @@ -15,6 +15,7 @@ public sealed class ShrinkDataSaverRuntimeConfig public int MaxSlots { get; set; } = 3; public float SettingsWriteDebounceSeconds { get; set; } = 0.35f; public bool DontDestroyOnLoadDriver { get; set; } + public IStorageProvider? Storage { get; set; } } public static class ShrinkDataSaverRuntime @@ -29,7 +30,7 @@ public static class ShrinkDataSaverRuntime ? ShrinkRuntimeServices.Paths.PersistentDataPath : config.RootPath!; Directory.CreateDirectory(root); - var storage = new LocalStorageProvider(root); + var storage = config.Storage ?? new LocalStorageProvider(root); ShrinkDataSaverPlatform.SettingsWriteDebounceSeconds = config.SettingsWriteDebounceSeconds; ShrinkDataSaverPlatform.MaxSlots = config.MaxSlots; ShrinkSettings.Initialize(storage, Path.Combine(root, config.SettingsFileName)); diff --git a/DotNet~/ShrinkSDK.DataSaver.csproj b/DotNet~/ShrinkSDK.DataSaver.csproj index a86f83a..53c19a2 100644 --- a/DotNet~/ShrinkSDK.DataSaver.csproj +++ b/DotNet~/ShrinkSDK.DataSaver.csproj @@ -5,7 +5,7 @@ ShrinkDataSaver.Runtime ShrinkDataSaver ShrinkSDK.DataSaver - 2.4.0 + 2.5.0 ShrinkSDK engine-neutral versioned save and settings runtime. false disable @@ -24,6 +24,6 @@ - + diff --git a/README.md b/README.md index 61bb50e..27d1c5b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Godot 和普通 .NET 项目安装 `ShrinkSDK.DataSaver`;可打包源码位于 | ⚙️ **Settings 系统** | 独立于存档的键值对设置,防抖写入,本地持久化 | | 🧷 **最近游玩槽位** | 自动记录最近一次成功进入的槽位,可用于“继续游戏” | | 🛠️ **双副本备份** | 主文件原子替换,自动保留 `.bak1` / `.bak2` 双副本轮换 | +| 📦 **可注入存储** | 宿主可传入自定义 `IStorageProvider`,或选择 `RenameOnly` 提交策略 | | ☁️ **云存档兼容** | 每槽单文件 `.sav`,模块级 `EnableCloudSync` 开关 | | 🔗 **EventBus 集成** | 可选接入 ShrinkEventBus,所有事件自动桥接到事件总线 | | 🖥️ **Editor 工具** | 中文可视化调试窗口,实时查看/操作设置与存档 | @@ -75,6 +76,16 @@ Bootstrap 会自动 `DontDestroyOnLoad`,并在应用退出 / 移动端切后 - 如果项目接入了 `ShrinkApp`,则可由宿主统一调用 `ShrinkDataSaverRuntime.Initialize(...)` - 宿主已接管时,旧 bootstrap 会自动幂等退出,不重复初始化 +Godot 或其他 .NET 8 宿主可以注入自己的存储实现,并显式提供覆盖重命名操作。共享 netstandard2.1 库不绑定平台原生 API;缺少该操作时 `RenameOnly` 构造会失败。Godot 也可直接使用 `ShrinkGodotStorage.CreateRenameOnly(rootPath)`: + +```csharp +var storage = new LocalStorageProvider(rootPath, LocalStorageCommitStrategy.RenameOnly, + (source, destination) => File.Move(source, destination, overwrite: true)); +ShrinkDataSaverPlatform.Initialize(storage, rootPath); +``` + +`ShrinkDataSaverRuntimeConfig.Storage`(Unity 与 .NET 两套运行时配置均提供)和 `ShrinkDataSaverAppModuleConfig` 也支持传入自定义存储。`RenameOnly` 适合不支持原子替换的沙箱文件系统;它仍保留 `.bak1` / `.bak2`,并会在读取或下一次写入前恢复中断的提交。 + ### 第三步:注册存档模块 ```csharp @@ -529,7 +540,7 @@ await ShrinkSave.LoadSlotAsync(slotIndex, key); - **模块注册顺序无关**:`SaveSlotAsync` 序列化所有已注册模块,`LoadSlotAsync` 分发到对应模块,缺失的模块会跳过但不会导致加载失败。 - **宿主接管兼容**:如果项目接入了 `ShrinkApp`,推荐通过 `ShrinkDataSaver.Integration.App` 让宿主统一初始化,而不是继续依赖场景 bootstrap。 -- **写入原子性**:`LocalStorageProvider` 先异步写入 `.tmp` 临时文件,再通过原子替换提交主文件,并自动轮换 `.bak1` / `.bak2` 两份备份。 +- **写入原子性**:`LocalStorageProvider` 先异步写入 `.tmp` 临时文件,再按 `FileReplace` 或 `RenameOnly` 策略提交主文件,并自动轮换 `.bak1` / `.bak2` 两份备份。 - **读取恢复**:`ShrinkSave` 与 `ShrinkSettings` 读取主文件失败时,会自动回退到 `.bak1`、`.bak2`,并在成功后修复主文件。 - **Settings 防抖**:`Set()` 调用后不立刻写磁盘,在 300ms(可配置)内连续调用只触发一次写入。退出时强制跳过防抖直接写入。 - **加密密钥管理**:框架不存储密钥。密钥丢失则对应存档无法解密,建议在 UI 层给玩家明确提示。 diff --git a/Runtime/LocalStorageProvider.cs b/Runtime/LocalStorageProvider.cs index 53296b9..daf621a 100644 --- a/Runtime/LocalStorageProvider.cs +++ b/Runtime/LocalStorageProvider.cs @@ -8,6 +8,12 @@ using Cysharp.Threading.Tasks; namespace ShrinkDataSaver { + public enum LocalStorageCommitStrategy + { + FileReplace = 0, + RenameOnly = 1 + } + public class LocalStorageProvider : IStorageProvider { private const string BackupPrimarySuffix = ".bak1"; @@ -18,16 +24,37 @@ namespace ShrinkDataSaver new(StringComparer.OrdinalIgnoreCase); private readonly string _rootPath; + private readonly LocalStorageCommitStrategy _commitStrategy; + private readonly Action _overwriteMove; public LocalStorageProvider(string rootPath = null) + : this(rootPath, LocalStorageCommitStrategy.FileReplace) { + } + + public LocalStorageProvider(string rootPath, LocalStorageCommitStrategy commitStrategy) + : this(rootPath, commitStrategy, null) + { + } + + public LocalStorageProvider(string rootPath, LocalStorageCommitStrategy commitStrategy, + Action overwriteMove) + { + if (!Enum.IsDefined(typeof(LocalStorageCommitStrategy), commitStrategy)) + throw new ArgumentOutOfRangeException(nameof(commitStrategy)); + if (commitStrategy == LocalStorageCommitStrategy.RenameOnly && overwriteMove == null) + throw new ArgumentException("RenameOnly requires an overwrite-rename implementation.", nameof(overwriteMove)); + _overwriteMove = overwriteMove; _rootPath = rootPath ?? ShrinkDataSaverPlatform.Paths?.PersistentDataPath #if UNITY_5_3_OR_NEWER ?? UnityEngine.Application.persistentDataPath #endif ?? throw new InvalidOperationException("ShrinkDataSaver persistent path is not configured."); + _commitStrategy = commitStrategy; } + public LocalStorageCommitStrategy CommitStrategy => _commitStrategy; + private string Resolve(string path) => Path.IsPathRooted(path) ? path : Path.Combine(_rootPath, path); @@ -40,47 +67,25 @@ namespace ShrinkDataSaver await Task.Run(() => Directory.CreateDirectory(dir), ct); } - var tempPath = fullPath + TempWriteSuffix; - var backupPath = fullPath + BackupPrimarySuffix; - var secondaryBackupPath = fullPath + BackupSecondarySuffix; var pathLock = GetPathLock(fullPath); - await pathLock.WaitAsync(ct); try { - await WriteFileBytesAsync(tempPath, data, ct); - await Task.Run(() => - { - if (File.Exists(fullPath)) - { - if (File.Exists(secondaryBackupPath)) - { - File.Delete(secondaryBackupPath); - } + await Task.Run(() => RecoverInterruptedWrite(fullPath), CancellationToken.None); + ct.ThrowIfCancellationRequested(); - if (File.Exists(backupPath)) - { - File.Move(backupPath, secondaryBackupPath); - } - - File.Replace(tempPath, fullPath, backupPath, true); - } - else - { - File.Move(tempPath, fullPath); - } - }, ct); - } - catch - { - await Task.Run(() => + var tempPath = fullPath + TempWriteSuffix; + try { - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } - }, CancellationToken.None); - throw; + await WriteFileBytesAsync(tempPath, data, ct); + ct.ThrowIfCancellationRequested(); + await Task.Run(() => Commit(tempPath, fullPath), CancellationToken.None); + } + catch + { + await Task.Run(() => TryDelete(tempPath), CancellationToken.None); + throw; + } } finally { @@ -95,6 +100,7 @@ namespace ShrinkDataSaver await pathLock.WaitAsync(ct); try { + await Task.Run(() => RecoverInterruptedWrite(fullPath), CancellationToken.None); if (!File.Exists(fullPath)) { throw new FileNotFoundException($"ShrinkDataSaver: file not found: {fullPath}"); @@ -109,7 +115,22 @@ namespace ShrinkDataSaver } public async UniTask ExistsAsync(string path, CancellationToken ct = default) => - await Task.Run(() => File.Exists(Resolve(path)), ct); + await ExistsCoreAsync(Resolve(path), ct); + + private async UniTask ExistsCoreAsync(string fullPath, CancellationToken ct) + { + var pathLock = GetPathLock(fullPath); + await pathLock.WaitAsync(ct); + try + { + await Task.Run(() => RecoverInterruptedWrite(fullPath), CancellationToken.None); + return File.Exists(fullPath); + } + finally + { + pathLock.Release(); + } + } public async UniTask DeleteAsync(string path, CancellationToken ct = default) { @@ -120,10 +141,10 @@ namespace ShrinkDataSaver { await Task.Run(() => { - if (File.Exists(fullPath)) - { - File.Delete(fullPath); - } + File.Delete(fullPath + TempWriteSuffix); + File.Delete(fullPath + BackupPrimarySuffix); + File.Delete(fullPath + BackupSecondarySuffix); + File.Delete(fullPath); }, ct); } finally @@ -148,6 +169,104 @@ namespace ShrinkDataSaver }, ct); } + private void Commit(string tempPath, string fullPath) + { + if (_commitStrategy == LocalStorageCommitStrategy.RenameOnly) + CommitRenameOnly(tempPath, fullPath); + else + CommitFileReplace(tempPath, fullPath); + } + + private static void CommitFileReplace(string tempPath, string fullPath) + { + var backupPath = fullPath + BackupPrimarySuffix; + var secondaryBackupPath = fullPath + BackupSecondarySuffix; + if (!File.Exists(fullPath)) + { + File.Move(tempPath, fullPath); + return; + } + + if (File.Exists(secondaryBackupPath)) + { + File.Delete(secondaryBackupPath); + } + + if (File.Exists(backupPath)) + { + File.Move(backupPath, secondaryBackupPath); + } + + File.Replace(tempPath, fullPath, backupPath, true); + } + + private void CommitRenameOnly(string tempPath, string fullPath) + { + var backupPath = fullPath + BackupPrimarySuffix; + var secondaryBackupPath = fullPath + BackupSecondarySuffix; + if (File.Exists(fullPath)) + { + if (File.Exists(backupPath)) + { + if (File.Exists(secondaryBackupPath)) + { + File.Delete(secondaryBackupPath); + } + + MoveFileReplacing(backupPath, secondaryBackupPath); + } + + MoveFileReplacing(fullPath, backupPath); + } + + MoveFileReplacing(tempPath, fullPath); + } + + private void RecoverInterruptedWrite(string fullPath) + { + if (_commitStrategy != LocalStorageCommitStrategy.RenameOnly) + return; + + var tempPath = fullPath + TempWriteSuffix; + var primaryBackupPath = fullPath + BackupPrimarySuffix; + var secondaryBackupPath = fullPath + BackupSecondarySuffix; + if (!File.Exists(fullPath)) + { + if (File.Exists(primaryBackupPath)) + { + MoveFileReplacing(primaryBackupPath, fullPath); + } + else if (File.Exists(secondaryBackupPath)) + { + MoveFileReplacing(secondaryBackupPath, fullPath); + } + } + + if (File.Exists(tempPath)) + { + TryDelete(tempPath); + } + } + + private void MoveFileReplacing(string source, string destination) => _overwriteMove(source, destination); + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + private static SemaphoreSlim GetPathLock(string fullPath) { return PathLocks.GetOrAdd(fullPath, _ => new SemaphoreSlim(1, 1)); diff --git a/Runtime/ShrinkDataSaverPlatform.cs b/Runtime/ShrinkDataSaverPlatform.cs index cf6c2ba..671558b 100644 --- a/Runtime/ShrinkDataSaverPlatform.cs +++ b/Runtime/ShrinkDataSaverPlatform.cs @@ -33,8 +33,28 @@ namespace ShrinkDataSaver if (string.IsNullOrWhiteSpace(rootPath)) throw new InvalidOperationException("ShrinkDataSaver platform paths are not configured."); var storage = new LocalStorageProvider(rootPath); - ShrinkSettings.Initialize(storage, System.IO.Path.Combine(rootPath, settingsFileName)); - ShrinkSave.Initialize(storage, System.IO.Path.Combine(rootPath, "saves"), saveFileExtension, currentSaveVersion); + Initialize(storage, rootPath, saveFileExtension, settingsFileName, currentSaveVersion); + } + + public static void Initialize(IStorageProvider storage, string? rootPath = null, + string saveFileExtension = ".json", string settingsFileName = "settings.json", + int currentSaveVersion = 1) + { + if (storage == null) + throw new ArgumentNullException(nameof(storage)); + + rootPath ??= Paths?.PersistentDataPath; +#if UNITY_5_3_OR_NEWER + rootPath ??= UnityEngine.Application.persistentDataPath; +#endif + var settingsPath = string.IsNullOrWhiteSpace(rootPath) + ? settingsFileName + : System.IO.Path.Combine(rootPath, settingsFileName); + var savesDir = string.IsNullOrWhiteSpace(rootPath) + ? "saves" + : System.IO.Path.Combine(rootPath, "saves"); + ShrinkSettings.Initialize(storage, settingsPath); + ShrinkSave.Initialize(storage, savesDir, saveFileExtension, currentSaveVersion); } internal static double TimeSeconds diff --git a/Runtime/ShrinkDataSaverRuntime.cs b/Runtime/ShrinkDataSaverRuntime.cs index 63b8ccc..1a01828 100644 --- a/Runtime/ShrinkDataSaverRuntime.cs +++ b/Runtime/ShrinkDataSaverRuntime.cs @@ -11,6 +11,7 @@ namespace ShrinkDataSaver public ShrinkDataSaverSettings? SettingsOverride { get; set; } public int CurrentSaveVersion { get; set; } = 1; public bool DontDestroyOnLoadDriver { get; set; } = true; + public IStorageProvider? Storage { get; set; } } public static class ShrinkDataSaverRuntime @@ -36,7 +37,7 @@ namespace ShrinkDataSaver var savesDir = Path.Combine(rootPath, "saves"); var settingsPath = Path.Combine(rootPath, cfg.settingsFileName); - var storage = new LocalStorageProvider(rootPath); + var storage = config.Storage ?? new LocalStorageProvider(rootPath); ShrinkDataSaverPlatform.SettingsWriteDebounceSeconds = cfg.settingsWriteDebounceSeconds; ShrinkDataSaverPlatform.MaxSlots = cfg.maxSlots; diff --git a/Tests/LocalStorageProviderStrategyTests.cs b/Tests/LocalStorageProviderStrategyTests.cs new file mode 100644 index 0000000..cba6a19 --- /dev/null +++ b/Tests/LocalStorageProviderStrategyTests.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Cysharp.Threading.Tasks; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace ShrinkDataSaver.Tests +{ + [TestFixture] + public class LocalStorageProviderStrategyTests + { + private string _rootPath; + + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), $"ShrinkDataSaverStrategyTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_rootPath); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + [Test] + public void DefaultConstructor_UsesFileReplace() + { + Assert.AreEqual(LocalStorageCommitStrategy.FileReplace, + new LocalStorageProvider(_rootPath).CommitStrategy); + } + + [UnityTest] + public IEnumerator RenameOnly_RotatesTwoBackups() => UniTask.ToCoroutine(async () => + { + var provider = new LocalStorageProvider(_rootPath, LocalStorageCommitStrategy.RenameOnly, TestMove); + await WriteAsync(provider, "one"); + await WriteAsync(provider, "two"); + await WriteAsync(provider, "three"); + + Assert.AreEqual("three", await ReadAsync(provider, "save.dat")); + Assert.AreEqual("two", await ReadAsync(provider, "save.dat.bak1")); + Assert.AreEqual("one", await ReadAsync(provider, "save.dat.bak2")); + }); + + [UnityTest] + public IEnumerator RenameOnly_RecoversPrimaryFromBak1_WhenPrimaryMissing() => UniTask.ToCoroutine(async () => + { + var provider = new LocalStorageProvider(_rootPath, LocalStorageCommitStrategy.RenameOnly, TestMove); + await WriteAsync(provider, "one"); + await WriteAsync(provider, "two"); + + var primary = Path.Combine(_rootPath, "save.dat"); + var primaryBackup = primary + ".bak1"; + File.Delete(primaryBackup); + File.Move(primary, primaryBackup); + + Assert.IsTrue(await provider.ExistsAsync("save.dat")); + Assert.AreEqual("two", await ReadAsync(provider, "save.dat")); + Assert.IsTrue(File.Exists(primary)); + Assert.IsFalse(File.Exists(primaryBackup)); + }); + + [UnityTest] + public IEnumerator RenameOnly_RecoversPrimaryFromBak2_WhenBak1Missing() => UniTask.ToCoroutine(async () => + { + var provider = new LocalStorageProvider(_rootPath, LocalStorageCommitStrategy.RenameOnly, TestMove); + await WriteAsync(provider, "one"); + await WriteAsync(provider, "two"); + await WriteAsync(provider, "three"); + + var primary = Path.Combine(_rootPath, "save.dat"); + File.Delete(primary); + File.Delete(primary + ".bak1"); + + Assert.AreEqual("one", await ReadAsync(provider, "save.dat")); + }); + + [UnityTest] + public IEnumerator RenameOnly_DeletesStaleTempBeforeWrite() => UniTask.ToCoroutine(async () => + { + var provider = new LocalStorageProvider(_rootPath, LocalStorageCommitStrategy.RenameOnly, TestMove); + await WriteAsync(provider, "one"); + var primary = Path.Combine(_rootPath, "save.dat"); + File.WriteAllText(primary + ".tmp", "stale", Encoding.UTF8); + + await WriteAsync(provider, "two"); + + Assert.IsFalse(File.Exists(primary + ".tmp")); + Assert.AreEqual("two", await ReadAsync(provider, "save.dat")); + }); + + [UnityTest] + public IEnumerator RenameOnly_PreservesSecondGeneration_WhenBak1Missing() => UniTask.ToCoroutine(async () => + { + var provider = new LocalStorageProvider(_rootPath, LocalStorageCommitStrategy.RenameOnly, TestMove); + await WriteAsync(provider, "one"); + await WriteAsync(provider, "two"); + await WriteAsync(provider, "three"); + + var primary = Path.Combine(_rootPath, "save.dat"); + File.Delete(primary + ".bak1"); + + await WriteAsync(provider, "four"); + + Assert.AreEqual("four", await ReadAsync(provider, "save.dat")); + Assert.AreEqual("three", await ReadAsync(provider, "save.dat.bak1")); + Assert.AreEqual("one", await ReadAsync(provider, "save.dat.bak2")); + }); + + [UnityTest] + public IEnumerator RenameOnly_DoesNotPromoteOrphanTemp() => UniTask.ToCoroutine(async () => + { + var provider = new LocalStorageProvider(_rootPath, LocalStorageCommitStrategy.RenameOnly, TestMove); + var primary = Path.Combine(_rootPath, "save.dat"); + File.WriteAllText(primary + ".tmp", "partial", new UTF8Encoding(false)); + + Assert.IsFalse(await provider.ExistsAsync("save.dat")); + Assert.IsFalse(File.Exists(primary)); + Assert.IsFalse(File.Exists(primary + ".tmp")); + + var readFailed = false; + try + { + await ReadAsync(provider, "save.dat"); + } + catch (FileNotFoundException) + { + readFailed = true; + } + + Assert.IsTrue(readFailed); + }); + + [UnityTest] + public IEnumerator RenameOnly_PrefersBackupOverPartialTemp() => UniTask.ToCoroutine(async () => + { + var provider = new LocalStorageProvider(_rootPath, LocalStorageCommitStrategy.RenameOnly, TestMove); + await WriteAsync(provider, "one"); + await WriteAsync(provider, "two"); + + var primary = Path.Combine(_rootPath, "save.dat"); + File.Delete(primary); + File.WriteAllText(primary + ".tmp", "partial", new UTF8Encoding(false)); + + Assert.IsTrue(await provider.ExistsAsync("save.dat")); + Assert.AreEqual("one", await ReadAsync(provider, "save.dat")); + Assert.IsFalse(File.Exists(primary + ".tmp")); + }); + + [UnityTest] + public IEnumerator FileReplace_DoesNotApplyRenameOnlyRecovery() => UniTask.ToCoroutine(async () => + { + var provider = new LocalStorageProvider(_rootPath); + await WriteAsync(provider, "one"); + await WriteAsync(provider, "two"); + + var primary = Path.Combine(_rootPath, "save.dat"); + File.Delete(primary); + File.WriteAllText(primary + ".tmp", "partial", new UTF8Encoding(false)); + + Assert.IsFalse(await provider.ExistsAsync("save.dat")); + Assert.IsTrue(File.Exists(primary + ".bak1")); + Assert.IsTrue(File.Exists(primary + ".tmp")); + + var readFailed = false; + try + { + await ReadAsync(provider, "save.dat"); + } + catch (FileNotFoundException) + { + readFailed = true; + } + + Assert.IsTrue(readFailed); + }); + + [UnityTest] + public IEnumerator DeleteAsync_RemovesBackupsAndTemp() => UniTask.ToCoroutine(async () => + { + var provider = new LocalStorageProvider(_rootPath, LocalStorageCommitStrategy.RenameOnly, TestMove); + await WriteAsync(provider, "one"); + await WriteAsync(provider, "two"); + var primary = Path.Combine(_rootPath, "save.dat"); + File.WriteAllText(primary + ".tmp", "stale", Encoding.UTF8); + + await provider.DeleteAsync("save.dat"); + + Assert.IsFalse(File.Exists(primary)); + Assert.IsFalse(File.Exists(primary + ".bak1")); + Assert.IsFalse(File.Exists(primary + ".bak2")); + Assert.IsFalse(File.Exists(primary + ".tmp")); + }); + + [UnityTest] + public IEnumerator PlatformInitialize_UsesInjectedStorage() => UniTask.ToCoroutine(async () => + { + var storage = new MockStorageProvider(); + ShrinkSettings.ResetForTesting(); + ShrinkDataSaverPlatform.Initialize(storage, "runtime-root"); + ShrinkSettings.Set("injected", 7); + await ShrinkSettings.SaveAsync(); + + Assert.IsTrue(await storage.ExistsAsync("runtime-root/settings.json")); + var bytes = await storage.ReadAsync("runtime-root/settings.json"); + StringAssert.Contains("\"injected\"", Encoding.UTF8.GetString(bytes)); + }); + + // Tests the shared rotation contract; Godot integration tests exercise actual overwrite rename. + private static void TestMove(string source, string destination) + { + if (File.Exists(destination)) File.Delete(destination); + File.Move(source, destination); + } + + [Test] + public void RenameOnly_RequiresOverwriteCapability() => Assert.Throws(() => + new LocalStorageProvider(_rootPath, LocalStorageCommitStrategy.RenameOnly)); + + private static async UniTask WriteAsync(IStorageProvider provider, string value) + { + await provider.WriteAsync("save.dat", Encoding.UTF8.GetBytes(value)); + } + + private static async UniTask ReadAsync(IStorageProvider provider, string path) + { + var bytes = await provider.ReadAsync(path); + return Encoding.UTF8.GetString(bytes); + } + } +} diff --git a/Tests/LocalStorageProviderStrategyTests.cs.meta b/Tests/LocalStorageProviderStrategyTests.cs.meta new file mode 100644 index 0000000..e7526c2 --- /dev/null +++ b/Tests/LocalStorageProviderStrategyTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f087803def7c483eab1449e4f5dce096 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/ShrinkSettingsTests.cs b/Tests/ShrinkSettingsTests.cs index a2de758..5a20910 100644 --- a/Tests/ShrinkSettingsTests.cs +++ b/Tests/ShrinkSettingsTests.cs @@ -1,5 +1,7 @@ using System.Collections; using System.Collections.Generic; +using System.IO; +using System.Threading; using Cysharp.Threading.Tasks; using NUnit.Framework; using UnityEngine; @@ -199,6 +201,48 @@ namespace ShrinkDataSaver.Tests CollectionAssert.AreEqual(backupBytes, repairedPrimary); }); + [UnityTest] + public IEnumerator LoadAsync_FallsBackToSecondaryBackup_WhenPrimaryAndFirstBackupCorrupted() => + UniTask.ToCoroutine(async () => + { + ShrinkSettings.Set("lang", "zh_cn"); + await ShrinkSettings.SaveAsync(); + + var backupBytes = await _storage.ReadAsync("settings.json"); + await _storage.WriteAsync("settings.json.bak2", backupBytes); + await _storage.WriteAsync("settings.json.bak1", System.Text.Encoding.UTF8.GetBytes("{ broken")); + await _storage.WriteAsync("settings.json", System.Text.Encoding.UTF8.GetBytes("{ broken")); + + ShrinkSettings.ResetForTesting(); + ShrinkSettings.Initialize(_storage, "settings.json"); + + await ShrinkSettings.LoadAsync(); + + Assert.AreEqual("zh_cn", ShrinkSettings.Get("lang")); + var repairedPrimary = await _storage.ReadAsync("settings.json"); + CollectionAssert.AreEqual(backupBytes, repairedPrimary); + }); + + [UnityTest] + public IEnumerator SaveAsync_PropagatesStorageFailure() => UniTask.ToCoroutine(async () => + { + ShrinkSettings.ResetForTesting(); + ShrinkSettings.Initialize(new FailingStorageProvider(), "settings.json"); + ShrinkSettings.Set("lang", "zh_cn"); + + var failed = false; + try + { + await ShrinkSettings.SaveAsync(); + } + catch (IOException) + { + failed = true; + } + + Assert.IsTrue(failed); + }); + // ── Overwrite ── [Test] @@ -208,5 +252,23 @@ namespace ShrinkDataSaver.Tests ShrinkSettings.Set("key", "second"); Assert.AreEqual("second", ShrinkSettings.Get("key")); } + + private sealed class FailingStorageProvider : IStorageProvider + { + public UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default) => + throw new IOException("write failed"); + + public UniTask ReadAsync(string path, CancellationToken ct = default) => + throw new FileNotFoundException(path); + + public UniTask ExistsAsync(string path, CancellationToken ct = default) => + UniTask.FromResult(false); + + public UniTask DeleteAsync(string path, CancellationToken ct = default) => + UniTask.CompletedTask; + + public UniTask ListAsync(string prefix = "", CancellationToken ct = default) => + UniTask.FromResult(System.Array.Empty()); + } } } diff --git a/package.json b/package.json index 619fd11..3d67401 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.cneicy.shrink-datasaver", - "version": "2.4.0", + "version": "2.5.0", "displayName": "ShrinkDataSaver", "description": "模块化 Unity 存档与设置管理系统,支持多槽位、链式版本迁移、AES-256 加密、关键模块保护、跨模块查询与事件驱动架构。", "unity": "2022.3", @@ -12,7 +12,7 @@ "com.cysharp.unitask": "2.5.0", "com.unity.modules.imageconversion": "1.0.0", "com.unity.modules.screencapture": "1.0.0", - "com.cneicy.shrink-runtime-abstractions": "0.1.0" + "com.cneicy.shrink-runtime-abstractions": "0.2.0" }, "keywords": [ "save",