feat: add optional student nickname for informal display

Keep legal first and last names for formal lists; show DisplayFirstName on teams, calendars, and import matching so two Josiahs can be told apart.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-12 00:06:33 -04:00
co-authored by Cursor
parent c03ffc0833
commit 29101e2ead
35 changed files with 516 additions and 41 deletions
+8
View File
@@ -12,6 +12,7 @@ public class StudentBuilder
private int _id = _idCounter++;
private string _firstName = "Test";
private string _lastName = "Student";
private string? _nickname = null;
private int _grade = 9;
private string? _email = null;
private string? _phoneNumber = null;
@@ -41,6 +42,12 @@ public class StudentBuilder
return this;
}
public StudentBuilder WithNickname(string? nickname)
{
_nickname = nickname;
return this;
}
public StudentBuilder WithGrade(int grade)
{
_grade = grade;
@@ -126,6 +133,7 @@ public class StudentBuilder
Id = _id,
FirstName = _firstName,
LastName = _lastName,
Nickname = _nickname,
Grade = _grade,
Email = _email,
PhoneNumber = _phoneNumber,
+57
View File
@@ -0,0 +1,57 @@
using Tests.Builders;
namespace Tests.Entities;
[TestFixture]
public class Student_Tests
{
[Test]
public void DisplayFirstName_FallsBackToFirstName()
{
var student = StudentBuilder.Create("Josiah", "Brown").Build();
Assert.That(student.DisplayFirstName, Is.EqualTo("Josiah"));
Assert.That(student.ToString(), Is.EqualTo("Josiah"));
}
[Test]
public void DisplayFirstName_UsesTrimmedNickname()
{
var student = StudentBuilder.Create("Josiah", "Brown").WithNickname(" Jo ").Build();
Assert.That(student.DisplayFirstName, Is.EqualTo("Jo"));
Assert.That(student.ToString(), Is.EqualTo("Jo"));
}
[Test]
public void LegalNameProperties_IgnoreNickname()
{
var student = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
Assert.That(student.FirstName, Is.EqualTo("Josiah"));
Assert.That(student.LastName, Is.EqualTo("Brown"));
Assert.That(student.Name, Is.EqualTo("Josiah Brown"));
Assert.That(student.FirstNameLastName, Is.EqualTo("Josiah Brown"));
Assert.That(student.LastNameFirstName, Is.EqualTo("Brown, Josiah"));
}
[Test]
public void DisplayFirstName_TreatsWhitespaceAsUnset()
{
var student = StudentBuilder.Create("Josiah", "Brown").WithNickname(" ").Build();
Assert.That(student.DisplayFirstName, Is.EqualTo("Josiah"));
}
[Test]
public void NormalizeNickname_ClearsBlankAndTrims()
{
var blank = StudentBuilder.Create("Josiah", "Brown").WithNickname(" ").Build();
blank.NormalizeNickname();
Assert.That(blank.Nickname, Is.Null);
var nick = StudentBuilder.Create("Josiah", "Brown").WithNickname(" Jo ").Build();
nick.NormalizeNickname();
Assert.That(nick.Nickname, Is.EqualTo("Jo"));
}
}
@@ -1,3 +1,9 @@
using System.Text;
using Core.Entities;
using Core.Models;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers;
public class AssignmentRequirement_Tests
@@ -16,4 +22,53 @@ public class AssignmentRequirement_Tests
Assert.Pass();
}
[Test]
public void Parse_MatchesNicknameColumnsForTwoJosiahs()
{
StudentBuilder.ResetIdCounter();
var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
var josiahB = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build();
var coding = EventDefinitionBuilder.Individual("Coding").WithShortName("Coding").Build();
const string csv = """
,Jo,josiah b
Coding,i,x
""";
var requirements = ParseCsv(csv, [coding], [jo, josiahB]);
Assert.That(requirements, Has.Length.EqualTo(2));
Assert.That(requirements.Single(r => r.Student.Id == jo.Id).Requirement, Is.EqualTo(Requirement.Include));
Assert.That(requirements.Single(r => r.Student.Id == josiahB.Id).Requirement, Is.EqualTo(Requirement.Exclude));
}
[Test]
public void Parse_SharedLegalFirstName_TakesFirstStudent()
{
StudentBuilder.ResetIdCounter();
var first = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
var second = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build();
var coding = EventDefinitionBuilder.Individual("Coding").WithShortName("Coding").Build();
const string csv = """
,Josiah
Coding,i
""";
var requirements = ParseCsv(csv, [coding], [first, second]);
Assert.That(requirements, Has.Length.EqualTo(1));
Assert.That(requirements[0].Student.Id, Is.EqualTo(first.Id));
}
private static AssignmentRequirement[] ParseCsv(
string csv,
ICollection<EventDefinition> events,
ICollection<Student> students)
{
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
using var parser = new AssignmentRequirementParser(reader);
return parser.Parse(events, students);
}
}
@@ -0,0 +1,41 @@
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers;
[TestFixture]
public class FuzzyStudentMatcher_Tests
{
[SetUp]
public void SetUp() => StudentBuilder.ResetIdCounter();
[Test]
public void Find_MatchesNicknameAmongTwoJosiahs()
{
var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
var josiahB = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build();
var match = FuzzyStudentMatcher.Find([jo, josiahB], "Jo");
Assert.That(match, Is.Not.Null);
Assert.That(match!.Value.Student.Id, Is.EqualTo(jo.Id));
}
[Test]
public void Score_LegalNameStillMatchesWhenNicknameSet()
{
var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
Assert.That(FuzzyStudentMatcher.Score(jo, "Josiah Brown"), Is.GreaterThanOrEqualTo(FuzzyStudentMatcher.MatchThreshold));
Assert.That(FuzzyStudentMatcher.Score(jo, "Brown, Josiah"), Is.GreaterThanOrEqualTo(FuzzyStudentMatcher.MatchThreshold));
}
[Test]
public void Score_NullNicknameDoesNotThrow()
{
var student = StudentBuilder.Create("Aria", "Cole").Build();
Assert.That(FuzzyStudentMatcher.Score(student, "Aria Cole"), Is.GreaterThanOrEqualTo(FuzzyStudentMatcher.MatchThreshold));
Assert.That(FuzzyStudentMatcher.Find([student], "Nobody"), Is.Null);
}
}
@@ -68,6 +68,21 @@ public class StudentEventRankingParser_Tests
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria));
}
[Test]
public void Parse_NicknamePlusLastName_MatchesStudent()
{
var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
var result = ParseCsv("""
Student Name,1
Jo Brown,Coding
""", [jo], _events);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(jo));
}
[Test]
public void Parse_ShortName_MatchesEvent()
{
@@ -34,6 +34,22 @@ public class StudentNotesFieldParser_Tests
Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here"));
}
[Test]
public void Parse_MatchesNicknamePlusLastName()
{
var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
const string csv = """
Student Name,Interview Time
Jo Brown,3:20-3:35
""";
var result = Parse(csv, [jo]);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Student, Is.SameAs(jo));
}
[Test]
public void Parse_SameExistingNote_HasNoChanges()
{
+26
View File
@@ -1,3 +1,7 @@
using System.Text;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers;
public class TeamParser_Tests
@@ -37,4 +41,26 @@ public class TeamParser_Tests
}
}
[Test]
public void Parse_MatchesNicknameAndLegalStudentColumns()
{
StudentBuilder.ResetIdCounter();
var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
var josiahB = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build();
var coding = EventDefinitionBuilder.Team("Coding", 2, 2).Build();
const string csv = """
Team Name,Event Name,Regional Time Slot,Student 1,Student 2
A,Coding,,Jo,Josiah Green
""";
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
using var parser = new TeamParser(reader);
var teams = parser.Parse([coding], [jo, josiahB]);
Assert.That(teams, Has.Length.EqualTo(1));
Assert.That(teams[0].Students.Select(s => s.Id), Is.EquivalentTo(new[] { jo.Id, josiahB.Id }));
Assert.That(teams[0].Captain!.Id, Is.EqualTo(jo.Id));
}
}
@@ -17,6 +17,22 @@ public class NoteTemplateMerger_Tests
Assert.That(result, Is.EqualTo("Hello Aria Cole"));
}
[Test]
public void Merge_FirstNameStaysLegalWhenDisplayFirstNameIsNickname()
{
var tokens = PrintTokenMap.Create();
tokens["FirstName"] = "Josiah";
tokens["Nickname"] = "Jo";
tokens["DisplayFirstName"] = "Jo";
tokens["LastName"] = "Brown";
var result = NoteTemplateMerger.Merge(
"{{FirstName}} {{LastName}} / {{DisplayFirstName}} ({{Nickname}})",
tokens);
Assert.That(result, Is.EqualTo("Josiah Brown / Jo (Jo)"));
}
[Test]
public void Merge_SupportsDottedAndSpacedNames()
{
@@ -0,0 +1,88 @@
using Core.Utility;
using Tests.Builders;
namespace Tests.Utility;
[TestFixture]
public class StudentNameFormatter_Tests
{
[Test]
public void FormatStudentName_UsesDisplayFirstNameAndSuffixes()
{
var student = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
var formatted = StudentNameFormatter.FormatStudentName(student, new StudentNameFormatter.FormatOptions
{
HasOverlap = true,
IsAbsent = true
});
Assert.That(formatted, Is.EqualTo("Jo* (absent)"));
}
}
[TestFixture]
public class TeamStudentNameFormatter_Tests
{
[Test]
public void FormatStudentName_KeepsCaptainAndOverlapMarkersOnNickname()
{
var captain = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
var teammate = StudentBuilder.Create("Aria", "Cole").Build();
var evt = EventDefinitionBuilder.Team("Flight Endurance", 2, 2).Build();
var team = TeamBuilder.Create(evt)
.WithStudent(captain, isCaptain: true)
.WithStudent(teammate)
.Build();
var formatted = TeamStudentNameFormatter.FormatStudentName(
captain,
team,
new TeamStudentNameFormatter.FormatOptions
{
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain,
MarkOverlaps = true,
HasOverlaps = _ => true
});
Assert.That(formatted, Is.EqualTo("Jo(Cpt)*"));
}
[Test]
public void FormatStudentList_AlphabeticalUsesDisplayFirstName()
{
var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build();
var josiahB = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build();
var aria = StudentBuilder.Create("Aria", "Cole").Build();
var evt = EventDefinitionBuilder.Team("Flight Endurance", 3, 3).Build();
var team = TeamBuilder.Create(evt)
.WithStudent(josiahB)
.WithStudent(aria)
.WithStudent(jo, isCaptain: true)
.Build();
var list = TeamStudentNameFormatter.FormatStudentList(
team,
new TeamStudentNameFormatter.FormatOptions
{
Ordering = TeamStudentNameFormatter.OrderingStyle.Alphabetical
});
Assert.That(list, Is.EqualTo("Aria, Jo, Josiah B"));
}
[Test]
public void FormatStudentName_FallsBackToFirstName()
{
var student = StudentBuilder.Create("Josiah", "Green").Build();
var evt = EventDefinitionBuilder.Team("Flight Endurance", 2, 2).Build();
var team = TeamBuilder.Create(evt).WithStudent(student).Build();
var formatted = TeamStudentNameFormatter.FormatStudentName(
student,
team,
new TeamStudentNameFormatter.FormatOptions());
Assert.That(formatted, Is.EqualTo("Josiah"));
}
}
@@ -196,6 +196,32 @@ public class YearTransitionPlanner_Tests
Assert.That(result.UnmatchedNames, Is.EquivalentTo(new[] { "Nobody Here" }));
}
[Test]
public void MatchPastedNames_MatchesNicknamePlusLastName()
{
var josiah = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").WithGrade(7).Build();
var result = YearTransitionPlanner.MatchPastedNames(
[josiah],
["Jo Brown", "Brown, Jo"]);
Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { josiah.Id }));
Assert.That(result.UnmatchedNames, Is.Empty);
}
[Test]
public void MatchPastedNames_LegalNameStillMatchesWhenNicknameSet()
{
var josiah = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").WithGrade(7).Build();
var result = YearTransitionPlanner.MatchPastedNames(
[josiah],
["Josiah Brown", "Brown, Josiah"]);
Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { josiah.Id }));
Assert.That(result.UnmatchedNames, Is.Empty);
}
[Test]
public void MatchPastedNames_AmbiguousWhenDuplicateNames()
{