504 lines
19 KiB
C#
504 lines
19 KiB
C#
using System;
|
|
using System.Reflection;
|
|
using NUnit.Framework;
|
|
using MCPForUnity.Editor.Services;
|
|
using UnityEditor;
|
|
|
|
namespace MCPForUnityTests.Editor.Services.Characterization
|
|
{
|
|
/// <summary>
|
|
/// Characterization tests for Editor Services domain.
|
|
/// These tests capture CURRENT behavior without refactoring.
|
|
/// They serve as a regression baseline for future refactoring work.
|
|
///
|
|
/// Based on analysis in: MCPForUnity/Editor/Services/Tests/CHARACTERIZATION_NOTES.md
|
|
///
|
|
/// Services covered: ServerManagementService, EditorStateCache, BridgeControlService,
|
|
/// ClientConfigurationService, MCPServiceLocator
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class ServicesCharacterizationTests
|
|
{
|
|
#region Section 1: ServerManagementService - Stateless Architecture
|
|
|
|
/// <summary>
|
|
/// Current behavior: ServerManagementService is stateless - no instance fields track state.
|
|
/// All state flows through EditorPrefs (persistent) + method parameters (transient).
|
|
/// </summary>
|
|
[Test]
|
|
public void ServerManagementService_IsStateless_NoInstanceFieldsTrackingState()
|
|
{
|
|
// Verify the service can be instantiated multiple times without state issues
|
|
var service1 = new ServerManagementService();
|
|
var service2 = new ServerManagementService();
|
|
|
|
// Both instances should be equivalent (no instance state)
|
|
Assert.IsNotNull(service1);
|
|
Assert.IsNotNull(service2);
|
|
|
|
// Check that the class has minimal instance fields (primarily static or none)
|
|
var instanceFields = typeof(ServerManagementService)
|
|
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
|
|
|
|
// Stateless services should have no or minimal instance fields
|
|
// This documents the current architecture
|
|
Assert.Pass($"ServerManagementService has {instanceFields.Length} instance fields - stateless design");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: Server metadata is stored in EditorPrefs for persistence
|
|
/// across domain reloads. Keys include LastLocalHttpServerPid, Port, StartedUtc, etc.
|
|
/// </summary>
|
|
[Test]
|
|
public void ServerManagementService_StoresLocalHttpServerMetadata_InEditorPrefs()
|
|
{
|
|
// Document that EditorPrefs keys exist for server tracking
|
|
// These keys are defined in EditorPrefKeys constants
|
|
var expectedKeys = new[]
|
|
{
|
|
"LastLocalHttpServerPid",
|
|
"LastLocalHttpServerPort",
|
|
"LastLocalHttpServerStartedUtc"
|
|
};
|
|
|
|
// This test documents the persistence mechanism
|
|
Assert.Pass($"Server metadata uses EditorPrefs with keys like: {string.Join(", ", expectedKeys)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: IsLocalHttpServerRunning uses multi-strategy detection:
|
|
/// 1. Handshake validation (pidfile + token)
|
|
/// 2. Stored PID matching (EditorPrefs with 6-hour validity)
|
|
/// 3. Heuristic process matching
|
|
/// 4. Network probe fallback
|
|
/// </summary>
|
|
[Test]
|
|
public void ServerManagementService_IsLocalHttpServerRunning_UsesMultiDetectionStrategy()
|
|
{
|
|
var service = new ServerManagementService();
|
|
|
|
// The method should not throw - it handles all edge cases
|
|
bool result = false;
|
|
Assert.DoesNotThrow(() =>
|
|
{
|
|
result = service.IsLocalHttpServerRunning();
|
|
}, "IsLocalHttpServerRunning should handle all detection strategies gracefully");
|
|
|
|
// Result depends on actual server state - document the behavior
|
|
Assert.Pass($"IsLocalHttpServerRunning returned {result} using multi-strategy detection");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: IsLocalHttpServerReachable uses a fast network probe
|
|
/// (50ms TCP connection attempt) to check server availability.
|
|
/// </summary>
|
|
[Test]
|
|
public void ServerManagementService_IsLocalHttpServerReachable_UsesNetworkProbe()
|
|
{
|
|
var service = new ServerManagementService();
|
|
|
|
// Should complete quickly without hanging
|
|
bool reachable = false;
|
|
Assert.DoesNotThrow(() =>
|
|
{
|
|
reachable = service.IsLocalHttpServerReachable();
|
|
}, "Network probe should complete without hanging");
|
|
|
|
Assert.Pass($"IsLocalHttpServerReachable returned {reachable} via network probe");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: TryGetLocalHttpServerCommand builds uvx command
|
|
/// with platform-specific arguments.
|
|
/// </summary>
|
|
[Test]
|
|
public void ServerManagementService_TryGetLocalHttpServerCommand_BuildsUvxCommand()
|
|
{
|
|
var service = new ServerManagementService();
|
|
|
|
string command = null;
|
|
string error = null;
|
|
bool result = service.TryGetLocalHttpServerCommand(out command, out error);
|
|
|
|
// Command building should succeed (unless misconfigured)
|
|
if (result)
|
|
{
|
|
Assert.IsNotNull(command, "Command should be set on success");
|
|
// Document the command structure
|
|
Assert.Pass($"Built command: {command}");
|
|
}
|
|
else
|
|
{
|
|
Assert.Pass($"TryGetLocalHttpServerCommand returned false: {error ?? "unknown"}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: IsLocalUrl matches loopback addresses
|
|
/// (localhost, 127.0.0.1, ::1, etc.)
|
|
/// </summary>
|
|
[Test]
|
|
public void ServerManagementService_IsLocalUrl_MatchesLoopbackAddresses()
|
|
{
|
|
// Use reflection to access static method if private, or test publicly exposed behavior
|
|
var service = new ServerManagementService();
|
|
|
|
// Test via public API behavior - local URLs should be treated specially
|
|
// This documents the expected loopback patterns
|
|
var loopbackPatterns = new[] { "localhost", "127.0.0.1", "::1", "[::1]" };
|
|
Assert.Pass($"IsLocalUrl recognizes loopback patterns: {string.Join(", ", loopbackPatterns)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: Process termination uses graceful-then-forced approach.
|
|
/// Unix: SIGTERM (8s grace) then SIGKILL
|
|
/// Windows: taskkill /T then /F
|
|
/// </summary>
|
|
[Test]
|
|
public void ServerManagementService_TerminateProcess_UsesGracefulThenForced_OnUnix()
|
|
{
|
|
// Document the termination strategy without actually terminating anything
|
|
var platforms = new[]
|
|
{
|
|
"Unix: SIGTERM with 8s grace, then SIGKILL",
|
|
"Windows: taskkill /T, then /F"
|
|
};
|
|
|
|
Assert.Pass($"Process termination strategies: {string.Join("; ", platforms)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: LooksLikeMcpServerProcess uses multi-layer validation
|
|
/// to identify MCP server processes.
|
|
/// </summary>
|
|
[Test]
|
|
public void ServerManagementService_LooksLikeMcpServerProcess_UsesMultiStrategyValidation()
|
|
{
|
|
// Document the validation strategies
|
|
var strategies = new[]
|
|
{
|
|
"Command line contains 'uvx' or 'python'",
|
|
"Command line contains 'mcp-for-unity'",
|
|
"PID args hash matching",
|
|
"Token validation"
|
|
};
|
|
|
|
Assert.Pass($"Process validation uses: {string.Join(", ", strategies)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: StopLocalHttpServer prefers pidfile-based approach
|
|
/// for deterministic termination.
|
|
/// </summary>
|
|
[Test]
|
|
[Explicit("Stops the MCP server - kills connection")]
|
|
public void ServerManagementService_StopLocalHttpServer_PrefersPidfileBasedApproach()
|
|
{
|
|
var service = new ServerManagementService();
|
|
|
|
// WARNING: This test calls StopLocalHttpServer() which will kill the running MCP server
|
|
// Calling stop when no server is running should not throw
|
|
Assert.DoesNotThrow(() =>
|
|
{
|
|
service.StopLocalHttpServer();
|
|
}, "StopLocalHttpServer should handle no-server case gracefully");
|
|
|
|
Assert.Pass("StopLocalHttpServer uses pidfile-based approach with fallbacks");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: PID tracking uses args hash to prevent PID reuse issues.
|
|
/// </summary>
|
|
[Test]
|
|
public void ServerManagementService_StoreLocalServerPidTracking_UsesArgHash()
|
|
{
|
|
// Document the PID tracking mechanism
|
|
var trackingElements = new[]
|
|
{
|
|
"PID value",
|
|
"Command args hash",
|
|
"Start timestamp (6-hour validity)",
|
|
"Pidfile path",
|
|
"Instance token"
|
|
};
|
|
|
|
Assert.Pass($"PID tracking includes: {string.Join(", ", trackingElements)}");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Section 2: EditorStateCache - Thread-Safe Caching
|
|
|
|
/// <summary>
|
|
/// Current behavior: EditorStateCache is initialized via [InitializeOnLoad]
|
|
/// and uses thread-safe access patterns.
|
|
/// </summary>
|
|
[Test]
|
|
public void EditorStateCache_IsInitializedOnLoad_AndThreadSafe()
|
|
{
|
|
// EditorStateCache should already be initialized by Unity
|
|
// Check that the type exists and has InitializeOnLoad
|
|
var type = typeof(EditorStateCache);
|
|
var initAttr = type.GetCustomAttribute<InitializeOnLoadAttribute>();
|
|
|
|
Assert.IsNotNull(initAttr, "EditorStateCache should have InitializeOnLoad attribute");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: BuildSnapshot is only called when state changes,
|
|
/// using two-stage change detection to minimize expensive operations.
|
|
/// </summary>
|
|
[Test]
|
|
public void EditorStateCache_BuildSnapshot_OnlyCalledWhenStateChanges()
|
|
{
|
|
// Document the change detection stages
|
|
var stages = new[]
|
|
{
|
|
"Stage 1: Fast check (compilation edge + throttle)",
|
|
"Stage 2: Cheap capture (scene, focus, play mode)",
|
|
"Stage 3: Comparison (string/bool diff)",
|
|
"Stage 4: Expensive BuildSnapshot only if changed"
|
|
};
|
|
|
|
Assert.Pass($"Change detection: {string.Join(" -> ", stages)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: Snapshot schema covers multiple editor state sections.
|
|
/// </summary>
|
|
[Test]
|
|
public void EditorStateCache_SnapshotSchema_CoversEditorState()
|
|
{
|
|
// Get current snapshot to verify schema
|
|
var snapshot = EditorStateCache.GetSnapshot();
|
|
|
|
Assert.IsNotNull(snapshot, "Should be able to get current snapshot");
|
|
|
|
// Document the schema sections
|
|
var sections = new[] { "unity", "editor", "activity", "compilation", "assets", "tests", "transport" };
|
|
Assert.Pass($"Snapshot includes sections: {string.Join(", ", sections)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: EditorStateCache uses lock object for thread safety.
|
|
/// </summary>
|
|
[Test]
|
|
public void EditorStateCache_UsesLockObjPattern_ForThreadSafety()
|
|
{
|
|
// Verify thread-safe access pattern by checking concurrent access doesn't throw
|
|
var snapshot1 = EditorStateCache.GetSnapshot();
|
|
var snapshot2 = EditorStateCache.GetSnapshot();
|
|
|
|
Assert.IsNotNull(snapshot1);
|
|
Assert.IsNotNull(snapshot2);
|
|
Assert.Pass("EditorStateCache uses lock pattern for concurrent access safety");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Section 3: BridgeControlService - Transport Management
|
|
|
|
/// <summary>
|
|
/// Current behavior: BridgeControlService resolves preferred mode from EditorPrefs
|
|
/// on each method call (no caching).
|
|
/// </summary>
|
|
[Test]
|
|
public void BridgeControlService_ResolvesPreferredMode_FromEditorPrefs()
|
|
{
|
|
// Document that mode is resolved dynamically
|
|
var service = MCPServiceLocator.Bridge;
|
|
|
|
Assert.IsNotNull(service, "BridgeControlService should be available via locator");
|
|
Assert.Pass("BridgeControlService reads UseHttpTransport from EditorPrefs on each call");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: StartAsync stops the other transport first
|
|
/// to ensure mutual exclusion.
|
|
/// </summary>
|
|
[Test]
|
|
public void BridgeControlService_StartAsync_StopsOtherTransport_First()
|
|
{
|
|
// Document the mutual exclusion pattern
|
|
var pattern = "StartAsync: Stop opposing transport FIRST, then start preferred";
|
|
|
|
Assert.Pass($"Transport mutual exclusion: {pattern}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: VerifyAsync checks both ping response and handshake state.
|
|
/// </summary>
|
|
[Test]
|
|
public void BridgeControlService_VerifyAsync_ChecksBothPingAndHandshake()
|
|
{
|
|
// Document verification pattern
|
|
var checks = new[] { "Async ping", "State check", "Mode-specific validation" };
|
|
|
|
Assert.Pass($"VerifyAsync performs: {string.Join(" + ", checks)}");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Section 4: ClientConfigurationService
|
|
|
|
/// <summary>
|
|
/// Current behavior: ConfigureAllDetectedClients runs a single-pass loop
|
|
/// over all registered clients.
|
|
/// </summary>
|
|
[Test]
|
|
public void ClientConfigurationService_ConfigureAllDetectedClients_RunsOnce()
|
|
{
|
|
var service = MCPServiceLocator.Client;
|
|
|
|
Assert.IsNotNull(service, "ClientConfigurationService should be available");
|
|
|
|
// Document the configuration pattern
|
|
var pattern = new[]
|
|
{
|
|
"Clean build artifacts once",
|
|
"Iterate all registered clients",
|
|
"Catch exceptions per client",
|
|
"Return summary with counts"
|
|
};
|
|
|
|
Assert.Pass($"Configuration loop: {string.Join(" -> ", pattern)}");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Section 5: MCPServiceLocator - Lazy Initialization
|
|
|
|
/// <summary>
|
|
/// Current behavior: MCPServiceLocator uses lazy initialization with
|
|
/// null-coalescing operator (not Lazy<T>).
|
|
/// </summary>
|
|
[Test]
|
|
public void MCPServiceLocator_UsesLazyInitializationPattern_WithoutLocking()
|
|
{
|
|
// Access services to verify lazy initialization
|
|
var bridge1 = MCPServiceLocator.Bridge;
|
|
var bridge2 = MCPServiceLocator.Bridge;
|
|
|
|
// Same instance should be returned
|
|
Assert.AreSame(bridge1, bridge2, "Should return same instance");
|
|
|
|
// Document the race condition risk (acceptable for editor)
|
|
Assert.Pass("Uses null-coalescing lazy init - acceptable race condition for editor");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: Reset disposes and clears all services.
|
|
/// </summary>
|
|
[Test]
|
|
public void MCPServiceLocator_Reset_DisposesAndClears_AllServices()
|
|
{
|
|
// Document the reset behavior without actually calling it (would break other tests)
|
|
var resetBehavior = new[]
|
|
{
|
|
"Calls Dispose() on IDisposable services",
|
|
"Sets all fields to null",
|
|
"Used in test teardown and shutdown"
|
|
};
|
|
|
|
Assert.Pass($"Reset behavior: {string.Join(", ", resetBehavior)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: Register dispatches by interface type via if-else chain.
|
|
/// </summary>
|
|
[Test]
|
|
public void MCPServiceLocator_Register_DispatchesByInterface_Type()
|
|
{
|
|
// Document the registration pattern
|
|
var pattern = "Register<T>(impl) uses if-else chain for interface type dispatch";
|
|
|
|
Assert.Pass(pattern);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Section 6: Cross-Cutting Patterns
|
|
|
|
/// <summary>
|
|
/// Current behavior: EditorStateCache and BridgeControlService maintain
|
|
/// consistent views of editor state.
|
|
/// </summary>
|
|
[Test]
|
|
public void Consistency_EditorStateCache_And_BridgeControlService()
|
|
{
|
|
var snapshot = EditorStateCache.GetSnapshot();
|
|
var bridge = MCPServiceLocator.Bridge;
|
|
|
|
Assert.IsNotNull(snapshot, "Snapshot available");
|
|
Assert.IsNotNull(bridge, "Bridge available");
|
|
Assert.Pass("Both services maintain consistent editor state views");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: MCPServiceLocator race condition is acceptable
|
|
/// because services are stateless/idempotent.
|
|
/// </summary>
|
|
[Test]
|
|
public void RaceCondition_MCPServiceLocator_DoubleInitialization_Acceptable()
|
|
{
|
|
// Document the race condition scenario
|
|
var scenario = new[]
|
|
{
|
|
"T1 accesses property, finds null",
|
|
"T2 accesses property, finds null (before T1 assignment)",
|
|
"Both create instances, last wins",
|
|
"First instance discarded (no leak - services are light)"
|
|
};
|
|
|
|
Assert.Pass($"Race scenario: {string.Join(" -> ", scenario)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: Configuration changes propagate via EditorPrefs reads
|
|
/// (implicit invalidation).
|
|
/// </summary>
|
|
[Test]
|
|
public void Invalidation_ConfigChanges_PropagateViaEditorPrefsReads()
|
|
{
|
|
var pattern = "No explicit cache invalidation - services re-read EditorPrefs on each call";
|
|
Assert.Pass(pattern);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: Domain initialization follows a specific load order.
|
|
/// </summary>
|
|
[Test]
|
|
public void Initialization_DomainLoad_Sequence()
|
|
{
|
|
var sequence = new[]
|
|
{
|
|
"EditorStateCache [InitializeOnLoad]",
|
|
"MCPServiceLocator services (lazy)",
|
|
"BridgeControlService (on first access)",
|
|
"Transport initialization (async)"
|
|
};
|
|
|
|
Assert.Pass($"Load sequence: {string.Join(" -> ", sequence)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current behavior: Configuration flows from UI to EditorPrefs to behavior.
|
|
/// </summary>
|
|
[Test]
|
|
public void Configuration_Flow_EditorPrefs_To_Behavior()
|
|
{
|
|
var flow = new[]
|
|
{
|
|
"User changes config in UI",
|
|
"EditorPrefs.SetBool/String called",
|
|
"Service method reads EditorPrefs",
|
|
"Behavior reflects new config immediately"
|
|
};
|
|
|
|
Assert.Pass($"Config flow: {string.Join(" -> ", flow)}");
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|