feat: import leftover student fields into notes and show them on the roster

Store leftover CSV columns on hidden student notes, move catalog import to /events/import, and persist Students index columns from Chapter Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 23:55:03 -04:00
co-authored by Cursor
parent 4c91db37c2
commit 4cfd85b902
39 changed files with 2437 additions and 166 deletions
+179
View File
@@ -0,0 +1,179 @@
using Core.Notes;
namespace Tests.Notes;
[TestFixture]
public class ImportedFieldsTable_Tests
{
[Test]
public void NormalizeValue_X_BecomesYes()
{
Assert.That(ImportedFieldsTable.NormalizeValue("x"), Is.EqualTo("Yes"));
Assert.That(ImportedFieldsTable.NormalizeValue("X"), Is.EqualTo("Yes"));
Assert.That(ImportedFieldsTable.NormalizeValue(" x "), Is.EqualTo("Yes"));
}
[Test]
public void NormalizeValue_Blank_StaysEmpty()
{
Assert.That(ImportedFieldsTable.NormalizeValue(null), Is.EqualTo(string.Empty));
Assert.That(ImportedFieldsTable.NormalizeValue(" "), Is.EqualTo(string.Empty));
}
[Test]
public void Merge_IncomingWins_AndAppendsNewFields()
{
var existing = """
Freeform note
## Imported fields
| Field | Value |
| --- | --- |
| Interview Time | 1:00-1:15 |
| Application | Yes |
""";
var result = ImportedFieldsTable.Merge(existing,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Teacher Rec 1", "Fuqua")
]);
Assert.That(result.Changed, Is.True);
Assert.That(result.Changes, Has.Count.EqualTo(2));
var fields = ImportedFieldsTable.ParseFields(result.Markdown);
Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
Assert.That(result.Markdown, Does.Contain("Freeform note"));
}
[Test]
public void Merge_SameDataTwice_IsNoOp()
{
var first = ImportedFieldsTable.Merge(null,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Application", "x")
]);
Assert.That(first.Changed, Is.True);
Assert.That(ImportedFieldsTable.ParseFields(first.Markdown).Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
var second = ImportedFieldsTable.Merge(first.Markdown,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Application", "x")
]);
Assert.That(second.Changed, Is.False);
Assert.That(second.Changes, Is.Empty);
Assert.That(ImportedFieldsTable.ParseFields(second.Markdown), Is.EqualTo(ImportedFieldsTable.ParseFields(first.Markdown)));
}
[Test]
public void GetFieldValue_ReadsImportedTable()
{
var markdown = ImportedFieldsTable.FormatSection(
[
new ImportedField("Interview Time", "3:20-3:35")
]);
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Interview Time"), Is.EqualTo("3:20-3:35"));
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Application"), Is.Null);
}
[Test]
public void Merge_PreservesTextAfterSection()
{
var existing = """
## Imported fields
| Field | Value |
| --- | --- |
| Application | Yes |
## Advisor comments
Great interview.
""";
var result = ImportedFieldsTable.Merge(existing, [new ImportedField("Application", "Yes")]);
Assert.That(result.Changed, Is.False);
Assert.That(result.Markdown, Does.Contain("## Advisor comments"));
Assert.That(result.Markdown, Does.Contain("Great interview."));
}
[Test]
public void ParseFields_KeepsBlankValues()
{
var markdown = ImportedFieldsTable.FormatSection(
[
new ImportedField("Application", "Yes"),
new ImportedField("Teacher Rec 3", "")
]);
var fields = ImportedFieldsTable.ParseFields(markdown);
Assert.That(fields.Single(f => f.Name == "Teacher Rec 3").Value, Is.EqualTo(string.Empty));
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Teacher Rec 3"), Is.EqualTo(string.Empty));
}
[Test]
public void Merge_BlankFields_SameDataTwice_IsNoOp()
{
var first = ImportedFieldsTable.Merge(null,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Teacher Rec 3", "")
]);
Assert.That(first.Changed, Is.True);
Assert.That(ImportedFieldsTable.ParseFields(first.Markdown), Has.Count.EqualTo(2));
var second = ImportedFieldsTable.Merge(first.Markdown,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Teacher Rec 3", "")
]);
Assert.That(second.Changed, Is.False);
Assert.That(second.Changes, Is.Empty);
}
[Test]
public void Merge_IncomingBlank_ClearsExistingValue()
{
var existing = ImportedFieldsTable.Merge(null, [new ImportedField("Teacher Rec 1", "Fuqua")]).Markdown;
var result = ImportedFieldsTable.Merge(existing, [new ImportedField("Teacher Rec 1", "")]);
Assert.That(result.Changed, Is.True);
Assert.That(ImportedFieldsTable.GetFieldValue(result.Markdown, "Teacher Rec 1"), Is.EqualTo(string.Empty));
}
[Test]
public void GetFieldValue_IsCaseInsensitive()
{
var markdown = ImportedFieldsTable.FormatSection([new ImportedField("Interview Time", "3:20-3:35")]);
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "interview time"), Is.EqualTo("3:20-3:35"));
}
[Test]
public void DistinctFieldNames_UnionsNotes_AndKeepsFirstCasing()
{
var first = ImportedFieldsTable.FormatSection(
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Application", "Yes")
]);
var second = ImportedFieldsTable.FormatSection(
[
new ImportedField("application", "Yes"),
new ImportedField("Teacher Rec 1", "Fuqua")
]);
var names = ImportedFieldsTable.DistinctFieldNames([first, second, null, ""]);
Assert.That(names, Is.EqualTo(new[] { "Application", "Interview Time", "Teacher Rec 1" }));
}
}
@@ -0,0 +1,30 @@
using Core.Parsers;
namespace Tests.Parsers;
[TestFixture]
public class StudentImportCsvTemplate_Tests
{
[Test]
public void Build_IncludesRosterAndDefaultLeftoverColumns()
{
var csv = StudentImportCsvTemplate.Build();
var header = csv.Split('\n')[0].TrimEnd('\r');
Assert.That(header, Does.StartWith("Student Name,Grade,TSA year,State ID,Regional ID,National ID"));
Assert.That(header, Does.Contain("Interview Time"));
Assert.That(header, Does.Contain("Application"));
Assert.That(csv, Does.Contain("\"Last, First\""));
}
[Test]
public void Build_UsesConfiguredLeftoverFields_AndSkipsReserved()
{
var csv = StudentImportCsvTemplate.Build(["Teacher Rec 1", "Grade", " Application "]);
var header = csv.Split('\n')[0].TrimEnd('\r');
Assert.That(header, Does.Contain("Teacher Rec 1"));
Assert.That(header, Does.Contain("Application"));
Assert.That(header.Split(',').Count(c => c.Equals("Grade", StringComparison.OrdinalIgnoreCase)), Is.EqualTo(1));
}
}
@@ -0,0 +1,196 @@
using System.Text;
using Core.Notes;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers;
[TestFixture]
public class StudentNotesFieldParser_Tests
{
[SetUp]
public void SetUp()
{
BuilderExtensions.ResetAllBuilders();
}
[Test]
public void Parse_MatchesStudentAndReportsUnmatched()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Interview Time,Application
"Chittenden, Aria",3:20-3:35,x
Nobody Here,1:00-1:15,x
""";
var result = Parse(csv, [aria]);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Student, Is.SameAs(aria));
Assert.That(result.Matches[0].Merge.Changed, Is.True);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here"));
}
[Test]
public void Parse_SameExistingNote_HasNoChanges()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
var existing = ImportedFieldsTable.Merge(null,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Application", "Yes")
]).Markdown;
const string csv = """
Student Name,Interview Time,Application
Aria Chittenden,3:20-3:35,x
""";
var result = Parse(csv, [aria], new Dictionary<int, string?> { [aria.Id] = existing });
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Merge.Changed, Is.False);
Assert.That(result.StudentsWithChanges, Is.EqualTo(0));
}
[Test]
public void Parse_MissingStudentNameColumn_IsError()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Name,Interview Time
Aria Chittenden,3:20-3:35
""";
var result = Parse(csv, [aria]);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Errors, Has.Some.Contains("Student Name"));
Assert.That(result.Matches, Is.Empty);
}
[Test]
public void Parse_PreservesFieldsNotInCsv()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
var existing = ImportedFieldsTable.Merge(null,
[
new ImportedField("Interview Time", "1:00-1:15"),
new ImportedField("Teacher Rec 1", "Fuqua")
]).Markdown;
const string csv = """
Student Name,Interview Time
Aria Chittenden,3:20-3:35
""";
var result = Parse(csv, [aria], new Dictionary<int, string?> { [aria.Id] = existing });
var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
Assert.That(result.Matches[0].Merge.Changed, Is.True);
Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
}
[Test]
public void Parse_X_BecomesYes_AndBlankFieldsRoundTrip()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Application,Teacher Rec 3
Aria Chittenden,x,
""";
var first = Parse(csv, [aria]);
var markdown = first.Matches[0].Merge.Markdown;
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Application"), Is.EqualTo("Yes"));
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Teacher Rec 3"), Is.EqualTo(string.Empty));
var second = Parse(csv, [aria], new Dictionary<int, string?> { [aria.Id] = markdown });
Assert.That(second.Matches[0].Merge.Changed, Is.False);
Assert.That(second.StudentsWithChanges, Is.EqualTo(0));
}
[Test]
public void Parse_DuplicateStudentRows_LastValueWins_OneMatch()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Interview Time,Teacher Rec 1
Aria Chittenden,1:00-1:15,Fuqua
"Chittenden, Aria",3:20-3:35,Young
""";
var result = Parse(csv, [aria]);
var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Young"));
}
[Test]
public void Parse_NameOnlyCsv_WarnsAndMatchesWithoutFieldChanges()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name
Aria Chittenden
""";
var result = Parse(csv, [aria]);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Warnings, Has.Some.Contains("No leftover field columns"));
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Merge.Changed, Is.False);
}
[Test]
public void Parse_ExcludesRosterAndRankingColumns()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Grade,TSA year,State ID,Regional ID,National ID,Officer,1,2,TOTAL # OF EVENTS,Interview Time,Application
Aria Chittenden,6,1st,,,,,Coding,JSS,2,3:20-3:35,x
""";
var result = Parse(csv, [aria]);
var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
Assert.That(result.FieldNames, Is.EquivalentTo(["Interview Time", "Application"]));
Assert.That(fields.Select(f => f.Name), Is.EquivalentTo(["Interview Time", "Application"]));
Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
Assert.That(fields.Any(f => f.Name is "Grade" or "1" or "Officer"), Is.False);
}
[Test]
public void Parse_RosterOnlyCsv_HasNoLeftoverFields()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Grade,TSA year,State ID,Regional ID,National ID
Aria Chittenden,6,1st,,,
""";
var result = Parse(csv, [aria]);
Assert.That(result.FieldNames, Is.Empty);
Assert.That(result.Matches[0].Merge.Changed, Is.False);
Assert.That(result.Warnings, Has.Some.Contains("No leftover field columns"));
}
private static Core.Models.StudentNotesImportResult Parse(
string csv,
ICollection<Core.Entities.Student> students,
IReadOnlyDictionary<int, string?>? notes = null)
{
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
using var parser = new StudentNotesFieldParser(reader);
return parser.Parse(students, notes ?? new Dictionary<int, string?>());
}
}
+28
View File
@@ -0,0 +1,28 @@
using Core.Services;
namespace Tests.Services;
[TestFixture]
public class NoteNamingService_Tests
{
private readonly NoteNamingService _service = new();
[Test]
public void GetStudentNoteTitle_UsesStablePrefix()
{
Assert.That(_service.GetStudentNoteTitle(42), Is.EqualTo("#Student:42"));
}
[Test]
public void IsStudentNote_AndParseId()
{
Assert.That(_service.IsStudentNote("#Student:12"), Is.True);
Assert.That(_service.IsStudentNote("#Students"), Is.False);
Assert.That(_service.TryParseStudentNoteId("#Student:12", out var id), Is.True);
Assert.That(id, Is.EqualTo(12));
Assert.That(_service.TryParseStudentNoteId("#Event Ranking", out _), Is.False);
Assert.That(_service.IsStudentNote(null), Is.False);
Assert.That(_service.TryParseStudentNoteId("#Student:", out _), Is.False);
Assert.That(_service.IsPageNote("#Student:12"), Is.True);
}
}
@@ -0,0 +1,64 @@
using Core.Models;
using Core.Notes;
using Core.Services;
using Tests.Builders;
namespace Tests.Services;
[TestFixture]
public class StudentNotesImportPlan_Tests
{
[SetUp]
public void SetUp()
{
BuilderExtensions.ResetAllBuilders();
}
[Test]
public void Create_SkipsUnchanged_CreatesAndUpdatesChanged()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
var blake = StudentBuilder.Create("Blake", "Nguyen").Build();
var casey = StudentBuilder.Create("Casey", "Ortiz").Build();
var firstWrite = ImportedFieldsTable.Merge(null, [new ImportedField("Application", "Yes")]);
var unchanged = ImportedFieldsTable.Merge(firstWrite.Markdown, [new ImportedField("Application", "Yes")]);
var created = ImportedFieldsTable.Merge(null, [new ImportedField("Interview Time", "3:20-3:35")]);
var updated = ImportedFieldsTable.Merge(
ImportedFieldsTable.Merge(null, [new ImportedField("Application", "Yes")]).Markdown,
[new ImportedField("Application", "")]);
var parseResult = new StudentNotesImportResult
{
Matches =
[
Match(aria, unchanged),
Match(blake, created),
Match(casey, updated)
]
};
HashSet<int> existingNoteIds = [casey.Id];
var actions = StudentNotesImportPlan.Create(parseResult, existingNoteIds);
Assert.That(actions, Has.Count.EqualTo(2));
Assert.That(actions.Any(a => a.StudentId == aria.Id), Is.False);
Assert.That(actions.Single(a => a.StudentId == blake.Id).Kind, Is.EqualTo(StudentNotePersistKind.Create));
Assert.That(actions.Single(a => a.StudentId == casey.Id).Kind, Is.EqualTo(StudentNotePersistKind.Update));
Assert.That(actions.Single(a => a.StudentId == casey.Id).Markdown, Is.EqualTo(updated.Markdown));
}
[Test]
public void Create_EmptyMatches_ReturnsNoActions()
{
var actions = StudentNotesImportPlan.Create(new StudentNotesImportResult(), new HashSet<int>());
Assert.That(actions, Is.Empty);
}
private static StudentNotesImportMatch Match(Core.Entities.Student student, ImportedFieldsMergeResult merge) =>
new()
{
Student = student,
Merge = merge
};
}