using Xunit; using KArtSell.Modules.ModelOperations.Domain; namespace KArtSell.ModelOperations.UnitTests; /// /// AEG-VS-00-03: Pure Policy Unit Tests /// Tests domain policies in isolation (no I/O, no state) /// Evidence for: Domain layer validation (AGENTS.md v16.0) /// public class SellPriorityPolicyTests { /// /// Policy: Sell priority is immutable and strictly ordered /// HARD_IMPAIRMENT → PORTFOLIO_SURVIVAL → DYNAMIC_PROFIT_FLOOR → /// CONCENTRATION/LIQUIDITY → OPPORTUNITY_COST → REENTRY_OPTION /// [Fact] public void SellPriority_Sort_RespectsImmutableOrder() { // Arrange: Random order of sell priorities var priorities = new[] { SellPriority.OPPORTUNITY_COST, SellPriority.HARD_IMPAIRMENT, SellPriority.REENTRY_OPTION, SellPriority.DYNAMIC_PROFIT_FLOOR, SellPriority.CONCENTRATION_LIQUIDITY, SellPriority.PORTFOLIO_SURVIVAL, }; // Act: Sort according to policy var sorted = SellPriorityPolicy.SortByPriority(priorities); // Assert: Must match canonical order (no exceptions) var expected = new[] { SellPriority.HARD_IMPAIRMENT, SellPriority.PORTFOLIO_SURVIVAL, SellPriority.DYNAMIC_PROFIT_FLOOR, SellPriority.CONCENTRATION_LIQUIDITY, SellPriority.OPPORTUNITY_COST, SellPriority.REENTRY_OPTION, }; Assert.Equal(expected, sorted); } /// /// Policy: Bounds validation (no magic numbers) /// Loss threshold: -50% to 0% (not beyond -50% loss) /// Profit floor: 0% to 100% (not beyond +100% gain) /// [Fact] public void LossBounds_Reject_OutOfRange() { // Arrange: Invalid loss bounds var invalid = new[] { -0.51m, -1.0m, -10.0m }; // Beyond -50% // Act & Assert: All must be rejected foreach (var loss in invalid) { Assert.False(SellPriorityPolicy.IsValidLossBound(loss), $"Loss {loss} should be rejected"); } } [Fact] public void LossBounds_Accept_ValidRange() { // Arrange: Valid loss bounds var valid = new[] { -0.50m, -0.25m, -0.10m, 0.0m }; // Act & Assert: All must be accepted foreach (var loss in valid) { Assert.True(SellPriorityPolicy.IsValidLossBound(loss), $"Loss {loss} should be accepted"); } } [Fact] public void ProfitFloor_Reject_OutOfRange() { // Arrange: Invalid profit floors var invalid = new[] { 1.01m, 2.0m, 10.0m }; // Beyond +100% // Act & Assert: All must be rejected foreach (var floor in invalid) { Assert.False(SellPriorityPolicy.IsValidProfitFloor(floor), $"Floor {floor} should be rejected"); } } [Fact] public void ProfitFloor_Accept_ValidRange() { // Arrange: Valid profit floors var valid = new[] { 0.0m, 0.10m, 0.50m, 1.0m }; // Act & Assert: All must be accepted foreach (var floor in valid) { Assert.True(SellPriorityPolicy.IsValidProfitFloor(floor), $"Floor {floor} should be accepted"); } } } public class ModelStateTransitionPolicyTests { /// /// Policy: Model lifecycle is strictly linear (no shortcuts, no skips) /// Freeze → Mature → Score → Diagnose → Hypothesis → Challenger → Validate → Review → ManualActivation /// [Fact] public void ModelStateTransition_RejectsNonLinearTransitions() { // Arrange: Invalid transitions (skipping states) var invalidTransitions = new[] { (from: ModelStatus.Freeze, to: ModelStatus.Score), // Skip Mature (from: ModelStatus.Mature, to: ModelStatus.Diagnose), // Skip Score (from: ModelStatus.Hypothesis, to: ModelStatus.Validate), // Skip Challenger (from: ModelStatus.Score, to: ModelStatus.Freeze), // Backward }; // Act & Assert: All must be rejected foreach (var (from, to) in invalidTransitions) { Assert.False( ModelStateTransitionPolicy.IsValidTransition(from, to), $"Transition {from} → {to} should be invalid (non-linear)" ); } } [Fact] public void ModelStateTransition_AcceptsLinearProgression() { // Arrange: Valid linear progression var validTransitions = new[] { (from: ModelStatus.Freeze, to: ModelStatus.Mature), (from: ModelStatus.Mature, to: ModelStatus.Score), (from: ModelStatus.Score, to: ModelStatus.Diagnose), (from: ModelStatus.Diagnose, to: ModelStatus.Hypothesis), (from: ModelStatus.Hypothesis, to: ModelStatus.Challenger), (from: ModelStatus.Challenger, to: ModelStatus.Validate), (from: ModelStatus.Validate, to: ModelStatus.Review), (from: ModelStatus.Review, to: ModelStatus.ManualActivation), }; // Act & Assert: All must be accepted foreach (var (from, to) in validTransitions) { Assert.True( ModelStateTransitionPolicy.IsValidTransition(from, to), $"Transition {from} → {to} should be valid" ); } } [Fact] public void ModelStateTransition_IdentityTransitionAllowed() { // Arrange: Same-state transitions (e.g., revision updates) var statuses = new[] { ModelStatus.Freeze, ModelStatus.Mature, ModelStatus.Score, ModelStatus.Diagnose, ModelStatus.Hypothesis, ModelStatus.Challenger, ModelStatus.Validate, ModelStatus.Review, ModelStatus.ManualActivation, }; // Act & Assert: All identity transitions must be allowed (revision bump) foreach (var status in statuses) { Assert.True( ModelStateTransitionPolicy.IsValidTransition(status, status), $"Transition {status} → {status} should be valid (revision update)" ); } } } public class MonotonicityPolicyTests { /// /// Policy: Key metrics are monotonic (non-decreasing or non-increasing) /// - Confidence score: non-decreasing (model improves or stays same) /// - Loss threshold: non-increasing (gets stricter over time) /// [Fact] public void ConfidenceScore_IsMonotonicIncreasing() { // Arrange: Sequence of confidence scores (should only increase) var scores = new decimal[] { 0.50m, 0.60m, 0.70m, 0.75m, 0.75m, 0.80m }; // Act: Check monotonicity bool isMonotonic = MonotonicityPolicy.IsNonDecreasing(scores); // Assert: Must be monotonic Assert.True(isMonotonic, "Confidence scores should be non-decreasing"); } [Fact] public void ConfidenceScore_RejectsDecreasingSeries() { // Arrange: Decreasing confidence (violates monotonicity) var scores = new decimal[] { 0.80m, 0.70m, 0.75m }; // Drop from 0.80 to 0.70 // Act: Check monotonicity bool isMonotonic = MonotonicityPolicy.IsNonDecreasing(scores); // Assert: Must reject Assert.False(isMonotonic, "Decreasing confidence should be rejected"); } [Fact] public void LossThreshold_IsMonotonicDecreasing() { // Arrange: Loss thresholds becoming stricter over time (more negative = stricter) var thresholds = new decimal[] { -0.20m, -0.30m, -0.40m, -0.50m }; // Act: Check monotonicity (getting stricter = more negative = non-increasing) bool isMonotonic = MonotonicityPolicy.IsNonIncreasing(thresholds); // Assert: Must be monotonic Assert.True(isMonotonic, "Loss thresholds should be non-increasing (stricter)"); } [Fact] public void LossThreshold_RejectsLooser_Thresholds() { // Arrange: Loss threshold getting weaker (violates stricter policy) var thresholds = new decimal[] { -0.30m, -0.40m, -0.20m }; // Went from -0.30 to -0.40 to -0.20 // Act: Check monotonicity bool isMonotonic = MonotonicityPolicy.IsNonIncreasing(thresholds); // Assert: Must reject (allows loosening) Assert.False(isMonotonic, "Loosening loss thresholds should be rejected"); } } // Domain Policy implementations (pure functions, no state) public static class SellPriorityPolicy { public static SellPriority[] SortByPriority(SellPriority[] priorities) { var priority = new Dictionary { { SellPriority.HARD_IMPAIRMENT, 1 }, { SellPriority.PORTFOLIO_SURVIVAL, 2 }, { SellPriority.DYNAMIC_PROFIT_FLOOR, 3 }, { SellPriority.CONCENTRATION_LIQUIDITY, 4 }, { SellPriority.OPPORTUNITY_COST, 5 }, { SellPriority.REENTRY_OPTION, 6 }, }; return priorities.OrderBy(p => priority[p]).ToArray(); } public static bool IsValidLossBound(decimal loss) => loss >= -0.50m && loss <= 0.0m; public static bool IsValidProfitFloor(decimal floor) => floor >= 0.0m && floor <= 1.0m; } public static class ModelStateTransitionPolicy { private static readonly Dictionary ValidTransitions = new() { { ModelStatus.Freeze, ModelStatus.Mature }, { ModelStatus.Mature, ModelStatus.Score }, { ModelStatus.Score, ModelStatus.Diagnose }, { ModelStatus.Diagnose, ModelStatus.Hypothesis }, { ModelStatus.Hypothesis, ModelStatus.Challenger }, { ModelStatus.Challenger, ModelStatus.Validate }, { ModelStatus.Validate, ModelStatus.Review }, { ModelStatus.Review, ModelStatus.ManualActivation }, }; public static bool IsValidTransition(ModelStatus from, ModelStatus to) { // Identity transition (revision bump) allowed if (from == to) return true; // Check valid progression return ValidTransitions.TryGetValue(from, out var nextStatus) && nextStatus == to; } } public static class MonotonicityPolicy { public static bool IsNonDecreasing(decimal[] values) { for (int i = 1; i < values.Length; i++) { if (values[i] < values[i - 1]) return false; } return true; } public static bool IsNonIncreasing(decimal[] values) { for (int i = 1; i < values.Length; i++) { if (values[i] > values[i - 1]) return false; } return true; } } // Enums (domain model) public enum SellPriority { HARD_IMPAIRMENT, PORTFOLIO_SURVIVAL, DYNAMIC_PROFIT_FLOOR, CONCENTRATION_LIQUIDITY, OPPORTUNITY_COST, REENTRY_OPTION, } public enum ModelStatus { Freeze, Mature, Score, Diagnose, Hypothesis, Challenger, Validate, Review, ManualActivation, }