From c2215b10cfbcd5e5458e5a1fab72da1b4f40ac7e Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 25 Nov 2024 22:02:11 -0500 Subject: [PATCH] Add extensive test scene for skin migrations This is different from `SkinDeserialisationTest` in that layouts can be written programmatically with as much ease, allowing to test migration logic with different scenarios without running the game and exporting skins and attaching them to tests. --- .../Visual/Skinning/TestSceneSkinMigration.cs | 225 ++++++++++++++++++ osu.Game/Skinning/SkinInfo.cs | 2 +- 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 osu.Game.Tests/Visual/Skinning/TestSceneSkinMigration.cs diff --git a/osu.Game.Tests/Visual/Skinning/TestSceneSkinMigration.cs b/osu.Game.Tests/Visual/Skinning/TestSceneSkinMigration.cs new file mode 100644 index 0000000000..934abca248 --- /dev/null +++ b/osu.Game.Tests/Visual/Skinning/TestSceneSkinMigration.cs @@ -0,0 +1,225 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Dummy; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.Models; +using osu.Game.Rulesets; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; + +namespace osu.Game.Tests.Visual.Skinning +{ + public partial class TestSceneSkinMigration : OsuTestScene + { + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Test] + public void TestEmptyConfiguration() + { + LegacySkin skin = null!; + + AddStep("load skin with empty configuration", () => skin = loadSkin()); + AddAssert("skin has no configuration", () => !skin.LayoutInfos.Any()); + } + + [Test] + public void TestSomeConfiguration() + { + LegacySkin skin = null!; + + AddStep("load skin with some configuration", () => + { + skin = loadSkin(new Dictionary + { + { GlobalSkinnableContainers.MainHUDComponents, createLayout(SkinLayoutInfo.LATEST_VERSION, [nameof(LegacyHealthDisplay)]) }, + }); + }); + + AddAssert("skin has correct configuration", () => + { + return skin.LayoutInfos.Single().Value.DrawableInfo["global"].Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox), nameof(LegacyHealthDisplay)]) && + skin.LayoutInfos.Single().Value.DrawableInfo.Where(d => d.Key != @"global") + .All(d => d.Value.Single().Type.Name == nameof(BigBlackBox)); + }); + } + + #region Version 1 + + [Test] + public void TestMigration_1() + { + LegacySkin skin = null!; + + AddStep("load skin", () => + { + skin = loadSkin(new Dictionary + { + { + GlobalSkinnableContainers.MainHUDComponents, + createLayout(0, [nameof(LegacyDefaultComboCounter)]) + }, + }); + }); + + AddAssert("combo counter removed from global", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"global").Value.Single().Type.Name == nameof(BigBlackBox); + }); + AddAssert("combo counter moved to each ruleset", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Where(kvp => kvp.Key != @"global").All(kvp => kvp.Value.Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox), nameof(LegacyDefaultComboCounter)])); + }); + } + + [Test] + public void TestMigration_1_NoComboCounter() + { + LegacySkin skin = null!; + + AddStep("load skin", () => + { + skin = loadSkin(new Dictionary + { + { + GlobalSkinnableContainers.MainHUDComponents, + createLayout(0) + }, + }); + }); + + AddAssert("nothing removed from global", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Single(kvp => kvp.Key == @"global").Value.Single().Type.Name == nameof(BigBlackBox); + }); + AddAssert("no combo counter added", () => + { + var dict = skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].DrawableInfo; + return dict.Where(kvp => kvp.Key != @"global").All(kvp => kvp.Value.Select(d => d.Type.Name).SequenceEqual([nameof(BigBlackBox)])); + }); + } + + #endregion + + private SkinLayoutInfo createLayout(int version, string[]? globalComponents = null, string? ruleset = null, string[]? rulesetComponents = null) + { + var info = new SkinLayoutInfo + { + Version = version, + DrawableInfo = + { + { "global", globalComponents?.Select(c => resolveComponent(c).CreateSerialisedInfo()).ToArray() ?? Array.Empty() }, + } + }; + + if (ruleset != null && rulesetComponents != null) + info.DrawableInfo.Add(ruleset, rulesetComponents.Select(c => resolveComponent(c).CreateSerialisedInfo()).ToArray()); + + // add random drawable to ensure nothing is incorrectly discarded + foreach (string key in rulesets.AvailableRulesets.Select(r => r.ShortName).Prepend(@"global")) + { + if (!info.DrawableInfo.TryGetValue(key, out var drawables)) + info.DrawableInfo.Add(key, drawables = Array.Empty()); + + info.DrawableInfo[key] = drawables.Prepend(new BigBlackBox().CreateSerialisedInfo()).ToArray(); + } + + return info; + } + + private Drawable resolveComponent(string name, string? ruleset = null) + { + var drawables = SerialisedDrawableInfo.GetAllAvailableDrawables(); + + if (ruleset != null) + drawables = drawables.Concat(SerialisedDrawableInfo.GetAllAvailableDrawables(rulesets.GetRuleset(ruleset))).ToArray(); + + return (Drawable)Activator.CreateInstance(drawables.Single(d => d.Name == name))!; + } + + private T loadSkin(IDictionary? layout = null) + where T : Skin + { + var info = new TestSkinInfo(typeof(T).GetInvariantInstantiationInfo(), layout); + return (T)info.CreateInstance(new TestStorageResourceProvider(layout, info.Files, Realm)); + } + + private class TestSkinInfo : SkinInfo + { + public override IList Files { get; } = new List(); + + public TestSkinInfo(string instantiationInfo, IDictionary? layout) + : base("test skin", "me", instantiationInfo) + { + if (layout != null) + { + foreach (var kvp in layout) + Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = Guid.NewGuid().ToString().ComputeMD5Hash() }, kvp.Key + ".json")); + } + } + } + + private class TestStorageResourceProvider : IStorageResourceProvider + { + public IRenderer Renderer { get; } = new DummyRenderer(); + public IResourceStore Resources { get; } = new ResourceStore(); + public IResourceStore? CreateTextureLoaderStore(IResourceStore underlyingStore) => null; + + public AudioManager? AudioManager => null; + + public IResourceStore Files { get; } + public RealmAccess RealmAccess { get; } + + public TestStorageResourceProvider(IDictionary? layout, IList files, RealmAccess realm) + { + Files = new TestResourceStore(layout, files); + RealmAccess = realm; + } + + private class TestResourceStore : ResourceStore + { + private readonly IDictionary? layout; + private readonly IList files; + + public TestResourceStore(IDictionary? layout, IList files) + { + this.layout = layout; + this.files = files; + } + + public override byte[] Get(string name) + { + string? filename = files.SingleOrDefault(f => f.File.GetStoragePath() == name)?.Filename; + if (filename == null || layout == null) + return base.Get(name); + + if (!Enum.TryParse(filename.Replace(@".json", string.Empty), out var type) || + !layout.TryGetValue(type, out var info)) + return base.Get(name); + + string json = JsonConvert.SerializeObject(info, new JsonSerializerSettings { Formatting = Formatting.Indented }); + return Encoding.UTF8.GetBytes(json); + } + } + } + } +} diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 9763d3b57e..c715f263c4 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -58,7 +58,7 @@ namespace osu.Game.Skinning return (Skin)Activator.CreateInstance(type, this, resources)!; } - public IList Files { get; } = null!; + public virtual IList Files { get; } = null!; public bool DeletePending { get; set; }