+ {
+ ["https://it.bidoo.com/closed"] = closedPageHtml
+ // Intentionally no mapping for /asta/fail-1 -> will fail
+ });
+
+ var scraper = new ClosedAuctionsScraper(handler);
+ var results = await scraper.ScrapeAsync("https://it.bidoo.com/closed");
+
+ Assert.Single(results);
+ Assert.Equal("(parse error)", results[0].ProductName);
+ }
+
+ [Fact]
+ public async Task ScrapeAsync_ExtractsBidsUsed()
+ {
+ var closedPageHtml = @"";
+ var auctionHtml = @"
+
+ Test Product
+ puntate456 Puntate utilizzate
+ ";
+
+ var handler = new MockHttpHandler(new Dictionary
+ {
+ ["https://it.bidoo.com/closed"] = closedPageHtml,
+ ["https://it.bidoo.com/asta/bid-test"] = auctionHtml
+ });
+
+ var scraper = new ClosedAuctionsScraper(handler);
+ var results = await scraper.ScrapeAsync("https://it.bidoo.com/closed");
+
+ Assert.Single(results);
+ Assert.Equal(456, results[0].BidsUsed);
+ }
+
+ ///
+ /// Simple mock HTTP handler for testing
+ ///
+ private class MockHttpHandler : HttpMessageHandler
+ {
+ private readonly Dictionary _responses;
+
+ public MockHttpHandler(Dictionary responses)
+ {
+ _responses = responses;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var url = request.RequestUri!.ToString();
+
+ if (_responses.TryGetValue(url, out var content))
+ {
+ return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
+ {
+ Content = new StringContent(content, System.Text.Encoding.UTF8, "text/html")
+ });
+ }
+
+ return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)
+ {
+ Content = new StringContent("Not Found")
+ });
+ }
+ }
+}
diff --git a/Mimante/Tests/AutoBidder.Tests/HtmlCacheServiceTests.cs b/Mimante/Tests/AutoBidder.Tests/HtmlCacheServiceTests.cs
new file mode 100644
index 0000000..0756089
--- /dev/null
+++ b/Mimante/Tests/AutoBidder.Tests/HtmlCacheServiceTests.cs
@@ -0,0 +1,77 @@
+using AutoBidder.Services;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+public class HtmlCacheServiceTests
+{
+ // === Constructor / GetStats ===
+
+ [Fact]
+ public void Constructor_InitializesWithDefaults()
+ {
+ var service = new HtmlCacheService();
+ var stats = service.GetStats();
+
+ Assert.Equal(0, stats.TotalEntries);
+ Assert.True(stats.AvailableSlots > 0);
+ Assert.True(stats.MaxConcurrent > 0);
+ }
+
+ [Fact]
+ public void Constructor_CustomParameters_ReflectsInStats()
+ {
+ var service = new HtmlCacheService(maxConcurrentRequests: 5);
+ var stats = service.GetStats();
+
+ Assert.Equal(5, stats.MaxConcurrent);
+ Assert.Equal(5, stats.AvailableSlots);
+ }
+
+ // === ClearCache ===
+
+ [Fact]
+ public void ClearCache_EmptyCache_DoesNotThrow()
+ {
+ var service = new HtmlCacheService();
+ service.ClearCache();
+
+ Assert.Equal(0, service.GetStats().TotalEntries);
+ }
+
+ // === CleanExpiredCache ===
+
+ [Fact]
+ public void CleanExpiredCache_EmptyCache_DoesNotThrow()
+ {
+ var service = new HtmlCacheService();
+ service.CleanExpiredCache();
+
+ Assert.Equal(0, service.GetStats().TotalEntries);
+ }
+
+ // === OnLog callback ===
+
+ [Fact]
+ public void ClearCache_InvokesOnLog()
+ {
+ var service = new HtmlCacheService();
+ var logged = false;
+ service.OnLog = _ => logged = true;
+
+ service.ClearCache();
+
+ Assert.True(logged);
+ }
+
+ // === GetStats ===
+
+ [Fact]
+ public void GetStats_ReturnsNonNullResult()
+ {
+ var service = new HtmlCacheService();
+ var stats = service.GetStats();
+
+ Assert.NotNull(stats);
+ }
+}
diff --git a/Mimante/Tests/AutoBidder.Tests/ModelTests.cs b/Mimante/Tests/AutoBidder.Tests/ModelTests.cs
new file mode 100644
index 0000000..e57aff2
--- /dev/null
+++ b/Mimante/Tests/AutoBidder.Tests/ModelTests.cs
@@ -0,0 +1,314 @@
+using AutoBidder.Models;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+public class ModelTests
+{
+ // === BidooSession.IsValid ===
+
+ [Fact]
+ public void BidooSession_IsValid_FalseWhenEmpty()
+ {
+ var session = new BidooSession();
+ Assert.False(session.IsValid);
+ }
+
+ [Fact]
+ public void BidooSession_IsValid_TrueWithAuthToken()
+ {
+ var session = new BidooSession { AuthToken = "abc123" };
+ Assert.True(session.IsValid);
+ }
+
+ [Fact]
+ public void BidooSession_IsValid_TrueWithCookieString()
+ {
+ var session = new BidooSession { CookieString = "session=abc" };
+ Assert.True(session.IsValid);
+ }
+
+ [Fact]
+ public void BidooSession_IsValid_FalseWithWhitespaceOnly()
+ {
+ var session = new BidooSession { AuthToken = " ", CookieString = " " };
+ Assert.False(session.IsValid);
+ }
+
+ // === ProductStatisticsRecord.WinRate ===
+
+ [Fact]
+ public void ProductStatisticsRecord_WinRate_ZeroWhenNoAuctions()
+ {
+ var record = new ProductStatisticsRecord { TotalAuctions = 0, WonAuctions = 0 };
+ Assert.Equal(0, record.WinRate);
+ }
+
+ [Fact]
+ public void ProductStatisticsRecord_WinRate_CorrectPercentage()
+ {
+ var record = new ProductStatisticsRecord { TotalAuctions = 10, WonAuctions = 3 };
+ Assert.Equal(30.0, record.WinRate, 1);
+ }
+
+ [Fact]
+ public void ProductStatisticsRecord_WinRate_100Percent()
+ {
+ var record = new ProductStatisticsRecord { TotalAuctions = 5, WonAuctions = 5 };
+ Assert.Equal(100.0, record.WinRate, 1);
+ }
+
+ // === HourlyStats.WinRate ===
+
+ [Fact]
+ public void HourlyStats_WinRate_ZeroWhenNoAuctions()
+ {
+ var stats = new HourlyStats { TotalAuctions = 0, WonAuctions = 0 };
+ Assert.Equal(0, stats.WinRate);
+ }
+
+ [Fact]
+ public void HourlyStats_WinRate_CorrectPercentage()
+ {
+ var stats = new HourlyStats { TotalAuctions = 20, WonAuctions = 5 };
+ Assert.Equal(25.0, stats.WinRate, 1);
+ }
+
+ // === CompleteAuctionHistoryRecord ===
+
+ [Fact]
+ public void CompleteAuctionHistoryRecord_DurationFormatted_WithDuration()
+ {
+ var record = new CompleteAuctionHistoryRecord { DurationSeconds = 3661 }; // 1h 1m 1s
+ Assert.Equal("01:01:01", record.DurationFormatted);
+ }
+
+ [Fact]
+ public void CompleteAuctionHistoryRecord_DurationFormatted_NoDuration()
+ {
+ var record = new CompleteAuctionHistoryRecord { DurationSeconds = null };
+ Assert.Equal("-", record.DurationFormatted);
+ }
+
+ [Fact]
+ public void CompleteAuctionHistoryRecord_SuccessRate_ZeroWhenNoBids()
+ {
+ var record = new CompleteAuctionHistoryRecord { MySuccessfulBids = 0, MyFailedBids = 0 };
+ Assert.Equal(0, record.SuccessRate);
+ }
+
+ [Fact]
+ public void CompleteAuctionHistoryRecord_SuccessRate_CorrectPercentage()
+ {
+ var record = new CompleteAuctionHistoryRecord { MySuccessfulBids = 7, MyFailedBids = 3 };
+ Assert.Equal(70.0, record.SuccessRate, 1);
+ }
+
+ // === ProductStat ===
+
+ [Fact]
+ public void ProductStat_AverageBidsUsed_ZeroWhenNoAuctions()
+ {
+ var stat = new ProductStat { TotalAuctions = 0, TotalBidsUsed = 0 };
+ Assert.Equal(0, stat.AverageBidsUsed);
+ }
+
+ [Fact]
+ public void ProductStat_AverageBidsUsed_CorrectAverage()
+ {
+ var stat = new ProductStat { TotalAuctions = 5, TotalBidsUsed = 100 };
+ Assert.Equal(20.0, stat.AverageBidsUsed, 1);
+ }
+
+ [Fact]
+ public void ProductStat_AverageFinalPrice_ZeroWhenNoAuctions()
+ {
+ var stat = new ProductStat { TotalAuctions = 0, TotalFinalPriceCents = 0 };
+ Assert.Equal(0, stat.AverageFinalPrice);
+ }
+
+ [Fact]
+ public void ProductStat_AverageFinalPrice_CorrectAverage()
+ {
+ var stat = new ProductStat { TotalAuctions = 2, TotalFinalPriceCents = 1000 };
+ Assert.Equal(5.0, stat.AverageFinalPrice, 2); // 1000 cents / 100 / 2 = 5.0
+ }
+
+ // === BidHistoryEntry ===
+
+ [Fact]
+ public void BidHistoryEntry_PriceFormatted_TwoDecimals()
+ {
+ var entry = new BidHistoryEntry { Price = 5.5m };
+ // PriceFormatted uses default culture formatting
+ Assert.Equal(5.5m.ToString("0.00"), entry.PriceFormatted);
+ }
+
+ [Fact]
+ public void BidHistoryEntry_TimeFormatted_ValidFormat()
+ {
+ var entry = new BidHistoryEntry { Timestamp = 1700000000 };
+ // Should produce a valid HH:mm:ss string
+ Assert.Matches(@"^\d{2}:\d{2}:\d{2}$", entry.TimeFormatted);
+ }
+
+ // === BidderInfo ===
+
+ [Fact]
+ public void BidderInfo_LastBidTimeDisplay_DashWhenDefault()
+ {
+ var info = new BidderInfo();
+ Assert.Equal("-", info.LastBidTimeDisplay);
+ }
+
+ [Fact]
+ public void BidderInfo_LastBidTimeDisplay_FormattedWhenSet()
+ {
+ var info = new BidderInfo { LastBidTime = new DateTime(2024, 1, 1, 14, 30, 45) };
+ Assert.Equal("14:30:45", info.LastBidTimeDisplay);
+ }
+
+ // === AuctionLogEntry ===
+
+ [Fact]
+ public void AuctionLogEntry_LevelIcon_ReturnsBootstrapClass()
+ {
+ var entry = new AuctionLogEntry { Level = AuctionLogLevel.Error };
+ Assert.Contains("bi-", entry.LevelIcon);
+ }
+
+ [Fact]
+ public void AuctionLogEntry_LevelClass_ReturnsCorrectCssClass()
+ {
+ var entry = new AuctionLogEntry { Level = AuctionLogLevel.Error };
+ Assert.Equal("alog-error", entry.LevelClass);
+ }
+
+ [Fact]
+ public void AuctionLogEntry_LevelLabel_ReturnsShortLabel()
+ {
+ Assert.Equal("ERR", new AuctionLogEntry { Level = AuctionLogLevel.Error }.LevelLabel);
+ Assert.Equal("WARN", new AuctionLogEntry { Level = AuctionLogLevel.Warning }.LevelLabel);
+ Assert.Equal("OK", new AuctionLogEntry { Level = AuctionLogLevel.Success }.LevelLabel);
+ Assert.Equal("BID", new AuctionLogEntry { Level = AuctionLogLevel.Bid }.LevelLabel);
+ }
+
+ [Fact]
+ public void AuctionLogEntry_CategoryLabel_ReturnsLabel()
+ {
+ var entry = new AuctionLogEntry { Category = AuctionLogCategory.BidAttempt };
+ Assert.Equal("Puntata", entry.CategoryLabel);
+ }
+
+ [Fact]
+ public void AuctionLogEntry_TimeDisplay_FormattedWithMs()
+ {
+ var entry = new AuctionLogEntry { Timestamp = new DateTime(2024, 1, 1, 14, 30, 45, 123) };
+ Assert.Equal("14:30:45.123", entry.TimeDisplay);
+ }
+
+ // === AuctionInfo.AddLog ===
+
+ [Fact]
+ public void AuctionInfo_AddLog_AddsEntry()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ auction.AddLog("[BID] Test bid placed");
+
+ Assert.Single(auction.AuctionLog);
+ }
+
+ [Fact]
+ public void AuctionInfo_AddLog_ParsesTag()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ auction.AddLog("[ERROR] Something went wrong");
+
+ Assert.Equal(AuctionLogLevel.Error, auction.AuctionLog[0].Level);
+ }
+
+ [Fact]
+ public void AuctionInfo_AddLog_Deduplicates()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ auction.AddLog("[BID] Same message");
+ auction.AddLog("[BID] Same message");
+ auction.AddLog("[BID] Same message");
+
+ Assert.Single(auction.AuctionLog);
+ Assert.Equal(3, auction.AuctionLog[0].RepeatCount);
+ }
+
+ [Fact]
+ public void AuctionInfo_AddLog_TruncatesAtMaxLines()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ for (int i = 0; i < 300; i++)
+ {
+ auction.AddLog($"Message {i}");
+ }
+
+ Assert.True(auction.AuctionLog.Count <= 200);
+ }
+
+ [Fact]
+ public void AuctionInfo_AddLogTyped_AddsWithLevelAndCategory()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ auction.AddLog("Test", AuctionLogLevel.Warning, AuctionLogCategory.Price);
+
+ Assert.Single(auction.AuctionLog);
+ Assert.Equal(AuctionLogLevel.Warning, auction.AuctionLog[0].Level);
+ Assert.Equal(AuctionLogCategory.Price, auction.AuctionLog[0].Category);
+ }
+
+ // === AuctionInfo.AddLatencyMeasurement ===
+
+ [Fact]
+ public void AuctionInfo_AddLatencyMeasurement_AddsToHistory()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ auction.AddLatencyMeasurement(50);
+ auction.AddLatencyMeasurement(100);
+
+ Assert.Equal(2, auction.LatencyHistory.Count);
+ Assert.Equal(100, auction.PollingLatencyMs);
+ }
+
+ [Fact]
+ public void AuctionInfo_AddLatencyMeasurement_TruncatesHistory()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ for (int i = 0; i < 20; i++)
+ {
+ auction.AddLatencyMeasurement(i * 10);
+ }
+
+ Assert.True(auction.LatencyHistory.Count <= 10);
+ }
+
+ [Fact]
+ public void AuctionInfo_AverageLatencyMs_ComputesAverage()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ auction.AddLatencyMeasurement(50);
+ auction.AddLatencyMeasurement(100);
+
+ Assert.Equal(75.0, auction.AverageLatencyMs, 1);
+ }
+
+ [Fact]
+ public void AuctionInfo_AverageLatencyMs_FallsBackToPollingLatency()
+ {
+ var auction = new AuctionInfo { AuctionId = "1", PollingLatencyMs = 80 };
+
+ Assert.Equal(80.0, auction.AverageLatencyMs, 1);
+ }
+
+ [Fact]
+ public void AuctionInfo_AverageLatencyMs_Default60WhenNoData()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ Assert.Equal(60.0, auction.AverageLatencyMs, 1);
+ }
+}
diff --git a/Mimante/Tests/AutoBidder.Tests/ProductInsightsTests.cs b/Mimante/Tests/AutoBidder.Tests/ProductInsightsTests.cs
new file mode 100644
index 0000000..8012e49
--- /dev/null
+++ b/Mimante/Tests/AutoBidder.Tests/ProductInsightsTests.cs
@@ -0,0 +1,161 @@
+using AutoBidder.Models;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+public class ProductInsightsTests
+{
+ // === Calculate ===
+
+ [Fact]
+ public void Calculate_NullAuctions_ReturnsLowConfidence()
+ {
+ var insights = ProductInsights.Calculate(null!, "key", "name");
+
+ Assert.Equal(0, insights.ConfidenceScore);
+ Assert.Contains("insufficienti", insights.RecommendedStrategy);
+ }
+
+ [Fact]
+ public void Calculate_EmptyAuctions_ReturnsLowConfidence()
+ {
+ var insights = ProductInsights.Calculate(new List(), "key", "name");
+
+ Assert.Equal(0, insights.ConfidenceScore);
+ }
+
+ [Fact]
+ public void Calculate_WithValidData_ComputesPriceStats()
+ {
+ var auctions = new List
+ {
+ new() { FinalPrice = 5.0, BidsUsed = 10, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = 10.0, BidsUsed = 20, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = 15.0, BidsUsed = 30, ScrapedAt = DateTime.UtcNow },
+ };
+
+ var insights = ProductInsights.Calculate(auctions, "key", "Test Product");
+
+ Assert.Equal(3, insights.TotalAuctions);
+ Assert.Equal(10.0, insights.AverageFinalPrice, 1);
+ Assert.Equal(5.0, insights.MinFinalPrice, 1);
+ Assert.Equal(15.0, insights.MaxFinalPrice, 1);
+ Assert.Equal(10.0, insights.MedianFinalPrice, 1);
+ }
+
+ [Fact]
+ public void Calculate_WithBidsData_ComputesBidsStats()
+ {
+ var auctions = new List
+ {
+ new() { FinalPrice = 5.0, BidsUsed = 10, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = 10.0, BidsUsed = 20, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = 15.0, BidsUsed = 30, ScrapedAt = DateTime.UtcNow },
+ };
+
+ var insights = ProductInsights.Calculate(auctions, "key", "name");
+
+ Assert.Equal(20.0, insights.AverageBidsUsed, 1);
+ Assert.Equal(10, insights.MinBidsUsed);
+ Assert.Equal(30, insights.MaxBidsUsed);
+ }
+
+ [Fact]
+ public void Calculate_SetsRecommendedValues()
+ {
+ var auctions = Enumerable.Range(1, 10)
+ .Select(i => new ClosedAuctionRecord
+ {
+ FinalPrice = i * 2.0,
+ BidsUsed = i * 5,
+ ScrapedAt = DateTime.UtcNow
+ })
+ .ToList();
+
+ var insights = ProductInsights.Calculate(auctions, "key", "name");
+
+ Assert.True(insights.RecommendedMaxPrice > 0);
+ Assert.True(insights.RecommendedMaxBids > 0);
+ Assert.False(string.IsNullOrEmpty(insights.RecommendedStrategy));
+ }
+
+ [Fact]
+ public void Calculate_HourlyDistribution_BasedOnScrapedAt()
+ {
+ var auctions = new List
+ {
+ new() { FinalPrice = 5.0, ScrapedAt = new DateTime(2024, 1, 1, 14, 0, 0) },
+ new() { FinalPrice = 10.0, ScrapedAt = new DateTime(2024, 1, 1, 14, 30, 0) },
+ new() { FinalPrice = 15.0, ScrapedAt = new DateTime(2024, 1, 1, 20, 0, 0) },
+ };
+
+ var insights = ProductInsights.Calculate(auctions, "key", "name");
+
+ Assert.True(insights.HourlyDistribution.ContainsKey(14));
+ Assert.Equal(2, insights.HourlyDistribution[14]);
+ }
+
+ [Fact]
+ public void Calculate_OptimalStartPrice_NonNegative()
+ {
+ var auctions = new List
+ {
+ new() { FinalPrice = 1.0, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = 2.0, ScrapedAt = DateTime.UtcNow },
+ };
+
+ var insights = ProductInsights.Calculate(auctions, "key", "name");
+
+ Assert.True(insights.OptimalStartPrice >= 0);
+ }
+
+ [Fact]
+ public void Calculate_HighCompetition_StrategyContainsAlta()
+ {
+ // Create data with high bid variance relative to average (high competition intensity)
+ var auctions = new List
+ {
+ new() { FinalPrice = 5.0, BidsUsed = 100, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = 10.0, BidsUsed = 5, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = 15.0, BidsUsed = 200, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = 8.0, BidsUsed = 3, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = 12.0, BidsUsed = 150, ScrapedAt = DateTime.UtcNow },
+ };
+
+ var insights = ProductInsights.Calculate(auctions, "key", "name");
+
+ // CompetitionIntensity is clamped to [0,1]; just verify it's calculated
+ Assert.True(insights.CompetitionIntensity >= 0);
+ Assert.True(insights.CompetitionIntensity <= 1);
+ }
+
+ [Fact]
+ public void Calculate_LowConfidence_StrategyContainsWarning()
+ {
+ // Very few samples = low confidence
+ var auctions = new List
+ {
+ new() { FinalPrice = 5.0, BidsUsed = 10, ScrapedAt = DateTime.UtcNow },
+ };
+
+ var insights = ProductInsights.Calculate(auctions, "key", "name");
+
+ Assert.True(insights.ConfidenceScore < 50);
+ }
+
+ [Fact]
+ public void Calculate_NullPricesAndBids_HandlesGracefully()
+ {
+ var auctions = new List
+ {
+ new() { FinalPrice = null, BidsUsed = null, ScrapedAt = DateTime.UtcNow },
+ new() { FinalPrice = null, BidsUsed = null, ScrapedAt = DateTime.UtcNow },
+ };
+
+ var insights = ProductInsights.Calculate(auctions, "key", "name");
+
+ Assert.Equal(2, insights.TotalAuctions);
+ Assert.Equal(0, insights.AverageFinalPrice);
+ Assert.Equal(0, insights.AverageBidsUsed);
+ }
+}
diff --git a/Mimante/Tests/AutoBidder.Tests/ProductStatisticsServiceTests.cs b/Mimante/Tests/AutoBidder.Tests/ProductStatisticsServiceTests.cs
new file mode 100644
index 0000000..68374e2
--- /dev/null
+++ b/Mimante/Tests/AutoBidder.Tests/ProductStatisticsServiceTests.cs
@@ -0,0 +1,188 @@
+using AutoBidder.Models;
+using AutoBidder.Services;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+public class ProductStatisticsServiceTests
+{
+ // === GenerateProductKey ===
+
+ [Fact]
+ public void GenerateProductKey_NullOrWhitespace_ReturnsUnknown()
+ {
+ Assert.Equal("unknown", ProductStatisticsService.GenerateProductKey(null!));
+ Assert.Equal("unknown", ProductStatisticsService.GenerateProductKey(""));
+ Assert.Equal("unknown", ProductStatisticsService.GenerateProductKey(" "));
+ }
+
+ [Fact]
+ public void GenerateProductKey_NormalizesToLowerAndTrims()
+ {
+ var key = ProductStatisticsService.GenerateProductKey(" iPhone 15 Pro ");
+ Assert.DoesNotContain(" ", key);
+ Assert.Equal(key, key.ToLowerInvariant());
+ }
+
+ [Fact]
+ public void GenerateProductKey_RemovesParentheses()
+ {
+ var key = ProductStatisticsService.GenerateProductKey("iPhone 15 (128GB Nero)");
+ Assert.DoesNotContain("128", key);
+ Assert.DoesNotContain("nero", key.ToLowerInvariant());
+ }
+
+ [Fact]
+ public void GenerateProductKey_RemovesBrackets()
+ {
+ var key = ProductStatisticsService.GenerateProductKey("Samsung Galaxy [Special Edition]");
+ Assert.DoesNotContain("special", key);
+ }
+
+ [Fact]
+ public void GenerateProductKey_RemovesColors()
+ {
+ var keyNero = ProductStatisticsService.GenerateProductKey("iPhone nero");
+ var keyBianco = ProductStatisticsService.GenerateProductKey("iPhone bianco");
+ // Both should produce same base key after color removal
+ Assert.Equal(keyNero, keyBianco);
+ }
+
+ [Fact]
+ public void GenerateProductKey_RemovesStorageCapacity()
+ {
+ var key128 = ProductStatisticsService.GenerateProductKey("iPhone 15 128GB");
+ var key256 = ProductStatisticsService.GenerateProductKey("iPhone 15 256GB");
+ Assert.Equal(key128, key256);
+ }
+
+ [Fact]
+ public void GenerateProductKey_LimitsLengthTo50()
+ {
+ var longName = new string('a', 200);
+ var key = ProductStatisticsService.GenerateProductKey(longName);
+ Assert.True(key.Length <= 50);
+ }
+
+ [Fact]
+ public void GenerateProductKey_SameProductDifferentVariants_SameKey()
+ {
+ var key1 = ProductStatisticsService.GenerateProductKey("Apple iPhone 15 Pro");
+ var key2 = ProductStatisticsService.GenerateProductKey("Apple iPhone 15 Pro (256GB)");
+ Assert.Equal(key1, key2);
+ }
+
+ // === CalculateRecommendedLimits ===
+
+ [Fact]
+ public void CalculateRecommendedLimits_LessThan3Results_ZeroConfidence()
+ {
+ var service = CreateService();
+ var results = new List
+ {
+ CreateResult(won: true, price: 5.0)
+ };
+
+ var limits = service.CalculateRecommendedLimits(results);
+
+ Assert.Equal(0, limits.ConfidenceScore);
+ }
+
+ [Fact]
+ public void CalculateRecommendedLimits_NoWins_ReturnsConservativeLimits()
+ {
+ var service = CreateService();
+ var results = Enumerable.Range(1, 5)
+ .Select(i => CreateResult(won: false, price: 10.0 + i))
+ .ToList();
+
+ var limits = service.CalculateRecommendedLimits(results);
+
+ Assert.Equal(10, limits.ConfidenceScore);
+ Assert.True(limits.MinPrice > 0);
+ Assert.True(limits.MaxPrice > limits.MinPrice);
+ }
+
+ [Fact]
+ public void CalculateRecommendedLimits_WithWins_CalculatesPercentilePrices()
+ {
+ var service = CreateService();
+ var results = Enumerable.Range(1, 20)
+ .Select(i => CreateResult(won: true, price: i * 1.0, resets: i, bidsUsed: i * 5))
+ .ToList();
+
+ var limits = service.CalculateRecommendedLimits(results);
+
+ Assert.True(limits.MinPrice > 0);
+ Assert.True(limits.MaxPrice > limits.MinPrice);
+ Assert.True(limits.MaxBids > 0);
+ Assert.True(limits.ConfidenceScore >= 50);
+ }
+
+ [Fact]
+ public void CalculateRecommendedLimits_ConfidenceScalesWithSampleSize()
+ {
+ var service = CreateService();
+
+ var small = Enumerable.Range(1, 5)
+ .Select(i => CreateResult(won: true, price: i * 1.0))
+ .ToList();
+ var large = Enumerable.Range(1, 50)
+ .Select(i => CreateResult(won: true, price: i * 1.0))
+ .ToList();
+
+ var smallLimits = service.CalculateRecommendedLimits(small);
+ var largeLimits = service.CalculateRecommendedLimits(large);
+
+ Assert.True(largeLimits.ConfidenceScore > smallLimits.ConfidenceScore);
+ }
+
+ [Fact]
+ public void CalculateRecommendedLimits_FindsBestHourToPlay()
+ {
+ var service = CreateService();
+ var results = new List();
+
+ // 5 wins at hour 14, 2 wins at hour 20
+ for (int i = 0; i < 5; i++)
+ results.Add(CreateResult(won: true, price: 5.0, closedAtHour: 14));
+ for (int i = 0; i < 2; i++)
+ results.Add(CreateResult(won: true, price: 5.0, closedAtHour: 20));
+
+ var limits = service.CalculateRecommendedLimits(results);
+
+ Assert.Equal(14, limits.BestHourToPlay);
+ }
+
+ // === Helpers ===
+
+ private static ProductStatisticsService CreateService()
+ {
+ // ProductStatisticsService requires DatabaseService, but CalculateRecommendedLimits
+ // and GenerateProductKey don't use it. We pass null through a helper constructor workaround.
+ // Since there's no interface, we instantiate with a real (unused) DatabaseService.
+ // The static methods and CalculateRecommendedLimits don't access _db.
+ return new ProductStatisticsService(null!);
+ }
+
+ private static AuctionResultExtended CreateResult(
+ bool won,
+ double price,
+ int? resets = null,
+ int bidsUsed = 0,
+ int? winnerBidsUsed = null,
+ int? closedAtHour = null)
+ {
+ return new AuctionResultExtended
+ {
+ AuctionId = Guid.NewGuid().ToString(),
+ AuctionName = "Test",
+ FinalPrice = price,
+ BidsUsed = bidsUsed,
+ Won = won,
+ TotalResets = resets,
+ WinnerBidsUsed = winnerBidsUsed ?? (won ? bidsUsed : null),
+ ClosedAtHour = closedAtHour
+ };
+ }
+}
diff --git a/Mimante/Tests/AutoBidder.Tests/ProductValueCalculatorTests.cs b/Mimante/Tests/AutoBidder.Tests/ProductValueCalculatorTests.cs
new file mode 100644
index 0000000..abbbbca
--- /dev/null
+++ b/Mimante/Tests/AutoBidder.Tests/ProductValueCalculatorTests.cs
@@ -0,0 +1,285 @@
+using AutoBidder.Models;
+using AutoBidder.Utilities;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+public class ProductValueCalculatorTests
+{
+ // === Calculate ===
+
+ [Fact]
+ public void Calculate_BasicAuction_ComputesTotalCostIfWin()
+ {
+ var auction = new AuctionInfo
+ {
+ AuctionId = "1",
+ BidCost = 0.20,
+ BuyNowPrice = 100.0,
+ ShippingCost = 5.0,
+ BidsUsedOnThisAuction = 10
+ };
+
+ var result = ProductValueCalculator.Calculate(auction, currentPrice: 2.0, totalBids: 200);
+
+ Assert.Equal(2.0, result.CurrentPrice);
+ Assert.Equal(200, result.TotalBids);
+ Assert.Equal(10, result.MyBids);
+ Assert.Equal(2.0, result.MyBidsCost, 2); // 10 * 0.20
+ Assert.Equal(9.0, result.TotalCostIfWin, 2); // 2.0 + 2.0 + 5.0
+ }
+
+ [Fact]
+ public void Calculate_WithBuyNowPrice_ComputesSavings()
+ {
+ var auction = new AuctionInfo
+ {
+ AuctionId = "1",
+ BidCost = 0.20,
+ BuyNowPrice = 100.0,
+ ShippingCost = 5.0,
+ BidsUsedOnThisAuction = 10
+ };
+
+ var result = ProductValueCalculator.Calculate(auction, currentPrice: 2.0, totalBids: 200);
+
+ // buyNowTotal = 100 + 5 = 105
+ // totalCostIfWin = 2.0 + 2.0 + 5.0 = 9.0
+ // savings = 105 - 9 = 96
+ Assert.True(result.Savings.HasValue);
+ Assert.Equal(96.0, result.Savings!.Value, 2);
+ Assert.True(result.IsWorthIt);
+ }
+
+ [Fact]
+ public void Calculate_WithoutBuyNowPrice_IsWorthItTrue()
+ {
+ var auction = new AuctionInfo
+ {
+ AuctionId = "1",
+ BidCost = 0.20,
+ BuyNowPrice = null,
+ ShippingCost = null,
+ BidsUsedOnThisAuction = 5
+ };
+
+ var result = ProductValueCalculator.Calculate(auction, currentPrice: 1.0, totalBids: 100);
+
+ Assert.True(result.IsWorthIt);
+ Assert.Null(result.Savings);
+ Assert.Null(result.SavingsPercentage);
+ }
+
+ [Fact]
+ public void Calculate_WhenTotalCostExceedsBuyNow_NotWorthIt()
+ {
+ var auction = new AuctionInfo
+ {
+ AuctionId = "1",
+ BidCost = 0.20,
+ BuyNowPrice = 5.0,
+ ShippingCost = 1.0,
+ BidsUsedOnThisAuction = 50
+ };
+
+ // totalCostIfWin = 3.0 + 10.0 + 1.0 = 14.0 > buyNowTotal (5 + 1 = 6)
+ var result = ProductValueCalculator.Calculate(auction, currentPrice: 3.0, totalBids: 300);
+
+ Assert.False(result.IsWorthIt);
+ Assert.True(result.Savings!.Value < 0);
+ }
+
+ [Fact]
+ public void Calculate_ZeroBuyNowPrice_IsWorthItTrue()
+ {
+ var auction = new AuctionInfo
+ {
+ AuctionId = "1",
+ BidCost = 0.20,
+ BuyNowPrice = 0.0,
+ ShippingCost = null,
+ BidsUsedOnThisAuction = 1
+ };
+
+ var result = ProductValueCalculator.Calculate(auction, currentPrice: 1.0, totalBids: 100);
+
+ // BuyNowPrice == 0 falls into the else branch (no valid buy now price)
+ Assert.True(result.IsWorthIt);
+ Assert.Null(result.Savings);
+ Assert.Null(result.SavingsPercentage);
+ }
+
+ [Fact]
+ public void Calculate_NoShippingCost_ExcludesShippingFromTotal()
+ {
+ var auction = new AuctionInfo
+ {
+ AuctionId = "1",
+ BidCost = 0.20,
+ BuyNowPrice = 50.0,
+ ShippingCost = null,
+ BidsUsedOnThisAuction = 5
+ };
+
+ var result = ProductValueCalculator.Calculate(auction, currentPrice: 1.0, totalBids: 100);
+
+ // totalCostIfWin = 1.0 + 1.0 = 2.0 (no shipping)
+ Assert.Equal(2.0, result.TotalCostIfWin, 2);
+ }
+
+ [Fact]
+ public void Calculate_GeneratesSummaryString()
+ {
+ var auction = new AuctionInfo
+ {
+ AuctionId = "1",
+ BidCost = 0.20,
+ BuyNowPrice = 100.0,
+ ShippingCost = null,
+ BidsUsedOnThisAuction = 0
+ };
+
+ var result = ProductValueCalculator.Calculate(auction, currentPrice: 5.0, totalBids: 500);
+
+ Assert.False(string.IsNullOrEmpty(result.Summary));
+ }
+
+ // === ExtractProductInfo ===
+
+ [Fact]
+ public void ExtractProductInfo_EmptyHtml_ReturnsFalse()
+ {
+ var auction = new AuctionInfo { AuctionId = "1" };
+ Assert.False(ProductValueCalculator.ExtractProductInfo("", auction));
+ Assert.False(ProductValueCalculator.ExtractProductInfo(null!, auction));
+ }
+
+ [Fact]
+ public void ExtractProductInfo_ProductValueSpan_ExtractsBuyNowPrice()
+ {
+ var html = @"Valore: 18,90 €";
+ var auction = new AuctionInfo { AuctionId = "1" };
+
+ var result = ProductValueCalculator.ExtractProductInfo(html, auction);
+
+ Assert.True(result);
+ Assert.Equal(18.90, auction.BuyNowPrice!.Value, 2);
+ }
+
+ [Fact]
+ public void ExtractProductInfo_BuyItNowButton_ExtractsBuyNowPrice()
+ {
+ var html = @"Compra a 25,50 €";
+ var auction = new AuctionInfo { AuctionId = "1" };
+
+ var result = ProductValueCalculator.ExtractProductInfo(html, auction);
+
+ Assert.True(result);
+ Assert.Equal(25.50, auction.BuyNowPrice!.Value, 2);
+ }
+
+ [Fact]
+ public void ExtractProductInfo_CompraloOra_ExtractsBuyNowPrice()
+ {
+ var html = @"COMPRALO ORA A 42,99 €";
+ var auction = new AuctionInfo { AuctionId = "1" };
+
+ var result = ProductValueCalculator.ExtractProductInfo(html, auction);
+
+ Assert.True(result);
+ Assert.Equal(42.99, auction.BuyNowPrice!.Value, 2);
+ }
+
+ [Fact]
+ public void ExtractProductInfo_ShippingCost_ExtractsShipping()
+ {
+ var html = @"Spese di spedizione: 4,99 €";
+ var auction = new AuctionInfo { AuctionId = "1" };
+
+ var result = ProductValueCalculator.ExtractProductInfo(html, auction);
+
+ Assert.True(result);
+ Assert.Equal(4.99, auction.ShippingCost!.Value, 2);
+ }
+
+ [Fact]
+ public void ExtractProductInfo_TransactionCost_ExtractsAsShipping()
+ {
+ var html = @"Spese di transazione: 1,00 €";
+ var auction = new AuctionInfo { AuctionId = "1" };
+
+ var result = ProductValueCalculator.ExtractProductInfo(html, auction);
+
+ Assert.True(result);
+ Assert.Equal(1.00, auction.ShippingCost!.Value, 2);
+ }
+
+ [Fact]
+ public void ExtractProductInfo_WinLimit_ExtractsLimit()
+ {
+ var html = @"Limiti di vincita: 1 ogni 30 giorni";
+ var auction = new AuctionInfo { AuctionId = "1" };
+
+ var result = ProductValueCalculator.ExtractProductInfo(html, auction);
+
+ Assert.True(result);
+ Assert.True(auction.HasWinLimit);
+ Assert.Equal("1 ogni 30 giorni", auction.WinLimitDescription);
+ }
+
+ // === FormatValueMessage ===
+
+ [Fact]
+ public void FormatValueMessage_NoBuyNowPrice_ReturnsGenericMessage()
+ {
+ var value = new ProductValue
+ {
+ TotalCostIfWin = 5.0,
+ CurrentPrice = 3.0,
+ MyBidsCost = 2.0,
+ BuyNowPrice = null
+ };
+
+ var message = ProductValueCalculator.FormatValueMessage(value);
+
+ Assert.Contains("5,00", message);
+ }
+
+ [Fact]
+ public void FormatValueMessage_IsWorthIt_ContainsConveniente()
+ {
+ var value = new ProductValue
+ {
+ TotalCostIfWin = 10.0,
+ CurrentPrice = 5.0,
+ MyBidsCost = 5.0,
+ BuyNowPrice = 100.0,
+ IsWorthIt = true,
+ Savings = 90.0,
+ SavingsPercentage = 90.0
+ };
+
+ var message = ProductValueCalculator.FormatValueMessage(value);
+
+ Assert.Contains("Conveniente", message);
+ }
+
+ [Fact]
+ public void FormatValueMessage_NotWorthIt_ContainsNonConveniente()
+ {
+ var value = new ProductValue
+ {
+ TotalCostIfWin = 120.0,
+ CurrentPrice = 100.0,
+ MyBidsCost = 20.0,
+ BuyNowPrice = 50.0,
+ IsWorthIt = false,
+ Savings = -70.0,
+ SavingsPercentage = -140.0
+ };
+
+ var message = ProductValueCalculator.FormatValueMessage(value);
+
+ Assert.Contains("Non conveniente", message);
+ }
+}