using Microsoft.Data.Sqlite; using Scry.Core.Data; using Scry.Core.Imaging; using Scry.Core.Models; using Scry.Core.Recognition; using SkiaSharp; using Xunit; using Xunit.Abstractions; namespace Scry.Tests; public class CardRecognitionTests : IDisposable { private readonly ITestOutputHelper _output; private readonly string _dbPath; private readonly CardDatabase _database; private readonly CardRecognitionService _recognitionService; public CardRecognitionTests(ITestOutputHelper output) { _output = output; _dbPath = Path.Combine(Path.GetTempPath(), $"scry_recognition_test_{Guid.NewGuid()}.db"); _database = new CardDatabase(_dbPath); _recognitionService = new CardRecognitionService(_database); } [Fact] public async Task RecognizeAsync_EmptyDatabase_ReturnsFailed() { using var bitmap = CreateTestBitmap(100, 100); var result = await _recognitionService.RecognizeAsync(bitmap); Assert.False(result.Success); Assert.Contains("No cards", result.ErrorMessage); } [Fact] public async Task RecognizeAsync_ExactMatch_ReturnsSuccess() { using var bitmap = CreateTestBitmap(100, 100); var hash = _recognitionService.ComputeHash(bitmap); // Insert oracle and set first await _database.InsertOracleAsync(new Oracle { Id = "oracle-test", Name = "Test Card" }); await _database.InsertSetAsync(new Set { Id = "set-test", Code = "TST", Name = "Test Set" }); await _database.InsertCardAsync(new Card { Id = "test-card", OracleId = "oracle-test", SetId = "set-test", SetCode = "TST", Name = "Test Card", Hash = hash, ImageUri = "https://example.com/test.jpg" }); await _recognitionService.InvalidateCacheAsync(); var result = await _recognitionService.RecognizeAsync(bitmap); Assert.True(result.Success); Assert.Equal("Test Card", result.Card?.Name); Assert.Equal(1.0f, result.Confidence); Assert.Equal(0, result.HammingDistance); } [Theory] [InlineData("reference/brainstorm.png")] [InlineData("reference/force_of_will.png")] [InlineData("single_cards/llanowar_elves.jpg")] public async Task RecognizeAsync_ReferenceImage_SelfMatch(string imagePath) { var fullPath = Path.Combine("TestImages", imagePath); if (!File.Exists(fullPath)) { _output.WriteLine($"Skipping test - file not found: {fullPath}"); return; } using var bitmap = SKBitmap.Decode(fullPath); Assert.NotNull(bitmap); var hash = _recognitionService.ComputeHash(bitmap); var cardName = Path.GetFileNameWithoutExtension(imagePath); await _database.InsertOracleAsync(new Oracle { Id = $"oracle-{cardName}", Name = cardName }); await _database.InsertSetAsync(new Set { Id = "set-ref", Code = "REF", Name = "Reference Set" }); await _database.InsertCardAsync(new Card { Id = cardName, OracleId = $"oracle-{cardName}", SetId = "set-ref", SetCode = "REF", Name = cardName, Hash = hash }); await _recognitionService.InvalidateCacheAsync(); var result = await _recognitionService.RecognizeAsync(bitmap); Assert.True(result.Success, $"Recognition failed: {result.ErrorMessage}"); Assert.Equal(cardName, result.Card?.Name); Assert.True(result.Confidence >= 0.85f); _output.WriteLine($"Matched: {cardName}, Confidence: {result.Confidence:P0}, Distance: {result.HammingDistance}"); } [Fact] public async Task RecognizeAsync_SerraAngelFromDatabase_Matches() { // Find the solution root (where .git is) var currentDir = Directory.GetCurrentDirectory(); var rootDir = currentDir; while (!Directory.Exists(Path.Combine(rootDir, ".git")) && Directory.GetParent(rootDir) != null) { rootDir = Directory.GetParent(rootDir)!.FullName; } var dbPath = Path.Combine(rootDir, "src", "Scry.App", "Resources", "Raw", "card_hashes.db"); if (!File.Exists(dbPath)) { _output.WriteLine($"Skipping - database not found at {dbPath}"); return; } var imagePath = Path.Combine(rootDir, "TestImages", "reference_alpha", "serra_angel.jpg"); if (!File.Exists(imagePath)) { _output.WriteLine($"Skipping - image not found at {imagePath}"); return; } using var testDb = new CardDatabase(dbPath); using var testRecognition = new CardRecognitionService(testDb); using var bitmap = SKBitmap.Decode(imagePath); Assert.NotNull(bitmap); // First, just compute hash and check distance manually var queryHash = testRecognition.ComputeHash(bitmap); var allCards = await testDb.GetCardsWithHashAsync(); _output.WriteLine($"Query hash length: {queryHash.Length} bytes"); _output.WriteLine($"Database has {allCards.Count} cards with hashes"); // Find Serra Angel and compute distance var serraCard = allCards.FirstOrDefault(c => c.Name == "Serra Angel"); if (serraCard?.Hash != null) { var distance = PerceptualHash.HammingDistance(queryHash, serraCard.Hash); _output.WriteLine($"Serra Angel hash length: {serraCard.Hash.Length} bytes"); _output.WriteLine($"Distance to Serra Angel: {distance}"); } // Find the actual best match int bestDistance = int.MaxValue; string? bestName = null; foreach (var card in allCards) { if (card.Hash == null || card.Hash.Length != queryHash.Length) continue; var dist = PerceptualHash.HammingDistance(queryHash, card.Hash); if (dist < bestDistance) { bestDistance = dist; bestName = card.Name; } } _output.WriteLine($"Best match: {bestName}, distance: {bestDistance}"); // Now try actual recognition var result = await testRecognition.RecognizeAsync(bitmap); if (result.Success) { _output.WriteLine($"Recognition succeeded: {result.Card?.Name}, confidence: {result.Confidence:P0}"); Assert.Equal("Serra Angel", result.Card?.Name); } else { _output.WriteLine($"Recognition failed: {result.ErrorMessage}"); // For debugging - this should be 0 since we're using the exact same image Assert.Fail($"Expected to match Serra Angel, but got: {result.ErrorMessage}"); } } [Fact] public async Task RecognizeAsync_MeasuresProcessingTime() { using var bitmap = CreateTestBitmap(200, 300); var hash = _recognitionService.ComputeHash(bitmap); await _database.InsertOracleAsync(new Oracle { Id = "oracle-timing", Name = "Timing Test Card" }); await _database.InsertSetAsync(new Set { Id = "set-timing", Code = "TST", Name = "Test Set" }); await _database.InsertCardAsync(new Card { Id = "timing-test", OracleId = "oracle-timing", SetId = "set-timing", SetCode = "TST", Name = "Timing Test Card", Hash = hash }); await _recognitionService.InvalidateCacheAsync(); var result = await _recognitionService.RecognizeAsync(bitmap); Assert.True(result.Success); Assert.True(result.ProcessingTime.TotalMilliseconds > 0); _output.WriteLine($"Processing time: {result.ProcessingTime.TotalMilliseconds:F2}ms"); } private static SKBitmap CreateTestBitmap(int width, int height) { var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); var random = new Random(42); for (var y = 0; y < height; y++) { for (var x = 0; x < width; x++) { var r = (byte)random.Next(256); var g = (byte)random.Next(256); var b = (byte)random.Next(256); bitmap.SetPixel(x, y, new SKColor(r, g, b)); } } return bitmap; } public void Dispose() { _recognitionService.Dispose(); _database.Dispose(); SqliteConnection.ClearAllPools(); try { if (File.Exists(_dbPath)) { File.Delete(_dbPath); } } catch (IOException) { } } }