diff --git a/Assets/Tests/Editor/SkillInstallLayoutTests.cs b/Assets/Tests/Editor/SkillInstallLayoutTests.cs index 2cffb55439..ed22d6c08b 100644 --- a/Assets/Tests/Editor/SkillInstallLayoutTests.cs +++ b/Assets/Tests/Editor/SkillInstallLayoutTests.cs @@ -131,6 +131,44 @@ public void GetToolDescriptionsByToolName_WhenSkillHasDescription_MapsDescriptio Assert.That(descriptions[UnityCliLoopConstants.COMMAND_NAME_WAIT_FOR_PAUSE_POINT], Does.StartWith("Pauses Unity's playback")); } + // Tests that duplicate skill names use the earlier source root across each precedence boundary. + [Test] + public void GetSkillSourceInfos_WhenNamesOverlap_PrefersEarlierSourceRoot() + { + string temporaryRoot = CreateTemporaryProjectRoot(); + string manifestPackageRoot = CreateTemporaryProjectRoot(); + string assetsRoot = Path.Combine(temporaryRoot, "Assets"); + string directPackageRoot = Path.Combine(temporaryRoot, "Packages", "com.example.direct"); + string cachePackageRoot = Path.Combine( + temporaryRoot, + "Library", + "PackageCache", + "com.example.cached@1.0.0"); + + WriteSourceSkill(assetsRoot, "uloop-assets-priority", "assets-winner", "AssetsWinner"); + WriteSourceSkill(directPackageRoot, "uloop-assets-priority", "direct-loser", "DirectLoser"); + WriteSourceSkill(directPackageRoot, "uloop-package-priority", "direct-winner", "DirectWinner"); + WriteSourceSkill(manifestPackageRoot, "uloop-package-priority", "manifest-loser", "ManifestLoser"); + WriteSourceSkill(manifestPackageRoot, "uloop-manifest-priority", "manifest-winner", "ManifestWinner"); + WriteSourceSkill(cachePackageRoot, "uloop-manifest-priority", "cache-loser", "CacheLoser"); + + string manifestPath = Path.Combine(temporaryRoot, "Packages", "manifest.json"); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)); + string portableManifestPackageRoot = manifestPackageRoot.Replace(Path.DirectorySeparatorChar, '/'); + File.WriteAllText( + manifestPath, + $"{{\"dependencies\":{{\"com.example.manifest\":\"file:{portableManifestPackageRoot}\"," + + "\"com.example.cached\":\"1.0.0\"}}}"); + + Dictionary skillSources = SkillInstallLayout + .GetSkillSourceInfos(temporaryRoot) + .ToDictionary(skill => skill.Name, StringComparer.Ordinal); + + Assert.That(skillSources["uloop-assets-priority"].ToolName, Is.EqualTo("assets-winner")); + Assert.That(skillSources["uloop-package-priority"].ToolName, Is.EqualTo("direct-winner")); + Assert.That(skillSources["uloop-manifest-priority"].ToolName, Is.EqualTo("manifest-winner")); + } + // Tests that skill discovery follows bundled tools after they move into the first-party plugin assembly. [Test] public void GetSkillSourceInfos_WhenFirstPartyToolIsUnderFirstPartyTools_IncludesToolSkill() @@ -347,6 +385,19 @@ private static void CreateFakeSourceSkill( File.WriteAllText(Path.Combine(skillDir, additionalFileRelativePath), additionalFileContent); } + private static void WriteSourceSkill( + string sourceRoot, + string skillName, + string toolName, + string toolDirectoryName) + { + string skillDirectory = Path.Combine(sourceRoot, "Editor", toolDirectoryName, "Skill"); + Directory.CreateDirectory(skillDirectory); + File.WriteAllText( + Path.Combine(skillDirectory, SkillInstallLayout.SkillFileName), + $"---\nname: {skillName}\ntoolName: {toolName}\n---\n"); + } + /// /// Test support type used by editor fixtures. /// diff --git a/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs index 4c73d96b89..c9d1bd4240 100644 --- a/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs +++ b/Packages/src/Editor/Infrastructure/SkillSetup/SkillSourceRootEnumerator.cs @@ -167,39 +167,49 @@ private static IEnumerable EnumerateSourceSkillFiles(string searchRoot) private static IEnumerable EnumerateSkillSourceRoots(string projectRoot) { + List orderedRoots = new(); HashSet seenRoots = new(StringComparer.Ordinal); - AddSkillSourceRoot(seenRoots, GetCliOnlySkillSourceRoot(projectRoot)); - AddSkillSourceRoot(seenRoots, Path.Combine(projectRoot, "Assets")); + AddSkillSourceRoot(orderedRoots, seenRoots, GetCliOnlySkillSourceRoot(projectRoot)); + AddSkillSourceRoot(orderedRoots, seenRoots, Path.Combine(projectRoot, "Assets")); foreach (string packageRoot in EnumerateDirectProjectPackageRoots(projectRoot)) { - AddSkillSourceRoot(seenRoots, packageRoot); + AddSkillSourceRoot(orderedRoots, seenRoots, packageRoot); } foreach (string packageRoot in EnumerateManifestLocalPackageRoots(projectRoot)) { - AddSkillSourceRoot(seenRoots, packageRoot); + AddSkillSourceRoot(orderedRoots, seenRoots, packageRoot); } foreach (string packageRoot in EnumerateDependencyPackageCacheRoots(projectRoot)) { - AddSkillSourceRoot(seenRoots, packageRoot); + AddSkillSourceRoot(orderedRoots, seenRoots, packageRoot); } - foreach (string root in seenRoots) + foreach (string root in orderedRoots) { yield return root; } } - private static void AddSkillSourceRoot(HashSet roots, string root) + private static void AddSkillSourceRoot( + List orderedRoots, + HashSet seenRoots, + string root) { if (string.IsNullOrEmpty(root)) { return; } - roots.Add(Path.GetFullPath(root)); + string normalizedRoot = Path.GetFullPath(root); + if (!seenRoots.Add(normalizedRoot)) + { + return; + } + + orderedRoots.Add(normalizedRoot); } private static string GetCliOnlySkillSourceRoot(string projectRoot)