diff --git a/src/SentenceStudio.Shared/AssemblyInfo.cs b/src/SentenceStudio.Shared/AssemblyInfo.cs new file mode 100644 index 00000000..49394e0f --- /dev/null +++ b/src/SentenceStudio.Shared/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SentenceStudio.UnitTests")] diff --git a/tests/SentenceStudio.UnitTests/Services/ClassifyContentAsyncTests.cs b/tests/SentenceStudio.UnitTests/Services/ClassifyContentAsyncTests.cs new file mode 100644 index 00000000..bb88b92c --- /dev/null +++ b/tests/SentenceStudio.UnitTests/Services/ClassifyContentAsyncTests.cs @@ -0,0 +1,173 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using SentenceStudio.Abstractions; +using SentenceStudio.Data; +using SentenceStudio.Shared.Models; +using SentenceStudio.Services; +using Xunit; + +namespace SentenceStudio.UnitTests.Services; + +/// +/// Tests for ContentImportService.ClassifyContentAsync — covering all 3 branches: +/// null AI response, AI exception, and the 4-arm type-switch (vocabulary/phrases/sentences/transcript + unknown). +/// +public class ClassifyContentAsyncTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ServiceProvider _serviceProvider; + private readonly Mock _mockAiService; + + public ClassifyContentAsyncTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var mockPreferences = new Mock(); + mockPreferences.Setup(p => p.Get(It.IsAny(), It.IsAny())) + .Returns((string key, string defaultValue) => defaultValue); + + var mockFileSystem = new Mock(); + _mockAiService = new Mock(); + + var services = new ServiceCollection(); + + services.AddDbContext(options => + { + options.UseSqlite(_connection); + options.ConfigureWarnings(w => + w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); + }); + + services.AddLogging(builder => builder.AddDebug()); + services.AddSingleton(mockPreferences.Object); + services.AddSingleton(new NoOpSyncService()); + services.AddSingleton(mockFileSystem.Object); + services.AddSingleton(_mockAiService.Object); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + _serviceProvider = services.BuildServiceProvider(); + + using var scope = _serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.EnsureCreated(); + } + + public void Dispose() + { + _serviceProvider.Dispose(); + _connection.Close(); + _connection.Dispose(); + } + + // ----------------------------------------------------------------- + // Branch 1: null AI response → Vocabulary / 0.5f + // ----------------------------------------------------------------- + + [Fact] + public async Task ClassifyContent_NullAiResponse_DefaultsToVocabulary() + { + // Arrange + _mockAiService + .Setup(s => s.SendPrompt(It.IsAny())) + .ReturnsAsync((ContentClassificationAiResponse?)null); + + using var scope = _serviceProvider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + // Act + var result = await service.ClassifyContentAsync("apple,사과\nbanana,바나나", null); + + // Assert + result.ContentType.Should().Be(ContentType.Vocabulary); + result.Confidence.Should().BeApproximately(0.5f, 0.001f); + } + + // ----------------------------------------------------------------- + // Branch 2: AI throws → Vocabulary / 0.3f with error signals + // ----------------------------------------------------------------- + + [Fact] + public async Task ClassifyContent_AiException_DefaultsWithLowConfidence() + { + // Arrange + _mockAiService + .Setup(s => s.SendPrompt(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("AI unavailable")); + + using var scope = _serviceProvider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + // Act + var result = await service.ClassifyContentAsync("apple,사과\nbanana,바나나", null); + + // Assert + result.ContentType.Should().Be(ContentType.Vocabulary); + result.Confidence.Should().BeApproximately(0.3f, 0.001f); + result.Signals.Should().Contain("error"); + result.Signals.Should().Contain(nameof(InvalidOperationException)); + } + + // ----------------------------------------------------------------- + // Branch 3: type-switch arms + // ----------------------------------------------------------------- + + [Theory] + [InlineData("vocabulary", ContentType.Vocabulary)] + [InlineData("phrases", ContentType.Phrases)] + [InlineData("sentences", ContentType.Sentences)] + [InlineData("transcript", ContentType.Transcript)] + public async Task ClassifyContent_KnownType_MapsCorrectly(string aiType, ContentType expectedType) + { + // Arrange + _mockAiService + .Setup(s => s.SendPrompt(It.IsAny())) + .ReturnsAsync(new ContentClassificationAiResponse + { + Type = aiType, + Confidence = 0.9f, + Reasoning = $"Looks like {aiType}", + Signals = new List { aiType } + }); + + using var scope = _serviceProvider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + // Act + var result = await service.ClassifyContentAsync("some content sample", null); + + // Assert + result.ContentType.Should().Be(expectedType); + result.Confidence.Should().BeApproximately(0.9f, 0.001f); + } + + [Fact] + public async Task ClassifyContent_UnknownType_FallsBackToVocabulary() + { + // Arrange — AI returns a type not in the switch arms + _mockAiService + .Setup(s => s.SendPrompt(It.IsAny())) + .ReturnsAsync(new ContentClassificationAiResponse + { + Type = "unknown_future_type", + Confidence = 0.6f, + Reasoning = "unrecognised", + Signals = new List() + }); + + using var scope = _serviceProvider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + // Act + var result = await service.ClassifyContentAsync("some content sample", null); + + // Assert + result.ContentType.Should().Be(ContentType.Vocabulary); + } +}