From ded76fcdc70856eac8cf8fffa1b9a1909783db88 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Mon, 20 Apr 2026 12:13:06 -0300 Subject: [PATCH] feat(domain): WordCounterService + WordCountResult + ChargeableCharConfig entity + exceptions (PRC-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WordCounterService: pure domain service, 7-step algorithm (null/empty fast path → length check → emoji detection via Rune.EnumerateRunes → count specials before replace → replace specials+hyphens → collapse whitespace → tokenize) - WordCountResult: sealed record with TotalWords + IReadOnlyDictionary SpecialCharCounts - 4 domain exceptions extending DomainException: EmojiDetectedException, WordCountValidationException, ChargeableCharConfigInvalidException, ChargeableCharConfigForwardOnlyException - ChargeableCharConfig: rich entity with Create factory (invariants), Rehydrate reconstructor, ScheduleNewPrice (forward-only, returns new entity), Deactivate (idempotent) - ChargeableCharCategories: enum-as-string constants (Currency, Percentage, Exclamation, Question, Other) - DomainTimeProviderExtensions: internal GetArgentinaToday helper (mirrors Application.Common without creating Domain→Application dependency) - 60 new tests: 25 golden cases all GREEN, 12 entity invariant tests, 12 exception tests, 5 WordCountResult tests, 6 ChargeableCharConfig entity tests --- .../ChargeableCharCategories.cs | 22 ++ .../ChargeableChars/ChargeableCharConfig.cs | 112 ++++++ ...hargeableCharConfigForwardOnlyException.cs | 28 ++ .../ChargeableCharConfigInvalidException.cs | 22 ++ .../Exceptions/EmojiDetectedException.cs | 19 + .../WordCountValidationException.cs | 21 ++ .../Pricing/TimeProviderExtensions.cs | 34 ++ .../Pricing/WordCounter/WordCountResult.cs | 10 + .../Pricing/WordCounter/WordCounterService.cs | 128 +++++++ .../ChargeableCharConfigTests.cs | 233 +++++++++++++ .../Exceptions/PricingExceptionTests.cs | 146 ++++++++ .../WordCounter/WordCountResultTests.cs | 56 +++ .../WordCounterGoldenCasesTests.cs | 328 ++++++++++++++++++ 13 files changed, 1159 insertions(+) create mode 100644 src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharCategories.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/ChargeableChars/ChargeableCharConfig.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigForwardOnlyException.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/Exceptions/ChargeableCharConfigInvalidException.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/Exceptions/EmojiDetectedException.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/Exceptions/WordCountValidationException.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs create mode 100644 src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Pricing/ChargeableChars/ChargeableCharConfigTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Pricing/Exceptions/PricingExceptionTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCountResultTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Domain/Pricing/WordCounter/WordCounterGoldenCasesTests.cs 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); + } +}