diff --git a/Core/Utility/TeamClipboardMatcher.cs b/Core/Utility/TeamClipboardMatcher.cs new file mode 100644 index 0000000..8b0b0ef --- /dev/null +++ b/Core/Utility/TeamClipboardMatcher.cs @@ -0,0 +1,91 @@ +using Core.Entities; +using FuzzySharp; + +namespace Core.Utility; + +/// +/// Utility class for matching team names from clipboard text using fuzzy matching. +/// +public static class TeamClipboardMatcher +{ + /// + /// Matches team names from clipboard text against available teams using fuzzy matching. + /// + /// The text content from the clipboard. + /// The collection of available teams to match against. + /// The minimum fuzzy match score threshold (0-100). Default is 85. + /// A list of teams that match the clipboard text, ordered by match quality. + public static List MatchTeamsFromClipboard( + string clipboardText, + IEnumerable availableTeams, + int matchThreshold = 85) + { + var matchedTeams = new List(); + var teamsList = availableTeams.ToList(); + + if (string.IsNullOrWhiteSpace(clipboardText) || !teamsList.Any()) + { + return matchedTeams; + } + + // Split clipboard text by newlines + var lines = clipboardText.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + // Extract team name portion (before " - " if present, as clipboard format includes student lists) + var teamName = ExtractTeamNameFromLine(line); + + // Skip empty lines or lines that look like headers/metadata + if (ShouldSkipLine(teamName)) + { + continue; + } + + // Find best match using fuzzy matching + var matchedTeam = FindBestMatch(teamName, teamsList, matchThreshold); + + if (matchedTeam != null && !matchedTeams.Any(t => t.Id == matchedTeam.Id)) + { + matchedTeams.Add(matchedTeam); + } + } + + return matchedTeams; + } + + private static string ExtractTeamNameFromLine(string line) + { + var dashIndex = line.IndexOf(" - "); + if (dashIndex > 0) + { + return line.Substring(0, dashIndex).Trim(); + } + return line.Trim(); + } + + private static bool ShouldSkipLine(string teamName) + { + return string.IsNullOrWhiteSpace(teamName) || + teamName.StartsWith("--") || + teamName.Equals("Unscheduled", StringComparison.OrdinalIgnoreCase); + } + + private static Team? FindBestMatch(string teamName, List availableTeams, int matchThreshold) + { + // Normalize both strings to lowercase for case-insensitive comparison + var normalizedTeamName = teamName.ToLowerInvariant(); + + var bestMatch = availableTeams + .Select(team => new + { + Team = team, + Score = Fuzz.Ratio(normalizedTeamName, team.ToString().ToLowerInvariant()) + }) + .Where(x => x.Score >= matchThreshold) + .OrderByDescending(x => x.Score) + .FirstOrDefault(); + + return bestMatch?.Team; + } +} diff --git a/Tests/Utility/TeamClipboardMatcher_Tests.cs b/Tests/Utility/TeamClipboardMatcher_Tests.cs new file mode 100644 index 0000000..8b0bbfb --- /dev/null +++ b/Tests/Utility/TeamClipboardMatcher_Tests.cs @@ -0,0 +1,313 @@ +using Core.Entities; +using Core.Utility; +using Tests.Builders; + +namespace Tests.Utility; + +[TestFixture] +public class TeamClipboardMatcher_Tests +{ + private List _availableTeams = []; + + [SetUp] + public void SetUp() + { + BuilderExtensions.ResetAllBuilders(); + + // Create test teams + var construction = EventDefinitionBuilder.Team("Construction Challenge", 2, 4).Build(); + var flight = EventDefinitionBuilder.Individual("Flight").Build(); + var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).Build(); + var coding = EventDefinitionBuilder.Individual("Coding").Build(); + var medicalTech = EventDefinitionBuilder.Team("Medical Technology", 2, 3).Build(); + + _availableTeams = + [ + TeamBuilder.Create(construction).WithIdentifier("2").Build(), + TeamBuilder.Create(flight).Build(), + TeamBuilder.Create(robotics).Build(), + TeamBuilder.Create(coding).Build(), + TeamBuilder.Create(medicalTech).Build() + ]; + } + + [Test] + public void MatchTeamsFromClipboard_ExactMatch_ReturnsMatchingTeam() + { + // Arrange + var clipboardText = "Construction Challenge (2)"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge")); + Assert.That(result[0].Identifier, Is.EqualTo("2")); + } + + [Test] + public void MatchTeamsFromClipboard_ExactMatchWithoutIdentifier_ReturnsMatchingTeam() + { + // Arrange + var clipboardText = "Flight"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Event.Name, Is.EqualTo("Flight")); + } + + [Test] + public void MatchTeamsFromClipboard_ClipboardFormatWithStudentList_ExtractsTeamName() + { + // Arrange + var clipboardText = "Construction Challenge (2) - John Doe, Jane Smith"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge")); + } + + [Test] + public void MatchTeamsFromClipboard_MultipleTeams_ReturnsAllMatches() + { + // Arrange + var clipboardText = "Flight\r\nRobotics\r\nCoding"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(3)); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight")); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics")); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Coding")); + } + + [Test] + public void MatchTeamsFromClipboard_FuzzyMatch_ReturnsBestMatch() + { + // Arrange + var clipboardText = "Construcion Challange"; // Intentional typos + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge")); + } + + [Test] + public void MatchTeamsFromClipboard_CaseInsensitive_MatchesCorrectly() + { + // Arrange + var clipboardText = "FLIGHT\r\nconstruction challenge (2)"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight")); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Construction Challenge")); + } + + [Test] + public void MatchTeamsFromClipboard_SkipsEmptyLines_IgnoresEmptyEntries() + { + // Arrange + var clipboardText = "Flight\r\n\r\nRobotics\r\n \r\nCoding"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(3)); + } + + [Test] + public void MatchTeamsFromClipboard_SkipsMetadataHeaders_IgnoresSpecialMarkers() + { + // Arrange + var clipboardText = "--Unscheduled\r\nFlight\r\n--Another Header\r\nRobotics\r\nUnscheduled"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight")); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics")); + } + + [Test] + public void MatchTeamsFromClipboard_NoMatches_ReturnsEmptyList() + { + // Arrange + var clipboardText = "NonExistent Team Name"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void MatchTeamsFromClipboard_BelowThreshold_ReturnsEmptyList() + { + // Arrange + var clipboardText = "XYZ"; // Very different from any team name + var highThreshold = 95; // Very high threshold + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams, highThreshold); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void MatchTeamsFromClipboard_CustomThreshold_RespectsThreshold() + { + // Arrange + var clipboardText = "Construction Challeng"; // Close match, should work with lower threshold + var lowThreshold = 70; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams, lowThreshold); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge")); + } + + [Test] + public void MatchTeamsFromClipboard_EmptyClipboard_ReturnsEmptyList() + { + // Arrange + var clipboardText = ""; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void MatchTeamsFromClipboard_NullClipboard_ReturnsEmptyList() + { + // Arrange + string? clipboardText = null; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText!, _availableTeams); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void MatchTeamsFromClipboard_EmptyTeamsList_ReturnsEmptyList() + { + // Arrange + var clipboardText = "Flight"; + var emptyTeams = new List(); + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, emptyTeams); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void MatchTeamsFromClipboard_DuplicateTeamNames_ReturnsSingleInstance() + { + // Arrange + var clipboardText = "Flight\r\nFlight\r\nFlight"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Event.Name, Is.EqualTo("Flight")); + } + + [Test] + public void MatchTeamsFromClipboard_MultipleLinesWithDifferentFormats_HandlesAllFormats() + { + // Arrange + var clipboardText = "Flight\r\nRobotics - Student List\r\nMedical Technology (1) - More Students"; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(3)); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight")); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics")); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Medical Technology")); + } + + [Test] + public void MatchTeamsFromClipboard_WhitespaceAroundTeamName_TrimsCorrectly() + { + // Arrange + var clipboardText = " Flight \r\n Robotics "; + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight")); + Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics")); + } + + [Test] + public void MatchTeamsFromClipboard_ReturnsTeamsFromInputCollection_MaintainsReferenceEquality() + { + // Arrange + var clipboardText = "Flight"; + var inputTeam = _availableTeams.First(t => t.Event.Name == "Flight"); + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams); + + // Assert + Assert.That(result[0], Is.SameAs(inputTeam)); + } + + [Test] + public void MatchTeamsFromClipboard_BestMatchSelected_ReturnsHighestScoringMatch() + { + // Arrange + // Create teams with similar names + var construction1 = EventDefinitionBuilder.Team("Construction Challenge", 2, 4).Build(); + var construction2 = EventDefinitionBuilder.Team("Construction Challenge Advanced", 2, 4).Build(); + var teams = new[] + { + TeamBuilder.Create(construction1).Build(), + TeamBuilder.Create(construction2).Build() + }; + var clipboardText = "Construction Challenge"; // Should match the first one exactly + + // Act + var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, teams); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + // The exact match should be selected (higher score) + Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge")); + } +} diff --git a/WebApp/Components/Features/MeetingSchedule/Index.razor b/WebApp/Components/Features/MeetingSchedule/Index.razor index 473627c..4b87c5d 100644 --- a/WebApp/Components/Features/MeetingSchedule/Index.razor +++ b/WebApp/Components/Features/MeetingSchedule/Index.razor @@ -47,46 +47,75 @@ Time Slots - - - - - - - Add High Effort - + + + Time Slots + + + + - Add Regionals + - Remove Individual + - Remove Low Effort + + + + Invert + + + + Load from Clipboard + + Reset - - Solve - - - - + + + + + + + Solve + + - @@ -149,6 +178,7 @@ private TeamSchedulerSolution _solution = null!; private TeamSchedulerOptions _parameters = null!; bool _isSolving; + private bool _isLoadingClipboard = false; private IEnumerable _scheduledTeams = []; private IEnumerable _absentStudents = []; private IEnumerable _possibleAdditions = []; @@ -195,12 +225,40 @@ StateHasChanged(); } + private void RemoveHighLevelOfEffort() + { + var highEffortTeamIds = _teams.Where(t => t.Event.LevelOfEffort >= 3).Select(t => t.Id).ToHashSet(); + _scheduledTeams = _scheduledTeams.Where(t => !highEffortTeamIds.Contains(t.Id)); + StateHasChanged(); + } + + private void RemoveRegionals() + { + var regionalTeamIds = _teams.Where(t => t.Event.RegionalEvent).Select(t => t.Id).ToHashSet(); + _scheduledTeams = _scheduledTeams.Where(t => !regionalTeamIds.Contains(t.Id)); + StateHasChanged(); + } + + private void AddIndividual() + { + var individualTeams = _teams.Where(t => t.Event.EventFormat == EventFormat.Individual); + _scheduledTeams = _scheduledTeams.Concat(individualTeams).Distinct(); + StateHasChanged(); + } + private void RemoveIndividual() { _scheduledTeams = _scheduledTeams.RemoveIndividual(); StateHasChanged(); } + private void AddLowLevelOfEffort() + { + var lowEffortTeams = _teams.Where(t => t.Event.LevelOfEffort <= 1); + _scheduledTeams = _scheduledTeams.Concat(lowEffortTeams).Distinct(); + StateHasChanged(); + } + private void RemoveLowLevelOfEffort() { _scheduledTeams = _scheduledTeams.RemoveLowLevelOfEffort(); @@ -512,6 +570,63 @@ // Note: Success message is already shown in the dialog, no need to show another here } + private async Task LoadTeamsFromClipboard() + { + if (_isLoadingClipboard) return; + + try + { + _isLoadingClipboard = true; + StateHasChanged(); + + var clipboardText = await ClipboardService.ReadTextAsync(); + + if (string.IsNullOrWhiteSpace(clipboardText)) + { + Snackbar.Add("Clipboard is empty", Severity.Warning); + return; + } + + var matchedTeams = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _teams); + + if (!matchedTeams.Any()) + { + Snackbar.Add("No matching teams found in clipboard text", Severity.Info); + return; + } + + // Combine with existing scheduled teams, avoiding duplicates + var existingTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet(); + var newTeams = matchedTeams.Where(t => !existingTeamIds.Contains(t.Id)); + var updatedTeams = _scheduledTeams.Concat(newTeams).ToList(); + + OnScheduledTeamsChanged(updatedTeams); + + var newCount = newTeams.Count(); + var totalCount = matchedTeams.Count(); + if (newCount == totalCount) + { + Snackbar.Add($"Selected {totalCount} team(s) from clipboard", Severity.Success); + } + else + { + Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success); + } + } + catch (JSException ex) + { + Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Error loading teams from clipboard: {ex.Message}", Severity.Error); + } + finally + { + _isLoadingClipboard = false; + StateHasChanged(); + } + } } \ No newline at end of file diff --git a/WebApp/Components/Shared/Components/AddRemoveFilter.razor b/WebApp/Components/Shared/Components/AddRemoveFilter.razor new file mode 100644 index 0000000..0bb43c6 --- /dev/null +++ b/WebApp/Components/Shared/Components/AddRemoveFilter.razor @@ -0,0 +1,54 @@ +@namespace WebApp.Components.Shared.Components + + + + + + + @Label + + + + + + +@code { + [Parameter] + public required string Label { get; set; } + + [Parameter] + public EventCallback OnAdd { get; set; } + + [Parameter] + public EventCallback OnRemove { get; set; } + + [Parameter] + public string? AddTooltip { get; set; } + + [Parameter] + public string? RemoveTooltip { get; set; } + + private async Task HandleAdd() + { + if (OnAdd.HasDelegate) + { + await OnAdd.InvokeAsync(); + } + } + + private async Task HandleRemove() + { + if (OnRemove.HasDelegate) + { + await OnRemove.InvokeAsync(); + } + } +}