Add TeamClipboardMatcher utility and corresponding tests for fuzzy team name matching
This commit introduces the TeamClipboardMatcher class, which provides functionality to match team names from clipboard text using fuzzy matching techniques. The class includes methods for extracting team names and finding the best match based on a specified threshold. Additionally, comprehensive unit tests are added in TeamClipboardMatcher_Tests to validate various matching scenarios, including exact matches, fuzzy matches, and handling of different clipboard formats. This enhancement improves the application's ability to efficiently match teams from user input.
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using FuzzySharp;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for matching team names from clipboard text using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamClipboardMatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Matches team names from clipboard text against available teams using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clipboardText">The text content from the clipboard.</param>
|
||||||
|
/// <param name="availableTeams">The collection of available teams to match against.</param>
|
||||||
|
/// <param name="matchThreshold">The minimum fuzzy match score threshold (0-100). Default is 85.</param>
|
||||||
|
/// <returns>A list of teams that match the clipboard text, ordered by match quality.</returns>
|
||||||
|
public static List<Team> MatchTeamsFromClipboard(
|
||||||
|
string clipboardText,
|
||||||
|
IEnumerable<Team> availableTeams,
|
||||||
|
int matchThreshold = 85)
|
||||||
|
{
|
||||||
|
var matchedTeams = new List<Team>();
|
||||||
|
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<Team> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Utility;
|
||||||
|
using Tests.Builders;
|
||||||
|
|
||||||
|
namespace Tests.Utility;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class TeamClipboardMatcher_Tests
|
||||||
|
{
|
||||||
|
private List<Team> _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<Team>();
|
||||||
|
|
||||||
|
// 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,46 +47,75 @@
|
|||||||
<MudText Typo="Typo.h4">Time Slots</MudText>
|
<MudText Typo="Typo.h4">Time Slots</MudText>
|
||||||
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="6" sm="3" lg="2">
|
|
||||||
<MudNumericField Value="_parameters.TimeSlots"
|
|
||||||
ValueChanged="async (int val) => await OnTimeSlotCountChanged(val)"
|
|
||||||
Label="Time Slots" Min="1" Max="4">
|
|
||||||
</MudNumericField>
|
|
||||||
</MudItem>
|
|
||||||
<MudFlexBreak/>
|
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false">
|
<MudPaper Elevation="0" Class="pa-1" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="AddHighLevelOfEffort" FullWidth="true">Add High Effort</MudButton>
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
</MudTooltip>
|
<MudText Typo="Typo.body2">Time Slots</MudText>
|
||||||
|
<MudNumericField Value="_parameters.TimeSlots"
|
||||||
|
ValueChanged="async (int val) => await OnTimeSlotCountChanged(val)"
|
||||||
|
Label=""
|
||||||
|
Min="1"
|
||||||
|
Max="4"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Style="width: 80px;">
|
||||||
|
</MudNumericField>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="AddRegionals" FullWidth="true">Add Regionals</MudButton>
|
<AddRemoveFilter Label="High Effort"
|
||||||
|
OnAdd="AddHighLevelOfEffort"
|
||||||
|
OnRemove="RemoveHighLevelOfEffort"
|
||||||
|
AddTooltip="Schedule teams with Level of Effort >= 3"
|
||||||
|
RemoveTooltip="Remove teams with Level of Effort >= 3" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="RemoveIndividual" FullWidth="true">Remove Individual</MudButton>
|
<AddRemoveFilter Label="Regionals"
|
||||||
|
OnAdd="AddRegionals"
|
||||||
|
OnRemove="RemoveRegionals"
|
||||||
|
AddTooltip="Add regional event teams"
|
||||||
|
RemoveTooltip="Remove regional event teams" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="RemoveLowLevelOfEffort" FullWidth="true">Remove Low Effort</MudButton>
|
<AddRemoveFilter Label="Individual"
|
||||||
|
OnAdd="AddIndividual"
|
||||||
|
OnRemove="RemoveIndividual"
|
||||||
|
AddTooltip="Add individual event teams"
|
||||||
|
RemoveTooltip="Remove individual event teams" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<AddRemoveFilter Label="Low Effort"
|
||||||
|
OnAdd="AddLowLevelOfEffort"
|
||||||
|
OnRemove="RemoveLowLevelOfEffort"
|
||||||
|
AddTooltip="Add teams with Level of Effort <= 1"
|
||||||
|
RemoveTooltip="Remove teams with Level of Effort <= 1" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<MudTooltip Text="Load teams from clipboard text by matching team names">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="LoadTeamsFromClipboard" FullWidth="true" Disabled="@_isLoadingClipboard">Load from Clipboard</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
Class="ma-3"
|
<MudSpacer />
|
||||||
OnClick="Solve"
|
<MudTooltip Text="Copy to Clipboard">
|
||||||
Color="Color.Primary"
|
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
||||||
Disabled="@_isSolving">
|
</MudTooltip>
|
||||||
Solve
|
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
||||||
</MudButton>
|
OnClick="Solve"
|
||||||
<MudTooltip Text="Copy to Clipboard">
|
Color="Color.Primary"
|
||||||
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
Disabled="@_isSolving">
|
||||||
</MudTooltip>
|
Solve
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@@ -149,6 +178,7 @@
|
|||||||
private TeamSchedulerSolution _solution = null!;
|
private TeamSchedulerSolution _solution = null!;
|
||||||
private TeamSchedulerOptions _parameters = null!;
|
private TeamSchedulerOptions _parameters = null!;
|
||||||
bool _isSolving;
|
bool _isSolving;
|
||||||
|
private bool _isLoadingClipboard = false;
|
||||||
private IEnumerable<Team> _scheduledTeams = [];
|
private IEnumerable<Team> _scheduledTeams = [];
|
||||||
private IEnumerable<Student> _absentStudents = [];
|
private IEnumerable<Student> _absentStudents = [];
|
||||||
private IEnumerable<Team> _possibleAdditions = [];
|
private IEnumerable<Team> _possibleAdditions = [];
|
||||||
@@ -195,12 +225,40 @@
|
|||||||
StateHasChanged();
|
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()
|
private void RemoveIndividual()
|
||||||
{
|
{
|
||||||
_scheduledTeams = _scheduledTeams.RemoveIndividual();
|
_scheduledTeams = _scheduledTeams.RemoveIndividual();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AddLowLevelOfEffort()
|
||||||
|
{
|
||||||
|
var lowEffortTeams = _teams.Where(t => t.Event.LevelOfEffort <= 1);
|
||||||
|
_scheduledTeams = _scheduledTeams.Concat(lowEffortTeams).Distinct();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private void RemoveLowLevelOfEffort()
|
private void RemoveLowLevelOfEffort()
|
||||||
{
|
{
|
||||||
_scheduledTeams = _scheduledTeams.RemoveLowLevelOfEffort();
|
_scheduledTeams = _scheduledTeams.RemoveLowLevelOfEffort();
|
||||||
@@ -512,6 +570,63 @@
|
|||||||
// Note: Success message is already shown in the dialog, no need to show another here
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@namespace WebApp.Components.Shared.Components
|
||||||
|
|
||||||
|
<MudPaper Elevation="0" Class="pa-1" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
<MudTooltip Text="@AddTooltip">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Add"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="HandleAdd"
|
||||||
|
Size="Size.Small" />
|
||||||
|
</MudTooltip>
|
||||||
|
<MudText Typo="Typo.body2" Style="flex: 1; text-align: center;">@Label</MudText>
|
||||||
|
<MudTooltip Text="@RemoveTooltip">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Remove"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Color="Color.Error"
|
||||||
|
OnClick="HandleRemove"
|
||||||
|
Size="Size.Small" />
|
||||||
|
</MudTooltip>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user