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