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:
@@ -17,6 +17,10 @@ public class Student : IEquatable<Student>
|
||||
[Display(Name = "Last Name")]
|
||||
public string LastName { get; set; } = null!;
|
||||
|
||||
[StringLength(50)]
|
||||
[Display(Name = "Nickname")]
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
[Range(5,12)]
|
||||
[Display(Name = "Grade")]
|
||||
public int Grade { get; set; }
|
||||
@@ -55,6 +59,17 @@ public class Student : IEquatable<Student>
|
||||
|
||||
public string FirstNameLastName => $"{FirstName} {LastName}";
|
||||
|
||||
/// <summary>
|
||||
/// Nickname when set, otherwise <see cref="FirstName"/>.
|
||||
/// </summary>
|
||||
public string DisplayFirstName =>
|
||||
string.IsNullOrWhiteSpace(Nickname) ? FirstName : Nickname.Trim();
|
||||
|
||||
public void NormalizeNickname()
|
||||
{
|
||||
Nickname = string.IsNullOrWhiteSpace(Nickname) ? null : Nickname.Trim();
|
||||
}
|
||||
|
||||
public static Tuple<string, string> ParseNameParts(string fullName)
|
||||
{
|
||||
var match = Match(fullName, @"(.*),\s*(.*)");
|
||||
@@ -67,10 +82,7 @@ public class Student : IEquatable<Student>
|
||||
: new Tuple<string, string>(fullName, string.Empty);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return FirstName;
|
||||
}
|
||||
public override string ToString() => DisplayFirstName;
|
||||
|
||||
public bool VotingDelegate => OfficerRole is Entities.OfficerRole.President or Entities.OfficerRole.VicePresident;
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ public class AssignmentRequirementParser : CsvParserBase
|
||||
{
|
||||
}
|
||||
|
||||
public AssignmentRequirementParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
|
||||
{
|
||||
}
|
||||
|
||||
public AssignmentRequirement[] Parse(ICollection<EventDefinition> events, ICollection<Student> students)
|
||||
{
|
||||
var assumptions = new List<AssignmentRequirement>();
|
||||
@@ -20,7 +24,11 @@ public class AssignmentRequirementParser : CsvParserBase
|
||||
|
||||
var studentArray =
|
||||
studentColumns
|
||||
.Select(c => students.FirstOrDefault(s => s.FirstName == c)).ToArray();
|
||||
.Select(c => students.FirstOrDefault(s =>
|
||||
string.Equals(s.DisplayFirstName, c, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(s.Nickname, c, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(s.FirstName, c, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToArray();
|
||||
|
||||
while (CsvReader.Read())
|
||||
{
|
||||
|
||||
@@ -23,7 +23,14 @@ public static class FuzzyStudentMatcher
|
||||
|
||||
public static int Score(Student student, string name)
|
||||
{
|
||||
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
|
||||
var candidates = new[]
|
||||
{
|
||||
student.Name,
|
||||
student.FirstNameLastName,
|
||||
student.LastNameFirstName,
|
||||
student.DisplayFirstName,
|
||||
student.Nickname
|
||||
}.Where(c => !string.IsNullOrWhiteSpace(c));
|
||||
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ namespace Core.Parsers
|
||||
{
|
||||
}
|
||||
|
||||
public TeamParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
|
||||
{
|
||||
}
|
||||
|
||||
public Team[] Parse(ICollection<EventDefinition> events, ICollection<Student> students)
|
||||
{
|
||||
var teams = new List<Team>();
|
||||
@@ -118,7 +122,9 @@ namespace Core.Parsers
|
||||
{
|
||||
Fuzz.Ratio(s.Name, studentName),
|
||||
Fuzz.Ratio(s.FirstNameLastName, studentName),
|
||||
Fuzz.Ratio(s.FirstName, studentName)
|
||||
Fuzz.Ratio(s.FirstName, studentName),
|
||||
Fuzz.Ratio(s.DisplayFirstName, studentName),
|
||||
string.IsNullOrWhiteSpace(s.Nickname) ? 0 : Fuzz.Ratio(s.Nickname, studentName)
|
||||
}.Max()
|
||||
where rat > 90
|
||||
orderby rat descending
|
||||
|
||||
@@ -34,7 +34,7 @@ public static class StudentNameFormatter
|
||||
if (student == null)
|
||||
return string.Empty;
|
||||
|
||||
var name = student.FirstName;
|
||||
var name = student.DisplayFirstName;
|
||||
|
||||
// Add overlap marker
|
||||
if (options.HasOverlap)
|
||||
|
||||
@@ -107,7 +107,7 @@ public static class TeamStudentNameFormatter
|
||||
if (student == null)
|
||||
return string.Empty;
|
||||
|
||||
var name = student.FirstName;
|
||||
var name = student.DisplayFirstName;
|
||||
|
||||
// Add captain indicator (before overlap/absent markers)
|
||||
if (options.CaptainIndicator != CaptainIndicatorStyle.None && team != null && team.Captain != null && team.Captain.Equals(student))
|
||||
@@ -137,7 +137,7 @@ public static class TeamStudentNameFormatter
|
||||
|
||||
// Get the suffix from StudentNameFormatter (overlap/absent markers)
|
||||
var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions);
|
||||
var suffix = baseFormatted.Substring(student.FirstName.Length);
|
||||
var suffix = baseFormatted.Substring(student.DisplayFirstName.Length);
|
||||
|
||||
return name + suffix;
|
||||
}
|
||||
@@ -214,10 +214,10 @@ public static class TeamStudentNameFormatter
|
||||
{
|
||||
OrderingStyle.CaptainFirst => studentsWithCaptainInfo
|
||||
.OrderBy(x => !x.IsCaptain)
|
||||
.ThenBy(x => x.Student.FirstName)
|
||||
.ThenBy(x => x.Student.DisplayFirstName)
|
||||
.Select(x => x.Student),
|
||||
OrderingStyle.Alphabetical => studentsWithCaptainInfo
|
||||
.OrderBy(x => x.Student.FirstName)
|
||||
.OrderBy(x => x.Student.DisplayFirstName)
|
||||
.Select(x => x.Student),
|
||||
OrderingStyle.GradeDescending => studentsWithCaptainInfo
|
||||
.OrderByDescending(x => x.Student.Grade + x.Student.TsaYear)
|
||||
@@ -252,8 +252,8 @@ public static class TeamStudentNameFormatter
|
||||
{
|
||||
OrderingStyle.CaptainFirst => students
|
||||
.OrderBy(s => team.Captain == null || !team.Captain.Equals(s))
|
||||
.ThenBy(s => s.FirstName),
|
||||
OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName),
|
||||
.ThenBy(s => s.DisplayFirstName),
|
||||
OrderingStyle.Alphabetical => students.OrderBy(s => s.DisplayFirstName),
|
||||
OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear),
|
||||
_ => students
|
||||
};
|
||||
|
||||
@@ -242,7 +242,9 @@ public static class YearTransitionPlanner
|
||||
// Exact full-name matches first
|
||||
var fullMatches = students.Where(s =>
|
||||
comparer.Equals(s.FirstNameLastName, line) ||
|
||||
comparer.Equals(s.LastNameFirstName, line)).ToList();
|
||||
comparer.Equals(s.LastNameFirstName, line) ||
|
||||
comparer.Equals($"{s.DisplayFirstName} {s.LastName}", line) ||
|
||||
comparer.Equals($"{s.LastName}, {s.DisplayFirstName}", line)).ToList();
|
||||
if (fullMatches.Count > 0)
|
||||
return fullMatches;
|
||||
|
||||
@@ -251,8 +253,10 @@ public static class YearTransitionPlanner
|
||||
return [];
|
||||
|
||||
return students.Where(s =>
|
||||
comparer.Equals(s.FirstName.Trim(), first) &&
|
||||
comparer.Equals(s.LastName.Trim(), last));
|
||||
comparer.Equals(s.LastName.Trim(), last) &&
|
||||
(comparer.Equals(s.FirstName.Trim(), first)
|
||||
|| comparer.Equals(s.DisplayFirstName, first)
|
||||
|| comparer.Equals(s.Nickname?.Trim(), first)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -24,6 +24,9 @@ namespace Data.Configurations
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(s => s.Nickname)
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(s => s.Email)
|
||||
.HasMaxLength(255);
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260911190000_AddStudentNickname")]
|
||||
public partial class AddStudentNickname : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Nickname",
|
||||
table: "Students",
|
||||
type: "TEXT",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Nickname",
|
||||
table: "Students");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,6 +322,10 @@ namespace Data.Migrations
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NationalId")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -222,7 +222,7 @@ else
|
||||
.ThenInclude(t => t!.Captain)
|
||||
.Include(s => s.Teams)
|
||||
.ThenInclude(t => t!.Students)
|
||||
.OrderBy(s => s.FirstName)
|
||||
.OrderBy(s => s.Nickname ?? s.FirstName)
|
||||
.ThenBy(s => s.LastName)
|
||||
.ToArrayAsync();
|
||||
}
|
||||
@@ -391,15 +391,15 @@ else
|
||||
|
||||
if (isIndividual)
|
||||
{
|
||||
var ordered = students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
|
||||
var ordered = students.OrderBy(s => s.DisplayFirstName, StringComparer.OrdinalIgnoreCase);
|
||||
return string.Join(", ", ordered.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
|
||||
}
|
||||
|
||||
var cap = team.Captain;
|
||||
var capInRoster = cap != null && students.Exists(s => s.Id == cap.Id);
|
||||
IEnumerable<Student> orderedTeam = capInRoster
|
||||
? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!)
|
||||
: students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
|
||||
? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.DisplayFirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!)
|
||||
: students.OrderBy(s => s.DisplayFirstName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
|
||||
}
|
||||
@@ -410,12 +410,12 @@ else
|
||||
{
|
||||
var sid = student.StateId?.Trim();
|
||||
return !string.IsNullOrEmpty(sid)
|
||||
? $"{student.FirstName} ({sid})"
|
||||
: student.FirstName;
|
||||
? $"{student.DisplayFirstName} ({sid})"
|
||||
: student.DisplayFirstName;
|
||||
}
|
||||
|
||||
var isCpt = team.Captain?.Id == student.Id;
|
||||
return isCpt ? $"{student.FirstName} (Cpt.)" : student.FirstName;
|
||||
return isCpt ? $"{student.DisplayFirstName} (Cpt.)" : student.DisplayFirstName;
|
||||
}
|
||||
|
||||
private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity);
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||
@{
|
||||
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
|
||||
var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName);
|
||||
var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.DisplayFirstName);
|
||||
}
|
||||
@foreach (var student in allStudents)
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
Label="@Label"
|
||||
@bind-Value="_currentStudent"
|
||||
SearchFunc="@SearchStudents"
|
||||
ToStringFunc="@(s => ShowFullName ? s?.FirstNameLastName : s?.FirstName)"
|
||||
ToStringFunc="@(s => ShowFullName ? s?.FirstNameLastName : s?.DisplayFirstName)"
|
||||
Immediate="true"
|
||||
ResetValueOnEmptyText="true"
|
||||
CoerceText="false"
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@student.FirstName
|
||||
@student.DisplayFirstName
|
||||
}
|
||||
@if (ShowGrade)
|
||||
{
|
||||
@@ -33,11 +33,11 @@
|
||||
@if (SelectedStudents.Any())
|
||||
{
|
||||
<MudChipSet T="Student" AllClosable="true" Class="mt-2">
|
||||
@foreach (var student in SelectedStudents.OrderBy(s => s.FirstName))
|
||||
@foreach (var student in SelectedStudents.OrderBy(s => s.DisplayFirstName))
|
||||
{
|
||||
<MudChip T="Student"
|
||||
Value="@student"
|
||||
Text="@(ShowFullName ? student.FirstNameLastName : student.FirstName)"
|
||||
Text="@(ShowFullName ? student.FirstNameLastName : student.DisplayFirstName)"
|
||||
OnClose="@(() => RemoveStudent(student))" />
|
||||
}
|
||||
</MudChipSet>
|
||||
@@ -91,8 +91,9 @@
|
||||
return Task.FromResult<IEnumerable<Student>>(Students
|
||||
.Where(s => !SelectedStudents.Contains(s))
|
||||
.Where(s => s.FirstName.ToLower().Contains(search) ||
|
||||
s.LastName.ToLower().Contains(search))
|
||||
.OrderBy(s => s.FirstName));
|
||||
s.LastName.ToLower().Contains(search) ||
|
||||
(s.Nickname != null && s.Nickname.ToLower().Contains(search)))
|
||||
.OrderBy(s => s.DisplayFirstName));
|
||||
}
|
||||
|
||||
private void RemoveStudent(Student student)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
ValuesChanged="@OnSelectedStudentsChanged"
|
||||
Vertical="true"
|
||||
CheckMark>
|
||||
@foreach (var student in Students.OrderBy(e => e.FirstName))
|
||||
@foreach (var student in Students.OrderBy(e => e.DisplayFirstName))
|
||||
{
|
||||
<MudToggleItem Value="@student" Style="font-size: .75rem;">
|
||||
@if (ShowFullName)
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@student.FirstName
|
||||
@student.DisplayFirstName
|
||||
}
|
||||
</MudToggleItem>
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField T="string" Label="Last Name" @bind-Value="Student.LastName" For="@(() => Student.LastName)" Variant="Variant.Outlined"></MudTextField>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField T="string" Label="Nickname" @bind-Value="Student.Nickname" For="@(() => Student.Nickname)" Variant="Variant.Outlined" HelperText="Shown instead of first name on teams, rankings, and printouts"></MudTextField>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField T="string" Label="Email Address" @bind-Value="Student.Email" For="@(() => Student.Email)" Variant="Variant.Outlined"></MudTextField>
|
||||
</MudItem>
|
||||
@@ -80,6 +83,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
Student.NormalizeNickname();
|
||||
Context.Students.Add(Student);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Last Name</MudText>
|
||||
<MudText Typo="Typo.body1">@student.LastName</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Nickname</MudText>
|
||||
<MudText Typo="Typo.body1">@(string.IsNullOrWhiteSpace(student.Nickname) ? "—" : student.Nickname)</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Grade</MudText>
|
||||
<MudText Typo="Typo.body1">@student.Grade</MudText>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||
<MudTextField T="string" Label="First Name" @bind-Value="Student.FirstName" For="@(() => Student.FirstName)"></MudTextField>
|
||||
<MudTextField T="string" Label="Last Name" @bind-Value="Student.LastName" For="@(() => Student.LastName)"></MudTextField>
|
||||
<MudTextField T="string" Label="Nickname" @bind-Value="Student.Nickname" For="@(() => Student.Nickname)" HelperText="Shown instead of first name on teams, rankings, and printouts"></MudTextField>
|
||||
<MudTextField T="string" Label="Email Adress" @bind-Value="Student.Email" For="@(() => Student.Email)"></MudTextField>
|
||||
<MudTextField T="string" Label="Phone Number" @bind-Value="Student.PhoneNumber" For="@(() => Student.PhoneNumber)"></MudTextField>
|
||||
<MudTextField T="int" Label="Grade" @bind-Value="Student.Grade" For="@(() => Student.Grade)"></MudTextField>
|
||||
@@ -121,7 +122,8 @@
|
||||
if (Student?.OfficerRole == 0)
|
||||
Student.OfficerRole = null;
|
||||
|
||||
Context.Attach(Student!).State = EntityState.Modified;
|
||||
Student!.NormalizeNickname();
|
||||
Context.Attach(Student).State = EntityState.Modified;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
Color="Color.Primary">
|
||||
@context.Item.LastNameFirstName
|
||||
</MudLink>
|
||||
@if (!string.IsNullOrWhiteSpace(context.Item.Nickname))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary">@context.Item.Nickname</MudText>
|
||||
}
|
||||
@if (context.Item.OfficerRole != null)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Icon="@(AppIcons.OfficerRoleIcon(context.Item.OfficerRole.Value))">@context.Item.OfficerRole</MudChip>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Value="@SelectedCaptain"
|
||||
ValueChanged="@OnSelectedCaptainChanged"
|
||||
CheckMark>
|
||||
@foreach (var student in Students.OrderBy(e => e.FirstName))
|
||||
@foreach (var student in Students.OrderBy(e => e.DisplayFirstName))
|
||||
{
|
||||
<MudToggleItem Value="@student" Text="@student.Name" />
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
{
|
||||
// For individual events, use student's first name as identifier
|
||||
var student = _selectedStudents.First();
|
||||
Team.Identifier = student.FirstName;
|
||||
Team.Identifier = student.DisplayFirstName;
|
||||
Team.Captain = student;
|
||||
}
|
||||
else if (existingTeamCount == 1)
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
{
|
||||
case EventFormat.Individual when Team.Students.Count == 1:
|
||||
Team.Captain ??= Team.Students[0];
|
||||
Team.Identifier ??= Team.Captain.FirstName;
|
||||
Team.Identifier ??= Team.Captain.DisplayFirstName;
|
||||
break;
|
||||
case EventFormat.Team:
|
||||
break;
|
||||
@@ -134,7 +134,7 @@
|
||||
if (Team is { Event.EventFormat: EventFormat.Individual, Students.Count: 1 })
|
||||
{
|
||||
Team.Captain ??= Team.Students[0];
|
||||
Team.Identifier = Team.Captain.FirstName;
|
||||
Team.Identifier = Team.Captain.DisplayFirstName;
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -72,7 +72,7 @@ else
|
||||
.ThenInclude(e => e.Event)
|
||||
.Include(e => e.Teams)
|
||||
.ThenInclude(e => e.Students)
|
||||
.OrderBy(e => e.FirstName)
|
||||
.OrderBy(e => e.Nickname ?? e.FirstName)
|
||||
.ToArrayAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,6 @@ else
|
||||
.ThenInclude(e => e.Captain)
|
||||
.Include(e => e.EventRankings)
|
||||
.ThenInclude(e => e.EventDefinition)
|
||||
.OrderBy(e => e.FirstName).ToArrayAsync();
|
||||
.OrderBy(e => e.Nickname ?? e.FirstName).ToArrayAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public class MeetingScheduleDataService : IMeetingScheduleDataService
|
||||
.ThenInclude(t => t.Captain)
|
||||
.Include(e => e.EventRankings)
|
||||
.ThenInclude(e => e.EventDefinition)
|
||||
.OrderBy(e => e.FirstName)
|
||||
.OrderBy(e => e.Nickname ?? e.FirstName)
|
||||
.ToArrayAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Student nicknames
|
||||
|
||||
**Created:** 2026-09-11
|
||||
**Last updated:** 2026-09-11
|
||||
**Description:** Optional nickname used as the display first name on informal surfaces.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **First Name** and **Last Name** stay the official name (`Name`, `FirstNameLastName`, `LastNameFirstName`).
|
||||
- Optional **Nickname** on `Student`. Blank or whitespace is stored as null.
|
||||
- `DisplayFirstName` is the trimmed nickname when set, otherwise first name.
|
||||
- Informal UI (teams, event rankings, calendars, meeting lists, rank badges, team member print tokens) uses `DisplayFirstName`.
|
||||
- `{{FirstName}}` stays legal. New tokens: `{{Nickname}}` (empty when unset) and `{{DisplayFirstName}}`.
|
||||
|
||||
## Data
|
||||
|
||||
Migration `AddStudentNickname` adds nullable `Students.Nickname` (max 100). Applied on next app start via `MigrateAsync`.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests stay in the Core `Tests` project (no Blazor/bUnit). They lock the two-Josiahs contract:
|
||||
|
||||
| Layer | What we assert |
|
||||
| --- | --- |
|
||||
| Entity | `DisplayFirstName` / `ToString` / `NormalizeNickname`; legal `Name` / `FirstNameLastName` / `LastNameFirstName` ignore nickname |
|
||||
| Display | Formatters and alphabetical team lists use nickname; suffix markers still attach; no nickname falls back to first name |
|
||||
| Matching | Fuzzy matcher, year-rollover paste, assignment CSV columns, team import, notes import, and ranking import distinguish `Jo` vs `Josiah B` |
|
||||
| Collision | Assignment header `Josiah` still binds the first Josiah (requirement files must use display names) |
|
||||
| Print | Catalog includes `Nickname` and `DisplayFirstName`; merge keeps `{{FirstName}}` legal |
|
||||
|
||||
Not covered by unit tests: Blazor create/edit/index captions, `NotePrintService` token map (same keys as the catalog/merger contract), and rewriting stored team identifiers.
|
||||
|
||||
## Follow-up
|
||||
|
||||
After setting nicknames on students who share a first name, rename any team identifiers that still collide (identifiers are stored, not recomputed).
|
||||
Reference in New Issue
Block a user