Files
2026-07-13 12:49:17 +08:00

211 lines
10 KiB
C#

using System;
using System.IO;
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Meshy adapter against a <see cref="FakeHttpTransport"/> — no network.
/// Asserts request shaping (endpoint, method, Bearer scheme), the task id field, and
/// poll-state mapping. The bearer secret is never asserted beyond the "Bearer " prefix.
/// </summary>
public class MeshyAdapterTests
{
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Submit_PostsTextEndpoint_WithBearerHeader_AndReturnsResultId()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"task_meshy_1\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "a brass lantern", Format = "glb" };
string taskId = adapter.SubmitAsync(req, "msy_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("task_meshy_1", taskId);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/openapi/v2/text-to-3d", rec.Url);
Assert.AreEqual("POST", rec.Method);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Bearer ", rec.Headers["Authorization"]);
string body = Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("a brass lantern", body);
StringAssert.Contains("preview", body);
}
[Test]
public void Poll_Succeeded_ReturnsGlbUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json(
"{\"status\":\"SUCCEEDED\",\"progress\":100," +
"\"model_urls\":{\"glb\":\"https://assets.meshy.ai/model.glb\",\"fbx\":\"https://assets.meshy.ai/model.fbx\"}}")
};
var adapter = new MeshyAdapter();
// Texture=false -> single-phase (no refine), so a SUCCEEDED preview surfaces directly.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Format = "glb", Texture = false };
adapter.SubmitAsync(req, "k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://assets.meshy.ai/model.glb", res.DownloadUrl);
Assert.AreEqual(1f, res.Progress, 0.001f);
}
[Test]
public void Submit_NoTaskIdInResponse_ErrorIncludesBody()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"message\":\"quota exceeded\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Texture = false };
var ex = Assert.Throws<System.Exception>(() =>
adapter.SubmitAsync(req, "k", fake, CancellationToken.None).GetAwaiter().GetResult());
StringAssert.Contains("quota exceeded", ex.Message);
}
[Test]
public void Poll_InProgress_ReturnsRunning_WithProgress()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"IN_PROGRESS\",\"progress\":37}") };
var adapter = new MeshyAdapter();
// Submit single-phase (Texture=false) so progress is reported raw (not split across refine).
adapter.SubmitAsync(
new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Texture = false },
"k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, res.State);
Assert.AreEqual(0.37f, res.Progress, 0.001f);
}
[Test]
public void Submit_ImageMode_PollsV1ImageEndpoint()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"img1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = _ => Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/i.glb\"}}")
};
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImageUrl = "https://ex.com/ref.png", Format = "glb" };
string id = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("img1", id);
StringAssert.Contains("/openapi/v1/image-to-3d", submitFake.RecordedRequests[0].Url);
ProviderPollResult res = adapter.PollAsync(id, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
// Image tasks must be polled at the v1 image endpoint, not the v2 text endpoint.
StringAssert.Contains("/openapi/v1/image-to-3d/img1", pollFake.RecordedRequests[0].Url);
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://m/i.glb", res.DownloadUrl);
}
[Test]
public void Submit_ImageMode_LocalPath_SendsDataUri()
{
string rel = WriteProjectFile("Assets/Generated/__assetgen_meshy_adapter/ref.png", new byte[] { 137, 80, 78, 71 });
try
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImagePath = rel, Format = "glb" };
adapter.SubmitAsync(req, "k", fake, CancellationToken.None).GetAwaiter().GetResult();
HttpRequestSpec rec = fake.RecordedRequests[0];
StringAssert.Contains("/openapi/v1/image-to-3d", rec.Url);
StringAssert.Contains("data:image/png;base64,", Encoding.UTF8.GetString(rec.Body));
}
finally { try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_meshy_adapter"), true); } catch { } }
}
[Test]
public void TextWithTexture_PreviewSucceeded_SubmitsRefine_ThenReturnsTexturedModel()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"prev1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = spec => spec.Method == "POST"
? Json("{\"result\":\"refine1\"}") // refine submit
: Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/refined.glb\"}}")
};
var adapter = new MeshyAdapter();
// Texture defaults to true -> two-phase preview+refine.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "a chair", Format = "glb" };
string previewId = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("prev1", previewId);
// Poll #1: preview SUCCEEDED -> adapter submits a refine task and reports Running.
ProviderPollResult p1 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, p1.State);
bool refinePosted = false;
foreach (HttpRequestSpec r in pollFake.RecordedRequests)
{
if (r.Method == "POST" && r.Body != null)
{
string b = Encoding.UTF8.GetString(r.Body);
if (b.Contains("refine") && b.Contains("prev1")) refinePosted = true;
}
}
Assert.IsTrue(refinePosted, "expected a refine POST carrying the preview_task_id");
// Poll #2: the refine task SUCCEEDED -> textured model url surfaced.
ProviderPollResult p2 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, p2.State);
Assert.AreEqual("https://m/refined.glb", p2.DownloadUrl);
}
[Test]
public void Poll_Failed_MapsFailed_WithError()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"status\":\"FAILED\",\"progress\":0,\"task_error\":{\"message\":\"render error\"}}")
};
var adapter = new MeshyAdapter();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, res.State);
Assert.IsNotEmpty(res.Error);
}
}
}