encapsulate path utilities for reuse, add unit tests

This commit is contained in:
Jesse Plamondon-Willard 2018-02-19 20:18:30 -05:00
parent 049952de33
commit 3b4e81bf69
7 changed files with 138 additions and 33 deletions

View File

@ -0,0 +1,70 @@
using NUnit.Framework;
using StardewModdingAPI.Framework.Utilities;
namespace StardewModdingAPI.Tests.Core
{
/// <summary>Unit tests for <see cref="PathUtilities"/>.</summary>
[TestFixture]
public class PathUtilitiesTests
{
/*********
** Unit tests
*********/
[Test(Description = "Assert that GetSegments returns the expected values.")]
[TestCase("", ExpectedResult = "")]
[TestCase("/", ExpectedResult = "")]
[TestCase("///", ExpectedResult = "")]
[TestCase("/usr/bin", ExpectedResult = "usr|bin")]
[TestCase("/usr//bin//", ExpectedResult = "usr|bin")]
[TestCase("/usr//bin//.././boop.exe", ExpectedResult = "usr|bin|..|.|boop.exe")]
[TestCase(@"C:", ExpectedResult = "C:")]
[TestCase(@"C:/boop", ExpectedResult = "C:|boop")]
[TestCase(@"C:\boop\/usr//bin//.././boop.exe", ExpectedResult = "C:|boop|usr|bin|..|.|boop.exe")]
public string GetSegments(string path)
{
return string.Join("|", PathUtilities.GetSegments(path));
}
[Test(Description = "Assert that NormalisePathSeparators returns the expected values.")]
#if SMAPI_FOR_WINDOWS
[TestCase("", ExpectedResult = "")]
[TestCase("/", ExpectedResult = "")]
[TestCase("///", ExpectedResult = "")]
[TestCase("/usr/bin", ExpectedResult = @"usr\bin")]
[TestCase("/usr//bin//", ExpectedResult = @"usr\bin")]
[TestCase("/usr//bin//.././boop.exe", ExpectedResult = @"usr\bin\..\.\boop.exe")]
[TestCase("C:", ExpectedResult = "C:")]
[TestCase("C:/boop", ExpectedResult = @"C:\boop")]
[TestCase(@"C:\usr\bin//.././boop.exe", ExpectedResult = @"C:\usr\bin\..\.\boop.exe")]
#else
[TestCase("", ExpectedResult = "")]
[TestCase("/", ExpectedResult = "/")]
[TestCase("///", ExpectedResult = "/")]
[TestCase("/usr/bin", ExpectedResult = "/usr/bin")]
[TestCase("/usr//bin//", ExpectedResult = "/usr/bin")]
[TestCase("/usr//bin//.././boop.exe", ExpectedResult = "/usr/bin/.././boop.exe")]
[TestCase("C:", ExpectedResult = "C:")]
[TestCase("C:/boop", ExpectedResult = "C:/boop")]
[TestCase(@"C:\usr\bin//.././boop.exe", ExpectedResult = "C:/usr/bin/.././boop.exe")]
#endif
public string NormalisePathSeparators(string path)
{
return PathUtilities.NormalisePathSeparators(path);
}
[Test(Description = "Assert that GetRelativePath returns the expected values.")]
#if SMAPI_FOR_WINDOWS
[TestCase(@"C:\", @"C:\", ExpectedResult = "./")]
[TestCase(@"C:\grandparent\parent\child", @"C:\grandparent\parent\sibling", ExpectedResult = @"..\sibling")]
[TestCase(@"C:\grandparent\parent\child", @"C:\cousin\file.exe", ExpectedResult = @"..\..\..\cousin\file.exe")]
#else
[TestCase("/", "/", ExpectedResult = "./")]
[TestCase("/grandparent/parent/child", "/grandparent/parent/sibling", ExpectedResult = "../sibling")]
[TestCase("/grandparent/parent/child", "/cousin/file.exe", ExpectedResult = "../../../cousin/file.exe")]
#endif
public string GetRelativePath(string sourceDir, string targetPath)
{
return PathUtilities.GetRelativePath(sourceDir, targetPath);
}
}
}

View File

@ -49,6 +49,7 @@
<Compile Include="..\..\build\GlobalAssemblyInfo.cs">
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
<Compile Include="Core\PathUtilitiesTests.cs" />
<Compile Include="Utilities\SemanticVersionTests.cs" />
<Compile Include="Utilities\SDateTests.cs" />
<Compile Include="Core\TranslationTests.cs" />

View File

@ -5,6 +5,7 @@ using System.Linq;
using Microsoft.Xna.Framework;
using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
using StardewValley;
namespace StardewModdingAPI.Framework.Content
@ -18,12 +19,6 @@ namespace StardewModdingAPI.Framework.Content
/// <summary>The underlying asset cache.</summary>
private readonly IDictionary<string, object> Cache;
/// <summary>The possible directory separator characters in an asset key.</summary>
private readonly char[] PossiblePathSeparators;
/// <summary>The preferred directory separator chaeacter in an asset key.</summary>
private readonly string PreferredPathSeparator;
/// <summary>Applies platform-specific asset key normalisation so it's consistent with the underlying cache.</summary>
private readonly Func<string, string> NormaliseAssetNameForPlatform;
@ -52,14 +47,10 @@ namespace StardewModdingAPI.Framework.Content
/// <summary>Construct an instance.</summary>
/// <param name="contentManager">The underlying content manager whose cache to manage.</param>
/// <param name="reflection">Simplifies access to private game code.</param>
/// <param name="possiblePathSeparators">The possible directory separator characters in an asset key.</param>
/// <param name="preferredPathSeparator">The preferred directory separator chaeacter in an asset key.</param>
public ContentCache(LocalizedContentManager contentManager, Reflector reflection, char[] possiblePathSeparators, string preferredPathSeparator)
public ContentCache(LocalizedContentManager contentManager, Reflector reflection)
{
// init
this.Cache = reflection.GetField<Dictionary<string, object>>(contentManager, "loadedAssets").GetValue();
this.PossiblePathSeparators = possiblePathSeparators;
this.PreferredPathSeparator = preferredPathSeparator;
// get key normalisation logic
if (Constants.TargetPlatform == Platform.Windows)
@ -90,11 +81,7 @@ namespace StardewModdingAPI.Framework.Content
[Pure]
public string NormalisePathSeparators(string path)
{
string[] parts = path.Split(this.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
string normalised = string.Join(this.PreferredPathSeparator, parts);
if (path.StartsWith(this.PreferredPathSeparator))
normalised = this.PreferredPathSeparator + normalised; // keep root slash
return normalised;
return PathUtilities.NormalisePathSeparators(path);
}
/// <summary>Normalise a cache key so it's consistent with the underlying cache.</summary>

View File

@ -8,6 +8,7 @@ using System.Linq;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Utilities;
using StardewValley;
using xTile;
using xTile.Format;
@ -238,7 +239,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
string imageSource = tilesheet.ImageSource;
// validate tilesheet path
if (Path.IsPathRooted(imageSource) || imageSource.Split(SContentManager.PossiblePathSeparators).Contains(".."))
if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains(".."))
throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../).");
// get seasonal name (if applicable)

View File

@ -35,9 +35,6 @@ namespace StardewModdingAPI.Framework
/*********
** Properties
*********/
/// <summary>The preferred directory separator chaeacter in an asset key.</summary>
private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString();
/// <summary>Encapsulates monitoring and logging.</summary>
private readonly IMonitor Monitor;
@ -75,9 +72,6 @@ namespace StardewModdingAPI.Framework
/// <summary>Interceptors which edit matching assets after they're loaded.</summary>
internal IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>();
/// <summary>The possible directory separator characters in an asset key.</summary>
internal static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray();
/// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
internal string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory);
@ -100,7 +94,7 @@ namespace StardewModdingAPI.Framework
{
// init
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.Cache = new ContentCache(this, reflection, SContentManager.PossiblePathSeparators, SContentManager.PreferredPathSeparator);
this.Cache = new ContentCache(this, reflection);
this.GetKeyLocale = reflection.GetMethod(this, "languageCode");
this.ModContentPrefix = this.GetAssetNameFromFilePath(Constants.ModPath);
@ -399,15 +393,7 @@ namespace StardewModdingAPI.Framework
/// <param name="targetPath">The target file path.</param>
private string GetRelativePath(string targetPath)
{
// convert to URIs
Uri from = new Uri(this.FullRootDirectory + "/");
Uri to = new Uri(targetPath + "/");
if (from.Scheme != to.Scheme)
throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{this.FullRootDirectory}'.");
// get relative path
return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())
.Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform
return PathUtilities.GetRelativePath(this.FullRootDirectory, targetPath);
}
/// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary>

View File

@ -0,0 +1,59 @@
using System;
using System.Diagnostics.Contracts;
using System.IO;
using System.Linq;
namespace StardewModdingAPI.Framework.Utilities
{
/// <summary>Provides utilities for normalising file paths.</summary>
internal static class PathUtilities
{
/*********
** Properties
*********/
/// <summary>The possible directory separator characters in a file path.</summary>
private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray();
/// <summary>The preferred directory separator chaeacter in an asset key.</summary>
private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString();
/*********
** Public methods
*********/
/// <summary>Get the segments from a path (e.g. <c>/usr/bin/boop</c> => <c>usr</c>, <c>bin</c>, and <c>boop</c>).</summary>
/// <param name="path">The path to split.</param>
public static string[] GetSegments(string path)
{
return path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries);
}
/// <summary>Normalise path separators in a file path.</summary>
/// <param name="path">The file path to normalise.</param>
[Pure]
public static string NormalisePathSeparators(string path)
{
string[] parts = PathUtilities.GetSegments(path);
string normalised = string.Join(PathUtilities.PreferredPathSeparator, parts);
if (path.StartsWith(PathUtilities.PreferredPathSeparator))
normalised = PathUtilities.PreferredPathSeparator + normalised; // keep root slash
return normalised;
}
/// <summary>Get a directory or file path relative to a given source path.</summary>
/// <param name="sourceDir">The source folder path.</param>
/// <param name="targetPath">The target folder or file path.</param>
[Pure]
public static string GetRelativePath(string sourceDir, string targetPath)
{
// convert to URIs
Uri from = new Uri(sourceDir.TrimEnd(PathUtilities.PossiblePathSeparators) + "/");
Uri to = new Uri(targetPath.TrimEnd(PathUtilities.PossiblePathSeparators) + "/");
if (from.Scheme != to.Scheme)
throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{sourceDir}'.");
// get relative path
return PathUtilities.NormalisePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()));
}
}
}

View File

@ -125,6 +125,7 @@
<Compile Include="Framework\Serialisation\CrossplatformConverters\ColorConverter.cs" />
<Compile Include="Framework\Serialisation\CrossplatformConverters\PointConverter.cs" />
<Compile Include="Framework\Utilities\ContextHash.cs" />
<Compile Include="Framework\Utilities\PathUtilities.cs" />
<Compile Include="IContentPack.cs" />
<Compile Include="IManifestContentPackFor.cs" />
<Compile Include="IReflectedField.cs" />