From c77d2117cd579f89c9e7accb31658e5bf3fb1930 Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Sun, 14 Dec 2025 11:08:04 -0500 Subject: [PATCH] Add testing for validation --- Core/Validation/ValidationConfiguration.cs | 15 +- Tests/Builders/StudentBuilder.cs | 1 + Tests/Builders/TeamBuilder.cs | 4 +- .../Rules/StudentAssignmentRulesTests.cs | 440 ++++++++++++++++++ .../Rules/StudentRankingRulesTests.cs | 294 ++++++++++++ Tests/Validation/Rules/TeamRulesTests.cs | 310 ++++++++++++ .../ValidationConfigurationTests.cs | 229 +++++++++ Tests/Validation/ValidationServiceTests.cs | 425 +++++++++++++++++ 8 files changed, 1713 insertions(+), 5 deletions(-) create mode 100644 Tests/Validation/Rules/StudentAssignmentRulesTests.cs create mode 100644 Tests/Validation/Rules/StudentRankingRulesTests.cs create mode 100644 Tests/Validation/Rules/TeamRulesTests.cs create mode 100644 Tests/Validation/ValidationConfigurationTests.cs create mode 100644 Tests/Validation/ValidationServiceTests.cs diff --git a/Core/Validation/ValidationConfiguration.cs b/Core/Validation/ValidationConfiguration.cs index 41d45b0..7fc0b62 100644 --- a/Core/Validation/ValidationConfiguration.cs +++ b/Core/Validation/ValidationConfiguration.cs @@ -148,7 +148,12 @@ public class ValidationConfiguration { try { - return JsonSerializer.Deserialize(json) ?? Default; + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }; + return JsonSerializer.Deserialize(json, options) ?? Default; } catch { @@ -183,10 +188,12 @@ public class ValidationConfiguration /// Path where the JSON file should be saved public async Task SaveToFileAsync(string path) { - var json = JsonSerializer.Serialize(this, new JsonSerializerOptions + var options = new JsonSerializerOptions { - WriteIndented = true - }); + WriteIndented = true, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }; + var json = JsonSerializer.Serialize(this, options); await File.WriteAllTextAsync(path, json); } } diff --git a/Tests/Builders/StudentBuilder.cs b/Tests/Builders/StudentBuilder.cs index fca2292..5b06e3e 100644 --- a/Tests/Builders/StudentBuilder.cs +++ b/Tests/Builders/StudentBuilder.cs @@ -147,6 +147,7 @@ public class StudentBuilder Rank = rank }; student.EventRankings.Add(ranking); + student.RankedEvents.Add(eventDef); // Also add to RankedEvents for validation rules } return student; diff --git a/Tests/Builders/TeamBuilder.cs b/Tests/Builders/TeamBuilder.cs index d28f1bd..dfd7af4 100644 --- a/Tests/Builders/TeamBuilder.cs +++ b/Tests/Builders/TeamBuilder.cs @@ -21,10 +21,12 @@ public class TeamBuilder return this; } - public TeamBuilder WithStudent(Student student) + public TeamBuilder WithStudent(Student student, bool isCaptain = false) { if (!_students.Contains(student)) _students.Add(student); + if (isCaptain) + _captain = student; return this; } diff --git a/Tests/Validation/Rules/StudentAssignmentRulesTests.cs b/Tests/Validation/Rules/StudentAssignmentRulesTests.cs new file mode 100644 index 0000000..b7fdf58 --- /dev/null +++ b/Tests/Validation/Rules/StudentAssignmentRulesTests.cs @@ -0,0 +1,440 @@ +using Core.Entities; +using Core.Validation; +using Core.Validation.Rules.StudentAssignmentRules; +using Tests.Builders; + +namespace Tests.Validation.Rules; + +[TestFixture] +public class StudentAssignmentRulesTests +{ + private Student _testStudent; + + [SetUp] + public void SetUp() + { + BuilderExtensions.ResetAllBuilders(); + _testStudent = StudentBuilder.Default().WithName("Test", "Student").Build(); + } + + #region TooFewEventsRule Tests + + [Test] + public void TooFewEventsRule_AboveMinimum_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { MinRecommendedEvents = 2 }; + var rule = new TooFewEventsRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Flight").Build(), + EventDefinitionBuilder.Individual("Coding").Build(), + EventDefinitionBuilder.Individual("Speech").Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void TooFewEventsRule_BelowMinimum_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + MinRecommendedEvents = 3, + MinCriticalEvents = 1, + EventCountSeverity = ValidationSeverity.Warning + }; + var rule = new TooFewEventsRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Flight").Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("TOO_FEW_EVENTS")); + Assert.That(warning.Message, Does.Contain("1 events")); + Assert.That(warning.Message, Does.Contain("min recommended: 3")); + Assert.That(warning.Severity, Is.EqualTo(ValidationSeverity.Warning)); + } + + [Test] + public void TooFewEventsRule_BelowCritical_EscalatesToError() + { + // Arrange + var config = new ValidationConfiguration + { + MinRecommendedEvents = 3, + MinCriticalEvents = 2, + EventCountSeverity = ValidationSeverity.Warning + }; + var rule = new TooFewEventsRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Flight").Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Severity, Is.EqualTo(ValidationSeverity.Error), + "Should escalate to Error when below critical threshold"); + } + + #endregion + + #region TooManyEventsRule Tests + + [Test] + public void TooManyEventsRule_BelowMaximum_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { MaxRecommendedEvents = 4 }; + var rule = new TooManyEventsRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Event1").Build(), + EventDefinitionBuilder.Individual("Event2").Build(), + EventDefinitionBuilder.Individual("Event3").Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void TooManyEventsRule_AboveMaximum_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + MaxRecommendedEvents = 3, + MaxCriticalEvents = 6, + EventCountSeverity = ValidationSeverity.Warning + }; + var rule = new TooManyEventsRule(); + + var events = new List(); + for (int i = 0; i < 5; i++) + { + events.Add(EventDefinitionBuilder.Individual($"Event{i}").Build()); + } + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = events + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("TOO_MANY_EVENTS")); + Assert.That(warning.Message, Does.Contain("5 events")); + Assert.That(warning.Message, Does.Contain("max recommended: 3")); + Assert.That(warning.Severity, Is.EqualTo(ValidationSeverity.Warning)); + } + + [Test] + public void TooManyEventsRule_AboveCritical_EscalatesToError() + { + // Arrange + var config = new ValidationConfiguration + { + MaxRecommendedEvents = 4, + MaxCriticalEvents = 6, + EventCountSeverity = ValidationSeverity.Warning + }; + var rule = new TooManyEventsRule(); + + var events = new List(); + for (int i = 0; i < 7; i++) + { + events.Add(EventDefinitionBuilder.Individual($"Event{i}").Build()); + } + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = events + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Severity, Is.EqualTo(ValidationSeverity.Error), + "Should escalate to Error when above critical threshold"); + } + + [Test] + public void TooManyEventsRule_ContainsMetadata() + { + // Arrange + var config = new ValidationConfiguration + { + MaxRecommendedEvents = 2, + MaxCriticalEvents = 5 + }; + var rule = new TooManyEventsRule(); + + var events = new List(); + for (int i = 0; i < 4; i++) + { + events.Add(EventDefinitionBuilder.Individual($"Event{i}").Build()); + } + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = events + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Metadata, Contains.Key("MaxRecommended")); + Assert.That(warning.Metadata, Contains.Key("MaxCritical")); + Assert.That(warning.Metadata["MaxRecommended"], Is.EqualTo(2)); + Assert.That(warning.Metadata["MaxCritical"], Is.EqualTo(5)); + } + + #endregion + + #region NoRegionalEventAssignmentRule Tests + + [Test] + public void NoRegionalEventAssignmentRule_HasRegionalEvent_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { RequireRegionalEvent = true }; + var rule = new NoRegionalEventAssignmentRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void NoRegionalEventAssignmentRule_LacksRegionalEvent_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + RequireRegionalEvent = true, + NoRegionalEventSeverity = ValidationSeverity.Warning + }; + var rule = new NoRegionalEventAssignmentRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Coding").Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("NO_REGIONAL_EVENT_ASSIGNED")); + Assert.That(warning.Context, Is.EqualTo(ValidationContext.StudentAssignment)); + } + + #endregion + + #region NoOnSiteActivityAssignmentRule Tests + + [Test] + public void NoOnSiteActivityAssignmentRule_HasOnSiteActivity_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { RequireOnSiteActivity = true }; + var rule = new NoOnSiteActivityAssignmentRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Speech").AsOnSite().Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void NoOnSiteActivityAssignmentRule_LacksOnSiteActivity_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + RequireOnSiteActivity = true, + NoOnSiteActivitySeverity = ValidationSeverity.Error + }; + var rule = new NoOnSiteActivityAssignmentRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Flight").Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("NO_ONSITE_ACTIVITY_ASSIGNED")); + Assert.That(warning.Severity, Is.EqualTo(ValidationSeverity.Error)); + } + + #endregion + + #region TooManyRegionalEventsAssignmentRule Tests + + [Test] + public void TooManyRegionalEventsAssignmentRule_WithinLimit_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { MaxRegionalEvents = 3 }; + var rule = new TooManyRegionalEventsAssignmentRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build(), + EventDefinitionBuilder.Individual("Coding").AsRegionalEvent().Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void TooManyRegionalEventsAssignmentRule_ExceedsLimit_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + MaxRegionalEvents = 2, + TooManyRegionalEventsSeverity = ValidationSeverity.Warning + }; + var rule = new TooManyRegionalEventsAssignmentRule(); + + var stats = new StudentEventStatistics + { + Student = _testStudent, + Events = new List + { + EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build(), + EventDefinitionBuilder.Individual("Coding").AsRegionalEvent().Build(), + EventDefinitionBuilder.Individual("Essays").AsRegionalEvent().Build() + } + }; + + // Act + var warning = rule.Validate(stats, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("TOO_MANY_REGIONAL_EVENTS")); + Assert.That(warning.Message, Does.Contain("3 regional events")); + } + + #endregion + + #region AppliesTo Tests + + [Test] + public void AssignmentRules_ApplyToCorrectContexts() + { + // Arrange + var rules = new IValidationRule[] + { + new TooFewEventsRule(), + new TooManyEventsRule(), + new NoRegionalEventAssignmentRule(), + new NoOnSiteActivityAssignmentRule(), + new TooManyRegionalEventsAssignmentRule() + }; + + // Act & Assert - all assignment rules should apply to these contexts + foreach (var rule in rules) + { + Assert.That(rule.AppliesTo(ValidationContext.StudentAssignment), Is.True, + $"{rule.GetType().Name} should apply to StudentAssignment"); + Assert.That(rule.AppliesTo(ValidationContext.StudentRegistration), Is.True, + $"{rule.GetType().Name} should apply to StudentRegistration"); + Assert.That(rule.AppliesTo(ValidationContext.StudentRanking), Is.False, + $"{rule.GetType().Name} should not apply to StudentRanking"); + Assert.That(rule.AppliesTo(ValidationContext.Team), Is.False, + $"{rule.GetType().Name} should not apply to Team"); + } + } + + #endregion +} diff --git a/Tests/Validation/Rules/StudentRankingRulesTests.cs b/Tests/Validation/Rules/StudentRankingRulesTests.cs new file mode 100644 index 0000000..84fc028 --- /dev/null +++ b/Tests/Validation/Rules/StudentRankingRulesTests.cs @@ -0,0 +1,294 @@ +using Core.Entities; +using Core.Validation; +using Core.Validation.Rules.StudentRankingRules; +using Tests.Builders; + +namespace Tests.Validation.Rules; + +[TestFixture] +public class StudentRankingRulesTests +{ + [SetUp] + public void SetUp() + { + BuilderExtensions.ResetAllBuilders(); + } + + #region NoRegionalEventRule Tests + + [Test] + public void NoRegionalEventRule_StudentHasRegionalEvent_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration + { + RequireRegionalEvent = true, + RequireOnSiteActivity = false, + RequireIndividualEvent = false + }; + var rule = new NoRegionalEventRule(); + + var flight = EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build(); + var student = StudentBuilder.Default() + .WithRanking(flight, 1) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Null, "Student with regional event should not trigger warning"); + } + + [Test] + public void NoRegionalEventRule_StudentLacksRegionalEvent_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + RequireRegionalEvent = true, + NoRegionalEventSeverity = ValidationSeverity.Warning + }; + var rule = new NoRegionalEventRule(); + + var coding = EventDefinitionBuilder.Individual("Coding").Build(); // Not regional + var student = StudentBuilder.Default() + .WithRanking(coding, 1) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("NO_REGIONAL_EVENT")); + Assert.That(warning.Message, Is.EqualTo("No Regional Event")); + Assert.That(warning.Severity, Is.EqualTo(ValidationSeverity.Warning)); + Assert.That(warning.IconIdentifier, Is.EqualTo("RegionalEvent")); + } + + [Test] + public void NoRegionalEventRule_RequirementDisabled_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { RequireRegionalEvent = false }; + var rule = new NoRegionalEventRule(); + + var coding = EventDefinitionBuilder.Individual("Coding").Build(); + var student = StudentBuilder.Default() + .WithRanking(coding, 1) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Null, "Warning should not be returned when requirement is disabled"); + } + + [Test] + public void NoRegionalEventRule_AppliesTo_CorrectContexts() + { + // Arrange + var rule = new NoRegionalEventRule(); + + // Act & Assert + Assert.That(rule.AppliesTo(ValidationContext.StudentRanking), Is.True); + Assert.That(rule.AppliesTo(ValidationContext.StudentRegistration), Is.True); + Assert.That(rule.AppliesTo(ValidationContext.StudentAssignment), Is.False); + Assert.That(rule.AppliesTo(ValidationContext.Team), Is.False); + } + + #endregion + + #region NoOnSiteActivityRule Tests + + [Test] + public void NoOnSiteActivityRule_StudentHasOnSiteActivity_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration + { + RequireOnSiteActivity = true, + RequireRegionalEvent = false, + RequireIndividualEvent = false + }; + var rule = new NoOnSiteActivityRule(); + + var speech = EventDefinitionBuilder.Individual("Speech").AsOnSite().Build(); + var student = StudentBuilder.Default() + .WithRanking(speech, 1) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void NoOnSiteActivityRule_StudentLacksOnSiteActivity_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + RequireOnSiteActivity = true, + NoOnSiteActivitySeverity = ValidationSeverity.Error + }; + var rule = new NoOnSiteActivityRule(); + + var flight = EventDefinitionBuilder.Individual("Flight").Build(); // Not on-site + var student = StudentBuilder.Default() + .WithRanking(flight, 1) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("NO_ONSITE_ACTIVITY")); + Assert.That(warning.Severity, Is.EqualTo(ValidationSeverity.Error)); + } + + #endregion + + #region NoIndividualEventRule Tests + + [Test] + public void NoIndividualEventRule_StudentHasIndividualEvent_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration + { + RequireIndividualEvent = true, + RequireRegionalEvent = false, + RequireOnSiteActivity = false + }; + var rule = new NoIndividualEventRule(); + + var flight = EventDefinitionBuilder.Individual("Flight").Build(); + var student = StudentBuilder.Default() + .WithRanking(flight, 1) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void NoIndividualEventRule_StudentOnlyHasTeamEvents_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + RequireIndividualEvent = true, + NoIndividualEventSeverity = ValidationSeverity.Warning + }; + var rule = new NoIndividualEventRule(); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).Build(); + var student = StudentBuilder.Default() + .WithRanking(robotics, 1) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("NO_INDIVIDUAL_EVENT")); + Assert.That(warning.Message, Is.EqualTo("No Individual Event")); + } + + #endregion + + #region TooManyRegionalEventsRule Tests + + [Test] + public void TooManyRegionalEventsRule_WithinLimit_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { MaxRegionalEvents = 3 }; + var rule = new TooManyRegionalEventsRule(); + + var flight = EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build(); + var coding = EventDefinitionBuilder.Individual("Coding").AsRegionalEvent().Build(); + + var student = StudentBuilder.Default() + .WithRanking(flight, 1) + .WithRanking(coding, 2) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void TooManyRegionalEventsRule_ExceedsLimit_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + MaxRegionalEvents = 2, + TooManyRegionalEventsSeverity = ValidationSeverity.Warning + }; + var rule = new TooManyRegionalEventsRule(); + + var flight = EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build(); + var coding = EventDefinitionBuilder.Individual("Coding").AsRegionalEvent().Build(); + var essays = EventDefinitionBuilder.Individual("Essays").AsRegionalEvent().Build(); + + var student = StudentBuilder.Default() + .WithRanking(flight, 1) + .WithRanking(coding, 2) + .WithRanking(essays, 3) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("TOO_MANY_REGIONAL_EVENTS")); + Assert.That(warning.Message, Does.Contain("3 regional events")); + Assert.That(warning.Message, Does.Contain("max recommended: 2")); + Assert.That(warning.IconIdentifier, Is.EqualTo("RegionalEvent")); + } + + [Test] + public void TooManyRegionalEventsRule_ContainsMetadata() + { + // Arrange + var config = new ValidationConfiguration { MaxRegionalEvents = 1 }; + var rule = new TooManyRegionalEventsRule(); + + var flight = EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build(); + var coding = EventDefinitionBuilder.Individual("Coding").AsRegionalEvent().Build(); + + var student = StudentBuilder.Default() + .WithName("Alice", "Anderson") + .WithRanking(flight, 1) + .WithRanking(coding, 2) + .Build(); + + // Act + var warning = rule.Validate(student, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Metadata, Contains.Key("RegionalEventCount")); + Assert.That(warning.Metadata, Contains.Key("MaxRegionalEvents")); + Assert.That(warning.Metadata["RegionalEventCount"], Is.EqualTo(2)); + Assert.That(warning.Metadata["MaxRegionalEvents"], Is.EqualTo(1)); + } + + #endregion +} diff --git a/Tests/Validation/Rules/TeamRulesTests.cs b/Tests/Validation/Rules/TeamRulesTests.cs new file mode 100644 index 0000000..1e79903 --- /dev/null +++ b/Tests/Validation/Rules/TeamRulesTests.cs @@ -0,0 +1,310 @@ +using Core.Entities; +using Core.Validation; +using Core.Validation.Rules.TeamRules; +using Tests.Builders; + +namespace Tests.Validation.Rules; + +[TestFixture] +public class TeamRulesTests +{ + [SetUp] + public void SetUp() + { + BuilderExtensions.ResetAllBuilders(); + } + + #region MissingCaptainRule Tests + + [Test] + public void MissingCaptainRule_TeamHasCaptain_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { RequireTeamCaptain = true }; + var rule = new MissingCaptainRule(); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 4).Build(); + var captain = StudentBuilder.Default().WithName("Alice", "A").Build(); + var member = StudentBuilder.Default().WithName("Bob", "B").Build(); + + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(captain, isCaptain: true) + .WithStudent(member) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void MissingCaptainRule_TeamLacksCaptain_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + RequireTeamCaptain = true, + MissingCaptainSeverity = ValidationSeverity.Warning + }; + var rule = new MissingCaptainRule(); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 4).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().WithName("Alice", "A").Build()) + .WithStudent(StudentBuilder.Default().WithName("Bob", "B").Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("MISSING_CAPTAIN")); + Assert.That(warning.Message, Does.Contain("no captain assigned")); + Assert.That(warning.Severity, Is.EqualTo(ValidationSeverity.Warning)); + Assert.That(warning.IconIdentifier, Is.EqualTo("Captain")); + } + + [Test] + public void MissingCaptainRule_RequirementDisabled_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { RequireTeamCaptain = false }; + var rule = new MissingCaptainRule(); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 4).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void MissingCaptainRule_IndividualEvent_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { RequireTeamCaptain = true }; + var rule = new MissingCaptainRule(); + + var flight = EventDefinitionBuilder.Individual("Flight").Build(); + var team = TeamBuilder.Default() + .ForEvent(flight) + .WithStudent(StudentBuilder.Default().Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Null, "Individual events should not require a captain"); + } + + #endregion + + #region TeamSizeTooSmallRule Tests + + [Test] + public void TeamSizeTooSmallRule_MeetsMinimum_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { TeamSizeSeverity = ValidationSeverity.Warning }; + var rule = new TeamSizeTooSmallRule(); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 4).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().WithName("Alice", "A").Build()) + .WithStudent(StudentBuilder.Default().WithName("Bob", "B").Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void TeamSizeTooSmallRule_BelowMinimum_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration { TeamSizeSeverity = ValidationSeverity.Error }; + var rule = new TeamSizeTooSmallRule(); + + var robotics = EventDefinitionBuilder.Team("Robotics", 3, 5).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().WithName("Alice", "A").Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("TEAM_SIZE_TOO_SMALL")); + Assert.That(warning.Message, Does.Contain("1 members")); + Assert.That(warning.Message, Does.Contain("min: 3")); + Assert.That(warning.Severity, Is.EqualTo(ValidationSeverity.Error)); + } + + [Test] + public void TeamSizeTooSmallRule_IndividualEvent_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { TeamSizeSeverity = ValidationSeverity.Warning }; + var rule = new TeamSizeTooSmallRule(); + + var flight = EventDefinitionBuilder.Individual("Flight").Build(); + var team = TeamBuilder.Default() + .ForEvent(flight) + .WithStudent(StudentBuilder.Default().Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void TeamSizeTooSmallRule_ContainsMetadata() + { + // Arrange + var config = new ValidationConfiguration { TeamSizeSeverity = ValidationSeverity.Warning }; + var rule = new TeamSizeTooSmallRule(); + + var robotics = EventDefinitionBuilder.Team("Robotics", 4, 6).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().Build()) + .WithStudent(StudentBuilder.Default().Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Metadata, Contains.Key("EventName")); + Assert.That(warning.Metadata, Contains.Key("ActualSize")); + Assert.That(warning.Metadata, Contains.Key("MinSize")); + Assert.That(warning.Metadata["ActualSize"], Is.EqualTo(2)); + Assert.That(warning.Metadata["MinSize"], Is.EqualTo(4)); + } + + #endregion + + #region TeamSizeTooLargeRule Tests + + [Test] + public void TeamSizeTooLargeRule_MeetsMaximum_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { TeamSizeSeverity = ValidationSeverity.Warning }; + var rule = new TeamSizeTooLargeRule(); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 4).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().WithName("Alice", "A").Build()) + .WithStudent(StudentBuilder.Default().WithName("Bob", "B").Build()) + .WithStudent(StudentBuilder.Default().WithName("Carol", "C").Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Null); + } + + [Test] + public void TeamSizeTooLargeRule_ExceedsMaximum_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration { TeamSizeSeverity = ValidationSeverity.Warning }; + var rule = new TeamSizeTooLargeRule(); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 3).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().WithName("Alice", "A").Build()) + .WithStudent(StudentBuilder.Default().WithName("Bob", "B").Build()) + .WithStudent(StudentBuilder.Default().WithName("Carol", "C").Build()) + .WithStudent(StudentBuilder.Default().WithName("David", "D").Build()) + .WithStudent(StudentBuilder.Default().WithName("Eve", "E").Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Code, Is.EqualTo("TEAM_SIZE_TOO_LARGE")); + Assert.That(warning.Message, Does.Contain("5 members")); + Assert.That(warning.Message, Does.Contain("max: 3")); + } + + [Test] + public void TeamSizeTooLargeRule_IndividualEvent_ReturnsNull() + { + // Arrange + var config = new ValidationConfiguration { TeamSizeSeverity = ValidationSeverity.Warning }; + var rule = new TeamSizeTooLargeRule(); + + var flight = EventDefinitionBuilder.Individual("Flight").Build(); + var team = TeamBuilder.Default() + .ForEvent(flight) + .WithStudent(StudentBuilder.Default().Build()) + .Build(); + + // Act + var warning = rule.Validate(team, config); + + // Assert + Assert.That(warning, Is.Null); + } + + #endregion + + #region AppliesTo Tests + + [Test] + public void TeamRules_ApplyToCorrectContexts() + { + // Arrange + var rules = new IValidationRule[] + { + new MissingCaptainRule(), + new TeamSizeTooSmallRule(), + new TeamSizeTooLargeRule() + }; + + // Act & Assert - all team rules should apply to Team context only + foreach (var rule in rules) + { + Assert.That(rule.AppliesTo(ValidationContext.Team), Is.True, + $"{rule.GetType().Name} should apply to Team"); + Assert.That(rule.AppliesTo(ValidationContext.StudentRanking), Is.False, + $"{rule.GetType().Name} should not apply to StudentRanking"); + Assert.That(rule.AppliesTo(ValidationContext.StudentAssignment), Is.False, + $"{rule.GetType().Name} should not apply to StudentAssignment"); + Assert.That(rule.AppliesTo(ValidationContext.StudentRegistration), Is.False, + $"{rule.GetType().Name} should not apply to StudentRegistration"); + } + } + + #endregion +} diff --git a/Tests/Validation/ValidationConfigurationTests.cs b/Tests/Validation/ValidationConfigurationTests.cs new file mode 100644 index 0000000..f5f442d --- /dev/null +++ b/Tests/Validation/ValidationConfigurationTests.cs @@ -0,0 +1,229 @@ +using Core.Entities; +using Core.Validation; +using System.Text.Json; + +namespace Tests.Validation; + +[TestFixture] +public class ValidationConfigurationTests +{ + [Test] + public void Default_ReturnsExpectedValues() + { + // Arrange & Act + var config = ValidationConfiguration.Default; + + // Assert + Assert.That(config.MinRecommendedEvents, Is.EqualTo(2)); + Assert.That(config.MaxRecommendedEvents, Is.EqualTo(4)); + Assert.That(config.MinCriticalEvents, Is.EqualTo(1)); + Assert.That(config.MaxCriticalEvents, Is.EqualTo(6)); + Assert.That(config.MaxRegionalEvents, Is.EqualTo(3)); + Assert.That(config.RequireRegionalEvent, Is.True); + Assert.That(config.RequireOnSiteActivity, Is.True); + Assert.That(config.RequireIndividualEvent, Is.False); + Assert.That(config.RequireTeamCaptain, Is.True); + Assert.That(config.NoRegionalEventSeverity, Is.EqualTo(ValidationSeverity.Warning)); + Assert.That(config.EventCountSeverity, Is.EqualTo(ValidationSeverity.Warning)); + Assert.That(config.MissingCaptainSeverity, Is.EqualTo(ValidationSeverity.Warning)); + Assert.That(config.TooManyRegionalEventsSeverity, Is.EqualTo(ValidationSeverity.Warning)); + } + + [Test] + public void FromAssignmentParameters_CreatesCorrectConfiguration() + { + // Arrange + var parameters = new AssignmentParameters( + effortLowerBound: 5, + effortUpperBound: 8, + eventsLowerBound: 2, + eventsUpperBound: 4, + requireRegional: true, + requireOnSite: false + ); + + // Act + var config = ValidationConfiguration.FromAssignmentParameters(parameters); + + // Assert + Assert.That(config.MinRecommendedEvents, Is.EqualTo(2)); + Assert.That(config.MaxRecommendedEvents, Is.EqualTo(4)); + Assert.That(config.RequireRegionalEvent, Is.True); + Assert.That(config.RequireOnSiteActivity, Is.False); + Assert.That(config.MinCriticalEvents, Is.EqualTo(1)); + Assert.That(config.MaxCriticalEvents, Is.EqualTo(6)); + Assert.That(config.RequireTeamCaptain, Is.True); + } + + [Test] + public void FromJson_ValidJson_ReturnsConfiguration() + { + // Arrange + var json = @"{ + ""MinRecommendedEvents"": 3, + ""MaxRecommendedEvents"": 5, + ""RequireRegionalEvent"": false, + ""NoRegionalEventSeverity"": ""Error"" + }"; + + // Act + var config = ValidationConfiguration.FromJson(json); + + // Assert + Assert.That(config.MinRecommendedEvents, Is.EqualTo(3)); + Assert.That(config.MaxRecommendedEvents, Is.EqualTo(5)); + Assert.That(config.RequireRegionalEvent, Is.False); + Assert.That(config.NoRegionalEventSeverity, Is.EqualTo(ValidationSeverity.Error)); + } + + [Test] + public void FromJson_InvalidJson_ReturnsDefault() + { + // Arrange + var invalidJson = "{ invalid json }"; + + // Act + var config = ValidationConfiguration.FromJson(invalidJson); + + // Assert - should return Default config on error + Assert.That(config, Is.Not.Null); + Assert.That(config.MinRecommendedEvents, Is.EqualTo(ValidationConfiguration.Default.MinRecommendedEvents)); + } + + [Test] + public async Task LoadFromFileAsync_NonExistentFile_ReturnsDefault() + { + // Arrange + var nonExistentPath = Path.Combine(Path.GetTempPath(), $"non_existent_{Guid.NewGuid()}.json"); + + // Act + var config = await ValidationConfiguration.LoadFromFileAsync(nonExistentPath); + + // Assert + Assert.That(config, Is.Not.Null); + Assert.That(config.MinRecommendedEvents, Is.EqualTo(ValidationConfiguration.Default.MinRecommendedEvents)); + } + + [Test] + public async Task SaveToFileAsync_And_LoadFromFileAsync_RoundTrip() + { + // Arrange + var tempPath = Path.Combine(Path.GetTempPath(), $"test_config_{Guid.NewGuid()}.json"); + var originalConfig = new ValidationConfiguration + { + MinRecommendedEvents = 3, + MaxRecommendedEvents = 5, + RequireRegionalEvent = false, + NoRegionalEventSeverity = ValidationSeverity.Error, + MaxRegionalEvents = 2 + }; + + try + { + // Act + await originalConfig.SaveToFileAsync(tempPath); + var loadedConfig = await ValidationConfiguration.LoadFromFileAsync(tempPath); + + // Assert + Assert.That(loadedConfig.MinRecommendedEvents, Is.EqualTo(3)); + Assert.That(loadedConfig.MaxRecommendedEvents, Is.EqualTo(5)); + Assert.That(loadedConfig.RequireRegionalEvent, Is.False); + Assert.That(loadedConfig.NoRegionalEventSeverity, Is.EqualTo(ValidationSeverity.Error)); + Assert.That(loadedConfig.MaxRegionalEvents, Is.EqualTo(2)); + } + finally + { + // Cleanup + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + + [Test] + public async Task SaveToFileAsync_CreatesReadableJson() + { + // Arrange + var tempPath = Path.Combine(Path.GetTempPath(), $"test_config_{Guid.NewGuid()}.json"); + var config = new ValidationConfiguration + { + MinRecommendedEvents = 2, + MaxRecommendedEvents = 4 + }; + + try + { + // Act + await config.SaveToFileAsync(tempPath); + var jsonContent = await File.ReadAllTextAsync(tempPath); + + // Assert + Assert.That(jsonContent, Does.Contain("MinRecommendedEvents")); + Assert.That(jsonContent, Does.Contain("MaxRecommendedEvents")); + + // Verify it's valid JSON + var doc = JsonDocument.Parse(jsonContent); + Assert.That(doc, Is.Not.Null); + } + finally + { + // Cleanup + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + + [Test] + public void ValidationConfiguration_CanBeModified() + { + // Arrange + var config = ValidationConfiguration.Default; + + // Act + config.MinRecommendedEvents = 5; + config.RequireRegionalEvent = false; + config.NoRegionalEventSeverity = ValidationSeverity.Error; + + // Assert + Assert.That(config.MinRecommendedEvents, Is.EqualTo(5)); + Assert.That(config.RequireRegionalEvent, Is.False); + Assert.That(config.NoRegionalEventSeverity, Is.EqualTo(ValidationSeverity.Error)); + } + + [Test] + public void ValidationConfiguration_AllPropertiesSerializable() + { + // Arrange + var config = new ValidationConfiguration + { + MinRecommendedEvents = 1, + MaxRecommendedEvents = 2, + MinCriticalEvents = 3, + MaxCriticalEvents = 4, + MaxRegionalEvents = 5, + RequireRegionalEvent = true, + RequireOnSiteActivity = false, + RequireIndividualEvent = true, + RequireTeamCaptain = false, + NoRegionalEventSeverity = ValidationSeverity.Error, + NoOnSiteActivitySeverity = ValidationSeverity.Warning, + NoIndividualEventSeverity = ValidationSeverity.Error, + TeamSizeSeverity = ValidationSeverity.Warning, + EventCountSeverity = ValidationSeverity.Error, + MissingCaptainSeverity = ValidationSeverity.Warning, + TooManyRegionalEventsSeverity = ValidationSeverity.Error + }; + + // Act + var json = JsonSerializer.Serialize(config); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + Assert.That(deserialized, Is.Not.Null); + Assert.That(deserialized!.MinRecommendedEvents, Is.EqualTo(config.MinRecommendedEvents)); + Assert.That(deserialized.MaxRecommendedEvents, Is.EqualTo(config.MaxRecommendedEvents)); + Assert.That(deserialized.RequireRegionalEvent, Is.EqualTo(config.RequireRegionalEvent)); + Assert.That(deserialized.NoRegionalEventSeverity, Is.EqualTo(config.NoRegionalEventSeverity)); + Assert.That(deserialized.MaxRegionalEvents, Is.EqualTo(config.MaxRegionalEvents)); + Assert.That(deserialized.TooManyRegionalEventsSeverity, Is.EqualTo(config.TooManyRegionalEventsSeverity)); + } +} diff --git a/Tests/Validation/ValidationServiceTests.cs b/Tests/Validation/ValidationServiceTests.cs new file mode 100644 index 0000000..381853d --- /dev/null +++ b/Tests/Validation/ValidationServiceTests.cs @@ -0,0 +1,425 @@ +using Core.Entities; +using Core.Validation; +using Tests.Builders; + +namespace Tests.Validation; + +[TestFixture] +public class ValidationServiceTests +{ + private ValidationConfiguration _defaultConfig; + + [SetUp] + public void SetUp() + { + BuilderExtensions.ResetAllBuilders(); + _defaultConfig = ValidationConfiguration.Default; + } + + [Test] + public void ValidationService_DiscoverRules_FindsAllRules() + { + // Arrange + var service = new ValidationService(_defaultConfig); + + // Act - rules are discovered in constructor via reflection + + // Assert - verify rules were discovered by validating entities + var student = StudentBuilder.Default() + .WithRanking(EventDefinitionBuilder.Individual("Flight").Build(), 1) + .Build(); + + var warnings = service.ValidateStudentRankings(student, ValidationContext.StudentRanking); + + // Should find warnings since student has no regional event (if required by config) + Assert.That(warnings, Is.Not.Null); + } + + [Test] + public void ValidateStudent_WithValidRankings_ReturnsNoWarnings() + { + // Arrange + var config = new ValidationConfiguration + { + RequireRegionalEvent = true, + RequireOnSiteActivity = true, + RequireIndividualEvent = false, + MinRecommendedEvents = 2, + MaxRecommendedEvents = 4 + }; + var service = new ValidationService(config); + + var flight = EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build(); + var speech = EventDefinitionBuilder.Individual("Speech").AsRegionalEvent().AsOnSite().Build(); + var coding = EventDefinitionBuilder.Individual("Coding").Build(); + + var student = StudentBuilder.Default() + .WithRanking(flight, 1) + .WithRanking(speech, 2) + .WithRanking(coding, 3) + .Build(); + + // Act + var warnings = service.ValidateStudentRankings(student, ValidationContext.StudentRanking); + + // Assert + Assert.That(warnings, Is.Empty, "Student with valid rankings should have no warnings"); + } + + [Test] + public void ValidateStudent_MissingRegionalEvent_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + RequireRegionalEvent = true, + RequireOnSiteActivity = false, + RequireIndividualEvent = false, + NoRegionalEventSeverity = ValidationSeverity.Warning + }; + var service = new ValidationService(config); + + var coding = EventDefinitionBuilder.Individual("Coding").Build(); // Not regional + + var student = StudentBuilder.Default() + .WithRanking(coding, 1) + .Build(); + + // Act + var warnings = service.ValidateStudentRankings(student, ValidationContext.StudentRanking); + + // Assert + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Code, Is.EqualTo("NO_REGIONAL_EVENT")); + Assert.That(warnings[0].Severity, Is.EqualTo(ValidationSeverity.Warning)); + Assert.That(warnings[0].Context, Is.EqualTo(ValidationContext.StudentRanking)); + } + + [Test] + public void ValidateStudent_MissingOnSiteActivity_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + RequireOnSiteActivity = true, + RequireRegionalEvent = false, + NoOnSiteActivitySeverity = ValidationSeverity.Warning + }; + var service = new ValidationService(config); + + var flight = EventDefinitionBuilder.Individual("Flight").Build(); // Not on-site + + var student = StudentBuilder.Default() + .WithRanking(flight, 1) + .Build(); + + // Act + var warnings = service.ValidateStudentRankings(student, ValidationContext.StudentRanking); + + // Assert + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Code, Is.EqualTo("NO_ONSITE_ACTIVITY")); + Assert.That(warnings[0].Severity, Is.EqualTo(ValidationSeverity.Warning)); + } + + [Test] + public void ValidateStudent_TooManyRegionalEvents_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + MaxRegionalEvents = 2, + TooManyRegionalEventsSeverity = ValidationSeverity.Warning, + RequireRegionalEvent = false, + RequireOnSiteActivity = false + }; + var service = new ValidationService(config); + + var flight = EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build(); + var coding = EventDefinitionBuilder.Individual("Coding").AsRegionalEvent().Build(); + var essays = EventDefinitionBuilder.Individual("Essays").AsRegionalEvent().Build(); + + var student = StudentBuilder.Default() + .WithRanking(flight, 1) + .WithRanking(coding, 2) + .WithRanking(essays, 3) + .Build(); + + // Act + var warnings = service.ValidateStudentRankings(student, ValidationContext.StudentRanking); + + // Assert + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Code, Is.EqualTo("TOO_MANY_REGIONAL_EVENTS")); + Assert.That(warnings[0].Message, Does.Contain("3 regional events")); + Assert.That(warnings[0].Message, Does.Contain("max recommended: 2")); + } + + [Test] + public void ValidateStudentAssignment_ValidAssignment_ReturnsNoWarnings() + { + // Arrange + var config = new ValidationConfiguration + { + RequireRegionalEvent = true, + RequireOnSiteActivity = true, + MinRecommendedEvents = 2, + MaxRecommendedEvents = 4 + }; + var service = new ValidationService(config); + + var flight = EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build(); + var speech = EventDefinitionBuilder.Individual("Speech").AsOnSite().Build(); + var coding = EventDefinitionBuilder.Individual("Coding").Build(); + + var student = StudentBuilder.Default().Build(); + var stats = new StudentEventStatistics + { + Student = student, + Events = new List { flight, speech, coding } + }; + + // Act + var warnings = service.ValidateStudentStatistics(stats, ValidationContext.StudentAssignment); + + // Assert + Assert.That(warnings, Is.Empty); + } + + [Test] + public void ValidateStudentAssignment_TooFewEvents_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + MinRecommendedEvents = 3, + EventCountSeverity = ValidationSeverity.Warning, + RequireRegionalEvent = false, + RequireOnSiteActivity = false + }; + var service = new ValidationService(config); + + var flight = EventDefinitionBuilder.Individual("Flight").Build(); + var student = StudentBuilder.Default().Build(); + var stats = new StudentEventStatistics + { + Student = student, + Events = new List { flight } + }; + + // Act + var warnings = service.ValidateStudentStatistics(stats, ValidationContext.StudentAssignment); + + // Assert + Assert.That(warnings, Has.Count.GreaterThan(0)); + var warning = warnings.FirstOrDefault(w => w.Code == "TOO_FEW_EVENTS"); + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Severity, Is.EqualTo(ValidationSeverity.Warning)); + } + + [Test] + public void ValidateStudentAssignment_TooManyEvents_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + MaxRecommendedEvents = 3, + EventCountSeverity = ValidationSeverity.Warning, + RequireRegionalEvent = false, + RequireOnSiteActivity = false + }; + var service = new ValidationService(config); + + var student = StudentBuilder.Default().Build(); + var events = new List(); + for (int i = 0; i < 5; i++) + { + events.Add(EventDefinitionBuilder.Individual($"Event{i}").Build()); + } + var stats = new StudentEventStatistics + { + Student = student, + Events = events + }; + + // Act + var warnings = service.ValidateStudentStatistics(stats, ValidationContext.StudentAssignment); + + // Assert + var warning = warnings.FirstOrDefault(w => w.Code == "TOO_MANY_EVENTS"); + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Message, Does.Contain("5 events")); + } + + [Test] + public void ValidateTeam_ValidTeam_ReturnsNoWarnings() + { + // Arrange + var config = new ValidationConfiguration + { + RequireTeamCaptain = true + }; + var service = new ValidationService(config); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 4).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().WithName("Alice", "A").Build(), isCaptain: true) + .WithStudent(StudentBuilder.Default().WithName("Bob", "B").Build()) + .Build(); + + // Act + var warnings = service.ValidateTeam(team); + + // Assert + Assert.That(warnings, Is.Empty); + } + + [Test] + public void ValidateTeam_MissingCaptain_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + RequireTeamCaptain = true, + MissingCaptainSeverity = ValidationSeverity.Warning + }; + var service = new ValidationService(config); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 4).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().WithName("Alice", "A").Build()) + .WithStudent(StudentBuilder.Default().WithName("Bob", "B").Build()) + .Build(); + + // Act + var warnings = service.ValidateTeam(team); + + // Assert + Assert.That(warnings, Has.Count.EqualTo(1)); + Assert.That(warnings[0].Code, Is.EqualTo("MISSING_CAPTAIN")); + Assert.That(warnings[0].Severity, Is.EqualTo(ValidationSeverity.Warning)); + } + + [Test] + public void ValidateTeam_TeamTooSmall_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + TeamSizeSeverity = ValidationSeverity.Warning, + RequireTeamCaptain = false + }; + var service = new ValidationService(config); + + var robotics = EventDefinitionBuilder.Team("Robotics", 3, 5).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().WithName("Alice", "A").Build()) + .Build(); + + // Act + var warnings = service.ValidateTeam(team); + + // Assert + var warning = warnings.FirstOrDefault(w => w.Code == "TEAM_SIZE_TOO_SMALL"); + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Message, Does.Contain("1 members")); + Assert.That(warning.Message, Does.Contain("min: 3")); + } + + [Test] + public void ValidateTeam_TeamTooLarge_ReturnsWarning() + { + // Arrange + var config = new ValidationConfiguration + { + TeamSizeSeverity = ValidationSeverity.Warning, + RequireTeamCaptain = false + }; + var service = new ValidationService(config); + + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 3).Build(); + var team = TeamBuilder.Default() + .ForEvent(robotics) + .WithStudent(StudentBuilder.Default().WithName("Alice", "A").Build()) + .WithStudent(StudentBuilder.Default().WithName("Bob", "B").Build()) + .WithStudent(StudentBuilder.Default().WithName("Carol", "C").Build()) + .WithStudent(StudentBuilder.Default().WithName("David", "D").Build()) + .Build(); + + // Act + var warnings = service.ValidateTeam(team); + + // Assert + var warning = warnings.FirstOrDefault(w => w.Code == "TEAM_SIZE_TOO_LARGE"); + Assert.That(warning, Is.Not.Null); + Assert.That(warning!.Message, Does.Contain("4 members")); + Assert.That(warning.Message, Does.Contain("max: 3")); + } + + [Test] + public void ValidationService_ConfigurationChanges_AffectResults() + { + // Arrange + var student = StudentBuilder.Default() + .WithRanking(EventDefinitionBuilder.Individual("Flight").Build(), 1) + .Build(); + + // Strict config + var strictConfig = new ValidationConfiguration + { + RequireRegionalEvent = true, + RequireOnSiteActivity = false, + RequireIndividualEvent = false, + NoRegionalEventSeverity = ValidationSeverity.Error + }; + var strictService = new ValidationService(strictConfig); + + // Lenient config + var lenientConfig = new ValidationConfiguration + { + RequireRegionalEvent = false, + RequireOnSiteActivity = false, + RequireIndividualEvent = false + }; + var lenientService = new ValidationService(lenientConfig); + + // Act + var strictWarnings = strictService.ValidateStudentRankings(student, ValidationContext.StudentRanking); + var lenientWarnings = lenientService.ValidateStudentRankings(student, ValidationContext.StudentRanking); + + // Assert + Assert.That(strictWarnings, Has.Count.EqualTo(1)); + Assert.That(strictWarnings[0].Severity, Is.EqualTo(ValidationSeverity.Error)); + Assert.That(lenientWarnings, Is.Empty, "Lenient config should not require regional event"); + } + + [Test] + public void ValidationWarning_ContainsMetadata() + { + // Arrange + var config = new ValidationConfiguration + { + RequireRegionalEvent = true, + RequireOnSiteActivity = false + }; + var service = new ValidationService(config); + + var student = StudentBuilder.Default() + .WithName("Alice", "Anderson") + .WithRanking(EventDefinitionBuilder.Individual("Coding").Build(), 1) + .Build(); + + // Act + var warnings = service.ValidateStudentRankings(student, ValidationContext.StudentRanking); + + // Assert + Assert.That(warnings, Has.Count.EqualTo(1)); + var warning = warnings[0]; + Assert.That(warning.Metadata, Contains.Key("StudentId")); + Assert.That(warning.Metadata, Contains.Key("StudentName")); + Assert.That(warning.Metadata["StudentName"], Is.EqualTo("Alice Anderson")); + } +}