diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Console.GetLogs.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Console.GetLogs.cs index 680d5787..45ee8880 100644 --- a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Console.GetLogs.cs +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/Console.GetLogs.cs @@ -54,14 +54,14 @@ public string GetLogs { var cutoffTime = DateTime.Now.AddMinutes(-lastMinutes); allLogs = allLogs - .Where(log => log.timestamp >= cutoffTime); + .Where(log => log.Timestamp >= cutoffTime); } // Apply log type filter if (logTypeFilter.HasValue) { allLogs = allLogs - .Where(log => log.logType == logTypeFilter.Value); + .Where(log => log.LogType == logTypeFilter.Value); } // Take the most recent entries (up to maxEntries) @@ -92,4 +92,4 @@ public string GetLogs }); } } -} +} \ No newline at end of file diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/TestRunner/TestRunResponse.cs.meta b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/TestRunner/TestRunResponse.cs.meta index 87757abc..a2c31ca2 100644 --- a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/TestRunner/TestRunResponse.cs.meta +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/API/Tool/TestRunner/TestRunResponse.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: b73658fef863958479617144b3ac8b41 \ No newline at end of file +guid: b73658fef863958479617144b3ac8b41 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Startup.Editor.cs b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Startup.Editor.cs index a777d66c..9d19f845 100644 --- a/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Startup.Editor.cs +++ b/Unity-MCP-Plugin/Assets/root/Editor/Scripts/Startup.Editor.cs @@ -39,6 +39,7 @@ static void OnApplicationUnloading() { Debug.Log($"{nameof(Startup)} {nameof(OnApplicationUnloading)} triggered: No UnityMcpPlugin instance to disconnect."); } + LogUtils.SaveToFileImmediate(); } static void OnApplicationQuitting() { @@ -51,6 +52,7 @@ static void OnApplicationQuitting() { Debug.Log($"{nameof(Startup)} {nameof(OnApplicationQuitting)} triggered: No UnityMcpPlugin instance to disconnect."); } + _ = LogUtils.HandleQuit(); } static void OnBeforeAssemblyReload() { @@ -63,6 +65,7 @@ static void OnBeforeAssemblyReload() { Debug.Log($"{nameof(Startup)} {nameof(OnBeforeAssemblyReload)} triggered: No UnityMcpPlugin instance to disconnect."); } + LogUtils.SaveToFileImmediate(); } static void OnAfterAssemblyReload() { @@ -75,6 +78,8 @@ static void OnAfterAssemblyReload() if (connectionAllowed) UnityMcpPlugin.ConnectIfNeeded(); + + _ = LogUtils.LoadFromFile(); } static void OnPlayModeStateChanged(PlayModeStateChange state) diff --git a/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogCache.cs b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogCache.cs new file mode 100644 index 00000000..aef9fd9e --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogCache.cs @@ -0,0 +1,168 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Author: Ivan Murzak (https://github.com/IvanMurzak) │ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Copyright (c) 2025 Ivan Murzak │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using com.IvanMurzak.ReflectorNet.Utils; +using R3; +using UnityEngine; + +namespace com.IvanMurzak.Unity.MCP +{ + internal class LogCache : IDisposable + { + readonly string _cacheFilePath; + readonly string _cacheFileName; + readonly string _cacheFile; + readonly JsonSerializerOptions _jsonOptions; + readonly SemaphoreSlim _fileLock = new(1, 1); + readonly CancellationTokenSource _shutdownCts = new(); + bool _saving = false; + + IDisposable? timerSubscription; + + internal LogCache(string? cacheFilePath = null, string? cacheFileName = null, JsonSerializerOptions? jsonOptions = null) + { + if (!MainThread.Instance.IsMainThread) + throw new System.Exception($"{nameof(LogCache)} must be initialized on the main thread."); + + _cacheFilePath = cacheFilePath ?? (Application.isEditor + ? $"{Path.GetDirectoryName(Application.dataPath)}/Temp/mcp-server" + : $"{Application.persistentDataPath}/Temp/mcp-server"); + + _cacheFileName = cacheFileName ?? (Application.isEditor + ? "editor-logs.txt" + : "player-logs.txt"); + + _cacheFile = $"{Path.Combine(_cacheFilePath, _cacheFileName)}"; + _jsonOptions = jsonOptions ?? new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = false, + }; + + timerSubscription = Observable.Timer( + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(1) + ) + .Subscribe(x => + { + if (!_saving && !_shutdownCts.IsCancellationRequested) + Task.Run(HandleLogCache, _shutdownCts.Token); + }); + } + + public async Task HandleQuit() + { + if (!_shutdownCts.IsCancellationRequested) + _shutdownCts.Cancel(); + timerSubscription?.Dispose(); + await HandleLogCache(); + } + + public async Task HandleLogCache() + { + if (LogUtils.LogEntries > 0) + { + var logs = LogUtils.GetAllLogs(); + await CacheLogEntriesAsync(logs); + } + } + + public Task CacheLogEntriesAsync(LogEntry[] entries) + { + return Task.Run(async () => + { + await _fileLock.WaitAsync(); + try + { + WriteCacheToFile(entries); + } + finally + { + _fileLock.Release(); + } + }); + } + + public void CacheLogEntries(LogEntry[] entries) + { + _fileLock.Wait(); + try + { + WriteCacheToFile(entries); + } + finally + { + _fileLock.Release(); + } + } + + void WriteCacheToFile(LogEntry[] entries) + { + _saving = true; + var data = new LogWrapper { Entries = entries }; + + if (!Directory.Exists(_cacheFilePath)) + Directory.CreateDirectory(_cacheFilePath); + + // Stream JSON directly to file without creating entire JSON string in memory + var tempFile = _cacheFile + ".tmp"; + using (var fileStream = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: false)) + { + System.Text.Json.JsonSerializer.Serialize(fileStream, data, _jsonOptions); + fileStream.Flush(); + } + + // Atomic file replacement + if (File.Exists(_cacheFile)) + File.Delete(_cacheFile); + File.Move(tempFile, _cacheFile); + _saving = false; + } + public Task GetCachedLogEntriesAsync() + { + return Task.Run(async () => + { + await _fileLock.WaitAsync(); + try + { + if (!File.Exists(_cacheFile)) + return null; + + using var fileStream = File.OpenRead(_cacheFile); + return await System.Text.Json.JsonSerializer.DeserializeAsync(fileStream, _jsonOptions); + } + finally + { + _fileLock.Release(); + } + }); + } + + public void Dispose() + { + timerSubscription?.Dispose(); + timerSubscription = null; + + if (!_shutdownCts.IsCancellationRequested) + _shutdownCts.Cancel(); + _shutdownCts.Dispose(); + + _fileLock.Dispose(); + } + + ~LogCache() => Dispose(); + } +} \ No newline at end of file diff --git a/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogCache.cs.meta b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogCache.cs.meta new file mode 100644 index 00000000..8c99249f --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a9fd3e6a4abae402599a6a8899057661 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogEntry.cs b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogEntry.cs index 2c05a2e9..2de93ee1 100644 --- a/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogEntry.cs +++ b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogEntry.cs @@ -14,32 +14,48 @@ namespace com.IvanMurzak.Unity.MCP { - [Serializable] public class LogEntry { - public string message; - public string stackTrace; - public LogType logType; - public DateTime timestamp; - public string logTypeString; + public LogType LogType { get; set; } + public string Message { get; set; } + public DateTime Timestamp { get; set; } + public string? StackTrace { get; set; } - public LogEntry(string message, string stackTrace, LogType logType) + public LogEntry() { - this.message = message; - this.stackTrace = stackTrace; - this.logType = logType; - this.timestamp = DateTime.Now; - this.logTypeString = logType.ToString(); + LogType = LogType.Log; + Message = string.Empty; + Timestamp = DateTime.Now; + StackTrace = null; + } + public LogEntry(LogType logType, string message) + { + LogType = logType; + Message = message; + Timestamp = DateTime.Now; + } + public LogEntry(LogType logType, string message, string? stackTrace = null) + { + LogType = logType; + Message = message; + Timestamp = DateTime.Now; + StackTrace = stackTrace; + } + public LogEntry(LogType logType, string message, DateTime timestamp, string? stackTrace = null) + { + LogType = logType; + Message = message; + Timestamp = timestamp; + StackTrace = stackTrace; } public override string ToString() => ToString(includeStackTrace: false); public string ToString(bool includeStackTrace) { - if (includeStackTrace && !string.IsNullOrEmpty(stackTrace)) - return $"{timestamp:yyyy-MM-dd HH:mm:ss.fff} [{logTypeString}] {message}\nStack Trace:\n{stackTrace}"; - else - return $"{timestamp:yyyy-MM-dd HH:mm:ss.fff} [{logTypeString}] {message}"; + return includeStackTrace && !string.IsNullOrEmpty(StackTrace) + ? $"{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{LogType}] {Message}\nStack Trace:\n{StackTrace}" + : $"{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{LogType}] {Message}"; } } } diff --git a/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogUtils.cs b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogUtils.cs index 339d8581..bd0912db 100644 --- a/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogUtils.cs +++ b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogUtils.cs @@ -10,6 +10,7 @@ #nullable enable using System.Collections.Concurrent; +using System.Threading.Tasks; using com.IvanMurzak.ReflectorNet.Utils; using UnityEngine; @@ -19,9 +20,17 @@ public static class LogUtils { public const int MaxLogEntries = 5000; // Default max entries to keep in memory - static readonly ConcurrentQueue _logEntries = new(); + static ConcurrentQueue _logEntries = new(); + static readonly LogCache _logCache = new(); static readonly object _lockObject = new(); - static bool _isSubscribed = false; + static volatile bool _isSubscribed = false; + + static LogUtils() + { + if (!MainThread.Instance.IsMainThread) + throw new System.Exception($"{nameof(LogUtils)} must be initialized on the main thread."); + EnsureSubscribed(); + } public static int LogEntries { @@ -38,31 +47,68 @@ public static void ClearLogs() { lock (_lockObject) { - _logEntries.Clear(); + _logEntries = new ConcurrentQueue(); } } - public static LogEntry[] GetAllLogs() + /// + /// Synchronously saves all current log entries to the cache file. + /// + /// A task that completes when the save operation is finished. + public static void SaveToFileImmediate() + { + var logEntries = GetAllLogs(); + _logCache.CacheLogEntriesAsync(logEntries); + } + + /// + /// Asynchronously saves all current log entries to the cache file. + /// + /// A task that completes when the save operation is finished. + public static Task SaveToFile() + { + var logEntries = GetAllLogs(); + return _logCache.CacheLogEntriesAsync(logEntries); + } + + /// + /// Asynchronously loads log entries from the cache file and replaces the current log entries. + /// + /// A task that completes when the load operation is finished. + public static async Task LoadFromFile() { + var logWrapper = await _logCache.GetCachedLogEntriesAsync(); lock (_lockObject) { - return _logEntries.ToArray(); + _logEntries = new ConcurrentQueue(logWrapper?.Entries ?? new LogEntry[0]); } } - static LogUtils() + /// + /// Asynchronously handles application quit by saving log entries to file and cleaning up resources. + /// + /// A task that completes when the quit handling is finished. + public static async Task HandleQuit() { - EnsureSubscribed(); + SaveToFileImmediate(); + _logCache.HandleQuit(); + } + + public static LogEntry[] GetAllLogs() + { + lock (_lockObject) + { + return _logEntries.ToArray(); + } } - public static void EnsureSubscribed() + public static Task EnsureSubscribed() { - MainThread.Instance.RunAsync(() => + return MainThread.Instance.RunAsync(() => { lock (_lockObject) { if (!_isSubscribed) { - Application.logMessageReceived += OnLogMessageReceived; Application.logMessageReceivedThreaded += OnLogMessageReceived; _isSubscribed = true; } @@ -74,7 +120,11 @@ static void OnLogMessageReceived(string message, string stackTrace, LogType type { try { - var logEntry = new LogEntry(message, stackTrace, type); + var logEntry = new LogEntry( + message: message, + stackTrace: stackTrace, + logType: type); + lock (_lockObject) { _logEntries.Enqueue(logEntry); diff --git a/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogWrapper.cs b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogWrapper.cs new file mode 100644 index 00000000..9c0b699b --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogWrapper.cs @@ -0,0 +1,24 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Author: Ivan Murzak (https://github.com/IvanMurzak) │ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Copyright (c) 2025 Ivan Murzak │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable + +namespace com.IvanMurzak.Unity.MCP +{ + public class LogWrapper + { + public LogEntry[]? Entries { get; set; } + + public LogWrapper() + { + // none + } + } +} \ No newline at end of file diff --git a/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogWrapper.cs.meta b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogWrapper.cs.meta new file mode 100644 index 00000000..7b0dfafc --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Runtime/Unity/Logs/LogWrapper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c12966e92bc57473c9891a9922438201 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestLogUtils.cs b/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestLogUtils.cs new file mode 100644 index 00000000..936d9d6e --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestLogUtils.cs @@ -0,0 +1,444 @@ +/* +┌──────────────────────────────────────────────────────────────────┐ +│ Author: Ivan Murzak (https://github.com/IvanMurzak) │ +│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ +│ Copyright (c) 2025 Ivan Murzak │ +│ Licensed under the Apache License, Version 2.0. │ +│ See the LICENSE file in the project root for more information. │ +└──────────────────────────────────────────────────────────────────┘ +*/ + +#nullable enable +using System; +using System.Collections; +using System.Linq; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace com.IvanMurzak.Unity.MCP.Editor.Tests +{ + public class TestLogUtils : BaseTest + { + private const int Timeout = 100000; + + [SetUp] + public void TestSetUp() + { + LogUtils.ClearLogs(); + } + + [UnityTest] + public IEnumerator SaveToFile_LoadFromFile_PreservesAllLogTypes() + { + // Test that all Unity log types are preserved during save/load + LogUtils.ClearLogs(); + yield return null; + + var testData = new[] + { + new { Message = "Regular log message", Type = LogType.Log }, + new { Message = "Warning message", Type = LogType.Warning } + // new { Message = "Error message", Type = LogType.Error }, + // new { Message = "Assert message", Type = LogType.Assert }, + // new { Message = "Exception message", Type = LogType.Exception } + }; + + // Generate logs of different types + foreach (var test in testData) + { + switch (test.Type) + { + case LogType.Log: + Debug.Log(test.Message); + break; + case LogType.Warning: + Debug.LogWarning(test.Message); + break; + case LogType.Error: + Debug.LogError(test.Message); + break; + case LogType.Assert: + Debug.LogAssertion(test.Message); + break; + case LogType.Exception: + Debug.LogException(new Exception(test.Message)); + break; + } + } + + // Wait for logs to be collected + yield return WaitForLogCount(testData.Length); + + // Save to file + yield return WaitForTask(LogUtils.SaveToFile()); + + // Clear and reload + LogUtils.ClearLogs(); + Assert.AreEqual(0, LogUtils.LogEntries); + + yield return WaitForTask(LogUtils.LoadFromFile()); + + var loadedLogs = LogUtils.GetAllLogs(); + Assert.AreEqual(testData.Length, loadedLogs.Length, "All log types should be preserved"); + + // Verify each log type is preserved + foreach (var test in testData) + { + var matchingLog = loadedLogs.FirstOrDefault(log => + log.Message.Contains(test.Message) && log.LogType == test.Type); + Assert.IsNotNull(matchingLog, $"Log type {test.Type} with message '{test.Message}' should be preserved"); + } + } + + [UnityTest] + public IEnumerator SaveToFile_LoadFromFile_PreservesSpecialCharacters() + { + // Test that special characters, unicode, and formatting are preserved + LogUtils.ClearLogs(); + yield return null; + + var specialMessages = new[] + { + "Message with \"quotes\" and 'apostrophes'", + "Unicode: 你好世界 🚀 émojis", + "Newlines:\nLine 1\nLine 2\nLine 3", + "Tabs:\tindented\t\ttext", + "Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?/~`", + "Backslashes: C:\\Path\\To\\File.txt", + "Empty message:", + " Leading and trailing spaces " + }; + + foreach (var message in specialMessages) + { + Debug.Log(message); + } + + yield return WaitForLogCount(specialMessages.Length); + + // Save and reload + yield return WaitForTask(LogUtils.SaveToFile()); + LogUtils.ClearLogs(); + yield return WaitForTask(LogUtils.LoadFromFile()); + + var loadedLogs = LogUtils.GetAllLogs(); + Assert.AreEqual(specialMessages.Length, loadedLogs.Length, "All logs should be preserved"); + + // Verify exact message preservation + foreach (var expectedMessage in specialMessages) + { + var matchingLog = loadedLogs.FirstOrDefault(log => log.Message == expectedMessage); + Assert.IsNotNull(matchingLog, $"Message should be preserved exactly: '{expectedMessage}'"); + } + } + + [UnityTest] + public IEnumerator SaveToFile_LoadFromFile_PreservesStackTraces() + { + // Save original stack trace settings + var originalWarningStackTrace = Application.GetStackTraceLogType(LogType.Warning); + + try + { + // Enable stack traces for warning logs (we can't use Error/Assert as they fail tests) + Application.SetStackTraceLogType(LogType.Warning, StackTraceLogType.ScriptOnly); + + // Test that stack traces are preserved + LogUtils.ClearLogs(); + yield return null; + + // Generate logs with stack traces (only warnings, as errors/assertions fail tests) + Debug.LogWarning("Warning with stack trace 1"); + Debug.LogWarning("Warning with stack trace 2"); + Debug.LogWarning("Warning with stack trace 3"); + + const int expectedLogs = 3; + yield return WaitForLogCount(expectedLogs); + + var originalLogs = LogUtils.GetAllLogs(); + Assert.AreEqual(expectedLogs, originalLogs.Length); + + // Verify original logs have stack traces + foreach (var log in originalLogs) + { + Assert.IsFalse(string.IsNullOrEmpty(log.StackTrace), + $"Original log should have stack trace: {log.Message}"); + } + + // Save and reload + yield return WaitForTask(LogUtils.SaveToFile()); + LogUtils.ClearLogs(); + yield return WaitForTask(LogUtils.LoadFromFile()); + + var loadedLogs = LogUtils.GetAllLogs(); + Assert.AreEqual(expectedLogs, loadedLogs.Length, "All logs should be preserved"); + + // Verify stack traces are preserved + for (int i = 0; i < expectedLogs; i++) + { + var original = originalLogs[i]; + var loaded = loadedLogs.FirstOrDefault(log => log.Message == original.Message); + + Assert.IsNotNull(loaded, $"Log should be found: {original.Message}"); + Assert.AreEqual(original.StackTrace, loaded.StackTrace, + $"Stack trace should be preserved for: {original.Message}"); + } + } + finally + { + // Restore original stack trace settings even if test fails + Application.SetStackTraceLogType(LogType.Warning, originalWarningStackTrace); + } + } + + [UnityTest] + public IEnumerator SaveToFile_LoadFromFile_PreservesTimestamps() + { + // Test that timestamps are preserved with accuracy + LogUtils.ClearLogs(); + yield return null; + + const int testCount = 5; + for (int i = 0; i < testCount; i++) + { + Debug.Log($"Timestamp test {i}"); + } + + yield return WaitForLogCount(testCount); + + var originalLogs = LogUtils.GetAllLogs(); + var originalTimestamps = originalLogs.Select(log => log.Timestamp).ToArray(); + + // Save and reload + yield return WaitForTask(LogUtils.SaveToFile()); + LogUtils.ClearLogs(); + yield return WaitForTask(LogUtils.LoadFromFile()); + + var loadedLogs = LogUtils.GetAllLogs(); + Assert.AreEqual(testCount, loadedLogs.Length); + + // Verify timestamps are preserved (allowing for minimal serialization precision loss) + for (int i = 0; i < testCount; i++) + { + var original = originalLogs[i]; + var loaded = loadedLogs.FirstOrDefault(log => log.Message == original.Message); + + Assert.IsNotNull(loaded); + + // Timestamps should be equal or very close (within 1 second to account for serialization) + var timeDiff = Math.Abs((original.Timestamp - loaded.Timestamp).TotalMilliseconds); + Assert.Less(timeDiff, 1000, + $"Timestamp difference should be minimal. Original: {original.Timestamp}, Loaded: {loaded.Timestamp}"); + } + } + + [UnityTest] + public IEnumerator SaveToFile_LoadFromFile_HandlesEmptyLogs() + { + // Test saving/loading when there are no logs + LogUtils.ClearLogs(); + yield return null; + + Assert.AreEqual(0, LogUtils.LogEntries); + + // Save empty logs + yield return WaitForTask(LogUtils.SaveToFile()); + + // Try to load (should result in empty logs) + yield return WaitForTask(LogUtils.LoadFromFile()); + + Assert.AreEqual(0, LogUtils.LogEntries, "Loading empty logs should result in zero entries"); + Assert.AreEqual(0, LogUtils.GetAllLogs().Length); + } + + [UnityTest] + public IEnumerator SaveToFile_LoadFromFile_HandlesLargeMessages() + { + // Test very long log messages + LogUtils.ClearLogs(); + yield return null; + + var largeMessage = new string('A', 10000); // 10KB message + var mediumMessage = new string('B', 1000); // 1KB message + + Debug.Log(largeMessage); + Debug.Log(mediumMessage); + Debug.Log("Small message"); + + const int expectedLogs = 3; + yield return WaitForLogCount(expectedLogs); + + + + // Save and reload + yield return WaitForTask(LogUtils.SaveToFile()); + LogUtils.ClearLogs(); + yield return WaitForTask(LogUtils.LoadFromFile()); + + var loadedLogs = LogUtils.GetAllLogs(); + Assert.AreEqual(expectedLogs, loadedLogs.Length); + + // Verify large messages are preserved exactly + Assert.IsTrue(loadedLogs.Any(log => log.Message == largeMessage), + "Large message should be preserved"); + Assert.IsTrue(loadedLogs.Any(log => log.Message == mediumMessage), + "Medium message should be preserved"); + Assert.IsTrue(loadedLogs.Any(log => log.Message == "Small message"), + "Small message should be preserved"); + } + + [UnityTest] + public IEnumerator SaveToFile_LoadFromFile_MultipleSaveCycles() + { + // Test multiple save/load cycles to ensure data integrity over time + LogUtils.ClearLogs(); + yield return null; + + const int cycles = 3; + const int logsPerCycle = 5; + + for (int cycle = 0; cycle < cycles; cycle++) + { + // Add logs for this cycle + for (int i = 0; i < logsPerCycle; i++) + { + Debug.Log($"Cycle {cycle}, Log {i}"); + } + + yield return WaitForLogCount((cycle + 1) * logsPerCycle); + + // Save to file + yield return WaitForTask(LogUtils.SaveToFile()); + + // Verify count before clearing + Assert.AreEqual((cycle + 1) * logsPerCycle, LogUtils.LogEntries, + $"Should have {(cycle + 1) * logsPerCycle} logs after cycle {cycle}"); + + // Clear and reload + LogUtils.ClearLogs(); + yield return WaitForTask(LogUtils.LoadFromFile()); + + // Verify all logs from all cycles are still present + var loadedLogs = LogUtils.GetAllLogs(); + Assert.AreEqual((cycle + 1) * logsPerCycle, loadedLogs.Length, + $"All logs should be preserved after cycle {cycle}"); + + // Verify specific logs from each cycle + for (int pastCycle = 0; pastCycle <= cycle; pastCycle++) + { + for (int i = 0; i < logsPerCycle; i++) + { + var expectedMessage = $"Cycle {pastCycle}, Log {i}"; + Assert.IsTrue(loadedLogs.Any(log => log.Message == expectedMessage), + $"Log should exist: {expectedMessage}"); + } + } + } + } + + [UnityTest] + public IEnumerator SaveToFile_LoadFromFile_PreservesLogOrder() + { + // Test that log order is preserved + LogUtils.ClearLogs(); + yield return null; + + const int testCount = 20; + var messages = Enumerable.Range(0, testCount) + .Select(i => $"Ordered log {i:D3}") + .ToArray(); + + foreach (var message in messages) + { + Debug.Log(message); + } + + yield return WaitForLogCount(testCount); + + + + // Save and reload + yield return WaitForTask(LogUtils.SaveToFile()); + LogUtils.ClearLogs(); + yield return WaitForTask(LogUtils.LoadFromFile()); + + var loadedLogs = LogUtils.GetAllLogs(); + Assert.AreEqual(testCount, loadedLogs.Length); + + // Verify order is preserved by comparing timestamps + for (int i = 0; i < testCount - 1; i++) + { + Assert.LessOrEqual(loadedLogs[i].Timestamp, loadedLogs[i + 1].Timestamp, + $"Logs should be in chronological order: {i} -> {i + 1}"); + } + + // Verify all messages are present in original order + for (int i = 0; i < testCount; i++) + { + var expectedMessage = messages[i]; + var matchingLog = loadedLogs.FirstOrDefault(log => log.Message == expectedMessage); + Assert.IsNotNull(matchingLog, $"Log {i} should be preserved: {expectedMessage}"); + } + } + + [UnityTest] + public IEnumerator ClearLogs_RemovesAllLogs() + { + const int logsCount = 10; + // Test that ClearLogs actually removes all logs + LogUtils.ClearLogs(); + yield return null; + + // Add some logs + for (int i = 0; i < logsCount; i++) + { + Debug.Log($"Test log {i}"); + } + + yield return WaitForLogCount(logsCount); + Assert.AreEqual(logsCount, LogUtils.LogEntries); + + // Clear logs + LogUtils.ClearLogs(); + + Assert.AreEqual(0, LogUtils.LogEntries, "LogEntries should be zero after clear"); + Assert.AreEqual(0, LogUtils.GetAllLogs().Length, "GetAllLogs should return empty array after clear"); + } + + #region Helper Methods + + private IEnumerator WaitForLogCount(int expectedCount) + { + var frameCount = 0; + while (LogUtils.LogEntries < expectedCount) + { + yield return null; + frameCount++; + Assert.Less(frameCount, Timeout, + $"Timeout waiting for {expectedCount} logs. Current count: {LogUtils.LogEntries}"); + } + } + + private IEnumerator WaitForTask(System.Threading.Tasks.Task task) + { + var frameCount = 0; + while (!task.IsCompleted) + { + yield return null; + frameCount++; + Assert.Less(frameCount, Timeout, + $"Timeout waiting for task to complete. Status: {task.Status}"); + } + + // Check if task faulted + if (task.IsFaulted && task.Exception != null) + { + throw task.Exception; + } + } + + #endregion + } +} + diff --git a/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestLogUtils.cs.meta b/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestLogUtils.cs.meta new file mode 100644 index 00000000..1f72cb92 --- /dev/null +++ b/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestLogUtils.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2e6b4cb0867f0ef489526f487f597019 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestToolConsole.cs b/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestToolConsole.cs index de37ed3f..e7e57206 100644 --- a/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestToolConsole.cs +++ b/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestToolConsole.cs @@ -325,18 +325,17 @@ public void ConsoleLogEntry_CreatesCorrectly() { // Arrange & Act var logEntry = new LogEntry( - "Test message", - "Test stack trace", - LogType.Warning + message: "Test message", + stackTrace: "Test stack trace", + logType: LogType.Warning ); // Assert - Assert.AreEqual("Test message", logEntry.message); - Assert.AreEqual("Test stack trace", logEntry.stackTrace); - Assert.AreEqual(LogType.Warning, logEntry.logType); - Assert.AreEqual("Warning", logEntry.logTypeString); - Assert.IsTrue(logEntry.timestamp <= DateTime.Now); - Assert.IsTrue(logEntry.timestamp >= DateTime.Now.AddMinutes(-1)); // Should be very recent + Assert.AreEqual("Test message", logEntry.Message); + Assert.AreEqual("Test stack trace", logEntry.StackTrace); + Assert.AreEqual(LogType.Warning, logEntry.LogType); + Assert.IsTrue(logEntry.Timestamp <= DateTime.Now); + Assert.IsTrue(logEntry.Timestamp >= DateTime.Now.AddMinutes(-1)); // Should be very recent } [Test] @@ -344,9 +343,9 @@ public void ConsoleLogEntry_ToString_FormatsCorrectly() { // Arrange - Test with Warning to avoid causing test failure var logEntry = new LogEntry( - "Test message", - "Test stack trace", - LogType.Warning + message: "Test message", + stackTrace: "Test stack trace", + logType: LogType.Warning ); // Act @@ -355,7 +354,7 @@ public void ConsoleLogEntry_ToString_FormatsCorrectly() // Assert Assert.IsTrue(result.Contains("[Warning]"), "Should contain log type"); Assert.IsTrue(result.Contains("Test message"), "Should contain message"); - Assert.IsTrue(result.Contains(logEntry.timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")), "Should contain formatted timestamp"); + Assert.IsTrue(result.Contains(logEntry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")), "Should contain formatted timestamp"); } [Test] @@ -363,16 +362,15 @@ public void ConsoleLogEntry_ErrorType_CreatesCorrectly() { // Test Error log type creation directly (without using Debug.LogError) var errorLogEntry = new LogEntry( - "Error message", - "Error stack trace", - LogType.Error + message: "Error message", + stackTrace: "Error stack trace", + logType: LogType.Error ); // Assert - Assert.AreEqual("Error message", errorLogEntry.message); - Assert.AreEqual("Error stack trace", errorLogEntry.stackTrace); - Assert.AreEqual(LogType.Error, errorLogEntry.logType); - Assert.AreEqual("Error", errorLogEntry.logTypeString); + Assert.AreEqual("Error message", errorLogEntry.Message); + Assert.AreEqual("Error stack trace", errorLogEntry.StackTrace); + Assert.AreEqual(LogType.Error, errorLogEntry.LogType); Assert.IsTrue(errorLogEntry.ToString().Contains("[Error]"), "Should format Error type correctly"); } @@ -381,16 +379,15 @@ public void ConsoleLogEntry_AssertType_CreatesCorrectly() { // Test Assert log type creation directly (without using Debug.LogAssertion) var assertLogEntry = new LogEntry( - "Assert message", - "Assert stack trace", - LogType.Assert + message: "Assert message", + stackTrace: "Assert stack trace", + logType: LogType.Assert ); // Assert - Assert.AreEqual("Assert message", assertLogEntry.message); - Assert.AreEqual("Assert stack trace", assertLogEntry.stackTrace); - Assert.AreEqual(LogType.Assert, assertLogEntry.logType); - Assert.AreEqual("Assert", assertLogEntry.logTypeString); + Assert.AreEqual("Assert message", assertLogEntry.Message); + Assert.AreEqual("Assert stack trace", assertLogEntry.StackTrace); + Assert.AreEqual(LogType.Assert, assertLogEntry.LogType); Assert.IsTrue(assertLogEntry.ToString().Contains("[Assert]"), "Should format Assert type correctly"); } [Test] diff --git a/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestToolConsoleIntegration.cs b/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestToolConsoleIntegration.cs index 849c9efa..b45223d0 100644 --- a/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestToolConsoleIntegration.cs +++ b/Unity-MCP-Plugin/Assets/root/Tests/Editor/Tool/Console/TestToolConsoleIntegration.cs @@ -25,6 +25,9 @@ public class TestToolConsoleIntegration : BaseTest [SetUp] public void TestSetUp() { + // var task = LogUtils.EnsureSubscribed(); + // while (!task.IsCompleted) + // yield return null; _tool = new Tool_Console(); } @@ -234,6 +237,105 @@ public void GetLogs_MaxEntriesParameterDescription_EndsWithMax() Assert.IsTrue(description.EndsWith($"Max: {LogUtils.MaxLogEntries}"), $"{parameterName} parameter description should end with 'Max: {LogUtils.MaxLogEntries}'. Actual description: '{description}'"); } + + [UnityTest] + public IEnumerator GetLogs_Validate_LogCount() + { + // This test verifies that logs are being stored and read from the log cache properly. + var testCount = 15; + var timeout = 10000; + var startCount = LogUtils.LogEntries; + for (int i = 0; i < testCount; i++) + { + Debug.Log($"Test Log {i + 1}"); + } + + var frameCount = 0; + while (LogUtils.LogEntries < startCount + testCount) + { + yield return null; + frameCount++; + Assert.Less(frameCount, timeout, "Timeout waiting for logs to be collected."); + } + Assert.AreEqual(startCount + testCount, LogUtils.LogEntries, "Log entry count should match the amount of logs generated by this test."); + } + + [UnityTest] + public IEnumerator GetLogs_Validate_ConsoleLogRetention() + { + // This test verifies that logs are being stored and read from the log cache properly. + const int testCount = 15; + const int timeout = 100000; + + var logMessages = Enumerable.Range(1, testCount) + .Select(i => $"Test Log {i}") + .ToArray(); + + Debug.Log($"Starting log retention test with {testCount} logs."); + Debug.Log($"Generated log messages:\n{string.Join("\n", logMessages)}"); + + // Ensure a clean slate + Debug.Log($"Clearing existing logs."); + LogUtils.ClearLogs(); + yield return null; + + var startCount = LogUtils.LogEntries; + Assert.AreEqual(0, startCount, "Log entries should be empty at the start."); + + foreach (var logMessage in logMessages) + { + Debug.Log(logMessage); + } + + // Wait for logs to be collected + var frameCount = 0; + while (LogUtils.LogEntries < testCount) + { + yield return null; + frameCount++; + Assert.Less(frameCount, timeout, "Timeout waiting for logs to be collected."); + } + Assert.AreEqual(testCount, LogUtils.LogEntries, "Log entries count should include new entries."); + + // Save to file and wait for completion + var saveTask = LogUtils.SaveToFile(); + frameCount = 0; + while (!saveTask.IsCompleted) + { + yield return null; + frameCount++; + Assert.Less(frameCount, timeout, $"Timeout waiting for {nameof(LogUtils.SaveToFile)} to complete."); + } + + // Clear logs and confirm + LogUtils.ClearLogs(); + Assert.AreEqual(0, LogUtils.LogEntries, "Log entries should be cleared."); + Assert.AreEqual(0, LogUtils.GetAllLogs().Length, "Log entries should be cleared."); + + // Load from file and wait for completion + var loadTask = LogUtils.LoadFromFile(); + frameCount = 0; + while (!loadTask.IsCompleted) + { + yield return null; + frameCount++; + Assert.Less(frameCount, timeout, $"Timeout waiting for {nameof(LogUtils.LoadFromFile)} to complete."); + } + + var allLogs = LogUtils.GetAllLogs(); + + Assert.AreEqual(LogUtils.LogEntries, allLogs.Length, "Loaded log entries count should match the saved entries."); + + // Final assertion + Assert.AreEqual(testCount, LogUtils.LogEntries, "LogUtils should have the restored logs in memory."); + + for (int i = 0; i < testCount; i++) + { + var expectedMessage = logMessages[i]; + Assert.IsTrue(allLogs.Any(entry => entry.Message == expectedMessage), + $"Restored logs should contain: {expectedMessage}"); + } + } } }