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:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
34
src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs
Normal file
34
src/api/SIGCM2.Domain/Pricing/TimeProviderExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs
Normal file
10
src/api/SIGCM2.Domain/Pricing/WordCounter/WordCountResult.cs
Normal 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);
|
||||
128
src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs
Normal file
128
src/api/SIGCM2.Domain/Pricing/WordCounter/WordCounterService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user