diff --git a/CHANGELOG.md b/CHANGELOG.md index 729b0fd8..11e32a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ which carries no version resource outside Windows * The **Installed Unity Games** window reports these games' Unity version instead of falling back to scanning file headers +* Two `PathComponent`s resolve per-machine locations so a shared PathReference no longer + has to be hand-edited per collaborator — + [SpecialFolder](Editor/Core/Paths/Components/SpecialFolder.cs) and + [EnvironmentVariable](Editor/Core/Paths/Components/EnvironmentVariable.cs) + * `SpecialFolder` selects an `Environment.SpecialFolder`, so `ApplicationData` resolves + to `%APPDATA%`, `~/.config` or `~/Library/Application Support` from one asset + * `EnvironmentVariable` looks a variable up by name with an optional fallback, so an + unset variable is reported rather than becoming a literal path segment + * Neither parses `%VAR%` or `$VAR`, so the same asset resolves on Windows, Linux and + macOS ### Fixes @@ -35,6 +45,20 @@ executor sub-assets, preserving each executor's enabled state * Equal-priority executors are ordered by type name, so the saved order no longer depends on Unity's sub-asset ordering +* Path resolution reports authoring mistakes instead of failing opaquely — new + [PathAssembler](Editor/Core/Paths/PathAssembler.cs) and + [PathResolutionScope](Editor/Core/Paths/PathResolutionScope.cs) sit behind + `PathReference.GetPath` + * A cycle through `OutputReference` or a `Resolver` token names the chain that caused + it, rather than recursing until `StackOverflowException` terminates the Editor + * Null, invalid, drive-relative and misplaced rooted segments are refused and name the + `PathComponent` that produced them, so a segment can no longer silently discard the + components before it + * Two `PathReference` assets sharing a name report both assets instead of surfacing as + a dictionary key collision +* [PathReference](Editor/Core/Paths/PathReference.cs) `ElementTemplate` scaffolds + `GetPathInternal`, so generated `PathComponent`s compile — it previously declared an + override of the non-virtual `GetPath` ### Tests @@ -47,6 +71,20 @@ * Added [PlayerDataResolverTests](Tests/Editor/PlayerDataResolverTests.cs) covering player layout selection and the bundle branch against a synthesized UnityFS bundle, so the compressed layout is exercised without a multi-megabyte fixture +* Added coverage for the path component system across + [PathComponentTests](Tests/Editor/PathComponentTests.cs), + [PathComponentFileSystemTests](Tests/Editor/PathComponentFileSystemTests.cs), + [PathReferenceCombineTests](Tests/Editor/PathReferenceCombineTests.cs), + [PathReferenceCycleTests](Tests/Editor/PathReferenceCycleTests.cs) and + [PathReferenceAssetTests](Tests/Editor/PathReferenceAssetTests.cs) + * Pins the authoring patterns that ship in `Templates/`, including `Constant("..")` and + a rooted first component, so future validation cannot outlaw them + * Records that `ManifestName` and `ManifestVersion` return null rather than entering + their reported-error paths, and that `FindFile` and `FindDirectory` lose the + underlying cause outside pipeline execution +* [PathComponentEnvironmentTests](Tests/Editor/PathComponentEnvironmentTests.cs) cover both + new components, including the unset-variable diagnostic and that shell syntax in a + variable name is looked up verbatim rather than unwrapped ## 9.4.3 diff --git a/Editor/Core/Paths/Components/EnvironmentVariable.cs b/Editor/Core/Paths/Components/EnvironmentVariable.cs new file mode 100644 index 00000000..b302d238 --- /dev/null +++ b/Editor/Core/Paths/Components/EnvironmentVariable.cs @@ -0,0 +1,34 @@ +using System; +using ThunderKit.Core.Pipelines; +using UnityEngine; + +namespace ThunderKit.Core.Paths.Components +{ + public class EnvironmentVariable : PathComponent + { + [Tooltip("Name only, without % or $. Names differ per platform; prefer SpecialFolder for a portable location")] + public string VariableName; + + [Tooltip("Used when the variable is not set. Leave empty to require it")] + public string Fallback; + + protected override string GetPathInternal(PathReference output, Pipeline pipeline) + { + if (string.IsNullOrEmpty(VariableName)) + throw new InvalidOperationException( + $"{PathDiagnostics.Link(output, this)} has no variable name assigned."); + + var value = Environment.GetEnvironmentVariable(VariableName); + if (!string.IsNullOrEmpty(value)) + return value; + + if (!string.IsNullOrEmpty(Fallback)) + return Fallback; + + throw new InvalidOperationException( + $"{PathDiagnostics.Link(output, this)} requires environment variable \"{VariableName}\", " + + "which is not set for the Editor process. The Editor reads the environment it was " + + "launched with, so a newly added variable needs an Editor restart."); + } + } +} diff --git a/Editor/Core/Paths/Components/EnvironmentVariable.cs.meta b/Editor/Core/Paths/Components/EnvironmentVariable.cs.meta new file mode 100644 index 00000000..56671c6c --- /dev/null +++ b/Editor/Core/Paths/Components/EnvironmentVariable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2dfc473eee484983b4bbe81789575a46 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Core/Paths/Components/OutputReference.cs b/Editor/Core/Paths/Components/OutputReference.cs index 3201e32b..5caa78fd 100644 --- a/Editor/Core/Paths/Components/OutputReference.cs +++ b/Editor/Core/Paths/Components/OutputReference.cs @@ -1,7 +1,5 @@ using System; using ThunderKit.Core.Pipelines; -using UnityEditor; -using UnityEngine.Networking; namespace ThunderKit.Core.Paths.Components { @@ -16,14 +14,12 @@ protected override string GetPathInternal(PathReference output, Pipeline pipelin } catch (NullReferenceException nre) { - var pathReferencePath = UnityWebRequest.EscapeURL(AssetDatabase.GetAssetPath(output)); - var pathReferenceLink = $"[{output.name}.{name}.reference](assetlink://{pathReferencePath})"; + var pathReferenceLink = PathDiagnostics.Link(output, this, ".reference"); throw new InvalidOperationException($"Error {pathReferenceLink} is unassigned or null", nre); } catch (Exception e) { - var pathReferencePath = UnityWebRequest.EscapeURL(AssetDatabase.GetAssetPath(output)); - var pathReferenceLink = $"[{output.name}.{name}.reference({reference.name})](assetlink://{pathReferencePath})"; + var pathReferenceLink = PathDiagnostics.Link(output, this, $".reference({reference.name})"); throw new InvalidOperationException($"Error Invoking PathReference: {pathReferenceLink}", e); } } diff --git a/Editor/Core/Paths/Components/SpecialFolder.cs b/Editor/Core/Paths/Components/SpecialFolder.cs new file mode 100644 index 00000000..742afa3a --- /dev/null +++ b/Editor/Core/Paths/Components/SpecialFolder.cs @@ -0,0 +1,33 @@ +using System; +using ThunderKit.Core.Pipelines; +using UnityEngine; + +namespace ThunderKit.Core.Paths.Components +{ + public class SpecialFolder : PathComponent + { + [Tooltip("Resolved per platform. ApplicationData: %APPDATA%, ~/.config, ~/Library/Application Support")] + public Environment.SpecialFolder Folder = Environment.SpecialFolder.ApplicationData; + + protected override string GetPathInternal(PathReference output, Pipeline pipeline) + { + string folderPath; + try + { + folderPath = Environment.GetFolderPath(Folder); + } + catch (ArgumentException argumentException) + { + throw new InvalidOperationException( + $"{PathDiagnostics.Link(output, this)} is set to {(int)Folder}, which is not a known special folder.", + argumentException); + } + + if (string.IsNullOrEmpty(folderPath)) + throw new InvalidOperationException( + $"{PathDiagnostics.Link(output, this)} requested {Folder}, which has no location on this platform."); + + return folderPath; + } + } +} diff --git a/Editor/Core/Paths/Components/SpecialFolder.cs.meta b/Editor/Core/Paths/Components/SpecialFolder.cs.meta new file mode 100644 index 00000000..4abc89b7 --- /dev/null +++ b/Editor/Core/Paths/Components/SpecialFolder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fd7511a5a4e04a86adcc32e7a4f8902d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Core/Paths/PathAssembler.cs b/Editor/Core/Paths/PathAssembler.cs new file mode 100644 index 00000000..253baf6d --- /dev/null +++ b/Editor/Core/Paths/PathAssembler.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using System.Linq; +using ThunderKit.Core.Pipelines; + +namespace ThunderKit.Core.Paths +{ + // Every PathComponent result converges here, so this is the only place that can + // enforce that a component cannot silently corrupt the assembled path. + internal static class PathAssembler + { + static readonly char[] InvalidPathChars = Path.GetInvalidPathChars(); + + // Drive-relative segments ("C:") only carry that meaning where paths are DOS + // shaped; ':' is an ordinary filename character elsewhere. + static readonly bool DosPaths = Path.DirectorySeparatorChar == '\\'; + + public static string Assemble(PathReference output, Pipeline pipeline, ComposableElement[] data) + { + if (data == null) + return string.Empty; + + var components = data.OfType().ToArray(); + var segments = new string[components.Length]; + for (int index = 0; index < components.Length; index++) + { + var component = components[index]; + segments[index] = Validated(output, component, component.GetPath(output, pipeline), index); + } + + if (segments.Length == 0) + return string.Empty; + + return Path.Combine(segments); + } + + // Ordered so that Path.IsPathRooted and Path.Combine, which reject invalid + // characters on the .NET Framework profile, are never reached with them. + static string Validated(PathReference output, PathComponent component, string segment, int index) + { + if (segment == null) + throw new InvalidOperationException( + $"{PathDiagnostics.Link(output, component)} returned null. " + + "A PathComponent must return a path segment or an empty string."); + + if (segment.IndexOfAny(InvalidPathChars) >= 0) + throw new InvalidOperationException( + $"{PathDiagnostics.Link(output, component)} returned \"{segment}\", " + + "which contains characters that are not valid in a path."); + + if (DosPaths && IsDriveRelative(segment)) + throw new InvalidOperationException( + $"{PathDiagnostics.Link(output, component)} returned the drive-relative path \"{segment}\", " + + "which resolves against the current directory of that drive rather than its root. " + + "Add a trailing separator to mean the drive root."); + + if (index > 0 && Path.IsPathRooted(segment)) + throw new InvalidOperationException( + $"{PathDiagnostics.Link(output, component)} returned the rooted path \"{segment}\" at position {index}. " + + "Combining a rooted segment discards every component before it; " + + "move it to the first position or make it relative."); + + return segment; + } + + static bool IsDriveRelative(string segment) + { + if (segment.Length < 2 || segment[1] != ':' || !char.IsLetter(segment[0])) + return false; + + if (segment.Length == 2) + return true; + + return segment[2] != '\\' && segment[2] != '/'; + } + } +} diff --git a/Editor/Core/Paths/PathAssembler.cs.meta b/Editor/Core/Paths/PathAssembler.cs.meta new file mode 100644 index 00000000..afa1bf28 --- /dev/null +++ b/Editor/Core/Paths/PathAssembler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 10279cd62aea49728bb5dfa2b89d62bc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Core/Paths/PathDiagnostics.cs b/Editor/Core/Paths/PathDiagnostics.cs new file mode 100644 index 00000000..7ff56547 --- /dev/null +++ b/Editor/Core/Paths/PathDiagnostics.cs @@ -0,0 +1,27 @@ +using UnityEditor; +using UnityEngine.Networking; +using Object = UnityEngine.Object; + +namespace ThunderKit.Core.Paths +{ + // Pipeline logs render assetlink:// URIs as clickable links to the offending + // asset, so every path resolution failure is reported through this shape. + internal static class PathDiagnostics + { + public static string Link(PathReference output, PathComponent component, string suffix = null) + { + return Link(output, $"{Describe(output)}.{Describe(component)}{suffix}"); + } + + public static string Link(PathReference output, string label) + { + var assetPath = UnityWebRequest.EscapeURL(AssetDatabase.GetAssetPath(output)); + return $"[{label}](assetlink://{assetPath})"; + } + + static string Describe(Object target) + { + return target ? target.name : ""; + } + } +} diff --git a/Editor/Core/Paths/PathDiagnostics.cs.meta b/Editor/Core/Paths/PathDiagnostics.cs.meta new file mode 100644 index 00000000..ff0543a3 --- /dev/null +++ b/Editor/Core/Paths/PathDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 000582a88c4e4ddd84254855ff6f60fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Core/Paths/PathReference.cs b/Editor/Core/Paths/PathReference.cs index ae326805..442748c8 100644 --- a/Editor/Core/Paths/PathReference.cs +++ b/Editor/Core/Paths/PathReference.cs @@ -7,7 +7,6 @@ using ThunderKit.Core.Utilities; using UnityEditor; using UnityEngine.Networking; -using static System.IO.Path; namespace ThunderKit.Core.Paths { @@ -71,17 +70,29 @@ public static string ResolvePath(string input, Pipeline pipeline, UnityEngine.Ob public string GetPath(Pipeline pipeline) { - return Combine(Data.OfType().Select(pc => pc.GetPath(this, pipeline)).ToArray()); + using (PathResolutionScope.Enter(this)) + return PathAssembler.Assemble(this, pipeline, Data); } private static Dictionary FindAllPathReferences() { - var pathReferenceGuids = AssetDatabase.FindAssets($"t:{nameof(PathReference)}", Constants.FindAllFolders); - return pathReferenceGuids - .Select(x => AssetDatabase.GUIDToAssetPath(x)) - .Select(x => AssetDatabase.LoadAssetAtPath(x)) - .Where(x => x != null) - .ToDictionary(pr => pr.name); + var pathReferences = AssetDatabase.FindAssets($"t:{nameof(PathReference)}", Constants.FindAllFolders) + .Select(guid => AssetDatabase.GUIDToAssetPath(guid)) + .Select(assetPath => AssetDatabase.LoadAssetAtPath(assetPath)) + .Where(pathReference => pathReference != null) + .ToArray(); + + var ambiguous = pathReferences.GroupBy(pathReference => pathReference.name) + .FirstOrDefault(group => group.Count() > 1); + if (ambiguous != null) + { + var assetPaths = ambiguous.Select(pathReference => AssetDatabase.GetAssetPath(pathReference)).ToArray(); + throw new InvalidOperationException( + $"PathReference name \"{ambiguous.Key}\" is used by more than one asset: {string.Join(", ", assetPaths)}. " + + "PathReference names must be unique because they are addressed by name."); + } + + return pathReferences.ToDictionary(pathReference => pathReference.name); } public override string ElementTemplate => @@ -92,9 +103,9 @@ namespace {{0}} {{{{ public class {{1}} : PathComponent {{{{ - public override string GetPath({nameof(PathReference)} output, Pipeline pipeline) + protected override string GetPathInternal({nameof(PathReference)} output, Pipeline pipeline) {{{{ - return base.GetPath(output, pipeline); + return base.GetPathInternal(output, pipeline); }}}} }}}} }}}} diff --git a/Editor/Core/Paths/PathResolutionScope.cs b/Editor/Core/Paths/PathResolutionScope.cs new file mode 100644 index 00000000..f66d271b --- /dev/null +++ b/Editor/Core/Paths/PathResolutionScope.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ThunderKit.Core.Paths +{ + // PathReference resolution re-enters itself through OutputReference and through + // Resolver's tokens. An authoring cycle would otherwise recurse until + // StackOverflowException, which cannot be caught and terminates the Editor. + internal sealed class PathResolutionScope : IDisposable + { + [ThreadStatic] + static List resolving; + + public static PathResolutionScope Enter(PathReference reference) + { + if (resolving == null) + resolving = new List(); + + var cycleStart = resolving.IndexOf(reference); + if (cycleStart >= 0) + throw new InvalidOperationException(CycleMessage(cycleStart, reference)); + + resolving.Add(reference); + return new PathResolutionScope(); + } + + public void Dispose() + { + resolving.RemoveAt(resolving.Count - 1); + } + + static string CycleMessage(int cycleStart, PathReference repeated) + { + var chain = resolving.Skip(cycleStart) + .Concat(new[] { repeated }) + .Select(reference => PathDiagnostics.Link(reference, reference ? reference.name : "")); + + return $"PathReference cycle detected: {string.Join(" -> ", chain.ToArray())}"; + } + } +} diff --git a/Editor/Core/Paths/PathResolutionScope.cs.meta b/Editor/Core/Paths/PathResolutionScope.cs.meta new file mode 100644 index 00000000..6ecadd0d --- /dev/null +++ b/Editor/Core/Paths/PathResolutionScope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 750dc0440b0b4bc7bd126ca5899ff1e0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/ConstantTests.cs b/Tests/Editor/ConstantTests.cs index 5462bf4e..f7c2f04f 100644 --- a/Tests/Editor/ConstantTests.cs +++ b/Tests/Editor/ConstantTests.cs @@ -7,17 +7,62 @@ namespace ThunderKitTests [TestFixture] public class ConstantTests { + Constant constant; + + [SetUp] + public void SetUp() + { + // Constant.GetPathInternal ignores the output/pipeline arguments, so nulls + // are fine for every case here. + constant = ScriptableObject.CreateInstance(); + } + + [TearDown] + public void TearDown() + { + Object.DestroyImmediate(constant); + } + [Test] public void GetPath_ReturnsConfiguredValue() { - // Constant.GetPathInternal ignores the output/pipeline arguments and - // returns its Value verbatim, so nulls are fine here. - var constant = ScriptableObject.CreateInstance(); constant.Value = "MyConstantPath"; Assert.That(constant.GetPath(null, null), Is.EqualTo("MyConstantPath")); + } - Object.DestroyImmediate(constant); + [Test] + public void GetPath_EmptyValue_ReturnsEmpty() + { + constant.Value = string.Empty; + + Assert.That(constant.GetPath(null, null), Is.Empty); + } + + [Test] + public void GetPath_ValueWithSeparators_IsNotNormalised() + { + constant.Value = "a\\b/c"; + + Assert.That(constant.GetPath(null, null), Is.EqualTo("a\\b/c")); + } + + // Current behaviour: Constant is literal. Pull request #132 proposes expanding + // environment variables here, which this case would detect. + [Test] + public void GetPath_EnvironmentVariableSyntax_IsNotExpanded() + { + constant.Value = "%APPDATA%"; + + Assert.That(constant.GetPath(null, null), Is.EqualTo("%APPDATA%")); + } + + [Test] + public void GetPath_PathReferenceTokenSyntax_IsNotResolved() + { + constant.Value = ""; + + Assert.That(constant.GetPath(null, null), Is.EqualTo("")); } } } diff --git a/Tests/Editor/PathComponentEnvironmentTests.cs b/Tests/Editor/PathComponentEnvironmentTests.cs new file mode 100644 index 00000000..2f2b2ed7 --- /dev/null +++ b/Tests/Editor/PathComponentEnvironmentTests.cs @@ -0,0 +1,132 @@ +using System; +using System.IO; +using NUnit.Framework; +using ThunderKit.Core.Paths.Components; + +namespace ThunderKitTests +{ + // Both components take a name or an enum rather than a template string, so no + // platform specific syntax is parsed and the same asset resolves on Windows, + // Linux and macOS. CI runs these on Linux. + [TestFixture] + public class PathComponentEnvironmentTests + { + const string VariableName = "__TK_TEST_PATH_VAR__"; + + PathComponentFixture fixture; + + [SetUp] + public void SetUp() + { + fixture = new PathComponentFixture(); + Environment.SetEnvironmentVariable(VariableName, null); + } + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable(VariableName, null); + fixture.DestroyAll(); + } + + [Test] + public void EnvironmentVariable_SetVariable_ReturnsValue() + { + Environment.SetEnvironmentVariable(VariableName, "SomeValue"); + + Assert.That(Variable(VariableName).GetPath(null, null), Is.EqualTo("SomeValue")); + } + + // The failure this component exists to prevent: an unset variable must not + // become a literal path segment. + [Test] + public void EnvironmentVariable_UnsetVariable_ThrowsNamingTheComponent() + { + var exception = Assert.Throws( + () => Variable(VariableName).GetPath(null, null)); + + Assert.That(exception.Message, Does.Contain(VariableName)); + Assert.That(exception.Message, Does.Contain("EnvironmentVariable")); + } + + [Test] + public void EnvironmentVariable_UnsetVariableWithFallback_ReturnsFallback() + { + var component = Variable(VariableName); + component.Fallback = "DefaultLocation"; + + Assert.That(component.GetPath(null, null), Is.EqualTo("DefaultLocation")); + } + + [Test] + public void EnvironmentVariable_EmptyValue_IsTreatedAsUnset() + { + Environment.SetEnvironmentVariable(VariableName, string.Empty); + + Assert.Throws(() => Variable(VariableName).GetPath(null, null)); + } + + [Test] + public void EnvironmentVariable_NoVariableName_Throws() + { + Assert.Throws(() => Variable(string.Empty).GetPath(null, null)); + } + + // The name is looked up verbatim, so shell syntax is not accepted and cannot + // silently pass through as a literal segment. + [Test] + public void EnvironmentVariable_PercentSyntaxInName_IsNotUnwrapped() + { + Environment.SetEnvironmentVariable(VariableName, "SomeValue"); + + Assert.Throws( + () => Variable($"%{VariableName}%").GetPath(null, null)); + } + + [Test] + public void SpecialFolder_DefaultsToApplicationData() + { + Assert.That(fixture.Create().Folder, + Is.EqualTo(Environment.SpecialFolder.ApplicationData)); + } + + [Test] + public void SpecialFolder_UserProfile_ReturnsRootedPath() + { + AssertResolvesToRootedPath(Environment.SpecialFolder.UserProfile); + } + + [Test] + public void SpecialFolder_ApplicationData_ReturnsRootedPath() + { + AssertResolvesToRootedPath(Environment.SpecialFolder.ApplicationData); + } + + [Test] + public void SpecialFolder_UnknownFolder_Throws() + { + var component = fixture.Create(); + component.Folder = (Environment.SpecialFolder)(-1); + + Assert.Throws(() => component.GetPath(null, null)); + } + + void AssertResolvesToRootedPath(Environment.SpecialFolder folder) + { + var component = fixture.Create(); + component.Folder = folder; + + var result = component.GetPath(null, null); + + Assert.That(result, Is.Not.Empty); + Assert.That(Path.IsPathRooted(result), Is.True, result); + } + + EnvironmentVariable Variable(string variableName) + { + var component = fixture.Create(); + component.VariableName = variableName; + return component; + } + } +} diff --git a/Tests/Editor/PathComponentEnvironmentTests.cs.meta b/Tests/Editor/PathComponentEnvironmentTests.cs.meta new file mode 100644 index 00000000..8561ab0f --- /dev/null +++ b/Tests/Editor/PathComponentEnvironmentTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcc1bdba3a5742deb92661d962f65e8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PathComponentFileSystemTests.cs b/Tests/Editor/PathComponentFileSystemTests.cs new file mode 100644 index 00000000..9d68053b --- /dev/null +++ b/Tests/Editor/PathComponentFileSystemTests.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using NUnit.Framework; +using ThunderKit.Core.Paths; +using ThunderKit.Core.Paths.Components; +using ThunderKit.Core.Pipelines; + +namespace ThunderKitTests +{ + // FindFile and FindDirectory are the only components that touch the file system. + // They are also the only ones that wrap failures in a diagnostic, so both the + // success and the failure path are pinned here. + [TestFixture] + public class PathComponentFileSystemTests + { + static readonly string SearchRoot = + Path.Combine(Directory.GetCurrentDirectory(), "Temp", "__TK_FindTests__"); + + PathComponentFixture fixture; + + [SetUp] + public void SetUp() + { + fixture = new PathComponentFixture(); + + if (Directory.Exists(SearchRoot)) + Directory.Delete(SearchRoot, true); + + Directory.CreateDirectory(Path.Combine(SearchRoot, "plugins", "nested")); + File.WriteAllText(Path.Combine(SearchRoot, "target.dll"), string.Empty); + File.WriteAllText(Path.Combine(SearchRoot, "plugins", "nested", "deep.dll"), string.Empty); + } + + [TearDown] + public void TearDown() + { + fixture.DestroyAll(); + + if (Directory.Exists(SearchRoot)) + Directory.Delete(SearchRoot, true); + } + + [Test] + public void FindFile_MatchInSearchRoot_ReturnsExistingFilePath() + { + var findFile = fixture.Create(); + findFile.path = SearchRoot; + findFile.searchPattern = "target.dll"; + findFile.searchOption = SearchOption.TopDirectoryOnly; + + var result = findFile.GetPath(null, null); + + Assert.That(File.Exists(result), Is.True, result); + Assert.That(result, Does.EndWith("target.dll")); + } + + [Test] + public void FindFile_AllDirectories_FindsNestedMatch() + { + var findFile = fixture.Create(); + findFile.path = SearchRoot; + findFile.searchPattern = "deep.dll"; + findFile.searchOption = SearchOption.AllDirectories; + + var result = findFile.GetPath(null, null); + + Assert.That(File.Exists(result), Is.True, result); + } + + [Test] + public void FindFile_TopDirectoryOnly_DoesNotFindNestedMatch() + { + var findFile = fixture.Create(); + findFile.path = SearchRoot; + findFile.searchPattern = "deep.dll"; + findFile.searchOption = SearchOption.TopDirectoryOnly; + + Assert.Throws(() => findFile.GetPath(null, null)); + } + + [Test] + public void FindDirectory_MatchInSearchRoot_ReturnsExistingDirectoryPath() + { + var findDirectory = fixture.Create(); + findDirectory.path = SearchRoot; + findDirectory.searchPattern = "plugins"; + findDirectory.searchOption = SearchOption.TopDirectoryOnly; + + var result = findDirectory.GetPath(null, null); + + Assert.That(Directory.Exists(result), Is.True, result); + Assert.That(result, Does.EndWith("plugins")); + } + + // The diagnostic these components build starts with pipeline.pipelineLink, + // which dereferences a job array that Pipeline only populates once Execute + // begins. Outside execution the reported failure is therefore a + // NullReferenceException and the underlying cause is lost. + [Test] + public void FindDirectory_NoMatch_DiagnosticIsLostOutsidePipelineExecution() + { + var findDirectory = fixture.Create(); + findDirectory.path = SearchRoot; + findDirectory.searchPattern = "__TK_NoSuchDirectory__"; + findDirectory.searchOption = SearchOption.TopDirectoryOnly; + + Assert.Throws( + () => findDirectory.GetPath(null, fixture.Create())); + } + + [Test] + public void FindFile_MissingSearchRoot_DiagnosticIsLostOutsidePipelineExecution() + { + var findFile = fixture.Create(); + findFile.path = Path.Combine(SearchRoot, "__TK_NoSuchFolder__"); + findFile.searchPattern = "*.dll"; + findFile.searchOption = SearchOption.TopDirectoryOnly; + + Assert.Throws( + () => findFile.GetPath(fixture.Create(), fixture.Create())); + } + } +} diff --git a/Tests/Editor/PathComponentFileSystemTests.cs.meta b/Tests/Editor/PathComponentFileSystemTests.cs.meta new file mode 100644 index 00000000..234e3e2c --- /dev/null +++ b/Tests/Editor/PathComponentFileSystemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3550a966de184e5bb6a52ead23963057 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PathComponentFixture.cs b/Tests/Editor/PathComponentFixture.cs new file mode 100644 index 00000000..44c56181 --- /dev/null +++ b/Tests/Editor/PathComponentFixture.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.IO; +using ThunderKit.Core.Paths; +using ThunderKit.Core.Paths.Components; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace ThunderKitTests +{ + // Builds PathComponents and PathReferences in memory. Data is assigned directly + // rather than through InsertElement, which would require an asset on disk. + internal sealed class PathComponentFixture + { + readonly List created = new List(); + + public PathReference Reference(params PathComponent[] components) + { + return Compose(Create(), components); + } + + public PathReference Compose(PathReference reference, params PathComponent[] components) + { + reference.Data = components; + return reference; + } + + public Constant Literal(string value) + { + var constant = Create(); + constant.Value = value; + return constant; + } + + public OutputReference Output(PathReference target) + { + var output = Create(); + output.reference = target; + return output; + } + + public T Create() where T : ScriptableObject + { + var instance = ScriptableObject.CreateInstance(); + created.Add(instance); + return instance; + } + + public void DestroyAll() + { + for (int index = created.Count - 1; index >= 0; index--) + if (created[index]) + Object.DestroyImmediate(created[index]); + + created.Clear(); + } + + public static bool DosPaths + { + get { return Path.DirectorySeparatorChar == '\\'; } + } + + // A rooted path that is valid on the platform running the tests. + public static string RootedPath + { + get { return DosPaths ? @"C:\Games\Example" : "/games/example"; } + } + } +} diff --git a/Tests/Editor/PathComponentFixture.cs.meta b/Tests/Editor/PathComponentFixture.cs.meta new file mode 100644 index 00000000..0ce0c5aa --- /dev/null +++ b/Tests/Editor/PathComponentFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4268d6a6d804475d8c0a5bda777158b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PathComponentTests.cs b/Tests/Editor/PathComponentTests.cs new file mode 100644 index 00000000..ca3597cd --- /dev/null +++ b/Tests/Editor/PathComponentTests.cs @@ -0,0 +1,157 @@ +using System; +using System.IO; +using NUnit.Framework; +using ThunderKit.Core.Paths; +using ThunderKit.Core.Paths.Components; +using ThunderKit.Core.Pipelines; +using UnityEditor; + +namespace ThunderKitTests +{ + // Characterises what each PathComponent returns today, including the cases that + // return null or an absolute path, because those results are combined into paths + // that Delete, Copy and ExecuteProcess act on unchecked. + [TestFixture] + public class PathComponentTests + { + PathComponentFixture fixture; + + [SetUp] + public void SetUp() + { + fixture = new PathComponentFixture(); + } + + [TearDown] + public void TearDown() + { + fixture.DestroyAll(); + } + + [Test] + public void PathComponent_Base_ReturnsEmpty() + { + Assert.That(fixture.Create().GetPath(null, null), Is.EqualTo(string.Empty)); + } + + [Test] + public void ThunderKitRoot_ReturnsConstantFolderName() + { + Assert.That(fixture.Create().GetPath(null, null), Is.EqualTo("ThunderKit")); + } + + [Test] + public void WorkingDirectory_ReturnsCurrentDirectory() + { + Assert.That(fixture.Create().GetPath(null, null), + Is.EqualTo(Directory.GetCurrentDirectory())); + } + + [Test] + public void WorkingDirectory_IsRooted() + { + Assert.That(Path.IsPathRooted(fixture.Create().GetPath(null, null)), Is.True); + } + + [Test] + public void AssetReference_UnassignedAsset_ReturnsEmpty() + { + Assert.That(fixture.Create().GetPath(null, null), Is.EqualTo(string.Empty)); + } + + [Test] + public void AssetReference_AssignedFolder_ReturnsProjectRelativePath() + { + const string folderPath = "Assets/__TK_AssetReference__"; + AssetDatabase.DeleteAsset(folderPath); + AssetDatabase.CreateFolder("Assets", "__TK_AssetReference__"); + try + { + var assetReference = fixture.Create(); + assetReference.Asset = AssetDatabase.LoadAssetAtPath(folderPath); + + Assert.That(assetReference.GetPath(null, null), Is.EqualTo(folderPath)); + } + finally + { + AssetDatabase.DeleteAsset(folderPath); + } + } + + [Test] + public void Resolver_ValueWithoutToken_ReturnsValueWithForwardSlashes() + { + var resolver = fixture.Create(); + resolver.value = "some\\plain\\path"; + + Assert.That(resolver.GetPath(null, null), Is.EqualTo("some/plain/path")); + } + + [Test] + public void Resolver_UnknownToken_Throws() + { + var resolver = fixture.Create(); + resolver.value = "<__TK_NonExistentReference__>"; + + Assert.Throws(() => resolver.GetPath(null, null)); + } + + [Test] + public void OutputReference_ResolvesTargetReference() + { + var target = fixture.Reference(fixture.Literal("ThunderKit"), fixture.Literal("Staging")); + var output = fixture.Output(target); + + Assert.That(output.GetPath(null, null), Is.EqualTo(Path.Combine("ThunderKit", "Staging"))); + } + + [Test] + public void OutputReference_UnassignedReference_ThrowsWithDiagnosticLink() + { + var owner = fixture.Create(); + var output = fixture.Create(); + + var exception = Assert.Throws(() => output.GetPath(owner, null)); + Assert.That(exception.Message, Does.Contain("unassigned or null")); + Assert.That(exception.Message, Does.Contain("assetlink://")); + } + + [Test] + public void ManifestName_NoManifest_ReturnsNull() + { + var pipeline = fixture.Create(); + + Assert.That(fixture.Create().GetPath(null, pipeline), Is.Null); + } + + // The null-conditional chain short circuits, so the catch block that reports + // "ManifestIdentity not found" is never reached for this case. + [Test] + public void ManifestName_ManifestWithoutIdentity_ReturnsNull() + { + var pipeline = fixture.Create(); + pipeline.manifest = fixture.Create(); + + Assert.That(fixture.Create().GetPath(null, pipeline), Is.Null); + } + + [Test] + public void ManifestName_NullPipeline_ThrowsNullReference() + { + Assert.Throws(() => fixture.Create().GetPath(null, null)); + } + + [Test] + public void RegistryLookup_MissingKey_ReturnsNull() + { + if (!PathComponentFixture.DosPaths) + Assert.Ignore("Registry lookups are Windows only."); + + var lookup = fixture.Create(); + lookup.KeyName = @"HKEY_CURRENT_USER\Software\__TK_NonExistentKey__"; + lookup.ValueName = "Path"; + + Assert.That(lookup.GetPath(null, null), Is.Null); + } + } +} diff --git a/Tests/Editor/PathComponentTests.cs.meta b/Tests/Editor/PathComponentTests.cs.meta new file mode 100644 index 00000000..969cdebb --- /dev/null +++ b/Tests/Editor/PathComponentTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3fa80c9b81324c9ab4f9570a81b0bb29 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PathReferenceAssetTests.cs b/Tests/Editor/PathReferenceAssetTests.cs new file mode 100644 index 00000000..242fc8e3 --- /dev/null +++ b/Tests/Editor/PathReferenceAssetTests.cs @@ -0,0 +1,114 @@ +using System; +using NUnit.Framework; +using ThunderKit.Core.Paths; +using ThunderKit.Core.Paths.Components; +using UnityEditor; +using UnityEngine; + +namespace ThunderKitTests +{ + // ResolvePath addresses PathReferences by name through the AssetDatabase, so + // these cases need assets on disk rather than in-memory instances. + [TestFixture] + public class PathReferenceAssetTests + { + const string FirstFolder = "__TK_PathRefA__"; + const string SecondFolder = "__TK_PathRefB__"; + + [SetUp] + public void SetUp() + { + Cleanup(); + } + + [TearDown] + public void TearDown() + { + Cleanup(); + } + + [Test] + public void ResolvePath_KnownToken_SubstitutesAssembledPath() + { + CreateReference(FirstFolder, "__TK_TokenTarget__", "Resolved"); + + Assert.That(PathReference.ResolvePath("prefix/<__TK_TokenTarget__>/suffix", null, null), + Is.EqualTo("prefix/Resolved/suffix")); + } + + [Test] + public void ResolvePath_MultipleTokens_SubstitutesEach() + { + CreateReference(FirstFolder, "__TK_TokenOne__", "One"); + CreateReference(SecondFolder, "__TK_TokenTwo__", "Two"); + + Assert.That(PathReference.ResolvePath("<__TK_TokenOne__>/<__TK_TokenTwo__>", null, null), + Is.EqualTo("One/Two")); + } + + // References are addressed by name, so two assets sharing one is ambiguous. + // The failure names the duplicate and both assets rather than surfacing as a + // bare dictionary key collision. + [Test] + public void ResolvePath_DuplicateReferenceNames_ReportsTheConflict() + { + CreateReference(FirstFolder, "__TK_Duplicate__", "First"); + CreateReference(SecondFolder, "__TK_Duplicate__", "Second"); + + var exception = Assert.Throws( + () => PathReference.ResolvePath("<__TK_Duplicate__>", null, null)); + + Assert.That(exception.Message, Does.Contain("__TK_Duplicate__")); + Assert.That(exception.Message, Does.Contain(FirstFolder)); + Assert.That(exception.Message, Does.Contain(SecondFolder)); + } + + // The second way resolution re-enters itself: a Resolver whose token names + // the reference that owns it. + [Test] + public void GetPath_ResolverTokenNamingItsOwnReference_Throws() + { + var reference = CreateResolverReference(FirstFolder, "__TK_TokenCycle__"); + + var exception = Assert.Throws(() => reference.GetPath(null)); + Assert.That(exception.Message, Does.Contain("cycle")); + } + + static PathReference CreateResolverReference(string folder, string referenceName) + { + if (!AssetDatabase.IsValidFolder($"Assets/{folder}")) + AssetDatabase.CreateFolder("Assets", folder); + + var reference = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(reference, $"Assets/{folder}/{referenceName}.asset"); + + var resolver = ScriptableObject.CreateInstance(); + reference.InsertElement(resolver, 0); + resolver.value = $"<{referenceName}>"; + + AssetDatabase.SaveAssets(); + return reference; + } + + static void CreateReference(string folder, string referenceName, string constantValue) + { + if (!AssetDatabase.IsValidFolder($"Assets/{folder}")) + AssetDatabase.CreateFolder("Assets", folder); + + var reference = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(reference, $"Assets/{folder}/{referenceName}.asset"); + + var constant = ScriptableObject.CreateInstance(); + reference.InsertElement(constant, 0); + constant.Value = constantValue; + + AssetDatabase.SaveAssets(); + } + + static void Cleanup() + { + AssetDatabase.DeleteAsset($"Assets/{FirstFolder}"); + AssetDatabase.DeleteAsset($"Assets/{SecondFolder}"); + } + } +} diff --git a/Tests/Editor/PathReferenceAssetTests.cs.meta b/Tests/Editor/PathReferenceAssetTests.cs.meta new file mode 100644 index 00000000..eceea895 --- /dev/null +++ b/Tests/Editor/PathReferenceAssetTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 78144c32bdfc4b9c898b6d481682a724 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PathReferenceCombineTests.cs b/Tests/Editor/PathReferenceCombineTests.cs new file mode 100644 index 00000000..d38153f8 --- /dev/null +++ b/Tests/Editor/PathReferenceCombineTests.cs @@ -0,0 +1,183 @@ +using System; +using System.IO; +using NUnit.Framework; +using ThunderKit.Core; +using ThunderKit.Core.Paths; +using ThunderKit.Core.Pipelines; + +namespace ThunderKitTests +{ + // How PathReference turns its PathComponents into one path. Several cases pin + // authoring patterns that ship in Templates/; the rest record what an + // unexpected component result does today, since the assembled path is handed + // straight to Delete, Copy, Zip and ExecuteProcess. + // + // A reference cycle is deliberately not covered: without a guard it recurses + // until StackOverflowException, which cannot be caught and would take the test + // runner down with it. + [TestFixture] + public class PathReferenceCombineTests + { + PathComponentFixture fixture; + + [SetUp] + public void SetUp() + { + fixture = new PathComponentFixture(); + } + + [TearDown] + public void TearDown() + { + fixture.DestroyAll(); + } + + [Test] + public void GetPath_CombinesComponentsInOrder() + { + var reference = fixture.Reference(fixture.Literal("ThunderKit"), fixture.Literal("Staging")); + + Assert.That(reference.GetPath(null), Is.EqualTo(Path.Combine("ThunderKit", "Staging"))); + } + + [Test] + public void GetPath_SingleComponent_ReturnsSegmentVerbatim() + { + Assert.That(fixture.Reference(fixture.Literal("ThunderKit")).GetPath(null), Is.EqualTo("ThunderKit")); + } + + [Test] + public void GetPath_NoComponents_ReturnsEmpty() + { + Assert.That(fixture.Reference().GetPath(null), Is.Empty); + } + + [Test] + public void GetPath_EmptySegment_IsOmitted() + { + var reference = fixture.Reference( + fixture.Literal("ThunderKit"), + fixture.Literal(string.Empty), + fixture.Literal("Staging")); + + Assert.That(reference.GetPath(null), Is.EqualTo(Path.Combine("ThunderKit", "Staging"))); + } + + [Test] + public void GetPath_NonPathComponentData_IsIgnored() + { + var reference = fixture.Create(); + reference.Data = new ComposableElement[] + { + fixture.Create(), + fixture.Literal("Staging") + }; + + Assert.That(reference.GetPath(null), Is.EqualTo("Staging")); + } + + // Templates/BepInEx/PathReferences/BepInExPackSource.asset is FindDirectory + // followed by Constant(".."), so parent traversal must keep working. + [Test] + public void GetPath_ParentDirectorySegment_IsPreserved() + { + var reference = fixture.Reference(fixture.Literal("BepInExPack"), fixture.Literal("..")); + + Assert.That(reference.GetPath(null), Is.EqualTo(Path.Combine("BepInExPack", ".."))); + } + + // Templates/PathReferences/GameExecutable.asset is GamePath, which is + // absolute, followed by the executable file name. + [Test] + public void GetPath_RootedFirstComponent_IsAllowed() + { + var reference = fixture.Reference( + fixture.Literal(PathComponentFixture.RootedPath), + fixture.Literal("Example.exe")); + + Assert.That(reference.GetPath(null), + Is.EqualTo(Path.Combine(PathComponentFixture.RootedPath, "Example.exe"))); + } + + [Test] + public void GetPath_NestedOutputReference_ResolvesTargetFirst() + { + var root = fixture.Reference(fixture.Literal("ThunderKit"), fixture.Literal("Staging")); + var nested = fixture.Reference(fixture.Output(root), fixture.Literal("plugins")); + + Assert.That(nested.GetPath(null), Is.EqualTo(Path.Combine("ThunderKit", "Staging", "plugins"))); + } + + [Test] + public void GetPath_SameReferenceUsedTwiceAsSiblings_ResolvesBoth() + { + var shared = fixture.Reference(fixture.Literal("Shared")); + var reference = fixture.Reference(fixture.Output(shared), fixture.Output(shared)); + + Assert.That(reference.GetPath(null), Is.EqualTo(Path.Combine("Shared", "Shared"))); + } + + // A rooted segment would discard everything before it, so it is refused + // anywhere but the first position. + [Test] + public void GetPath_RootedSegmentAfterFirst_Throws() + { + var reference = fixture.Reference( + fixture.Literal("ThunderKit"), + fixture.Literal(PathComponentFixture.RootedPath)); + + var exception = Assert.Throws(() => reference.GetPath(null)); + Assert.That(exception.Message, Does.Contain("discards")); + } + + // "C:" is what %SYSTEMDRIVE% and %HOMEDRIVE% expand to. + [Test] + public void GetPath_DriveRelativeSegment_Throws() + { + if (!PathComponentFixture.DosPaths) + Assert.Ignore("Drive-relative paths are a DOS path concept."); + + var reference = fixture.Reference(fixture.Literal("ThunderKit"), fixture.Literal("C:")); + + var exception = Assert.Throws(() => reference.GetPath(null)); + Assert.That(exception.Message, Does.Contain("drive-relative")); + } + + [Test] + public void GetPath_DriveRelativeFirstSegment_Throws() + { + if (!PathComponentFixture.DosPaths) + Assert.Ignore("Drive-relative paths are a DOS path concept."); + + var reference = fixture.Reference(fixture.Literal("C:"), fixture.Literal("BepInEx")); + + Assert.Throws(() => reference.GetPath(null)); + } + + // ManifestName returns null with no manifest assigned. The failure now names + // the component that produced it instead of surfacing as a bare + // ArgumentNullException from Path.Combine. + [Test] + public void GetPath_ComponentReturnsNull_ThrowsNamingTheComponent() + { + var pipeline = fixture.Create(); + var reference = fixture.Reference( + fixture.Create()); + + var exception = Assert.Throws(() => reference.GetPath(pipeline)); + Assert.That(exception.Message, Does.Contain("ManifestName")); + Assert.That(exception.Message, Does.Contain("assetlink://")); + } + + // Data stays null until a component is added, which is the state a newly + // created PathReference asset is in. + [Test] + public void GetPath_UnassignedData_ReturnsEmpty() + { + var reference = fixture.Create(); + + Assert.That(reference.Data, Is.Null); + Assert.That(reference.GetPath(null), Is.Empty); + } + } +} diff --git a/Tests/Editor/PathReferenceCombineTests.cs.meta b/Tests/Editor/PathReferenceCombineTests.cs.meta new file mode 100644 index 00000000..22c43646 --- /dev/null +++ b/Tests/Editor/PathReferenceCombineTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5f8741e0a0c3487b913554028140b358 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PathReferenceCycleTests.cs b/Tests/Editor/PathReferenceCycleTests.cs new file mode 100644 index 00000000..1473cc80 --- /dev/null +++ b/Tests/Editor/PathReferenceCycleTests.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using NUnit.Framework; +using ThunderKit.Core.Paths; + +namespace ThunderKitTests +{ + // Resolution re-enters itself through OutputReference, so an authoring cycle + // used to recurse until StackOverflowException, which cannot be caught and + // takes the Editor with it. These cases are only writable once a guard exists. + [TestFixture] + public class PathReferenceCycleTests + { + PathComponentFixture fixture; + + [SetUp] + public void SetUp() + { + fixture = new PathComponentFixture(); + } + + [TearDown] + public void TearDown() + { + fixture.DestroyAll(); + } + + [Test] + public void GetPath_SelfReferencingOutputReference_Throws() + { + var reference = fixture.Reference(); + fixture.Compose(reference, fixture.Output(reference)); + + var exception = Assert.Throws(() => reference.GetPath(null)); + Assert.That(DescribesCycle(exception), Is.True, exception.ToString()); + } + + [Test] + public void GetPath_MutuallyReferencingOutputReferences_Throws() + { + var first = fixture.Reference(); + var second = fixture.Reference(); + fixture.Compose(first, fixture.Output(second)); + fixture.Compose(second, fixture.Output(first)); + + var exception = Assert.Throws(() => first.GetPath(null)); + Assert.That(DescribesCycle(exception), Is.True, exception.ToString()); + } + + [Test] + public void GetPath_ThreeReferenceCycle_Throws() + { + var first = fixture.Reference(); + var second = fixture.Reference(); + var third = fixture.Reference(); + fixture.Compose(first, fixture.Output(second)); + fixture.Compose(second, fixture.Output(third)); + fixture.Compose(third, fixture.Output(first)); + + var exception = Assert.Throws(() => first.GetPath(null)); + Assert.That(DescribesCycle(exception), Is.True, exception.ToString()); + } + + // A leaked resolution scope would poison every later resolution in the + // session, so both failure kinds have to unwind cleanly. + [Test] + public void GetPath_AfterCycleFailure_UnrelatedReferenceStillResolves() + { + var cyclic = fixture.Reference(); + fixture.Compose(cyclic, fixture.Output(cyclic)); + Assert.Throws(() => cyclic.GetPath(null)); + + AssertHealthyReferenceResolves(); + } + + [Test] + public void GetPath_AfterValidationFailure_UnrelatedReferenceStillResolves() + { + var invalid = fixture.Reference( + fixture.Literal("ThunderKit"), + fixture.Literal(PathComponentFixture.RootedPath)); + Assert.Throws(() => invalid.GetPath(null)); + + AssertHealthyReferenceResolves(); + } + + [Test] + public void GetPath_SameReferenceResolvedTwiceInSequence_Succeeds() + { + var reference = fixture.Reference(fixture.Literal("ThunderKit")); + + Assert.That(reference.GetPath(null), Is.EqualTo("ThunderKit")); + Assert.That(reference.GetPath(null), Is.EqualTo("ThunderKit")); + } + + void AssertHealthyReferenceResolves() + { + var healthy = fixture.Reference(fixture.Literal("ThunderKit"), fixture.Literal("Staging")); + + Assert.That(healthy.GetPath(null), Is.EqualTo(Path.Combine("ThunderKit", "Staging"))); + } + + static bool DescribesCycle(Exception exception) + { + for (var current = exception; current != null; current = current.InnerException) + if (current.Message.IndexOf("cycle", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + + return false; + } + } +} diff --git a/Tests/Editor/PathReferenceCycleTests.cs.meta b/Tests/Editor/PathReferenceCycleTests.cs.meta new file mode 100644 index 00000000..1ff0088b --- /dev/null +++ b/Tests/Editor/PathReferenceCycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6bb80f8314d04bbca10af5269b3a207d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PathReferenceTests.cs b/Tests/Editor/PathReferenceTests.cs index fcabdb81..e6a62f76 100644 --- a/Tests/Editor/PathReferenceTests.cs +++ b/Tests/Editor/PathReferenceTests.cs @@ -1,6 +1,9 @@ using System; +using System.Reflection; using NUnit.Framework; using ThunderKit.Core.Paths; +using UnityEngine; +using Object = UnityEngine.Object; namespace ThunderKitTests { @@ -25,5 +28,32 @@ public void ResolvePath_UnknownToken_ThrowsInvalidOperationException() () => PathReference.ResolvePath("<__NonExistentPathReference_Test__>", null, null), Throws.InstanceOf()); } + + // The template is the source for scaffolded PathComponents, so it has to + // override the virtual extension point rather than the public entry point. + [Test] + public void ElementTemplate_OverridesTheVirtualExtensionPoint() + { + var reference = ScriptableObject.CreateInstance(); + try + { + var extensionPoint = typeof(PathComponent).GetMethod( + "GetPathInternal", BindingFlags.Instance | BindingFlags.NonPublic); + var entryPoint = typeof(PathComponent).GetMethod( + "GetPath", BindingFlags.Instance | BindingFlags.Public); + + Assert.That(extensionPoint, Is.Not.Null); + Assert.That(extensionPoint.IsVirtual, Is.True); + Assert.That(entryPoint.IsVirtual, Is.False); + + Assert.That(reference.ElementTemplate, Does.Contain(extensionPoint.Name)); + Assert.That(reference.ElementTemplate, + Does.Not.Contain($"override string {entryPoint.Name}(")); + } + finally + { + Object.DestroyImmediate(reference); + } + } } }