diff --git a/docs/release-notes.md b/docs/release-notes.md
index d549b99c..b84f8a06 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -6,6 +6,8 @@
* Improved translations. Thanks to ChulkyBow (updated Ukrainian)!
* For mod authors:
+ * Added `IAssetName` field to the asset info received by `IAssetEditor` and `IAssetLoader` methods.
+ _This provides utility methods for working with asset names, parsed locales, etc. The `asset.AssetNameEquals` method is now deprecated in favor of `asset.Name.IsEquivalentTo`_.
* The `SDate` constructor is no longer case-sensitive for season names.
* Fixed issue where suppressing `[Left|Right]Thumbstick[Down|Left]` keys would suppress the opposite direction instead.
* Fixed support for using locale codes from custom languages in asset names (e.g. `Data/Achievements.eo-EU`).
diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs
new file mode 100644
index 00000000..8785aab8
--- /dev/null
+++ b/src/SMAPI.Tests/Core/AssetNameTests.cs
@@ -0,0 +1,295 @@
+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
+ string calledWithLocale = null;
+ IAssetName assetName = AssetName.Parse(name, parseLocale: locale => 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-sensitive
+ [TestCase("Data/Achievements", " Data/Achievements ", ExpectedResult = false)]
+ [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = false)]
+
+ // 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 = 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 = 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)]
+
+ // leading-whitespace-sensitive
+ [TestCase("Data/Achievements", " Data/Achievements", ExpectedResult = false)]
+ [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = false)]
+
+ // invalid prefixes
+ [TestCase("Data/Achievements", null, ExpectedResult = false)]
+ [TestCase("Data/Achievements", " ", ExpectedResult = false)]
+
+ // with locale codes
+ [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)]
+ 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, true, true);
+ 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;
+ }
+
+
+ /****
+ ** 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");
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Content/AssetData.cs b/src/SMAPI/Framework/Content/AssetData.cs
index 5c90d83b..05be8a3b 100644
--- a/src/SMAPI/Framework/Content/AssetData.cs
+++ b/src/SMAPI/Framework/Content/AssetData.cs
@@ -25,11 +25,11 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// Construct an instance.
/// The content's locale code, if the content is localized.
- /// The normalized asset name being read.
+ /// The asset name being read.
/// The content data being read.
/// Normalizes an asset key to match the cache key.
/// A callback to invoke when the data is replaced (if any).
- public AssetData(string locale, string assetName, TValue data, Func getNormalizedPath, Action onDataReplaced)
+ public AssetData(string locale, IAssetName assetName, TValue data, Func getNormalizedPath, Action onDataReplaced)
: base(locale, assetName, data.GetType(), getNormalizedPath)
{
this.Data = data;
diff --git a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs
index 26cbff5a..735b651c 100644
--- a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs
@@ -11,11 +11,11 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// Construct an instance.
/// The content's locale code, if the content is localized.
- /// The normalized asset name being read.
+ /// The asset name being read.
/// The content data being read.
/// Normalizes an asset key to match the cache key.
/// A callback to invoke when the data is replaced (if any).
- public AssetDataForDictionary(string locale, string assetName, IDictionary data, Func getNormalizedPath, Action> onDataReplaced)
+ public AssetDataForDictionary(string locale, IAssetName assetName, IDictionary data, Func getNormalizedPath, Action> onDataReplaced)
: base(locale, assetName, data, getNormalizedPath, onDataReplaced) { }
}
}
diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs
index c75514bc..b0f1b5c7 100644
--- a/src/SMAPI/Framework/Content/AssetDataForImage.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs
@@ -21,11 +21,11 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// Construct an instance.
/// The content's locale code, if the content is localized.
- /// The normalized asset name being read.
+ /// The asset name being read.
/// The content data being read.
/// Normalizes an asset key to match the cache key.
/// A callback to invoke when the data is replaced (if any).
- public AssetDataForImage(string locale, string assetName, Texture2D data, Func getNormalizedPath, Action onDataReplaced)
+ public AssetDataForImage(string locale, IAssetName assetName, Texture2D data, Func getNormalizedPath, Action onDataReplaced)
: base(locale, assetName, data, getNormalizedPath, onDataReplaced) { }
///
diff --git a/src/SMAPI/Framework/Content/AssetDataForMap.cs b/src/SMAPI/Framework/Content/AssetDataForMap.cs
index 0a5fa7e7..26e4986e 100644
--- a/src/SMAPI/Framework/Content/AssetDataForMap.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForMap.cs
@@ -18,11 +18,11 @@ namespace StardewModdingAPI.Framework.Content
*********/
/// Construct an instance.
/// The content's locale code, if the content is localized.
- /// The normalized asset name being read.
+ /// The asset name being read.
/// The content data being read.
/// Normalizes an asset key to match the cache key.
/// A callback to invoke when the data is replaced (if any).
- public AssetDataForMap(string locale, string assetName, Map data, Func getNormalizedPath, Action