From 2a9c8d43df156ba2b6eb32c690eba4a80167a549 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 7 Jun 2017 02:08:20 -0400 Subject: add date utility --- src/StardewModdingAPI.Tests/SDateTests.cs | 112 +++++++++++++++++++++ .../StardewModdingAPI.Tests.csproj | 1 + 2 files changed, 113 insertions(+) create mode 100644 src/StardewModdingAPI.Tests/SDateTests.cs (limited to 'src/StardewModdingAPI.Tests') diff --git a/src/StardewModdingAPI.Tests/SDateTests.cs b/src/StardewModdingAPI.Tests/SDateTests.cs new file mode 100644 index 00000000..a4c65a98 --- /dev/null +++ b/src/StardewModdingAPI.Tests/SDateTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.RegularExpressions; +using NUnit.Framework; +using StardewModdingAPI.Utilities; + +namespace StardewModdingAPI.Tests +{ + /// Unit tests for . + [TestFixture] + internal class SDateTests + { + /********* + ** Properties + *********/ + /// All valid seasons. + private static string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; + + /// All valid days of a month. + private static int[] ValidDays = Enumerable.Range(1, 28).ToArray(); + + + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = "Assert that the constructor sets the expected values for all valid dates.")] + public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.ValidSeasons))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) + { + // act + SDate date = new SDate(day, season, year); + + // assert + Assert.AreEqual(day, date.Day); + Assert.AreEqual(season, date.Season); + Assert.AreEqual(year, date.Year); + } + + [Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] + [TestCase(01, "Spring", 1)] // seasons are case-sensitive + [TestCase(01, "springs", 1)] // invalid season name + [TestCase(-1, "spring", 1)] // day < 0 + [TestCase(29, "spring", 1)] // day > 28 + [TestCase(01, "spring", -1)] // year < 1 + [TestCase(01, "spring", 0)] // year < 1 + [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] + public void Constructor_RejectsInvalidValues(int day, string season, int year) + { + // act & assert + Assert.Throws(() => _ = new SDate(day, season, year), "Constructing the invalid date didn't throw the expected exception."); + } + + /**** + ** ToString + ****/ + [Test(Description = "Assert that ToString returns the expected string.")] + [TestCase("14 spring Y1", ExpectedResult = "14 spring Y1")] + [TestCase("01 summer Y16", ExpectedResult = "01 summer Y16")] + [TestCase("28 fall Y10", ExpectedResult = "28 fall Y10")] + [TestCase("01 winter Y1", ExpectedResult = "01 winter Y1")] + public string ToString(string dateStr) + { + return this.ParseDate(dateStr).ToString(); + } + + /**** + ** AddDays + ****/ + [Test(Description = "Assert that AddDays returns the expected date.")] + [TestCase("01 spring Y1", 15, ExpectedResult = "16 spring Y1")] // day transition + [TestCase("01 spring Y1", 28, ExpectedResult = "01 summer Y1")] // season transition + [TestCase("01 spring Y1", 28 * 4, ExpectedResult = "01 spring Y2")] // year transition + [TestCase("01 spring Y1", 28 * 7 + 17, ExpectedResult = "18 winter Y2")] // year transition + [TestCase("15 spring Y1", -14, ExpectedResult = "01 spring Y1")] // negative day transition + [TestCase("15 summer Y1", -28, ExpectedResult = "15 spring Y1")] // negative season transition + [TestCase("15 summer Y2", -28 * 4, ExpectedResult = "15 summer Y1")] // negative year transition + [TestCase("01 spring Y3", -(28 * 7 + 17), ExpectedResult = "12 spring Y1")] // negative year transition + public string AddDays(string dateStr, int addDays) + { + return this.ParseDate(dateStr).AddDays(addDays).ToString(); + } + + + /********* + ** Private methods + *********/ + /// Convert a string date into a game date, to make unit tests easier to read. + /// The date string like "dd MMMM yy". + private SDate ParseDate(string dateStr) + { + void Fail(string reason) => throw new AssertionException($"Couldn't parse date '{dateStr}' because {reason}."); + + // parse + Match match = Regex.Match(dateStr, @"^(?\d+) (?\w+) Y(?\d+)$"); + if (!match.Success) + Fail("it doesn't match expected pattern (should be like 28 spring Y1)"); + + // extract parts + string season = match.Groups["season"].Value; + if (!int.TryParse(match.Groups["day"].Value, out int day)) + Fail($"'{match.Groups["day"].Value}' couldn't be parsed as a day."); + if (!int.TryParse(match.Groups["year"].Value, out int year)) + Fail($"'{match.Groups["year"].Value}' couldn't be parsed as a year."); + + // build date + return new SDate(day, season, year); + } + } +} diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index 3818ec9c..3ddb1326 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -48,6 +48,7 @@ Properties\GlobalAssemblyInfo.cs + -- cgit From a4713ea88238e6a6d62447aef97b35321e63c010 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 12 Jun 2017 18:44:36 -0400 Subject: add separate list of obsolete mods --- release-notes.md | 6 ++++++ src/StardewModdingAPI.Tests/ModResolverTests.cs | 6 +++--- .../Framework/ModLoading/ModResolver.cs | 22 +++++++++++++++++----- .../Framework/Models/DisabledMod.cs | 22 ++++++++++++++++++++++ src/StardewModdingAPI/Framework/Models/SConfig.cs | 3 +++ src/StardewModdingAPI/Program.cs | 2 +- .../StardewModdingAPI.config.json | 20 ++++++++++++-------- src/StardewModdingAPI/StardewModdingAPI.csproj | 1 + 8 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 src/StardewModdingAPI/Framework/Models/DisabledMod.cs (limited to 'src/StardewModdingAPI.Tests') diff --git a/release-notes.md b/release-notes.md index e39ae3a8..f52e66cd 100644 --- a/release-notes.md +++ b/release-notes.md @@ -10,6 +10,12 @@ For mod developers: images). --> +## 1.15 +See [log](https://github.com/Pathoschild/SMAPI/compare/1.14...1.15). + +For players: +* SMAPI will no longer load mods known to be obsolete or unneeded. + ## 1.14 See [log](https://github.com/Pathoschild/SMAPI/compare/1.13...1.14). diff --git a/src/StardewModdingAPI.Tests/ModResolverTests.cs b/src/StardewModdingAPI.Tests/ModResolverTests.cs index 23aeba64..a9df2056 100644 --- a/src/StardewModdingAPI.Tests/ModResolverTests.cs +++ b/src/StardewModdingAPI.Tests/ModResolverTests.cs @@ -31,7 +31,7 @@ namespace StardewModdingAPI.Tests Directory.CreateDirectory(rootFolder); // act - IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0]).ToArray(); + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); // assert Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead."); @@ -46,7 +46,7 @@ namespace StardewModdingAPI.Tests Directory.CreateDirectory(modFolder); // act - IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0]).ToArray(); + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); IModMetadata mod = mods.FirstOrDefault(); // assert @@ -85,7 +85,7 @@ namespace StardewModdingAPI.Tests File.WriteAllText(filename, JsonConvert.SerializeObject(original)); // act - IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0]).ToArray(); + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); IModMetadata mod = mods.FirstOrDefault(); // assert diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index f5139ce5..e8308f3e 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -17,10 +17,13 @@ namespace StardewModdingAPI.Framework.ModLoading /// The root path to search for mods. /// The JSON helper with which to read manifests. /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + /// Metadata about mods that SMAPI should consider obsolete and not load. /// Returns the manifests by relative folder. - public IEnumerable ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable compatibilityRecords) + public IEnumerable ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable compatibilityRecords, IEnumerable disabledMods) { compatibilityRecords = compatibilityRecords.ToArray(); + disabledMods = disabledMods.ToArray(); + foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) { // read file @@ -47,20 +50,29 @@ namespace StardewModdingAPI.Framework.ModLoading error = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; } - // get compatibility record + // validate metadata ModCompatibility compatibility = null; if (manifest != null) { + // get unique key for lookups string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + + // check if mod should be disabled + DisabledMod disabledMod = disabledMods.FirstOrDefault(mod => mod.ID.Contains(key, StringComparer.InvariantCultureIgnoreCase)); + if (disabledMod != null) + error = $"it's obsolete: {disabledMod.ReasonPhrase}"; + + // get compatibility record compatibility = ( from mod in compatibilityRecords where - mod.ID.Contains(key, StringComparer.InvariantCultureIgnoreCase) - && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) - && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) + mod.ID.Contains(key, StringComparer.InvariantCultureIgnoreCase) + && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) + && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) select mod ).FirstOrDefault(); } + // build metadata string displayName = !string.IsNullOrWhiteSpace(manifest?.Name) ? manifest.Name diff --git a/src/StardewModdingAPI/Framework/Models/DisabledMod.cs b/src/StardewModdingAPI/Framework/Models/DisabledMod.cs new file mode 100644 index 00000000..170fa760 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/DisabledMod.cs @@ -0,0 +1,22 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// Metadata about for a mod that should never be loaded. + internal class DisabledMod + { + /********* + ** Accessors + *********/ + /**** + ** From config + ****/ + /// The unique mod IDs. + public string[] ID { get; set; } + + /// The mod name. + public string Name { get; set; } + + /// The reason phrase to show in the warning, or null to use the default value. + /// "this mod is no longer supported or used" + public string ReasonPhrase { get; set; } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/SConfig.cs b/src/StardewModdingAPI/Framework/Models/SConfig.cs index c3f0816e..b2ca4113 100644 --- a/src/StardewModdingAPI/Framework/Models/SConfig.cs +++ b/src/StardewModdingAPI/Framework/Models/SConfig.cs @@ -17,5 +17,8 @@ /// A list of mod versions which should be considered compatible or incompatible regardless of whether SMAPI detects incompatible code. public ModCompatibility[] ModCompatibility { get; set; } + + /// A list of mods which should be considered obsolete and not loaded. + public DisabledMod[] DisabledMods { get; set; } } } diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index d75d5193..71f09f5c 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -364,7 +364,7 @@ namespace StardewModdingAPI ModResolver resolver = new ModResolver(); // load manifests - IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, new JsonHelper(), this.Settings.ModCompatibility).ToArray(); + IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, new JsonHelper(), this.Settings.ModCompatibility, this.Settings.DisabledMods).ToArray(); resolver.ValidateManifests(mods, Constants.ApiVersion); // check for deprecated metadata diff --git a/src/StardewModdingAPI/StardewModdingAPI.config.json b/src/StardewModdingAPI/StardewModdingAPI.config.json index f62db90c..432a40e5 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.config.json +++ b/src/StardewModdingAPI/StardewModdingAPI.config.json @@ -26,6 +26,18 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha */ "VerboseLogging": false, + /** + * A list of mods SMAPI should consider obsolete and not load. Changing this field is not + * recommended and may destabilise your game. + */ + "DisabledMods": [ + { + "Name": "StarDustCore", + "ID": [ "StarDustCore" ], + "ReasonPhrase": "it was only used by earlier versions of Save Anywhere (which no longer uses it), and is no longer maintained." + } + ], + /** * A list of mod versions SMAPI should consider compatible or broken regardless of whether it * detects incompatible code. Each record can be set to `AssumeCompatible` or `AssumeBroken`. @@ -315,14 +327,6 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/798", "Notes": "Needs update for SDV 1.2." }, - { - "Name": "StarDustCore", - "ID": [ "StarDustCore" ], - "UpperVersion": "1.0", - "Compatibility": "AssumeBroken", - "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/683", - "Notes": "Obsolete (originally needed by Save Anywhere); broken in SDV 1.2." - }, { "Name": "Teleporter", "ID": [ "Teleporter" ], diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index 7cc537ac..0e832848 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -124,6 +124,7 @@ + -- cgit From 3c3953a7fdca6e79f50a4a5474be69ca6aab6446 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 18 Jun 2017 18:18:04 -0400 Subject: add support for minimum dependency versions (#286) --- release-notes.md | 1 + src/StardewModdingAPI.Tests/ModResolverTests.cs | 136 +++++++++++++++------ .../Framework/ModLoading/ModResolver.cs | 44 ++++--- .../Framework/Models/ManifestDependency.cs | 9 +- .../Serialisation/ManifestFieldConverter.cs | 3 +- src/StardewModdingAPI/IManifestDependency.cs | 3 + 6 files changed, 139 insertions(+), 57 deletions(-) (limited to 'src/StardewModdingAPI.Tests') diff --git a/release-notes.md b/release-notes.md index c75f0c19..8a8aa46e 100644 --- a/release-notes.md +++ b/release-notes.md @@ -17,6 +17,7 @@ For players: * SMAPI will no longer load mods known to be obsolete or unneeded. For modders: +* You can now specify minimum dependency versions in `manifest.json`. * Added `System.ValueTuple.dll` to the SMAPI install package so mods can use [C# 7 value tuples](https://docs.microsoft.com/en-us/dotnet/csharp/tuples). ## 1.14 diff --git a/src/StardewModdingAPI.Tests/ModResolverTests.cs b/src/StardewModdingAPI.Tests/ModResolverTests.cs index a9df2056..4afba162 100644 --- a/src/StardewModdingAPI.Tests/ModResolverTests.cs +++ b/src/StardewModdingAPI.Tests/ModResolverTests.cs @@ -160,7 +160,7 @@ namespace StardewModdingAPI.Tests Mock mock = new Mock(MockBehavior.Strict); mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); mock.Setup(p => p.Compatibility).Returns(() => null); - mock.Setup(p => p.Manifest).Returns(this.GetRandomManifest(m => m.MinimumApiVersion = "1.1")); + mock.Setup(p => p.Manifest).Returns(this.GetManifest(m => m.MinimumApiVersion = "1.1")); mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); // act @@ -177,7 +177,7 @@ namespace StardewModdingAPI.Tests Mock mock = new Mock(MockBehavior.Strict); mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); mock.Setup(p => p.Compatibility).Returns(() => null); - mock.Setup(p => p.Manifest).Returns(this.GetRandomManifest()); + mock.Setup(p => p.Manifest).Returns(this.GetManifest()); mock.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath()); mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); @@ -192,7 +192,7 @@ namespace StardewModdingAPI.Tests public void ValidateManifests_Valid_Passes() { // set up manifest - IManifest manifest = this.GetRandomManifest(); + IManifest manifest = this.GetManifest(); // create DLL string modFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); @@ -231,9 +231,9 @@ namespace StardewModdingAPI.Tests { // arrange // A B C - Mock modA = this.GetMetadataForDependencyTest("Mod A"); - Mock modB = this.GetMetadataForDependencyTest("Mod B"); - Mock modC = this.GetMetadataForDependencyTest("Mod C"); + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B"); + Mock modC = this.GetMetadata("Mod C"); // act IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }).ToArray(); @@ -267,9 +267,9 @@ namespace StardewModdingAPI.Tests // ▲ ▲ // │ │ // └─ C ─┘ - Mock modA = this.GetMetadataForDependencyTest("Mod A"); - Mock modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod A", "Mod B" }); + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod A", "Mod B" }); // act IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }).ToArray(); @@ -286,10 +286,10 @@ namespace StardewModdingAPI.Tests { // arrange // A ◀── B ◀── C ◀── D - Mock modA = this.GetMetadataForDependencyTest("Mod A"); - Mock modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }); - Mock modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod C" }); + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); + Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); // act IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray(); @@ -310,12 +310,12 @@ namespace StardewModdingAPI.Tests // ▲ ▲ // │ │ // E ◀── F - Mock modA = this.GetMetadataForDependencyTest("Mod A"); - Mock modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }); - Mock modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod C" }); - Mock modE = this.GetMetadataForDependencyTest("Mod E", dependencies: new[] { "Mod B" }); - Mock modF = this.GetMetadataForDependencyTest("Mod F", dependencies: new[] { "Mod C", "Mod E" }); + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); + Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); + Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod B" }); + Mock modF = this.GetMetadata("Mod F", dependencies: new[] { "Mod C", "Mod E" }); // act IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }).ToArray(); @@ -338,11 +338,11 @@ namespace StardewModdingAPI.Tests // ▲ │ // │ ▼ // └──── E - Mock modA = this.GetMetadataForDependencyTest("Mod A"); - Mock modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true); - Mock modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true); - Mock modE = this.GetMetadataForDependencyTest("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true); + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true); + Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true); + Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true); // act IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modE.Object }).ToArray(); @@ -361,9 +361,9 @@ namespace StardewModdingAPI.Tests { // arrange // A ◀── B ◀── C D (failed) - Mock modA = this.GetMetadataForDependencyTest("Mod A"); - Mock modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true); + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true); Mock modD = new Mock(MockBehavior.Strict); modD.Setup(p => p.Manifest).Returns(null); modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); @@ -378,13 +378,47 @@ namespace StardewModdingAPI.Tests Assert.AreSame(modB.Object, mods[2], "The load order is incorrect: mod B should be third since it needs mod A, and is needed by mod C."); Assert.AreSame(modC.Object, mods[3], "The load order is incorrect: mod C should be fourth since it needs mod B, and is needed by mod D."); } + + [Test(Description = "Assert that dependencies are failed if they don't meet the minimum version.")] + public void ProcessDependencies_WithMinVersions_FailsIfNotMet() + { + // arrange + // A 1.0 ◀── B (need A 1.1) + Mock modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); + Mock modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.1")), allowStatusChange: true); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray(); + + // assert + Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); + } + + [Test(Description = "Assert that dependencies are accepted if they meet the minimum version.")] + public void ProcessDependencies_WithMinVersions_SucceedsIfMet() + { + // arrange + // A 1.0 ◀── B (need A 1.0-beta) + Mock modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); + Mock modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0-beta")), allowStatusChange: false); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray(); + + // assert + Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); + } + /********* ** Private methods *********/ /// Get a randomised basic manifest. /// Adjust the generated manifest. - private Manifest GetRandomManifest(Action adjust = null) + private Manifest GetManifest(Action adjust = null) { Manifest manifest = new Manifest { @@ -401,26 +435,50 @@ namespace StardewModdingAPI.Tests /// Get a randomised basic manifest. /// The mod's name and unique ID. + /// The mod version. /// The dependencies this mod requires. + private IManifest GetManifest(string uniqueID, string version, params IManifestDependency[] dependencies) + { + return this.GetManifest(manifest => + { + manifest.Name = uniqueID; + manifest.UniqueID = uniqueID; + manifest.Version = new SemanticVersion(version); + manifest.Dependencies = dependencies; + }); + } + + /// Get a randomised basic manifest. + /// The mod's name and unique ID. + private Mock GetMetadata(string uniqueID) + { + return this.GetMetadata(this.GetManifest(uniqueID, "1.0")); + } + + /// Get a randomised basic manifest. + /// The mod's name and unique ID. + /// The dependencies this mod requires. + /// Whether the code being tested is allowed to change the mod status. + private Mock GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false) + { + IManifest manifest = this.GetManifest(uniqueID, "1.0", dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray()); + return this.GetMetadata(manifest, allowStatusChange); + } + + /// Get a randomised basic manifest. + /// The mod manifest. /// Whether the code being tested is allowed to change the mod status. - private Mock GetMetadataForDependencyTest(string uniqueID, string[] dependencies = null, bool allowStatusChange = false) + private Mock GetMetadata(IManifest manifest, bool allowStatusChange = false) { Mock mod = new Mock(MockBehavior.Strict); mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mod.Setup(p => p.DisplayName).Returns(uniqueID); - mod.Setup(p => p.Manifest).Returns( - this.GetRandomManifest(manifest => - { - manifest.Name = uniqueID; - manifest.UniqueID = uniqueID; - manifest.Dependencies = dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID)).ToArray(); - }) - ); + mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID); + mod.Setup(p => p.Manifest).Returns(manifest); if (allowStatusChange) { mod .Setup(p => p.SetStatus(It.IsAny(), It.IsAny())) - .Callback((status, message) => Console.WriteLine($"<{uniqueID} changed status: [{status}] {message}")) + .Callback((status, message) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}")) .Returns(mod.Object); } return mod; diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index e8308f3e..dc140483 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -205,20 +205,40 @@ namespace StardewModdingAPI.Framework.ModLoading return states[mod] = ModDependencyStatus.Sorted; } + // get dependencies + var dependencies = + ( + from entry in mod.Manifest.Dependencies + let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase)) + orderby entry.UniqueID + select (ID: entry.UniqueID, MinVersion: entry.MinimumVersion, Mod: dependencyMod) + ) + .ToArray(); + // missing required dependencies, mark failed { - string[] missingModIDs = + string[] failedIDs = (from entry in dependencies where entry.Mod == null select entry.ID).ToArray(); + if (failedIDs.Any()) + { + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", failedIDs)})."); + return states[mod] = ModDependencyStatus.Failed; + } + } + + // dependency min version not met, mark failed + { + string[] failedLabels = ( - from dependency in mod.Manifest.Dependencies - where mods.All(m => m.Manifest?.UniqueID != dependency.UniqueID) - orderby dependency.UniqueID - select dependency.UniqueID + from entry in dependencies + where entry.MinVersion != null && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version) + select $"{entry.Mod.DisplayName} (needs {entry.MinVersion} or later)" ) .ToArray(); - if (missingModIDs.Any()) + if (failedLabels.Any()) { sortedMods.Push(mod); - mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", missingModIDs)})."); + mod.SetStatus(ModMetadataStatus.Failed, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}."); return states[mod] = ModDependencyStatus.Failed; } } @@ -227,16 +247,8 @@ namespace StardewModdingAPI.Framework.ModLoading { states[mod] = ModDependencyStatus.Checking; - // get mods to load first - IModMetadata[] modsToLoadFirst = - ( - from other in mods - where mod.Manifest.Dependencies.Any(required => required.UniqueID == other.Manifest?.UniqueID) - select other - ) - .ToArray(); - // recursively sort dependencies + IModMetadata[] modsToLoadFirst = dependencies.Select(p => p.Mod).ToArray(); foreach (IModMetadata requiredMod in modsToLoadFirst) { var subchain = new List(currentChain) { mod }; diff --git a/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs index 2f580c1d..a0ff0c90 100644 --- a/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs +++ b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs @@ -9,15 +9,22 @@ /// The unique mod ID to require. public string UniqueID { get; set; } + /// The minimum required version (if any). + public ISemanticVersion MinimumVersion { get; set; } + /********* ** Public methods *********/ /// Construct an instance. /// The unique mod ID to require. - public ManifestDependency(string uniqueID) + /// The minimum required version (if any). + public ManifestDependency(string uniqueID, string minimumVersion) { this.UniqueID = uniqueID; + this.MinimumVersion = !string.IsNullOrWhiteSpace(minimumVersion) + ? new SemanticVersion(minimumVersion) + : null; } } } diff --git a/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs index 6b5a6aaa..7acb5fd0 100644 --- a/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs +++ b/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs @@ -51,7 +51,8 @@ namespace StardewModdingAPI.Framework.Serialisation foreach (JObject obj in JArray.Load(reader).Children()) { string uniqueID = obj.Value(nameof(IManifestDependency.UniqueID)); - result.Add(new ManifestDependency(uniqueID)); + string minVersion = obj.Value(nameof(IManifestDependency.MinimumVersion)); + result.Add(new ManifestDependency(uniqueID, minVersion)); } return result.ToArray(); } diff --git a/src/StardewModdingAPI/IManifestDependency.cs b/src/StardewModdingAPI/IManifestDependency.cs index 7bd2e8b6..ebb1140e 100644 --- a/src/StardewModdingAPI/IManifestDependency.cs +++ b/src/StardewModdingAPI/IManifestDependency.cs @@ -8,5 +8,8 @@ *********/ /// The unique mod ID to require. string UniqueID { get; } + + /// The minimum required version (if any). + ISemanticVersion MinimumVersion { get; } } } -- cgit From 230ab1738af34c89e9c308d2ce4976d4ae8dbe70 Mon Sep 17 00:00:00 2001 From: Nicholas Johnson Date: Fri, 16 Jun 2017 03:47:36 -0700 Subject: - This adds in operators to SDate. And Tests. And a NUnit Adapter - sorry about the latter.. --- src/StardewModdingAPI.Tests/SDateTests.cs | 41 ++++++ .../StardewModdingAPI.Tests.csproj | 3 + src/StardewModdingAPI.Tests/packages.config | 1 + src/StardewModdingAPI/Utilities/SDate.cs | 150 +++++++++++++++++++++ 4 files changed, 195 insertions(+) (limited to 'src/StardewModdingAPI.Tests') diff --git a/src/StardewModdingAPI.Tests/SDateTests.cs b/src/StardewModdingAPI.Tests/SDateTests.cs index a4c65a98..1cce6335 100644 --- a/src/StardewModdingAPI.Tests/SDateTests.cs +++ b/src/StardewModdingAPI.Tests/SDateTests.cs @@ -83,6 +83,47 @@ namespace StardewModdingAPI.Tests return this.ParseDate(dateStr).AddDays(addDays).ToString(); } + [Test(Description = "Assert that the equality operators work as expected")] + public void EqualityOperators() + { + SDate s1 = new SDate(1, "spring", 2); + SDate s2 = new SDate(1, "spring", 2); + SDate s3 = new SDate(1, "spring", 3); + SDate s4 = new SDate(12, "spring", 2); + SDate s5 = new SDate(1, "summer", 2); + + Assert.AreEqual(true, s1 == s2); + Assert.AreNotEqual(true, s1 == s3); + Assert.AreNotEqual(true, s1 == s4); + Assert.AreNotEqual(true, s1 == s5); + } + + [Test(Description = "Assert that the comparison operators work as expected")] + public void ComparisonOperators() + { + SDate s1 = new SDate(1, "spring", 2); + SDate s2 = new SDate(1, "spring", 2); + SDate s3 = new SDate(1, "spring", 3); + SDate s4 = new SDate(12, "spring", 2); + SDate s5 = new SDate(1, "summer", 2); + SDate s6 = new SDate(1, "winter", 1); + SDate s7 = new SDate(13, "fall", 1); + + Assert.AreEqual(true, s1 <= s2); + Assert.AreEqual(true, s1 >= s2); + Assert.AreEqual(true, s1 < s4); + Assert.AreEqual(true, s1 <= s4); + Assert.AreEqual(true, s4 > s1); + Assert.AreEqual(true, s4 >= s1); + Assert.AreEqual(true, s5 > s7); + Assert.AreEqual(true, s5 >= s7); + Assert.AreEqual(true, s6 < s5); + Assert.AreEqual(true, s6 <= s5); + Assert.AreEqual(true, s1 < s5); + Assert.AreEqual(true, s1 <= s5); + Assert.AreEqual(true, s5 > s1); + Assert.AreEqual(true, s5 >= s1); + } /********* ** Private methods diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index 3ddb1326..fbce657d 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -63,6 +63,9 @@ StardewModdingAPI + + + \ No newline at end of file diff --git a/src/StardewModdingAPI.Tests/packages.config b/src/StardewModdingAPI.Tests/packages.config index ba954308..d25dae06 100644 --- a/src/StardewModdingAPI.Tests/packages.config +++ b/src/StardewModdingAPI.Tests/packages.config @@ -4,4 +4,5 @@ + \ No newline at end of file diff --git a/src/StardewModdingAPI/Utilities/SDate.cs b/src/StardewModdingAPI/Utilities/SDate.cs index 4729bfb9..fdeffe80 100644 --- a/src/StardewModdingAPI/Utilities/SDate.cs +++ b/src/StardewModdingAPI/Utilities/SDate.cs @@ -113,6 +113,156 @@ namespace StardewModdingAPI.Utilities return new SDate(Game1.dayOfMonth, Game1.currentSeason, Game1.year); } + /********* + ** Operator methods + *********/ + + /// + /// Equality operator. Tests the date being equal to each other + /// + /// The first date being compared + /// The second date being compared + /// The equality of the dates + public static bool operator ==(SDate s1, SDate s2) + { + if (s1.Day == s2.Day && s1.Year == s2.Year && s1.Season == s2.Season) + return true; + else + return false; + } + + /// + /// Inequality operator. Tests the date being not equal to each other + /// + /// The first date being compared + /// The second date being compared + /// The inequality of the dates + public static bool operator !=(SDate s1, SDate s2) + { + if (s1.Day == s2.Day && s1.Year == s2.Year && s1.Season == s2.Season) + return false; + else + return true; + } + + /// + /// Less than operator. Tests the date being less than to each other + /// + /// The first date being compared + /// The second date being compared + /// If the dates are less than + public static bool operator >(SDate s1, SDate s2) + { + if (s1.Year > s2.Year) + return true; + else if (s1.Year == s2.Year) + { + if (s1.Season == "winter" && s2.Season != "winter") + return true; + else if (s1.Season == s2.Season && s1.Day > s2.Day) + return true; + if (s1.Season == "fall" && (s2.Season == "summer" || s2.Season == "spring")) + return true; + if (s1.Season == "summer" && s2.Season == "spring") + return true; + } + + return false; + } + + /// + /// Less or equal than operator. Tests the date being less than or equal to each other + /// + /// The first date being compared + /// The second date being compared + /// If the dates are less than or equal than + public static bool operator >=(SDate s1, SDate s2) + { + if (s1.Year > s2.Year) + return true; + else if (s1.Year == s2.Year) + { + if (s1.Season == "winter" && s2.Season != "winter") + return true; + else if (s1.Season == s2.Season && s1.Day >= s2.Day) + return true; + if (s1.Season == "fall" && (s2.Season == "summer" || s2.Season == "spring")) + return true; + if (s1.Season == "summer" && s2.Season == "spring") + return true; + } + + return false; + } + + /// + /// Greater or equal than operator. Tests the date being greater than or equal to each other + /// + /// The first date being compared + /// The second date being compared + /// If the dates are greater than or equal than + public static bool operator <=(SDate s1, SDate s2) + { + if (s1.Year < s2.Year) + return true; + else if (s1.Year == s2.Year) + { + if (s1.Season == s2.Season && s1.Day <= s2.Day) + return true; + else if (s1.Season == "spring" && s2.Season != "spring") + return true; + if (s1.Season == "summer" && (s2.Season == "fall" || s2.Season == "winter")) + return true; + if (s1.Season == "fall" && s2.Season == "winter") + return true; + } + + return false; + } + + /// + /// Greater than operator. Tests the date being greater than to each other + /// + /// The first date being compared + /// The second date being compared + /// If the dates are greater than + public static bool operator <(SDate s1, SDate s2) + { + if (s1.Year < s2.Year) + return true; + else if (s1.Year == s2.Year) + { + if (s1.Season == s2.Season && s1.Day < s2.Day) + return true; + else if (s1.Season == "spring" && s2.Season != "spring") + return true; + if (s1.Season == "summer" && (s2.Season == "fall" || s2.Season == "winter")) + return true; + if (s1.Season == "fall" && s2.Season == "winter") + return true; + } + + return false; + } + + /// + /// Overrides the equals function. + /// + /// Object being compared. + /// The equalaity of the object. + public override bool Equals(object obj) + { + return base.Equals(obj); + } + + /// + /// This returns the hashcode of the object + /// + /// The hashcode of the object. + public override int GetHashCode() + { + return base.GetHashCode(); + } /********* ** Private methods -- cgit From 9c22c2378fd42ddd78095c077b5ebab54df1df6b Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 18 Jun 2017 20:22:35 -0400 Subject: remove test adapter (#307) --- src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj | 3 --- src/StardewModdingAPI.Tests/packages.config | 1 - 2 files changed, 4 deletions(-) (limited to 'src/StardewModdingAPI.Tests') diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index fbce657d..3ddb1326 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -63,9 +63,6 @@ StardewModdingAPI - - - \ No newline at end of file diff --git a/src/StardewModdingAPI.Tests/packages.config b/src/StardewModdingAPI.Tests/packages.config index d25dae06..ba954308 100644 --- a/src/StardewModdingAPI.Tests/packages.config +++ b/src/StardewModdingAPI.Tests/packages.config @@ -4,5 +4,4 @@ - \ No newline at end of file -- cgit From 7e815911e2880b3139846fdc6aed6e5cf0a8e994 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 18 Jun 2017 20:23:15 -0400 Subject: add tuples to test project (#307) --- src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj | 3 +++ src/StardewModdingAPI.Tests/packages.config | 1 + 2 files changed, 4 insertions(+) (limited to 'src/StardewModdingAPI.Tests') diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index 3ddb1326..a50d23b3 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -43,6 +43,9 @@ ..\packages\NUnit.3.6.1\lib\net45\nunit.framework.dll + + ..\packages\System.ValueTuple.4.3.1\lib\netstandard1.0\System.ValueTuple.dll + diff --git a/src/StardewModdingAPI.Tests/packages.config b/src/StardewModdingAPI.Tests/packages.config index ba954308..7ba8c7b2 100644 --- a/src/StardewModdingAPI.Tests/packages.config +++ b/src/StardewModdingAPI.Tests/packages.config @@ -4,4 +4,5 @@ + \ No newline at end of file -- cgit From 0a8c07cc0773a1e3c109a3ccfa8b95896b7d75a8 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 18 Jun 2017 20:24:32 -0400 Subject: simplify date operators by making SDate.GetHashCode() return unique ordered values, expand unit tests (#307) --- src/StardewModdingAPI.Tests/SDateTests.cs | 182 +++++++++++++++++++++++------- src/StardewModdingAPI/Utilities/SDate.cs | 180 +++++++++-------------------- 2 files changed, 197 insertions(+), 165 deletions(-) (limited to 'src/StardewModdingAPI.Tests') diff --git a/src/StardewModdingAPI.Tests/SDateTests.cs b/src/StardewModdingAPI.Tests/SDateTests.cs index 1cce6335..fa898918 100644 --- a/src/StardewModdingAPI.Tests/SDateTests.cs +++ b/src/StardewModdingAPI.Tests/SDateTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; @@ -15,10 +16,35 @@ namespace StardewModdingAPI.Tests ** Properties *********/ /// All valid seasons. - private static string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; + private static readonly string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; /// All valid days of a month. - private static int[] ValidDays = Enumerable.Range(1, 28).ToArray(); + private static readonly int[] ValidDays = Enumerable.Range(1, 28).ToArray(); + + /// Sample relative dates for test cases. + private static class Dates + { + /// The base date to which other dates are relative. + public const string Now = "02 summer Y2"; + + /// The day before . + public const string PrevDay = "01 summer Y2"; + + /// The month before . + public const string PrevMonth = "02 spring Y2"; + + /// The year before . + public const string PrevYear = "02 summer Y1"; + + /// The day after . + public const string NextDay = "03 summer Y2"; + + /// The month after . + public const string NextMonth = "02 fall Y2"; + + /// The year after . + public const string NextYear = "02 summer Y3"; + } /********* @@ -63,7 +89,7 @@ namespace StardewModdingAPI.Tests [TestCase("01 winter Y1", ExpectedResult = "01 winter Y1")] public string ToString(string dateStr) { - return this.ParseDate(dateStr).ToString(); + return this.GetDate(dateStr).ToString(); } /**** @@ -80,58 +106,132 @@ namespace StardewModdingAPI.Tests [TestCase("01 spring Y3", -(28 * 7 + 17), ExpectedResult = "12 spring Y1")] // negative year transition public string AddDays(string dateStr, int addDays) { - return this.ParseDate(dateStr).AddDays(addDays).ToString(); + return this.GetDate(dateStr).AddDays(addDays).ToString(); + } + + /**** + ** GetHashCode + ****/ + [Test(Description = "Assert that GetHashCode returns a unique ordered value for every date.")] + public void GetHashCode_ReturnsUniqueOrderedValue() + { + IDictionary hashes = new Dictionary(); + int lastHash = int.MinValue; + for (int year = 1; year <= 4; year++) + { + foreach (string season in SDateTests.ValidSeasons) + { + foreach (int day in SDateTests.ValidDays) + { + SDate date = new SDate(day, season, year); + int hash = date.GetHashCode(); + if (hashes.TryGetValue(hash, out SDate otherDate)) + Assert.Fail($"Received identical hash code {hash} for dates {otherDate} and {date}."); + if (hash < lastHash) + Assert.Fail($"Received smaller hash code for date {date} ({hash}) relative to {hashes[lastHash]} ({lastHash})."); + + lastHash = hash; + hashes[hash] = date; + } + } + } } - [Test(Description = "Assert that the equality operators work as expected")] - public void EqualityOperators() + [Test(Description = "Assert that the == operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_Equals(string now, string other) { - SDate s1 = new SDate(1, "spring", 2); - SDate s2 = new SDate(1, "spring", 2); - SDate s3 = new SDate(1, "spring", 3); - SDate s4 = new SDate(12, "spring", 2); - SDate s5 = new SDate(1, "summer", 2); - - Assert.AreEqual(true, s1 == s2); - Assert.AreNotEqual(true, s1 == s3); - Assert.AreNotEqual(true, s1 == s4); - Assert.AreNotEqual(true, s1 == s5); + return this.GetDate(now) == this.GetDate(other); } - [Test(Description = "Assert that the comparison operators work as expected")] - public void ComparisonOperators() + [Test(Description = "Assert that the != operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_NotEquals(string now, string other) { - SDate s1 = new SDate(1, "spring", 2); - SDate s2 = new SDate(1, "spring", 2); - SDate s3 = new SDate(1, "spring", 3); - SDate s4 = new SDate(12, "spring", 2); - SDate s5 = new SDate(1, "summer", 2); - SDate s6 = new SDate(1, "winter", 1); - SDate s7 = new SDate(13, "fall", 1); - - Assert.AreEqual(true, s1 <= s2); - Assert.AreEqual(true, s1 >= s2); - Assert.AreEqual(true, s1 < s4); - Assert.AreEqual(true, s1 <= s4); - Assert.AreEqual(true, s4 > s1); - Assert.AreEqual(true, s4 >= s1); - Assert.AreEqual(true, s5 > s7); - Assert.AreEqual(true, s5 >= s7); - Assert.AreEqual(true, s6 < s5); - Assert.AreEqual(true, s6 <= s5); - Assert.AreEqual(true, s1 < s5); - Assert.AreEqual(true, s1 <= s5); - Assert.AreEqual(true, s5 > s1); - Assert.AreEqual(true, s5 >= s1); + return this.GetDate(now) != this.GetDate(other); } + [Test(Description = "Assert that the < operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_LessThan(string now, string other) + { + return this.GetDate(now) < this.GetDate(other); + } + + [Test(Description = "Assert that the <= operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_LessThanOrEqual(string now, string other) + { + return this.GetDate(now) <= this.GetDate(other); + } + + [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_MoreThan(string now, string other) + { + return this.GetDate(now) > this.GetDate(other); + } + + [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_MoreThanOrEqual(string now, string other) + { + return this.GetDate(now) > this.GetDate(other); + } + + /********* ** Private methods *********/ /// Convert a string date into a game date, to make unit tests easier to read. /// The date string like "dd MMMM yy". - private SDate ParseDate(string dateStr) + private SDate GetDate(string dateStr) { + if (dateStr == null) + return null; + void Fail(string reason) => throw new AssertionException($"Couldn't parse date '{dateStr}' because {reason}."); // parse diff --git a/src/StardewModdingAPI/Utilities/SDate.cs b/src/StardewModdingAPI/Utilities/SDate.cs index fdeffe80..5f7ff030 100644 --- a/src/StardewModdingAPI/Utilities/SDate.cs +++ b/src/StardewModdingAPI/Utilities/SDate.cs @@ -13,6 +13,9 @@ namespace StardewModdingAPI.Utilities /// The internal season names in order. private readonly string[] Seasons = { "spring", "summer", "fall", "winter" }; + /// The number of seasons in a year. + private int SeasonsInYear => this.Seasons.Length; + /// The number of days in a season. private readonly int DaysInSeason = 28; @@ -77,10 +80,8 @@ namespace StardewModdingAPI.Utilities // handle season transition if (day > this.DaysInSeason || day < 1) { - // get current season index - int curSeasonIndex = Array.IndexOf(this.Seasons, this.Season); - if (curSeasonIndex == -1) - throw new InvalidOperationException($"The current season '{this.Season}' wasn't recognised."); + // get season index + int curSeasonIndex = this.GetSeasonIndex(); // get season offset int seasonOffset = day / this.DaysInSeason; @@ -94,7 +95,7 @@ namespace StardewModdingAPI.Utilities } // validate - if(year < 1) + if (year < 1) throw new ArithmeticException($"Adding {offset} days to {this} would result in invalid date {day:00} {season} {year}."); // return new date @@ -116,157 +117,88 @@ namespace StardewModdingAPI.Utilities /********* ** Operator methods *********/ - - /// - /// Equality operator. Tests the date being equal to each other - /// - /// The first date being compared - /// The second date being compared + /// Get whether one date is equal to another. + /// The base date to compare. + /// The other date to compare. /// The equality of the dates - public static bool operator ==(SDate s1, SDate s2) + public static bool operator ==(SDate date, SDate other) { - if (s1.Day == s2.Day && s1.Year == s2.Year && s1.Season == s2.Season) - return true; - else - return false; + return date?.GetHashCode() == other?.GetHashCode(); } - /// - /// Inequality operator. Tests the date being not equal to each other - /// - /// The first date being compared - /// The second date being compared - /// The inequality of the dates - public static bool operator !=(SDate s1, SDate s2) + /// Get whether one date is not equal to another. + /// The base date to compare. + /// The other date to compare. + public static bool operator !=(SDate date, SDate other) { - if (s1.Day == s2.Day && s1.Year == s2.Year && s1.Season == s2.Season) - return false; - else - return true; + return date?.GetHashCode() != other?.GetHashCode(); } - /// - /// Less than operator. Tests the date being less than to each other - /// - /// The first date being compared - /// The second date being compared - /// If the dates are less than - public static bool operator >(SDate s1, SDate s2) + /// Get whether one date is more than another. + /// The base date to compare. + /// The other date to compare. + public static bool operator >(SDate date, SDate other) { - if (s1.Year > s2.Year) - return true; - else if (s1.Year == s2.Year) - { - if (s1.Season == "winter" && s2.Season != "winter") - return true; - else if (s1.Season == s2.Season && s1.Day > s2.Day) - return true; - if (s1.Season == "fall" && (s2.Season == "summer" || s2.Season == "spring")) - return true; - if (s1.Season == "summer" && s2.Season == "spring") - return true; - } - - return false; + return date?.GetHashCode() > other?.GetHashCode(); } - /// - /// Less or equal than operator. Tests the date being less than or equal to each other - /// - /// The first date being compared - /// The second date being compared - /// If the dates are less than or equal than - public static bool operator >=(SDate s1, SDate s2) + /// Get whether one date is more than or equal to another. + /// The base date to compare. + /// The other date to compare. + public static bool operator >=(SDate date, SDate other) { - if (s1.Year > s2.Year) - return true; - else if (s1.Year == s2.Year) - { - if (s1.Season == "winter" && s2.Season != "winter") - return true; - else if (s1.Season == s2.Season && s1.Day >= s2.Day) - return true; - if (s1.Season == "fall" && (s2.Season == "summer" || s2.Season == "spring")) - return true; - if (s1.Season == "summer" && s2.Season == "spring") - return true; - } - - return false; + return date?.GetHashCode() >= other?.GetHashCode(); } - /// - /// Greater or equal than operator. Tests the date being greater than or equal to each other - /// - /// The first date being compared - /// The second date being compared - /// If the dates are greater than or equal than - public static bool operator <=(SDate s1, SDate s2) + /// Get whether one date is less than or equal to another. + /// The base date to compare. + /// The other date to compare. + public static bool operator <=(SDate date, SDate other) { - if (s1.Year < s2.Year) - return true; - else if (s1.Year == s2.Year) - { - if (s1.Season == s2.Season && s1.Day <= s2.Day) - return true; - else if (s1.Season == "spring" && s2.Season != "spring") - return true; - if (s1.Season == "summer" && (s2.Season == "fall" || s2.Season == "winter")) - return true; - if (s1.Season == "fall" && s2.Season == "winter") - return true; - } - - return false; + return date?.GetHashCode() <= other?.GetHashCode(); } - /// - /// Greater than operator. Tests the date being greater than to each other - /// - /// The first date being compared - /// The second date being compared - /// If the dates are greater than - public static bool operator <(SDate s1, SDate s2) + /// Get whether one date is less than another. + /// The base date to compare. + /// The other date to compare. + public static bool operator <(SDate date, SDate other) { - if (s1.Year < s2.Year) - return true; - else if (s1.Year == s2.Year) - { - if (s1.Season == s2.Season && s1.Day < s2.Day) - return true; - else if (s1.Season == "spring" && s2.Season != "spring") - return true; - if (s1.Season == "summer" && (s2.Season == "fall" || s2.Season == "winter")) - return true; - if (s1.Season == "fall" && s2.Season == "winter") - return true; - } - - return false; + return date?.GetHashCode() < other?.GetHashCode(); } - /// - /// Overrides the equals function. - /// + /// Overrides the equals function. /// Object being compared. /// The equalaity of the object. public override bool Equals(object obj) { - return base.Equals(obj); + return obj is SDate other && this == other; } - /// - /// This returns the hashcode of the object - /// - /// The hashcode of the object. + /// Get a hash code which uniquely identifies a date. public override int GetHashCode() { - return base.GetHashCode(); + // return the number of days since 01 spring Y1 + int yearIndex = this.Year - 1; + return + yearIndex * this.DaysInSeason * this.SeasonsInYear + + this.GetSeasonIndex() * this.DaysInSeason + + this.Day; } + /********* ** Private methods *********/ + /// Get the current season index. + /// The current season wasn't recognised. + private int GetSeasonIndex() + { + int index = Array.IndexOf(this.Seasons, this.Season); + if (index == -1) + throw new InvalidOperationException($"The current season '{this.Season}' wasn't recognised."); + return index; + } + /// Get the real index in an array which should be treated as a two-way loop. /// The index in the looped array. /// The number of elements in the array. -- cgit From ec914874eca7d7f12e4d1fa1cabaf8275fa8a50b Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 18 Jun 2017 22:16:51 -0400 Subject: reorganise unit tests --- .../Core/ModResolverTests.cs | 486 ++++++++++++++++++++ .../Core/TranslationTests.cs | 356 +++++++++++++++ src/StardewModdingAPI.Tests/Framework/Sample.cs | 30 -- src/StardewModdingAPI.Tests/ModResolverTests.cs | 487 --------------------- src/StardewModdingAPI.Tests/SDateTests.cs | 253 ----------- src/StardewModdingAPI.Tests/Sample.cs | 30 ++ .../StardewModdingAPI.Tests.csproj | 8 +- src/StardewModdingAPI.Tests/TranslationTests.cs | 356 --------------- .../Utilities/SDateTests.cs | 253 +++++++++++ 9 files changed, 1129 insertions(+), 1130 deletions(-) create mode 100644 src/StardewModdingAPI.Tests/Core/ModResolverTests.cs create mode 100644 src/StardewModdingAPI.Tests/Core/TranslationTests.cs delete mode 100644 src/StardewModdingAPI.Tests/Framework/Sample.cs delete mode 100644 src/StardewModdingAPI.Tests/ModResolverTests.cs delete mode 100644 src/StardewModdingAPI.Tests/SDateTests.cs create mode 100644 src/StardewModdingAPI.Tests/Sample.cs delete mode 100644 src/StardewModdingAPI.Tests/TranslationTests.cs create mode 100644 src/StardewModdingAPI.Tests/Utilities/SDateTests.cs (limited to 'src/StardewModdingAPI.Tests') diff --git a/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs b/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs new file mode 100644 index 00000000..efb1c348 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs @@ -0,0 +1,486 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Tests.Core +{ + /// Unit tests for . + [TestFixture] + public class ModResolverTests + { + /********* + ** Unit tests + *********/ + /**** + ** ReadManifests + ****/ + [Test(Description = "Assert that the resolver correctly returns an empty list if there are no mods installed.")] + public void ReadBasicManifest_NoMods_ReturnsEmptyList() + { + // arrange + string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootFolder); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); + + // assert + Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead."); + } + + [Test(Description = "Assert that the resolver correctly returns a failed metadata if there's an empty mod folder.")] + public void ReadBasicManifest_EmptyModFolder_ReturnsFailedManifest() + { + // arrange + string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(modFolder); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); + IModMetadata mod = mods.FirstOrDefault(); + + // assert + Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead."); + Assert.AreEqual(ModMetadataStatus.Failed, mod.Status, "The mod metadata was not marked failed."); + Assert.IsNotNull(mod.Error, "The mod metadata did not have an error message set."); + } + + [Test(Description = "Assert that the resolver correctly reads manifest data from a randomised file.")] + public void ReadBasicManifest_CanReadFile() + { + // create manifest data + IDictionary originalDependency = new Dictionary + { + [nameof(IManifestDependency.UniqueID)] = Sample.String() + }; + IDictionary original = new Dictionary + { + [nameof(IManifest.Name)] = Sample.String(), + [nameof(IManifest.Author)] = Sample.String(), + [nameof(IManifest.Version)] = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), + [nameof(IManifest.Description)] = Sample.String(), + [nameof(IManifest.UniqueID)] = $"{Sample.String()}.{Sample.String()}", + [nameof(IManifest.EntryDll)] = $"{Sample.String()}.dll", + [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}-{Sample.String()}", + [nameof(IManifest.Dependencies)] = new[] { originalDependency }, + ["ExtraString"] = Sample.String(), + ["ExtraInt"] = Sample.Int() + }; + + // write to filesystem + string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); + string filename = Path.Combine(modFolder, "manifest.json"); + Directory.CreateDirectory(modFolder); + File.WriteAllText(filename, JsonConvert.SerializeObject(original)); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); + IModMetadata mod = mods.FirstOrDefault(); + + // assert + Assert.AreEqual(1, mods.Length, 0, "Expected to find one manifest."); + Assert.IsNotNull(mod, "The loaded manifest shouldn't be null."); + Assert.AreEqual(null, mod.Compatibility, "The compatibility record should be null since we didn't provide one."); + Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match."); + Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match."); + Assert.AreEqual(null, mod.Error, "The error should be null since parsing should have succeeded."); + + Assert.AreEqual(original[nameof(IManifest.Name)], mod.DisplayName, "The display name should use the manifest name."); + Assert.AreEqual(original[nameof(IManifest.Name)], mod.Manifest.Name, "The manifest's name doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Author)], mod.Manifest.Author, "The manifest's author doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Description)], mod.Manifest.Description, "The manifest's description doesn't match."); + Assert.AreEqual(original[nameof(IManifest.EntryDll)], mod.Manifest.EntryDll, "The manifest's entry DLL doesn't match."); + Assert.AreEqual(original[nameof(IManifest.MinimumApiVersion)], mod.Manifest.MinimumApiVersion, "The manifest's minimum API version doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Version)]?.ToString(), mod.Manifest.Version?.ToString(), "The manifest's version doesn't match."); + + Assert.IsNotNull(mod.Manifest.ExtraFields, "The extra fields should not be null."); + Assert.AreEqual(2, mod.Manifest.ExtraFields.Count, "The extra fields should contain two values."); + Assert.AreEqual(original["ExtraString"], mod.Manifest.ExtraFields["ExtraString"], "The manifest's extra fields should contain an 'ExtraString' value."); + Assert.AreEqual(original["ExtraInt"], mod.Manifest.ExtraFields["ExtraInt"], "The manifest's extra fields should contain an 'ExtraInt' value."); + + Assert.IsNotNull(mod.Manifest.Dependencies, "The dependencies field should not be null."); + Assert.AreEqual(1, mod.Manifest.Dependencies.Length, "The dependencies field should contain one value."); + Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match."); + } + + /**** + ** ValidateManifests + ****/ + [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] + public void ValidateManifests_NoMods_DoesNothing() + { + new ModResolver().ValidateManifests(new ModMetadata[0], apiVersion: new SemanticVersion("1.0")); + } + + [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] + public void ValidateManifests_Skips_Failed() + { + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); + } + + [Test(Description = "Assert that validation fails if the mod has 'assume broken' compatibility.")] + public void ValidateManifests_ModCompatibility_AssumeBroken_Fails() + { + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.Compatibility).Returns(new ModCompatibility { Compatibility = ModCompatibilityType.AssumeBroken }); + mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } + + [Test(Description = "Assert that validation fails when the minimum API version is higher than the current SMAPI version.")] + public void ValidateManifests_MinimumApiVersion_Fails() + { + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.Compatibility).Returns(() => null); + mock.Setup(p => p.Manifest).Returns(this.GetManifest(m => m.MinimumApiVersion = "1.1")); + mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } + + [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] + public void ValidateManifests_MissingEntryDLL_Fails() + { + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.Compatibility).Returns(() => null); + mock.Setup(p => p.Manifest).Returns(this.GetManifest()); + mock.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath()); + mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } + + [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] + public void ValidateManifests_Valid_Passes() + { + // set up manifest + IManifest manifest = this.GetManifest(); + + // create DLL + string modFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(modFolder); + File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll), ""); + + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.Compatibility).Returns(() => null); + mock.Setup(p => p.Manifest).Returns(manifest); + mock.Setup(p => p.DirectoryPath).Returns(modFolder); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. + } + + /**** + ** ProcessDependencies + ****/ + [Test(Description = "Assert that processing dependencies doesn't fail if there are no mods installed.")] + public void ProcessDependencies_NoMods_DoesNothing() + { + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new IModMetadata[0]).ToArray(); + + // assert + Assert.AreEqual(0, mods.Length, 0, "Expected to get an empty list of mods."); + } + + [Test(Description = "Assert that processing dependencies doesn't change the order if there are no mod dependencies.")] + public void ProcessDependencies_NoDependencies_DoesNothing() + { + // arrange + // A B C + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B"); + Mock modC = this.GetMetadata("Mod C"); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }).ToArray(); + + // assert + Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order unexpectedly changed with no dependencies."); + Assert.AreSame(modB.Object, mods[1], "The load order unexpectedly changed with no dependencies."); + Assert.AreSame(modC.Object, mods[2], "The load order unexpectedly changed with no dependencies."); + } + + [Test(Description = "Assert that processing dependencies skips mods that have already failed without calling any other properties.")] + public void ProcessDependencies_Skips_Failed() + { + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + + // act + new ModResolver().ProcessDependencies(new[] { mock.Object }); + + // assert + mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); + } + + [Test(Description = "Assert that simple dependencies are reordered correctly.")] + public void ProcessDependencies_Reorders_SimpleDependencies() + { + // arrange + // A ◀── B + // ▲ ▲ + // │ │ + // └─ C ─┘ + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod A", "Mod B" }); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }).ToArray(); + + // assert + Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since the other mods depend on it."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs both mod A and mod B."); + } + + [Test(Description = "Assert that simple dependency chains are reordered correctly.")] + public void ProcessDependencies_Reorders_DependencyChain() + { + // arrange + // A ◀── B ◀── C ◀── D + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); + Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray(); + + // assert + Assert.AreEqual(4, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); + Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); + } + + [Test(Description = "Assert that overlapping dependency chains are reordered correctly.")] + public void ProcessDependencies_Reorders_OverlappingDependencyChain() + { + // arrange + // A ◀── B ◀── C ◀── D + // ▲ ▲ + // │ │ + // E ◀── F + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); + Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); + Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod B" }); + Mock modF = this.GetMetadata("Mod F", dependencies: new[] { "Mod C", "Mod E" }); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }).ToArray(); + + // assert + Assert.AreEqual(6, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); + Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); + Assert.AreSame(modE.Object, mods[4], "The load order is incorrect: mod E should be fifth since it needs mod B, but is specified after C which also needs mod B."); + Assert.AreSame(modF.Object, mods[5], "The load order is incorrect: mod F should be last since it needs mods E and C."); + } + + [Test(Description = "Assert that mods with circular dependency chains are skipped, but any other mods are loaded in the correct order.")] + public void ProcessDependencies_Skips_CircularDependentMods() + { + // arrange + // A ◀── B ◀── C ──▶ D + // ▲ │ + // │ ▼ + // └──── E + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true); + Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true); + Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modE.Object }).ToArray(); + + // assert + Assert.AreEqual(5, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); + modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); + modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); + modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); + } + + [Test(Description = "Assert that dependencies are sorted correctly even if some of the mods failed during metadata loading.")] + public void ProcessDependencies_WithSomeFailedMods_Succeeds() + { + // arrange + // A ◀── B ◀── C D (failed) + Mock modA = this.GetMetadata("Mod A"); + Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true); + Mock modD = new Mock(MockBehavior.Strict); + modD.Setup(p => p.Manifest).Returns(null); + modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray(); + + // assert + Assert.AreEqual(4, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modD.Object, mods[0], "The load order is incorrect: mod D should be first since it was already failed."); + Assert.AreSame(modA.Object, mods[1], "The load order is incorrect: mod A should be second since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[2], "The load order is incorrect: mod B should be third since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[3], "The load order is incorrect: mod C should be fourth since it needs mod B, and is needed by mod D."); + } + + [Test(Description = "Assert that dependencies are failed if they don't meet the minimum version.")] + public void ProcessDependencies_WithMinVersions_FailsIfNotMet() + { + // arrange + // A 1.0 ◀── B (need A 1.1) + Mock modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); + Mock modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.1")), allowStatusChange: true); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray(); + + // assert + Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); + } + + [Test(Description = "Assert that dependencies are accepted if they meet the minimum version.")] + public void ProcessDependencies_WithMinVersions_SucceedsIfMet() + { + // arrange + // A 1.0 ◀── B (need A 1.0-beta) + Mock modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); + Mock modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0-beta")), allowStatusChange: false); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray(); + + // assert + Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); + } + + + /********* + ** Private methods + *********/ + /// Get a randomised basic manifest. + /// Adjust the generated manifest. + private Manifest GetManifest(Action adjust = null) + { + Manifest manifest = new Manifest + { + Name = Sample.String(), + Author = Sample.String(), + Version = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), + Description = Sample.String(), + UniqueID = $"{Sample.String()}.{Sample.String()}", + EntryDll = $"{Sample.String()}.dll" + }; + adjust?.Invoke(manifest); + return manifest; + } + + /// Get a randomised basic manifest. + /// The mod's name and unique ID. + /// The mod version. + /// The dependencies this mod requires. + private IManifest GetManifest(string uniqueID, string version, params IManifestDependency[] dependencies) + { + return this.GetManifest(manifest => + { + manifest.Name = uniqueID; + manifest.UniqueID = uniqueID; + manifest.Version = new SemanticVersion(version); + manifest.Dependencies = dependencies; + }); + } + + /// Get a randomised basic manifest. + /// The mod's name and unique ID. + private Mock GetMetadata(string uniqueID) + { + return this.GetMetadata(this.GetManifest(uniqueID, "1.0")); + } + + /// Get a randomised basic manifest. + /// The mod's name and unique ID. + /// The dependencies this mod requires. + /// Whether the code being tested is allowed to change the mod status. + private Mock GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false) + { + IManifest manifest = this.GetManifest(uniqueID, "1.0", dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray()); + return this.GetMetadata(manifest, allowStatusChange); + } + + /// Get a randomised basic manifest. + /// The mod manifest. + /// Whether the code being tested is allowed to change the mod status. + private Mock GetMetadata(IManifest manifest, bool allowStatusChange = false) + { + Mock mod = new Mock(MockBehavior.Strict); + mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID); + mod.Setup(p => p.Manifest).Returns(manifest); + if (allowStatusChange) + { + mod + .Setup(p => p.SetStatus(It.IsAny(), It.IsAny())) + .Callback((status, message) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}")) + .Returns(mod.Object); + } + return mod; + } + } +} diff --git a/src/StardewModdingAPI.Tests/Core/TranslationTests.cs b/src/StardewModdingAPI.Tests/Core/TranslationTests.cs new file mode 100644 index 00000000..ce3431e4 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Core/TranslationTests.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using StardewModdingAPI.Framework; +using StardewValley; + +namespace StardewModdingAPI.Tests.Core +{ + /// Unit tests for and . + [TestFixture] + public class TranslationTests + { + /********* + ** Data + *********/ + /// Sample translation text for unit tests. + public static string[] Samples = { null, "", " ", "boop", " boop " }; + + + /********* + ** Unit tests + *********/ + /**** + ** Translation helper + ****/ + [Test(Description = "Assert that the translation helper correctly handles no translations.")] + public void Helper_HandlesNoTranslations() + { + // arrange + var data = new Dictionary>(); + + // act + ITranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + Translation translation = helper.Get("key"); + Translation[] translationList = helper.GetTranslations()?.ToArray(); + + // assert + Assert.AreEqual("en", helper.Locale, "The locale doesn't match the input value."); + Assert.AreEqual(LocalizedContentManager.LanguageCode.en, helper.LocaleEnum, "The locale enum doesn't match the input value."); + Assert.IsNotNull(translationList, "The full list of translations is unexpectedly null."); + Assert.AreEqual(0, translationList.Length, "The full list of translations is unexpectedly not empty."); + + Assert.IsNotNull(translation, "The translation helper unexpectedly returned a null translation."); + Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value."); + } + + [Test(Description = "Assert that the translation helper returns the expected translations correctly.")] + public void Helper_GetTranslations_ReturnsExpectedText() + { + // arrange + var data = this.GetSampleData(); + var expected = this.GetExpectedTranslations(); + + // act + var actual = new Dictionary(); + TranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + foreach (string locale in expected.Keys) + { + this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); + actual[locale] = helper.GetTranslations()?.ToArray(); + } + + // assert + foreach (string locale in expected.Keys) + { + Assert.IsNotNull(actual[locale], $"The translations for {locale} is unexpectedly null."); + Assert.That(actual[locale], Is.EquivalentTo(expected[locale]).Using(this.CompareEquality), $"The translations for {locale} don't match the expected values."); + } + } + + [Test(Description = "Assert that the translations returned by the helper has the expected text.")] + public void Helper_Get_ReturnsExpectedText() + { + // arrange + var data = this.GetSampleData(); + var expected = this.GetExpectedTranslations(); + + // act + var actual = new Dictionary(); + TranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + foreach (string locale in expected.Keys) + { + this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); + + List translations = new List(); + foreach (Translation translation in expected[locale]) + translations.Add(helper.Get(translation.Key)); + actual[locale] = translations.ToArray(); + } + + // assert + foreach (string locale in expected.Keys) + { + Assert.IsNotNull(actual[locale], $"The translations for {locale} is unexpectedly null."); + Assert.That(actual[locale], Is.EquivalentTo(expected[locale]).Using(this.CompareEquality), $"The translations for {locale} don't match the expected values."); + } + } + + /**** + ** Translation + ****/ + [Test(Description = "Assert that HasValue returns the expected result for various inputs.")] + [TestCase(null, ExpectedResult = false)] + [TestCase("", ExpectedResult = false)] + [TestCase(" ", ExpectedResult = true)] + [TestCase("boop", ExpectedResult = true)] + [TestCase(" boop ", ExpectedResult = true)] + public bool Translation_HasValue(string text) + { + return new Translation("ModName", "pt-BR", "key", text).HasValue(); + } + + [Test(Description = "Assert that the translation's ToString method returns the expected text for various inputs.")] + public void Translation_ToString([ValueSource(nameof(TranslationTests.Samples))] string text) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text); + + // assert + if (translation.HasValue()) + Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid input."); + else + Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty input."); + } + + [Test(Description = "Assert that the translation's implicit string conversion returns the expected text for various inputs.")] + public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string text) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text); + + // assert + if (translation.HasValue()) + Assert.AreEqual(text, (string)translation, "The translation returned an unexpected value given a valid input."); + else + Assert.AreEqual(this.GetPlaceholderText("key"), (string)translation, "The translation returned an unexpected value given a null or empty input."); + } + + [Test(Description = "Assert that the translation returns the expected text given a use-placeholder setting.")] + public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string text) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text).UsePlaceholder(value); + + // assert + if (translation.HasValue()) + Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid input."); + else if (!value) + Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a null or empty input with the placeholder disabled."); + else + Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty input with the placeholder enabled."); + } + + [Test(Description = "Assert that the translation's Assert method throws the expected exception.")] + public void Translation_Assert([ValueSource(nameof(TranslationTests.Samples))] string text) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text); + + // assert + if (translation.HasValue()) + Assert.That(() => translation.Assert(), Throws.Nothing, "The assert unexpected threw an exception for a valid input."); + else + Assert.That(() => translation.Assert(), Throws.Exception.TypeOf(), "The assert didn't throw an exception for invalid input."); + } + + [Test(Description = "Assert that the translation returns the expected text after setting the default.")] + public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string text, [ValueSource(nameof(TranslationTests.Samples))] string @default) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text).Default(@default); + + // assert + if (!string.IsNullOrEmpty(text)) + Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid base text."); + else if (!string.IsNullOrEmpty(@default)) + Assert.AreEqual(@default, translation.ToString(), "The translation returned an unexpected value given a null or empty base text, but valid default."); + else + Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty base and default text."); + } + + /**** + ** Translation tokens + ****/ + [Test(Description = "Assert that multiple translation tokens are replaced correctly regardless of the token structure.")] + public void Translation_Tokens([Values("anonymous object", "class", "IDictionary", "IDictionary")] string structure) + { + // arrange + string start = Guid.NewGuid().ToString("N"); + string middle = Guid.NewGuid().ToString("N"); + string end = Guid.NewGuid().ToString("N"); + const string input = "{{start}} tokens are properly replaced (including {{middle}} {{ MIDdlE}}) {{end}}"; + string expected = $"{start} tokens are properly replaced (including {middle} {middle}) {end}"; + + // act + Translation translation = new Translation("ModName", "pt-BR", "key", input); + switch (structure) + { + case "anonymous object": + translation = translation.Tokens(new { start, middle, end }); + break; + + case "class": + translation = translation.Tokens(new TokenModel { Start = start, Middle = middle, End = end }); + break; + + case "IDictionary": + translation = translation.Tokens(new Dictionary { ["start"] = start, ["middle"] = middle, ["end"] = end }); + break; + + case "IDictionary": + translation = translation.Tokens(new Dictionary { ["start"] = start, ["middle"] = middle, ["end"] = end }); + break; + + default: + throw new NotSupportedException($"Unknown structure '{structure}'."); + } + + // assert + Assert.AreEqual(expected, translation.ToString(), "The translation returned an unexpected text."); + } + + [Test(Description = "Assert that the translation can replace tokens in all valid formats.")] + [TestCase("{{value}}", "value")] + [TestCase("{{ value }}", "value")] + [TestCase("{{value }}", "value")] + [TestCase("{{ the_value }}", "the_value")] + [TestCase("{{ the.value_here }}", "the.value_here")] + [TestCase("{{ the_value-here.... }}", "the_value-here....")] + [TestCase("{{ tHe_vALuE-HEre.... }}", "tHe_vALuE-HEre....")] + public void Translation_Tokens_ValidFormats(string text, string key) + { + // arrange + string value = Guid.NewGuid().ToString("N"); + + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text).Tokens(new Dictionary { [key] = value }); + + // assert + Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); + } + + [Test(Description = "Assert that translation tokens are case-insensitive and surrounding-whitespace-insensitive.")] + [TestCase("{{value}}", "value")] + [TestCase("{{VaLuE}}", "vAlUe")] + [TestCase("{{VaLuE }}", " vAlUe")] + public void Translation_Tokens_KeysAreNormalised(string text, string key) + { + // arrange + string value = Guid.NewGuid().ToString("N"); + + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text).Tokens(new Dictionary { [key] = value }); + + // assert + Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); + } + + + /********* + ** Private methods + *********/ + /// Set a translation helper's locale and assert that it was set correctly. + /// The translation helper to change. + /// The expected locale. + /// The expected game language code. + private void AssertSetLocale(TranslationHelper helper, string locale, LocalizedContentManager.LanguageCode localeEnum) + { + helper.SetLocale(locale, localeEnum); + Assert.AreEqual(locale, helper.Locale, "The locale doesn't match the input value."); + Assert.AreEqual(localeEnum, helper.LocaleEnum, "The locale enum doesn't match the input value."); + } + + /// Get sample raw translations to input. + private IDictionary> GetSampleData() + { + return new Dictionary> + { + ["default"] = new Dictionary + { + ["key A"] = "default A", + ["key C"] = "default C" + }, + ["en"] = new Dictionary + { + ["key A"] = "en A", + ["key B"] = "en B" + }, + ["en-US"] = new Dictionary(), + ["zzz"] = new Dictionary + { + ["key A"] = "zzz A" + } + }; + } + + /// Get the expected translation output given , based on the expected locale fallback. + private IDictionary GetExpectedTranslations() + { + var expected = new Dictionary + { + ["default"] = new[] + { + new Translation(string.Empty, "default", "key A", "default A"), + new Translation(string.Empty, "default", "key C", "default C") + }, + ["en"] = new[] + { + new Translation(string.Empty, "en", "key A", "en A"), + new Translation(string.Empty, "en", "key B", "en B"), + new Translation(string.Empty, "en", "key C", "default C") + }, + ["zzz"] = new[] + { + new Translation(string.Empty, "zzz", "key A", "zzz A"), + new Translation(string.Empty, "zzz", "key C", "default C") + } + }; + expected["en-us"] = expected["en"].ToArray(); + return expected; + } + + /// Get whether two translations have the same public values. + /// The first translation to compare. + /// The second translation to compare. + private bool CompareEquality(Translation a, Translation b) + { + return a.Key == b.Key && a.ToString() == b.ToString(); + } + + /// Get the default placeholder text when a translation is missing. + /// The translation key. + private string GetPlaceholderText(string key) + { + return string.Format(Translation.PlaceholderText, key); + } + + + /********* + ** Test models + *********/ + /// A model used to test token support. + private class TokenModel + { + /// A sample token property. + public string Start { get; set; } + + /// A sample token property. + public string Middle { get; set; } + + /// A sample token field. + public string End; + } + } +} diff --git a/src/StardewModdingAPI.Tests/Framework/Sample.cs b/src/StardewModdingAPI.Tests/Framework/Sample.cs deleted file mode 100644 index 10006f1e..00000000 --- a/src/StardewModdingAPI.Tests/Framework/Sample.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; - -namespace StardewModdingAPI.Tests.Framework -{ - /// Provides sample values for unit testing. - internal static class Sample - { - /********* - ** Properties - *********/ - /// A random number generator. - private static readonly Random Random = new Random(); - - - /********* - ** Properties - *********/ - /// Get a sample string. - public static string String() - { - return Guid.NewGuid().ToString("N"); - } - - /// Get a sample integer. - public static int Int() - { - return Sample.Random.Next(); - } - } -} diff --git a/src/StardewModdingAPI.Tests/ModResolverTests.cs b/src/StardewModdingAPI.Tests/ModResolverTests.cs deleted file mode 100644 index 4afba162..00000000 --- a/src/StardewModdingAPI.Tests/ModResolverTests.cs +++ /dev/null @@ -1,487 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Moq; -using Newtonsoft.Json; -using NUnit.Framework; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Models; -using StardewModdingAPI.Framework.ModLoading; -using StardewModdingAPI.Framework.Serialisation; -using StardewModdingAPI.Tests.Framework; - -namespace StardewModdingAPI.Tests -{ - /// Unit tests for . - [TestFixture] - public class ModResolverTests - { - /********* - ** Unit tests - *********/ - /**** - ** ReadManifests - ****/ - [Test(Description = "Assert that the resolver correctly returns an empty list if there are no mods installed.")] - public void ReadBasicManifest_NoMods_ReturnsEmptyList() - { - // arrange - string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(rootFolder); - - // act - IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); - - // assert - Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead."); - } - - [Test(Description = "Assert that the resolver correctly returns a failed metadata if there's an empty mod folder.")] - public void ReadBasicManifest_EmptyModFolder_ReturnsFailedManifest() - { - // arrange - string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(modFolder); - - // act - IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); - IModMetadata mod = mods.FirstOrDefault(); - - // assert - Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead."); - Assert.AreEqual(ModMetadataStatus.Failed, mod.Status, "The mod metadata was not marked failed."); - Assert.IsNotNull(mod.Error, "The mod metadata did not have an error message set."); - } - - [Test(Description = "Assert that the resolver correctly reads manifest data from a randomised file.")] - public void ReadBasicManifest_CanReadFile() - { - // create manifest data - IDictionary originalDependency = new Dictionary - { - [nameof(IManifestDependency.UniqueID)] = Sample.String() - }; - IDictionary original = new Dictionary - { - [nameof(IManifest.Name)] = Sample.String(), - [nameof(IManifest.Author)] = Sample.String(), - [nameof(IManifest.Version)] = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), - [nameof(IManifest.Description)] = Sample.String(), - [nameof(IManifest.UniqueID)] = $"{Sample.String()}.{Sample.String()}", - [nameof(IManifest.EntryDll)] = $"{Sample.String()}.dll", - [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}-{Sample.String()}", - [nameof(IManifest.Dependencies)] = new[] { originalDependency }, - ["ExtraString"] = Sample.String(), - ["ExtraInt"] = Sample.Int() - }; - - // write to filesystem - string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); - string filename = Path.Combine(modFolder, "manifest.json"); - Directory.CreateDirectory(modFolder); - File.WriteAllText(filename, JsonConvert.SerializeObject(original)); - - // act - IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); - IModMetadata mod = mods.FirstOrDefault(); - - // assert - Assert.AreEqual(1, mods.Length, 0, "Expected to find one manifest."); - Assert.IsNotNull(mod, "The loaded manifest shouldn't be null."); - Assert.AreEqual(null, mod.Compatibility, "The compatibility record should be null since we didn't provide one."); - Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match."); - Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match."); - Assert.AreEqual(null, mod.Error, "The error should be null since parsing should have succeeded."); - - Assert.AreEqual(original[nameof(IManifest.Name)], mod.DisplayName, "The display name should use the manifest name."); - Assert.AreEqual(original[nameof(IManifest.Name)], mod.Manifest.Name, "The manifest's name doesn't match."); - Assert.AreEqual(original[nameof(IManifest.Author)], mod.Manifest.Author, "The manifest's author doesn't match."); - Assert.AreEqual(original[nameof(IManifest.Description)], mod.Manifest.Description, "The manifest's description doesn't match."); - Assert.AreEqual(original[nameof(IManifest.EntryDll)], mod.Manifest.EntryDll, "The manifest's entry DLL doesn't match."); - Assert.AreEqual(original[nameof(IManifest.MinimumApiVersion)], mod.Manifest.MinimumApiVersion, "The manifest's minimum API version doesn't match."); - Assert.AreEqual(original[nameof(IManifest.Version)]?.ToString(), mod.Manifest.Version?.ToString(), "The manifest's version doesn't match."); - - Assert.IsNotNull(mod.Manifest.ExtraFields, "The extra fields should not be null."); - Assert.AreEqual(2, mod.Manifest.ExtraFields.Count, "The extra fields should contain two values."); - Assert.AreEqual(original["ExtraString"], mod.Manifest.ExtraFields["ExtraString"], "The manifest's extra fields should contain an 'ExtraString' value."); - Assert.AreEqual(original["ExtraInt"], mod.Manifest.ExtraFields["ExtraInt"], "The manifest's extra fields should contain an 'ExtraInt' value."); - - Assert.IsNotNull(mod.Manifest.Dependencies, "The dependencies field should not be null."); - Assert.AreEqual(1, mod.Manifest.Dependencies.Length, "The dependencies field should contain one value."); - Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match."); - } - - /**** - ** ValidateManifests - ****/ - [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] - public void ValidateManifests_NoMods_DoesNothing() - { - new ModResolver().ValidateManifests(new ModMetadata[0], apiVersion: new SemanticVersion("1.0")); - } - - [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] - public void ValidateManifests_Skips_Failed() - { - // arrange - Mock mock = new Mock(MockBehavior.Strict); - mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); - - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); - - // assert - mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); - } - - [Test(Description = "Assert that validation fails if the mod has 'assume broken' compatibility.")] - public void ValidateManifests_ModCompatibility_AssumeBroken_Fails() - { - // arrange - Mock mock = new Mock(MockBehavior.Strict); - mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mock.Setup(p => p.Compatibility).Returns(new ModCompatibility { Compatibility = ModCompatibilityType.AssumeBroken }); - mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); - - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); - - // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "The validation did not fail the metadata."); - } - - [Test(Description = "Assert that validation fails when the minimum API version is higher than the current SMAPI version.")] - public void ValidateManifests_MinimumApiVersion_Fails() - { - // arrange - Mock mock = new Mock(MockBehavior.Strict); - mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mock.Setup(p => p.Compatibility).Returns(() => null); - mock.Setup(p => p.Manifest).Returns(this.GetManifest(m => m.MinimumApiVersion = "1.1")); - mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); - - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); - - // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "The validation did not fail the metadata."); - } - - [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] - public void ValidateManifests_MissingEntryDLL_Fails() - { - // arrange - Mock mock = new Mock(MockBehavior.Strict); - mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mock.Setup(p => p.Compatibility).Returns(() => null); - mock.Setup(p => p.Manifest).Returns(this.GetManifest()); - mock.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath()); - mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); - - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); - - // assert - mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "The validation did not fail the metadata."); - } - - [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] - public void ValidateManifests_Valid_Passes() - { - // set up manifest - IManifest manifest = this.GetManifest(); - - // create DLL - string modFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(modFolder); - File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll), ""); - - // arrange - Mock mock = new Mock(MockBehavior.Strict); - mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mock.Setup(p => p.Compatibility).Returns(() => null); - mock.Setup(p => p.Manifest).Returns(manifest); - mock.Setup(p => p.DirectoryPath).Returns(modFolder); - - // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); - - // assert - // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. - } - - /**** - ** ProcessDependencies - ****/ - [Test(Description = "Assert that processing dependencies doesn't fail if there are no mods installed.")] - public void ProcessDependencies_NoMods_DoesNothing() - { - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new IModMetadata[0]).ToArray(); - - // assert - Assert.AreEqual(0, mods.Length, 0, "Expected to get an empty list of mods."); - } - - [Test(Description = "Assert that processing dependencies doesn't change the order if there are no mod dependencies.")] - public void ProcessDependencies_NoDependencies_DoesNothing() - { - // arrange - // A B C - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B"); - Mock modC = this.GetMetadata("Mod C"); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }).ToArray(); - - // assert - Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order unexpectedly changed with no dependencies."); - Assert.AreSame(modB.Object, mods[1], "The load order unexpectedly changed with no dependencies."); - Assert.AreSame(modC.Object, mods[2], "The load order unexpectedly changed with no dependencies."); - } - - [Test(Description = "Assert that processing dependencies skips mods that have already failed without calling any other properties.")] - public void ProcessDependencies_Skips_Failed() - { - // arrange - Mock mock = new Mock(MockBehavior.Strict); - mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); - - // act - new ModResolver().ProcessDependencies(new[] { mock.Object }); - - // assert - mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); - } - - [Test(Description = "Assert that simple dependencies are reordered correctly.")] - public void ProcessDependencies_Reorders_SimpleDependencies() - { - // arrange - // A ◀── B - // ▲ ▲ - // │ │ - // └─ C ─┘ - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod A", "Mod B" }); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }).ToArray(); - - // assert - Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since the other mods depend on it."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); - Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs both mod A and mod B."); - } - - [Test(Description = "Assert that simple dependency chains are reordered correctly.")] - public void ProcessDependencies_Reorders_DependencyChain() - { - // arrange - // A ◀── B ◀── C ◀── D - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); - Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray(); - - // assert - Assert.AreEqual(4, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); - Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); - Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); - } - - [Test(Description = "Assert that overlapping dependency chains are reordered correctly.")] - public void ProcessDependencies_Reorders_OverlappingDependencyChain() - { - // arrange - // A ◀── B ◀── C ◀── D - // ▲ ▲ - // │ │ - // E ◀── F - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); - Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); - Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod B" }); - Mock modF = this.GetMetadata("Mod F", dependencies: new[] { "Mod C", "Mod E" }); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }).ToArray(); - - // assert - Assert.AreEqual(6, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); - Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); - Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); - Assert.AreSame(modE.Object, mods[4], "The load order is incorrect: mod E should be fifth since it needs mod B, but is specified after C which also needs mod B."); - Assert.AreSame(modF.Object, mods[5], "The load order is incorrect: mod F should be last since it needs mods E and C."); - } - - [Test(Description = "Assert that mods with circular dependency chains are skipped, but any other mods are loaded in the correct order.")] - public void ProcessDependencies_Skips_CircularDependentMods() - { - // arrange - // A ◀── B ◀── C ──▶ D - // ▲ │ - // │ ▼ - // └──── E - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true); - Mock modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true); - Mock modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modE.Object }).ToArray(); - - // assert - Assert.AreEqual(5, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); - modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); - modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); - modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); - } - - [Test(Description = "Assert that dependencies are sorted correctly even if some of the mods failed during metadata loading.")] - public void ProcessDependencies_WithSomeFailedMods_Succeeds() - { - // arrange - // A ◀── B ◀── C D (failed) - Mock modA = this.GetMetadata("Mod A"); - Mock modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); - Mock modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true); - Mock modD = new Mock(MockBehavior.Strict); - modD.Setup(p => p.Manifest).Returns(null); - modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray(); - - // assert - Assert.AreEqual(4, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modD.Object, mods[0], "The load order is incorrect: mod D should be first since it was already failed."); - Assert.AreSame(modA.Object, mods[1], "The load order is incorrect: mod A should be second since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[2], "The load order is incorrect: mod B should be third since it needs mod A, and is needed by mod C."); - Assert.AreSame(modC.Object, mods[3], "The load order is incorrect: mod C should be fourth since it needs mod B, and is needed by mod D."); - } - - [Test(Description = "Assert that dependencies are failed if they don't meet the minimum version.")] - public void ProcessDependencies_WithMinVersions_FailsIfNotMet() - { - // arrange - // A 1.0 ◀── B (need A 1.1) - Mock modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); - Mock modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.1")), allowStatusChange: true); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray(); - - // assert - Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); - } - - [Test(Description = "Assert that dependencies are accepted if they meet the minimum version.")] - public void ProcessDependencies_WithMinVersions_SucceedsIfMet() - { - // arrange - // A 1.0 ◀── B (need A 1.0-beta) - Mock modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); - Mock modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0-beta")), allowStatusChange: false); - - // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray(); - - // assert - Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); - Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); - Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); - } - - - /********* - ** Private methods - *********/ - /// Get a randomised basic manifest. - /// Adjust the generated manifest. - private Manifest GetManifest(Action adjust = null) - { - Manifest manifest = new Manifest - { - Name = Sample.String(), - Author = Sample.String(), - Version = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), - Description = Sample.String(), - UniqueID = $"{Sample.String()}.{Sample.String()}", - EntryDll = $"{Sample.String()}.dll" - }; - adjust?.Invoke(manifest); - return manifest; - } - - /// Get a randomised basic manifest. - /// The mod's name and unique ID. - /// The mod version. - /// The dependencies this mod requires. - private IManifest GetManifest(string uniqueID, string version, params IManifestDependency[] dependencies) - { - return this.GetManifest(manifest => - { - manifest.Name = uniqueID; - manifest.UniqueID = uniqueID; - manifest.Version = new SemanticVersion(version); - manifest.Dependencies = dependencies; - }); - } - - /// Get a randomised basic manifest. - /// The mod's name and unique ID. - private Mock GetMetadata(string uniqueID) - { - return this.GetMetadata(this.GetManifest(uniqueID, "1.0")); - } - - /// Get a randomised basic manifest. - /// The mod's name and unique ID. - /// The dependencies this mod requires. - /// Whether the code being tested is allowed to change the mod status. - private Mock GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false) - { - IManifest manifest = this.GetManifest(uniqueID, "1.0", dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray()); - return this.GetMetadata(manifest, allowStatusChange); - } - - /// Get a randomised basic manifest. - /// The mod manifest. - /// Whether the code being tested is allowed to change the mod status. - private Mock GetMetadata(IManifest manifest, bool allowStatusChange = false) - { - Mock mod = new Mock(MockBehavior.Strict); - mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID); - mod.Setup(p => p.Manifest).Returns(manifest); - if (allowStatusChange) - { - mod - .Setup(p => p.SetStatus(It.IsAny(), It.IsAny())) - .Callback((status, message) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}")) - .Returns(mod.Object); - } - return mod; - } - } -} diff --git a/src/StardewModdingAPI.Tests/SDateTests.cs b/src/StardewModdingAPI.Tests/SDateTests.cs deleted file mode 100644 index fa898918..00000000 --- a/src/StardewModdingAPI.Tests/SDateTests.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text.RegularExpressions; -using NUnit.Framework; -using StardewModdingAPI.Utilities; - -namespace StardewModdingAPI.Tests -{ - /// Unit tests for . - [TestFixture] - internal class SDateTests - { - /********* - ** Properties - *********/ - /// All valid seasons. - private static readonly string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; - - /// All valid days of a month. - private static readonly int[] ValidDays = Enumerable.Range(1, 28).ToArray(); - - /// Sample relative dates for test cases. - private static class Dates - { - /// The base date to which other dates are relative. - public const string Now = "02 summer Y2"; - - /// The day before . - public const string PrevDay = "01 summer Y2"; - - /// The month before . - public const string PrevMonth = "02 spring Y2"; - - /// The year before . - public const string PrevYear = "02 summer Y1"; - - /// The day after . - public const string NextDay = "03 summer Y2"; - - /// The month after . - public const string NextMonth = "02 fall Y2"; - - /// The year after . - public const string NextYear = "02 summer Y3"; - } - - - /********* - ** Unit tests - *********/ - /**** - ** Constructor - ****/ - [Test(Description = "Assert that the constructor sets the expected values for all valid dates.")] - public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.ValidSeasons))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) - { - // act - SDate date = new SDate(day, season, year); - - // assert - Assert.AreEqual(day, date.Day); - Assert.AreEqual(season, date.Season); - Assert.AreEqual(year, date.Year); - } - - [Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] - [TestCase(01, "Spring", 1)] // seasons are case-sensitive - [TestCase(01, "springs", 1)] // invalid season name - [TestCase(-1, "spring", 1)] // day < 0 - [TestCase(29, "spring", 1)] // day > 28 - [TestCase(01, "spring", -1)] // year < 1 - [TestCase(01, "spring", 0)] // year < 1 - [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] - public void Constructor_RejectsInvalidValues(int day, string season, int year) - { - // act & assert - Assert.Throws(() => _ = new SDate(day, season, year), "Constructing the invalid date didn't throw the expected exception."); - } - - /**** - ** ToString - ****/ - [Test(Description = "Assert that ToString returns the expected string.")] - [TestCase("14 spring Y1", ExpectedResult = "14 spring Y1")] - [TestCase("01 summer Y16", ExpectedResult = "01 summer Y16")] - [TestCase("28 fall Y10", ExpectedResult = "28 fall Y10")] - [TestCase("01 winter Y1", ExpectedResult = "01 winter Y1")] - public string ToString(string dateStr) - { - return this.GetDate(dateStr).ToString(); - } - - /**** - ** AddDays - ****/ - [Test(Description = "Assert that AddDays returns the expected date.")] - [TestCase("01 spring Y1", 15, ExpectedResult = "16 spring Y1")] // day transition - [TestCase("01 spring Y1", 28, ExpectedResult = "01 summer Y1")] // season transition - [TestCase("01 spring Y1", 28 * 4, ExpectedResult = "01 spring Y2")] // year transition - [TestCase("01 spring Y1", 28 * 7 + 17, ExpectedResult = "18 winter Y2")] // year transition - [TestCase("15 spring Y1", -14, ExpectedResult = "01 spring Y1")] // negative day transition - [TestCase("15 summer Y1", -28, ExpectedResult = "15 spring Y1")] // negative season transition - [TestCase("15 summer Y2", -28 * 4, ExpectedResult = "15 summer Y1")] // negative year transition - [TestCase("01 spring Y3", -(28 * 7 + 17), ExpectedResult = "12 spring Y1")] // negative year transition - public string AddDays(string dateStr, int addDays) - { - return this.GetDate(dateStr).AddDays(addDays).ToString(); - } - - /**** - ** GetHashCode - ****/ - [Test(Description = "Assert that GetHashCode returns a unique ordered value for every date.")] - public void GetHashCode_ReturnsUniqueOrderedValue() - { - IDictionary hashes = new Dictionary(); - int lastHash = int.MinValue; - for (int year = 1; year <= 4; year++) - { - foreach (string season in SDateTests.ValidSeasons) - { - foreach (int day in SDateTests.ValidDays) - { - SDate date = new SDate(day, season, year); - int hash = date.GetHashCode(); - if (hashes.TryGetValue(hash, out SDate otherDate)) - Assert.Fail($"Received identical hash code {hash} for dates {otherDate} and {date}."); - if (hash < lastHash) - Assert.Fail($"Received smaller hash code for date {date} ({hash}) relative to {hashes[lastHash]} ({lastHash})."); - - lastHash = hash; - hashes[hash] = date; - } - } - } - } - - [Test(Description = "Assert that the == operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] - public bool Operators_Equals(string now, string other) - { - return this.GetDate(now) == this.GetDate(other); - } - - [Test(Description = "Assert that the != operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] - public bool Operators_NotEquals(string now, string other) - { - return this.GetDate(now) != this.GetDate(other); - } - - [Test(Description = "Assert that the < operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] - public bool Operators_LessThan(string now, string other) - { - return this.GetDate(now) < this.GetDate(other); - } - - [Test(Description = "Assert that the <= operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] - public bool Operators_LessThanOrEqual(string now, string other) - { - return this.GetDate(now) <= this.GetDate(other); - } - - [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] - public bool Operators_MoreThan(string now, string other) - { - return this.GetDate(now) > this.GetDate(other); - } - - [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] - [TestCase(Dates.Now, null, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] - [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] - [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] - public bool Operators_MoreThanOrEqual(string now, string other) - { - return this.GetDate(now) > this.GetDate(other); - } - - - /********* - ** Private methods - *********/ - /// Convert a string date into a game date, to make unit tests easier to read. - /// The date string like "dd MMMM yy". - private SDate GetDate(string dateStr) - { - if (dateStr == null) - return null; - - void Fail(string reason) => throw new AssertionException($"Couldn't parse date '{dateStr}' because {reason}."); - - // parse - Match match = Regex.Match(dateStr, @"^(?\d+) (?\w+) Y(?\d+)$"); - if (!match.Success) - Fail("it doesn't match expected pattern (should be like 28 spring Y1)"); - - // extract parts - string season = match.Groups["season"].Value; - if (!int.TryParse(match.Groups["day"].Value, out int day)) - Fail($"'{match.Groups["day"].Value}' couldn't be parsed as a day."); - if (!int.TryParse(match.Groups["year"].Value, out int year)) - Fail($"'{match.Groups["year"].Value}' couldn't be parsed as a year."); - - // build date - return new SDate(day, season, year); - } - } -} diff --git a/src/StardewModdingAPI.Tests/Sample.cs b/src/StardewModdingAPI.Tests/Sample.cs new file mode 100644 index 00000000..99835d92 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Sample.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Tests +{ + /// Provides sample values for unit testing. + internal static class Sample + { + /********* + ** Properties + *********/ + /// A random number generator. + private static readonly Random Random = new Random(); + + + /********* + ** Properties + *********/ + /// Get a sample string. + public static string String() + { + return Guid.NewGuid().ToString("N"); + } + + /// Get a sample integer. + public static int Int() + { + return Sample.Random.Next(); + } + } +} diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index a50d23b3..f626eb08 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -51,11 +51,11 @@ Properties\GlobalAssemblyInfo.cs - - - + + + - + diff --git a/src/StardewModdingAPI.Tests/TranslationTests.cs b/src/StardewModdingAPI.Tests/TranslationTests.cs deleted file mode 100644 index 157a08a2..00000000 --- a/src/StardewModdingAPI.Tests/TranslationTests.cs +++ /dev/null @@ -1,356 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using StardewModdingAPI.Framework; -using StardewValley; - -namespace StardewModdingAPI.Tests -{ - /// Unit tests for and . - [TestFixture] - public class TranslationTests - { - /********* - ** Data - *********/ - /// Sample translation text for unit tests. - public static string[] Samples = { null, "", " ", "boop", " boop " }; - - - /********* - ** Unit tests - *********/ - /**** - ** Translation helper - ****/ - [Test(Description = "Assert that the translation helper correctly handles no translations.")] - public void Helper_HandlesNoTranslations() - { - // arrange - var data = new Dictionary>(); - - // act - ITranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); - Translation translation = helper.Get("key"); - Translation[] translationList = helper.GetTranslations()?.ToArray(); - - // assert - Assert.AreEqual("en", helper.Locale, "The locale doesn't match the input value."); - Assert.AreEqual(LocalizedContentManager.LanguageCode.en, helper.LocaleEnum, "The locale enum doesn't match the input value."); - Assert.IsNotNull(translationList, "The full list of translations is unexpectedly null."); - Assert.AreEqual(0, translationList.Length, "The full list of translations is unexpectedly not empty."); - - Assert.IsNotNull(translation, "The translation helper unexpectedly returned a null translation."); - Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value."); - } - - [Test(Description = "Assert that the translation helper returns the expected translations correctly.")] - public void Helper_GetTranslations_ReturnsExpectedText() - { - // arrange - var data = this.GetSampleData(); - var expected = this.GetExpectedTranslations(); - - // act - var actual = new Dictionary(); - TranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); - foreach (string locale in expected.Keys) - { - this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); - actual[locale] = helper.GetTranslations()?.ToArray(); - } - - // assert - foreach (string locale in expected.Keys) - { - Assert.IsNotNull(actual[locale], $"The translations for {locale} is unexpectedly null."); - Assert.That(actual[locale], Is.EquivalentTo(expected[locale]).Using(this.CompareEquality), $"The translations for {locale} don't match the expected values."); - } - } - - [Test(Description = "Assert that the translations returned by the helper has the expected text.")] - public void Helper_Get_ReturnsExpectedText() - { - // arrange - var data = this.GetSampleData(); - var expected = this.GetExpectedTranslations(); - - // act - var actual = new Dictionary(); - TranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); - foreach (string locale in expected.Keys) - { - this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); - - List translations = new List(); - foreach (Translation translation in expected[locale]) - translations.Add(helper.Get(translation.Key)); - actual[locale] = translations.ToArray(); - } - - // assert - foreach (string locale in expected.Keys) - { - Assert.IsNotNull(actual[locale], $"The translations for {locale} is unexpectedly null."); - Assert.That(actual[locale], Is.EquivalentTo(expected[locale]).Using(this.CompareEquality), $"The translations for {locale} don't match the expected values."); - } - } - - /**** - ** Translation - ****/ - [Test(Description = "Assert that HasValue returns the expected result for various inputs.")] - [TestCase(null, ExpectedResult = false)] - [TestCase("", ExpectedResult = false)] - [TestCase(" ", ExpectedResult = true)] - [TestCase("boop", ExpectedResult = true)] - [TestCase(" boop ", ExpectedResult = true)] - public bool Translation_HasValue(string text) - { - return new Translation("ModName", "pt-BR", "key", text).HasValue(); - } - - [Test(Description = "Assert that the translation's ToString method returns the expected text for various inputs.")] - public void Translation_ToString([ValueSource(nameof(TranslationTests.Samples))] string text) - { - // act - Translation translation = new Translation("ModName", "pt-BR", "key", text); - - // assert - if (translation.HasValue()) - Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid input."); - else - Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty input."); - } - - [Test(Description = "Assert that the translation's implicit string conversion returns the expected text for various inputs.")] - public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string text) - { - // act - Translation translation = new Translation("ModName", "pt-BR", "key", text); - - // assert - if (translation.HasValue()) - Assert.AreEqual(text, (string)translation, "The translation returned an unexpected value given a valid input."); - else - Assert.AreEqual(this.GetPlaceholderText("key"), (string)translation, "The translation returned an unexpected value given a null or empty input."); - } - - [Test(Description = "Assert that the translation returns the expected text given a use-placeholder setting.")] - public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string text) - { - // act - Translation translation = new Translation("ModName", "pt-BR", "key", text).UsePlaceholder(value); - - // assert - if (translation.HasValue()) - Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid input."); - else if (!value) - Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a null or empty input with the placeholder disabled."); - else - Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty input with the placeholder enabled."); - } - - [Test(Description = "Assert that the translation's Assert method throws the expected exception.")] - public void Translation_Assert([ValueSource(nameof(TranslationTests.Samples))] string text) - { - // act - Translation translation = new Translation("ModName", "pt-BR", "key", text); - - // assert - if (translation.HasValue()) - Assert.That(() => translation.Assert(), Throws.Nothing, "The assert unexpected threw an exception for a valid input."); - else - Assert.That(() => translation.Assert(), Throws.Exception.TypeOf(), "The assert didn't throw an exception for invalid input."); - } - - [Test(Description = "Assert that the translation returns the expected text after setting the default.")] - public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string text, [ValueSource(nameof(TranslationTests.Samples))] string @default) - { - // act - Translation translation = new Translation("ModName", "pt-BR", "key", text).Default(@default); - - // assert - if (!string.IsNullOrEmpty(text)) - Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid base text."); - else if (!string.IsNullOrEmpty(@default)) - Assert.AreEqual(@default, translation.ToString(), "The translation returned an unexpected value given a null or empty base text, but valid default."); - else - Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty base and default text."); - } - - /**** - ** Translation tokens - ****/ - [Test(Description = "Assert that multiple translation tokens are replaced correctly regardless of the token structure.")] - public void Translation_Tokens([Values("anonymous object", "class", "IDictionary", "IDictionary")] string structure) - { - // arrange - string start = Guid.NewGuid().ToString("N"); - string middle = Guid.NewGuid().ToString("N"); - string end = Guid.NewGuid().ToString("N"); - const string input = "{{start}} tokens are properly replaced (including {{middle}} {{ MIDdlE}}) {{end}}"; - string expected = $"{start} tokens are properly replaced (including {middle} {middle}) {end}"; - - // act - Translation translation = new Translation("ModName", "pt-BR", "key", input); - switch (structure) - { - case "anonymous object": - translation = translation.Tokens(new { start, middle, end }); - break; - - case "class": - translation = translation.Tokens(new TokenModel { Start = start, Middle = middle, End = end }); - break; - - case "IDictionary": - translation = translation.Tokens(new Dictionary { ["start"] = start, ["middle"] = middle, ["end"] = end }); - break; - - case "IDictionary": - translation = translation.Tokens(new Dictionary { ["start"] = start, ["middle"] = middle, ["end"] = end }); - break; - - default: - throw new NotSupportedException($"Unknown structure '{structure}'."); - } - - // assert - Assert.AreEqual(expected, translation.ToString(), "The translation returned an unexpected text."); - } - - [Test(Description = "Assert that the translation can replace tokens in all valid formats.")] - [TestCase("{{value}}", "value")] - [TestCase("{{ value }}", "value")] - [TestCase("{{value }}", "value")] - [TestCase("{{ the_value }}", "the_value")] - [TestCase("{{ the.value_here }}", "the.value_here")] - [TestCase("{{ the_value-here.... }}", "the_value-here....")] - [TestCase("{{ tHe_vALuE-HEre.... }}", "tHe_vALuE-HEre....")] - public void Translation_Tokens_ValidFormats(string text, string key) - { - // arrange - string value = Guid.NewGuid().ToString("N"); - - // act - Translation translation = new Translation("ModName", "pt-BR", "key", text).Tokens(new Dictionary { [key] = value }); - - // assert - Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); - } - - [Test(Description = "Assert that translation tokens are case-insensitive and surrounding-whitespace-insensitive.")] - [TestCase("{{value}}", "value")] - [TestCase("{{VaLuE}}", "vAlUe")] - [TestCase("{{VaLuE }}", " vAlUe")] - public void Translation_Tokens_KeysAreNormalised(string text, string key) - { - // arrange - string value = Guid.NewGuid().ToString("N"); - - // act - Translation translation = new Translation("ModName", "pt-BR", "key", text).Tokens(new Dictionary { [key] = value }); - - // assert - Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); - } - - - /********* - ** Private methods - *********/ - /// Set a translation helper's locale and assert that it was set correctly. - /// The translation helper to change. - /// The expected locale. - /// The expected game language code. - private void AssertSetLocale(TranslationHelper helper, string locale, LocalizedContentManager.LanguageCode localeEnum) - { - helper.SetLocale(locale, localeEnum); - Assert.AreEqual(locale, helper.Locale, "The locale doesn't match the input value."); - Assert.AreEqual(localeEnum, helper.LocaleEnum, "The locale enum doesn't match the input value."); - } - - /// Get sample raw translations to input. - private IDictionary> GetSampleData() - { - return new Dictionary> - { - ["default"] = new Dictionary - { - ["key A"] = "default A", - ["key C"] = "default C" - }, - ["en"] = new Dictionary - { - ["key A"] = "en A", - ["key B"] = "en B" - }, - ["en-US"] = new Dictionary(), - ["zzz"] = new Dictionary - { - ["key A"] = "zzz A" - } - }; - } - - /// Get the expected translation output given , based on the expected locale fallback. - private IDictionary GetExpectedTranslations() - { - var expected = new Dictionary - { - ["default"] = new[] - { - new Translation(string.Empty, "default", "key A", "default A"), - new Translation(string.Empty, "default", "key C", "default C") - }, - ["en"] = new[] - { - new Translation(string.Empty, "en", "key A", "en A"), - new Translation(string.Empty, "en", "key B", "en B"), - new Translation(string.Empty, "en", "key C", "default C") - }, - ["zzz"] = new[] - { - new Translation(string.Empty, "zzz", "key A", "zzz A"), - new Translation(string.Empty, "zzz", "key C", "default C") - } - }; - expected["en-us"] = expected["en"].ToArray(); - return expected; - } - - /// Get whether two translations have the same public values. - /// The first translation to compare. - /// The second translation to compare. - private bool CompareEquality(Translation a, Translation b) - { - return a.Key == b.Key && a.ToString() == b.ToString(); - } - - /// Get the default placeholder text when a translation is missing. - /// The translation key. - private string GetPlaceholderText(string key) - { - return string.Format(Translation.PlaceholderText, key); - } - - - /********* - ** Test models - *********/ - /// A model used to test token support. - private class TokenModel - { - /// A sample token property. - public string Start { get; set; } - - /// A sample token property. - public string Middle { get; set; } - - /// A sample token field. - public string End; - } - } -} diff --git a/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs b/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs new file mode 100644 index 00000000..714756e0 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.RegularExpressions; +using NUnit.Framework; +using StardewModdingAPI.Utilities; + +namespace StardewModdingAPI.Tests.Utilities +{ + /// Unit tests for . + [TestFixture] + internal class SDateTests + { + /********* + ** Properties + *********/ + /// All valid seasons. + private static readonly string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; + + /// All valid days of a month. + private static readonly int[] ValidDays = Enumerable.Range(1, 28).ToArray(); + + /// Sample relative dates for test cases. + private static class Dates + { + /// The base date to which other dates are relative. + public const string Now = "02 summer Y2"; + + /// The day before . + public const string PrevDay = "01 summer Y2"; + + /// The month before . + public const string PrevMonth = "02 spring Y2"; + + /// The year before . + public const string PrevYear = "02 summer Y1"; + + /// The day after . + public const string NextDay = "03 summer Y2"; + + /// The month after . + public const string NextMonth = "02 fall Y2"; + + /// The year after . + public const string NextYear = "02 summer Y3"; + } + + + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = "Assert that the constructor sets the expected values for all valid dates.")] + public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.ValidSeasons))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) + { + // act + SDate date = new SDate(day, season, year); + + // assert + Assert.AreEqual(day, date.Day); + Assert.AreEqual(season, date.Season); + Assert.AreEqual(year, date.Year); + } + + [Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] + [TestCase(01, "Spring", 1)] // seasons are case-sensitive + [TestCase(01, "springs", 1)] // invalid season name + [TestCase(-1, "spring", 1)] // day < 0 + [TestCase(29, "spring", 1)] // day > 28 + [TestCase(01, "spring", -1)] // year < 1 + [TestCase(01, "spring", 0)] // year < 1 + [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] + public void Constructor_RejectsInvalidValues(int day, string season, int year) + { + // act & assert + Assert.Throws(() => _ = new SDate(day, season, year), "Constructing the invalid date didn't throw the expected exception."); + } + + /**** + ** ToString + ****/ + [Test(Description = "Assert that ToString returns the expected string.")] + [TestCase("14 spring Y1", ExpectedResult = "14 spring Y1")] + [TestCase("01 summer Y16", ExpectedResult = "01 summer Y16")] + [TestCase("28 fall Y10", ExpectedResult = "28 fall Y10")] + [TestCase("01 winter Y1", ExpectedResult = "01 winter Y1")] + public string ToString(string dateStr) + { + return this.GetDate(dateStr).ToString(); + } + + /**** + ** AddDays + ****/ + [Test(Description = "Assert that AddDays returns the expected date.")] + [TestCase("01 spring Y1", 15, ExpectedResult = "16 spring Y1")] // day transition + [TestCase("01 spring Y1", 28, ExpectedResult = "01 summer Y1")] // season transition + [TestCase("01 spring Y1", 28 * 4, ExpectedResult = "01 spring Y2")] // year transition + [TestCase("01 spring Y1", 28 * 7 + 17, ExpectedResult = "18 winter Y2")] // year transition + [TestCase("15 spring Y1", -14, ExpectedResult = "01 spring Y1")] // negative day transition + [TestCase("15 summer Y1", -28, ExpectedResult = "15 spring Y1")] // negative season transition + [TestCase("15 summer Y2", -28 * 4, ExpectedResult = "15 summer Y1")] // negative year transition + [TestCase("01 spring Y3", -(28 * 7 + 17), ExpectedResult = "12 spring Y1")] // negative year transition + public string AddDays(string dateStr, int addDays) + { + return this.GetDate(dateStr).AddDays(addDays).ToString(); + } + + /**** + ** GetHashCode + ****/ + [Test(Description = "Assert that GetHashCode returns a unique ordered value for every date.")] + public void GetHashCode_ReturnsUniqueOrderedValue() + { + IDictionary hashes = new Dictionary(); + int lastHash = int.MinValue; + for (int year = 1; year <= 4; year++) + { + foreach (string season in SDateTests.ValidSeasons) + { + foreach (int day in SDateTests.ValidDays) + { + SDate date = new SDate(day, season, year); + int hash = date.GetHashCode(); + if (hashes.TryGetValue(hash, out SDate otherDate)) + Assert.Fail($"Received identical hash code {hash} for dates {otherDate} and {date}."); + if (hash < lastHash) + Assert.Fail($"Received smaller hash code for date {date} ({hash}) relative to {hashes[lastHash]} ({lastHash})."); + + lastHash = hash; + hashes[hash] = date; + } + } + } + } + + [Test(Description = "Assert that the == operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_Equals(string now, string other) + { + return this.GetDate(now) == this.GetDate(other); + } + + [Test(Description = "Assert that the != operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_NotEquals(string now, string other) + { + return this.GetDate(now) != this.GetDate(other); + } + + [Test(Description = "Assert that the < operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_LessThan(string now, string other) + { + return this.GetDate(now) < this.GetDate(other); + } + + [Test(Description = "Assert that the <= operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_LessThanOrEqual(string now, string other) + { + return this.GetDate(now) <= this.GetDate(other); + } + + [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_MoreThan(string now, string other) + { + return this.GetDate(now) > this.GetDate(other); + } + + [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_MoreThanOrEqual(string now, string other) + { + return this.GetDate(now) > this.GetDate(other); + } + + + /********* + ** Private methods + *********/ + /// Convert a string date into a game date, to make unit tests easier to read. + /// The date string like "dd MMMM yy". + private SDate GetDate(string dateStr) + { + if (dateStr == null) + return null; + + void Fail(string reason) => throw new AssertionException($"Couldn't parse date '{dateStr}' because {reason}."); + + // parse + Match match = Regex.Match(dateStr, @"^(?\d+) (?\w+) Y(?\d+)$"); + if (!match.Success) + Fail("it doesn't match expected pattern (should be like 28 spring Y1)"); + + // extract parts + string season = match.Groups["season"].Value; + if (!int.TryParse(match.Groups["day"].Value, out int day)) + Fail($"'{match.Groups["day"].Value}' couldn't be parsed as a day."); + if (!int.TryParse(match.Groups["year"].Value, out int year)) + Fail($"'{match.Groups["year"].Value}' couldn't be parsed as a year."); + + // build date + return new SDate(day, season, year); + } + } +} -- cgit From a011c28d4000f469327ac85f79b4801a432a498b Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 19 Jun 2017 01:05:43 -0400 Subject: make version parsing stricter, add unit tests for parsing (#309) --- release-notes.md | 3 + .../StardewModdingAPI.Tests.csproj | 1 + .../Utilities/SemanticVersionTests.cs | 136 +++++++++++++++++++++ src/StardewModdingAPI/SemanticVersion.cs | 32 +++-- 4 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs (limited to 'src/StardewModdingAPI.Tests') diff --git a/release-notes.md b/release-notes.md index 851e6abe..67de304a 100644 --- a/release-notes.md +++ b/release-notes.md @@ -20,6 +20,9 @@ For players: For modders: * You can now specify minimum dependency versions in `manifest.json`. * Added `System.ValueTuple.dll` to the SMAPI install package so mods can use [C# 7 value tuples](https://docs.microsoft.com/en-us/dotnet/csharp/tuples). +* Fixed `SemanticVersion` parsing some invalid versions into close approximations (like `1.apple` → `1.0-apple`). +* Fixed `SemanticVersion` not treating hyphens as separators when comparing prerelease tags. + _(While that was technically correct, it leads to unintuitive behaviour like sorting `-alpha-2` _after_ `-alpha-10`, even though `-alpha.2` sorts before `-alpha.10`.)_ ## 1.14 See [log](https://github.com/Pathoschild/SMAPI/compare/1.13...1.14). diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index f626eb08..7129cfb7 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -51,6 +51,7 @@ Properties\GlobalAssemblyInfo.cs + diff --git a/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs b/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs new file mode 100644 index 00000000..355e3a84 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using NUnit.Framework; + +namespace StardewModdingAPI.Tests.Utilities +{ + /// Unit tests for . + [TestFixture] + internal class SemanticVersionTests + { + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = "Assert that the constructor sets the expected values for all valid versions.")] + [TestCase("1.0", ExpectedResult = "1.0")] + [TestCase("1.0.0", ExpectedResult = "1.0")] + [TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")] + [TestCase("1.2-some-tag.4", ExpectedResult = "1.2-some-tag.4")] + [TestCase("1.2.3-some-tag.4", ExpectedResult = "1.2.3-some-tag.4")] + [TestCase("1.2.3-some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")] + public string Constructor_FromString(string input) + { + return new SemanticVersion(input).ToString(); + } + + [Test(Description = "Assert that the constructor sets the expected values for all valid versions.")] + [TestCase(1, 0, 0, null, ExpectedResult = "1.0")] + [TestCase(3000, 4000, 5000, null, ExpectedResult = "3000.4000.5000")] + [TestCase(1, 2, 3, "", ExpectedResult = "1.2.3")] + [TestCase(1, 2, 3, " ", ExpectedResult = "1.2.3")] + [TestCase(1, 2, 3, "some-tag.4", ExpectedResult = "1.2.3-some-tag.4")] + [TestCase(1, 2, 3, "some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")] + public string Constructor_FromParts(int major, int minor, int patch, string tag) + { + // act + ISemanticVersion version = new SemanticVersion(major, minor, patch, tag); + + // assert + Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value."); + Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value."); + Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value."); + Assert.AreEqual(string.IsNullOrWhiteSpace(tag) ? null : tag.Trim(), version.Build, "The tag doesn't match the given value."); + return version.ToString(); + } + + [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("1")] + [TestCase("01.0")] + [TestCase("1.05")] + [TestCase("1.5.06")] // leading zeros specifically prohibited by spec + [TestCase("1.2.3.4")] + [TestCase("1.apple")] + [TestCase("1.2.apple")] + [TestCase("1.2.3.apple")] + [TestCase("1..2..3")] + [TestCase("1.2.3-")] + [TestCase("1.2.3-some-tag...")] + [TestCase("1.2.3-some-tag...4")] + [TestCase("apple")] + [TestCase("-apple")] + [TestCase("-5")] + public void Constructor_FromString_WithInvalidValues(string input) + { + if (input == null) + this.AssertAndLogException(() => new SemanticVersion(input)); + else + this.AssertAndLogException(() => new SemanticVersion(input)); + } + + //[Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] + //[TestCase(01, "Spring", 1)] // seasons are case-sensitive + //[TestCase(01, "springs", 1)] // invalid season name + //[TestCase(-1, "spring", 1)] // day < 0 + //[TestCase(29, "spring", 1)] // day > 28 + //[TestCase(01, "spring", -1)] // year < 1 + //[TestCase(01, "spring", 0)] // year < 1 + //[SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] + //public void Constructor_RejectsInvalidValues(int day, string season, int year) + //{ + // // act & assert + // Assert.Throws(() => _ = new SDate(day, season, year), "Constructing the invalid date didn't throw the expected exception."); + //} + + + /********* + ** Private methods + *********/ + /// Assert that the expected exception type is thrown, and log the action output and thrown exception. + /// The expected exception type. + /// The action which may throw the exception. + /// The message to log if the expected exception isn't thrown. + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] + private void AssertAndLogException(Func action, string message = null) + where T : Exception + { + this.AssertAndLogException(() => + { + object result = action(); + TestContext.WriteLine($"Func result: {result}"); + }); + } + + /// Assert that the expected exception type is thrown, and log the thrown exception. + /// The expected exception type. + /// The action which may throw the exception. + /// The message to log if the expected exception isn't thrown. + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] + private void AssertAndLogException(Action action, string message = null) + where T : Exception + { + try + { + action(); + } + catch (T ex) + { + TestContext.WriteLine($"Exception thrown:\n{ex}"); + return; + } + catch (Exception ex) when (!(ex is AssertionException)) + { + TestContext.WriteLine($"Exception thrown:\n{ex}"); + Assert.Fail(message ?? $"Didn't throw the expected exception; expected {typeof(T).FullName}, got {ex.GetType().FullName}."); + } + + // no exception thrown + Assert.Fail(message ?? "Didn't throw an exception."); + } + } +} diff --git a/src/StardewModdingAPI/SemanticVersion.cs b/src/StardewModdingAPI/SemanticVersion.cs index a2adb657..4b27c819 100644 --- a/src/StardewModdingAPI/SemanticVersion.cs +++ b/src/StardewModdingAPI/SemanticVersion.cs @@ -10,8 +10,14 @@ namespace StardewModdingAPI ** Properties *********/ /// A regular expression matching a semantic version string. - /// Derived from https://github.com/maxhauser/semver. - private static readonly Regex Regex = new Regex(@"^(?\d+)(\.(?\d+))?(\.(?\d+))?(?.*)$", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture); + /// + /// This pattern is derived from the BNF documentation in the semver repo, + /// with three important deviations intended to support Stardew Valley mod conventions: + /// - allows short-form "x.y" versions; + /// - allows hyphens in prerelease tags as synonyms for dots (like "-unofficial-update.3"); + /// - doesn't allow '+build' suffixes. + /// + private static readonly Regex Regex = new Regex(@"^(?0|[1-9]\d*)\.(?0|[1-9]\d*)(\.(?0|[1-9]\d*))?(?:-(?([a-z0-9]+[\-\.]?)+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture); /********* @@ -48,17 +54,22 @@ namespace StardewModdingAPI /// Construct an instance. /// The semantic version string. + /// The is null. /// The is not a valid semantic version. public SemanticVersion(string version) { - var match = SemanticVersion.Regex.Match(version); + // parse + if (version == null) + throw new ArgumentNullException(nameof(version), "The input version string can't be null."); + var match = SemanticVersion.Regex.Match(version.Trim()); if (!match.Success) - throw new FormatException($"The input '{version}' is not a valid semantic version."); + throw new FormatException($"The input '{version}' isn't a valid semantic version."); + // initialise this.MajorVersion = int.Parse(match.Groups["major"].Value); this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0; this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0; - this.Build = match.Groups["build"].Success ? this.GetNormalisedTag(match.Groups["build"].Value) : null; + this.Build = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null; } /// Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version. @@ -93,8 +104,8 @@ namespace StardewModdingAPI return curOlder; // compare two pre-release tag values - string[] curParts = this.Build.Split('.'); - string[] otherParts = other.Build.Split('.'); + string[] curParts = this.Build.Split('.', '-'); + string[] otherParts = other.Build.Split('.', '-'); for (int i = 0; i < curParts.Length; i++) { // longer prerelease tag supercedes if otherwise equal @@ -200,6 +211,7 @@ namespace StardewModdingAPI } } + /********* ** Private methods *********/ @@ -207,11 +219,9 @@ namespace StardewModdingAPI /// The tag to normalise. private string GetNormalisedTag(string tag) { - tag = tag?.Trim().Trim('-', '.'); - if (string.IsNullOrWhiteSpace(tag)) + tag = tag?.Trim(); + if (string.IsNullOrWhiteSpace(tag) || tag == "0") // '0' from incorrect examples in old SMAPI documentation return null; - if (tag == "0") - return null; // from incorrect examples in old SMAPI documentation return tag; } } -- cgit From 565aa2c67b2619e478e2e7c1e212926ca1ba2369 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 19 Jun 2017 01:26:22 -0400 Subject: add unit tests for version comparison --- .../Utilities/SemanticVersionTests.cs | 145 +++++++++++++++++++-- 1 file changed, 132 insertions(+), 13 deletions(-) (limited to 'src/StardewModdingAPI.Tests') diff --git a/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs b/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs index 355e3a84..95d0d74f 100644 --- a/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs @@ -73,19 +73,138 @@ namespace StardewModdingAPI.Tests.Utilities this.AssertAndLogException(() => new SemanticVersion(input)); } - //[Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] - //[TestCase(01, "Spring", 1)] // seasons are case-sensitive - //[TestCase(01, "springs", 1)] // invalid season name - //[TestCase(-1, "spring", 1)] // day < 0 - //[TestCase(29, "spring", 1)] // day > 28 - //[TestCase(01, "spring", -1)] // year < 1 - //[TestCase(01, "spring", 0)] // year < 1 - //[SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] - //public void Constructor_RejectsInvalidValues(int day, string season, int year) - //{ - // // act & assert - // Assert.Throws(() => _ = new SDate(day, season, year), "Constructing the invalid date didn't throw the expected exception."); - //} + /**** + ** CompareTo + ****/ + [Test(Description = "Assert that version.CompareTo returns the expected value.")] + // equal + [TestCase("0.5.7", "0.5.7", ExpectedResult = 0)] + [TestCase("1.0", "1.0", ExpectedResult = 0)] + [TestCase("1.0-beta", "1.0-beta", ExpectedResult = 0)] + [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = 0)] + [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = 0)] + + // less than + [TestCase("0.5.7", "0.5.8", ExpectedResult = -1)] + [TestCase("1.0", "1.1", ExpectedResult = -1)] + [TestCase("1.0-beta", "1.0", ExpectedResult = -1)] + [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = -1)] + [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = -1)] + [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = -1)] + [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = -1)] + + // more than + [TestCase("0.5.8", "0.5.7", ExpectedResult = 1)] + [TestCase("1.1", "1.0", ExpectedResult = 1)] + [TestCase("1.0", "1.0-beta", ExpectedResult = 1)] + [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = 1)] + [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = 1)] + [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = 1)] + [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)] + public int CompareTo(string versionStrA, string versionStrB) + { + ISemanticVersion versionA = new SemanticVersion(versionStrA); + ISemanticVersion versionB = new SemanticVersion(versionStrB); + return versionA.CompareTo(versionB); + } + + /**** + ** IsOlderThan + ****/ + [Test(Description = "Assert that version.IsOlderThan returns the expected value.")] + // keep test cases in sync with CompareTo for simplicity. + // equal + [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] + [TestCase("1.0", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] + + // less than + [TestCase("0.5.7", "0.5.8", ExpectedResult = true)] + [TestCase("1.0", "1.1", ExpectedResult = true)] + [TestCase("1.0-beta", "1.0", ExpectedResult = true)] + [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = true)] + [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = true)] + [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = true)] + + // more than + [TestCase("0.5.8", "0.5.7", ExpectedResult = false)] + [TestCase("1.1", "1.0", ExpectedResult = false)] + [TestCase("1.0", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = false)] + [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = false)] + [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)] + public bool IsOlderThan(string versionStrA, string versionStrB) + { + ISemanticVersion versionA = new SemanticVersion(versionStrA); + ISemanticVersion versionB = new SemanticVersion(versionStrB); + return versionA.IsOlderThan(versionB); + } + + /**** + ** IsNewerThan + ****/ + [Test(Description = "Assert that version.IsNewerThan returns the expected value.")] + // keep test cases in sync with CompareTo for simplicity. + // equal + [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] + [TestCase("1.0", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] + + // less than + [TestCase("0.5.7", "0.5.8", ExpectedResult = false)] + [TestCase("1.0", "1.1", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = false)] + [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = false)] + [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = false)] + + // more than + [TestCase("0.5.8", "0.5.7", ExpectedResult = true)] + [TestCase("1.1", "1.0", ExpectedResult = true)] + [TestCase("1.0", "1.0-beta", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = true)] + [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = true)] + [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)] + public bool IsNewerThan(string versionStrA, string versionStrB) + { + ISemanticVersion versionA = new SemanticVersion(versionStrA); + ISemanticVersion versionB = new SemanticVersion(versionStrB); + return versionA.IsNewerThan(versionB); + } + + /**** + ** IsBetween + ****/ + [Test(Description = "Assert that version.IsNewerThan returns the expected value.")] + // is between + [TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)] + [TestCase("1.0", "1.0", "1.1", ExpectedResult = true)] + [TestCase("1.0", "1.0-beta", "1.1", ExpectedResult = true)] + [TestCase("1.0", "0.5", "1.1", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta.1", "1.0-beta.3", ExpectedResult = true)] + [TestCase("1.0-beta-2", "1.0-beta-1", "1.0-beta-3", ExpectedResult = true)] + + // is not between + [TestCase("1.0-beta", "1.0", "1.1", ExpectedResult = false)] + [TestCase("1.0", "1.1", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.1", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta.10", "1.0-beta.3", ExpectedResult = false)] + [TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)] + public bool IsBetween(string versionStr, string lowerStr, string upperStr) + { + ISemanticVersion lower = new SemanticVersion(lowerStr); + ISemanticVersion upper = new SemanticVersion(upperStr); + ISemanticVersion version = new SemanticVersion(versionStr); + return version.IsBetween(lower, upper); + } /********* -- cgit From 6073d24cabe3fa93ddbba7e4a613e7342a8b20c2 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 26 Jun 2017 11:08:45 -0400 Subject: change manifest.MinimumApiVersion to ISemanticVersion --- release-notes.md | 1 + src/StardewModdingAPI.Tests/Core/ModResolverTests.cs | 4 ++-- src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs | 14 +++----------- src/StardewModdingAPI/Framework/Models/Manifest.cs | 3 ++- src/StardewModdingAPI/IManifest.cs | 2 +- 5 files changed, 9 insertions(+), 15 deletions(-) (limited to 'src/StardewModdingAPI.Tests') diff --git a/release-notes.md b/release-notes.md index d1f02588..e5cfb7f8 100644 --- a/release-notes.md +++ b/release-notes.md @@ -23,6 +23,7 @@ For modders: * You can now specify minimum dependency versions in `manifest.json`. * Added `System.ValueTuple.dll` to the SMAPI install package so mods can use [C# 7 value tuples](https://docs.microsoft.com/en-us/dotnet/csharp/tuples). * Improved trace logging when SMAPI loads mods. +* Changed `manifest.MinimumApiVersion` from string to `ISemanticVersion`. * Fixed `SemanticVersion` parsing some invalid versions into close approximations (like `1.apple` → `1.0-apple`). * Fixed `SemanticVersion` not treating hyphens as separators when comparing prerelease tags. _(While that was technically correct, it leads to unintuitive behaviour like sorting `-alpha-2` _after_ `-alpha-10`, even though `-alpha.2` sorts before `-alpha.10`.)_ diff --git a/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs b/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs index efb1c348..36cc3495 100644 --- a/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs +++ b/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs @@ -100,7 +100,7 @@ namespace StardewModdingAPI.Tests.Core Assert.AreEqual(original[nameof(IManifest.Author)], mod.Manifest.Author, "The manifest's author doesn't match."); Assert.AreEqual(original[nameof(IManifest.Description)], mod.Manifest.Description, "The manifest's description doesn't match."); Assert.AreEqual(original[nameof(IManifest.EntryDll)], mod.Manifest.EntryDll, "The manifest's entry DLL doesn't match."); - Assert.AreEqual(original[nameof(IManifest.MinimumApiVersion)], mod.Manifest.MinimumApiVersion, "The manifest's minimum API version doesn't match."); + Assert.AreEqual(original[nameof(IManifest.MinimumApiVersion)], mod.Manifest.MinimumApiVersion?.ToString(), "The manifest's minimum API version doesn't match."); Assert.AreEqual(original[nameof(IManifest.Version)]?.ToString(), mod.Manifest.Version?.ToString(), "The manifest's version doesn't match."); Assert.IsNotNull(mod.Manifest.ExtraFields, "The extra fields should not be null."); @@ -159,7 +159,7 @@ namespace StardewModdingAPI.Tests.Core Mock mock = new Mock(MockBehavior.Strict); mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); mock.Setup(p => p.Compatibility).Returns(() => null); - mock.Setup(p => p.Manifest).Returns(this.GetManifest(m => m.MinimumApiVersion = "1.1")); + mock.Setup(p => p.Manifest).Returns(this.GetManifest(m => m.MinimumApiVersion = new SemanticVersion("1.1"))); mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); // act diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 045b175c..cefc860b 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -122,18 +122,10 @@ namespace StardewModdingAPI.Framework.ModLoading } // validate SMAPI version - if (!string.IsNullOrWhiteSpace(mod.Manifest.MinimumApiVersion)) + if (mod.Manifest.MinimumApiVersion?.IsNewerThan(apiVersion) == true) { - if (!SemanticVersion.TryParse(mod.Manifest.MinimumApiVersion, out ISemanticVersion minVersion)) - { - mod.SetStatus(ModMetadataStatus.Failed, $"it has an invalid minimum SMAPI version '{mod.Manifest.MinimumApiVersion}'. This should be a semantic version number like {apiVersion}."); - continue; - } - if (minVersion.IsNewerThan(apiVersion)) - { - mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); - continue; - } + mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod."); + continue; } // validate DLL path diff --git a/src/StardewModdingAPI/Framework/Models/Manifest.cs b/src/StardewModdingAPI/Framework/Models/Manifest.cs index be781585..8e5d13f8 100644 --- a/src/StardewModdingAPI/Framework/Models/Manifest.cs +++ b/src/StardewModdingAPI/Framework/Models/Manifest.cs @@ -25,7 +25,8 @@ namespace StardewModdingAPI.Framework.Models public ISemanticVersion Version { get; set; } /// The minimum SMAPI version required by this mod, if any. - public string MinimumApiVersion { get; set; } + [JsonConverter(typeof(ManifestFieldConverter))] + public ISemanticVersion MinimumApiVersion { get; set; } /// The name of the DLL in the directory that has the method. public string EntryDll { get; set; } diff --git a/src/StardewModdingAPI/IManifest.cs b/src/StardewModdingAPI/IManifest.cs index 9533aadb..407db1ce 100644 --- a/src/StardewModdingAPI/IManifest.cs +++ b/src/StardewModdingAPI/IManifest.cs @@ -21,7 +21,7 @@ namespace StardewModdingAPI ISemanticVersion Version { get; } /// The minimum SMAPI version required by this mod, if any. - string MinimumApiVersion { get; } + ISemanticVersion MinimumApiVersion { get; } /// The unique mod ID. string UniqueID { get; } -- cgit From 136525b40df5d47b8e398a394af081e19efcf86c Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 3 Jul 2017 01:29:56 -0400 Subject: remove System.ValueTuple This caused reference errors on Linux/Mac, and there aren't enough use cases to look into it further for now. --- release-notes.md | 1 - .../StardewModdingAPI.Tests.csproj | 3 --- src/StardewModdingAPI.Tests/packages.config | 1 - .../Framework/ModLoading/ModResolver.cs | 2 +- src/StardewModdingAPI/Framework/SContentManager.cs | 24 +++++++++++----------- src/StardewModdingAPI/StardewModdingAPI.csproj | 3 --- src/StardewModdingAPI/packages.config | 1 - src/prepare-install-package.targets | 1 - 8 files changed, 13 insertions(+), 23 deletions(-) (limited to 'src/StardewModdingAPI.Tests') diff --git a/release-notes.md b/release-notes.md index 94106ba6..c1efc5ae 100644 --- a/release-notes.md +++ b/release-notes.md @@ -27,7 +27,6 @@ For players: For modders: * Added `SDate` utility for in-game date calculations (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Dates)). * Added support for minimum dependency versions in `manifest.json` (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Manifest)). -* Added `System.ValueTuple.dll` to the SMAPI install package so mods can use [C# 7 value tuples](https://docs.microsoft.com/en-us/dotnet/csharp/tuples). * Added more useful logging when loading mods. * Changed `manifest.MinimumApiVersion` from string to `ISemanticVersion`. This shouldn't affect mods unless they referenced that field in code. * Fixed `SemanticVersion` parsing some invalid versions into close approximations (like `1.apple` → `1.0-apple`). diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index 7129cfb7..9bfd7567 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -43,9 +43,6 @@ ..\packages\NUnit.3.6.1\lib\net45\nunit.framework.dll - - ..\packages\System.ValueTuple.4.3.1\lib\netstandard1.0\System.ValueTuple.dll - diff --git a/src/StardewModdingAPI.Tests/packages.config b/src/StardewModdingAPI.Tests/packages.config index 7ba8c7b2..ba954308 100644 --- a/src/StardewModdingAPI.Tests/packages.config +++ b/src/StardewModdingAPI.Tests/packages.config @@ -4,5 +4,4 @@ - \ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index ceb51bbb..9c56aaa4 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -228,7 +228,7 @@ namespace StardewModdingAPI.Framework.ModLoading from entry in mod.Manifest.Dependencies let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase)) orderby entry.UniqueID - select (ID: entry.UniqueID, MinVersion: entry.MinimumVersion, Mod: dependencyMod) + select new { ID = entry.UniqueID, MinVersion = entry.MinimumVersion, Mod = dependencyMod } ) .ToArray(); diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs index 5707aab1..ebf1c8a5 100644 --- a/src/StardewModdingAPI/Framework/SContentManager.cs +++ b/src/StardewModdingAPI/Framework/SContentManager.cs @@ -232,11 +232,11 @@ namespace StardewModdingAPI.Framework { try { - return entry.Interceptor.CanLoad(info); + return entry.Value.CanLoad(info); } catch (Exception ex) { - this.Monitor.Log($"{entry.Mod.DisplayName} crashed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"{entry.Key.DisplayName} crashed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); return false; } }) @@ -247,14 +247,14 @@ namespace StardewModdingAPI.Framework return null; if (loaders.Length > 1) { - string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray(); + string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray(); this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn); return null; } // fetch asset from loader - IModMetadata mod = loaders[0].Mod; - IAssetLoader loader = loaders[0].Interceptor; + IModMetadata mod = loaders[0].Key; + IAssetLoader loader = loaders[0].Value; T data; try { @@ -290,8 +290,8 @@ namespace StardewModdingAPI.Framework foreach (var entry in this.GetInterceptors(this.Editors)) { // check for match - IModMetadata mod = entry.Mod; - IAssetEditor editor = entry.Interceptor; + IModMetadata mod = entry.Key; + IAssetEditor editor = entry.Value; try { if (!editor.CanEdit(info)) @@ -299,7 +299,7 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - this.Monitor.Log($"{entry.Mod.DisplayName} crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"{mod.DisplayName} crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); continue; } @@ -312,7 +312,7 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - this.Monitor.Log($"{entry.Mod.DisplayName} crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"{mod.DisplayName} crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } // validate edit @@ -333,7 +333,7 @@ namespace StardewModdingAPI.Framework } /// Get all registered interceptors from a list. - private IEnumerable<(IModMetadata Mod, T Interceptor)> GetInterceptors(IDictionary> entries) + private IEnumerable> GetInterceptors(IDictionary> entries) { foreach (var entry in entries) { @@ -342,11 +342,11 @@ namespace StardewModdingAPI.Framework // special case if mod is an interceptor if (metadata.Mod is T modAsInterceptor) - yield return (metadata, modAsInterceptor); + yield return new KeyValuePair(metadata, modAsInterceptor); // registered editors foreach (T interceptor in interceptors) - yield return (metadata, interceptor); + yield return new KeyValuePair(metadata, interceptor); } } } diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index 4d65b1af..bf1c43d1 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -79,9 +79,6 @@ True - - ..\packages\System.ValueTuple.4.3.1\lib\netstandard1.0\System.ValueTuple.dll - diff --git a/src/StardewModdingAPI/packages.config b/src/StardewModdingAPI/packages.config index 6a2a8d1b..e5fa3c3a 100644 --- a/src/StardewModdingAPI/packages.config +++ b/src/StardewModdingAPI/packages.config @@ -2,5 +2,4 @@ - \ No newline at end of file diff --git a/src/prepare-install-package.targets b/src/prepare-install-package.targets index df8bb100..f0debdd2 100644 --- a/src/prepare-install-package.targets +++ b/src/prepare-install-package.targets @@ -31,7 +31,6 @@ - -- cgit From d928bf188e9ab171223bc07d7209d2887d954642 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Thu, 6 Jul 2017 17:46:04 -0400 Subject: add optional mod dependencies in SMAPI 2.0 (#287) --- release-notes.md | 4 ++- .../Core/ModResolverTests.cs | 34 ++++++++++++++++++++++ .../Framework/ModLoading/ModResolver.cs | 25 ++++++++++++---- .../Framework/Models/ManifestDependency.cs | 14 ++++++++- .../Serialisation/ManifestFieldConverter.cs | 5 ++++ src/StardewModdingAPI/IManifestDependency.cs | 5 ++++ 6 files changed, 80 insertions(+), 7 deletions(-) (limited to 'src/StardewModdingAPI.Tests') diff --git a/release-notes.md b/release-notes.md index dcc21aaf..ca0e00f7 100644 --- a/release-notes.md +++ b/release-notes.md @@ -11,7 +11,9 @@ For mod developers: * Added `InputEvents` which unify keyboard, mouse, and controller input for much simpler input handling (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Input_events)). * Added useful `InputEvents` metadata like the cursor position, grab tile, etc. * Added ability to prevent the game from handling a button press via `InputEvents`. -* The `manifest.json` version can now be specified as a string. +* In `manifest.json`: + * Dependencies can now be optional. + * The version can now be a string like `"1.0-alpha"` instead of a structure. * Removed all deprecated code. ## 1.15 diff --git a/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs b/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs index 36cc3495..b451465e 100644 --- a/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs +++ b/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs @@ -411,6 +411,40 @@ namespace StardewModdingAPI.Tests.Core Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); } +#if SMAPI_2_0 + [Test(Description = "Assert that optional dependencies are sorted correctly if present.")] + public void ProcessDependencies_IfOptional() + { + // arrange + // A ◀── B + Mock modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); + Mock modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0", required: false)), allowStatusChange: false); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object, modA.Object }).ToArray(); + + // assert + Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); + } + + [Test(Description = "Assert that optional dependencies are accepted if they're missing.")] + public void ProcessDependencies_IfOptional_SucceedsIfMissing() + { + // arrange + // A ◀── B where A doesn't exist + Mock modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0", required: false)), allowStatusChange: false); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object }).ToArray(); + + // assert + Assert.AreEqual(1, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modB.Object, mods[0], "The load order is incorrect: mod B should be first since it's the only mod."); + } +#endif + /********* ** Private methods diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 9c56aaa4..38dddce7 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -228,13 +228,24 @@ namespace StardewModdingAPI.Framework.ModLoading from entry in mod.Manifest.Dependencies let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase)) orderby entry.UniqueID - select new { ID = entry.UniqueID, MinVersion = entry.MinimumVersion, Mod = dependencyMod } + select new + { + ID = entry.UniqueID, + MinVersion = entry.MinimumVersion, + Mod = dependencyMod, + IsRequired = +#if SMAPI_2_0 + entry.IsRequired +#else + true +#endif + } ) .ToArray(); // missing required dependencies, mark failed { - string[] failedIDs = (from entry in dependencies where entry.Mod == null select entry.ID).ToArray(); + string[] failedIDs = (from entry in dependencies where entry.IsRequired && entry.Mod == null select entry.ID).ToArray(); if (failedIDs.Any()) { sortedMods.Push(mod); @@ -248,7 +259,7 @@ namespace StardewModdingAPI.Framework.ModLoading string[] failedLabels = ( from entry in dependencies - where entry.MinVersion != null && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version) + where entry.Mod != null && entry.MinVersion != null && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version) select $"{entry.Mod.DisplayName} (needs {entry.MinVersion} or later)" ) .ToArray(); @@ -265,11 +276,15 @@ namespace StardewModdingAPI.Framework.ModLoading states[mod] = ModDependencyStatus.Checking; // recursively sort dependencies - IModMetadata[] modsToLoadFirst = dependencies.Select(p => p.Mod).ToArray(); - foreach (IModMetadata requiredMod in modsToLoadFirst) + foreach (var dependency in dependencies) { + IModMetadata requiredMod = dependency.Mod; var subchain = new List(currentChain) { mod }; + // ignore missing optional dependency + if (!dependency.IsRequired && requiredMod == null) + continue; + // detect dependency loop if (states[requiredMod] == ModDependencyStatus.Checking) { diff --git a/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs index a0ff0c90..25d92a29 100644 --- a/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs +++ b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs @@ -12,6 +12,10 @@ /// The minimum required version (if any). public ISemanticVersion MinimumVersion { get; set; } +#if SMAPI_2_0 + /// Whether the dependency must be installed to use the mod. + public bool IsRequired { get; set; } +#endif /********* ** Public methods @@ -19,12 +23,20 @@ /// Construct an instance. /// The unique mod ID to require. /// The minimum required version (if any). - public ManifestDependency(string uniqueID, string minimumVersion) + /// Whether the dependency must be installed to use the mod. + public ManifestDependency(string uniqueID, string minimumVersion +#if SMAPI_2_0 + , bool required = true +#endif + ) { this.UniqueID = uniqueID; this.MinimumVersion = !string.IsNullOrWhiteSpace(minimumVersion) ? new SemanticVersion(minimumVersion) : null; +#if SMAPI_2_0 + this.IsRequired = required; +#endif } } } diff --git a/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs index e6d62d50..5be0f0b6 100644 --- a/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs +++ b/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs @@ -73,7 +73,12 @@ namespace StardewModdingAPI.Framework.Serialisation { string uniqueID = obj.Value(nameof(IManifestDependency.UniqueID)); string minVersion = obj.Value(nameof(IManifestDependency.MinimumVersion)); +#if SMAPI_2_0 + bool required = obj.Value(nameof(IManifestDependency.IsRequired)) ?? true; + result.Add(new ManifestDependency(uniqueID, minVersion, required)); +#else result.Add(new ManifestDependency(uniqueID, minVersion)); +#endif } return result.ToArray(); } diff --git a/src/StardewModdingAPI/IManifestDependency.cs b/src/StardewModdingAPI/IManifestDependency.cs index ebb1140e..027c1d59 100644 --- a/src/StardewModdingAPI/IManifestDependency.cs +++ b/src/StardewModdingAPI/IManifestDependency.cs @@ -11,5 +11,10 @@ /// The minimum required version (if any). ISemanticVersion MinimumVersion { get; } + +#if SMAPI_2_0 + /// Whether the dependency must be installed to use the mod. + bool IsRequired { get; } +#endif } } -- cgit From f033b5a2f72b96168f6e20e96fa50742e70b01d6 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 7 Jul 2017 11:39:09 -0400 Subject: group mod helpers (#318) --- .../Core/TranslationTests.cs | 1 + src/StardewModdingAPI/Framework/CommandHelper.cs | 53 ---- src/StardewModdingAPI/Framework/ContentHelper.cs | 351 --------------------- src/StardewModdingAPI/Framework/ModHelper.cs | 131 -------- .../Framework/ModHelpers/CommandHelper.cs | 52 +++ .../Framework/ModHelpers/ContentHelper.cs | 351 +++++++++++++++++++++ .../Framework/ModHelpers/ModHelper.cs | 131 ++++++++ .../Framework/ModHelpers/ReflectionHelper.cs | 158 ++++++++++ .../Framework/ModHelpers/TranslationHelper.cs | 138 ++++++++ .../Framework/TranslationHelper.cs | 138 -------- src/StardewModdingAPI/Program.cs | 1 + src/StardewModdingAPI/ReflectionHelper.cs | 158 ---------- src/StardewModdingAPI/StardewModdingAPI.csproj | 12 +- 13 files changed, 838 insertions(+), 837 deletions(-) delete mode 100644 src/StardewModdingAPI/Framework/CommandHelper.cs delete mode 100644 src/StardewModdingAPI/Framework/ContentHelper.cs delete mode 100644 src/StardewModdingAPI/Framework/ModHelper.cs create mode 100644 src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs create mode 100644 src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs create mode 100644 src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs create mode 100644 src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs create mode 100644 src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs delete mode 100644 src/StardewModdingAPI/Framework/TranslationHelper.cs delete mode 100644 src/StardewModdingAPI/ReflectionHelper.cs (limited to 'src/StardewModdingAPI.Tests') diff --git a/src/StardewModdingAPI.Tests/Core/TranslationTests.cs b/src/StardewModdingAPI.Tests/Core/TranslationTests.cs index ce3431e4..fceef0a3 100644 --- a/src/StardewModdingAPI.Tests/Core/TranslationTests.cs +++ b/src/StardewModdingAPI.Tests/Core/TranslationTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.ModHelpers; using StardewValley; namespace StardewModdingAPI.Tests.Core diff --git a/src/StardewModdingAPI/Framework/CommandHelper.cs b/src/StardewModdingAPI/Framework/CommandHelper.cs deleted file mode 100644 index 86734fc5..00000000 --- a/src/StardewModdingAPI/Framework/CommandHelper.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; - -namespace StardewModdingAPI.Framework -{ - /// Provides an API for managing console commands. - internal class CommandHelper : ICommandHelper - { - /********* - ** Accessors - *********/ - /// The friendly mod name for this instance. - private readonly string ModName; - - /// Manages console commands. - private readonly CommandManager CommandManager; - - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The friendly mod name for this instance. - /// Manages console commands. - public CommandHelper(string modName, CommandManager commandManager) - { - this.ModName = modName; - this.CommandManager = commandManager; - } - - /// Add a console command. - /// The command name, which the user must type to trigger it. - /// The human-readable documentation shown when the player runs the built-in 'help' command. - /// The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user. - /// The or is null or empty. - /// The is not a valid format. - /// There's already a command with that name. - public ICommandHelper Add(string name, string documentation, Action callback) - { - this.CommandManager.Add(this.ModName, name, documentation, callback); - return this; - } - - /// Trigger a command. - /// The command name. - /// The command arguments. - /// Returns whether a matching command was triggered. - public bool Trigger(string name, string[] arguments) - { - return this.CommandManager.Trigger(name, arguments); - } - } -} diff --git a/src/StardewModdingAPI/Framework/ContentHelper.cs b/src/StardewModdingAPI/Framework/ContentHelper.cs deleted file mode 100644 index 0c09fe94..00000000 --- a/src/StardewModdingAPI/Framework/ContentHelper.cs +++ /dev/null @@ -1,351 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Content; -using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Framework.Exceptions; -using StardewValley; -using xTile; -using xTile.Format; -using xTile.Tiles; - -namespace StardewModdingAPI.Framework -{ - /// Provides an API for loading content assets. - internal class ContentHelper : IContentHelper - { - /********* - ** Properties - *********/ - /// SMAPI's underlying content manager. - private readonly SContentManager ContentManager; - - /// The absolute path to the mod folder. - private readonly string ModFolderPath; - - /// The path to the mod's folder, relative to the game's content folder (e.g. "../Mods/ModName"). - private readonly string ModFolderPathFromContent; - - /// The friendly mod name for use in errors. - private readonly string ModName; - - - /********* - ** Accessors - *********/ - /// The observable implementation of . - internal ObservableCollection ObservableAssetEditors { get; } = new ObservableCollection(); - - /// The observable implementation of . - internal ObservableCollection ObservableAssetLoaders { get; } = new ObservableCollection(); - - /// Interceptors which provide the initial versions of matching content assets. - internal IList AssetLoaders => this.ObservableAssetLoaders; - - /// Interceptors which edit matching content assets after they're loaded. - internal IList AssetEditors => this.ObservableAssetEditors; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// SMAPI's underlying content manager. - /// The absolute path to the mod folder. - /// The friendly mod name for use in errors. - public ContentHelper(SContentManager contentManager, string modFolderPath, string modName) - { - this.ContentManager = contentManager; - this.ModFolderPath = modFolderPath; - this.ModName = modName; - this.ModFolderPathFromContent = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath); - } - - /// Load content from the game folder or mod folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. - /// The expected data type. The main supported types are and dictionaries; other types may be supported by the game's content pipeline. - /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. - /// Where to search for a matching content asset. - /// The is empty or contains invalid characters. - /// The content asset couldn't be loaded (e.g. because it doesn't exist). - public T Load(string key, ContentSource source = ContentSource.ModFolder) - { - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); - - this.AssertValidAssetKeyFormat(key); - try - { - switch (source) - { - case ContentSource.GameContent: - return this.ContentManager.Load(key); - - case ContentSource.ModFolder: - // get file - FileInfo file = this.GetModFile(key); - if (!file.Exists) - throw GetContentError($"there's no matching file at path '{file.FullName}'."); - - // get asset path - string assetPath = this.GetModAssetPath(key, file.FullName); - - // try cache - if (this.ContentManager.IsLoaded(assetPath)) - return this.ContentManager.Load(assetPath); - - // load content - switch (file.Extension.ToLower()) - { - // XNB file - case ".xnb": - { - T asset = this.ContentManager.Load(assetPath); - if (asset is Map) - this.FixLocalMapTilesheets(asset as Map, key); - return asset; - } - - // unpacked map - case ".tbin": - { - // validate - if (typeof(T) != typeof(Map)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); - - // fetch & cache - FormatManager formatManager = FormatManager.Instance; - Map map = formatManager.LoadMap(file.FullName); - this.FixLocalMapTilesheets(map, key); - - // inject map - this.ContentManager.Inject(assetPath, map); - return (T)(object)map; - } - - // unpacked image - case ".png": - // validate - if (typeof(T) != typeof(Texture2D)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); - - // fetch & cache - using (FileStream stream = File.OpenRead(file.FullName)) - { - Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); - texture = this.PremultiplyTransparency(texture); - this.ContentManager.Inject(assetPath, texture); - return (T)(object)texture; - } - - default: - throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'."); - } - - default: - throw GetContentError($"unknown content source '{source}'."); - } - } - catch (Exception ex) when (!(ex is SContentLoadException)) - { - throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}.", ex); - } - } - - /// Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists. - /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. - /// Where to search for a matching content asset. - /// The is empty or contains invalid characters. - public string GetActualAssetKey(string key, ContentSource source = ContentSource.ModFolder) - { - switch (source) - { - case ContentSource.GameContent: - return this.ContentManager.NormaliseAssetName(key); - - case ContentSource.ModFolder: - FileInfo file = this.GetModFile(key); - return this.ContentManager.NormaliseAssetName(this.GetModAssetPath(key, file.FullName)); - - default: - throw new NotSupportedException($"Unknown content source '{source}'."); - } - } - - - /********* - ** Private methods - *********/ - /// Fix the tilesheets for a map loaded from the mod folder. - /// The map whose tilesheets to fix. - /// The map asset key within the mod folder. - /// The map tilesheets could not be loaded. - private void FixLocalMapTilesheets(Map map, string mapKey) - { - if (!map.TileSheets.Any()) - return; - - string relativeMapFolder = Path.GetDirectoryName(mapKey) ?? ""; // folder path containing the map, relative to the mod folder - foreach (TileSheet tilesheet in map.TileSheets) - { - // check for tilesheet relative to map - { - string localKey = Path.Combine(relativeMapFolder, tilesheet.ImageSource); - FileInfo localFile = this.GetModFile(localKey); - if (localFile.Exists) - { - try - { - this.Load(localKey); - } - catch (Exception ex) - { - throw new ContentLoadException($"The local '{tilesheet.ImageSource}' tilesheet couldn't be loaded.", ex); - } - tilesheet.ImageSource = this.GetActualAssetKey(localKey); - continue; - } - } - - // fallback to game content - { - string contentKey = tilesheet.ImageSource; - if (contentKey.EndsWith(".png")) - contentKey = contentKey.Substring(0, contentKey.Length - 4); - try - { - this.ContentManager.Load(contentKey); - } - catch (Exception ex) - { - throw new ContentLoadException($"The '{tilesheet.ImageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); - } - tilesheet.ImageSource = contentKey; - } - } - } - - /// Assert that the given key has a valid format. - /// The asset key to check. - /// The asset key is empty or contains invalid characters. - [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "Parameter is only used for assertion checks by design.")] - private void AssertValidAssetKeyFormat(string key) - { - if (string.IsNullOrWhiteSpace(key)) - throw new ArgumentException("The asset key or local path is empty."); - if (key.Intersect(Path.GetInvalidPathChars()).Any()) - throw new ArgumentException("The asset key or local path contains invalid characters."); - } - - /// Get a file from the mod folder. - /// The asset path relative to the mod folder. - private FileInfo GetModFile(string path) - { - // try exact match - path = Path.Combine(this.ModFolderPath, this.ContentManager.NormalisePathSeparators(path)); - FileInfo file = new FileInfo(path); - - // try with default extension - if (!file.Exists && file.Extension.ToLower() != ".xnb") - { - FileInfo result = new FileInfo(path + ".xnb"); - if (result.Exists) - file = result; - } - - return file; - } - - /// Get the asset path which loads a mod folder through a content manager. - /// The file path relative to the mod's folder. - /// The absolute file path. - private string GetModAssetPath(string localPath, string absolutePath) - { -#if SMAPI_FOR_WINDOWS - // XNA doesn't allow absolute asset paths, so get a path relative to the content folder - return Path.Combine(this.ModFolderPathFromContent, localPath); -#else - // MonoGame is weird about relative paths on Mac, but allows absolute paths - return absolutePath; -#endif - } - - /// Get a directory path relative to a given root. - /// The root path from which the path should be relative. - /// The target file path. - private string GetRelativePath(string rootPath, string targetPath) - { - // convert to URIs - Uri from = new Uri(rootPath + "/"); - Uri to = new Uri(targetPath + "/"); - if (from.Scheme != to.Scheme) - throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{rootPath}'."); - - // get relative path - return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()) - .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform - } - - /// Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing. - /// The texture to premultiply. - /// Returns a premultiplied texture. - /// Based on code by Layoric. - private Texture2D PremultiplyTransparency(Texture2D texture) - { - // validate - if (Context.IsInDrawLoop) - throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop."); - - // process texture - SpriteBatch spriteBatch = Game1.spriteBatch; - GraphicsDevice gpu = Game1.graphics.GraphicsDevice; - using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height)) - { - // create blank render target to premultiply - gpu.SetRenderTarget(renderTarget); - gpu.Clear(Color.Black); - - // multiply each color by the source alpha, and write just the color values into the final texture - spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState - { - ColorDestinationBlend = Blend.Zero, - ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue, - AlphaDestinationBlend = Blend.Zero, - AlphaSourceBlend = Blend.SourceAlpha, - ColorSourceBlend = Blend.SourceAlpha - }); - spriteBatch.Draw(texture, texture.Bounds, Color.White); - spriteBatch.End(); - - // copy the alpha values from the source texture into the final one without multiplying them - spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState - { - ColorWriteChannels = ColorWriteChannels.Alpha, - AlphaDestinationBlend = Blend.Zero, - ColorDestinationBlend = Blend.Zero, - AlphaSourceBlend = Blend.One, - ColorSourceBlend = Blend.One - }); - spriteBatch.Draw(texture, texture.Bounds, Color.White); - spriteBatch.End(); - - // release GPU - gpu.SetRenderTarget(null); - - // extract premultiplied data - Color[] data = new Color[texture.Width * texture.Height]; - renderTarget.GetData(data); - - // unset texture from GPU to regain control - gpu.Textures[0] = null; - - // update texture with premultiplied data - texture.SetData(data); - } - - return texture; - } - } -} diff --git a/src/StardewModdingAPI/Framework/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelper.cs deleted file mode 100644 index 5a8ce459..00000000 --- a/src/StardewModdingAPI/Framework/ModHelper.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.IO; -using StardewModdingAPI.Framework.Serialisation; - -namespace StardewModdingAPI.Framework -{ - /// Provides simplified APIs for writing mods. - internal class ModHelper : IModHelper, IDisposable - { - /********* - ** Properties - *********/ - /// Encapsulates SMAPI's JSON file parsing. - private readonly JsonHelper JsonHelper; - - - /********* - ** Accessors - *********/ - /// The full path to the mod's folder. - public string DirectoryPath { get; } - - /// An API for loading content assets. - public IContentHelper Content { get; } - - /// Simplifies access to private game code. - public IReflectionHelper Reflection { get; } - - /// Metadata about loaded mods. - public IModRegistry ModRegistry { get; } - - /// An API for managing console commands. - public ICommandHelper ConsoleCommands { get; } - - /// Provides translations stored in the mod's i18n folder, with one file per locale (like en.json) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like pt-BR.json < pt.json < default.json). - public ITranslationHelper Translation { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The mod's display name. - /// The full path to the mod's folder. - /// Encapsulate SMAPI's JSON parsing. - /// Metadata about loaded mods. - /// Manages console commands. - /// The content manager which loads content assets. - /// Simplifies access to private game code. - /// An argument is null or empty. - /// The path does not exist on disk. - public ModHelper(string displayName, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager, SContentManager contentManager, IReflectionHelper reflection) - { - // validate - if (string.IsNullOrWhiteSpace(modDirectory)) - throw new ArgumentNullException(nameof(modDirectory)); - if (jsonHelper == null) - throw new ArgumentNullException(nameof(jsonHelper)); - if (modRegistry == null) - throw new ArgumentNullException(nameof(modRegistry)); - if (!Directory.Exists(modDirectory)) - throw new InvalidOperationException("The specified mod directory does not exist."); - - // initialise - this.DirectoryPath = modDirectory; - this.JsonHelper = jsonHelper; - this.Content = new ContentHelper(contentManager, modDirectory, displayName); - this.ModRegistry = modRegistry; - this.ConsoleCommands = new CommandHelper(displayName, commandManager); - this.Reflection = reflection; - this.Translation = new TranslationHelper(displayName, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); - } - - /**** - ** Mod config file - ****/ - /// Read the mod's configuration file (and create it if needed). - /// The config class type. This should be a plain class that has public properties for the settings you want. These can be complex types. - public TConfig ReadConfig() - where TConfig : class, new() - { - TConfig config = this.ReadJsonFile("config.json") ?? new TConfig(); - this.WriteConfig(config); // create file or fill in missing fields - return config; - } - - /// Save to the mod's configuration file. - /// The config class type. - /// The config settings to save. - public void WriteConfig(TConfig config) - where TConfig : class, new() - { - this.WriteJsonFile("config.json", config); - } - - /**** - ** Generic JSON files - ****/ - /// Read a JSON file. - /// The model type. - /// The file path relative to the mod directory. - /// Returns the deserialised model, or null if the file doesn't exist or is empty. - public TModel ReadJsonFile(string path) - where TModel : class - { - path = Path.Combine(this.DirectoryPath, path); - return this.JsonHelper.ReadJsonFile(path); - } - - /// Save to a JSON file. - /// The model type. - /// The file path relative to the mod directory. - /// The model to save. - public void WriteJsonFile(string path, TModel model) - where TModel : class - { - path = Path.Combine(this.DirectoryPath, path); - this.JsonHelper.WriteJsonFile(path, model); - } - - - /**** - ** Disposal - ****/ - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public void Dispose() - { - // nothing to dispose yet - } - } -} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs new file mode 100644 index 00000000..5fd56fdf --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs @@ -0,0 +1,52 @@ +using System; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// Provides an API for managing console commands. + internal class CommandHelper : ICommandHelper + { + /********* + ** Accessors + *********/ + /// The friendly mod name for this instance. + private readonly string ModName; + + /// Manages console commands. + private readonly CommandManager CommandManager; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The friendly mod name for this instance. + /// Manages console commands. + public CommandHelper(string modName, CommandManager commandManager) + { + this.ModName = modName; + this.CommandManager = commandManager; + } + + /// Add a console command. + /// The command name, which the user must type to trigger it. + /// The human-readable documentation shown when the player runs the built-in 'help' command. + /// The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user. + /// The or is null or empty. + /// The is not a valid format. + /// There's already a command with that name. + public ICommandHelper Add(string name, string documentation, Action callback) + { + this.CommandManager.Add(this.ModName, name, documentation, callback); + return this; + } + + /// Trigger a command. + /// The command name. + /// The command arguments. + /// Returns whether a matching command was triggered. + public bool Trigger(string name, string[] arguments) + { + return this.CommandManager.Trigger(name, arguments); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs new file mode 100644 index 00000000..4fc46dd0 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Exceptions; +using StardewValley; +using xTile; +using xTile.Format; +using xTile.Tiles; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// Provides an API for loading content assets. + internal class ContentHelper : IContentHelper + { + /********* + ** Properties + *********/ + /// SMAPI's underlying content manager. + private readonly SContentManager ContentManager; + + /// The absolute path to the mod folder. + private readonly string ModFolderPath; + + /// The path to the mod's folder, relative to the game's content folder (e.g. "../Mods/ModName"). + private readonly string ModFolderPathFromContent; + + /// The friendly mod name for use in errors. + private readonly string ModName; + + + /********* + ** Accessors + *********/ + /// The observable implementation of . + internal ObservableCollection ObservableAssetEditors { get; } = new ObservableCollection(); + + /// The observable implementation of . + internal ObservableCollection ObservableAssetLoaders { get; } = new ObservableCollection(); + + /// Interceptors which provide the initial versions of matching content assets. + internal IList AssetLoaders => this.ObservableAssetLoaders; + + /// Interceptors which edit matching content assets after they're loaded. + internal IList AssetEditors => this.ObservableAssetEditors; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// SMAPI's underlying content manager. + /// The absolute path to the mod folder. + /// The friendly mod name for use in errors. + public ContentHelper(SContentManager contentManager, string modFolderPath, string modName) + { + this.ContentManager = contentManager; + this.ModFolderPath = modFolderPath; + this.ModName = modName; + this.ModFolderPathFromContent = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath); + } + + /// Load content from the game folder or mod folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. + /// The expected data type. The main supported types are and dictionaries; other types may be supported by the game's content pipeline. + /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. + /// Where to search for a matching content asset. + /// The is empty or contains invalid characters. + /// The content asset couldn't be loaded (e.g. because it doesn't exist). + public T Load(string key, ContentSource source = ContentSource.ModFolder) + { + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); + + this.AssertValidAssetKeyFormat(key); + try + { + switch (source) + { + case ContentSource.GameContent: + return this.ContentManager.Load(key); + + case ContentSource.ModFolder: + // get file + FileInfo file = this.GetModFile(key); + if (!file.Exists) + throw GetContentError($"there's no matching file at path '{file.FullName}'."); + + // get asset path + string assetPath = this.GetModAssetPath(key, file.FullName); + + // try cache + if (this.ContentManager.IsLoaded(assetPath)) + return this.ContentManager.Load(assetPath); + + // load content + switch (file.Extension.ToLower()) + { + // XNB file + case ".xnb": + { + T asset = this.ContentManager.Load(assetPath); + if (asset is Map) + this.FixLocalMapTilesheets(asset as Map, key); + return asset; + } + + // unpacked map + case ".tbin": + { + // validate + if (typeof(T) != typeof(Map)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + + // fetch & cache + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + this.FixLocalMapTilesheets(map, key); + + // inject map + this.ContentManager.Inject(assetPath, map); + return (T)(object)map; + } + + // unpacked image + case ".png": + // validate + if (typeof(T) != typeof(Texture2D)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); + + // fetch & cache + using (FileStream stream = File.OpenRead(file.FullName)) + { + Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); + texture = this.PremultiplyTransparency(texture); + this.ContentManager.Inject(assetPath, texture); + return (T)(object)texture; + } + + default: + throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'."); + } + + default: + throw GetContentError($"unknown content source '{source}'."); + } + } + catch (Exception ex) when (!(ex is SContentLoadException)) + { + throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}.", ex); + } + } + + /// Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists. + /// The asset key to fetch (if the is ), or the local path to a content file relative to the mod folder. + /// Where to search for a matching content asset. + /// The is empty or contains invalid characters. + public string GetActualAssetKey(string key, ContentSource source = ContentSource.ModFolder) + { + switch (source) + { + case ContentSource.GameContent: + return this.ContentManager.NormaliseAssetName(key); + + case ContentSource.ModFolder: + FileInfo file = this.GetModFile(key); + return this.ContentManager.NormaliseAssetName(this.GetModAssetPath(key, file.FullName)); + + default: + throw new NotSupportedException($"Unknown content source '{source}'."); + } + } + + + /********* + ** Private methods + *********/ + /// Fix the tilesheets for a map loaded from the mod folder. + /// The map whose tilesheets to fix. + /// The map asset key within the mod folder. + /// The map tilesheets could not be loaded. + private void FixLocalMapTilesheets(Map map, string mapKey) + { + if (!map.TileSheets.Any()) + return; + + string relativeMapFolder = Path.GetDirectoryName(mapKey) ?? ""; // folder path containing the map, relative to the mod folder + foreach (TileSheet tilesheet in map.TileSheets) + { + // check for tilesheet relative to map + { + string localKey = Path.Combine(relativeMapFolder, tilesheet.ImageSource); + FileInfo localFile = this.GetModFile(localKey); + if (localFile.Exists) + { + try + { + this.Load(localKey); + } + catch (Exception ex) + { + throw new ContentLoadException($"The local '{tilesheet.ImageSource}' tilesheet couldn't be loaded.", ex); + } + tilesheet.ImageSource = this.GetActualAssetKey(localKey); + continue; + } + } + + // fallback to game content + { + string contentKey = tilesheet.ImageSource; + if (contentKey.EndsWith(".png")) + contentKey = contentKey.Substring(0, contentKey.Length - 4); + try + { + this.ContentManager.Load(contentKey); + } + catch (Exception ex) + { + throw new ContentLoadException($"The '{tilesheet.ImageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + } + tilesheet.ImageSource = contentKey; + } + } + } + + /// Assert that the given key has a valid format. + /// The asset key to check. + /// The asset key is empty or contains invalid characters. + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "Parameter is only used for assertion checks by design.")] + private void AssertValidAssetKeyFormat(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("The asset key or local path is empty."); + if (key.Intersect(Path.GetInvalidPathChars()).Any()) + throw new ArgumentException("The asset key or local path contains invalid characters."); + } + + /// Get a file from the mod folder. + /// The asset path relative to the mod folder. + private FileInfo GetModFile(string path) + { + // try exact match + path = Path.Combine(this.ModFolderPath, this.ContentManager.NormalisePathSeparators(path)); + FileInfo file = new FileInfo(path); + + // try with default extension + if (!file.Exists && file.Extension.ToLower() != ".xnb") + { + FileInfo result = new FileInfo(path + ".xnb"); + if (result.Exists) + file = result; + } + + return file; + } + + /// Get the asset path which loads a mod folder through a content manager. + /// The file path relative to the mod's folder. + /// The absolute file path. + private string GetModAssetPath(string localPath, string absolutePath) + { +#if SMAPI_FOR_WINDOWS + // XNA doesn't allow absolute asset paths, so get a path relative to the content folder + return Path.Combine(this.ModFolderPathFromContent, localPath); +#else + // MonoGame is weird about relative paths on Mac, but allows absolute paths + return absolutePath; +#endif + } + + /// Get a directory path relative to a given root. + /// The root path from which the path should be relative. + /// The target file path. + private string GetRelativePath(string rootPath, string targetPath) + { + // convert to URIs + Uri from = new Uri(rootPath + "/"); + Uri to = new Uri(targetPath + "/"); + if (from.Scheme != to.Scheme) + throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{rootPath}'."); + + // get relative path + return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()) + .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform + } + + /// Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing. + /// The texture to premultiply. + /// Returns a premultiplied texture. + /// Based on code by Layoric. + private Texture2D PremultiplyTransparency(Texture2D texture) + { + // validate + if (Context.IsInDrawLoop) + throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop."); + + // process texture + SpriteBatch spriteBatch = Game1.spriteBatch; + GraphicsDevice gpu = Game1.graphics.GraphicsDevice; + using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height)) + { + // create blank render target to premultiply + gpu.SetRenderTarget(renderTarget); + gpu.Clear(Color.Black); + + // multiply each color by the source alpha, and write just the color values into the final texture + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorDestinationBlend = Blend.Zero, + ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue, + AlphaDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.SourceAlpha, + ColorSourceBlend = Blend.SourceAlpha + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // copy the alpha values from the source texture into the final one without multiplying them + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorWriteChannels = ColorWriteChannels.Alpha, + AlphaDestinationBlend = Blend.Zero, + ColorDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.One, + ColorSourceBlend = Blend.One + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // release GPU + gpu.SetRenderTarget(null); + + // extract premultiplied data + Color[] data = new Color[texture.Width * texture.Height]; + renderTarget.GetData(data); + + // unset texture from GPU to regain control + gpu.Textures[0] = null; + + // update texture with premultiplied data + texture.SetData(data); + } + + return texture; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs new file mode 100644 index 00000000..965a940a --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// Provides simplified APIs for writing mods. + internal class ModHelper : IModHelper, IDisposable + { + /********* + ** Properties + *********/ + /// Encapsulates SMAPI's JSON file parsing. + private readonly JsonHelper JsonHelper; + + + /********* + ** Accessors + *********/ + /// The full path to the mod's folder. + public string DirectoryPath { get; } + + /// An API for loading content assets. + public IContentHelper Content { get; } + + /// Simplifies access to private game code. + public IReflectionHelper Reflection { get; } + + /// Metadata about loaded mods. + public IModRegistry ModRegistry { get; } + + /// An API for managing console commands. + public ICommandHelper ConsoleCommands { get; } + + /// Provides translations stored in the mod's i18n folder, with one file per locale (like en.json) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like pt-BR.json < pt.json < default.json). + public ITranslationHelper Translation { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's display name. + /// The full path to the mod's folder. + /// Encapsulate SMAPI's JSON parsing. + /// Metadata about loaded mods. + /// Manages console commands. + /// The content manager which loads content assets. + /// Simplifies access to private game code. + /// An argument is null or empty. + /// The path does not exist on disk. + public ModHelper(string displayName, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager, SContentManager contentManager, IReflectionHelper reflection) + { + // validate + if (string.IsNullOrWhiteSpace(modDirectory)) + throw new ArgumentNullException(nameof(modDirectory)); + if (jsonHelper == null) + throw new ArgumentNullException(nameof(jsonHelper)); + if (modRegistry == null) + throw new ArgumentNullException(nameof(modRegistry)); + if (!Directory.Exists(modDirectory)) + throw new InvalidOperationException("The specified mod directory does not exist."); + + // initialise + this.DirectoryPath = modDirectory; + this.JsonHelper = jsonHelper; + this.Content = new ContentHelper(contentManager, modDirectory, displayName); + this.ModRegistry = modRegistry; + this.ConsoleCommands = new CommandHelper(displayName, commandManager); + this.Reflection = reflection; + this.Translation = new TranslationHelper(displayName, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); + } + + /**** + ** Mod config file + ****/ + /// Read the mod's configuration file (and create it if needed). + /// The config class type. This should be a plain class that has public properties for the settings you want. These can be complex types. + public TConfig ReadConfig() + where TConfig : class, new() + { + TConfig config = this.ReadJsonFile("config.json") ?? new TConfig(); + this.WriteConfig(config); // create file or fill in missing fields + return config; + } + + /// Save to the mod's configuration file. + /// The config class type. + /// The config settings to save. + public void WriteConfig(TConfig config) + where TConfig : class, new() + { + this.WriteJsonFile("config.json", config); + } + + /**** + ** Generic JSON files + ****/ + /// Read a JSON file. + /// The model type. + /// The file path relative to the mod directory. + /// Returns the deserialised model, or null if the file doesn't exist or is empty. + public TModel ReadJsonFile(string path) + where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + return this.JsonHelper.ReadJsonFile(path); + } + + /// Save to a JSON file. + /// The model type. + /// The file path relative to the mod directory. + /// The model to save. + public void WriteJsonFile(string path, TModel model) + where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + this.JsonHelper.WriteJsonFile(path, model); + } + + + /**** + ** Disposal + ****/ + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + // nothing to dispose yet + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs new file mode 100644 index 00000000..5a21d999 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -0,0 +1,158 @@ +using System; +using StardewModdingAPI.Framework.Reflection; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// Provides helper methods for accessing private game code. + /// This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage). + internal class ReflectionHelper : IReflectionHelper + { + /********* + ** Properties + *********/ + /// The underlying reflection helper. + private readonly Reflector Reflector; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying reflection helper. + public ReflectionHelper(Reflector reflector) + { + this.Reflector = reflector; + } + + /**** + ** Fields + ****/ + /// Get a private instance field. + /// The field type. + /// The object which has the field. + /// The field name. + /// Whether to throw an exception if the private field is not found. + /// Returns the field wrapper, or null if the field doesn't exist and is false. + public IPrivateField GetPrivateField(object obj, string name, bool required = true) + { + return this.Reflector.GetPrivateField(obj, name, required); + } + + /// Get a private static field. + /// The field type. + /// The type which has the field. + /// The field name. + /// Whether to throw an exception if the private field is not found. + public IPrivateField GetPrivateField(Type type, string name, bool required = true) + { + return this.Reflector.GetPrivateField(type, name, required); + } + + /**** + ** Properties + ****/ + /// Get a private instance property. + /// The property type. + /// The object which has the property. + /// The property name. + /// Whether to throw an exception if the private property is not found. + public IPrivateProperty GetPrivateProperty(object obj, string name, bool required = true) + { + return this.Reflector.GetPrivateProperty(obj, name, required); + } + + /// Get a private static property. + /// The property type. + /// The type which has the property. + /// The property name. + /// Whether to throw an exception if the private property is not found. + public IPrivateProperty GetPrivateProperty(Type type, string name, bool required = true) + { + return this.Reflector.GetPrivateProperty(type, name, required); + } + + /**** + ** Field values + ** (shorthand since this is the most common case) + ****/ + /// Get the value of a private instance field. + /// The field type. + /// The object which has the field. + /// The field name. + /// Whether to throw an exception if the private field is not found. + /// Returns the field value, or the default value for if the field wasn't found and is false. + /// + /// This is a shortcut for followed by . + /// When is false, this will return the default value if reflection fails. If you need to check whether the field exists, use instead. + /// + public TValue GetPrivateValue(object obj, string name, bool required = true) + { + IPrivateField field = this.GetPrivateField(obj, name, required); + return field != null + ? field.GetValue() + : default(TValue); + } + + /// Get the value of a private static field. + /// The field type. + /// The type which has the field. + /// The field name. + /// Whether to throw an exception if the private field is not found. + /// Returns the field value, or the default value for if the field wasn't found and is false. + /// + /// This is a shortcut for followed by . + /// When is false, this will return the default value if reflection fails. If you need to check whether the field exists, use instead. + /// + public TValue GetPrivateValue(Type type, string name, bool required = true) + { + IPrivateField field = this.GetPrivateField(type, name, required); + return field != null + ? field.GetValue() + : default(TValue); + } + + /**** + ** Methods + ****/ + /// Get a private instance method. + /// The object which has the method. + /// The field name. + /// Whether to throw an exception if the private field is not found. + public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) + { + return this.Reflector.GetPrivateMethod(obj, name, required); + } + + /// Get a private static method. + /// The type which has the method. + /// The field name. + /// Whether to throw an exception if the private field is not found. + public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) + { + return this.Reflector.GetPrivateMethod(type, name, required); + } + + /**** + ** Methods by signature + ****/ + /// Get a private instance method. + /// The object which has the method. + /// The field name. + /// The argument types of the method signature to find. + /// Whether to throw an exception if the private field is not found. + public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) + { + return this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required); + } + + /// Get a private static method. + /// The type which has the method. + /// The field name. + /// The argument types of the method signature to find. + /// Whether to throw an exception if the private field is not found. + public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) + { + return this.Reflector.GetPrivateMethod(type, name, argumentTypes, required); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs new file mode 100644 index 00000000..86737f85 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// Provides translations stored in the mod's i18n folder, with one file per locale (like en.json) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like pt-BR.json < pt.json < default.json). + internal class TranslationHelper : ITranslationHelper + { + /********* + ** Properties + *********/ + /// The name of the relevant mod for error messages. + private readonly string ModName; + + /// The translations for each locale. + private readonly IDictionary> All = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); + + /// The translations for the current locale, with locale fallback taken into account. + private IDictionary ForLocale; + + + /********* + ** Accessors + *********/ + /// The current locale. + public string Locale { get; private set; } + + /// The game's current language code. + public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The name of the relevant mod for error messages. + /// The initial locale. + /// The game's current language code. + public TranslationHelper(string modName, string locale, LocalizedContentManager.LanguageCode languageCode) + { + // save data + this.ModName = modName; + + // set locale + this.SetLocale(locale, languageCode); + } + + /// Get all translations for the current locale. + public IEnumerable GetTranslations() + { + return this.ForLocale.Values.ToArray(); + } + + /// Get a translation for the current locale. + /// The translation key. + public Translation Get(string key) + { + this.ForLocale.TryGetValue(key, out Translation translation); + return translation ?? new Translation(this.ModName, this.Locale, key, null); + } + + /// Get a translation for the current locale. + /// The translation key. + /// An object containing token key/value pairs. This can be an anonymous object (like new { value = 42, name = "Cranberries" }), a dictionary, or a class instance. + public Translation Get(string key, object tokens) + { + return this.Get(key).Tokens(tokens); + } + + /// Set the translations to use. + /// The translations to use. + internal TranslationHelper SetTranslations(IDictionary> translations) + { + // reset translations + this.All.Clear(); + foreach (var pair in translations) + this.All[pair.Key] = new Dictionary(pair.Value, StringComparer.InvariantCultureIgnoreCase); + + // rebuild cache + this.SetLocale(this.Locale, this.LocaleEnum); + + return this; + } + + /// Set the current locale and precache translations. + /// The current locale. + /// The game's current language code. + internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) + { + this.Locale = locale.ToLower().Trim(); + this.LocaleEnum = localeEnum; + + this.ForLocale = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + foreach (string next in this.GetRelevantLocales(this.Locale)) + { + // skip if locale not defined + if (!this.All.TryGetValue(next, out IDictionary translations)) + continue; + + // add missing translations + foreach (var pair in translations) + { + if (!this.ForLocale.ContainsKey(pair.Key)) + this.ForLocale.Add(pair.Key, new Translation(this.ModName, this.Locale, pair.Key, pair.Value)); + } + } + } + + + /********* + ** Private methods + *********/ + /// Get the locales which can provide translations for the given locale, in precedence order. + /// The locale for which to find valid locales. + private IEnumerable GetRelevantLocales(string locale) + { + // given locale + yield return locale; + + // broader locales (like pt-BR => pt) + while (true) + { + int dashIndex = locale.LastIndexOf('-'); + if (dashIndex <= 0) + break; + + locale = locale.Substring(0, dashIndex); + yield return locale; + } + + // default + if (locale != "default") + yield return "default"; + } + } +} diff --git a/src/StardewModdingAPI/Framework/TranslationHelper.cs b/src/StardewModdingAPI/Framework/TranslationHelper.cs deleted file mode 100644 index fe387789..00000000 --- a/src/StardewModdingAPI/Framework/TranslationHelper.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using StardewValley; - -namespace StardewModdingAPI.Framework -{ - /// Provides translations stored in the mod's i18n folder, with one file per locale (like en.json) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like pt-BR.json < pt.json < default.json). - internal class TranslationHelper : ITranslationHelper - { - /********* - ** Properties - *********/ - /// The name of the relevant mod for error messages. - private readonly string ModName; - - /// The translations for each locale. - private readonly IDictionary> All = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); - - /// The translations for the current locale, with locale fallback taken into account. - private IDictionary ForLocale; - - - /********* - ** Accessors - *********/ - /// The current locale. - public string Locale { get; private set; } - - /// The game's current language code. - public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The name of the relevant mod for error messages. - /// The initial locale. - /// The game's current language code. - public TranslationHelper(string modName, string locale, LocalizedContentManager.LanguageCode languageCode) - { - // save data - this.ModName = modName; - - // set locale - this.SetLocale(locale, languageCode); - } - - /// Get all translations for the current locale. - public IEnumerable GetTranslations() - { - return this.ForLocale.Values.ToArray(); - } - - /// Get a translation for the current locale. - /// The translation key. - public Translation Get(string key) - { - this.ForLocale.TryGetValue(key, out Translation translation); - return translation ?? new Translation(this.ModName, this.Locale, key, null); - } - - /// Get a translation for the current locale. - /// The translation key. - /// An object containing token key/value pairs. This can be an anonymous object (like new { value = 42, name = "Cranberries" }), a dictionary, or a class instance. - public Translation Get(string key, object tokens) - { - return this.Get(key).Tokens(tokens); - } - - /// Set the translations to use. - /// The translations to use. - internal TranslationHelper SetTranslations(IDictionary> translations) - { - // reset translations - this.All.Clear(); - foreach (var pair in translations) - this.All[pair.Key] = new Dictionary(pair.Value, StringComparer.InvariantCultureIgnoreCase); - - // rebuild cache - this.SetLocale(this.Locale, this.LocaleEnum); - - return this; - } - - /// Set the current locale and precache translations. - /// The current locale. - /// The game's current language code. - internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) - { - this.Locale = locale.ToLower().Trim(); - this.LocaleEnum = localeEnum; - - this.ForLocale = new Dictionary(StringComparer.InvariantCultureIgnoreCase); - foreach (string next in this.GetRelevantLocales(this.Locale)) - { - // skip if locale not defined - if (!this.All.TryGetValue(next, out IDictionary translations)) - continue; - - // add missing translations - foreach (var pair in translations) - { - if (!this.ForLocale.ContainsKey(pair.Key)) - this.ForLocale.Add(pair.Key, new Translation(this.ModName, this.Locale, pair.Key, pair.Value)); - } - } - } - - - /********* - ** Private methods - *********/ - /// Get the locales which can provide translations for the given locale, in precedence order. - /// The locale for which to find valid locales. - private IEnumerable GetRelevantLocales(string locale) - { - // given locale - yield return locale; - - // broader locales (like pt-BR => pt) - while (true) - { - int dashIndex = locale.LastIndexOf('-'); - if (dashIndex <= 0) - break; - - locale = locale.Substring(0, dashIndex); - yield return locale; - } - - // default - if (locale != "default") - yield return "default"; - } - } -} diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 3b3f99b3..97bc0256 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -17,6 +17,7 @@ using StardewModdingAPI.Events; using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.Logging; using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Serialisation; diff --git a/src/StardewModdingAPI/ReflectionHelper.cs b/src/StardewModdingAPI/ReflectionHelper.cs deleted file mode 100644 index 56754cb4..00000000 --- a/src/StardewModdingAPI/ReflectionHelper.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System; -using StardewModdingAPI.Framework.Reflection; - -namespace StardewModdingAPI -{ - /// Provides helper methods for accessing private game code. - /// This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage). - internal class ReflectionHelper : IReflectionHelper - { - /********* - ** Properties - *********/ - /// The underlying reflection helper. - private readonly Reflector Reflector; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying reflection helper. - public ReflectionHelper(Reflector reflector) - { - this.Reflector = reflector; - } - - /**** - ** Fields - ****/ - /// Get a private instance field. - /// The field type. - /// The object which has the field. - /// The field name. - /// Whether to throw an exception if the private field is not found. - /// Returns the field wrapper, or null if the field doesn't exist and is false. - public IPrivateField GetPrivateField(object obj, string name, bool required = true) - { - return this.Reflector.GetPrivateField(obj, name, required); - } - - /// Get a private static field. - /// The field type. - /// The type which has the field. - /// The field name. - /// Whether to throw an exception if the private field is not found. - public IPrivateField GetPrivateField(Type type, string name, bool required = true) - { - return this.Reflector.GetPrivateField(type, name, required); - } - - /**** - ** Properties - ****/ - /// Get a private instance property. - /// The property type. - /// The object which has the property. - /// The property name. - /// Whether to throw an exception if the private property is not found. - public IPrivateProperty GetPrivateProperty(object obj, string name, bool required = true) - { - return this.Reflector.GetPrivateProperty(obj, name, required); - } - - /// Get a private static property. - /// The property type. - /// The type which has the property. - /// The property name. - /// Whether to throw an exception if the private property is not found. - public IPrivateProperty GetPrivateProperty(Type type, string name, bool required = true) - { - return this.Reflector.GetPrivateProperty(type, name, required); - } - - /**** - ** Field values - ** (shorthand since this is the most common case) - ****/ - /// Get the value of a private instance field. - /// The field type. - /// The object which has the field. - /// The field name. - /// Whether to throw an exception if the private field is not found. - /// Returns the field value, or the default value for if the field wasn't found and is false. - /// - /// This is a shortcut for followed by . - /// When is false, this will return the default value if reflection fails. If you need to check whether the field exists, use instead. - /// - public TValue GetPrivateValue(object obj, string name, bool required = true) - { - IPrivateField field = this.GetPrivateField(obj, name, required); - return field != null - ? field.GetValue() - : default(TValue); - } - - /// Get the value of a private static field. - /// The field type. - /// The type which has the field. - /// The field name. - /// Whether to throw an exception if the private field is not found. - /// Returns the field value, or the default value for if the field wasn't found and is false. - /// - /// This is a shortcut for followed by . - /// When is false, this will return the default value if reflection fails. If you need to check whether the field exists, use instead. - /// - public TValue GetPrivateValue(Type type, string name, bool required = true) - { - IPrivateField field = this.GetPrivateField(type, name, required); - return field != null - ? field.GetValue() - : default(TValue); - } - - /**** - ** Methods - ****/ - /// Get a private instance method. - /// The object which has the method. - /// The field name. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) - { - return this.Reflector.GetPrivateMethod(obj, name, required); - } - - /// Get a private static method. - /// The type which has the method. - /// The field name. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) - { - return this.Reflector.GetPrivateMethod(type, name, required); - } - - /**** - ** Methods by signature - ****/ - /// Get a private instance method. - /// The object which has the method. - /// The field name. - /// The argument types of the method signature to find. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) - { - return this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required); - } - - /// Get a private static method. - /// The type which has the method. - /// The field name. - /// The argument types of the method signature to find. - /// Whether to throw an exception if the private field is not found. - public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) - { - return this.Reflector.GetPrivateMethod(type, name, argumentTypes, required); - } - } -} diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index efef87b1..da058fb0 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -128,6 +128,11 @@ + + + + + @@ -135,7 +140,6 @@ - @@ -143,20 +147,17 @@ - - - @@ -200,7 +201,6 @@ - @@ -282,7 +282,7 @@ $(GamePath)\StardewModdingAPI.exe $(GamePath) - + -- cgit From 053c0577eccef3db3397a935863af79b30a0282f Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 7 Jul 2017 11:44:18 -0400 Subject: add mod ID to mod helpers (#318) --- .../Core/TranslationTests.cs | 6 +++--- .../Framework/ModHelpers/BaseHelper.cs | 23 ++++++++++++++++++++++ .../Framework/ModHelpers/CommandHelper.cs | 6 ++++-- .../Framework/ModHelpers/ContentHelper.cs | 6 ++++-- .../Framework/ModHelpers/ModHelper.cs | 12 ++++++----- .../Framework/ModHelpers/ReflectionHelper.cs | 6 ++++-- .../Framework/ModHelpers/TranslationHelper.cs | 6 ++++-- src/StardewModdingAPI/ICommandHelper.cs | 2 +- src/StardewModdingAPI/IContentHelper.cs | 2 +- src/StardewModdingAPI/IModLinked.cs | 12 +++++++++++ src/StardewModdingAPI/IReflectionHelper.cs | 2 +- src/StardewModdingAPI/ITranslationHelper.cs | 2 +- src/StardewModdingAPI/Program.cs | 4 ++-- src/StardewModdingAPI/StardewModdingAPI.csproj | 2 ++ 14 files changed, 69 insertions(+), 22 deletions(-) create mode 100644 src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs create mode 100644 src/StardewModdingAPI/IModLinked.cs (limited to 'src/StardewModdingAPI.Tests') diff --git a/src/StardewModdingAPI.Tests/Core/TranslationTests.cs b/src/StardewModdingAPI.Tests/Core/TranslationTests.cs index fceef0a3..8511e765 100644 --- a/src/StardewModdingAPI.Tests/Core/TranslationTests.cs +++ b/src/StardewModdingAPI.Tests/Core/TranslationTests.cs @@ -32,7 +32,7 @@ namespace StardewModdingAPI.Tests.Core var data = new Dictionary>(); // act - ITranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + ITranslationHelper helper = new TranslationHelper("ModID", "ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); Translation translation = helper.Get("key"); Translation[] translationList = helper.GetTranslations()?.ToArray(); @@ -55,7 +55,7 @@ namespace StardewModdingAPI.Tests.Core // act var actual = new Dictionary(); - TranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + TranslationHelper helper = new TranslationHelper("ModID", "ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); foreach (string locale in expected.Keys) { this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); @@ -79,7 +79,7 @@ namespace StardewModdingAPI.Tests.Core // act var actual = new Dictionary(); - TranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + TranslationHelper helper = new TranslationHelper("ModID", "ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); foreach (string locale in expected.Keys) { this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); diff --git a/src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs new file mode 100644 index 00000000..16032da1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs @@ -0,0 +1,23 @@ +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// The common base class for mod helpers. + internal abstract class BaseHelper : IModLinked + { + /********* + ** Accessors + *********/ + /// The unique ID of the mod for which the helper was created. + public string ModID { get; } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// The unique ID of the relevant mod. + protected BaseHelper(string modID) + { + this.ModID = modID; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs index 5fd56fdf..bdedb07c 100644 --- a/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Framework.ModHelpers { /// Provides an API for managing console commands. - internal class CommandHelper : ICommandHelper + internal class CommandHelper : BaseHelper, ICommandHelper { /********* ** Accessors @@ -19,9 +19,11 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// Construct an instance. + /// The unique ID of the relevant mod. /// The friendly mod name for this instance. /// Manages console commands. - public CommandHelper(string modName, CommandManager commandManager) + public CommandHelper(string modID, string modName, CommandManager commandManager) + : base(modID) { this.ModName = modName; this.CommandManager = commandManager; diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs index 4fc46dd0..5f72176e 100644 --- a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs @@ -16,7 +16,7 @@ using xTile.Tiles; namespace StardewModdingAPI.Framework.ModHelpers { /// Provides an API for loading content assets. - internal class ContentHelper : IContentHelper + internal class ContentHelper : BaseHelper, IContentHelper { /********* ** Properties @@ -56,8 +56,10 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Construct an instance. /// SMAPI's underlying content manager. /// The absolute path to the mod folder. + /// The unique ID of the relevant mod. /// The friendly mod name for use in errors. - public ContentHelper(SContentManager contentManager, string modFolderPath, string modName) + public ContentHelper(SContentManager contentManager, string modFolderPath, string modID, string modName) + : base(modID) { this.ContentManager = contentManager; this.ModFolderPath = modFolderPath; diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs index 965a940a..20d891a1 100644 --- a/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs @@ -5,7 +5,7 @@ using StardewModdingAPI.Framework.Serialisation; namespace StardewModdingAPI.Framework.ModHelpers { /// Provides simplified APIs for writing mods. - internal class ModHelper : IModHelper, IDisposable + internal class ModHelper : BaseHelper, IModHelper, IDisposable { /********* ** Properties @@ -40,6 +40,7 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// Construct an instance. + /// The mod's unique ID. /// The mod's display name. /// The full path to the mod's folder. /// Encapsulate SMAPI's JSON parsing. @@ -49,7 +50,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Simplifies access to private game code. /// An argument is null or empty. /// The path does not exist on disk. - public ModHelper(string displayName, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager, SContentManager contentManager, IReflectionHelper reflection) + public ModHelper(string modID, string displayName, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager, SContentManager contentManager, IReflectionHelper reflection) + : base(modID) { // validate if (string.IsNullOrWhiteSpace(modDirectory)) @@ -64,11 +66,11 @@ namespace StardewModdingAPI.Framework.ModHelpers // initialise this.DirectoryPath = modDirectory; this.JsonHelper = jsonHelper; - this.Content = new ContentHelper(contentManager, modDirectory, displayName); + this.Content = new ContentHelper(contentManager, modDirectory, modID, displayName); this.ModRegistry = modRegistry; - this.ConsoleCommands = new CommandHelper(displayName, commandManager); + this.ConsoleCommands = new CommandHelper(modID, displayName, commandManager); this.Reflection = reflection; - this.Translation = new TranslationHelper(displayName, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); + this.Translation = new TranslationHelper(modID, displayName, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); } /**** diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs index 5a21d999..9411a97a 100644 --- a/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -5,7 +5,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { /// Provides helper methods for accessing private game code. /// This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage). - internal class ReflectionHelper : IReflectionHelper + internal class ReflectionHelper : BaseHelper, IReflectionHelper { /********* ** Properties @@ -18,8 +18,10 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// Construct an instance. + /// The unique ID of the relevant mod. /// The underlying reflection helper. - public ReflectionHelper(Reflector reflector) + public ReflectionHelper(string modID, Reflector reflector) + : base(modID) { this.Reflector = reflector; } diff --git a/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs index 86737f85..bbe3a81a 100644 --- a/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs @@ -6,7 +6,7 @@ using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers { /// Provides translations stored in the mod's i18n folder, with one file per locale (like en.json) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like pt-BR.json < pt.json < default.json). - internal class TranslationHelper : ITranslationHelper + internal class TranslationHelper : BaseHelper, ITranslationHelper { /********* ** Properties @@ -35,10 +35,12 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// Construct an instance. + /// The unique ID of the relevant mod. /// The name of the relevant mod for error messages. /// The initial locale. /// The game's current language code. - public TranslationHelper(string modName, string locale, LocalizedContentManager.LanguageCode languageCode) + public TranslationHelper(string modID, string modName, string locale, LocalizedContentManager.LanguageCode languageCode) + : base(modID) { // save data this.ModName = modName; diff --git a/src/StardewModdingAPI/ICommandHelper.cs b/src/StardewModdingAPI/ICommandHelper.cs index 3a51ffb4..fb562e32 100644 --- a/src/StardewModdingAPI/ICommandHelper.cs +++ b/src/StardewModdingAPI/ICommandHelper.cs @@ -3,7 +3,7 @@ namespace StardewModdingAPI { /// Provides an API for managing console commands. - public interface ICommandHelper + public interface ICommandHelper : IModLinked { /********* ** Public methods diff --git a/src/StardewModdingAPI/IContentHelper.cs b/src/StardewModdingAPI/IContentHelper.cs index 1d520135..32a9ff19 100644 --- a/src/StardewModdingAPI/IContentHelper.cs +++ b/src/StardewModdingAPI/IContentHelper.cs @@ -5,7 +5,7 @@ using Microsoft.Xna.Framework.Graphics; namespace StardewModdingAPI { /// Provides an API for loading content assets. - public interface IContentHelper + public interface IContentHelper : IModLinked { /// Load content from the game folder or mod folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. /// The expected data type. The main supported types are and dictionaries; other types may be supported by the game's content pipeline. diff --git a/src/StardewModdingAPI/IModLinked.cs b/src/StardewModdingAPI/IModLinked.cs new file mode 100644 index 00000000..172ee30c --- /dev/null +++ b/src/StardewModdingAPI/IModLinked.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// An instance linked to a mod. + public interface IModLinked + { + /********* + ** Accessors + *********/ + /// The unique ID of the mod for which the instance was created. + string ModID { get; } + } +} diff --git a/src/StardewModdingAPI/IReflectionHelper.cs b/src/StardewModdingAPI/IReflectionHelper.cs index 77943c6c..f66e3a31 100644 --- a/src/StardewModdingAPI/IReflectionHelper.cs +++ b/src/StardewModdingAPI/IReflectionHelper.cs @@ -3,7 +3,7 @@ namespace StardewModdingAPI { /// Simplifies access to private game code. - public interface IReflectionHelper + public interface IReflectionHelper : IModLinked { /********* ** Public methods diff --git a/src/StardewModdingAPI/ITranslationHelper.cs b/src/StardewModdingAPI/ITranslationHelper.cs index dac83025..c4b72444 100644 --- a/src/StardewModdingAPI/ITranslationHelper.cs +++ b/src/StardewModdingAPI/ITranslationHelper.cs @@ -4,7 +4,7 @@ using StardewValley; namespace StardewModdingAPI { /// Provides translations stored in the mod's i18n folder, with one file per locale (like en.json) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like pt-BR.json < pt.json < default.json). - public interface ITranslationHelper + public interface ITranslationHelper : IModLinked { /********* ** Accessors diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 97bc0256..66ed0a85 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -703,8 +703,8 @@ namespace StardewModdingAPI // inject data mod.ModManifest = manifest; - var reflectionHelper = new ReflectionHelper(this.Reflection); - mod.Helper = new ModHelper(metadata.DisplayName, metadata.DirectoryPath, jsonHelper, this.ModRegistry, this.CommandManager, contentManager, reflectionHelper); + var reflectionHelper = new ReflectionHelper(manifest.UniqueID, this.Reflection); + mod.Helper = new ModHelper(manifest.UniqueID, metadata.DisplayName, metadata.DirectoryPath, jsonHelper, this.ModRegistry, this.CommandManager, contentManager, reflectionHelper); mod.Monitor = this.GetSecondaryMonitor(metadata.DisplayName); #if !SMAPI_2_0 mod.PathOnDisk = metadata.DirectoryPath; diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index da058fb0..93d55b0a 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -128,6 +128,7 @@ + @@ -186,6 +187,7 @@ + -- cgit