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,22 @@
namespace SIGCM2.Domain.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Canonical category names for chargeable characters.
/// Persisted as nvarchar(32) in the database (enum-as-string).
/// </summary>
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<string> Valid = new(StringComparer.Ordinal)
{
Currency, Percentage, Exclamation, Question, Other
};
/// <summary>Returns true if the given category string is a known valid category.</summary>
public static bool IsValid(string? category) => category != null && Valid.Contains(category);
}

View File

@@ -0,0 +1,112 @@
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Domain.Pricing.ChargeableChars;
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// Factory for new configs. Enforces all domain invariants.
/// Id is set to 0 until the entity is persisted.
/// </summary>
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);
}
/// <summary>
/// Reconstructor from database (no validation). Used by the repository mapper only.
/// Allows creating entities with any state (e.g., IsActive=false, ValidTo set).
/// </summary>
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);
/// <summary>
/// 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
/// </summary>
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);
}
/// <summary>
/// Deactivates this config row. Sets IsActive = false and ValidTo = today.
/// Idempotent: no-op if already inactive.
/// </summary>
public void Deactivate(DateOnly today)
{
if (!IsActive) return; // idempotent
IsActive = false;
ValidTo = today;
}
}

View File

@@ -0,0 +1,28 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// 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
/// </summary>
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;
}
}

View File

@@ -0,0 +1,22 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// 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.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,19 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// PRC-001 — Thrown when the input text contains any Unicode emoji codepoint.
/// Emoji detection occurs BEFORE normalization. → HTTP 400
/// </summary>
public sealed class EmojiDetectedException : DomainException
{
/// <summary>The Unicode codepoint value of the first detected emoji rune.</summary>
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;
}
}

View File

@@ -0,0 +1,21 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// 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.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,34 @@
namespace SIGCM2.Domain.Pricing;
/// <summary>
/// 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.
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Domain.Pricing.WordCounter;
/// <summary>
/// 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.
/// </summary>
public sealed record WordCountResult(
int TotalWords,
IReadOnlyDictionary<string, int> SpecialCharCounts);

View File

@@ -0,0 +1,128 @@
using System.Text;
using System.Text.RegularExpressions;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Domain.Pricing.WordCounter;
/// <summary>
/// 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.
/// </summary>
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<string, int>());
// 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<string, int>();
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);
}
/// <summary>
/// Returns true if the given rune is an emoji codepoint.
/// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ.
/// </summary>
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;
}
}

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);
}
}