281 lines
14 KiB
C#
281 lines
14 KiB
C#
|
|
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;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 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.
|
|||
|
|
/// </summary>
|
|||
|
|
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.
|
|||
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Builds a resolved config dictionary keyed by Symbol (simulates
|
|||
|
|
/// IChargeableCharConfigService.GetActiveConfigForMedioAsync result).
|
|||
|
|
/// </summary>
|
|||
|
|
private static IReadOnlyDictionary<string, ChargeableCharSnapshot> BuildResolvedConfig(
|
|||
|
|
IEnumerable<(string Symbol, string Category, decimal PricePerUnit)> entries)
|
|||
|
|
{
|
|||
|
|
var dict = new Dictionary<string, ChargeableCharSnapshot>(StringComparer.Ordinal);
|
|||
|
|
foreach (var (symbol, category, price) in entries)
|
|||
|
|
dict[symbol] = new ChargeableCharSnapshot(category, price);
|
|||
|
|
return dict;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 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.
|
|||
|
|
/// </summary>
|
|||
|
|
private static decimal ComputeSpecialCharCharge(
|
|||
|
|
WordCountResult countResult,
|
|||
|
|
IReadOnlyDictionary<string, ChargeableCharSnapshot> 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<string>(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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 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.
|
|||
|
|
/// </summary>
|
|||
|
|
private static decimal ComputeSpecialCharChargeByCategory(
|
|||
|
|
WordCountResult countResult,
|
|||
|
|
IReadOnlyDictionary<string, ChargeableCharSnapshot> resolvedConfig)
|
|||
|
|
=> ComputeSpecialCharCharge(countResult, resolvedConfig);
|
|||
|
|
}
|