336 lines
12 KiB
C#
336 lines
12 KiB
C#
using DramaLing.Api.Models.Entities;
|
||
using DramaLing.Api.Services;
|
||
using Microsoft.Extensions.Logging;
|
||
using Moq;
|
||
|
||
namespace DramaLing.Api.Tests.Services;
|
||
|
||
/// <summary>
|
||
/// QuestionGeneratorService 整合測試
|
||
/// </summary>
|
||
public class QuestionGeneratorServiceTests : TestBase
|
||
{
|
||
private readonly QuestionGeneratorService _questionService;
|
||
private readonly OptionsVocabularyService _optionsService;
|
||
private readonly Mock<ILogger<QuestionGeneratorService>> _mockQuestionLogger;
|
||
private readonly Mock<ILogger<OptionsVocabularyService>> _mockOptionsLogger;
|
||
|
||
public QuestionGeneratorServiceTests()
|
||
{
|
||
_mockQuestionLogger = CreateMockLogger<QuestionGeneratorService>();
|
||
_mockOptionsLogger = CreateMockLogger<OptionsVocabularyService>();
|
||
|
||
_optionsService = new OptionsVocabularyService(DbContext, MemoryCache, _mockOptionsLogger.Object);
|
||
_questionService = new QuestionGeneratorService(DbContext, _optionsService, _mockQuestionLogger.Object);
|
||
}
|
||
|
||
#region Vocab Choice Tests
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_VocabChoice_WithSufficientVocabulary_ShouldUseSmartOptions()
|
||
{
|
||
// Arrange
|
||
await SeedTestData();
|
||
var flashcard = CreateTestFlashcard("beautiful", "B1", "adjective");
|
||
|
||
// Act
|
||
var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice");
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.QuestionType.Should().Be("vocab-choice");
|
||
result.Options.Should().HaveCount(4); // 正確答案 + 3個干擾選項
|
||
result.Options.Should().Contain("beautiful");
|
||
result.CorrectAnswer.Should().Be("beautiful");
|
||
|
||
// 驗證干擾選項來自智能詞彙庫
|
||
var distractors = result.Options!.Where(o => o != "beautiful").ToList();
|
||
distractors.Should().HaveCount(3);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_VocabChoice_WithInsufficientVocabulary_ShouldUseFallback()
|
||
{
|
||
// Arrange
|
||
await SeedLimitedData();
|
||
var flashcard = CreateTestFlashcard("target", "C2", "idiom"); // 使用較少見的組合
|
||
|
||
// Act
|
||
var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice");
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.QuestionType.Should().Be("vocab-choice");
|
||
result.Options.Should().HaveCount(4); // 仍應該有4個選項(使用回退機制)
|
||
result.CorrectAnswer.Should().Be("target");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_VocabChoice_ShouldUseFlashcardProperties()
|
||
{
|
||
// Arrange
|
||
await SeedTestData();
|
||
var flashcard = CreateTestFlashcard("computer", "B2", "noun");
|
||
|
||
// Act
|
||
var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice");
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.CorrectAnswer.Should().Be("computer");
|
||
|
||
// 驗證使用了 Flashcard 的 DifficultyLevel 和 PartOfSpeech
|
||
// 這可以通過檢查生成的干擾選項是否符合相應的 CEFR 等級和詞性來間接驗證
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_VocabChoice_WithNullFlashcardProperties_ShouldUseDefaults()
|
||
{
|
||
// Arrange
|
||
await SeedTestData();
|
||
var flashcard = CreateTestFlashcard("test", null, null); // 空的屬性
|
||
|
||
// Act
|
||
var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice");
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.QuestionType.Should().Be("vocab-choice");
|
||
result.CorrectAnswer.Should().Be("test");
|
||
// 應該使用預設值 B1 和 noun
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Fill Blank Tests
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_FillBlank_WithValidExample_ShouldCreateBlankedSentence()
|
||
{
|
||
// Arrange
|
||
var flashcard = CreateTestFlashcard("beautiful", "B1", "adjective", "This is a beautiful flower.");
|
||
|
||
// Act
|
||
var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "sentence-fill");
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.QuestionType.Should().Be("sentence-fill");
|
||
result.BlankedSentence.Should().Contain("______");
|
||
result.BlankedSentence.Should().NotContain("beautiful");
|
||
result.CorrectAnswer.Should().Be("beautiful");
|
||
result.Sentence.Should().Be("This is a beautiful flower.");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_FillBlank_WithoutExample_ShouldThrowException()
|
||
{
|
||
// Arrange
|
||
var flashcard = CreateTestFlashcard("test", "B1", "noun", null);
|
||
|
||
// Act & Assert
|
||
await Assert.ThrowsAsync<ArgumentException>(
|
||
() => _questionService.GenerateQuestionAsync(flashcard.Id, "sentence-fill"));
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Sentence Reorder Tests
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_SentenceReorder_ShouldScrambleWords()
|
||
{
|
||
// Arrange
|
||
var flashcard = CreateTestFlashcard("test", "B1", "noun", "This is a simple test sentence.");
|
||
|
||
// Act
|
||
var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "sentence-reorder");
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.QuestionType.Should().Be("sentence-reorder");
|
||
result.ScrambledWords.Should().NotBeNull();
|
||
result.ScrambledWords!.Length.Should().BeGreaterThan(0);
|
||
result.CorrectAnswer.Should().Be("This is a simple test sentence.");
|
||
|
||
// 驗證包含所有單字(忽略順序)
|
||
var originalWords = "This is a simple test sentence."
|
||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(w => w.Trim('.', ',', '!', '?', ';', ':'));
|
||
|
||
foreach (var word in originalWords)
|
||
{
|
||
result.ScrambledWords.Should().Contain(word);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Sentence Listening Tests
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_SentenceListening_ShouldProvideOptions()
|
||
{
|
||
// Arrange
|
||
await SeedFlashcardsWithExamples();
|
||
var flashcard = CreateTestFlashcard("test", "B1", "noun", "This is a test sentence.");
|
||
|
||
// Act
|
||
var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "sentence-listening");
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.QuestionType.Should().Be("sentence-listening");
|
||
result.Options.Should().HaveCount(4);
|
||
result.Options.Should().Contain("This is a test sentence.");
|
||
result.CorrectAnswer.Should().Be("This is a test sentence.");
|
||
result.AudioUrl.Should().Contain(flashcard.Id.ToString());
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Error Handling Tests
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_WithNonExistentFlashcard_ShouldThrowException()
|
||
{
|
||
// Act & Assert
|
||
await Assert.ThrowsAsync<ArgumentException>(
|
||
() => _questionService.GenerateQuestionAsync(Guid.NewGuid(), "vocab-choice"));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_WithUnsupportedQuestionType_ShouldReturnBasicQuestion()
|
||
{
|
||
// Arrange
|
||
var flashcard = CreateTestFlashcard("test", "B1", "noun");
|
||
|
||
// Act
|
||
var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "unsupported-type");
|
||
|
||
// Assert
|
||
result.Should().NotBeNull();
|
||
result.QuestionType.Should().Be("unsupported-type");
|
||
result.CorrectAnswer.Should().Be("test");
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Performance Tests
|
||
|
||
[Fact]
|
||
public async Task GenerateQuestionAsync_ShouldCompleteWithinReasonableTime()
|
||
{
|
||
// Arrange
|
||
await SeedTestData();
|
||
var flashcard = CreateTestFlashcard("performance", "B1", "noun");
|
||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||
|
||
// Act
|
||
var result = await _questionService.GenerateQuestionAsync(flashcard.Id, "vocab-choice");
|
||
|
||
// Assert
|
||
stopwatch.Stop();
|
||
result.Should().NotBeNull();
|
||
stopwatch.ElapsedMilliseconds.Should().BeLessThan(500); // 應在500ms內完成
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Helper Methods
|
||
|
||
private async Task SeedTestData()
|
||
{
|
||
var vocabularies = new[]
|
||
{
|
||
new OptionsVocabulary { Id = Guid.NewGuid(), Word = "wonderful", CEFRLevel = "B1", PartOfSpeech = "adjective", WordLength = 9, IsActive = true },
|
||
new OptionsVocabulary { Id = Guid.NewGuid(), Word = "amazing", CEFRLevel = "B1", PartOfSpeech = "adjective", WordLength = 7, IsActive = true },
|
||
new OptionsVocabulary { Id = Guid.NewGuid(), Word = "fantastic", CEFRLevel = "B2", PartOfSpeech = "adjective", WordLength = 9, IsActive = true },
|
||
new OptionsVocabulary { Id = Guid.NewGuid(), Word = "excellent", CEFRLevel = "A2", PartOfSpeech = "adjective", WordLength = 9, IsActive = true },
|
||
new OptionsVocabulary { Id = Guid.NewGuid(), Word = "computer", CEFRLevel = "B1", PartOfSpeech = "noun", WordLength = 8, IsActive = true },
|
||
new OptionsVocabulary { Id = Guid.NewGuid(), Word = "keyboard", CEFRLevel = "B1", PartOfSpeech = "noun", WordLength = 8, IsActive = true },
|
||
new OptionsVocabulary { Id = Guid.NewGuid(), Word = "monitor", CEFRLevel = "B2", PartOfSpeech = "noun", WordLength = 7, IsActive = true },
|
||
};
|
||
|
||
DbContext.OptionsVocabularies.AddRange(vocabularies);
|
||
await DbContext.SaveChangesAsync();
|
||
}
|
||
|
||
private async Task SeedLimitedData()
|
||
{
|
||
var vocabularies = new[]
|
||
{
|
||
new OptionsVocabulary { Id = Guid.NewGuid(), Word = "cat", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 3, IsActive = true },
|
||
new OptionsVocabulary { Id = Guid.NewGuid(), Word = "dog", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 3, IsActive = true }
|
||
};
|
||
|
||
DbContext.OptionsVocabularies.AddRange(vocabularies);
|
||
await DbContext.SaveChangesAsync();
|
||
}
|
||
|
||
private async Task SeedFlashcardsWithExamples()
|
||
{
|
||
var user = new User { Id = Guid.NewGuid(), Email = "test@example.com", Username = "testuser" };
|
||
DbContext.Users.Add(user);
|
||
|
||
var flashcards = new[]
|
||
{
|
||
new Flashcard
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
UserId = user.Id,
|
||
Word = "example1",
|
||
Translation = "範例1",
|
||
Definition = "第一個範例",
|
||
Example = "This is the first example sentence.",
|
||
DifficultyLevel = "B1",
|
||
PartOfSpeech = "noun"
|
||
},
|
||
new Flashcard
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
UserId = user.Id,
|
||
Word = "example2",
|
||
Translation = "範例2",
|
||
Definition = "第二個範例",
|
||
Example = "This is the second example sentence.",
|
||
DifficultyLevel = "B1",
|
||
PartOfSpeech = "noun"
|
||
}
|
||
};
|
||
|
||
DbContext.Flashcards.AddRange(flashcards);
|
||
await DbContext.SaveChangesAsync();
|
||
}
|
||
|
||
private Flashcard CreateTestFlashcard(string word, string? difficultyLevel, string? partOfSpeech, string? example = null)
|
||
{
|
||
var user = new User { Id = Guid.NewGuid(), Email = "test@example.com", Username = "testuser" };
|
||
DbContext.Users.Add(user);
|
||
|
||
var flashcard = new Flashcard
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
UserId = user.Id,
|
||
Word = word,
|
||
Translation = $"{word} 的翻譯",
|
||
Definition = $"{word} 的定義",
|
||
Example = example,
|
||
DifficultyLevel = difficultyLevel,
|
||
PartOfSpeech = partOfSpeech
|
||
};
|
||
|
||
DbContext.Flashcards.Add(flashcard);
|
||
DbContext.SaveChanges();
|
||
|
||
return flashcard;
|
||
}
|
||
|
||
#endregion
|
||
|
||
protected override void Dispose()
|
||
{
|
||
ClearDatabase();
|
||
base.Dispose();
|
||
}
|
||
} |