From b0ee7239820f3fe5a223c40e327c1a7091653b32 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 24 Jul 2026 20:55:52 +0900 Subject: [PATCH 1/4] Add shared SkillsSetupPanel view, UXML, USS, and unit tests Extract the common skills status panel helpers and UI assets so Settings and Setup Wizard can share one CloneTree-based panel instead of divergent implementations. Co-authored-by: Cursor --- .../Tests/Editor/SkillsSetupPanelViewTests.cs | 267 +++++++++++ .../Editor/SkillsSetupPanelViewTests.cs.meta | 11 + Packages/src/Editor/Presentation/Shared.meta | 8 + .../Presentation/Shared/SkillsSetupPanel.uss | 105 +++++ .../Shared/SkillsSetupPanel.uss.meta | 11 + .../Presentation/Shared/SkillsSetupPanel.uxml | 21 + .../Shared/SkillsSetupPanel.uxml.meta | 10 + .../Shared/SkillsSetupPanelView.cs | 434 ++++++++++++++++++ .../Shared/SkillsSetupPanelView.cs.meta | 11 + 9 files changed, 878 insertions(+) create mode 100644 Assets/Tests/Editor/SkillsSetupPanelViewTests.cs create mode 100644 Assets/Tests/Editor/SkillsSetupPanelViewTests.cs.meta create mode 100644 Packages/src/Editor/Presentation/Shared.meta create mode 100644 Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uss create mode 100644 Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uss.meta create mode 100644 Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uxml create mode 100644 Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uxml.meta create mode 100644 Packages/src/Editor/Presentation/Shared/SkillsSetupPanelView.cs create mode 100644 Packages/src/Editor/Presentation/Shared/SkillsSetupPanelView.cs.meta diff --git a/Assets/Tests/Editor/SkillsSetupPanelViewTests.cs b/Assets/Tests/Editor/SkillsSetupPanelViewTests.cs new file mode 100644 index 000000000..7b79484b9 --- /dev/null +++ b/Assets/Tests/Editor/SkillsSetupPanelViewTests.cs @@ -0,0 +1,267 @@ +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Presentation; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies shared SkillsSetupPanelView pure helpers. + /// + public class SkillsSetupPanelViewTests + { + [Test] + public void FilterInstallableSkillTargets_ExcludesTargetsWithoutSkillsDirectory() + { + // Verifies that only targets with a skills directory remain installable. + List targets = new() + { + new("Claude Code", ".claude", "--claude", true, true), + new("Cursor", ".cursor", "--cursor", false, false), + new("Codex CLI", ".codex", "--codex", true, false, hasDifferentLayoutSkills: true) + }; + + List installableTargets = + SkillsSetupPanelView.FilterInstallableSkillTargets(targets); + + Assert.That(installableTargets.Count, Is.EqualTo(2)); + Assert.That(installableTargets[0].DirName, Is.EqualTo(".claude")); + Assert.That(installableTargets[1].DirName, Is.EqualTo(".codex")); + } + + [Test] + public void CreateFirstInstallSkillTarget_WhenClaudeSelected_ReturnsClaudeProjectTarget() + { + // Verifies Claude maps to the project-level first-install target metadata. + SkillSetupTargetInfo target = + SkillsSetupPanelView.CreateFirstInstallSkillTarget(SkillsTarget.Claude, true); + + Assert.That(target.DisplayName, Is.EqualTo("Claude Code")); + Assert.That(target.DirName, Is.EqualTo(".claude")); + Assert.That(target.InstallFlag, Is.EqualTo("--claude")); + Assert.That(target.HasSkillsDirectory, Is.False); + Assert.That(target.HasExistingSkills, Is.False); + } + + [TestCase(SkillsTarget.Cursor, "Cursor", ".cursor", "--cursor")] + [TestCase(SkillsTarget.Gemini, "Gemini CLI", ".gemini", "--gemini")] + [TestCase(SkillsTarget.Codex, "Codex CLI", ".codex", "--codex")] + [TestCase(SkillsTarget.Agents, "Other (.agents)", ".agents", "--agents")] + public void CreateFirstInstallSkillTarget_ReturnsMappedTarget( + SkillsTarget targetType, + string expectedDisplayName, + string expectedDirName, + string expectedInstallFlag) + { + // Verifies each SkillsTarget maps to the expected first-install metadata. + SkillSetupTargetInfo target = + SkillsSetupPanelView.CreateFirstInstallSkillTarget(targetType, true); + + Assert.That(target.DisplayName, Is.EqualTo(expectedDisplayName)); + Assert.That(target.DirName, Is.EqualTo(expectedDirName)); + Assert.That(target.InstallFlag, Is.EqualTo(expectedInstallFlag)); + Assert.That(target.HasSkillsDirectory, Is.False); + Assert.That(target.HasExistingSkills, Is.False); + } + + [Test] + public void CreateFirstInstallSkillTarget_WhenGroupingDisabled_KeepsTargetMetadata() + { + // Verifies grouping-off does not change target display/dir/flag metadata. + SkillSetupTargetInfo target = + SkillsSetupPanelView.CreateFirstInstallSkillTarget(SkillsTarget.Claude, false); + + Assert.That(target.DisplayName, Is.EqualTo("Claude Code")); + Assert.That(target.DirName, Is.EqualTo(".claude")); + Assert.That(target.InstallFlag, Is.EqualTo("--claude")); + } + + [Test] + public void GetSelectedSkillTargetInfo_WhenDetectedTargetExists_ReturnsDetectedState() + { + // Verifies a detected target wins over a synthetic first-install fallback. + List targets = new() + { + new( + "Claude Code", + ".claude", + "--claude", + hasSkillsDirectory: true, + hasExistingSkills: true, + installState: SkillInstallState.Installed) + }; + + SkillSetupTargetInfo target = SkillsSetupPanelView.GetSelectedSkillTargetInfo( + targets, + SkillsTarget.Claude, + groupSkillsUnderUnityCliLoop: true); + + Assert.That(target.DirName, Is.EqualTo(".claude")); + Assert.That(target.InstallState, Is.EqualTo(SkillInstallState.Installed)); + } + + [Test] + public void BuildSingleTargetInstallList_WhenSelectedTargetIsInstalled_ReturnsEmpty() + { + // Verifies an already-installed selected target yields an empty install list. + List targets = new() + { + new( + "Claude Code", + ".claude", + "--claude", + hasSkillsDirectory: true, + hasExistingSkills: true, + installState: SkillInstallState.Installed) + }; + + List installableTargets = + SkillsSetupPanelView.BuildSingleTargetInstallList( + targets, + SkillsTarget.Claude, + groupSkillsUnderUnityCliLoop: true); + + Assert.That(installableTargets, Is.Empty); + } + + [Test] + public void BuildSingleTargetInstallList_WhenSelectedTargetIsMissing_ReturnsMappedTarget() + { + // Verifies a missing selected target is mapped into a one-item install list. + List installableTargets = + SkillsSetupPanelView.BuildSingleTargetInstallList( + new List(), + SkillsTarget.Claude, + groupSkillsUnderUnityCliLoop: true); + + Assert.That(installableTargets.Count, Is.EqualTo(1)); + Assert.That(installableTargets[0].DirName, Is.EqualTo(".claude")); + Assert.That(installableTargets[0].InstallState, Is.EqualTo(SkillInstallState.Missing)); + } + + [TestCase(SkillInstallState.Installed, false, true, "Installed")] + [TestCase(SkillInstallState.Checking, false, true, "Checking...")] + [TestCase(SkillInstallState.Outdated, false, true, "Outdated")] + [TestCase(SkillInstallState.Missing, false, true, "Missing")] + [TestCase(SkillInstallState.Missing, true, true, "Not grouped")] + [TestCase(SkillInstallState.Missing, true, false, "Grouped")] + public void GetSkillInstallStatusText_ReturnsExpectedLabel( + SkillInstallState installState, + bool hasDifferentLayoutSkills, + bool groupSkillsUnderUnityCliLoop, + string expectedLabel) + { + // Verifies each install state maps to the status label shown in the panel list. + string label = SkillsSetupPanelView.GetSkillInstallStatusText( + installState, + hasDifferentLayoutSkills, + groupSkillsUnderUnityCliLoop); + + Assert.That(label, Is.EqualTo(expectedLabel)); + } + + [TestCase(SkillInstallState.Installed, false, "skill-target-item__status--installed")] + [TestCase(SkillInstallState.Checking, false, "skill-target-item__status--checking")] + [TestCase(SkillInstallState.Outdated, false, "skill-target-item__status--outdated")] + [TestCase(SkillInstallState.Missing, false, "skill-target-item__status--missing")] + [TestCase(SkillInstallState.Missing, true, "skill-target-item__status--different-layout")] + public void GetSkillInstallStatusClass_ReturnsExpectedClass( + SkillInstallState installState, + bool hasDifferentLayoutSkills, + string expectedClass) + { + // Verifies each skill install state maps to the shared panel status style class. + string className = SkillsSetupPanelView.GetSkillInstallStatusClass( + installState, + hasDifferentLayoutSkills); + + Assert.That(className, Is.EqualTo(expectedClass)); + } + + [TestCase(false, false, SkillInstallState.Missing, "Install Skills")] + [TestCase(true, true, SkillInstallState.Missing, "Installing...")] + [TestCase(true, false, SkillInstallState.Checking, "Checking...")] + [TestCase(true, false, SkillInstallState.Outdated, "Update Skills")] + [TestCase(true, false, SkillInstallState.Missing, "Install Skills")] + [TestCase(true, false, SkillInstallState.Installed, "Installed")] + public void GetInstallSkillsButtonText_ReturnsExpectedText( + bool isCliInstalled, + bool isInstallingSkills, + SkillInstallState installState, + string expectedText) + { + // Verifies the single-target Install button text follows CLI and install state. + string text = SkillsSetupPanelView.GetInstallSkillsButtonText( + isCliInstalled, + isInstallingSkills, + installState); + + Assert.That(text, Is.EqualTo(expectedText)); + } + + [TestCase(false, false, SkillInstallState.Missing, false)] + [TestCase(true, true, SkillInstallState.Missing, false)] + [TestCase(true, false, SkillInstallState.Checking, false)] + [TestCase(true, false, SkillInstallState.Installed, false)] + [TestCase(true, false, SkillInstallState.Outdated, true)] + [TestCase(true, false, SkillInstallState.Missing, true)] + public void IsInstallSkillsButtonEnabled_ReturnsExpectedValue( + bool isCliInstalled, + bool isInstallingSkills, + SkillInstallState installState, + bool expectedEnabled) + { + // Verifies the single-target Install button enablement follows only Skills state. + bool enabled = SkillsSetupPanelView.IsInstallSkillsButtonEnabled( + isCliInstalled, + isInstallingSkills, + installState); + + Assert.That(enabled, Is.EqualTo(expectedEnabled)); + } + + [TestCase(false, false, false, "Install Skills")] + [TestCase(true, true, false, "Installing...")] + [TestCase(true, false, true, "Update Skills")] + [TestCase(true, false, false, "Install Skills")] + public void GetBulkInstallButtonText_ReturnsExpectedLabel( + bool canManageSkills, + bool isInstallingSkills, + bool hasOutdatedSkills, + string expectedLabel) + { + // Verifies the bulk Install button text for manageability, install, and outdated states. + string label = SkillsSetupPanelView.GetBulkInstallButtonText( + canManageSkills, + isInstallingSkills, + hasOutdatedSkills); + + Assert.That(label, Is.EqualTo(expectedLabel)); + } + + [Test] + public void BuildInstalledSummaryText_ReturnsInstalledForNTargetsFormat() + { + // Verifies the status summary uses the shared "Installed for N targets" format. + string summary = SkillsSetupPanelView.BuildInstalledSummaryText(2); + + Assert.That(summary, Is.EqualTo("Installed for 2 targets")); + } + + [TestCase(0, true)] + [TestCase(1, false)] + [TestCase(3, false)] + public void ShouldExpandSpecificTargetFoldout_ReturnsExpectedValue( + int installableTargetCount, + bool expected) + { + // Verifies the specific-target foldout auto-expands only when no installable targets exist. + bool shouldExpand = SkillsSetupPanelView.ShouldExpandSpecificTargetFoldout( + installableTargetCount); + + Assert.That(shouldExpand, Is.EqualTo(expected)); + } + } +} diff --git a/Assets/Tests/Editor/SkillsSetupPanelViewTests.cs.meta b/Assets/Tests/Editor/SkillsSetupPanelViewTests.cs.meta new file mode 100644 index 000000000..5200fd0a8 --- /dev/null +++ b/Assets/Tests/Editor/SkillsSetupPanelViewTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 52e7a0e2d6aef4a949140eda5bc63834 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Presentation/Shared.meta b/Packages/src/Editor/Presentation/Shared.meta new file mode 100644 index 000000000..46f5358f7 --- /dev/null +++ b/Packages/src/Editor/Presentation/Shared.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ed175bc69ee324bb3891b485915f02b0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uss b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uss new file mode 100644 index 000000000..7dbbb3c03 --- /dev/null +++ b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uss @@ -0,0 +1,105 @@ +/* Shared Skills setup panel — self-contained colors (no Setup Wizard CSS variables). */ + +.skills-setup-panel { + flex-grow: 1; +} + +.skill-target-status-list { + margin-bottom: 6px; +} + +.skill-target-item { + flex-direction: row; + align-items: center; + justify-content: space-between; + margin-bottom: 2px; + margin-left: 4px; +} + +.skill-target-item__label { + flex-grow: 1; + margin-right: 8px; +} + +.skill-target-item__status { + flex-shrink: 0; + -unity-font-style: bold; +} + +.skill-target-item__status--installed { + color: #4caf50; +} + +.skill-target-item__status--checking { + color: #999999; +} + +.skill-target-item__status--outdated { + color: #ffd200; +} + +.skill-target-item__status--missing { + color: #999999; +} + +.skill-target-item__status--different-layout { + color: #ffd200; +} + +.skill-target-status-divider { + border-top-width: 1px; + border-top-color: rgba(255, 255, 255, 0.12); + margin-top: 6px; + margin-bottom: 12px; +} + +.skill-target-status-summary { + margin-bottom: 4px; +} + +.skills-setup-panel__install-button { + margin-top: 4px; + margin-bottom: 4px; +} + +.skills-setup-panel__foldout { + margin-left: -8px; + margin-top: 4px; +} + +.skills-setup-panel__toggle-row { + flex-direction: row; + align-items: center; + margin-bottom: 6px; +} + +.skills-setup-panel__target-row { + flex-direction: row; + align-items: center; + overflow: hidden; + margin-bottom: 4px; +} + +.skills-setup-panel__target-label { + width: 50px; + flex-shrink: 0; + margin-right: 6px; +} + +.skills-setup-panel__target-field { + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + overflow: hidden; +} + +.skills-setup-panel__refresh-button { + width: 20px; + height: 20px; + min-width: 20px; + margin: 0 3px 0 4px; + padding: 0; + font-size: 14px; + -unity-text-align: middle-center; + border-radius: 2px; +} diff --git a/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uss.meta b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uss.meta new file mode 100644 index 000000000..27081e174 --- /dev/null +++ b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uss.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8bd909dd7cc642bba9fc7cfae2c813b +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 diff --git a/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uxml b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uxml new file mode 100644 index 000000000..0e25c8ebe --- /dev/null +++ b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uxml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uxml.meta b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uxml.meta new file mode 100644 index 000000000..13d48ca94 --- /dev/null +++ b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanel.uxml.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 27d19594dc2d24a7087434b65708b023 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0} diff --git a/Packages/src/Editor/Presentation/Shared/SkillsSetupPanelView.cs b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanelView.cs new file mode 100644 index 000000000..40fd72ddc --- /dev/null +++ b/Packages/src/Editor/Presentation/Shared/SkillsSetupPanelView.cs @@ -0,0 +1,434 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Presentation +{ + /// + /// Shared skills setup panel view used by Settings and Setup Wizard. + /// + internal sealed class SkillsSetupPanelView + { + private readonly VisualElement _skillTargetStatusList; + private readonly VisualElement _skillTargetStatusDivider; + private readonly Label _skillTargetStatusSummary; + private readonly Button _installAllSkillsButton; + private readonly Foldout _installSpecificTargetFoldout; + private readonly VisualElement _groupSkillsRow; + private readonly Toggle _groupSkillsToggle; + private readonly Label _groupSkillsLabel; + private readonly EnumField _skillsTargetField; + private readonly Button _refreshSkillsStateButton; + private readonly Button _installSelectedSkillsButton; + + private bool _isTargetFieldInitialized; + + internal event System.Action OnInstallAllClicked; + internal event System.Action OnInstallSelectedClicked; + internal event System.Action OnRefreshClicked; + internal event System.Action OnTargetChanged; + internal event System.Action OnGroupSkillsChanged; + + internal Toggle GroupSkillsToggle => _groupSkillsToggle; + internal Label GroupSkillsLabel => _groupSkillsLabel; + internal VisualElement GroupSkillsRow => _groupSkillsRow; + + internal SkillsSetupPanelView(VisualElement panelRoot) + { + Debug.Assert(panelRoot != null, "panelRoot must not be null"); + VisualElement root = panelRoot ?? throw new System.ArgumentNullException(nameof(panelRoot)); + + _skillTargetStatusList = root.Q("skill-target-status-list"); + _skillTargetStatusDivider = root.Q("skill-target-status-divider"); + _skillTargetStatusSummary = root.Q