From d263f0c7e1ecb8a9d3fcb092211f951809daba0b Mon Sep 17 00:00:00 2001 From: Misaki Date: Fri, 30 Jan 2026 21:20:18 +0900 Subject: [PATCH] Imporving AssetDatabase --- .../AssetHandle/AssetDatabase.Loader.cs | 52 ++---- .../AssetHandle/AssetDatabase.Meta.cs | 53 +++--- .../AssetHandle/AssetDatabase.SQLite.cs | 1 + .../AssetHandle/AssetDatabase.cs | 164 ++++++------------ .../AssetHandle/AssetImporter.cs | 2 +- .../AssetHandle/Importers/TextImporter.cs | 4 +- .../AssetHandle/Importers/TextureImporter.cs | 28 +-- .../AssetDatabaseIntegrationTest.cs | 55 +++++- 8 files changed, 158 insertions(+), 201 deletions(-) diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs index c3a454c..3ff3ecb 100644 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Loader.cs @@ -9,19 +9,16 @@ public static partial class AssetDatabase { // Asset cache - stores loaded assets by GUID private static readonly ConcurrentDictionary s_assetCache = new(); - + // LRU tracking - stores access time for each cached asset private static readonly ConcurrentDictionary s_assetAccessTime = new(); - + // Maximum number of cached assets before eviction starts private const int MAX_CACHED_ASSETS = 1000; - - // Percentage of cache to evict when limit is reached (evict oldest 20%) - private const float CACHE_EVICTION_PERCENTAGE = 0.2f; - /// - /// Get the path to the imported asset data directory. - /// + // Percentage of cache to evict when limit is reached (evict oldest 20%) + private const float _CACHE_EVICTION_PERCENTAGE = 0.2f; + private static Result GetImportedAssetsDirectory() { if (AssetsDirectory == null) @@ -30,7 +27,6 @@ public static partial class AssetDatabase } var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "ImportedAssets"); - if (!Directory.Exists(cacheDir)) { Directory.CreateDirectory(cacheDir); @@ -39,11 +35,6 @@ public static partial class AssetDatabase return cacheDir; } - /// - /// Get the path where imported asset data is stored for a specific GUID. - /// - /// GUID of the asset. - /// Full path to the imported asset data file. private static Result GetImportedAssetPath(Guid guid) { var importedDirResult = GetImportedAssetsDirectory(); @@ -57,12 +48,6 @@ public static partial class AssetDatabase return assetDataPath; } - /// - /// Load asset by GUID with caching (internal implementation). - /// - /// Type of asset to load. - /// GUID of the asset. - /// The loaded asset. private static Result LoadAssetInternal(Guid guid) where T : Asset { // Check cache first @@ -70,7 +55,7 @@ public static partial class AssetDatabase { // Update access time for LRU s_assetAccessTime[guid] = DateTime.UtcNow; - + if (cachedAsset is T typedAsset) { return typedAsset; @@ -89,7 +74,6 @@ public static partial class AssetDatabase } var assetDataPath = assetPathResult.Value; - if (!File.Exists(assetDataPath)) { return Result.Failure($"Imported asset data not found at {assetDataPath}. Asset may not have been imported yet."); @@ -100,7 +84,6 @@ public static partial class AssetDatabase // Read and deserialize asset data var json = File.ReadAllText(assetDataPath); var asset = JsonSerializer.Deserialize(json); - if (asset == null) { return Result.Failure("Failed to deserialize asset data"); @@ -108,7 +91,6 @@ public static partial class AssetDatabase // Add to cache CacheAsset(guid, asset); - return asset; } catch (Exception ex) @@ -117,12 +99,6 @@ public static partial class AssetDatabase } } - /// - /// Load asset by path with caching. - /// - /// Type of asset to load. - /// Full or relative path to the asset. - /// The loaded asset. public static Result LoadAssetAtPath(string assetPath) where T : Asset { var guidResult = PathToGuid(assetPath); @@ -134,9 +110,6 @@ public static partial class AssetDatabase return LoadAsset(guidResult.Value); } - /// - /// Add an asset to the cache with LRU eviction if needed. - /// private static void CacheAsset(Guid guid, Asset asset) { // Check if we need to evict old assets @@ -149,13 +122,10 @@ public static partial class AssetDatabase s_assetAccessTime[guid] = DateTime.UtcNow; } - /// - /// Evict the oldest assets from cache based on LRU. - /// private static void EvictOldestAssets() { - var evictionCount = (int)(MAX_CACHED_ASSETS * CACHE_EVICTION_PERCENTAGE); - + var evictionCount = (int)(MAX_CACHED_ASSETS * _CACHE_EVICTION_PERCENTAGE); + // Sort by access time and remove oldest entries var oldestAssets = s_assetAccessTime .OrderBy(kvp => kvp.Value) @@ -216,7 +186,8 @@ public static partial class AssetDatabase /// GUID of the asset. /// Processed asset data to save. /// Result indicating success or failure. - internal static Result SaveImportedAsset(Guid guid, T assetData) where T : Asset + public static Result SaveImportedAsset(Guid guid, T assetData) + where T : Asset { var assetPathResult = GetImportedAssetPath(guid); if (assetPathResult.IsFailure) @@ -228,10 +199,9 @@ public static partial class AssetDatabase { var json = JsonSerializer.Serialize(assetData, s_defaultJsonOptions); File.WriteAllText(assetPathResult.Value, json); - + // Invalidate cache for this asset so it gets reloaded next time UnloadAsset(guid); - return Result.Success(); } catch (Exception ex) diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs index afade79..ba0fc5e 100644 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.Meta.cs @@ -1,6 +1,7 @@ using Ghost.Core; using Ghost.Editor.Core.Utilities; using System.Reflection; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text.Json; @@ -27,10 +28,10 @@ public static partial class AssetDatabase } } - s_watcher.Created += OnAssetCreated; - s_watcher.Deleted += OnAssetDeleted; + s_watcher.Created += OnFSEvent; + s_watcher.Deleted += OnFSEvent; + s_watcher.Changed += OnFSEvent; s_watcher.Renamed += OnAssetRenamed; - s_watcher.Changed += OnAssetChanged; } private static Result GetMetaFilePath(string assetPath) @@ -194,48 +195,38 @@ public static partial class AssetDatabase return r; } - private static void OnAssetCreated(object sender, FileSystemEventArgs e) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsMetaFile(string path) { - // Skip meta files - if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - PostCommand(new AssetCommand(AssetCommandType.FileCreated, e.FullPath, Timestamp: DateTime.UtcNow)); + return Path.GetExtension(path).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase); } - private static void OnAssetDeleted(object sender, FileSystemEventArgs e) + private static async void OnFSEvent(object sender, FileSystemEventArgs e) { - // Skip meta files - if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + if (IsMetaFile(e.FullPath)) { return; } - PostCommand(new AssetCommand(AssetCommandType.FileDeleted, e.FullPath, Timestamp: DateTime.UtcNow)); + var type = e.ChangeType switch + { + WatcherChangeTypes.Created => AssetCommandType.FileCreated, + WatcherChangeTypes.Deleted => AssetCommandType.FileDeleted, + WatcherChangeTypes.Changed => AssetCommandType.FileModified, + _ => throw new InvalidOperationException("Unsupported file system event type") + }; + + await PostCommandAsync(new AssetCommand(type, e.FullPath, Timestamp: DateTime.UtcNow)); } - private static void OnAssetRenamed(object sender, RenamedEventArgs e) + private static async void OnAssetRenamed(object sender, RenamedEventArgs e) { - // Skip meta files - if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) + if (IsMetaFile(e.FullPath)) { return; } - PostCommand(new AssetCommand(AssetCommandType.FileRenamed, e.FullPath, e.OldFullPath, DateTime.UtcNow)); - } - - private static void OnAssetChanged(object sender, FileSystemEventArgs e) - { - // Skip meta files - if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - PostCommand(new AssetCommand(AssetCommandType.FileModified, e.FullPath, Timestamp: DateTime.UtcNow)); + await PostCommandAsync(new AssetCommand(AssetCommandType.FileRenamed, e.FullPath, e.OldFullPath, DateTime.UtcNow)); } /// @@ -243,6 +234,8 @@ public static partial class AssetDatabase /// private static async Task MarkDependentAssetsDirtyAsync(Guid assetGuid) { + // TODO: We should have a reverse dependency lookup in the database to avoid scanning all assets. + // Query database for all assets and check their dependencies var allAssets = GetAllAssets(); diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs index 20ecb75..b802e53 100644 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.SQLite.cs @@ -50,6 +50,7 @@ public static partial class AssetDatabase ); CREATE INDEX IF NOT EXISTS idx_path ON Assets(Path); "; + await cmd.ExecuteNonQueryAsync(token); } diff --git a/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs b/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs index 56d9322..87f9e25 100644 --- a/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs +++ b/Ghost.Editor.Core/AssetHandle/AssetDatabase.cs @@ -1,5 +1,6 @@ using Ghost.Core; using Ghost.Data.Services; +using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Channels; @@ -41,23 +42,22 @@ public static partial class AssetDatabase private static readonly Dictionary s_pathAssetLookup = new(); // In-memory dirty asset tracking (for runtime modifications only) + // TODO: We do not handle the reimporting of dirty assets yet private static readonly HashSet s_dirtyAssets = new(); // Command buffer pattern - Channel for file system event commands private static Channel? s_commandChannel; private static Timer? s_commandProcessorTimer; - private static readonly HashSet s_pendingCommandPaths = new(); // Track paths with pending commands private static readonly Lock s_commandLock = new(); + private static readonly ConcurrentQueue s_waitingCommands = new(); // Commands waiting for manual refresh private static bool s_autoRefreshEnabled = true; - private static readonly Queue s_waitingCommands = new(); // Commands waiting for manual refresh - - private static TaskCompletionSource? s_refreshTcs; // Initialization guard private static readonly Lock s_initializationLock = new(); private static bool s_initialized = false; private static readonly TimeSpan s_debounceDelay = TimeSpan.FromMilliseconds(100); + private static ManualResetEventSlim s_resetEventSlim = new(false); private static readonly JsonSerializerOptions s_defaultJsonOptions = new() { @@ -79,14 +79,16 @@ public static partial class AssetDatabase /// Initialize the asset database. /// Must be called after project is loaded. /// + internal static async Task Initialize(CancellationToken token = default) { lock (s_initializationLock) { if (s_initialized) { - return; // Already initialized, skip + return; } + s_initialized = true; } @@ -99,8 +101,8 @@ public static partial class AssetDatabase s_commandChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { - SingleReader = false, // Timer callback reads - SingleWriter = false // Multiple FS events can write + SingleReader = false, + SingleWriter = false }); // Initialize command processor timer (starts disabled, triggered by events) @@ -120,6 +122,7 @@ public static partial class AssetDatabase InitializeAssetHandle(); InitializeMetaData(); + // TODO: Timestamp fake instead of full scan. await ValidateAndFixDatabaseAsync(token); } @@ -137,7 +140,7 @@ public static partial class AssetDatabase try { // Scan all files in assets directory - var allFiles = Directory.GetFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories) + var allFiles = Directory.EnumerateFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories) .Where(f => !f.EndsWith(Utilities.FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase)); // Ensure all files have metadata @@ -183,24 +186,16 @@ public static partial class AssetDatabase public static async Task RefreshAsync(CancellationToken token = default) { // Flush waiting commands to channel - lock (s_commandLock) + while (s_waitingCommands.TryDequeue(out var cmd)) { - while (s_waitingCommands.TryDequeue(out var cmd)) - { - s_commandChannel?.Writer.TryWrite(cmd); - } - - s_refreshTcs = new TaskCompletionSource(); + s_commandChannel?.Writer.TryWrite(cmd); } + s_resetEventSlim.Reset(); s_commandChannel?.Writer.TryWrite(new AssetCommand(AssetCommandType.ManualRefresh, string.Empty)); s_commandProcessorTimer?.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); - if (!await s_refreshTcs.Task.WaitAsync(token)) - { - return Result.Failure("Asset database refresh failed"); - } - + await Task.Run(s_resetEventSlim.Wait, token); return Result.Success(); } @@ -269,9 +264,6 @@ public static partial class AssetDatabase s_autoRefreshEnabled = enabled; } - /// - /// Process all pending commands immediately (synchronous, for testing). - /// internal static void FlushPendingCommands() { // Stop timer temporarily @@ -284,10 +276,7 @@ public static partial class AssetDatabase ProcessPendingCommands(null); } - /// - /// Post a command to the command channel for processing. - /// - private static void PostCommand(AssetCommand command) + private static async ValueTask PostCommandAsync(AssetCommand command, CancellationToken token = default) { if (s_commandChannel == null) { @@ -296,53 +285,38 @@ public static partial class AssetDatabase if (s_autoRefreshEnabled) { - // Add to pending paths for temp file tracking - lock (s_commandLock) - { - s_pendingCommandPaths.Add(command.Path); - if (command.OldPath != null) - { - s_pendingCommandPaths.Add(command.OldPath); - } - } - - // Write to channel (lock-free, async-safe) - s_commandChannel.Writer.TryWrite(command); - - // Start or reset timer (safe even if not initialized yet) + await s_commandChannel.Writer.WriteAsync(command, token); s_commandProcessorTimer?.Change(s_debounceDelay, Timeout.InfiniteTimeSpan); } else { - // Queue for manual refresh - lock (s_commandLock) - { - s_waitingCommands.Enqueue(command); - } + s_waitingCommands.Enqueue(command); } } - /// - /// Timer callback to process pending commands. - /// private static async void ProcessPendingCommands(object? state) { + if (s_commandChannel == null) + { + return; + } + try { - // Collect all pending commands - var commands = new List(); + // // Collect all pending commands + // var commands = new List(); + // + // while (s_commandChannel.Reader.TryRead(out var cmd)) + // { + // commands.Add(cmd); + // } - while (s_commandChannel?.Reader.TryRead(out var cmd) == true) - { - commands.Add(cmd); - } - - // Group commands by path (last command wins) - var commandsByPath = new Dictionary(); - foreach (var cmd in commands) - { - commandsByPath[cmd.Path] = cmd; - } + // // Group commands by path (last command wins) + // var commandsByPath = new Dictionary(); + // foreach (var cmd in commands) + // { + // commandsByPath[cmd.Path] = cmd; + // } // NOTE: We handle the temp file filtering in each command handler now // We should able to remove this allocation heavy code @@ -365,24 +339,31 @@ public static partial class AssetDatabase // } // Execute commands - foreach (var cmd in commandsByPath.Values) + // NOTE: We many don't need to collect all commands first, just process as we read. + // Channel in c# is thread-safe for multiple readers/writers. + //await foreach (var cmd in s_commandChannel.Reader.ReadAllAsync()) + //{ + // await ExecuteCommandAsync(cmd); + //} + + while (s_commandChannel.Reader.TryRead(out var cmd)) { await ExecuteCommandAsync(cmd); } - s_refreshTcs?.SetResult(true); + await ImportDirtyAssetsAsync(); } catch (Exception ex) { Logger.LogError($"Error processing commands: {ex.Message}"); - s_refreshTcs?.SetResult(false); + } + finally + { + s_resetEventSlim.Set(); } } - /// - /// Execute a single asset command. - /// - private static async Task ExecuteCommandAsync(AssetCommand command) + private static async ValueTask ExecuteCommandAsync(AssetCommand command) { switch (command.Type) { @@ -411,10 +392,7 @@ public static partial class AssetDatabase } } - /// - /// Handle file created event. - /// - private static async Task HandleFileCreatedAsync(string path) + private static async ValueTask HandleFileCreatedAsync(string path) { if (!File.Exists(path)) { @@ -424,10 +402,7 @@ public static partial class AssetDatabase await GenerateMetaFileAsync(path, CancellationToken.None); } - /// - /// Handle file modified event. - /// - private static async Task HandleFileModifiedAsync(string path) + private static async ValueTask HandleFileModifiedAsync(string path) { if (!File.Exists(path)) { @@ -443,7 +418,6 @@ public static partial class AssetDatabase return; } - // Calculate new hash and compare against database var newHash = await CalculateFileHashAsync(path, CancellationToken.None); var oldHash = await GetFileHashAsync(metaResult.Value.Guid, CancellationToken.None); @@ -455,16 +429,8 @@ public static partial class AssetDatabase } } - /// - /// Handle file deleted event. - /// - private static async Task HandleFileDeletedAsync(string path) + private static async ValueTask HandleFileDeletedAsync(string path) { - if (!File.Exists(path)) - { - return; - } - var metaFileResult = GetMetaFilePath(path); if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value)) { @@ -491,22 +457,8 @@ public static partial class AssetDatabase } } - /// - /// Handle file renamed event. - /// - private static async Task HandleFileRenamedAsync(string oldPath, string newPath) + private static async ValueTask HandleFileRenamedAsync(string oldPath, string newPath) { - if (!File.Exists(oldPath)) - { - return; - } - - if (File.Exists(newPath)) - { - Logger.LogWarning($"Cannot rename asset from '{oldPath}' to '{newPath}': target file already exists."); - return; - } - var oldMetaPath = oldPath + Utilities.FileExtensions.META_FILE_EXTENSION; var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION; @@ -543,22 +495,17 @@ public static partial class AssetDatabase } catch { - // Ignore } } } - /// - /// Shutdown the asset database. - /// Disposes resources and closes database connections. - /// internal static void Shutdown() { lock (s_initializationLock) { if (!s_initialized) { - return; // Not initialized, nothing to shutdown + return; } s_watcher?.Dispose(); @@ -574,7 +521,6 @@ public static partial class AssetDatabase s_assetPathLookup.Clear(); s_pathAssetLookup.Clear(); s_dirtyAssets.Clear(); - s_pendingCommandPaths.Clear(); s_waitingCommands.Clear(); s_importerInstances.Clear(); s_importerTypeLookup.Clear(); diff --git a/Ghost.Editor.Core/AssetHandle/AssetImporter.cs b/Ghost.Editor.Core/AssetHandle/AssetImporter.cs index fc39395..50d34b0 100644 --- a/Ghost.Editor.Core/AssetHandle/AssetImporter.cs +++ b/Ghost.Editor.Core/AssetHandle/AssetImporter.cs @@ -35,7 +35,7 @@ public abstract class AssetImporter /// /// List of dependency GUIDs extracted from the asset. /// Result indicating if all dependencies are valid. - protected virtual ValueTask ValidateDependenciesAsync(List dependencies) + protected virtual ValueTask ValidateDependenciesAsync(List dependencies, CancellationToken token = default) { foreach (var dependencyGuid in dependencies) { diff --git a/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs b/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs index ef928bf..4c72a47 100644 --- a/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs +++ b/Ghost.Editor.Core/AssetHandle/Importers/TextImporter.cs @@ -27,7 +27,7 @@ internal class TextImporterSettings : ImporterSettings [AssetImporter(".txt", ".md")] internal class TextImporter : AssetImporter { - public override async Task ImportAsync(string assetPath, AssetMeta meta) + public override async ValueTask ImportAsync(string assetPath, AssetMeta meta, CancellationToken token = default) { var settings = GetSettings(meta); @@ -45,7 +45,7 @@ internal class TextImporter : AssetImporter try { // Read the file - var content = await File.ReadAllTextAsync(assetPath); + var content = await File.ReadAllTextAsync(assetPath, token); if (settings.TrimWhitespace) { diff --git a/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs b/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs index d6306e0..b61b128 100644 --- a/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs +++ b/Ghost.Editor.Core/AssetHandle/Importers/TextureImporter.cs @@ -73,7 +73,7 @@ internal class TextureImporterSettings : ImporterSettings [AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")] internal class TextureImporter : AssetImporter { - public override async Task ImportAsync(string assetPath, AssetMeta meta) + public override async ValueTask ImportAsync(string assetPath, AssetMeta meta, CancellationToken token = default) { var settings = GetSettings(meta); @@ -82,7 +82,7 @@ internal class TextureImporter : AssetImporter var dependencies = new List(); // Validate dependencies - var depResult = await ValidateDependenciesAsync(dependencies); + var depResult = await ValidateDependenciesAsync(dependencies, token); if (depResult.IsFailure) { return depResult; @@ -97,8 +97,8 @@ internal class TextureImporter : AssetImporter } // Get image dimensions (simplified - in real implementation would use image library) - var (width, height) = await GetImageDimensionsAsync(assetPath); - + var (width, height) = GetImageDimensions(assetPath); + if (width == 0 || height == 0) { return Result.Failure("Failed to read image dimensions"); @@ -160,38 +160,38 @@ internal class TextureImporter : AssetImporter /// Get image dimensions from file. /// Simplified implementation - in production, use an image library. /// - private async Task<(uint width, uint height)> GetImageDimensionsAsync(string imagePath) + private static (uint width, uint height) GetImageDimensions(string imagePath) { // This is a placeholder implementation // In a real implementation, you would use a library like: // - ImageSharp // - StbImageSharp // - DirectXTex (for DDS files) - + var extension = Path.GetExtension(imagePath).ToLowerInvariant(); - + if (extension == ".dds") { // For DDS files, read the header // DDS header format: https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header - return await ReadDDSHeaderAsync(imagePath); + return ReadDDSHeader(imagePath); } else { // For PNG/JPG/etc, we would use an image library // For now, return placeholder values - return await Task.FromResult<(uint, uint)>((1024, 1024)); + return (1024, 1024); } } /// /// Read DDS file header to get dimensions. /// - private async Task<(uint width, uint height)> ReadDDSHeaderAsync(string ddsPath) + private static (uint width, uint height) ReadDDSHeader(string ddsPath) { try { - await using var stream = File.OpenRead(ddsPath); + using var stream = File.OpenRead(ddsPath); using var reader = new BinaryReader(stream); // Read magic number (should be "DDS ") @@ -226,7 +226,7 @@ internal class TextureImporter : AssetImporter /// /// Export a texture asset from memory to disk. /// - public override async Task ExportAsync(string assetPath, T assetData, AssetMeta meta) + public override async ValueTask ExportAsync(string assetPath, T assetData, AssetMeta meta, CancellationToken token = default) { if (assetData is not TextureAsset textureAsset) { @@ -239,14 +239,14 @@ internal class TextureImporter : AssetImporter // 1. Convert the texture data to the appropriate format // 2. Write the image file (PNG, DDS, etc.) // 3. Save metadata - + // For now, just save metadata as JSON var json = JsonSerializer.Serialize(textureAsset, new JsonSerializerOptions { WriteIndented = true }); - await File.WriteAllTextAsync(assetPath, json); + await File.WriteAllTextAsync(assetPath, json, token); return Result.Success(); } diff --git a/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs b/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs index 35c33a0..44d291f 100644 --- a/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs +++ b/Ghost.UnitTest/AssetDatabaseIntegrationTest.cs @@ -1,5 +1,6 @@ using Ghost.Editor.Core.AssetHandle; using Ghost.Data.Services; +using Ghost.Core; namespace Ghost.UnitTest; @@ -89,11 +90,25 @@ public class AssetDatabaseIntegrationTest { await Task.Delay(delayMs, TestContext.CancellationToken); AssetDatabase.FlushPendingCommands(); - + // Give a bit more time after flush for any final processing await Task.Delay(50, TestContext.CancellationToken); } + private static void CheckInternalErrors() + { + if (Logger.Logs.Count > 0) + { + foreach (var log in Logger.Logs) + { + if (log.Level == LogLevel.Error) + { + Assert.Fail($"Internal error logged: {log.Message}"); + } + } + } + } + [TestMethod] public async Task TestAutoMetaGeneration_WhenFileCreated() { @@ -111,6 +126,8 @@ public class AssetDatabaseIntegrationTest // Verify meta file content var metaContent = await File.ReadAllTextAsync(metaFile, TestContext.CancellationToken); Assert.Contains("Guid", metaContent, "Meta file should contain GUID"); + + CheckInternalErrors(); } [TestMethod] @@ -136,6 +153,8 @@ public class AssetDatabaseIntegrationTest // Test exact match results = await AssetDatabase.FindAssetsByNameAsync("enemy.txt", TestContext.CancellationToken); Assert.HasCount(1, results, "Should find 1 file matching 'enemy.txt'"); + + CheckInternalErrors(); } [TestMethod] @@ -164,6 +183,8 @@ public class AssetDatabaseIntegrationTest var newGuidResult = AssetDatabase.PathToGuid(newPath); Assert.IsTrue(newGuidResult.IsSuccess, "Should be able to get GUID after rename"); Assert.AreEqual(guid, newGuidResult.Value, "GUID should be preserved after rename"); + + CheckInternalErrors(); } [TestMethod] @@ -182,6 +203,7 @@ public class AssetDatabaseIntegrationTest File.Delete(filePath); await WaitForFileSystemEvents(); + await Task.Delay(1000, TestContext.CancellationToken); // Meta file should also be deleted var metaPath = filePath + ".gmeta"; Assert.IsFalse(File.Exists(metaPath), "Meta file should be deleted with asset"); @@ -189,6 +211,8 @@ public class AssetDatabaseIntegrationTest // Asset should be removed from database var pathResult = AssetDatabase.GuidToPath(guid); Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database"); + + CheckInternalErrors(); } [TestMethod] @@ -207,6 +231,8 @@ public class AssetDatabaseIntegrationTest // Should be in database var guidResult = AssetDatabase.PathToGuid(filePath); Assert.IsTrue(guidResult.IsSuccess, "Asset should be in database"); + + CheckInternalErrors(); } [TestMethod] @@ -240,6 +266,8 @@ public class AssetDatabaseIntegrationTest // GUID should be preserved var newGuid = AssetDatabase.PathToGuid(destPath).Value; Assert.AreEqual(guid, newGuid, "GUID should be preserved"); + + CheckInternalErrors(); } [TestMethod] @@ -264,6 +292,8 @@ public class AssetDatabaseIntegrationTest // Both should have different GUIDs var destGuid = AssetDatabase.PathToGuid(destPath).Value; Assert.AreNotEqual(sourceGuid, destGuid, "Copied asset should have different GUID"); + + CheckInternalErrors(); } [TestMethod] @@ -287,6 +317,8 @@ public class AssetDatabaseIntegrationTest // Should be removed from database var pathResult = AssetDatabase.GuidToPath(guid); Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database"); + + CheckInternalErrors(); } [TestMethod] @@ -323,6 +355,8 @@ public class AssetDatabaseIntegrationTest var metaContent = await File.ReadAllTextAsync(metaPath, TestContext.CancellationToken); Assert.Contains("Guid", metaContent, $"Meta file should be valid for {fileName}"); } + + CheckInternalErrors(); } [TestMethod] @@ -351,6 +385,8 @@ public class AssetDatabaseIntegrationTest var playerAssets = await AssetDatabase.FindAssetsByTagAsync("Player", TestContext.CancellationToken); Assert.HasCount(1, playerAssets, "Should find 1 asset with 'Player' tag"); + + CheckInternalErrors(); } [TestMethod] @@ -375,13 +411,24 @@ public class AssetDatabaseIntegrationTest // Only one meta file should exist var metaFiles = Directory.GetFiles(_testAssetsDir, "refresh.txt.gmeta"); Assert.HasCount(1, metaFiles, "Should have exactly one meta file"); + + CheckInternalErrors(); } [TestMethod] public async Task ThreadSafetyTest() { - var testFile = Path.Combine(_testAssetsDir, "test.txt"); - await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken); - await AssetDatabase.RefreshAsync(TestContext.CancellationToken); // This will cause race conditions if not handle properly because both AssetDatabase and FileSystemWatcher are involved + try + { + var testFile = Path.Combine(_testAssetsDir, "test.txt"); + await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken); + await AssetDatabase.RefreshAsync(TestContext.CancellationToken); // This will cause race conditions if not handle properly because both AssetDatabase and FileSystemWatcher are involved + } + catch (Exception ex) + { + Assert.Fail(ex.Message); + } + + CheckInternalErrors(); } }