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 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.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.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("").And.Contain("Output only the translated text"); var user = messages[1]; user.GetProperty("role").GetString().Should().Be("user"); user.GetProperty("content").GetString().Should().Be("\nHello\n"); } [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(); } }