feat: add CSV import for student event rankings

Let advisors load preference ranks from a converted CSV with fuzzy matching, known aliases, and a parse-preview-save page instead of relying on the ranking editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 23:41:13 -04:00
co-authored by Cursor
parent 2acd83a841
commit 4c91db37c2
13 changed files with 1156 additions and 59 deletions
@@ -0,0 +1,260 @@
using System.Text;
using Core.Entities;
using Core.Models;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers;
[TestFixture]
public class StudentEventRankingParser_Tests
{
private Student _aria = null!;
private EventDefinition _videoGame = null!;
private EventDefinition _coding = null!;
private EventDefinition _jss = null!;
private EventDefinition _digitalPhoto = null!;
private EventDefinition _biotech = null!;
private EventDefinition _inventions = null!;
private EventDefinition _techBowl = null!;
private EventDefinition _techDesign = null!;
private List<Student> _students = null!;
private List<EventDefinition> _events = null!;
[SetUp]
public void SetUp()
{
BuilderExtensions.ResetAllBuilders();
_aria = StudentBuilder.Create("Aria", "Chittenden").Build();
_videoGame = EventDefinitionBuilder.Team("Video Game Design", 2, 6).WithShortName("Video Game").Build();
_coding = EventDefinitionBuilder.Team("Coding", 2, 2).WithShortName("Coding").Build();
_jss = EventDefinitionBuilder.Team("Junior Solar Sprint", 2, 4).WithShortName("JSS").Build();
_digitalPhoto = EventDefinitionBuilder.Individual("Digital Photography").WithShortName("Digital Photo").Build();
_biotech = EventDefinitionBuilder.Team("Biotechnology", 2, 6).WithShortName("Biotech").Build();
_inventions = EventDefinitionBuilder.Team("Inventions & Innovations", 3, 6).WithShortName("I&I").Build();
_techBowl = EventDefinitionBuilder.Team("Tech Bowl", 3, 3).WithShortName("Tech Bowl").Build();
_techDesign = EventDefinitionBuilder.Team("Technical Design", 2, 2).WithShortName("Tech Design").Build();
_students = [_aria];
_events = [_videoGame, _coding, _jss, _digitalPhoto, _biotech, _inventions, _techBowl, _techDesign];
}
[Test]
public void Parse_LastCommaFirst_MatchesStudent()
{
var result = ParseCsv("""
Student Name,1
"Chittenden, Aria",Coding
""", _students, _events);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_coding));
Assert.That(result.Matches[0].Ranking.Rank, Is.EqualTo(1));
}
[Test]
public void Parse_FirstLast_MatchesStudent()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Coding
""", _students, _events);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria));
}
[Test]
public void Parse_ShortName_MatchesEvent()
{
var result = ParseCsv("""
Student Name,1,2,3
Aria Chittenden,Video Game,JSS,I&I
""", _students, _events);
Assert.That(result.Issues, Is.Empty);
Assert.That(result.Matches, Has.Count.EqualTo(3));
Assert.That(result.Matches.Select(m => m.Ranking.EventDefinition), Is.EqualTo(new[] { _videoGame, _jss, _inventions }));
}
[Test]
public void Parse_NearMissFullName_MatchesEvent()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Digital Photo
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_digitalPhoto));
Assert.That(result.Matches[0].EventScore, Is.GreaterThanOrEqualTo(StudentEventRankingParser.EventMatchThreshold));
}
[Test]
public void Parse_BiotechShortName_MatchesBiotechnology()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Biotech
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_biotech));
}
[Test]
public void Parse_UnmatchedStudent_IsReported()
{
var result = ParseCsv("""
Student Name,1
Nobody Here,Coding
""", _students, _events);
Assert.That(result.Matches, Is.Empty);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedStudent));
Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here"));
}
[Test]
public void Parse_SolarRacer_MatchesJuniorSolarSprint()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Solar Racer
""", _students, _events);
Assert.That(result.Issues, Is.Empty);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_jss));
}
[TestCase("Solar Racer", "Junior Solar Sprint")]
[TestCase("Solar Race", "Junior Solar Sprint")]
[TestCase("Challenging Tech", "Challenging Technology Issues")]
[TestCase("Digital Photo", "Digital Photography")]
[TestCase("Forensics", "Forensic Technology")]
[TestCase("Micro Controller", "Microcontroller Design")]
[TestCase("Med Tech", "Medical Technology")]
[TestCase("Innovations & Inventions", "Inventions & Innovations")]
[TestCase("Inventions and Innovations", "Inventions & Innovations")]
[TestCase("Systems Control Tech", "System Control Technology")]
[TestCase("Systems Control Technology", "System Control Technology")]
[TestCase("Structural Eng", "Structural Engineering")]
[TestCase("Drone Challenge", "Drone Challenge (UAV)")]
[TestCase("Robotics", "TSA Robotics")]
[TestCase("Audio Podcast", "Audio Podcasting")]
public void Parse_KnownAlias_MatchesOfficialEvent(string alias, string officialName)
{
var events = TestEntityHandler.GetEvents().ToList();
events.AddRange(
[
EventDefinitionBuilder.Team("Drone Challenge (UAV)", 2, 6).WithShortName("Drone").Build(),
EventDefinitionBuilder.Team("TSA Robotics", 2, 6).WithShortName("Robotics").Build(),
EventDefinitionBuilder.Team("Audio Podcasting", 2, 6).WithShortName("Podcasting").Build()
]);
var result = ParseCsv($"""
Student Name,1
Aria Chittenden,{alias}
""", _students, events);
Assert.That(result.Issues, Is.Empty, $"Expected '{alias}' to match '{officialName}'");
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition.Name, Is.EqualTo(officialName));
}
[Test]
public void Parse_UnmatchedEvent_IsReported()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Underwater Basket Weaving
""", _students, _events);
Assert.That(result.Matches, Is.Empty);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedEvent));
Assert.That(result.Issues[0].RawEventName, Is.EqualTo("Underwater Basket Weaving"));
}
[Test]
public void Parse_AmbiguousEvent_IsReported()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Tech
""", _students, _events);
Assert.That(result.Matches, Is.Empty);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.AmbiguousEvent));
}
[Test]
public void Parse_RankColumnsBeyondSix_AreRead()
{
var result = ParseCsv("""
Student Name,1,2,3,4,5,6,7
Aria Chittenden,Coding,,,,,,JSS
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(2));
Assert.That(result.Matches.Select(m => m.Ranking.Rank), Is.EqualTo(new[] { 1, 7 }));
Assert.That(result.Matches[1].Ranking.EventDefinition, Is.SameAs(_jss));
}
[Test]
public void Parse_DuplicateEvent_IsReported()
{
var result = ParseCsv("""
Student Name,1,2
Aria Chittenden,Coding,Coding
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.DuplicateEvent));
}
[Test]
public void Parse_MissingStudentNameHeader_IsError()
{
var result = ParseCsv("""
Name,1
Aria Chittenden,Coding
""", _students, _events);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Errors, Is.Not.Empty);
}
[Test]
public void Parse_2024RankingsFile_MatchesKnownStudentsAndEvents()
{
var events = TestEntityHandler.GetEvents();
var students = TestEntityHandler.GetStudents(events);
var rankings = TestEntityHandler.GetStudentEventRankings(students, events);
Assert.That(students, Has.Length.EqualTo(29));
Assert.That(rankings, Is.Not.Empty);
Assert.That(rankings.Select(r => r.Student.FirstNameLastName).Distinct().Count(), Is.EqualTo(29));
Assert.That(rankings.All(r => r.EventDefinition is not null), Is.True);
Assert.That(rankings.All(r => r.Rank is >= 1 and <= 10), Is.True);
Assert.That(rankings.Count(r => r.Student.FirstName == "First26"), Is.EqualTo(7));
}
private static StudentEventRankingParseResult ParseCsv(
string csv,
ICollection<Student> students,
ICollection<EventDefinition> events)
{
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
using var parser = new StudentEventRankingParser(reader);
return parser.Parse(students, events);
}
}
+3 -3
View File
@@ -69,9 +69,9 @@ public static class TestEntityHandler
public static StudentEventRanking[] GetStudentEventRankings(Student[] students, EventDefinition[] events)
{
var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2025 Student Event Rankings.csv");
var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2024 Student Event Rankings.csv");
var rankingParser = new StudentEventRankingParser(fileInfo);
return rankingParser.Parse(students, events);
using var rankingParser = new StudentEventRankingParser(fileInfo);
return [.. rankingParser.Parse(students, events).Rankings];
}
}