feat(asset): modern asset system with SQLite catalog
Refactored asset management to use a persistent, thread-safe SQLite-backed AssetCatalog, replacing in-memory dictionaries. Added AssetHandlerRegistry for O(1) handler lookup, ImportCoordinator for async background importing, and robust AssetMeta/AssetMetaIO for JSON-based metadata and settings. Refactored AssetRegistry to integrate these components and support auto-import via file system watcher. Updated IImportableAssetHandler for handler-specific settings and polymorphic serialization. Added comprehensive unit tests for all new systems. Removed obsolete code and legacy integration tests. BREAKING CHANGE: Asset system APIs and storage format have changed; migration required for existing projects.
This commit is contained in:
@@ -36,7 +36,7 @@ internal class TestSystemA : SystemBase
|
||||
}
|
||||
}
|
||||
|
||||
[UpdateAfter(typeof(TestSystemA))]
|
||||
[UpdateAfter<TestSystemA>]
|
||||
internal class TestSystemB : SystemBase
|
||||
{
|
||||
protected override void OnInitialize(ref readonly SystemAPI systemAPI)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#if false
|
||||
|
||||
using Ghost.Core;
|
||||
using Ghost.Core.Graphics;
|
||||
using Ghost.DSL.ShaderCompiler;
|
||||
@@ -368,3 +370,4 @@ public unsafe partial class TestRenderPipeline : IRenderPipeline
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,5 @@
|
||||
#if false
|
||||
|
||||
using Ghost.Graphics.Core;
|
||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||
using Misaki.HighPerformance.LowLevel.Collections;
|
||||
@@ -41,3 +43,5 @@ internal sealed class TestRenderPipelineSettings : IRenderPipelineSettings
|
||||
return new TestRenderPayload();
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if false
|
||||
using Ghost.Core;
|
||||
using Ghost.Engine;
|
||||
using Ghost.Engine.Components;
|
||||
@@ -146,3 +147,4 @@ public class RenderExtractionSystem : ISystem
|
||||
{
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -23,7 +23,7 @@ internal static class MeshUtility
|
||||
return new float4(t.xyz, w);
|
||||
}
|
||||
|
||||
public static unsafe Result LoadMesh(string filePath, Allocator allocator, out UnsafeList<Vertex> vertices, out UnsafeList<uint> indices)
|
||||
public static unsafe Result LoadMesh(string filePath, AllocationHandle allocationHandle, out UnsafeList<Vertex> vertices, out UnsafeList<uint> indices)
|
||||
{
|
||||
vertices = default;
|
||||
indices = default;
|
||||
@@ -160,8 +160,8 @@ internal static class MeshUtility
|
||||
|
||||
MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices);
|
||||
|
||||
vertices = new UnsafeList<Vertex>((int)numUniqueVertices, allocator);
|
||||
indices = new UnsafeList<uint>((int)numIndices, allocator);
|
||||
vertices = new UnsafeList<Vertex>((int)numUniqueVertices, allocationHandle);
|
||||
indices = new UnsafeList<uint>((int)numIndices, allocationHandle);
|
||||
|
||||
var finalVertexCount = MeshOptApi.OptimizeVertexFetch(vertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex));
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ public sealed partial class GraphicsTestWindow : Window
|
||||
|
||||
private void GraphicsTestWindow_Activated(object sender, WindowActivatedEventArgs e)
|
||||
{
|
||||
#if false
|
||||
if (_isFirstActivationHandled)
|
||||
{
|
||||
return;
|
||||
@@ -138,10 +139,12 @@ public sealed partial class GraphicsTestWindow : Window
|
||||
});
|
||||
|
||||
CompositionTarget.Rendering += OnRendering;
|
||||
#endif
|
||||
}
|
||||
|
||||
private void GraphicsTestWindow_Closed(object sender, WindowEventArgs e)
|
||||
{
|
||||
#if false
|
||||
try
|
||||
{
|
||||
CompositionTarget.Rendering -= OnRendering;
|
||||
@@ -169,6 +172,7 @@ public sealed partial class GraphicsTestWindow : Window
|
||||
finally
|
||||
{
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private void SwapChainPanel_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Graphics.RHI;
|
||||
using Ghost.Graphics.Utilities;
|
||||
using Ghost.MeshOptimizer;
|
||||
using Ghost.Test.Core;
|
||||
using Ghost.Ufbx;
|
||||
using Misaki.HighPerformance.LowLevel;
|
||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||
using Misaki.HighPerformance.LowLevel.Collections;
|
||||
using Misaki.HighPerformance.LowLevel.Utilities;
|
||||
using Misaki.HighPerformance.Mathematics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace Ghost.MicroTest;
|
||||
|
||||
internal class MeshoptBenchmark : ITest
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static float4 ComputeTangent(float3 t, float3 n, float3 b)
|
||||
{
|
||||
var proj = n * math.dot(n, t);
|
||||
t = math.normalize(t - proj);
|
||||
var w = math.dot(math.cross(n.xyz, t.xyz), b.xyz) < 0.0f ? -1.0f : 1.0f;
|
||||
return new float4(t.xyz, w);
|
||||
}
|
||||
|
||||
public static unsafe Result LoadMesh(string filePath, Allocator allocator, out UnsafeList<Vertex> vertices, out UnsafeList<uint> indices)
|
||||
{
|
||||
vertices = default;
|
||||
indices = default;
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return Result.Failure("Invalid file path.");
|
||||
}
|
||||
|
||||
if (!Path.GetExtension(filePath).Equals(".obj", StringComparison.OrdinalIgnoreCase)
|
||||
&& !Path.GetExtension(filePath).Equals(".fbx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Result.Failure("Unsupported file format. Only .obj and .fbx are supported.");
|
||||
}
|
||||
|
||||
var load_Opts = new ufbx_load_opts
|
||||
{
|
||||
target_axes = ufbx_coordinate_axes.left_handed_y_up,
|
||||
obj_axes = ufbx_coordinate_axes.right_handed_y_up,
|
||||
// Force X-axis mirroring to correctly convert handedness to Left-Handed,
|
||||
// while preserving correct left/right orientation when viewed from the front.
|
||||
handedness_conversion_axis = ufbx_mirror_axis.UFBX_MIRROR_AXIS_X,
|
||||
space_conversion = ufbx_space_conversion.UFBX_SPACE_CONVERSION_MODIFY_GEOMETRY,
|
||||
};
|
||||
var error = new ufbx_error();
|
||||
|
||||
using var pool = new MemoryPool<VirtualStack, VirtualStack.CreationOptions>(new VirtualStack.CreationOptions
|
||||
{
|
||||
reserveCapacity = 256 * 1024 * 1024 // 256 MB should be enough for most models, adjust as needed. Note that this use virtual memory and does not actually consume physical memory until allocations are made.
|
||||
});
|
||||
|
||||
using var scope0 = pool.Allocator.CreateScope(pool.AllocationHandle);
|
||||
using var str = new UnsafeArray<byte>(Encoding.UTF8.GetByteCount(filePath) + 1, scope0.AllocationHandle);
|
||||
var count = Encoding.UTF8.GetBytes(filePath, str.AsSpan());
|
||||
str[count] = 0;
|
||||
|
||||
using var scene = new DisposablePtr<ufbx_scene>(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error));
|
||||
if (scene.Get() == null)
|
||||
{
|
||||
return Result.Failure(error.description.ToString());
|
||||
}
|
||||
|
||||
using var flatVertices = new UnsafeList<Vertex>(1024, scope0.AllocationHandle);
|
||||
//using var flatIndices = new UnsafeList<uint>(1024, scope0.AllocationHandle);
|
||||
|
||||
var needComputeNormals = false;
|
||||
|
||||
for (var i = 0u; i < scene.Get()->nodes.count; i++)
|
||||
{
|
||||
var node = scene.Get()->nodes.data[i];
|
||||
if (node->is_root)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var scope1 = pool.Allocator.CreateScope(pool.AllocationHandle);
|
||||
|
||||
if (node->mesh != null)
|
||||
{
|
||||
var pMesh = node->mesh;
|
||||
if (pMesh->num_faces == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var maxScratchIndices = (int)(pMesh->max_face_triangles * 3u);
|
||||
|
||||
using var triIndicesArray = new UnsafeArray<uint>(maxScratchIndices, scope1.AllocationHandle);
|
||||
|
||||
for (var j = 0u; j < pMesh->num_faces; j++)
|
||||
{
|
||||
var face = pMesh->faces.data[j];
|
||||
|
||||
var numTris = UfbxApi.TriangulateFace(triIndicesArray.AsSpan(0, maxScratchIndices), pMesh, face);
|
||||
|
||||
var totalIndices = numTris * 3;
|
||||
for (var k = 0; k < totalIndices; k++)
|
||||
{
|
||||
var ufbxTopologyIndex = triIndicesArray[k];
|
||||
|
||||
var posIdx = pMesh->vertex_position.indices.data[ufbxTopologyIndex];
|
||||
var normIdx = pMesh->vertex_normal.exists ? pMesh->vertex_normal.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||
var tanIdx = pMesh->vertex_tangent.exists ? pMesh->vertex_tangent.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||
var uvIdx = pMesh->vertex_uv.exists ? pMesh->vertex_uv.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||
var colIdx = pMesh->vertex_color.exists ? pMesh->vertex_color.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||
var btanIdx = pMesh->vertex_bitangent.exists ? pMesh->vertex_bitangent.indices.data[ufbxTopologyIndex] : uint.MaxValue;
|
||||
|
||||
var vertex = new Vertex
|
||||
{
|
||||
position = pMesh->vertex_position.values.data[posIdx],
|
||||
normal = normIdx != uint.MaxValue ? pMesh->vertex_normal.values.data[normIdx] : default,
|
||||
uv = uvIdx != uint.MaxValue ? pMesh->vertex_uv.values.data[uvIdx] : default,
|
||||
color = colIdx != uint.MaxValue ? new Color128(pMesh->vertex_color.values.data[colIdx]) : default,
|
||||
};
|
||||
|
||||
if (tanIdx != uint.MaxValue)
|
||||
{
|
||||
var t = pMesh->vertex_tangent.values.data[tanIdx];
|
||||
var n = vertex.normal;
|
||||
var b = btanIdx != uint.MaxValue ? pMesh->vertex_bitangent.values.data[btanIdx] : math.cross(n, t);
|
||||
vertex.tangent = ComputeTangent(t, n, b);
|
||||
}
|
||||
|
||||
var newIndex = (uint)flatVertices.Count;
|
||||
|
||||
flatVertices.Add(vertex);
|
||||
|
||||
if (!needComputeNormals)
|
||||
{
|
||||
needComputeNormals = normIdx == uint.MaxValue || tanIdx == uint.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var numIndices = (uint)flatVertices.Count;
|
||||
|
||||
using var weldedIndices = new UnsafeArray<uint>((int)numIndices, scope0.AllocationHandle);
|
||||
using var cachedIndices = new UnsafeArray<uint>((int)numIndices, scope0.AllocationHandle);
|
||||
|
||||
var stream = new ufbx_vertex_stream
|
||||
{
|
||||
data = flatVertices.GetUnsafePtr(),
|
||||
vertex_count = numIndices,
|
||||
vertex_size = (nuint)sizeof(Vertex)
|
||||
};
|
||||
|
||||
var numUniqueVertices = UfbxApi.GenerateIndices([stream], weldedIndices, null, &error);
|
||||
if (numUniqueVertices == 0 && error.type != ufbx_error_type.UFBX_ERROR_NONE)
|
||||
{
|
||||
return Result.Failure($"Welding failed: {error.description}");
|
||||
}
|
||||
|
||||
MeshOptApi.OptimizeVertexCache((uint*)cachedIndices.GetUnsafePtr(), (uint*)weldedIndices.GetUnsafePtr(), numIndices, numUniqueVertices);
|
||||
|
||||
vertices = new UnsafeList<Vertex>((int)numUniqueVertices, allocator);
|
||||
indices = new UnsafeList<uint>((int)numIndices, allocator);
|
||||
|
||||
var finalVertexCount = MeshOptApi.OptimizeVertexFetch(vertices.GetUnsafePtr(), (uint*)cachedIndices.GetUnsafePtr(), numIndices, flatVertices.GetUnsafePtr(), numIndices, (nuint)sizeof(Vertex));
|
||||
|
||||
vertices.UnsafeSetCount((int)finalVertexCount);
|
||||
|
||||
MemoryUtility.MemCpy(indices.GetUnsafePtr(), cachedIndices.GetUnsafePtr(), numIndices * sizeof(uint));
|
||||
indices.UnsafeSetCount((int)numIndices);
|
||||
|
||||
//if (needComputeNormals)
|
||||
//{
|
||||
// MeshBuilder.ComputeNormal(vertices, indices);
|
||||
// MeshBuilder.ComputeTangents(vertices, indices);
|
||||
//}
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
private UnsafeList<Vertex> _vertices;
|
||||
private UnsafeList<uint> _indices;
|
||||
|
||||
private ClodConfig _config;
|
||||
private ClodMesh _clodMesh;
|
||||
|
||||
public unsafe void Setup()
|
||||
{
|
||||
var opts = new AllocationManagerInitOpts
|
||||
{
|
||||
ArenaCapacity = 1024 * 1024 * 1024, // 1GB
|
||||
StackCapacity = 1024 * 1024 * 32, // 32MB
|
||||
FreeListConcurrencyLevel = Environment.ProcessorCount,
|
||||
};
|
||||
|
||||
AllocationManager.Initialize(opts);
|
||||
|
||||
LoadMesh("F:/c/SimpleRayTracer/native/assets/bunny.obj", Allocator.Persistent, out _vertices, out _indices).ThrowIfFailed();
|
||||
|
||||
_config = new ClodConfig
|
||||
{
|
||||
maxVertices = 64,
|
||||
minTriangles = 32,
|
||||
maxTriangles = 124,
|
||||
|
||||
partitionSpatial = true,
|
||||
partitionSize = 16,
|
||||
|
||||
clusterSpatial = false,
|
||||
clusterSplitFactor = 2.0f,
|
||||
|
||||
optimizeClusters = true,
|
||||
optimizeClustersLevel = 1,
|
||||
|
||||
simplifyRatio = 0.5f,
|
||||
simplifyThreshold = 0.85f,
|
||||
simplifyErrorMergePrevious = 1.0f,
|
||||
simplifyErrorFactorSloppy = 2.0f,
|
||||
simplifyPermissive = true,
|
||||
simplifyFallbackPermissive = false,
|
||||
simplifyFallbackSloppy = true,
|
||||
};
|
||||
|
||||
// 2. Map Mesh to ClodMesh
|
||||
_clodMesh = new ClodMesh
|
||||
{
|
||||
vertexPositions = (float*)Unsafe.AsPointer(ref _vertices[0].position),
|
||||
vertexCount = (nuint)_vertices.Count,
|
||||
vertexPositionsStride = (nuint)sizeof(Vertex),
|
||||
vertexAttributes = (float*)Unsafe.AsPointer(ref _vertices[0].normal),
|
||||
vertexAttributesStride = (nuint)sizeof(Vertex),
|
||||
indices = (uint*)_indices.GetUnsafePtr(),
|
||||
indexCount = (nuint)_indices.Count,
|
||||
attributeProtectMask = 0,
|
||||
};
|
||||
}
|
||||
|
||||
public unsafe void Run()
|
||||
{
|
||||
// 3. Build
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
MeshletUtility.Build(in _config, in _clodMesh, null, null);
|
||||
Console.WriteLine($"Meshlet build time: {sw.Elapsed.TotalSeconds:F3} seconds");
|
||||
}
|
||||
|
||||
public void Cleanup()
|
||||
{
|
||||
_vertices.Dispose();
|
||||
_indices.Dispose();
|
||||
|
||||
AllocationManager.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Ghost.MicroTest;
|
||||
using Ghost.Test.Core;
|
||||
|
||||
TestRunner.Run<MeshoptBenchmark>();
|
||||
//TestRunner.Run<MeshoptBenchmark>();
|
||||
Console.WriteLine();
|
||||
@@ -1,436 +0,0 @@
|
||||
#if false
|
||||
using Ghost.Core;
|
||||
|
||||
namespace Ghost.UnitTest;
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive integration tests for AssetService.
|
||||
/// Tests database operations, file system watchers, searching, importing, and race conditions.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
[DoNotParallelize] // AssetService is a singleton, tests must run sequentially
|
||||
public class AssetDatabaseIntegrationTest
|
||||
{
|
||||
private string _tempPath = string.Empty;
|
||||
private string _testProjectDir = string.Empty;
|
||||
private string _testAssetsDir = string.Empty;
|
||||
|
||||
public TestContext TestContext { get; set; }
|
||||
|
||||
[TestInitialize]
|
||||
public async Task Setup()
|
||||
{
|
||||
// Create temporary test project structure
|
||||
_tempPath = Path.GetTempPath();
|
||||
_testProjectDir = Path.Combine(_tempPath, "GhostAssetDBIntegration_" + Guid.NewGuid().ToString());
|
||||
_testAssetsDir = Path.Combine(_testProjectDir, ProjectService.ASSETS_FOLDER);
|
||||
|
||||
Directory.CreateDirectory(_testProjectDir);
|
||||
Directory.CreateDirectory(_testAssetsDir);
|
||||
Directory.CreateDirectory(Path.Combine(_testProjectDir, ProjectService.CACHE_FOLDER));
|
||||
Directory.CreateDirectory(Path.Combine(_testProjectDir, ProjectService.CONFIG_FOLDER));
|
||||
|
||||
Console.WriteLine($"Test project directory: {_testProjectDir}");
|
||||
Console.WriteLine($"Test assets directory: {_testAssetsDir}");
|
||||
|
||||
// Create a minimal project file with required metadata
|
||||
var projectPath = Path.Combine(_testProjectDir, "TestProject.gproj");
|
||||
|
||||
// Create a proper ProjectMetadata instance
|
||||
var metadata = new Ghost.Data.Models.ProjectMetadata("TestProject", new Version(1, 0, 0));
|
||||
|
||||
await using var fileStream = File.Create(projectPath);
|
||||
await System.Text.Json.JsonSerializer.SerializeAsync(fileStream, metadata, Ghost.Data.JsonContext.Default.ProjectMetadata, TestContext.CancellationToken);
|
||||
await fileStream.FlushAsync(TestContext.CancellationToken);
|
||||
fileStream.Close();
|
||||
|
||||
// Set CurrentProject directly
|
||||
var projectMetadataInfo = new Data.Models.ProjectMetadataInfo(projectPath, metadata);
|
||||
ProjectService.CurrentProject = projectMetadataInfo;
|
||||
|
||||
// Init AssetService
|
||||
await AssetService.Initialize(TestContext.CancellationToken);
|
||||
|
||||
// Give the file system watcher time to start
|
||||
await Task.Delay(100, TestContext.CancellationToken);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
// Shutdown AssetService to release file watchers
|
||||
try
|
||||
{
|
||||
AssetService.Shutdown();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore shutdown errors
|
||||
}
|
||||
|
||||
// Clean up test directory
|
||||
if (Directory.Exists(_tempPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Add delay to allow file handles to be released
|
||||
Thread.Sleep(100);
|
||||
Directory.Delete(_tempPath, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to wait for file system events to be processed.
|
||||
/// </summary>
|
||||
private async Task WaitForFileSystemEvents(int delayMs = 300)
|
||||
{
|
||||
await Task.Delay(delayMs, TestContext.CancellationToken);
|
||||
AssetService.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()
|
||||
{
|
||||
// Create a test file directly in the file system
|
||||
var testFile = Path.Combine(_testAssetsDir, "test.txt");
|
||||
await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken);
|
||||
|
||||
// Wait for file system watcher to react and process commands
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
// Check if meta file was auto-generated
|
||||
var metaFile = testFile + ".gmeta";
|
||||
Assert.IsTrue(File.Exists(metaFile), "Meta file should be auto-generated");
|
||||
|
||||
// Verify meta file content
|
||||
var metaContent = await File.ReadAllTextAsync(metaFile, TestContext.CancellationToken);
|
||||
Assert.Contains("Guid", metaContent, "Meta file should contain GUID");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestFindAssetsByName_WithWildcards()
|
||||
{
|
||||
// Create test files
|
||||
await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "player.txt"), "data", TestContext.CancellationToken);
|
||||
await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "player1.txt"), "data", TestContext.CancellationToken);
|
||||
await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "player2.txt"), "data", TestContext.CancellationToken);
|
||||
await File.WriteAllTextAsync(Path.Combine(_testAssetsDir, "enemy.txt"), "data", TestContext.CancellationToken);
|
||||
|
||||
// Wait for database to update
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
// Test wildcard search: player*
|
||||
var results = await AssetService.FindAssetsByNameAsync("player*", TestContext.CancellationToken);
|
||||
Assert.HasCount(3, results, "Should find 3 files matching 'player*'");
|
||||
|
||||
// Test single character wildcard: player?
|
||||
results = await AssetService.FindAssetsByNameAsync("player?.txt", TestContext.CancellationToken);
|
||||
Assert.HasCount(2, results, "Should find 2 files matching 'player?.txt'");
|
||||
|
||||
// Test exact match
|
||||
results = await AssetService.FindAssetsByNameAsync("enemy.txt", TestContext.CancellationToken);
|
||||
Assert.HasCount(1, results, "Should find 1 file matching 'enemy.txt'");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestFileRename_ViaFileSystem()
|
||||
{
|
||||
// Create a file
|
||||
var originalPath = Path.Combine(_testAssetsDir, "original.txt");
|
||||
await File.WriteAllTextAsync(originalPath, "data", TestContext.CancellationToken);
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
// Get the GUID before rename
|
||||
var guidResult = AssetService.PathToGuid(originalPath);
|
||||
Assert.IsTrue(guidResult.IsSuccess, "Should be able to get GUID before rename");
|
||||
var guid = guidResult.Value;
|
||||
|
||||
// Rename via file system
|
||||
var newPath = Path.Combine(_testAssetsDir, "renamed.txt");
|
||||
File.Move(originalPath, newPath);
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
// Check if meta file was also moved
|
||||
var newMetaPath = newPath + ".gmeta";
|
||||
Assert.IsTrue(File.Exists(newMetaPath), "Meta file should be moved with the asset");
|
||||
|
||||
// Verify GUID is preserved
|
||||
var newGuidResult = AssetService.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]
|
||||
public async Task TestFileDelete_ViaFileSystem()
|
||||
{
|
||||
// Create a file
|
||||
var filePath = Path.Combine(_testAssetsDir, "todelete.txt");
|
||||
await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken);
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
var guidResult = AssetService.PathToGuid(filePath);
|
||||
Assert.IsTrue(guidResult.IsSuccess);
|
||||
var guid = guidResult.Value;
|
||||
|
||||
// Delete via file system
|
||||
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");
|
||||
|
||||
// Asset should be removed from database
|
||||
var pathResult = AssetService.GuidToPath(guid);
|
||||
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestFileCreate_ViaAPI()
|
||||
{
|
||||
var filePath = Path.Combine(_testAssetsDir, "apiCreated.txt");
|
||||
|
||||
// Create via API
|
||||
var result = await AssetService.CreateAssetAsync(filePath, TestContext.CancellationToken);
|
||||
Assert.IsTrue(result.IsSuccess, "Should create asset successfully");
|
||||
|
||||
// File and meta should exist
|
||||
Assert.IsTrue(File.Exists(filePath), "Asset file should exist");
|
||||
Assert.IsTrue(File.Exists(filePath + ".gmeta"), "Meta file should exist");
|
||||
|
||||
// Should be in database
|
||||
var guidResult = AssetService.PathToGuid(filePath);
|
||||
Assert.IsTrue(guidResult.IsSuccess, "Asset should be in database");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestFileMove_ViaAPI()
|
||||
{
|
||||
// Create initial file
|
||||
var sourcePath = Path.Combine(_testAssetsDir, "source.txt");
|
||||
await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken);
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
var guid = AssetService.PathToGuid(sourcePath).Value;
|
||||
|
||||
// Create subdirectory
|
||||
var subDir = Path.Combine(_testAssetsDir, "SubFolder");
|
||||
Directory.CreateDirectory(subDir);
|
||||
|
||||
var destPath = Path.Combine(subDir, "source.txt");
|
||||
|
||||
// Move via API
|
||||
var result = await AssetService.MoveAssetAsync(sourcePath, destPath, TestContext.CancellationToken);
|
||||
Assert.IsTrue(result.IsSuccess, $"Should move asset successfully. Error: {result.Message}");
|
||||
|
||||
// Old file should not exist
|
||||
Assert.IsFalse(File.Exists(sourcePath), "Source file should not exist");
|
||||
Assert.IsFalse(File.Exists(sourcePath + ".gmeta"), "Source meta should not exist");
|
||||
|
||||
// New file should exist
|
||||
Assert.IsTrue(File.Exists(destPath), "Destination file should exist");
|
||||
Assert.IsTrue(File.Exists(destPath + ".gmeta"), "Destination meta should exist");
|
||||
|
||||
// GUID should be preserved
|
||||
var newGuid = AssetService.PathToGuid(destPath).Value;
|
||||
Assert.AreEqual(guid, newGuid, "GUID should be preserved");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestFileCopy_ViaAPI()
|
||||
{
|
||||
// Create initial file
|
||||
var sourcePath = Path.Combine(_testAssetsDir, "tocopy.txt");
|
||||
await File.WriteAllTextAsync(sourcePath, "data", TestContext.CancellationToken);
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
var sourceGuid = AssetService.PathToGuid(sourcePath).Value;
|
||||
var destPath = Path.Combine(_testAssetsDir, "copied.txt");
|
||||
|
||||
// Copy via API
|
||||
var result = await AssetService.CopyAssetAsync(sourcePath, destPath, TestContext.CancellationToken);
|
||||
Assert.IsTrue(result.IsSuccess, "Should copy asset successfully");
|
||||
|
||||
// Both files should exist
|
||||
Assert.IsTrue(File.Exists(sourcePath), "Source file should still exist");
|
||||
Assert.IsTrue(File.Exists(destPath), "Destination file should exist");
|
||||
|
||||
// Both should have different GUIDs
|
||||
var destGuid = AssetService.PathToGuid(destPath).Value;
|
||||
Assert.AreNotEqual(sourceGuid, destGuid, "Copied asset should have different GUID");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestFileDelete_ViaAPI()
|
||||
{
|
||||
// Create initial file
|
||||
var filePath = Path.Combine(_testAssetsDir, "todelete2.txt");
|
||||
await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken);
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
var guid = AssetService.PathToGuid(filePath).Value;
|
||||
|
||||
// Delete via API
|
||||
var result = await AssetService.DeleteAssetAsync(filePath, TestContext.CancellationToken);
|
||||
Assert.IsTrue(result.IsSuccess, "Should delete asset successfully");
|
||||
|
||||
// File and meta should not exist
|
||||
Assert.IsFalse(File.Exists(filePath), "File should be deleted");
|
||||
Assert.IsFalse(File.Exists(filePath + ".gmeta"), "Meta should be deleted");
|
||||
|
||||
// Should be removed from database
|
||||
var pathResult = AssetService.GuidToPath(guid);
|
||||
Assert.IsTrue(pathResult.IsFailure, "Asset should be removed from database");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestRaceCondition_MultipleFileCreations()
|
||||
{
|
||||
// Create multiple files simultaneously to test debouncing
|
||||
var tasks = new List<Task>();
|
||||
var fileNames = new List<string>();
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var fileName = $"race{i}.txt";
|
||||
fileNames.Add(fileName);
|
||||
var filePath = Path.Combine(_testAssetsDir, fileName);
|
||||
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
await File.WriteAllTextAsync(filePath, $"data{i}", TestContext.CancellationToken);
|
||||
}, TestContext.CancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
await WaitForFileSystemEvents(500); // Wait for all file system events
|
||||
|
||||
// All files should have exactly one meta file
|
||||
foreach (var fileName in fileNames)
|
||||
{
|
||||
var filePath = Path.Combine(_testAssetsDir, fileName);
|
||||
var metaPath = filePath + ".gmeta";
|
||||
|
||||
Assert.IsTrue(File.Exists(metaPath), $"Meta file should exist for {fileName}");
|
||||
|
||||
// Read meta and verify it's valid JSON
|
||||
var metaContent = await File.ReadAllTextAsync(metaPath, TestContext.CancellationToken);
|
||||
Assert.Contains("Guid", metaContent, $"Meta file should be valid for {fileName}");
|
||||
}
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestTagSearching()
|
||||
{
|
||||
// Create files and add tags
|
||||
var file1 = Path.Combine(_testAssetsDir, "tagged1.txt");
|
||||
var file2 = Path.Combine(_testAssetsDir, "tagged2.txt");
|
||||
var file3 = Path.Combine(_testAssetsDir, "untagged.txt");
|
||||
|
||||
await File.WriteAllTextAsync(file1, "data", TestContext.CancellationToken);
|
||||
await File.WriteAllTextAsync(file2, "data", TestContext.CancellationToken);
|
||||
await File.WriteAllTextAsync(file3, "data", TestContext.CancellationToken);
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
var guid1 = AssetService.PathToGuid(file1).Value;
|
||||
var guid2 = AssetService.PathToGuid(file2).Value;
|
||||
|
||||
// Add tags
|
||||
await AssetService.SetAssetTagsAsync(guid1, new List<string> { "Test", "Player" }, TestContext.CancellationToken);
|
||||
await AssetService.SetAssetTagsAsync(guid2, new List<string> { "Test", "Enemy" }, TestContext.CancellationToken);
|
||||
|
||||
// Search by tag
|
||||
var testAssets = await AssetService.FindAssetsByTagAsync("Test", TestContext.CancellationToken);
|
||||
Assert.HasCount(2, testAssets, "Should find 2 assets with 'Test' tag");
|
||||
|
||||
var playerAssets = await AssetService.FindAssetsByTagAsync("Player", TestContext.CancellationToken);
|
||||
Assert.HasCount(1, playerAssets, "Should find 1 asset with 'Player' tag");
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestRefreshAsync_DoesNotDuplicateMetadata()
|
||||
{
|
||||
// Create a file
|
||||
var filePath = Path.Combine(_testAssetsDir, "refresh.txt");
|
||||
await File.WriteAllTextAsync(filePath, "data", TestContext.CancellationToken);
|
||||
await WaitForFileSystemEvents();
|
||||
|
||||
var guid1 = AssetService.PathToGuid(filePath).Value;
|
||||
|
||||
// Call RefreshAsync multiple times
|
||||
await AssetService.RefreshAsync(TestContext.CancellationToken);
|
||||
await AssetService.RefreshAsync(TestContext.CancellationToken);
|
||||
await AssetService.RefreshAsync(TestContext.CancellationToken);
|
||||
|
||||
// GUID should remain the same
|
||||
var guid2 = AssetService.PathToGuid(filePath).Value;
|
||||
Assert.AreEqual(guid1, guid2, "GUID should not change after refresh");
|
||||
|
||||
// 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()
|
||||
{
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(_testAssetsDir, "test.txt");
|
||||
await File.WriteAllTextAsync(testFile, "Hello World", TestContext.CancellationToken);
|
||||
await AssetService.RefreshAsync(TestContext.CancellationToken); // This will cause race conditions if not handle properly because both AssetService and FileSystemWatcher are involved
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Fail(ex.Message);
|
||||
}
|
||||
|
||||
CheckInternalErrors();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
47
src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs
Normal file
47
src/Test/Ghost.UnitTest/AssetSystem/AssertRegistryTest.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Ghost.Editor.Core.AssetHandler;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Ghost.Editor.Core.Services;
|
||||
|
||||
namespace Ghost.UnitTest.AssetSystem;
|
||||
|
||||
[TestClass]
|
||||
public class AssertRegistryTest
|
||||
{
|
||||
private string _assetsRoot = null!;
|
||||
private IAssetRegistry _registry = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
var testDir = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(testDir);
|
||||
|
||||
_assetsRoot = Path.Combine(testDir, "Assets");
|
||||
_registry = new AssetRegistry(_assetsRoot);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
_registry.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestAssetRegistry_AutoImport()
|
||||
{
|
||||
var sourcePath = "test.text";
|
||||
var fullSourcePath = Path.Combine(_assetsRoot, sourcePath);
|
||||
await File.WriteAllBytesAsync(fullSourcePath, [1, 2, 3]);
|
||||
|
||||
await Task.Delay(1000); // Wait for FSW to trigger
|
||||
|
||||
var metaPath = AssetMetaIO.GetMetaPath(fullSourcePath);
|
||||
Assert.IsTrue(File.Exists(metaPath));
|
||||
|
||||
var meta = await AssetMetaIO.ReadAsync(metaPath);
|
||||
Assert.IsNotNull(meta);
|
||||
|
||||
var guid = _registry.GetAssetGuid(sourcePath);
|
||||
Assert.AreEqual(meta.Guid, guid);
|
||||
}
|
||||
}
|
||||
92
src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs
Normal file
92
src/Test/Ghost.UnitTest/AssetSystem/AssetCatalogTests.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using Ghost.Editor.Core.AssetHandler;
|
||||
using Ghost.Editor.Core.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Ghost.UnitTest.AssetSystem;
|
||||
|
||||
[TestClass]
|
||||
public class AssetCatalogTests
|
||||
{
|
||||
private string _dbPath = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
var testDir = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(testDir);
|
||||
_dbPath = Path.Combine(testDir, "AssetDB.sqlite");
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
var dir = Path.GetDirectoryName(_dbPath);
|
||||
if (dir != null && Directory.Exists(dir))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Sometimes SQLite holds a lock for a bit longer
|
||||
Thread.Sleep(100);
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestAssetCatalog_UpsertLookup()
|
||||
{
|
||||
using var catalog = new AssetCatalog(_dbPath);
|
||||
var guid = Guid.NewGuid();
|
||||
var meta = new AssetMeta { Guid = guid, HandlerVersion = 1 };
|
||||
var path = "Textures/hero.png";
|
||||
|
||||
catalog.Upsert(meta, path);
|
||||
|
||||
Assert.AreEqual(guid, catalog.GetGuid(path));
|
||||
Assert.AreEqual(path, catalog.GetSourcePath(guid));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestAssetCatalog_Dependencies()
|
||||
{
|
||||
using var catalog = new AssetCatalog(_dbPath);
|
||||
var asset1 = Guid.NewGuid();
|
||||
var asset2 = Guid.NewGuid();
|
||||
|
||||
catalog.Upsert(new AssetMeta { Guid = asset1 }, "test1.png");
|
||||
catalog.Upsert(new AssetMeta { Guid = asset2 }, "test2.png");
|
||||
|
||||
catalog.SetDependencies(asset1, stackalloc[] { asset2 });
|
||||
|
||||
var referencers = catalog.GetReferencers(asset2);
|
||||
Assert.AreEqual(1, referencers.Count);
|
||||
Assert.AreEqual(asset1, referencers[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestAssetCatalog_MarkDirtyAndImported()
|
||||
{
|
||||
using var catalog = new AssetCatalog(_dbPath);
|
||||
var guid = Guid.NewGuid();
|
||||
catalog.Upsert(new AssetMeta { Guid = guid }, "test.png");
|
||||
|
||||
var dirtyBefore = catalog.GetDirtyAssets();
|
||||
Assert.IsTrue(dirtyBefore.Exists(x => x.guid == guid));
|
||||
|
||||
catalog.MarkImported(guid, "HASH1", "HASH2");
|
||||
|
||||
var dirtyAfter = catalog.GetDirtyAssets();
|
||||
Assert.IsFalse(dirtyAfter.Exists(x => x.guid == guid));
|
||||
|
||||
catalog.MarkDirty(guid);
|
||||
|
||||
var dirtyReopened = catalog.GetDirtyAssets();
|
||||
Assert.IsTrue(dirtyReopened.Exists(x => x.guid == guid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Core.Attributes;
|
||||
using Ghost.Editor.Core.AssetHandler;
|
||||
using Ghost.Editor.Core.Contracts;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Ghost.UnitTest.AssetSystem;
|
||||
|
||||
[TestClass]
|
||||
public class AssetHandlerRegistryTests
|
||||
{
|
||||
private sealed class MockAssetSettings : IAssetSettings;
|
||||
|
||||
[CustomAssetHandler(ID = "9A5B7F56-5B5B-4C5D-9E9A-8B8B7F565B5B", SupportedExtensions = [".test"])]
|
||||
private sealed class MockAssetHandler : IAssetHandler
|
||||
{
|
||||
public ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default) => throw new NotImplementedException();
|
||||
public ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestAssetHandlerRegistry_Discovery()
|
||||
{
|
||||
// For testing we rely on TypeCache being initialized.
|
||||
// In this environment we might need to be careful about what assemblies are scanned.
|
||||
var registry = new AssetHandlerRegistry();
|
||||
|
||||
// Find existing handlers (e.g. TextureAssetHandler if it exists and has attribute)
|
||||
var pngHandler = registry.GetByExtension(".png");
|
||||
Assert.IsNotNull(pngHandler, "Should find PNG handler if registered via CustomAssetHandlerAttribute");
|
||||
|
||||
var guid = new Guid("9A5B7F56-5B5B-4C5D-9E9A-8B8B7F565B5B");
|
||||
var handlerById = registry.GetByTypeId(guid);
|
||||
// Note: MockAssetHandler might not be found if the test assembly isn't marked with [EngineAssembly]
|
||||
// or if TypeCache hasn't scanned it.
|
||||
|
||||
Assert.IsTrue(registry.GetSupportedExtensions().Any());
|
||||
}
|
||||
}
|
||||
59
src/Test/Ghost.UnitTest/AssetSystem/AssetMetaTests.cs
Normal file
59
src/Test/Ghost.UnitTest/AssetSystem/AssetMetaTests.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Ghost.Editor.Core.AssetHandler;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Ghost.UnitTest.AssetSystem;
|
||||
|
||||
[TestClass]
|
||||
public class AssetMetaTests
|
||||
{
|
||||
private string _testDir = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestAssetMeta_ReadWrite()
|
||||
{
|
||||
var metaPath = Path.Combine(_testDir, "test.png.gmeta");
|
||||
var originalMeta = new AssetMeta
|
||||
{
|
||||
Guid = Guid.NewGuid(),
|
||||
HandlerTypeId = Guid.NewGuid(),
|
||||
HandlerVersion = 1,
|
||||
Labels = ["test", "hero"]
|
||||
};
|
||||
|
||||
await AssetMetaIO.WriteAsync(metaPath, originalMeta);
|
||||
Assert.IsTrue(File.Exists(metaPath));
|
||||
|
||||
var loadedMeta = await AssetMetaIO.ReadAsync(metaPath);
|
||||
Assert.IsNotNull(loadedMeta);
|
||||
Assert.AreEqual(originalMeta.Guid, loadedMeta.Guid);
|
||||
Assert.AreEqual(originalMeta.HandlerTypeId, loadedMeta.HandlerTypeId);
|
||||
Assert.AreEqual(originalMeta.HandlerVersion, loadedMeta.HandlerVersion);
|
||||
CollectionAssert.AreEqual(originalMeta.Labels, loadedMeta.Labels);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestAssetMetaIO_Paths()
|
||||
{
|
||||
var sourcePath = "f:/assets/hero.png";
|
||||
var expectedMetaPath = "f:/assets/hero.png.gmeta";
|
||||
|
||||
Assert.AreEqual(expectedMetaPath, AssetMetaIO.GetMetaPath(sourcePath));
|
||||
Assert.AreEqual(sourcePath, AssetMetaIO.GetSourcePath(expectedMetaPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Ghost.Editor.Core.AssetHandler;
|
||||
using Ghost.Editor.Core.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Ghost.UnitTest.AssetSystem;
|
||||
|
||||
[TestClass]
|
||||
public class ImportCoordinatorTests
|
||||
{
|
||||
private string _assetsRoot = null!;
|
||||
private string _libraryRoot = null!;
|
||||
private string _dbPath = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
var testDir = Path.Combine(Path.GetTempPath(), "GhostEngineTests", Guid.NewGuid().ToString());
|
||||
_assetsRoot = Path.Combine(testDir, "Assets");
|
||||
_libraryRoot = Path.Combine(testDir, "Library");
|
||||
_dbPath = Path.Combine(_libraryRoot, "AssetDB.sqlite");
|
||||
|
||||
Directory.CreateDirectory(_assetsRoot);
|
||||
Directory.CreateDirectory(_libraryRoot);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
var dir = Path.GetDirectoryName(_libraryRoot);
|
||||
if (dir != null && Directory.Exists(dir))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestImportCoordinator_BasicImport()
|
||||
{
|
||||
using var catalog = new AssetCatalog(_dbPath);
|
||||
var handlerRegistry = new AssetHandlerRegistry(); // discovery PNG/etc
|
||||
using var coordinator = new ImportCoordinator(catalog, handlerRegistry, _assetsRoot, _libraryRoot);
|
||||
|
||||
var assetGuid = Guid.NewGuid();
|
||||
var sourcePath = "test.png";
|
||||
var fullSourcePath = Path.Combine(_assetsRoot, sourcePath);
|
||||
await File.WriteAllBytesAsync(fullSourcePath, [1, 2, 3]);
|
||||
|
||||
var meta = new AssetMeta { Guid = assetGuid };
|
||||
var metaPath = AssetMetaIO.GetMetaPath(fullSourcePath);
|
||||
await AssetMetaIO.WriteAsync(metaPath, meta);
|
||||
|
||||
catalog.Upsert(meta, sourcePath);
|
||||
|
||||
await coordinator.EnqueueAsync(new ImportJob(assetGuid, sourcePath, metaPath, ImportReason.NewAsset));
|
||||
|
||||
// Note: Waiting is tricky for async workers.
|
||||
// In a real test, we'd poll or use a completion signal.
|
||||
var timeout = 0;
|
||||
while (catalog.GetDirtyAssets().Count > 0 && timeout < 50)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
timeout++;
|
||||
}
|
||||
|
||||
var dirty = catalog.GetDirtyAssets();
|
||||
Assert.AreEqual(0, dirty.Count, "Asset should have been imported");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user