Files
FictionArchive/FictionArchive.Service.TranslationService.Tests/NanoGpt/NanoGptTranslationEngineTests.cs
gamer147 d5a9529d6f
Some checks failed
CI / build-backend (pull_request) Failing after 1m48s
CI / build-frontend (pull_request) Successful in 1m27s
[FA-misc] Translation engine work
2026-08-25 10:38:27 -04:00

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();
}
}