250 lines
8.8 KiB
C#
250 lines
8.8 KiB
C#
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)
|
|
{
|
|
}
|
|
}
|
|
}
|