From 2c3a873a8b70de5191509ea5a78254c5b0a8049d Mon Sep 17 00:00:00 2001 From: PassivePicasso Date: Sat, 22 Aug 2026 15:08:11 -0400 Subject: [PATCH 1/9] Add characterization tests for the path component system Cover every PathComponent, PathReference assembly and ResolvePath token substitution against current behaviour, including the cases that return null, return an absolute path, or lose the underlying error. Reference cycles are not covered because they recurse until StackOverflowException. --- Tests/Editor/ConstantTests.cs | 53 +++++- Tests/Editor/PathComponentFileSystemTests.cs | 123 +++++++++++++ .../PathComponentFileSystemTests.cs.meta | 11 ++ Tests/Editor/PathComponentFixture.cs | 68 +++++++ Tests/Editor/PathComponentFixture.cs.meta | 11 ++ Tests/Editor/PathComponentTests.cs | 157 ++++++++++++++++ Tests/Editor/PathComponentTests.cs.meta | 11 ++ Tests/Editor/PathReferenceAssetTests.cs | 83 +++++++++ Tests/Editor/PathReferenceAssetTests.cs.meta | 11 ++ Tests/Editor/PathReferenceCombineTests.cs | 173 ++++++++++++++++++ .../Editor/PathReferenceCombineTests.cs.meta | 11 ++ 11 files changed, 708 insertions(+), 4 deletions(-) create mode 100644 Tests/Editor/PathComponentFileSystemTests.cs create mode 100644 Tests/Editor/PathComponentFileSystemTests.cs.meta create mode 100644 Tests/Editor/PathComponentFixture.cs create mode 100644 Tests/Editor/PathComponentFixture.cs.meta create mode 100644 Tests/Editor/PathComponentTests.cs create mode 100644 Tests/Editor/PathComponentTests.cs.meta create mode 100644 Tests/Editor/PathReferenceAssetTests.cs create mode 100644 Tests/Editor/PathReferenceAssetTests.cs.meta create mode 100644 Tests/Editor/PathReferenceCombineTests.cs create mode 100644 Tests/Editor/PathReferenceCombineTests.cs.meta diff --git a/Tests/Editor/ConstantTests.cs b/Tests/Editor/ConstantTests.cs index 5462bf4e..b0f60e92 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/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..518e180d --- /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..d47b9f0a --- /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..93d24126 --- /dev/null +++ b/Tests/Editor/PathReferenceAssetTests.cs @@ -0,0 +1,83 @@ +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")); + } + + // Current behaviour: names are collected into a dictionary, so two assets + // sharing a name break every path resolution in the project with an + // exception that names neither the reference nor the assets involved. + [Test] + public void ResolvePath_DuplicateReferenceNames_ThrowsArgumentException() + { + CreateReference(FirstFolder, "__TK_Duplicate__", "First"); + CreateReference(SecondFolder, "__TK_Duplicate__", "Second"); + + Assert.That(() => PathReference.ResolvePath("<__TK_Duplicate__>", null, null), + Throws.InstanceOf()); + } + + 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..84b3a19b --- /dev/null +++ b/Tests/Editor/PathReferenceCombineTests.cs @@ -0,0 +1,173 @@ +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"))); + } + + // Current behaviour: a rooted segment silently discards everything before it. + [Test] + public void GetPath_RootedSegmentAfterFirst_DiscardsPrecedingSegments() + { + var reference = fixture.Reference( + fixture.Literal("ThunderKit"), + fixture.Literal(PathComponentFixture.RootedPath)); + + Assert.That(reference.GetPath(null), Is.EqualTo(PathComponentFixture.RootedPath)); + } + + // Current behaviour: "C:" is what %SYSTEMDRIVE% and %HOMEDRIVE% expand to. + // The exact result differs between runtimes, but the preceding segments are + // dropped on all of them. + [Test] + public void GetPath_DriveRelativeSegment_DiscardsPrecedingSegments() + { + if (!PathComponentFixture.DosPaths) + Assert.Ignore("Drive-relative paths are a DOS path concept."); + + var reference = fixture.Reference(fixture.Literal("ThunderKit"), fixture.Literal("BepInEx")); + var expected = reference.GetPath(null); + + var driveRelative = fixture.Reference(fixture.Literal("ThunderKit"), fixture.Literal("C:")); + var result = driveRelative.GetPath(null); + + Assert.That(result, Is.Not.EqualTo(expected)); + Assert.That(result, Does.Not.StartWith("ThunderKit")); + } + + // Current behaviour: ManifestName returns null with no manifest assigned, and + // Path.Combine rejects it without naming the component responsible. + [Test] + public void GetPath_ComponentReturnsNull_ThrowsArgumentNullException() + { + var pipeline = fixture.Create(); + var reference = fixture.Reference( + fixture.Create()); + + Assert.Throws(() => reference.GetPath(pipeline)); + } + + // Current behaviour: a PathReference that has never had components assigned + // dereferences a null array. + [Test] + public void GetPath_UnassignedData_ThrowsNullReferenceException() + { + var reference = fixture.Create(); + + Assert.That(reference.Data, Is.Null); + Assert.Throws(() => reference.GetPath(null)); + } + } +} 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: From 6bb624f0c727fb04acfecab87129166c6067e32d Mon Sep 17 00:00:00 2001 From: PassivePicasso Date: Sat, 22 Aug 2026 15:14:52 -0400 Subject: [PATCH 2/9] Fix escaped backslashes in path component tests --- Tests/Editor/ConstantTests.cs | 4 ++-- Tests/Editor/PathComponentFixture.cs | 2 +- Tests/Editor/PathComponentTests.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/Editor/ConstantTests.cs b/Tests/Editor/ConstantTests.cs index b0f60e92..f7c2f04f 100644 --- a/Tests/Editor/ConstantTests.cs +++ b/Tests/Editor/ConstantTests.cs @@ -42,9 +42,9 @@ public void GetPath_EmptyValue_ReturnsEmpty() [Test] public void GetPath_ValueWithSeparators_IsNotNormalised() { - constant.Value = "a\b/c"; + constant.Value = "a\\b/c"; - Assert.That(constant.GetPath(null, null), Is.EqualTo("a\b/c")); + Assert.That(constant.GetPath(null, null), Is.EqualTo("a\\b/c")); } // Current behaviour: Constant is literal. Pull request #132 proposes expanding diff --git a/Tests/Editor/PathComponentFixture.cs b/Tests/Editor/PathComponentFixture.cs index 518e180d..44c56181 100644 --- a/Tests/Editor/PathComponentFixture.cs +++ b/Tests/Editor/PathComponentFixture.cs @@ -56,7 +56,7 @@ public void DestroyAll() public static bool DosPaths { - get { return Path.DirectorySeparatorChar == '\'; } + get { return Path.DirectorySeparatorChar == '\\'; } } // A rooted path that is valid on the platform running the tests. diff --git a/Tests/Editor/PathComponentTests.cs b/Tests/Editor/PathComponentTests.cs index d47b9f0a..ca3597cd 100644 --- a/Tests/Editor/PathComponentTests.cs +++ b/Tests/Editor/PathComponentTests.cs @@ -82,7 +82,7 @@ public void AssetReference_AssignedFolder_ReturnsProjectRelativePath() public void Resolver_ValueWithoutToken_ReturnsValueWithForwardSlashes() { var resolver = fixture.Create(); - resolver.value = "some\plain\path"; + resolver.value = "some\\plain\\path"; Assert.That(resolver.GetPath(null, null), Is.EqualTo("some/plain/path")); } From 41f8d98259fc7e1dce4786de57f55e934c39578b Mon Sep 17 00:00:00 2001 From: PassivePicasso Date: Sat, 22 Aug 2026 15:19:32 -0400 Subject: [PATCH 3/9] Correct fresh PathReference characterization Unity initialises serialized array fields, so Data on a newly created PathReference is empty rather than null and GetPath returns an empty string. --- Tests/Editor/PathReferenceCombineTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/Editor/PathReferenceCombineTests.cs b/Tests/Editor/PathReferenceCombineTests.cs index 84b3a19b..decd6e6e 100644 --- a/Tests/Editor/PathReferenceCombineTests.cs +++ b/Tests/Editor/PathReferenceCombineTests.cs @@ -159,15 +159,15 @@ public void GetPath_ComponentReturnsNull_ThrowsArgumentNullException() Assert.Throws(() => reference.GetPath(pipeline)); } - // Current behaviour: a PathReference that has never had components assigned - // dereferences a null array. + // Unity initialises serialized array fields, so a PathReference that has + // never had components assigned holds an empty Data array rather than null. [Test] - public void GetPath_UnassignedData_ThrowsNullReferenceException() + public void GetPath_FreshlyCreatedReference_ReturnsEmpty() { var reference = fixture.Create(); - Assert.That(reference.Data, Is.Null); - Assert.Throws(() => reference.GetPath(null)); + Assert.That(reference.Data, Is.Empty); + Assert.That(reference.GetPath(null), Is.Empty); } } } From c3845be22bd582823c172ed4c9262d6a45d00bde Mon Sep 17 00:00:00 2001 From: PassivePicasso Date: Sat, 22 Aug 2026 15:23:16 -0400 Subject: [PATCH 4/9] Correct unassigned Data characterization Data stays null until a component is added and GetPath fails in the OfType null check, so the exception is ArgumentNullException. --- Tests/Editor/PathReferenceCombineTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Tests/Editor/PathReferenceCombineTests.cs b/Tests/Editor/PathReferenceCombineTests.cs index decd6e6e..8ae38f68 100644 --- a/Tests/Editor/PathReferenceCombineTests.cs +++ b/Tests/Editor/PathReferenceCombineTests.cs @@ -159,15 +159,16 @@ public void GetPath_ComponentReturnsNull_ThrowsArgumentNullException() Assert.Throws(() => reference.GetPath(pipeline)); } - // Unity initialises serialized array fields, so a PathReference that has - // never had components assigned holds an empty Data array rather than null. + // Current behaviour: Data stays null until a component is added, and the + // failure surfaces from the OfType null check, naming neither the reference + // nor the asset it came from. [Test] - public void GetPath_FreshlyCreatedReference_ReturnsEmpty() + public void GetPath_UnassignedData_ThrowsArgumentNullException() { var reference = fixture.Create(); - Assert.That(reference.Data, Is.Empty); - Assert.That(reference.GetPath(null), Is.Empty); + Assert.That(reference.Data, Is.Null); + Assert.That(() => reference.GetPath(null), Throws.InstanceOf()); } } } From 332b44b6f2ca9d0177616d5deaca5f7e5ea9ba76 Mon Sep 17 00:00:00 2001 From: PassivePicasso Date: Sat, 22 Aug 2026 15:27:44 -0400 Subject: [PATCH 5/9] Guard path assembly against cycles and corrupt segments PathReference.GetPath now resolves inside a PathResolutionScope, so a reference cycle reports the chain instead of recursing until StackOverflowException, and PathAssembler rejects null, invalid, drive-relative and misplaced rooted segments while naming the component that produced them. --- .../Core/Paths/Components/OutputReference.cs | 8 +- Editor/Core/Paths/PathAssembler.cs | 77 ++++++++++++ Editor/Core/Paths/PathAssembler.cs.meta | 11 ++ Editor/Core/Paths/PathDiagnostics.cs | 27 +++++ Editor/Core/Paths/PathDiagnostics.cs.meta | 11 ++ Editor/Core/Paths/PathReference.cs | 4 +- Editor/Core/Paths/PathResolutionScope.cs | 42 +++++++ Editor/Core/Paths/PathResolutionScope.cs.meta | 11 ++ Tests/Editor/PathReferenceAssetTests.cs | 27 +++++ Tests/Editor/PathReferenceCombineTests.cs | 53 +++++---- Tests/Editor/PathReferenceCycleTests.cs | 112 ++++++++++++++++++ Tests/Editor/PathReferenceCycleTests.cs.meta | 11 ++ 12 files changed, 364 insertions(+), 30 deletions(-) create mode 100644 Editor/Core/Paths/PathAssembler.cs create mode 100644 Editor/Core/Paths/PathAssembler.cs.meta create mode 100644 Editor/Core/Paths/PathDiagnostics.cs create mode 100644 Editor/Core/Paths/PathDiagnostics.cs.meta create mode 100644 Editor/Core/Paths/PathResolutionScope.cs create mode 100644 Editor/Core/Paths/PathResolutionScope.cs.meta create mode 100644 Tests/Editor/PathReferenceCycleTests.cs create mode 100644 Tests/Editor/PathReferenceCycleTests.cs.meta 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/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..570398ae 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,7 +70,8 @@ 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() 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/PathReferenceAssetTests.cs b/Tests/Editor/PathReferenceAssetTests.cs index 93d24126..a0c2b67e 100644 --- a/Tests/Editor/PathReferenceAssetTests.cs +++ b/Tests/Editor/PathReferenceAssetTests.cs @@ -59,6 +59,33 @@ public void ResolvePath_DuplicateReferenceNames_ThrowsArgumentException() Throws.InstanceOf()); } + // 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}")) diff --git a/Tests/Editor/PathReferenceCombineTests.cs b/Tests/Editor/PathReferenceCombineTests.cs index 8ae38f68..d38153f8 100644 --- a/Tests/Editor/PathReferenceCombineTests.cs +++ b/Tests/Editor/PathReferenceCombineTests.cs @@ -117,58 +117,67 @@ public void GetPath_SameReferenceUsedTwiceAsSiblings_ResolvesBoth() Assert.That(reference.GetPath(null), Is.EqualTo(Path.Combine("Shared", "Shared"))); } - // Current behaviour: a rooted segment silently discards everything before it. + // A rooted segment would discard everything before it, so it is refused + // anywhere but the first position. [Test] - public void GetPath_RootedSegmentAfterFirst_DiscardsPrecedingSegments() + public void GetPath_RootedSegmentAfterFirst_Throws() { var reference = fixture.Reference( fixture.Literal("ThunderKit"), fixture.Literal(PathComponentFixture.RootedPath)); - Assert.That(reference.GetPath(null), Is.EqualTo(PathComponentFixture.RootedPath)); + var exception = Assert.Throws(() => reference.GetPath(null)); + Assert.That(exception.Message, Does.Contain("discards")); } - // Current behaviour: "C:" is what %SYSTEMDRIVE% and %HOMEDRIVE% expand to. - // The exact result differs between runtimes, but the preceding segments are - // dropped on all of them. + // "C:" is what %SYSTEMDRIVE% and %HOMEDRIVE% expand to. [Test] - public void GetPath_DriveRelativeSegment_DiscardsPrecedingSegments() + 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("BepInEx")); - var expected = reference.GetPath(null); + var reference = fixture.Reference(fixture.Literal("ThunderKit"), fixture.Literal("C:")); - var driveRelative = fixture.Reference(fixture.Literal("ThunderKit"), fixture.Literal("C:")); - var result = driveRelative.GetPath(null); + 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.That(result, Is.Not.EqualTo(expected)); - Assert.That(result, Does.Not.StartWith("ThunderKit")); + Assert.Throws(() => reference.GetPath(null)); } - // Current behaviour: ManifestName returns null with no manifest assigned, and - // Path.Combine rejects it without naming the component responsible. + // 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_ThrowsArgumentNullException() + public void GetPath_ComponentReturnsNull_ThrowsNamingTheComponent() { var pipeline = fixture.Create(); var reference = fixture.Reference( fixture.Create()); - Assert.Throws(() => reference.GetPath(pipeline)); + var exception = Assert.Throws(() => reference.GetPath(pipeline)); + Assert.That(exception.Message, Does.Contain("ManifestName")); + Assert.That(exception.Message, Does.Contain("assetlink://")); } - // Current behaviour: Data stays null until a component is added, and the - // failure surfaces from the OfType null check, naming neither the reference - // nor the asset it came from. + // Data stays null until a component is added, which is the state a newly + // created PathReference asset is in. [Test] - public void GetPath_UnassignedData_ThrowsArgumentNullException() + public void GetPath_UnassignedData_ReturnsEmpty() { var reference = fixture.Create(); Assert.That(reference.Data, Is.Null); - Assert.That(() => reference.GetPath(null), Throws.InstanceOf()); + Assert.That(reference.GetPath(null), Is.Empty); } } } 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: From a4c47a078a85addb43ebda6b826801b09cb243f1 Mon Sep 17 00:00:00 2001 From: PassivePicasso Date: Sat, 22 Aug 2026 15:28:53 -0400 Subject: [PATCH 6/9] Report ambiguous PathReference names Two assets sharing a name previously broke every path resolution in the project with a bare dictionary key collision; the failure now names the duplicate and the assets that declare it. --- Editor/Core/Paths/PathReference.cs | 23 +++++++++++++++++------ Tests/Editor/PathReferenceAssetTests.cs | 16 ++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/Editor/Core/Paths/PathReference.cs b/Editor/Core/Paths/PathReference.cs index 570398ae..92c39fbd 100644 --- a/Editor/Core/Paths/PathReference.cs +++ b/Editor/Core/Paths/PathReference.cs @@ -76,12 +76,23 @@ public string GetPath(Pipeline pipeline) 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 => diff --git a/Tests/Editor/PathReferenceAssetTests.cs b/Tests/Editor/PathReferenceAssetTests.cs index a0c2b67e..242fc8e3 100644 --- a/Tests/Editor/PathReferenceAssetTests.cs +++ b/Tests/Editor/PathReferenceAssetTests.cs @@ -46,17 +46,21 @@ public void ResolvePath_MultipleTokens_SubstitutesEach() Is.EqualTo("One/Two")); } - // Current behaviour: names are collected into a dictionary, so two assets - // sharing a name break every path resolution in the project with an - // exception that names neither the reference nor the assets involved. + // 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_ThrowsArgumentException() + public void ResolvePath_DuplicateReferenceNames_ReportsTheConflict() { CreateReference(FirstFolder, "__TK_Duplicate__", "First"); CreateReference(SecondFolder, "__TK_Duplicate__", "Second"); - Assert.That(() => PathReference.ResolvePath("<__TK_Duplicate__>", null, null), - Throws.InstanceOf()); + 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 From dbee29464e7ff1eb702f238807495eb02caa7807 Mon Sep 17 00:00:00 2001 From: PassivePicasso Date: Sat, 22 Aug 2026 15:30:22 -0400 Subject: [PATCH 7/9] Fix scaffolded PathComponent template ElementTemplate declared an override of PathComponent.GetPath, which is not virtual, so every generated component failed to compile; it now overrides GetPathInternal. --- Editor/Core/Paths/PathReference.cs | 4 ++-- Tests/Editor/PathReferenceTests.cs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Editor/Core/Paths/PathReference.cs b/Editor/Core/Paths/PathReference.cs index 92c39fbd..442748c8 100644 --- a/Editor/Core/Paths/PathReference.cs +++ b/Editor/Core/Paths/PathReference.cs @@ -103,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/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); + } + } } } From bbf3818064f88545549f07a018a2f1c5e476bcdc Mon Sep 17 00:00:00 2001 From: PassivePicasso Date: Sat, 22 Aug 2026 15:33:08 -0400 Subject: [PATCH 8/9] Document path resolution fixes and coverage in the changelog --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 729b0fd8..65a528f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,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 +61,17 @@ * 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 ## 9.4.3 From 797f45a2392904cac55db8a33cecd3153e6301d0 Mon Sep 17 00:00:00 2001 From: PassivePicasso Date: Sat, 22 Aug 2026 15:53:35 -0400 Subject: [PATCH 9/9] Add SpecialFolder and EnvironmentVariable path components Both resolve per-machine locations by enum or variable name rather than by parsing %VAR% or $VAR, so one asset resolves on Windows, Linux and macOS, and an unset variable is reported instead of becoming a literal path segment. --- CHANGELOG.md | 13 ++ .../Paths/Components/EnvironmentVariable.cs | 34 +++++ .../Components/EnvironmentVariable.cs.meta | 11 ++ Editor/Core/Paths/Components/SpecialFolder.cs | 33 +++++ .../Paths/Components/SpecialFolder.cs.meta | 11 ++ Tests/Editor/PathComponentEnvironmentTests.cs | 132 ++++++++++++++++++ .../PathComponentEnvironmentTests.cs.meta | 11 ++ 7 files changed, 245 insertions(+) create mode 100644 Editor/Core/Paths/Components/EnvironmentVariable.cs create mode 100644 Editor/Core/Paths/Components/EnvironmentVariable.cs.meta create mode 100644 Editor/Core/Paths/Components/SpecialFolder.cs create mode 100644 Editor/Core/Paths/Components/SpecialFolder.cs.meta create mode 100644 Tests/Editor/PathComponentEnvironmentTests.cs create mode 100644 Tests/Editor/PathComponentEnvironmentTests.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a528f4..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 @@ -72,6 +82,9 @@ * 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/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/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: