using FluentAssertions; using SIGCM2.Application.Pricing.ChargeableChars; using SIGCM2.Domain.Pricing.ChargeableChars; using SIGCM2.Domain.Pricing.WordCounter; using Xunit; namespace SIGCM2.Application.Tests.Pricing.ChargeableChars; /// /// PRC-001 Batch 7 — T7.6: WordCounterService × ChargeableCharConfig integration contract. /// /// Documents the integration pattern that PRC-002+ will follow when computing /// special-character billing charges for classified ads. /// /// Contract: /// 1. WordCounterService.Count(text) → WordCountResult with SpecialCharCounts keyed by Category. /// 2. IChargeableCharConfigService.GetActiveConfigForMedioAsync(medioId, today) /// → IReadOnlyDictionary<string, ChargeableCharSnapshot> keyed by Symbol. /// 3. A charge calculator maps SpecialCharCounts (by Category) against resolved config /// (by Symbol) to compute the total special-character billing amount. /// /// This test is a PURE UNIT TEST — no DB, no HTTP, no DI container. /// It exercises the in-memory integration point using the real WordCounterService /// and a hand-built resolved config dictionary. /// /// Spec reference: PRC-001-R1 + C5 golden cases + design §11.1. /// public sealed class WordCounterChargeableCharIntegrationTests { private readonly WordCounterService _svc = new(); // ───────────────────────────────────────────────────────────────────────── // T7.6.a — Basic integration contract // // Text: "vendo $5000 %50" // WordCount result: { Currency: 1, Percentage: 1 } // Resolved config: { "$": 2.00, "%": 3.00 } // Expected charge: 1 × 2.00 + 1 × 3.00 = 5.00 // ───────────────────────────────────────────────────────────────────────── [Fact] public void BasicIntegration_CurrencyAndPercentage_ComputesCorrectCharge() { const string text = "vendo $5000 %50"; // Step 1: count specials var countResult = _svc.Count(text); countResult.TotalWords.Should().Be(3, "vendo + 5000 + 50 after special replacement"); countResult.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1); countResult.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1); // Step 2: resolved config (simulates IChargeableCharConfigService output) // Key is Symbol; ChargeableCharSnapshot holds Category + PricePerUnit. var resolvedConfig = BuildResolvedConfig(new[] { ("$", "Currency", 2.0000m), ("%", "Percentage", 3.0000m), }); // Step 3: compute charge using the integration helper var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); charge.Should().Be(5.0000m, "1 × $2.00 (Currency) + 1 × $3.00 (Percentage) = $5.00"); } // ───────────────────────────────────────────────────────────────────────── // T7.6.b — Anti-fraud: multiple specials per word // // Text: "P$a$l$a$b$r$a" (anti-fraud: 7 currency symbols embedded in word) // WordCount: TotalWords = 7, Currency = 6 // Config: { "$": 1.50 } // Charge: 6 × 1.50 = 9.00 // ───────────────────────────────────────────────────────────────────────── [Fact] public void AntiFraud_MultipleSpecialsEmbedded_CountsEachOccurrence() { const string text = "P$a$l$a$b$r$a"; var countResult = _svc.Count(text); countResult.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(6, "anti-fraud: 6 '$' embedded in the word — each is counted before replacement"); var resolvedConfig = BuildResolvedConfig(new[] { ("$", "Currency", 1.5000m), }); var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); charge.Should().Be(9.0000m, "6 × $1.50 = $9.00"); } // ───────────────────────────────────────────────────────────────────────── // T7.6.c — Symbol not in config → 0 charge (free special) // // Text: "¿Que tal?" // WordCount: { Question: 2 } // Config: only "$" and "%" configured — "?" and "¿" are NOT priced symbols. // Charge: 0.00 (no config entry for Question symbols) // ───────────────────────────────────────────────────────────────────────── [Fact] public void SymbolNotInConfig_ProducesZeroCharge() { const string text = "¿Que tal?"; var countResult = _svc.Count(text); countResult.SpecialCharCounts.GetValueOrDefault("Question").Should().Be(2, "¿ and ? each count as 1 Question"); // Config has no entry for "?" or "¿" — Questions are not billable in this scenario var resolvedConfig = BuildResolvedConfig(new[] { ("$", "Currency", 2.0000m), ("%", "Percentage", 3.0000m), }); var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); charge.Should().Be(0.0000m, "Question symbols are not in the config — no charge should apply"); } // ───────────────────────────────────────────────────────────────────────── // T7.6.d — Combined scenario: multiple categories, all billable // // Text: "¡Oferta! $500 -20% hoy" // From GC-23: TotalWords=4, Currency=1, Percentage=1, Exclamation=2 // Config: { "$": 2.00, "%": 3.00, "!": 0.50, "¡": 0.50 } // Charge: 1×2.00 + 1×3.00 + 2×0.50 = 6.00 // // Note: "!" and "¡" are two distinct symbols but both are Exclamation category. // The charge is computed per-symbol-occurrence, not per-category. // WordCountResult gives us Exclamation=2 total; we price them the same since both // symbols have the same configured price (0.50 each). // ───────────────────────────────────────────────────────────────────────── [Fact] public void CombinedCategories_AllBillable_ComputesTotalCorrectly() { const string text = "¡Oferta! $500 -20% hoy"; var countResult = _svc.Count(text); // Verify WordCounterService counts (GC-23 equivalent) countResult.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1); countResult.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1); countResult.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(2, "both '¡' and '!' count as Exclamation (GC-23)"); // Resolved config: all four symbols priced var resolvedConfig = BuildResolvedConfig(new[] { ("$", "Currency", 2.0000m), ("%", "Percentage", 3.0000m), ("!", "Exclamation", 0.5000m), ("¡", "Exclamation", 0.5000m), }); // The charge calculator works at the CATEGORY level: // for each category in SpecialCharCounts, find the first config entry for that category, // use its price × occurrence count. (This simplification is valid for PRC-001 spike; // PRC-002 will introduce per-symbol pricing if symbols in same category have different prices.) var chargePerCategory = ComputeSpecialCharChargeByCategory(countResult, resolvedConfig); // 1×2.00 + 1×3.00 + 2×0.50 = 6.00 chargePerCategory.Should().Be(6.0000m, "Currency 1×2.00 + Percentage 1×3.00 + Exclamation 2×0.50 = 6.00"); } // ───────────────────────────────────────────────────────────────────────── // T7.6.e — Empty text → zero charge // ───────────────────────────────────────────────────────────────────────── [Fact] public void EmptyText_ProducesZeroWordsAndZeroCharge() { var countResult = _svc.Count(""); countResult.TotalWords.Should().Be(0); countResult.SpecialCharCounts.Should().BeEmpty(); var resolvedConfig = BuildResolvedConfig(new[] { ("$", "Currency", 2.0000m), }); var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); charge.Should().Be(0.0000m, "no specials in empty text → zero charge"); } // ───────────────────────────────────────────────────────────────────────── // T7.6.f — Null text → zero charge (WordCounterService null-safe) // ───────────────────────────────────────────────────────────────────────── [Fact] public void NullText_ProducesZeroWordsAndZeroCharge() { var countResult = _svc.Count(null); countResult.TotalWords.Should().Be(0); countResult.SpecialCharCounts.Should().BeEmpty(); var resolvedConfig = BuildResolvedConfig(Array.Empty<(string, string, decimal)>()); var charge = ComputeSpecialCharCharge(countResult, resolvedConfig); charge.Should().Be(0.0000m); } // ───────────────────────────────────────────────────────────────────────── // Integration contract: charge calculator helpers // // These helpers document the PRC-002+ integration contract. // A real implementation would live in a pricing service or use case handler. // ───────────────────────────────────────────────────────────────────────── /// /// Builds a resolved config dictionary keyed by Symbol (simulates /// IChargeableCharConfigService.GetActiveConfigForMedioAsync result). /// private static IReadOnlyDictionary BuildResolvedConfig( IEnumerable<(string Symbol, string Category, decimal PricePerUnit)> entries) { var dict = new Dictionary(StringComparer.Ordinal); foreach (var (symbol, category, price) in entries) dict[symbol] = new ChargeableCharSnapshot(category, price); return dict; } /// /// Computes the total special-character billing charge. /// /// Algorithm (per-symbol lookup): /// For each symbol in the resolved config where the config category matches a /// SpecialCharCounts category, multiply the category occurrence count by the symbol price. /// /// This is the SIMPLEST form of the integration contract: one price per category. /// PRC-002+ can extend this to per-symbol pricing if different symbols in the same /// category have different prices. /// private static decimal ComputeSpecialCharCharge( WordCountResult countResult, IReadOnlyDictionary resolvedConfig) { if (countResult.SpecialCharCounts.Count == 0) return 0m; var total = 0m; // For each configured symbol, check if there are occurrences of its category. // Use the first symbol in each category as the representative price. // (Sufficient for PRC-001 where symbols within the same category share the same price.) var chargedCategories = new HashSet(StringComparer.Ordinal); foreach (var (symbol, snapshot) in resolvedConfig) { if (chargedCategories.Contains(snapshot.Category)) continue; if (countResult.SpecialCharCounts.TryGetValue(snapshot.Category, out var count)) { total += count * snapshot.PricePerUnit; chargedCategories.Add(snapshot.Category); } } return total; } /// /// Category-level charge computation: resolves price by finding any configured symbol /// for each category, then multiplies by occurrence count. /// Explicit version of ComputeSpecialCharCharge for clarity in documentation tests. /// private static decimal ComputeSpecialCharChargeByCategory( WordCountResult countResult, IReadOnlyDictionary resolvedConfig) => ComputeSpecialCharCharge(countResult, resolvedConfig); }