188 lines
7.5 KiB
C#
188 lines
7.5 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using FictionArchive.Service.TranslationService.Models.Enums;
|
|
using FictionArchive.Service.TranslationService.Services.TranslationEngines.NanoGpt;
|
|
using FictionArchive.Service.TranslationService.Tests.TestSupport;
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Polly;
|
|
using Xunit;
|
|
using Language = FictionArchive.Common.Enums.Language;
|
|
|
|
namespace FictionArchive.Service.TranslationService.Tests.NanoGpt;
|
|
|
|
public class NanoGptTranslationEngineTests
|
|
{
|
|
private static (NanoGptTranslationEngine engine, TestHttpMessageHandler handler)
|
|
BuildEngine(Func<HttpRequestMessage, HttpResponseMessage> responder, string modelKey = "nanogpt-test", string modelId = "test-model")
|
|
{
|
|
var handler = new TestHttpMessageHandler(responder);
|
|
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://nano-gpt.test/api/") };
|
|
var apiClient = new NanoGptApiClient(httpClient, NullLogger<NanoGptApiClient>.Instance);
|
|
var options = new NanoGptModelOptions { Key = modelKey, DisplayName = "Test", Model = modelId };
|
|
var pipeline = new ResiliencePipelineBuilder().Build(); // no-retry pipeline for deterministic tests
|
|
var engine = new NanoGptTranslationEngine(apiClient, options, pipeline, NullLogger<NanoGptTranslationEngine>.Instance);
|
|
return (engine, handler);
|
|
}
|
|
|
|
private static HttpResponseMessage JsonResponse(HttpStatusCode status, string json)
|
|
{
|
|
return new HttpResponseMessage(status)
|
|
{
|
|
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
|
};
|
|
}
|
|
|
|
private const string SuccessJson = """
|
|
{
|
|
"choices": [{"message": {"role":"assistant","content":"안녕, 세상."}}],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
|
}
|
|
""";
|
|
|
|
[Fact]
|
|
public void Descriptor_ReflectsConfiguredOptions()
|
|
{
|
|
var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson),
|
|
modelKey: "nanogpt-gpt-4o");
|
|
|
|
engine.Descriptor.Key.Should().Be("nanogpt-gpt-4o");
|
|
engine.Descriptor.DisplayName.Should().Be("Test");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTranslation_OnSuccess_ReturnsTranslatedTextAndUsage()
|
|
{
|
|
var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson));
|
|
|
|
var result = await engine.GetTranslation("Hello, world.", Language.En, Language.Kr);
|
|
|
|
result.Status.Should().Be(TranslationRequestStatus.Success);
|
|
result.TranslatedText.Should().Be("안녕, 세상.");
|
|
result.OriginalText.Should().Be("Hello, world.");
|
|
result.From.Should().Be(Language.En);
|
|
result.To.Should().Be(Language.Kr);
|
|
result.TranslationEngineKey.Should().Be("nanogpt-test");
|
|
result.BilledCharacterCount.Should().Be(15u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTranslation_PostsToChatCompletionsEndpoint()
|
|
{
|
|
var (engine, handler) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson));
|
|
|
|
await engine.GetTranslation("Hello", Language.En, Language.Kr);
|
|
|
|
handler.Requests.Should().HaveCount(1);
|
|
var request = handler.Requests[0];
|
|
request.Method.Should().Be(HttpMethod.Post);
|
|
request.RequestUri!.AbsoluteUri.Should().Be("https://nano-gpt.test/api/v1/chat/completions");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTranslation_SendsConfiguredModelAndTemperature()
|
|
{
|
|
var (engine, handler) = BuildEngine(
|
|
_ => JsonResponse(HttpStatusCode.OK, SuccessJson),
|
|
modelId: "chatgpt-4o-latest");
|
|
|
|
await engine.GetTranslation("Hello", Language.En, Language.Kr);
|
|
|
|
var body = JsonDocument.Parse(handler.RequestBodies[0]).RootElement;
|
|
body.GetProperty("model").GetString().Should().Be("chatgpt-4o-latest");
|
|
body.GetProperty("temperature").GetDouble().Should().Be(0.2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTranslation_WrapsSourceTextInDelimitersAndIncludesSystemPrompt()
|
|
{
|
|
var (engine, handler) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson));
|
|
|
|
await engine.GetTranslation("Hello", Language.En, Language.Kr);
|
|
|
|
var body = JsonDocument.Parse(handler.RequestBodies[0]).RootElement;
|
|
var messages = body.GetProperty("messages");
|
|
messages.GetArrayLength().Should().Be(2);
|
|
|
|
var system = messages[0];
|
|
system.GetProperty("role").GetString().Should().Be("system");
|
|
var systemContent = system.GetProperty("content").GetString()!;
|
|
systemContent.Should().Contain("English").And.Contain("Korean");
|
|
systemContent.Should().Contain("<source_text>").And.Contain("Output only the translated text");
|
|
|
|
var user = messages[1];
|
|
user.GetProperty("role").GetString().Should().Be("user");
|
|
user.GetProperty("content").GetString().Should().Be("<source_text>\nHello\n</source_text>");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(Language.En, "English")]
|
|
[InlineData(Language.Kr, "Korean")]
|
|
[InlineData(Language.Ch, "Chinese")]
|
|
[InlineData(Language.Ja, "Japanese")]
|
|
public async Task GetTranslation_RendersExpectedLanguageNamesInSystemPrompt(Language to, string expectedName)
|
|
{
|
|
var (engine, handler) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson));
|
|
|
|
await engine.GetTranslation("Hello", Language.En, to);
|
|
|
|
var body = JsonDocument.Parse(handler.RequestBodies[0]).RootElement;
|
|
var systemContent = body.GetProperty("messages")[0].GetProperty("content").GetString()!;
|
|
systemContent.Should().Contain(expectedName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTranslation_On4xx_ReturnsFailedResultWithoutThrowing()
|
|
{
|
|
var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.BadRequest,
|
|
"""{"error":"bad model"}"""));
|
|
|
|
var result = await engine.GetTranslation("Hello", Language.En, Language.Kr);
|
|
|
|
result.Status.Should().Be(TranslationRequestStatus.Failed);
|
|
result.TranslatedText.Should().BeNull();
|
|
result.TranslationEngineKey.Should().Be("nanogpt-test");
|
|
result.OriginalText.Should().Be("Hello");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTranslation_On5xx_ReturnsFailedResultWithoutThrowing()
|
|
{
|
|
var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.InternalServerError, "{}"));
|
|
|
|
var result = await engine.GetTranslation("Hello", Language.En, Language.Kr);
|
|
|
|
result.Status.Should().Be(TranslationRequestStatus.Failed);
|
|
result.TranslatedText.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTranslation_OnMalformedJson_ReturnsFailedResult()
|
|
{
|
|
var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, "not json"));
|
|
|
|
var result = await engine.GetTranslation("Hello", Language.En, Language.Kr);
|
|
|
|
result.Status.Should().Be(TranslationRequestStatus.Failed);
|
|
result.TranslatedText.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTranslation_OnEmptyChoices_ReturnsFailedResult()
|
|
{
|
|
const string emptyChoicesJson = """
|
|
{
|
|
"choices": [],
|
|
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
}
|
|
""";
|
|
var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, emptyChoicesJson));
|
|
|
|
var result = await engine.GetTranslation("Hello", Language.En, Language.Kr);
|
|
|
|
result.Status.Should().Be(TranslationRequestStatus.Failed);
|
|
result.TranslatedText.Should().BeNull();
|
|
}
|
|
}
|