- Create higher-level AI services: - StoryGenerationService (uses MistralService) - WritingFeedbackService (uses MistralService) - SpeechExerciseService (uses VoskService) - AudioGenerationService (uses TtsService) - AiFallbackService (fallback mechanisms for service failures) - Register AiFallbackService in Program.cs DI container - Add comprehensive unit tests for all Phase 5 services: - AiFallbackServiceTests (14 tests) - AudioGenerationServiceTests (16 tests) - MistralServiceTests (16 tests) - SpeechExerciseServiceTests (12 tests) - StoryGenerationServiceTests (13 tests) - WritingFeedbackServiceTests (11 tests) - VoskServiceTests (15 tests) - TtsServiceTests (21 tests) - Update feature document (ai-services.md) to mark Phase 5 as complete Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
543 lines
16 KiB
C#
543 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using GermanApp.Application.Models;
|
|
using GermanApp.Application.Services;
|
|
using GermanApp.Domain.Interfaces;
|
|
using GermanApp.Infrastructure.Configuration;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
using Moq;
|
|
|
|
namespace GermanApp.Tests.Unit.Application.Services;
|
|
|
|
[TestClass]
|
|
public class MistralServiceTests
|
|
{
|
|
private Mock<IMistralConnector> _mockConnector;
|
|
private Mock<IOptions<MistralConfig>> _mockConfigOptions;
|
|
private MistralConfig _config;
|
|
private MistralService _service;
|
|
|
|
[TestInitialize]
|
|
public void Setup()
|
|
{
|
|
_mockConnector = new Mock<IMistralConnector>();
|
|
_mockConfigOptions = new Mock<IOptions<MistralConfig>>();
|
|
_config = new MistralConfig
|
|
{
|
|
ApiKey = "test-api-key",
|
|
BaseUrl = "https://api.mistral.ai/v1/",
|
|
DefaultModel = "mistral-medium",
|
|
TimeoutSeconds = 30,
|
|
MaxRetries = 3
|
|
};
|
|
_mockConfigOptions.Setup(c => c.Value).Returns(_config);
|
|
|
|
_service = new MistralService(_mockConnector.Object, _mockConfigOptions.Object);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateTextAsync_WithValidPrompt_ReturnsText()
|
|
{
|
|
// Arrange
|
|
var prompt = "Tell me a joke";
|
|
var expectedText = "Why did the chicken cross the road?";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Model = "mistral-medium",
|
|
Choices = new List<MistralChoice>
|
|
{
|
|
new MistralChoice { Text = expectedText, Index = 0 }
|
|
},
|
|
Usage = new MistralUsage()
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.Is<MistralRequest>(r => r.Prompt == prompt && r.Model == "mistral-medium"),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateTextAsync(prompt, null, 0.7f, null, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(expectedText, result);
|
|
_mockConnector.Verify(c => c.CompleteAsync(
|
|
It.IsAny<MistralRequest>(), cancellationToken), Times.Once);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateTextAsync_WithCustomModel_UsesCustomModel()
|
|
{
|
|
// Arrange
|
|
var prompt = "Tell me a story";
|
|
var customModel = "mistral-small";
|
|
var expectedText = "Once upon a time...";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.Is<MistralRequest>(r => r.Model == customModel),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateTextAsync(prompt, customModel, 0.7f, null, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(expectedText, result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateTextAsync_WithCustomParameters_UsesParameters()
|
|
{
|
|
// Arrange
|
|
var prompt = "Test";
|
|
var temperature = 0.9f;
|
|
var maxTokens = 100;
|
|
var expectedText = "Test response";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.Is<MistralRequest>(r =>
|
|
r.Temperature == temperature &&
|
|
r.MaxTokens == maxTokens),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateTextAsync(
|
|
prompt, null, temperature, maxTokens, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(expectedText, result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateTextAsync_WithNoChoices_ThrowsInvalidOperationException()
|
|
{
|
|
// Arrange
|
|
var prompt = "Test";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice>()
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.IsAny<MistralRequest>(),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act & Assert
|
|
try
|
|
{
|
|
await _service.GenerateTextAsync(prompt, null, 0.7f, null, cancellationToken);
|
|
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
// Expected
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateTextAsync_WithNullTextInChoice_ReturnsEmptyString()
|
|
{
|
|
// Arrange
|
|
var prompt = "Test";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice> { new MistralChoice { Text = null } }
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.IsAny<MistralRequest>(),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateTextAsync(prompt, null, 0.7f, null, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(string.Empty, result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateTextAsync_WhenConnectorThrows_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var prompt = "Test";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.IsAny<MistralRequest>(),
|
|
cancellationToken))
|
|
.ThrowsAsync(new Exception("API Error"));
|
|
|
|
// Act & Assert
|
|
try
|
|
{
|
|
await _service.GenerateTextAsync(prompt, null, 0.7f, null, cancellationToken);
|
|
Assert.Fail("Expected Exception was not thrown");
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Expected
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateChatAsync_WithValidMessages_ReturnsResponse()
|
|
{
|
|
// Arrange
|
|
var messages = new List<(string role, string content)>
|
|
{
|
|
("system", "You are a helpful assistant"),
|
|
("user", "Hello")
|
|
};
|
|
var expectedResponse = "Hi there! How can I help you?";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Model = "mistral-medium",
|
|
Choices = new List<MistralChoice>
|
|
{
|
|
new MistralChoice
|
|
{
|
|
Index = 0,
|
|
Message = new MistralChatMessage { Role = "assistant", Content = expectedResponse }
|
|
}
|
|
},
|
|
Usage = new MistralUsage()
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.ChatAsync(
|
|
It.Is<MistralChatRequest>(r => r.Messages.Count == 2),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateChatAsync(messages, null, 0.7f, null, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(expectedResponse, result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateChatAsync_WithNoChoices_ThrowsInvalidOperationException()
|
|
{
|
|
// Arrange
|
|
var messages = new List<(string role, string content)>
|
|
{
|
|
("user", "Hello")
|
|
};
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice>()
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.ChatAsync(
|
|
It.IsAny<MistralChatRequest>(),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act & Assert
|
|
try
|
|
{
|
|
await _service.GenerateChatAsync(messages, null, 0.7f, null, cancellationToken);
|
|
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
// Expected
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateChatAsync_WithNullMessage_ReturnsEmpty()
|
|
{
|
|
// Arrange
|
|
var messages = new List<(string role, string content)>
|
|
{
|
|
("user", "Hello")
|
|
};
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice>
|
|
{
|
|
new MistralChoice { Message = null }
|
|
}
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.ChatAsync(
|
|
It.IsAny<MistralChatRequest>(),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateChatAsync(messages, null, 0.7f, null, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(string.Empty, result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateStoryAsync_WithValidParameters_ReturnsStory()
|
|
{
|
|
// Arrange
|
|
var level = "A1";
|
|
var topic = "Travel";
|
|
var vocabularyWords = new List<string> { "Bahn", "Reise", "Stadt" };
|
|
var length = 200;
|
|
var expectedStory = "Es war einmal eine Reise mit der Bahn...";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedStory } }
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.Is<MistralRequest>(r =>
|
|
r.Model == "mistral-medium" &&
|
|
r.Temperature == 0.8f &&
|
|
r.MaxTokens == 1000),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateStoryAsync(
|
|
level, topic, vocabularyWords, length, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(expectedStory, result);
|
|
_mockConnector.Verify(c => c.CompleteAsync(
|
|
It.IsAny<MistralRequest>(), cancellationToken), Times.Once);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateStoryAsync_WithEmptyVocabulary_ReturnsStory()
|
|
{
|
|
// Arrange
|
|
var level = "A1";
|
|
var topic = "Travel";
|
|
var vocabularyWords = new List<string>();
|
|
var length = 200;
|
|
var expectedStory = "A story without specific vocabulary...";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedStory } }
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.IsAny<MistralRequest>(),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateStoryAsync(
|
|
level, topic, vocabularyWords, length, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(expectedStory, result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateWritingFeedbackAsync_WithValidParameters_ReturnsFeedback()
|
|
{
|
|
// Arrange
|
|
var userText = "Ich heisse Anna und wohne in Berlin.";
|
|
var level = "A1";
|
|
var expectedFeedback = "Great job! Your sentence structure is correct.";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice>
|
|
{
|
|
new MistralChoice
|
|
{
|
|
Message = new MistralChatMessage { Content = expectedFeedback }
|
|
}
|
|
}
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.ChatAsync(
|
|
It.Is<MistralChatRequest>(r =>
|
|
r.Model == "mistral-medium" &&
|
|
r.Temperature == 0.3f &&
|
|
r.MaxTokens == 800 &&
|
|
r.Messages.Count == 3),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateWritingFeedbackAsync(
|
|
userText, level, null, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(expectedFeedback, result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateWritingFeedbackAsync_WithCustomPrompt_ReturnsFeedback()
|
|
{
|
|
// Arrange
|
|
var userText = "Ich heisse Anna.";
|
|
var level = "A1";
|
|
var customPrompt = "Focus on grammar mistakes";
|
|
var expectedFeedback = "Grammar: You should use 'heiße' instead of 'heisse'";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice>
|
|
{
|
|
new MistralChoice
|
|
{
|
|
Message = new MistralChatMessage { Content = expectedFeedback }
|
|
}
|
|
}
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.ChatAsync(
|
|
It.IsAny<MistralChatRequest>(),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.GenerateWritingFeedbackAsync(
|
|
userText, level, customPrompt, cancellationToken);
|
|
|
|
// Assert
|
|
Assert.AreEqual(expectedFeedback, result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task TestConnectionAsync_WithSuccessfulResponse_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var expectedText = "test successful";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.Is<MistralRequest>(r => r.MaxTokens == 10),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.TestConnectionAsync(cancellationToken);
|
|
|
|
// Assert
|
|
Assert.IsTrue(result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task TestConnectionAsync_WithDifferentCaseResponse_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var expectedText = "TEST SUCCESSFUL";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.IsAny<MistralRequest>(),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.TestConnectionAsync(cancellationToken);
|
|
|
|
// Assert
|
|
Assert.IsTrue(result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task TestConnectionAsync_WithWrongResponse_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var expectedText = "Wrong response";
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
var response = new MistralResponse
|
|
{
|
|
Choices = new List<MistralChoice> { new MistralChoice { Text = expectedText } }
|
|
};
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.IsAny<MistralRequest>(),
|
|
cancellationToken))
|
|
.ReturnsAsync(response);
|
|
|
|
// Act
|
|
var result = await _service.TestConnectionAsync(cancellationToken);
|
|
|
|
// Assert
|
|
Assert.IsFalse(result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task TestConnectionAsync_WhenConnectorThrows_ReturnsFalse()
|
|
{
|
|
// Arrange
|
|
var cancellationToken = CancellationToken.None;
|
|
|
|
_mockConnector.Setup(c => c.CompleteAsync(
|
|
It.IsAny<MistralRequest>(),
|
|
cancellationToken))
|
|
.ThrowsAsync(new Exception("API Error"));
|
|
|
|
// Act
|
|
var result = await _service.TestConnectionAsync(cancellationToken);
|
|
|
|
// Assert
|
|
Assert.IsFalse(result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void BuildStoryPrompt_WithValidParameters_ReturnsPrompt()
|
|
{
|
|
// This is a private method, but we can test it indirectly through GenerateStoryAsync
|
|
// The test above already verifies the prompt building works
|
|
}
|
|
|
|
[TestMethod]
|
|
public void BuildFeedbackSystemPrompt_WithValidParameters_ReturnsPrompt()
|
|
{
|
|
// This is a private method, but we can test it indirectly through GenerateWritingFeedbackAsync
|
|
// The test above already verifies the prompt building works
|
|
}
|
|
}
|