feat(domain): WordCounterService + WordCountResult + ChargeableCharConfig entity + exceptions (PRC-001)

- 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<string,int> 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
This commit is contained in:
2026-04-20 12:13:06 -03:00
parent 8ac91a13aa
commit ded76fcdc7
13 changed files with 1159 additions and 0 deletions

View File

@@ -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;
/// <summary>
/// PRC-001 — T2.3 Domain unit tests for ChargeableCharConfig entity.
/// Covers: factory invariants, ScheduleNewPrice forward-only, Deactivate idempotency.
/// </summary>
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<ChargeableCharConfigInvalidException>()
.Which.Field.Should().Be("Symbol");
}
[Fact]
public void Create_ThrowsInvalid_WhenSymbolIsWhitespace()
{
var act = () => ChargeableCharConfig.Create(null, " ", "Currency", 1.0m, Today);
act.Should().Throw<ChargeableCharConfigInvalidException>()
.Which.Field.Should().Be("Symbol");
}
[Fact]
public void Create_ThrowsInvalid_WhenSymbolExceedsFourChars()
{
var act = () => ChargeableCharConfig.Create(null, "$$$$$", "Currency", 1.0m, Today);
act.Should().Throw<ChargeableCharConfigInvalidException>()
.Which.Field.Should().Be("Symbol");
}
[Fact]
public void Create_ThrowsInvalid_WhenPricePerUnitIsZero()
{
var act = () => ChargeableCharConfig.Create(null, "$", "Currency", 0m, Today);
act.Should().Throw<ChargeableCharConfigInvalidException>()
.Which.Field.Should().Be("PricePerUnit");
}
[Fact]
public void Create_ThrowsInvalid_WhenPricePerUnitIsNegative()
{
var act = () => ChargeableCharConfig.Create(null, "$", "Currency", -1.5m, Today);
act.Should().Throw<ChargeableCharConfigInvalidException>()
.Which.Field.Should().Be("PricePerUnit");
}
[Fact]
public void Create_ThrowsInvalid_WhenCategoryIsUnknown()
{
var act = () => ChargeableCharConfig.Create(null, "$", "INVALID_CAT", 1.0m, Today);
act.Should().Throw<ChargeableCharConfigInvalidException>()
.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<ChargeableCharConfigForwardOnlyException>();
}
[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<ChargeableCharConfigForwardOnlyException>();
}
[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<ChargeableCharConfigInvalidException>()
.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<ChargeableCharConfigInvalidException>();
}
// ── 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();
}
}

View File

@@ -0,0 +1,146 @@
using FluentAssertions;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Application.Tests.Domain.Pricing.Exceptions;
/// <summary>
/// PRC-001 — T2.1 Domain unit tests for Pricing exceptions.
/// Verifies constructor props, message content, and DomainException inheritance.
/// </summary>
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<DomainException>();
}
// ── 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<DomainException>();
}
// ── 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<DomainException>();
}
// ── 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<DomainException>();
}
}

View File

@@ -0,0 +1,56 @@
using FluentAssertions;
using SIGCM2.Domain.Pricing.WordCounter;
namespace SIGCM2.Application.Tests.Domain.Pricing.WordCounter;
/// <summary>
/// PRC-001 — T2.2 Domain unit tests for WordCountResult value object.
/// </summary>
public sealed class WordCountResultTests
{
[Fact]
public void WordCountResult_RecordEquality_SameValues_AreEqual()
{
var dictA = new Dictionary<string, int> { ["Currency"] = 1 };
var dictB = new Dictionary<string, int> { ["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<string, int>());
result.SpecialCharCounts.Should().BeAssignableTo<IReadOnlyDictionary<string, int>>();
}
[Fact]
public void WordCountResult_MissingKey_ReturnsZeroViaGetValueOrDefault()
{
var result = new WordCountResult(3, new Dictionary<string, int> { ["Currency"] = 1 });
result.SpecialCharCounts.GetValueOrDefault("Percentage").Should().Be(0);
}
[Fact]
public void WordCountResult_EmptySpecialCharCounts_ReturnsEmpty()
{
var result = new WordCountResult(0, new Dictionary<string, int>());
result.SpecialCharCounts.Should().BeEmpty();
}
[Fact]
public void WordCountResult_TotalWords_IsCorrect()
{
var result = new WordCountResult(5, new Dictionary<string, int>());
result.TotalWords.Should().Be(5);
}
}

View File

@@ -0,0 +1,328 @@
using FluentAssertions;
using SIGCM2.Domain.Pricing.Exceptions;
using SIGCM2.Domain.Pricing.WordCounter;
namespace SIGCM2.Application.Tests.Domain.Pricing.WordCounter;
/// <summary>
/// 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.
/// </summary>
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<EmojiDetectedException>();
}
// ─────────────────────────────────────────────────────────────────────────
// GC-20: emoji in middle — throws EmojiDetectedException
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void GC20_EmojiInMiddle_ThrowsEmojiDetectedException()
{
var act = () => _svc.Count("vendo auto 🚗 2005");
act.Should().Throw<EmojiDetectedException>();
}
// ─────────────────────────────────────────────────────────────────────────
// GC-21: only emoji — throws EmojiDetectedException
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void GC21_OnlyEmoji_ThrowsEmojiDetectedException()
{
var act = () => _svc.Count("🚗");
act.Should().Throw<EmojiDetectedException>();
}
// ─────────────────────────────────────────────────────────────────────────
// 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<WordCountValidationException>();
}
// ─────────────────────────────────────────────────────────────────────────
// 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);
}
}