using DramaLing.Api.Models.Entities; using DramaLing.Api.Services; using Microsoft.Extensions.Logging; using Moq; namespace DramaLing.Api.Tests.Services; /// /// OptionsVocabularyService 單元測試 /// public class OptionsVocabularyServiceTests : TestBase { private readonly OptionsVocabularyService _service; private readonly Mock> _mockLogger; public OptionsVocabularyServiceTests() { _mockLogger = CreateMockLogger(); _service = new OptionsVocabularyService(DbContext, MemoryCache, _mockLogger.Object); } #region GenerateDistractorsAsync Tests [Fact] public async Task GenerateDistractorsAsync_WithValidParameters_ShouldReturnDistractors() { // Arrange await SeedTestVocabularyData(); // Act var result = await _service.GenerateDistractorsAsync("target", "B1", "noun", 3); // Assert result.Should().NotBeNull(); result.Should().HaveCountLessOrEqualTo(3); result.Should().NotContain("target"); // 不應包含目標詞彙 } [Fact] public async Task GenerateDistractorsAsync_WithNoAvailableVocabulary_ShouldReturnEmptyList() { // Arrange - 空資料庫 // Act var result = await _service.GenerateDistractorsAsync("target", "B1", "noun", 3); // Assert result.Should().BeEmpty(); } [Fact] public async Task GenerateDistractorsAsync_WithInsufficientVocabulary_ShouldReturnAvailableWords() { // Arrange await SeedLimitedVocabularyData(); // 只有2個詞彙 // Act var result = await _service.GenerateDistractorsAsync("target", "A1", "noun", 5); // Assert result.Should().HaveCount(2); // 只能返回2個 } [Fact] public async Task GenerateDistractorsAsync_ShouldExcludeTargetWord() { // Arrange await SeedTestVocabularyData(); // Act var result = await _service.GenerateDistractorsAsync("cat", "A1", "noun", 3); // Assert result.Should().NotContain("cat"); } [Theory] [InlineData("", "B1", "noun", 3)] [InlineData("word", "", "noun", 3)] [InlineData("word", "B1", "", 3)] [InlineData("word", "B1", "noun", 0)] public async Task GenerateDistractorsAsync_WithInvalidParameters_ShouldHandleGracefully( string targetWord, string cefrLevel, string partOfSpeech, int count) { // Act & Assert var result = await _service.GenerateDistractorsAsync(targetWord, cefrLevel, partOfSpeech, count); result.Should().NotBeNull(); } #endregion #region GenerateDistractorsWithDetailsAsync Tests [Fact] public async Task GenerateDistractorsWithDetailsAsync_ShouldReturnDetailedVocabulary() { // Arrange await SeedTestVocabularyData(); // Act var result = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 2); // Assert result.Should().NotBeNull(); result.Should().HaveCountLessOrEqualTo(2); result.Should().OnlyContain(v => v.CEFRLevel != null && v.PartOfSpeech != null); } [Fact] public async Task GenerateDistractorsWithDetailsAsync_ShouldMatchWordLengthRange() { // Arrange await SeedTestVocabularyData(); const string targetWord = "hello"; // 5個字元 // Act var result = await _service.GenerateDistractorsWithDetailsAsync(targetWord, "B1", "noun", 5); // Assert result.Should().OnlyContain(v => v.WordLength >= 3 && v.WordLength <= 7); // targetLength ± 2 } #endregion #region HasSufficientVocabularyAsync Tests [Fact] public async Task HasSufficientVocabularyAsync_WithSufficientVocabulary_ShouldReturnTrue() { // Arrange await SeedTestVocabularyData(); // Act var result = await _service.HasSufficientVocabularyAsync("A1", "noun"); // Assert result.Should().BeTrue(); } [Fact] public async Task HasSufficientVocabularyAsync_WithInsufficientVocabulary_ShouldReturnFalse() { // Arrange await SeedLimitedVocabularyData(); // Act var result = await _service.HasSufficientVocabularyAsync("C2", "adverb"); // Assert result.Should().BeFalse(); } [Fact] public async Task HasSufficientVocabularyAsync_WithEmptyDatabase_ShouldReturnFalse() { // Act var result = await _service.HasSufficientVocabularyAsync("B1", "noun"); // Assert result.Should().BeFalse(); } #endregion #region Caching Tests [Fact] public async Task GenerateDistractorsWithDetailsAsync_ShouldUseCaching() { // Arrange await SeedTestVocabularyData(); // Act - 第一次呼叫 var result1 = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 3); // Act - 第二次呼叫(應該使用快取) var result2 = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 3); // Assert result1.Should().NotBeNull(); result2.Should().NotBeNull(); // 注意:由於有隨機性,我們主要測試快取功能不會拋出異常 } [Fact] public async Task CacheInvalidation_ShouldWorkCorrectly() { // Arrange await SeedTestVocabularyData(); await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 3); // Act ClearCache(); var resultAfterClearCache = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 3); // Assert resultAfterClearCache.Should().NotBeNull(); } #endregion #region CEFR Level Tests [Fact] public async Task GenerateDistractorsAsync_ShouldIncludeAdjacentCEFRLevels() { // Arrange await SeedVocabularyWithDifferentLevels(); // Act var result = await _service.GenerateDistractorsWithDetailsAsync("target", "B1", "noun", 10); // Assert result.Should().Contain(v => v.CEFRLevel == "A2" || v.CEFRLevel == "B1" || v.CEFRLevel == "B2"); } #endregion #region Error Handling Tests [Fact] public async Task GenerateDistractorsAsync_WithDatabaseError_ShouldReturnEmptyListAndLog() { // Arrange await DbContext.Database.EnsureDeletedAsync(); // 破壞資料庫連接 // Act var result = await _service.GenerateDistractorsAsync("target", "B1", "noun", 3); // Assert result.Should().BeEmpty(); // 驗證日誌記錄 _mockLogger.Verify( x => x.Log( LogLevel.Error, It.IsAny(), It.Is((v, t) => v.ToString()!.Contains("Error generating distractors")), It.IsAny(), It.IsAny>()), Times.Once); } #endregion #region Test Data Setup private async Task SeedTestVocabularyData() { 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 }, new OptionsVocabulary { Id = Guid.NewGuid(), Word = "house", CEFRLevel = "A1", PartOfSpeech = "noun", WordLength = 5, IsActive = true }, new OptionsVocabulary { Id = Guid.NewGuid(), Word = "beautiful", CEFRLevel = "B1", PartOfSpeech = "adjective", WordLength = 9, IsActive = true }, new OptionsVocabulary { Id = Guid.NewGuid(), Word = "quickly", CEFRLevel = "B1", PartOfSpeech = "adverb", WordLength = 7, IsActive = true }, new OptionsVocabulary { Id = Guid.NewGuid(), Word = "table", CEFRLevel = "A2", PartOfSpeech = "noun", WordLength = 5, IsActive = true }, new OptionsVocabulary { Id = Guid.NewGuid(), Word = "computer", CEFRLevel = "B1", PartOfSpeech = "noun", WordLength = 8, IsActive = true }, new OptionsVocabulary { Id = Guid.NewGuid(), Word = "wonderful", CEFRLevel = "B1", PartOfSpeech = "adjective", WordLength = 9, IsActive = true } }; DbContext.OptionsVocabularies.AddRange(vocabularies); await DbContext.SaveChangesAsync(); } private async Task SeedLimitedVocabularyData() { 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 SeedVocabularyWithDifferentLevels() { 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 = "A2", PartOfSpeech = "noun", WordLength = 3, IsActive = true }, new OptionsVocabulary { Id = Guid.NewGuid(), Word = "book", CEFRLevel = "B1", PartOfSpeech = "noun", WordLength = 4, IsActive = true }, new OptionsVocabulary { Id = Guid.NewGuid(), Word = "table", CEFRLevel = "B2", PartOfSpeech = "noun", WordLength = 5, IsActive = true }, new OptionsVocabulary { Id = Guid.NewGuid(), Word = "chair", CEFRLevel = "C1", PartOfSpeech = "noun", WordLength = 5, IsActive = true } }; DbContext.OptionsVocabularies.AddRange(vocabularies); await DbContext.SaveChangesAsync(); } #endregion protected override void Dispose() { ClearDatabase(); base.Dispose(); } }