using System; using System.Collections.Generic; using FluentAssertions; using NUnit.Framework; using StardewModdingAPI; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; namespace SMAPI.Tests.Core { /// Unit tests for . [TestFixture] internal class AssetNameTests { /********* ** Unit tests *********/ /**** ** Constructor ****/ [Test(Description = $"Assert that the {nameof(AssetName)} constructor creates an instance with the expected values.")] [TestCase("SimpleName", "SimpleName", null, null)] [TestCase("Data/Achievements", "Data/Achievements", null, null)] [TestCase("Characters/Dialogue/Abigail", "Characters/Dialogue/Abigail", null, null)] [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] [TestCase("Characters/Dialogue\\Abigail.fr-FR", "Characters/Dialogue/Abigail.fr-FR", null, null)] [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] public void Constructor_Valid(string name, string expectedBaseName, string? expectedLocale, LocalizedContentManager.LanguageCode? expectedLanguageCode) { // arrange name = PathUtilities.NormalizeAssetName(name); // act IAssetName assetName = AssetName.Parse(name, parseLocale: _ => expectedLanguageCode); // assert assetName.Name.Should() .NotBeNull() .And.Be(name.Replace("\\", "/")); assetName.BaseName.Should() .NotBeNull() .And.Be(expectedBaseName); assetName.LocaleCode.Should() .Be(expectedLocale); assetName.LanguageCode.Should() .Be(expectedLanguageCode); } [Test(Description = $"Assert that the {nameof(AssetName)} constructor throws an exception if the value is invalid.")] [TestCase(null)] [TestCase("")] [TestCase(" ")] [TestCase("\t")] [TestCase(" \t ")] public void Constructor_NullOrWhitespace(string? name) { // act ArgumentException exception = Assert.Throws(() => _ = AssetName.Parse(name!, _ => null))!; // assert exception.ParamName.Should().Be("rawName"); exception.Message.Should().Be("The asset name can't be null or empty. (Parameter 'rawName')"); } /**** ** IsEquivalentTo ****/ [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is included.")] // exact match (ignore case) [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] // exact match (ignore formatting) [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] // whitespace-insensitive [TestCase("Data/Achievements", " Data/Achievements ", ExpectedResult = true)] [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] // other is null or whitespace [TestCase("Data/Achievements", null, ExpectedResult = false)] [TestCase("Data/Achievements", "", ExpectedResult = false)] [TestCase("Data/Achievements", " ", ExpectedResult = false)] // with locale codes [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = false)] [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = true)] public bool IsEquivalentTo_Name(string mainAssetName, string otherAssetName) { // arrange mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); // act AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); // assert return name.IsEquivalentTo(otherAssetName); } [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is excluded.")] // a few samples from previous test to make sure [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] [TestCase("Data/Achievements", " ", ExpectedResult = false)] // with locale codes [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = false)] public bool IsEquivalentTo_BaseName(string mainAssetName, string otherAssetName) { // arrange mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); // act AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); // assert return name.IsEquivalentTo(otherAssetName, useBaseName: true); } /**** ** StartsWith ****/ [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for inputs that aren't affected by the input options.")] // exact match (ignore case and formatting) [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] // whitespace-insensitive [TestCase("Data/Achievements", " Data/Achievements", ExpectedResult = true)] [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] [TestCase("Data/Achievements", " ", ExpectedResult = true)] // invalid prefixes [TestCase("Data/Achievements", null, ExpectedResult = false)] // with locale codes [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] // prefix ends with path separator [TestCase("Data/Events/Boop", "Data/Events/", ExpectedResult = true)] [TestCase("Data/Events/Boop", "Data/Events\\", ExpectedResult = true)] [TestCase("Data/Events", "Data/Events/", ExpectedResult = false)] [TestCase("Data/Events", "Data/Events\\", ExpectedResult = false)] public bool StartsWith_SimpleCases(string mainAssetName, string prefix) { // arrange mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); // act AssetName name = AssetName.Parse(mainAssetName, _ => null); // assert value is the same for any combination of options bool result = name.StartsWith(prefix); foreach (bool allowPartialWord in new[] { true, false }) { foreach (bool allowSubfolder in new[] { true, true }) { if (allowPartialWord && allowSubfolder) continue; name.StartsWith(prefix, allowPartialWord, allowSubfolder) .Should().Be(result, $"the value returned for options ({nameof(allowPartialWord)}: {allowPartialWord}, {nameof(allowSubfolder)}: {allowSubfolder}) should match the base case"); } } // assert value return result; } [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowPartialWord' option.")] [TestCase("Data/AchievementsToIgnore", "Data/Achievements", true, ExpectedResult = true)] [TestCase("Data/AchievementsToIgnore", "Data/Achievements", false, ExpectedResult = false)] [TestCase("Data/Achievements X", "Data/Achievements", true, ExpectedResult = true)] [TestCase("Data/Achievements X", "Data/Achievements", false, ExpectedResult = true)] [TestCase("Data/Achievements.X", "Data/Achievements", true, ExpectedResult = true)] [TestCase("Data/Achievements.X", "Data/Achievements", false, ExpectedResult = true)] // with locale codes [TestCase("Data/Achievements.fr-FR", "Data/Achievements", true, ExpectedResult = true)] [TestCase("Data/Achievements.fr-FR", "Data/Achievements", false, ExpectedResult = true)] public bool StartsWith_PartialWord(string mainAssetName, string prefix, bool allowPartialWord) { // arrange mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); // act AssetName name = AssetName.Parse(mainAssetName, _ => null); // assert value is the same for any combination of options bool result = name.StartsWith(prefix, allowPartialWord: allowPartialWord, allowSubfolder: true); name.StartsWith(prefix, allowPartialWord, allowSubfolder: false) .Should().Be(result, "specifying allowSubfolder should have no effect for these inputs"); // assert value return result; } [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowSubfolder' option.")] // simple cases [TestCase("Data/Achievements/Path", "Data/Achievements", true, ExpectedResult = true)] [TestCase("Data/Achievements/Path", "Data/Achievements", false, ExpectedResult = false)] [TestCase("Data/Achievements/Path", "Data\\Achievements", true, ExpectedResult = true)] [TestCase("Data/Achievements/Path", "Data\\Achievements", false, ExpectedResult = false)] // trailing slash [TestCase("Data/Achievements/Path", "Data/", true, ExpectedResult = true)] [TestCase("Data/Achievements/Path", "Data/", false, ExpectedResult = false)] // normalize slash style [TestCase("Data/Achievements/Path", "Data\\", true, ExpectedResult = true)] [TestCase("Data/Achievements/Path", "Data\\", false, ExpectedResult = false)] [TestCase("Data/Achievements/Path", "Data/\\/", true, ExpectedResult = true)] [TestCase("Data/Achievements/Path", "Data/\\/", false, ExpectedResult = false)] // with locale code [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", true, ExpectedResult = true)] [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", false, ExpectedResult = false)] public bool StartsWith_Subfolder(string mainAssetName, string otherAssetName, bool allowSubfolder) { // arrange mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); // act AssetName name = AssetName.Parse(mainAssetName, _ => null); // assert value is the same for any combination of options bool result = name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); name.StartsWith(otherAssetName, allowPartialWord: false, allowSubfolder: allowSubfolder) .Should().Be(result, "specifying allowPartialWord should have no effect for these inputs"); // assert value return result; } [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", true, ExpectedResult = true)] [TestCase("Mods/SomeMod/SomeSubdirectory", "Mods/Some", false, ExpectedResult = false)] [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)] [TestCase("Mods/Jasper/Data", "Mods/Jas/Image", true, ExpectedResult = false)] public bool StartsWith_PartialMatchInPathSegment(string mainAssetName, string otherAssetName, bool allowSubfolder) { // arrange mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); // act AssetName name = AssetName.Parse(mainAssetName, _ => null); // assert value return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); } // The enumerator strips the trailing path separator, so each of these cases has to be handled on each branch. [TestCase("Mods/SomeMod", "Mods/", false, ExpectedResult = true)] [TestCase("Mods/SomeMod", "Mods", false, ExpectedResult = false)] [TestCase("Mods/Jasper/Data", "Mods/Jas/", false, ExpectedResult = false)] [TestCase("Mods/Jasper/Data", "Mods/Jas", false, ExpectedResult = false)] [TestCase("Mods/Jas", "Mods/Jas/", false, ExpectedResult = false)] [TestCase("Mods/Jas", "Mods/Jas", false, ExpectedResult = true)] public bool StartsWith_PrefixHasSeparator(string mainAssetName, string otherAssetName, bool allowSubfolder) { // arrange mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); // act AssetName name = AssetName.Parse(mainAssetName, _ => null); // assert value return name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); } /**** ** GetHashCode ****/ [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates the same hash code for two asset names which differ only by capitalization.")] public void GetHashCode_IsCaseInsensitive() { // arrange string left = "data/ACHIEVEMENTS"; string right = "DATA/achievements"; // act int leftHash = AssetName.Parse(left, _ => null).GetHashCode(); int rightHash = AssetName.Parse(right, _ => null).GetHashCode(); // assert leftHash.Should().Be(rightHash, "two asset names which differ only by capitalization should produce the same hash code"); } [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates few hash code collisions for an arbitrary set of asset names.")] public void GetHashCode_HasFewCollisions() { // generate list of names List names = new(); { Random random = new(); string characters = "abcdefghijklmnopqrstuvwxyz1234567890/"; while (names.Count < 1000) { char[] name = new char[random.Next(5, 20)]; for (int i = 0; i < name.Length; i++) name[i] = characters[random.Next(0, characters.Length)]; names.Add(new string(name)); } } // get distinct hash codes HashSet hashCodes = new(); foreach (string name in names) hashCodes.Add(AssetName.Parse(name, _ => null).GetHashCode()); // assert a collision frequency under 0.1% float collisionFrequency = 1 - (hashCodes.Count / (names.Count * 1f)); collisionFrequency.Should().BeLessOrEqualTo(0.001f, "hash codes should be relatively distinct with a collision rate under 0.1% for a small sample set"); } } }