diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs
new file mode 100644
index 0000000..2e7cde7
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Infrastructure/Pricing/ChargeableCharConfigHardeningTests.cs
@@ -0,0 +1,386 @@
+using Dapper;
+using FluentAssertions;
+using Microsoft.Data.SqlClient;
+using SIGCM2.Infrastructure.Persistence;
+using SIGCM2.TestSupport;
+using Xunit;
+
+namespace SIGCM2.Application.Tests.Infrastructure.Pricing;
+
+///
+/// PRC-001 Batch 7 — Integration hardening tests for ChargeableCharConfig.
+/// Covers cross-cutting concerns not addressed by individual layer batches:
+///
+/// T7.1 Concurrency: SemaphoreSlim barrier forces genuine parallel race on
+/// usp_ChargeableCharConfig_InsertWithClose — only 1 winner, SERIALIZABLE guard holds.
+///
+/// T7.2 SYSTEM_VERSIONING: exact row count before/after close (0 → 1).
+///
+/// T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state.
+///
+/// T7.4 Per-medio + global fallback resolution via GetActiveForMedioAsync:
+/// - ELDIA override for '$' → per-medio row returned at priority
+/// - ELPLATA (no override) → global fallback returned
+///
+/// All tests run against SIGCM2_Test_App (Database collection + SqlTestFixture).
+/// Each test seeds its own unique symbols to avoid cross-test interference.
+///
+[Collection("Database")]
+public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
+{
+ private readonly SqlTestFixture _db;
+ private int _eldiaId;
+ private int _elplataId;
+
+ public ChargeableCharConfigHardeningTests(SqlTestFixture db)
+ {
+ _db = db;
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _db.ResetAndSeedAsync();
+
+ await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
+ await conn.OpenAsync();
+
+ // Seed two dedicated medios: ELDIA (has per-medio override) and ELPLATA (no override).
+ _eldiaId = await conn.ExecuteScalarAsync("""
+ INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
+ OUTPUT INSERTED.Id
+ VALUES ('HARD_ELDIA', 'ELDIA Hardening', 1, 1)
+ """);
+
+ _elplataId = await conn.ExecuteScalarAsync("""
+ INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
+ OUTPUT INSERTED.Id
+ VALUES ('HARD_ELPLA', 'ELPLATA Hardening', 1, 1)
+ """);
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // T7.1 — Concurrency: only one winner survives the race
+ //
+ // Three parallel connections try to InsertWithClose for the same (MedioId=null, Symbol).
+ // The SP uses SERIALIZABLE + UPDLOCK + HOLDLOCK, so only one can commit.
+ // The other two must receive SqlException (50409, 2601, 2627, or deadlock 1205).
+ //
+ // After resolution: exactly 1 vigente row exists for (MedioId=NULL, Symbol).
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Concurrency_ThreeParallelInserts_ExactlyOneWins()
+ {
+ // Unique symbol so this test doesn't conflict with other tests or seed data.
+ const string symbol = "¢";
+ const string category = "Currency";
+
+ // Barrier: all tasks must wait until released simultaneously to maximize the race.
+ var barrier = new SemaphoreSlim(0, 3);
+
+ async Task TryInsert(decimal price)
+ {
+ await barrier.WaitAsync();
+ try
+ {
+ await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
+ await conn.OpenAsync();
+
+ var p = new DynamicParameters();
+ p.Add("@MedioId", null, System.Data.DbType.Int32);
+ p.Add("@Symbol", symbol, System.Data.DbType.String);
+ p.Add("@Category", category, System.Data.DbType.String);
+ p.Add("@PricePerUnit", price, System.Data.DbType.Decimal, precision: 18, scale: 4);
+ p.Add("@ValidFrom", new DateTime(2027, 9, 1), System.Data.DbType.Date);
+ p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
+ p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
+
+ await conn.ExecuteAsync(
+ "dbo.usp_ChargeableCharConfig_InsertWithClose",
+ p,
+ commandType: System.Data.CommandType.StoredProcedure);
+
+ return null; // winner
+ }
+ catch (SqlException ex)
+ {
+ // Expected losers: 50409 (forward-only), 2601/2627 (unique index), 1205 (deadlock)
+ return ex;
+ }
+ }
+
+ var t1 = Task.Run(() => TryInsert(1.00m));
+ var t2 = Task.Run(() => TryInsert(2.00m));
+ var t3 = Task.Run(() => TryInsert(3.00m));
+
+ // Release all three simultaneously to create a genuine race.
+ barrier.Release(3);
+
+ var results = await Task.WhenAll(t1, t2, t3);
+
+ var successes = results.Count(r => r is null);
+ var failures = results.Count(r => r is not null);
+
+ successes.Should().Be(1, "exactly one concurrent InsertWithClose must succeed");
+ failures.Should().Be(2, "the other two concurrent inserts must fail with SqlException");
+
+ // Verify post-race state: exactly 1 vigente row for (NULL, '¢')
+ await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb);
+ await verifyConn.OpenAsync();
+
+ var vigente = await verifyConn.ExecuteScalarAsync("""
+ SELECT COUNT(1)
+ FROM dbo.ChargeableCharConfig
+ WHERE MedioId IS NULL
+ AND Symbol = @Symbol
+ AND ValidTo IS NULL
+ AND IsActive = 1
+ """, new { Symbol = symbol });
+
+ vigente.Should().Be(1,
+ "filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (MedioId, Symbol)");
+
+ // No duplicates: total row count must also be 1 (only the winner was inserted)
+ var total = await verifyConn.ExecuteScalarAsync("""
+ SELECT COUNT(1)
+ FROM dbo.ChargeableCharConfig
+ WHERE MedioId IS NULL
+ AND Symbol = @Symbol
+ """, new { Symbol = symbol });
+
+ total.Should().Be(1, "no duplicate rows must exist — SERIALIZABLE guard ensures only one insert commits");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // T7.2 — SYSTEM_VERSIONING: exact history row count before and after close
+ //
+ // Before any UPDATE: history table has 0 rows for the new Id.
+ // After InsertWithClose closes the previous row (UPDATE): exactly 1 history row.
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task SystemVersioning_HistoryCount_IsZeroBeforeClose_AndOneAfter()
+ {
+ const string symbol = "₽";
+
+ await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
+ await conn.OpenAsync();
+
+ // Insert first row (becomes vigente)
+ var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 1.0m, new DateTime(2026, 1, 1));
+
+ // Before any UPDATE: history table must have 0 rows for firstId
+ var histBefore = await conn.ExecuteScalarAsync("""
+ SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id
+ """, new { Id = firstId });
+
+ histBefore.Should().Be(0,
+ "SYSTEM_VERSIONING only creates history rows on UPDATE/DELETE — INSERT produces no history row");
+
+ // Insert second row which closes (UPDATEs) the first
+ await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 2.0m, new DateTime(2026, 7, 1));
+
+ // After the UPDATE (close): exactly 1 history row for firstId
+ var histAfter = await conn.ExecuteScalarAsync("""
+ SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id
+ """, new { Id = firstId });
+
+ histAfter.Should().Be(1,
+ "SYSTEM_VERSIONING must produce exactly one history row when the vigente row is closed via UPDATE");
+
+ // Verify: the history row captured the pre-close state (ValidTo was NULL before the UPDATE)
+ var histRow = await conn.QuerySingleOrDefaultAsync("""
+ SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig_History WHERE Id = @Id
+ """, new { Id = firstId });
+
+ ((object?)histRow).Should().NotBeNull("history row must exist after close");
+
+ // The history captures the state as it was BEFORE the UPDATE:
+ // before close, the row had ValidTo = NULL and IsActive = 1.
+ ((object?)histRow!.ValidTo).Should().BeNull(
+ "the history row captures the state before the close UPDATE — ValidTo was NULL at that point");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // T7.3 — FOR SYSTEM_TIME AS OF: temporal snapshot returns pre-close state
+ //
+ // Create row at T0 → query FOR SYSTEM_TIME AS OF T0 → returns row with ValidTo = NULL.
+ // After close → current query → row has ValidTo != NULL.
+ // This validates that SYSTEM_VERSIONING preserves immutable history.
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ForSystemTimeAsOf_ReturnsSnapshotAtT0_BeforeClose()
+ {
+ const string symbol = "₿";
+
+ await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
+ await conn.OpenAsync();
+
+ // Insert first row and capture the UTC timestamp immediately after
+ var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 5.0m, new DateTime(2026, 3, 1));
+
+ // Capture T0: the SYSUTCDATETIME() right after INSERT (row is still active)
+ var t0 = await conn.ExecuteScalarAsync("SELECT SYSUTCDATETIME()");
+
+ // Wait 200ms so the SYSTEM_VERSIONING SysEndTime is strictly after T0
+ // (SQL Server DATETIME2 has ~100ns precision — 200ms is more than sufficient)
+ await Task.Delay(200);
+
+ // Insert second row — this closes (UPDATEs) the first, sending it to history
+ await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 6.0m, new DateTime(2026, 8, 1));
+
+ // FOR SYSTEM_TIME AS OF T0: must return the first row in its pre-close state
+ var snapshotRow = await conn.QuerySingleOrDefaultAsync("""
+ SELECT Id, PricePerUnit, ValidTo
+ FROM dbo.ChargeableCharConfig
+ FOR SYSTEM_TIME AS OF @T0
+ WHERE Id = @Id
+ """, new { T0 = t0, Id = firstId });
+
+ ((object?)snapshotRow).Should().NotBeNull(
+ "FOR SYSTEM_TIME AS OF T0 must return the row as it existed at T0 (before the close UPDATE)");
+
+ ((decimal)snapshotRow!.PricePerUnit).Should().Be(5.0m,
+ "snapshot must reflect the original price before the close");
+
+ ((object?)snapshotRow.ValidTo).Should().BeNull(
+ "at T0 the row was vigente (ValidTo IS NULL) — snapshot must preserve this");
+
+ // Current state: first row must now have ValidTo != NULL (it was closed)
+ var currentRow = await conn.QuerySingleOrDefaultAsync("""
+ SELECT ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id
+ """, new { Id = firstId });
+
+ ((object?)currentRow).Should().NotBeNull("the closed row still exists in the current table");
+ ((object?)currentRow!.ValidTo).Should().NotBeNull(
+ "after the close, the current row has ValidTo set — it is no longer vigente");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // T7.4 — Per-medio + global fallback resolution via GetActiveForMedioAsync
+ //
+ // Scenario:
+ // - Global '$' at price 1.00 (seed row from ResetAndSeedAsync / canonical V022 seed)
+ // - ELDIA-specific '$' at price 5.00 effective from today (per-medio override)
+ // - ELPLATA has no override for '$'
+ //
+ // GetActiveConfigForMedioAsync(ELDIA, today) → '$' = 5.00 (per-medio override wins)
+ // GetActiveConfigForMedioAsync(ELPLATA, today) → '$' = 1.00 (global fallback)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetActiveConfigForMedio_EldiaOverride_WinsOverGlobal()
+ {
+ var asOf = new DateOnly(2026, 6, 1);
+
+ // Seed per-medio override for ELDIA: '$' at 5.00 effective from 2026-01-01
+ await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
+ await seedConn.OpenAsync();
+
+ await ExecInsertWithCloseAsync(seedConn, _eldiaId, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1));
+
+ // Build the repository + service (same as application layer usage)
+ var repo = BuildRepository();
+ var rows = await repo.GetActiveForMedioAsync((long)_eldiaId, asOf);
+
+ // The per-medio '$' must be returned
+ var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
+ dollarRow.Should().NotBeNull("ELDIA has a per-medio '$' override — SP must return it");
+
+ dollarRow!.MedioId.Should().Be(_eldiaId,
+ "the per-medio row (MedioId = ELDIA) must take priority over the global row");
+
+ dollarRow.PricePerUnit.Should().Be(5.0000m,
+ "ELDIA override has price 5.00, not the global 1.00");
+ }
+
+ [Fact]
+ public async Task GetActiveConfigForMedio_ElplataNoOverride_FallsBackToGlobal()
+ {
+ var asOf = new DateOnly(2026, 6, 1);
+
+ // ELPLATA has no per-medio rows — the canonical global seed from ResetAndSeedAsync
+ // provides '$' at global price.
+ var repo = BuildRepository();
+ var rows = await repo.GetActiveForMedioAsync((long)_elplataId, asOf);
+
+ // Must have at least the global '$' from seed
+ rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01");
+
+ var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
+ dollarRow.Should().NotBeNull("global '$' must be returned for ELPLATA (no override exists)");
+
+ dollarRow!.MedioId.Should().BeNull(
+ "ELPLATA has no override — the returned row must be the global row (MedioId = NULL)");
+ }
+
+ [Fact]
+ public async Task GetActiveConfigForMedio_ServiceLayer_AppliesPriorityCorrectly()
+ {
+ // End-to-end: IChargeableCharConfigService resolves the final dictionary.
+ // Seed ELDIA override for '%' (percentage) at 3.00; global '%' at 2.00 (from V022 seed).
+ var asOf = new DateOnly(2026, 6, 1);
+
+ await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
+ await seedConn.OpenAsync();
+
+ await ExecInsertWithCloseAsync(seedConn, _eldiaId, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1));
+
+ // Build the service (wraps repo with priority resolution)
+ var service = BuildService();
+
+ var eldiaConfig = await service.GetActiveConfigForMedioAsync((long)_eldiaId, asOf);
+ var elplataConfig = await service.GetActiveConfigForMedioAsync((long)_elplataId, asOf);
+
+ // ELDIA: '%' must come from per-medio override at 3.00
+ eldiaConfig.Should().ContainKey("%",
+ "ELDIA has a per-medio override for '%'");
+ eldiaConfig["%"].PricePerUnit.Should().Be(3.0000m,
+ "per-medio '%' at 3.00 must override the global 2.00 for ELDIA");
+
+ // ELPLATA: '%' must come from global fallback
+ // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId).
+ // We verify via price: global '%' is seeded at 2.00 by V022.
+ elplataConfig.Should().ContainKey("%",
+ "global '%' from canonical seed must appear in ELPLATA's resolved config");
+ // V022 seeds global '%' at 1.0000 (placeholder — see V022__seed_chargeable_char_config.sql)
+ elplataConfig["%"].PricePerUnit.Should().Be(1.0000m,
+ "ELPLATA falls back to the global '%' at 1.00 (V022 seed placeholder price)");
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static async Task ExecInsertWithCloseAsync(
+ SqlConnection conn,
+ int? medioId,
+ string symbol,
+ string category,
+ decimal pricePerUnit,
+ DateTime validFrom)
+ {
+ var p = new DynamicParameters();
+ p.Add("@MedioId", medioId, System.Data.DbType.Int32);
+ p.Add("@Symbol", symbol, System.Data.DbType.String);
+ p.Add("@Category", category, System.Data.DbType.String);
+ p.Add("@PricePerUnit", pricePerUnit, System.Data.DbType.Decimal, precision: 18, scale: 4);
+ p.Add("@ValidFrom", validFrom, System.Data.DbType.Date);
+ p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
+ p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
+
+ await conn.ExecuteAsync(
+ "dbo.usp_ChargeableCharConfig_InsertWithClose",
+ p,
+ commandType: System.Data.CommandType.StoredProcedure);
+
+ return p.Get("@NewId");
+ }
+
+ private static ChargeableCharConfigRepository BuildRepository()
+ => new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb));
+
+ private static SIGCM2.Application.Pricing.ChargeableChars.ChargeableCharConfigService BuildService()
+ => new(BuildRepository());
+}
diff --git a/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs
new file mode 100644
index 0000000..055f317
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Pricing/ChargeableChars/WordCounterChargeableCharIntegrationTests.cs
@@ -0,0 +1,280 @@
+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);
+}