dramaling-vocab-learning/backend/DramaLing.Api.Tests/Services/OptionsVocabularyServiceTes...

300 lines
10 KiB
C#

using DramaLing.Api.Models.Entities;
using DramaLing.Api.Services;
using Microsoft.Extensions.Logging;
using Moq;
namespace DramaLing.Api.Tests.Services;
/// <summary>
/// OptionsVocabularyService 單元測試
/// </summary>
public class OptionsVocabularyServiceTests : TestBase
{
private readonly OptionsVocabularyService _service;
private readonly Mock<ILogger<OptionsVocabularyService>> _mockLogger;
public OptionsVocabularyServiceTests()
{
_mockLogger = CreateMockLogger<OptionsVocabularyService>();
_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<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Error generating distractors")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
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();
}
}