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:
@@ -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?>());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user