- StoryServiceTests.cs: 536 lines, comprehensive tests for StoryService - StoryGenerationServiceTests.cs: 469 lines, tests for AI story generation - StoryUnlockServiceTests.cs: 292 lines, tests for story unlock logic - StoryRepositoryTests.cs: 120 lines, tests for EF Core repository - StoryProgressRepositoryTests.cs: 74 lines, tests for progress repository - AppDbContext.cs: Made DbSet properties virtual for Moq compatibility - Fixed return types in StoryRepository and StoryProgressRepository - Updated docs/features/story-integration.md for Phase 3 completion - All 324 unit tests pass Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
469 lines
18 KiB
C#
469 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using GermanApp.Application.DTOs;
|
|
using GermanApp.Application.Services;
|
|
using GermanApp.Domain.Entities;
|
|
using GermanApp.Domain.Interfaces;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
using Moq;
|
|
|
|
namespace GermanApp.Tests.Unit.Application.Services;
|
|
|
|
[TestClass]
|
|
public class StoryGenerationServiceTests
|
|
{
|
|
private Mock<IMistralService> _mockMistralService;
|
|
private Mock<IStoryRepository> _mockStoryRepository;
|
|
private Mock<ITtsService> _mockTtsService;
|
|
private Mock<ILogger<StoryGenerationService>> _mockLogger;
|
|
private StoryGenerationService _service;
|
|
|
|
[TestInitialize]
|
|
public void Setup()
|
|
{
|
|
_mockMistralService = new Mock<IMistralService>();
|
|
_mockStoryRepository = new Mock<IStoryRepository>();
|
|
_mockTtsService = new Mock<ITtsService>();
|
|
_mockLogger = new Mock<ILogger<StoryGenerationService>>();
|
|
|
|
_service = new StoryGenerationService(
|
|
_mockMistralService.Object,
|
|
_mockStoryRepository.Object,
|
|
_mockTtsService.Object,
|
|
_mockLogger.Object);
|
|
}
|
|
|
|
private Lesson CreateTestLesson(int id, int levelId, string title, string topic, int order)
|
|
{
|
|
var lesson = Lesson.Create(levelId, title, order, topic);
|
|
// Use reflection to set the Id since it's private set
|
|
typeof(Lesson).GetProperty("Id")?.SetValue(lesson, id);
|
|
return lesson;
|
|
}
|
|
|
|
private StorySegment CreateTestSegment(int id, int levelId, int? lessonId, string content, int order, string title, string theme)
|
|
{
|
|
var segment = StorySegment.Create(levelId, lessonId, content, order, title, theme);
|
|
typeof(StorySegment).GetProperty("Id")?.SetValue(segment, id);
|
|
return segment;
|
|
}
|
|
|
|
#region GenerateStoryAsync Tests
|
|
|
|
[TestMethod]
|
|
public async Task GenerateStoryAsync_WithValidData_ReturnsResponse()
|
|
{
|
|
// Arrange
|
|
var levelId = 1;
|
|
var theme = "Abenteuer";
|
|
var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Einfuehrung", 1);
|
|
var lesson2 = CreateTestLesson(2, 1, "Lektion 2", "Fortsetzung", 2);
|
|
var lessons = new List<Lesson> { lesson1, lesson2 };
|
|
var expectedStory = "Es war einmal ein Abenteuer...";
|
|
|
|
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
"A1", theme, It.IsAny<IReadOnlyList<string>>(), 500, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(expectedStory);
|
|
|
|
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((StorySegment s, CancellationToken ct) => s);
|
|
|
|
// Act
|
|
var result = await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
|
|
// Assert
|
|
Assert.IsNotNull(result);
|
|
Assert.AreEqual(levelId, result.LevelId);
|
|
Assert.AreEqual(theme, result.Theme);
|
|
Assert.AreEqual(2, result.SegmentCount);
|
|
Assert.AreEqual(expectedStory, result.FullStoryText);
|
|
Assert.AreEqual(2, result.Segments.Count);
|
|
_mockMistralService.Verify(m => m.GenerateStoryAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateStoryAsync_WithNoVocabulary_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var levelId = 1;
|
|
var theme = "Abenteuer";
|
|
var lessons = new List<Lesson>(); // Empty list = no vocabulary
|
|
|
|
// Act & Assert
|
|
try
|
|
{
|
|
await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Assert.IsTrue(ex.Message.Contains("No vocabulary"));
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateStoryAsync_WithEmptyResponse_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var levelId = 1;
|
|
var theme = "Abenteuer";
|
|
var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Test", 1);
|
|
var lessons = new List<Lesson> { lesson1 };
|
|
|
|
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(string.Empty);
|
|
|
|
// Act & Assert
|
|
try
|
|
{
|
|
await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Assert.IsTrue(ex.Message.Contains("Empty response"));
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateStoryAsync_WithNullResponse_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var levelId = 1;
|
|
var theme = "Abenteuer";
|
|
var lesson1 = CreateTestLesson(1, 1, "Lektion 1", "Test", 1);
|
|
var lessons = new List<Lesson> { lesson1 };
|
|
|
|
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((string?)null);
|
|
|
|
// Act & Assert
|
|
try
|
|
{
|
|
await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Assert.IsTrue(ex.Message.Contains("Empty response"));
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateStoryAsync_WithLevelA2_UsesCorrectLevelCode()
|
|
{
|
|
// Arrange
|
|
var levelId = 2;
|
|
var theme = "Abenteuer";
|
|
var lesson1 = CreateTestLesson(1, 2, "Lektion 1", "Test", 1);
|
|
var lessons = new List<Lesson> { lesson1 };
|
|
var expectedStory = "A2 Story";
|
|
|
|
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
"A2", theme, It.IsAny<IReadOnlyList<string>>(), 500, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(expectedStory);
|
|
|
|
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((StorySegment s, CancellationToken ct) => s);
|
|
|
|
// Act
|
|
var result = await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
|
|
// Assert
|
|
Assert.IsNotNull(result);
|
|
_mockMistralService.Verify(m => m.GenerateStoryAsync(
|
|
"A2", It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateStoryAsync_WithLevelB1_UsesCorrectLevelCode()
|
|
{
|
|
// Arrange
|
|
var levelId = 3;
|
|
var theme = "Abenteuer";
|
|
var lesson1 = CreateTestLesson(1, 3, "Lektion 1", "Test", 1);
|
|
var lessons = new List<Lesson> { lesson1 };
|
|
var expectedStory = "B1 Story";
|
|
|
|
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
"B1", theme, It.IsAny<IReadOnlyList<string>>(), 500, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(expectedStory);
|
|
|
|
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((StorySegment s, CancellationToken ct) => s);
|
|
|
|
// Act
|
|
var result = await _service.GenerateStoryAsync(levelId, theme, lessons);
|
|
|
|
// Assert
|
|
Assert.IsNotNull(result);
|
|
_mockMistralService.Verify(m => m.GenerateStoryAsync(
|
|
"B1", It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
#endregion
|
|
|
|
#region GenerateSegmentAsync Tests
|
|
|
|
[TestMethod]
|
|
public async Task GenerateSegmentAsync_WithValidData_ReturnsSegment()
|
|
{
|
|
// Arrange
|
|
var levelId = 1;
|
|
var lessonId = 1;
|
|
var theme = "Abenteuer";
|
|
var vocabulary = new List<string> { "Haus", "Hund", "Katze" };
|
|
var order = 1;
|
|
var expectedContent = "Ein kurzer Text...";
|
|
|
|
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
"A1", theme, vocabulary, 100, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(expectedContent);
|
|
|
|
var createdSegment = CreateTestSegment(100, levelId, lessonId, expectedContent, order,
|
|
$"{theme} - Part {order}", theme);
|
|
|
|
_mockStoryRepository.Setup(s => s.AddAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(createdSegment);
|
|
|
|
// Act
|
|
var result = await _service.GenerateSegmentAsync(levelId, lessonId, theme, vocabulary, order);
|
|
|
|
// Assert
|
|
Assert.IsNotNull(result);
|
|
Assert.AreEqual(100, result.Id);
|
|
Assert.AreEqual(expectedContent, result.Content);
|
|
Assert.AreEqual($"{theme} - Part {order}", result.Title);
|
|
_mockMistralService.Verify(m => m.GenerateStoryAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateSegmentAsync_WithNoVocabulary_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var levelId = 1;
|
|
var lessonId = 1;
|
|
var theme = "Abenteuer";
|
|
var vocabulary = new List<string>();
|
|
var order = 1;
|
|
|
|
// Act & Assert
|
|
try
|
|
{
|
|
await _service.GenerateSegmentAsync(levelId, lessonId, theme, vocabulary, order);
|
|
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Assert.IsTrue(ex.Message.Contains("No vocabulary"));
|
|
}
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateSegmentAsync_WithEmptyResponse_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var levelId = 1;
|
|
var lessonId = 1;
|
|
var theme = "Abenteuer";
|
|
var vocabulary = new List<string> { "Test" };
|
|
var order = 1;
|
|
|
|
_mockMistralService.Setup(m => m.GenerateStoryAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(string.Empty);
|
|
|
|
// Act & Assert
|
|
try
|
|
{
|
|
await _service.GenerateSegmentAsync(levelId, lessonId, theme, vocabulary, order);
|
|
Assert.Fail("Expected InvalidOperationException was not thrown");
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Assert.IsTrue(ex.Message.Contains("Empty response"));
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region GenerateAudioAsync Tests
|
|
|
|
[TestMethod]
|
|
public async Task GenerateAudioAsync_WithExistingSegmentAndNoAudio_GeneratesAudio()
|
|
{
|
|
// Arrange
|
|
var segmentId = 1;
|
|
var segment = CreateTestSegment(segmentId, 1, 1, "Test content", 1, "Test Title", "Test Theme");
|
|
|
|
_mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(segment);
|
|
|
|
_mockTtsService.Setup(t => t.GenerateAudioToFileAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync("/audio/story/level1-segment1.wav");
|
|
|
|
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
// Act
|
|
var result = await _service.GenerateAudioAsync(segmentId);
|
|
|
|
// Assert
|
|
Assert.IsNotNull(result);
|
|
Assert.AreEqual("/audio/story/level1-segment1.wav", result.AudioUrl);
|
|
_mockTtsService.Verify(t => t.GenerateAudioToFileAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateAudioAsync_WithNonExistingSegment_ReturnsNull()
|
|
{
|
|
// Arrange
|
|
var segmentId = 999;
|
|
|
|
_mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((StorySegment?)null);
|
|
|
|
// Act
|
|
var result = await _service.GenerateAudioAsync(segmentId);
|
|
|
|
// Assert
|
|
Assert.IsNull(result);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateAudioAsync_WithExistingAudio_ReturnsExisting()
|
|
{
|
|
// Arrange
|
|
var segmentId = 1;
|
|
var segment = CreateTestSegment(segmentId, 1, 1, "Test content", 1, "Test Title", "Test Theme");
|
|
segment.UpdateAudioUrl("/audio/existing.wav");
|
|
|
|
_mockStoryRepository.Setup(s => s.GetByIdAsync(segmentId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(segment);
|
|
|
|
// Act
|
|
var result = await _service.GenerateAudioAsync(segmentId);
|
|
|
|
// Assert
|
|
Assert.IsNotNull(result);
|
|
Assert.AreEqual("/audio/existing.wav", result.AudioUrl);
|
|
_mockTtsService.Verify(t => t.GenerateAudioToFileAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
#endregion
|
|
|
|
#region GenerateAudioForAllSegmentsAsync Tests
|
|
|
|
[TestMethod]
|
|
public async Task GenerateAudioForAllSegmentsAsync_WithSegmentsNeedingAudio_GeneratesAll()
|
|
{
|
|
// Arrange
|
|
var segment1 = CreateTestSegment(1, 1, 1, "Content 1", 1, "Title 1", "Theme");
|
|
var segment2 = CreateTestSegment(2, 1, 2, "Content 2", 2, "Title 2", "Theme");
|
|
|
|
_mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
|
|
|
|
_mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2);
|
|
|
|
_mockTtsService.Setup(t => t.GenerateAudioToFileAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((string text, string outputPath, string? speaker, string language, CancellationToken ct) => outputPath);
|
|
|
|
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
// Act
|
|
var results = await _service.GenerateAudioForAllSegmentsAsync();
|
|
|
|
// Assert
|
|
Assert.IsNotNull(results);
|
|
Assert.AreEqual(2, results.Count);
|
|
_mockTtsService.Verify(t => t.GenerateAudioToFileAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateAudioForAllSegmentsAsync_WithLevelFilter_FiltersByLevel()
|
|
{
|
|
// Arrange
|
|
var segment1 = CreateTestSegment(1, 1, 1, "Content 1", 1, "Title 1", "Theme");
|
|
var segment2 = CreateTestSegment(2, 2, 1, "Content 2", 1, "Title 2", "Theme");
|
|
|
|
_mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
|
|
|
|
_mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2);
|
|
|
|
_mockTtsService.Setup(t => t.GenerateAudioToFileAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((string text, string outputPath, string? speaker, string language, CancellationToken ct) => outputPath);
|
|
|
|
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
// Act
|
|
var results = await _service.GenerateAudioForAllSegmentsAsync(levelId: 1);
|
|
|
|
// Assert
|
|
Assert.IsNotNull(results);
|
|
Assert.AreEqual(1, results.Count);
|
|
Assert.AreEqual(1, results[0].LevelId);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateAudioForAllSegmentsAsync_WithNoSegments_ReturnsEmpty()
|
|
{
|
|
// Arrange
|
|
_mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<StorySegment>());
|
|
|
|
// Act
|
|
var results = await _service.GenerateAudioForAllSegmentsAsync();
|
|
|
|
// Assert
|
|
Assert.IsNotNull(results);
|
|
Assert.AreEqual(0, results.Count);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task GenerateAudioForAllSegmentsAsync_WhenOneFails_ContinuesWithOthers()
|
|
{
|
|
// Arrange
|
|
var segment1 = CreateTestSegment(1, 1, 1, "Content 1", 1, "Title 1", "Theme");
|
|
var segment2 = CreateTestSegment(2, 1, 2, "Content 2", 2, "Title 2", "Theme");
|
|
|
|
_mockStoryRepository.Setup(s => s.GetSegmentsNeedingAudioAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<StorySegment> { segment1, segment2 });
|
|
|
|
_mockStoryRepository.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((int id, CancellationToken ct) => id == 1 ? segment1 : segment2);
|
|
|
|
// First call succeeds, second throws
|
|
_mockTtsService.SetupSequence(t => t.GenerateAudioToFileAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync("/audio/test1.wav")
|
|
.ThrowsAsync(new Exception("TTS Error"));
|
|
|
|
_mockStoryRepository.Setup(s => s.UpdateAsync(It.IsAny<StorySegment>(), It.IsAny<CancellationToken>()))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
// Act
|
|
var results = await _service.GenerateAudioForAllSegmentsAsync();
|
|
|
|
// Assert
|
|
Assert.IsNotNull(results);
|
|
Assert.AreEqual(1, results.Count); // Only first one succeeded
|
|
Assert.AreEqual(1, results[0].Id);
|
|
}
|
|
#endregion
|
|
}
|