diff --git a/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharCategories.cs b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharCategories.cs
new file mode 100644
index 0000000..3088257
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharCategories.cs
@@ -0,0 +1,22 @@
+namespace SIGCM2.Domain.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — Canonical category names for chargeable characters.
+/// Persisted as nvarchar(32) in the database (enum-as-string).
+///
+public static class ChargeableCharCategories
+{
+ public const string Currency = "Currency";
+ public const string Percentage = "Percentage";
+ public const string Exclamation = "Exclamation";
+ public const string Question = "Question";
+ public const string Other = "Other";
+
+ private static readonly HashSet Valid = new(StringComparer.Ordinal)
+ {
+ Currency, Percentage, Exclamation, Question, Other
+ };
+
+ /// Returns true if the given category string is a known valid category.
+ public static bool IsValid(string? category) => category != null && Valid.Contains(category);
+}
diff --git a/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs
new file mode 100644
index 0000000..5d4337a
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs
@@ -0,0 +1,112 @@
+using SIGCM2.Domain.Pricing.Exceptions;
+
+namespace SIGCM2.Domain.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — Rich domain entity for chargeable character configuration.
+/// Represents a price-per-occurrence for a special character in classified ad text,
+/// scoped to a Medio (MedioId) or global (MedioId = null).
+///
+/// Forward-only price history: each new price schedules a NEW row; the current row
+/// is closed via SP (ValidTo = newValidFrom - 1 day). ScheduleNewPrice does NOT mutate
+/// this instance — it returns a new one. The actual close+insert happens in the repository.
+///
+/// MedioId = null → global default (lowest priority, overridden by per-medio row).
+///
+public sealed class ChargeableCharConfig
+{
+ public long Id { get; }
+ public int? MedioId { get; }
+ public string Symbol { get; }
+ public string Category { get; }
+ public decimal PricePerUnit { get; private set; }
+ public DateOnly ValidFrom { get; }
+ public DateOnly? ValidTo { get; private set; }
+ public bool IsActive { get; private set; }
+
+ private ChargeableCharConfig(
+ long id, int? medioId, string symbol, string category,
+ decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
+ {
+ Id = id;
+ MedioId = medioId;
+ Symbol = symbol;
+ Category = category;
+ PricePerUnit = price;
+ ValidFrom = validFrom;
+ ValidTo = validTo;
+ IsActive = isActive;
+ }
+
+ ///
+ /// Factory for new configs. Enforces all domain invariants.
+ /// Id is set to 0 until the entity is persisted.
+ ///
+ public static ChargeableCharConfig Create(
+ int? medioId, string symbol, string category, decimal price, DateOnly validFrom)
+ {
+ if (string.IsNullOrWhiteSpace(symbol))
+ throw new ChargeableCharConfigInvalidException(
+ nameof(Symbol), "Symbol no puede estar vacío.");
+
+ if (symbol.Length > 4)
+ throw new ChargeableCharConfigInvalidException(
+ nameof(Symbol), "Symbol no puede exceder 4 caracteres.");
+
+ if (price <= 0m)
+ throw new ChargeableCharConfigInvalidException(
+ nameof(PricePerUnit), "PricePerUnit debe ser > 0.");
+
+ if (!ChargeableCharCategories.IsValid(category))
+ throw new ChargeableCharConfigInvalidException(
+ nameof(Category), $"Category '{category}' inválida. Valores válidos: Currency, Percentage, Exclamation, Question, Other.");
+
+ return new ChargeableCharConfig(0, medioId, symbol, category, price, validFrom, null, true);
+ }
+
+ ///
+ /// Reconstructor from database (no validation). Used by the repository mapper only.
+ /// Allows creating entities with any state (e.g., IsActive=false, ValidTo set).
+ ///
+ public static ChargeableCharConfig Rehydrate(
+ long id, int? medioId, string symbol, string category,
+ decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
+ => new(id, medioId, symbol, category, price, validFrom, validTo, isActive);
+
+ ///
+ /// Schedules a new price (forward-only semantics).
+ /// Returns a NEW ChargeableCharConfig instance with the updated price and validFrom.
+ /// This instance is NOT mutated — the close+insert of rows happens in the repository via SP.
+ ///
+ /// Validates:
+ /// - newValidFrom >= today (Argentina) via TimeProvider
+ /// - newValidFrom > current ValidFrom (strictly greater — forward-only)
+ /// - newPrice > 0
+ ///
+ public ChargeableCharConfig ScheduleNewPrice(decimal newPrice, DateOnly newValidFrom, TimeProvider timeProvider)
+ {
+ var today = timeProvider.GetArgentinaToday();
+
+ if (newValidFrom < today)
+ throw new ChargeableCharConfigInvalidException(
+ nameof(ValidFrom),
+ $"newValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser >= hoy_AR ({today:yyyy-MM-dd}).");
+
+ if (newValidFrom <= ValidFrom)
+ throw new ChargeableCharConfigForwardOnlyException(MedioId, Symbol, newValidFrom, ValidFrom);
+
+ // Create validates price > 0 and category — reuse factory
+ return Create(MedioId, Symbol, Category, newPrice, newValidFrom);
+ }
+
+ ///
+ /// Deactivates this config row. Sets IsActive = false and ValidTo = today.
+ /// Idempotent: no-op if already inactive.
+ ///
+ public void Deactivate(DateOnly today)
+ {
+ if (!IsActive) return; // idempotent
+ IsActive = false;
+ ValidTo = today;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs
new file mode 100644
index 0000000..e82c1a9
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs
@@ -0,0 +1,28 @@
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Domain.Pricing.Exceptions;
+
+///
+/// PRC-001 — Thrown when attempting to schedule a new price with a ValidFrom that is
+/// not strictly greater than the currently active row's ValidFrom. → HTTP 409
+///
+public sealed class ChargeableCharConfigForwardOnlyException : DomainException
+{
+ public int? MedioId { get; }
+ public string Symbol { get; }
+ public DateOnly NewValidFrom { get; }
+ public DateOnly ActiveValidFrom { get; }
+
+ public ChargeableCharConfigForwardOnlyException(
+ int? medioId,
+ string symbol,
+ DateOnly newValidFrom,
+ DateOnly activeValidFrom)
+ : base($"El nuevo ValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al ValidFrom del activo ({activeValidFrom:yyyy-MM-dd}).")
+ {
+ MedioId = medioId;
+ Symbol = symbol;
+ NewValidFrom = newValidFrom;
+ ActiveValidFrom = activeValidFrom;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigInvalidException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigInvalidException.cs
new file mode 100644
index 0000000..3334780
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigInvalidException.cs
@@ -0,0 +1,22 @@
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Domain.Pricing.Exceptions;
+
+///
+/// PRC-001 — Thrown when a ChargeableCharConfig value fails domain business validation
+/// (e.g., PricePerUnit ≤ 0, Symbol empty/too long, Category unknown, ValidFrom in the past).
+/// → HTTP 400
+/// Used as defense-in-depth alongside FluentValidation in the Application layer.
+///
+public sealed class ChargeableCharConfigInvalidException : DomainException
+{
+ public string Field { get; }
+ public string Reason { get; }
+
+ public ChargeableCharConfigInvalidException(string field, string reason)
+ : base($"Valor inválido para {field}: {reason}")
+ {
+ Field = field;
+ Reason = reason;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/EmojiDetectedException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/EmojiDetectedException.cs
new file mode 100644
index 0000000..17c68bf
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/EmojiDetectedException.cs
@@ -0,0 +1,19 @@
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Domain.Pricing.Exceptions;
+
+///
+/// PRC-001 — Thrown when the input text contains any Unicode emoji codepoint.
+/// Emoji detection occurs BEFORE normalization. → HTTP 400
+///
+public sealed class EmojiDetectedException : DomainException
+{
+ /// The Unicode codepoint value of the first detected emoji rune.
+ public int DetectedCodepoint { get; }
+
+ public EmojiDetectedException(int detectedCodepoint)
+ : base($"El texto contiene emojis (U+{detectedCodepoint:X4}), que no son tarifables. Eliminálos antes de continuar.")
+ {
+ DetectedCodepoint = detectedCodepoint;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Pricing/Exceptions/WordCountValidationException.cs b/src/api/SIGCM2.Domain/Pricing/Exceptions/WordCountValidationException.cs
new file mode 100644
index 0000000..ed80bd3
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/Exceptions/WordCountValidationException.cs
@@ -0,0 +1,21 @@
+using SIGCM2.Domain.Exceptions;
+
+namespace SIGCM2.Domain.Pricing.Exceptions;
+
+///
+/// PRC-001 — Thrown when WordCounterService input fails validation
+/// (e.g., exceeds maximum length of 2000 chars). → HTTP 400
+/// Used as defense-in-depth alongside FluentValidation in the Application layer.
+///
+public sealed class WordCountValidationException : DomainException
+{
+ public string Field { get; }
+ public string Reason { get; }
+
+ public WordCountValidationException(string field, string reason)
+ : base($"Valor inválido para {field}: {reason}")
+ {
+ Field = field;
+ Reason = reason;
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs b/src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs
new file mode 100644
index 0000000..8534324
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs
@@ -0,0 +1,34 @@
+namespace SIGCM2.Domain.Pricing;
+
+///
+/// Domain-layer extension for TimeProvider: returns Argentina civil date.
+/// Mirrors SIGCM2.Application.Common.TimeProviderArgentinaExtensions
+/// but lives in Domain to avoid a Domain → Application dependency.
+/// Domain layer is pure — no Application references allowed.
+///
+internal static class DomainTimeProviderExtensions
+{
+ private const string ArgentinaTimeZoneId = "America/Argentina/Buenos_Aires";
+ private const string ArgentinaTimeZoneIdWindows = "Argentina Standard Time";
+
+ private static readonly TimeZoneInfo ArgentinaTz = LoadArgentinaTz();
+
+ internal static DateOnly GetArgentinaToday(this TimeProvider timeProvider)
+ {
+ var utcNow = timeProvider.GetUtcNow();
+ var argentinaNow = TimeZoneInfo.ConvertTime(utcNow, ArgentinaTz);
+ return DateOnly.FromDateTime(argentinaNow.DateTime);
+ }
+
+ private static TimeZoneInfo LoadArgentinaTz()
+ {
+ try
+ {
+ return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneId);
+ }
+ catch (TimeZoneNotFoundException)
+ {
+ return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneIdWindows);
+ }
+ }
+}
diff --git a/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs
new file mode 100644
index 0000000..9d9e9ec
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs
@@ -0,0 +1,10 @@
+namespace SIGCM2.Domain.Pricing.WordCounter;
+
+///
+/// PRC-001 — Immutable value object representing the result of a word count operation.
+/// TotalWords: number of whitespace-separated tokens after normalization.
+/// SpecialCharCounts: map of category name → occurrence count in the ORIGINAL (pre-normalization) text.
+///
+public sealed record WordCountResult(
+ int TotalWords,
+ IReadOnlyDictionary SpecialCharCounts);
diff --git a/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs
new file mode 100644
index 0000000..81530ad
--- /dev/null
+++ b/src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs
@@ -0,0 +1,128 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using SIGCM2.Domain.Pricing.Exceptions;
+
+namespace SIGCM2.Domain.Pricing.WordCounter;
+
+///
+/// PRC-001 — Pure domain service for counting words in classified ad text.
+///
+/// Algorithm (in order):
+/// 1. Null/empty → WordCountResult(0, empty)
+/// 2. Length check: rawText.Length > 2000 → WordCountValidationException
+/// 3. Emoji detection via Rune.EnumerateRunes + IsEmojiRune → EmojiDetectedException
+/// 4. Count special chars by category (BEFORE replacement — anti-fraud ordering)
+/// 5. Replace specials with space; normalize line breaks; collapse whitespace; trim
+/// 6. Split(' ', RemoveEmptyEntries) → token count
+/// 7. Return WordCountResult
+///
+/// Tildes (á é í ó ú ñ etc.) are regular word letters — NOT specials.
+/// Hyphens are NOT specials — they split words naturally via whitespace split only when
+/// they appear as separators between non-whitespace chars (e.g. "buen-estado" → the hyphen
+/// itself becomes a word boundary because Split splits on spaces only, so hyphenated words
+/// are NOT split by default). Wait — spec GC-18: "buen-estado casi-nuevo" → TotalWords=4.
+/// This means hyphen DOES split. The tokenizer must split on hyphen too.
+///
+/// Design resolution: after normalization, split on whitespace OR hyphen.
+/// Hyphens are treated as word boundaries (split token), not as specials counted in SpecialCharCounts.
+///
+public sealed class WordCounterService
+{
+ public const int MaxInputLength = 2000;
+
+ // Category patterns — order matters for counting (BEFORE replacement)
+ private static readonly (string Category, Regex Pattern)[] CategoryPatterns =
+ [
+ ("Currency", new Regex(@"[\$€¥£]", RegexOptions.Compiled)),
+ ("Percentage", new Regex(@"%", RegexOptions.Compiled)),
+ ("Exclamation", new Regex(@"[!¡]", RegexOptions.Compiled)),
+ ("Question", new Regex(@"[?¿]", RegexOptions.Compiled)),
+ ];
+
+ // Collapses any run of spaces/tabs into a single space
+ private static readonly Regex WhitespaceCollapseRegex = new(@"[ \t]+", RegexOptions.Compiled);
+
+ public WordCountResult Count(string? rawText)
+ {
+ // Step 1: null/empty fast path
+ if (string.IsNullOrEmpty(rawText))
+ return new WordCountResult(0, new Dictionary());
+
+ // Step 2: length check (on raw input, pre-normalization)
+ if (rawText.Length > MaxInputLength)
+ throw new WordCountValidationException(
+ nameof(rawText),
+ $"El texto supera el máximo de {MaxInputLength} caracteres.");
+
+ // Step 3: emoji detection — fail-fast on first emoji rune found
+ foreach (var rune in rawText.EnumerateRunes())
+ {
+ if (IsEmojiRune(rune))
+ throw new EmojiDetectedException(rune.Value);
+ }
+
+ // Step 4: count specials by category on ORIGINAL text (anti-fraud ordering)
+ var counts = new Dictionary();
+ foreach (var (category, pattern) in CategoryPatterns)
+ {
+ var matchCount = pattern.Matches(rawText).Count;
+ if (matchCount > 0)
+ counts[category] = matchCount;
+ }
+
+ // Step 5: normalize
+ // 5a. Replace specials with space (each occurrence → space, enabling anti-fraud split)
+ var normalized = rawText;
+ foreach (var (_, pattern) in CategoryPatterns)
+ normalized = pattern.Replace(normalized, " ");
+
+ // 5b. Normalize line endings to space
+ normalized = normalized.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ');
+
+ // 5c. Replace hyphens with space (GC-18: "buen-estado" → 2 tokens)
+ // Hyphens are word-boundary separators, not special counted chars.
+ normalized = normalized.Replace('-', ' ');
+
+ // 5d. Collapse consecutive spaces/tabs to single space, then trim
+ normalized = WhitespaceCollapseRegex.Replace(normalized, " ").Trim();
+
+ // Step 6: tokenize on space
+ if (string.IsNullOrEmpty(normalized))
+ return new WordCountResult(0, counts);
+
+ var tokens = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+
+ // Step 7: return result
+ return new WordCountResult(tokens.Length, counts);
+ }
+
+ ///
+ /// Returns true if the given rune is an emoji codepoint.
+ /// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ.
+ ///
+ internal static bool IsEmojiRune(Rune r)
+ {
+ int v = r.Value;
+
+ // Main emoji blocks
+ if (v >= 0x1F300 && v <= 0x1F5FF) return true; // Misc Symbols & Pictographs
+ if (v >= 0x1F600 && v <= 0x1F64F) return true; // Emoticons
+ if (v >= 0x1F680 && v <= 0x1F6FF) return true; // Transport & Map
+ if (v >= 0x1F700 && v <= 0x1F77F) return true; // Alchemical Symbols
+ if (v >= 0x1F900 && v <= 0x1F9FF) return true; // Supplemental Symbols & Pictographs
+ if (v >= 0x1FA00 && v <= 0x1FAFF) return true; // Symbols and Pictographs Extended-A
+
+ // Misc Symbols and Dingbats (conditional — many non-emoji chars here too,
+ // but the design includes these ranges)
+ if (v >= 0x2600 && v <= 0x26FF) return true; // Misc Symbols (⚡☀️etc.)
+ if (v >= 0x2700 && v <= 0x27BF) return true; // Dingbats (✂️✈️etc.)
+
+ // Variation Selector-16 (U+FE0F) — used to force emoji presentation
+ if (v == 0xFE0F) return true;
+
+ // Zero Width Joiner (U+200D) — used in compound emoji (👨👩👧, 🧑🤝🧑)
+ if (v == 0x200D) return true;
+
+ return false;
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs
new file mode 100644
index 0000000..1f74e64
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs
@@ -0,0 +1,233 @@
+using FluentAssertions;
+using Microsoft.Extensions.Time.Testing;
+using SIGCM2.Domain.Pricing.ChargeableChars;
+using SIGCM2.Domain.Pricing.Exceptions;
+
+namespace SIGCM2.Application.Tests.Domain.Pricing.ChargeableChars;
+
+///
+/// PRC-001 — T2.3 Domain unit tests for ChargeableCharConfig entity.
+/// Covers: factory invariants, ScheduleNewPrice forward-only, Deactivate idempotency.
+///
+public sealed class ChargeableCharConfigTests
+{
+ // Fixed "today" for Argentina: 2026-04-20
+ private static FakeTimeProvider MakeFakeTimeProvider(DateOnly argentinaToday)
+ {
+ var fp = new FakeTimeProvider();
+ // Argentina is UTC-3. Set UTC to 12:00 of that day → Argentina 09:00 = same day.
+ fp.SetUtcNow(new DateTimeOffset(
+ argentinaToday.Year, argentinaToday.Month, argentinaToday.Day,
+ 12, 0, 0, TimeSpan.Zero));
+ return fp;
+ }
+
+ private static DateOnly Today => new DateOnly(2026, 4, 20);
+ private static FakeTimeProvider FakeToday => MakeFakeTimeProvider(Today);
+
+ // ── Create factory — invariant violations ─────────────────────────────────
+
+ [Fact]
+ public void Create_ThrowsInvalid_WhenSymbolIsEmpty()
+ {
+ var act = () => ChargeableCharConfig.Create(null, "", "Currency", 1.0m, Today);
+
+ act.Should().Throw()
+ .Which.Field.Should().Be("Symbol");
+ }
+
+ [Fact]
+ public void Create_ThrowsInvalid_WhenSymbolIsWhitespace()
+ {
+ var act = () => ChargeableCharConfig.Create(null, " ", "Currency", 1.0m, Today);
+
+ act.Should().Throw()
+ .Which.Field.Should().Be("Symbol");
+ }
+
+ [Fact]
+ public void Create_ThrowsInvalid_WhenSymbolExceedsFourChars()
+ {
+ var act = () => ChargeableCharConfig.Create(null, "$$$$$", "Currency", 1.0m, Today);
+
+ act.Should().Throw()
+ .Which.Field.Should().Be("Symbol");
+ }
+
+ [Fact]
+ public void Create_ThrowsInvalid_WhenPricePerUnitIsZero()
+ {
+ var act = () => ChargeableCharConfig.Create(null, "$", "Currency", 0m, Today);
+
+ act.Should().Throw()
+ .Which.Field.Should().Be("PricePerUnit");
+ }
+
+ [Fact]
+ public void Create_ThrowsInvalid_WhenPricePerUnitIsNegative()
+ {
+ var act = () => ChargeableCharConfig.Create(null, "$", "Currency", -1.5m, Today);
+
+ act.Should().Throw()
+ .Which.Field.Should().Be("PricePerUnit");
+ }
+
+ [Fact]
+ public void Create_ThrowsInvalid_WhenCategoryIsUnknown()
+ {
+ var act = () => ChargeableCharConfig.Create(null, "$", "INVALID_CAT", 1.0m, Today);
+
+ act.Should().Throw()
+ .Which.Field.Should().Be("Category");
+ }
+
+ // ── Create factory — happy path ───────────────────────────────────────────
+
+ [Fact]
+ public void Create_HappyPath_ReturnsEntityWithIsActiveTrue_AndIdZero()
+ {
+ var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.5m, Today);
+
+ entity.Id.Should().Be(0);
+ entity.IsActive.Should().BeTrue();
+ entity.ValidTo.Should().BeNull();
+ entity.Symbol.Should().Be("$");
+ entity.Category.Should().Be("Currency");
+ entity.PricePerUnit.Should().Be(1.5m);
+ entity.ValidFrom.Should().Be(Today);
+ entity.MedioId.Should().BeNull();
+ }
+
+ [Fact]
+ public void Create_WithMedioId_SetsCorrectly()
+ {
+ var entity = ChargeableCharConfig.Create(5, "$", "Currency", 2.0m, Today);
+
+ entity.MedioId.Should().Be(5);
+ }
+
+ [Fact]
+ public void Create_FourCharSymbol_IsValid()
+ {
+ var act = () => ChargeableCharConfig.Create(null, "$$$$", "Currency", 1.0m, Today);
+
+ act.Should().NotThrow();
+ }
+
+ // ── ScheduleNewPrice — forward-only ───────────────────────────────────────
+
+ [Fact]
+ public void ScheduleNewPrice_ThrowsForwardOnly_WhenNewValidFromEqualToCurrent()
+ {
+ var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
+
+ var act = () => entity.ScheduleNewPrice(2.0m, Today, FakeToday);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ScheduleNewPrice_ThrowsForwardOnly_WhenNewValidFromBeforeCurrent()
+ {
+ var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
+ var pastDate = Today.AddDays(-1);
+ var fp = MakeFakeTimeProvider(Today.AddDays(-2)); // today is 2 days ago so pastDate is still "future" relative to fake today
+ // But we want to test forward-only (new <= current ValidFrom), not past date
+ // Use a fake today that makes pastDate pass the ">=today" check, but still fail forward-only
+ var fpPast = MakeFakeTimeProvider(Today.AddDays(-10)); // today = Apr 10, pastDate = Apr 19 is future
+
+ var act = () => entity.ScheduleNewPrice(2.0m, pastDate, fpPast);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ScheduleNewPrice_ThrowsInvalid_WhenNewValidFromBeforeTodayAR()
+ {
+ var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today.AddDays(-10));
+ // today in fake = Apr 20; newValidFrom = Apr 18 < today → invalid (past date)
+ var pastDate = Today.AddDays(-2);
+
+ var act = () => entity.ScheduleNewPrice(2.0m, pastDate, FakeToday);
+
+ act.Should().Throw()
+ .Which.Field.Should().Be("ValidFrom");
+ }
+
+ [Fact]
+ public void ScheduleNewPrice_HappyPath_ReturnsNewEntity_DoesNotMutateThis()
+ {
+ var original = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
+ var futureDate = Today.AddDays(30);
+
+ var newEntity = original.ScheduleNewPrice(2.0m, futureDate, FakeToday);
+
+ // new entity has updated price and validFrom
+ newEntity.PricePerUnit.Should().Be(2.0m);
+ newEntity.ValidFrom.Should().Be(futureDate);
+ newEntity.Symbol.Should().Be("$");
+ newEntity.Category.Should().Be("Currency");
+ newEntity.IsActive.Should().BeTrue();
+
+ // original is unchanged (forward-only semantics)
+ original.PricePerUnit.Should().Be(1.0m);
+ original.ValidFrom.Should().Be(Today);
+ }
+
+ [Fact]
+ public void ScheduleNewPrice_ThrowsInvalid_WhenNewPriceIsZero()
+ {
+ var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
+ var futureDate = Today.AddDays(5);
+
+ var act = () => entity.ScheduleNewPrice(0m, futureDate, FakeToday);
+
+ act.Should().Throw();
+ }
+
+ // ── Deactivate ────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Deactivate_SetsIsActiveFalseAndValidToToday()
+ {
+ var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
+
+ entity.Deactivate(Today);
+
+ entity.IsActive.Should().BeFalse();
+ entity.ValidTo.Should().Be(Today);
+ }
+
+ [Fact]
+ public void Deactivate_Idempotent_WhenAlreadyInactive()
+ {
+ var entity = ChargeableCharConfig.Create(null, "$", "Currency", 1.0m, Today);
+ entity.Deactivate(Today);
+
+ // Second deactivate on same entity — no exception, no change
+ var act = () => entity.Deactivate(Today);
+
+ act.Should().NotThrow();
+ entity.IsActive.Should().BeFalse();
+ }
+
+ // ── Rehydrate (reconstructor) ─────────────────────────────────────────────
+
+ [Fact]
+ public void Rehydrate_SetsAllPropertiesWithoutValidation()
+ {
+ // Rehydrate can create entities that would fail Create (e.g., IsActive=false)
+ var entity = ChargeableCharConfig.Rehydrate(
+ id: 42, medioId: 5, symbol: "$", category: "Currency",
+ price: 1.5m, validFrom: Today, validTo: Today.AddDays(30), isActive: false);
+
+ entity.Id.Should().Be(42);
+ entity.MedioId.Should().Be(5);
+ entity.Symbol.Should().Be("$");
+ entity.Category.Should().Be("Currency");
+ entity.PricePerUnit.Should().Be(1.5m);
+ entity.ValidFrom.Should().Be(Today);
+ entity.ValidTo.Should().Be(Today.AddDays(30));
+ entity.IsActive.Should().BeFalse();
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs
new file mode 100644
index 0000000..a70b012
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs
@@ -0,0 +1,146 @@
+using FluentAssertions;
+using SIGCM2.Domain.Exceptions;
+using SIGCM2.Domain.Pricing.Exceptions;
+
+namespace SIGCM2.Application.Tests.Domain.Pricing.Exceptions;
+
+///
+/// PRC-001 — T2.1 Domain unit tests for Pricing exceptions.
+/// Verifies constructor props, message content, and DomainException inheritance.
+///
+public sealed class PricingExceptionTests
+{
+ // ── EmojiDetectedException ────────────────────────────────────────────────
+
+ [Fact]
+ public void EmojiDetectedException_SetsDetectedCodepoint()
+ {
+ var ex = new EmojiDetectedException(0x1F697); // 🚗
+
+ ex.DetectedCodepoint.Should().Be(0x1F697);
+ }
+
+ [Fact]
+ public void EmojiDetectedException_MessageContainsCodepoint()
+ {
+ var ex = new EmojiDetectedException(0x1F697);
+
+ ex.Message.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public void EmojiDetectedException_InheritsFromDomainException()
+ {
+ var ex = new EmojiDetectedException(0x1F697);
+
+ ex.Should().BeAssignableTo();
+ }
+
+ // ── WordCountValidationException ─────────────────────────────────────────
+
+ [Fact]
+ public void WordCountValidationException_SetsFieldAndReason()
+ {
+ var ex = new WordCountValidationException("rawText", "El texto supera el máximo de 2000 caracteres.");
+
+ ex.Field.Should().Be("rawText");
+ ex.Reason.Should().Be("El texto supera el máximo de 2000 caracteres.");
+ }
+
+ [Fact]
+ public void WordCountValidationException_MessageContainsFieldAndReason()
+ {
+ var ex = new WordCountValidationException("rawText", "supera el máximo");
+
+ ex.Message.Should().Contain("rawText");
+ ex.Message.Should().Contain("supera el máximo");
+ }
+
+ [Fact]
+ public void WordCountValidationException_InheritsFromDomainException()
+ {
+ var ex = new WordCountValidationException("field", "reason");
+
+ ex.Should().BeAssignableTo();
+ }
+
+ // ── ChargeableCharConfigInvalidException ─────────────────────────────────
+
+ [Fact]
+ public void ChargeableCharConfigInvalidException_SetsFieldAndReason()
+ {
+ var ex = new ChargeableCharConfigInvalidException("PricePerUnit", "debe ser > 0");
+
+ ex.Field.Should().Be("PricePerUnit");
+ ex.Reason.Should().Be("debe ser > 0");
+ }
+
+ [Fact]
+ public void ChargeableCharConfigInvalidException_MessageContainsFieldAndReason()
+ {
+ var ex = new ChargeableCharConfigInvalidException("Symbol", "no puede estar vacío");
+
+ ex.Message.Should().Contain("Symbol");
+ ex.Message.Should().Contain("no puede estar vacío");
+ }
+
+ [Fact]
+ public void ChargeableCharConfigInvalidException_InheritsFromDomainException()
+ {
+ var ex = new ChargeableCharConfigInvalidException("field", "reason");
+
+ ex.Should().BeAssignableTo();
+ }
+
+ // ── ChargeableCharConfigForwardOnlyException ──────────────────────────────
+
+ [Fact]
+ public void ChargeableCharConfigForwardOnlyException_SetsAllProperties()
+ {
+ var newVf = new DateOnly(2026, 3, 1);
+ var activeVf = new DateOnly(2026, 4, 1);
+
+ var ex = new ChargeableCharConfigForwardOnlyException(
+ medioId: 5, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf);
+
+ ex.MedioId.Should().Be(5);
+ ex.Symbol.Should().Be("$");
+ ex.NewValidFrom.Should().Be(newVf);
+ ex.ActiveValidFrom.Should().Be(activeVf);
+ }
+
+ [Fact]
+ public void ChargeableCharConfigForwardOnlyException_NullMedioId_IsAllowed()
+ {
+ var ex = new ChargeableCharConfigForwardOnlyException(
+ medioId: null, symbol: "$",
+ newValidFrom: new DateOnly(2026, 3, 1),
+ activeValidFrom: new DateOnly(2026, 4, 1));
+
+ ex.MedioId.Should().BeNull();
+ }
+
+ [Fact]
+ public void ChargeableCharConfigForwardOnlyException_MessageContainsKeyDates()
+ {
+ var newVf = new DateOnly(2026, 3, 1);
+ var activeVf = new DateOnly(2026, 4, 1);
+
+ var ex = new ChargeableCharConfigForwardOnlyException(
+ medioId: null, symbol: "$", newValidFrom: newVf, activeValidFrom: activeVf);
+
+ ex.Message.Should().Contain("2026-03-01");
+ ex.Message.Should().Contain("2026-04-01");
+ }
+
+ [Fact]
+ public void ChargeableCharConfigForwardOnlyException_InheritsFromDomainException()
+ {
+ var ex = new ChargeableCharConfigForwardOnlyException(
+ medioId: null, symbol: "$",
+ newValidFrom: new DateOnly(2026, 3, 1),
+ activeValidFrom: new DateOnly(2026, 4, 1));
+
+ ex.Should().BeAssignableTo();
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCountResultTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCountResultTests.cs
new file mode 100644
index 0000000..9ea3f42
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCountResultTests.cs
@@ -0,0 +1,56 @@
+using FluentAssertions;
+using SIGCM2.Domain.Pricing.WordCounter;
+
+namespace SIGCM2.Application.Tests.Domain.Pricing.WordCounter;
+
+///
+/// PRC-001 — T2.2 Domain unit tests for WordCountResult value object.
+///
+public sealed class WordCountResultTests
+{
+ [Fact]
+ public void WordCountResult_RecordEquality_SameValues_AreEqual()
+ {
+ var dictA = new Dictionary { ["Currency"] = 1 };
+ var dictB = new Dictionary { ["Currency"] = 1 };
+
+ var a = new WordCountResult(3, dictA);
+ var b = new WordCountResult(3, dictB);
+
+ // Records compare by value — TotalWords and SpecialCharCounts ref equality
+ // (not deep dict equality for records), but TotalWords equality is guaranteed.
+ a.TotalWords.Should().Be(b.TotalWords);
+ }
+
+ [Fact]
+ public void WordCountResult_SpecialCharCounts_IsIReadOnlyDictionary()
+ {
+ var result = new WordCountResult(0, new Dictionary());
+
+ result.SpecialCharCounts.Should().BeAssignableTo>();
+ }
+
+ [Fact]
+ public void WordCountResult_MissingKey_ReturnsZeroViaGetValueOrDefault()
+ {
+ var result = new WordCountResult(3, new Dictionary { ["Currency"] = 1 });
+
+ result.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(0);
+ }
+
+ [Fact]
+ public void WordCountResult_EmptySpecialCharCounts_ReturnsEmpty()
+ {
+ var result = new WordCountResult(0, new Dictionary());
+
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WordCountResult_TotalWords_IsCorrect()
+ {
+ var result = new WordCountResult(5, new Dictionary());
+
+ result.TotalWords.Should().Be(5);
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCounterGoldenCasesTests.cs b/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCounterGoldenCasesTests.cs
new file mode 100644
index 0000000..eccd9c3
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCounterGoldenCasesTests.cs
@@ -0,0 +1,328 @@
+using FluentAssertions;
+using SIGCM2.Domain.Pricing.Exceptions;
+using SIGCM2.Domain.Pricing.WordCounter;
+
+namespace SIGCM2.Application.Tests.Domain.Pricing.WordCounter;
+
+///
+/// PRC-001 — C5 Golden Cases Suite.
+/// 25 canonical test cases that constitute the SPIKE acceptance gate.
+/// Each golden case is a separate [Theory] row or [Fact] — individually identified.
+///
+public sealed class WordCounterGoldenCasesTests
+{
+ private static readonly WordCounterService _svc = new();
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-01: plain text — 4 words, no specials
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC01_PlainText_FourWords_NoSpecials()
+ {
+ var result = _svc.Count("vendo auto ford 2005");
+
+ result.TotalWords.Should().Be(4);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-02: dollar splits a token — 3 words, Currency=1
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC02_DollarSign_SplitsToken_ThreeWords_CurrencyOne()
+ {
+ var result = _svc.Count("vendo auto $5000");
+
+ result.TotalWords.Should().Be(3);
+ result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-03: percentage — 2 words, Percentage=1
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC03_Percentage_TwoWords_PercentageOne()
+ {
+ var result = _svc.Count("descuento 20%");
+
+ result.TotalWords.Should().Be(2);
+ result.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-04: exclamation mark — 5 words, Exclamation=1
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC04_Exclamation_FiveWords_ExclamationOne()
+ {
+ var result = _svc.Count("OFERTA! no te la pierdas");
+
+ result.TotalWords.Should().Be(5);
+ result.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(1);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-05: inverse + normal exclamation — 1 word, Exclamation=2
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC05_InverseAndNormalExclamation_OneWord_ExclamationTwo()
+ {
+ var result = _svc.Count("¡Oferta!");
+
+ result.TotalWords.Should().Be(1);
+ result.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(2);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-06: anti-fraud pattern P$a$l$a$b$r$a → 7 words, Currency=6
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC06_AntiFraudDollarPattern_SevenWords_CurrencySix()
+ {
+ var result = _svc.Count("P$a$l$a$b$r$a");
+
+ result.TotalWords.Should().Be(7);
+ result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(6);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-07: mixed specials + hyphen split
+ // VENDO | auto | 5000 | usado | 90 | buen | estado = 7
+ // Exclamation=1, Currency=1, Percentage=1
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC07_MixedSpecialsAndHyphen_SevenWords_OneEachCategory()
+ {
+ var result = _svc.Count("VENDO! auto $5000 usado %90 buen-estado");
+
+ result.TotalWords.Should().Be(7);
+ result.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(1);
+ result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(1);
+ result.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(1);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-08: multi-space collapses — 3 words, no specials
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC08_MultiSpace_Collapses_ThreeWords_NoSpecials()
+ {
+ var result = _svc.Count("vendo auto ford");
+
+ result.TotalWords.Should().Be(3);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-09: CRLF becomes space — 4 words
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC09_CRLF_BecomesSpace_FourWords()
+ {
+ var result = _svc.Count("vendo auto\r\nbuen estado");
+
+ result.TotalWords.Should().Be(4);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-10: leading/trailing whitespace stripped — 2 words
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC10_LeadingTrailingWhitespace_Stripped_TwoWords()
+ {
+ var result = _svc.Count(" vendo auto ");
+
+ result.TotalWords.Should().Be(2);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-11: tildes are regular letters — 4 words, no specials
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC11_Tildes_AreRegularLetters_FourWords_NoSpecials()
+ {
+ var result = _svc.Count("vendo máquina niño año");
+
+ result.TotalWords.Should().Be(4);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-12: ñ in words — 3 words, no specials
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC12_EnneInWords_ThreeWords_NoSpecials()
+ {
+ var result = _svc.Count("año mañana niño");
+
+ result.TotalWords.Should().Be(3);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-13: empty string — 0 words, empty dict
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC13_EmptyString_ZeroWords_EmptyDict()
+ {
+ var result = _svc.Count("");
+
+ result.TotalWords.Should().Be(0);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-14: whitespace-only — 0 words, empty dict
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC14_WhitespaceOnly_ZeroWords_EmptyDict()
+ {
+ var result = _svc.Count(" ");
+
+ result.TotalWords.Should().Be(0);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-15: single word — 1 word
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC15_SingleWord_OneWord()
+ {
+ var result = _svc.Count("auto");
+
+ result.TotalWords.Should().Be(1);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-16: numbers and tilde — 3 words
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC16_NumbersAndTilde_ThreeWords()
+ {
+ var result = _svc.Count("1978 2005 año");
+
+ result.TotalWords.Should().Be(3);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-17: URL treated as single token — 2 words
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC17_Url_TreatedAsSingleToken_TwoWords()
+ {
+ var result = _svc.Count("visita www.example.com");
+
+ result.TotalWords.Should().Be(2);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-18: hyphenated compounds split — 4 words
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC18_HyphenatedCompounds_SplitIntoFourWords()
+ {
+ var result = _svc.Count("buen-estado casi-nuevo");
+
+ result.TotalWords.Should().Be(4);
+ result.SpecialCharCounts.Should().BeEmpty();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-19: emoji at end — throws EmojiDetectedException
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC19_EmojiAtEnd_ThrowsEmojiDetectedException()
+ {
+ var act = () => _svc.Count("vendo 🚗");
+
+ act.Should().Throw();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-20: emoji in middle — throws EmojiDetectedException
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC20_EmojiInMiddle_ThrowsEmojiDetectedException()
+ {
+ var act = () => _svc.Count("vendo auto 🚗 2005");
+
+ act.Should().Throw();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-21: only emoji — throws EmojiDetectedException
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC21_OnlyEmoji_ThrowsEmojiDetectedException()
+ {
+ var act = () => _svc.Count("🚗");
+
+ act.Should().Throw();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-22: exactly 2000 chars — passes (no exception), TotalWords >= 1
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC22_Exactly2000Chars_PassesValidation()
+ {
+ // Build a 2000-char string of "a " repeated (1000 times = 2000 chars)
+ var input = string.Concat(Enumerable.Repeat("a ", 1000)).TrimEnd(); // 1999 chars — add one more
+ // Ensure exactly 2000: "a " x 999 = 1998 + "aa" = 2000
+ input = string.Concat(Enumerable.Repeat("a ", 999)) + "aa"; // 999*2 + 2 = 2000
+
+ var act = () => _svc.Count(input);
+
+ act.Should().NotThrow();
+ var result = _svc.Count(input);
+ result.TotalWords.Should().BeGreaterThanOrEqualTo(1);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-23: 2001 chars — throws WordCountValidationException
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC23_TwoThousandAndOneChars_ThrowsWordCountValidationException()
+ {
+ var input = new string('a', 2001);
+
+ var act = () => _svc.Count(input);
+
+ act.Should().Throw();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-24: all specials replaced → 0 words
+ // "$$ %% !! ¡¡" → Currency=2, Percentage=2, Exclamation=4, TotalWords=0
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC24_AllSpecials_ZeroWordsRemain_CorrectCounts()
+ {
+ var result = _svc.Count("$$ %% !! ¡¡");
+
+ result.TotalWords.Should().Be(0);
+ result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(2);
+ result.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(2);
+ result.SpecialCharCounts.GetValueOrDefault("Exclamation").Should().Be(4);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // GC-25: anti-fraud embedded in sentence — 9 words, Currency=6
+ // ─────────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void GC25_AntiFraudEmbedded_NineWords_CurrencySix()
+ {
+ // "vendo P$a$l$a$b$r$a usado"
+ // P$a$l$a$b$r$a → P a l a b r a = 7 tokens; + vendo + usado = 9
+ var result = _svc.Count("vendo P$a$l$a$b$r$a usado");
+
+ result.TotalWords.Should().Be(9);
+ result.SpecialCharCounts.GetValueOrDefault("Currency").Should().Be(6);
+ }
+}