feat: merge event rankings and attribute badges into the page printer

Interview notes can print each student's ranked events and a shared attribute legend from the same catalog as the ranking index, without leftover table markup or collapsed answer space.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 23:03:10 -04:00
co-authored by Cursor
parent 3f50d6e635
commit 3712dba974
19 changed files with 817 additions and 118 deletions
@@ -116,4 +116,53 @@ public class NoteTemplateMerger_Tests
Assert.That(raw, Is.EqualTo($"x{NoteTemplateMerger.AnswerSpaceHtml}y"));
Assert.That(wrapped, Is.EqualTo(NoteTemplateMerger.AnswerSpaceHtml));
}
[Test]
public void Merge_RankedStudents_IsHtmlFragmentSentinel()
{
var result = NoteTemplateMerger.Merge(
"{{Name}}\n{{RankedStudents}}",
PrintTokenMap.Create());
Assert.That(result, Does.Contain(NoteTemplateMerger.HtmlFragmentSentinel(NoteTemplateMerger.RankedStudentsToken)));
Assert.That(result, Does.Not.Contain("{{RankedStudents}}"));
}
[Test]
public void ApplyLayout_ReplacesHtmlFragmentSentinel()
{
var sentinel = NoteTemplateMerger.HtmlFragmentSentinel(NoteTemplateMerger.RankedStudentsToken);
var fragments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedStudentsToken] = "<div class=\"print-rank-badges\">Aria</div>"
};
var raw = NoteTemplateMerger.ApplyLayout($"x{sentinel}y", fragments);
var wrapped = NoteTemplateMerger.ApplyLayout($"<p>{sentinel}</p>", fragments);
var missing = NoteTemplateMerger.ApplyLayout($"x{sentinel}y");
Assert.That(raw, Is.EqualTo("x<div class=\"print-rank-badges\">Aria</div>y"));
Assert.That(wrapped, Is.EqualTo("<div class=\"print-rank-badges\">Aria</div>"));
Assert.That(missing, Is.EqualTo("xy"));
}
[Test]
public void Merge_Legend_IsSentinel()
{
var result = NoteTemplateMerger.Merge("{{Legend}}", PrintTokenMap.Create());
Assert.That(result, Does.Contain(NoteTemplateMerger.LegendSentinel));
Assert.That(result, Does.Not.Contain("{{Legend}}"));
}
[Test]
public void ApplyLayout_ReplacesLegendSentinel()
{
var html = NoteTemplateMerger.ApplyLayout(
$"<p>{NoteTemplateMerger.LegendSentinel}</p>");
Assert.That(html, Does.Contain("print-badge-legend"));
Assert.That(html, Does.Contain(EventAttributeMarks.Individual));
Assert.That(html, Does.Not.Contain("event-rank-1"));
}
}
+22
View File
@@ -1,3 +1,4 @@
using Core.Entities;
using Core.Printing;
namespace Tests.Printing;
@@ -12,14 +13,34 @@ public class PrintFieldCatalog_Tests
Assert.That(tokens, Does.Contain("FirstName"));
Assert.That(tokens, Does.Contain("Chapter.ShortName"));
Assert.That(tokens, Does.Contain("Rank1"));
Assert.That(tokens, Does.Contain("Rank10.ShortName"));
Assert.That(tokens, Does.Contain("Rank1.Attributes"));
Assert.That(tokens, Does.Contain(NoteTemplateMerger.RankedEventsToken));
Assert.That(tokens, Does.Not.Contain("Rank11"));
Assert.That(tokens, Does.Not.Contain("Interview Time"));
}
[Test]
public void StudentRanks_MatchesMaxRankAndStaysOffEntityTokens()
{
Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain("Rank1"));
Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain("Rank1.ShortName"));
Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain("Rank1.Attributes"));
Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain(NoteTemplateMerger.RankedEventsToken));
Assert.That(PrintFieldCatalog.StudentRanks, Has.Length.EqualTo(1 + StudentEventRanking.MaxRank * 3));
Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Student), Does.Not.Contain("Rank1"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Not.Contain("Rank1"));
}
[Test]
public void BuiltInFor_TeamAndEventHaveExpectedNames()
{
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventName"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventAttributes"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalEvent"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain(NoteTemplateMerger.RankedStudentsToken));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("EventAttributes"));
}
[Test]
@@ -27,6 +48,7 @@ public class PrintFieldCatalog_Tests
{
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.PageBreakToken));
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.AnswerSpaceToken));
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.LegendToken));
}
[Test]
+120
View File
@@ -0,0 +1,120 @@
using Core.Entities;
using Core.Printing;
using Tests.Builders;
namespace Tests.Printing;
[TestFixture]
public class PrintRankBadgeHtml_Tests
{
[Test]
public void ForStudentEvents_Empty_IsEmpty()
{
Assert.That(PrintRankBadgeHtml.ForStudentEvents([]), Is.EqualTo(string.Empty));
Assert.That(PrintRankBadgeHtml.ForStudentEvents(null), Is.EqualTo(string.Empty));
}
[Test]
public void ForStudentEvents_RendersShortNameDotAndAttributes()
{
var coding = EventDefinitionBuilder.Individual("Coding")
.WithShortName("Code")
.AsRegionalEvent()
.Build();
var student = StudentBuilder.Create("Aria", "Cole")
.WithRanking(coding, 1)
.Build();
var html = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings);
Assert.That(html, Does.Contain("print-rank-badges"));
Assert.That(html, Does.Contain("event-rank-1"));
Assert.That(html, Does.Contain("Code"));
Assert.That(html, Does.Contain(EventAttributeMarks.Individual));
Assert.That(html, Does.Contain(EventAttributeMarks.Regional));
Assert.That(html, Does.Not.Contain("{{"));
}
[Test]
public void ForEventStudents_SortsByRankThenSeniority()
{
var evt = EventDefinitionBuilder.Individual("Coding").Build();
var younger = StudentBuilder.Create("Bea", "Young").Build();
younger.Grade = 9;
younger.TsaYear = 1;
var older = StudentBuilder.Create("Aria", "Cole").Build();
older.Grade = 12;
older.TsaYear = 4;
var rankings = new List<StudentEventRanking>
{
new() { Student = younger, EventDefinition = evt, Rank = 1 },
new() { Student = older, EventDefinition = evt, Rank = 1 }
};
var html = PrintRankBadgeHtml.ForEventStudents(rankings);
var ariaAt = html.IndexOf("Aria", StringComparison.Ordinal);
var beaAt = html.IndexOf("Bea", StringComparison.Ordinal);
Assert.That(ariaAt, Is.GreaterThanOrEqualTo(0));
Assert.That(beaAt, Is.GreaterThan(ariaAt));
}
[Test]
public void EventAttributeMarks_IncludesEffortAndFlags()
{
var evt = EventDefinitionBuilder.Individual("Coding")
.AsOnSite()
.AsRegionalEvent()
.WithPresubmission()
.WithLevelOfEffort(2)
.Build();
var marks = EventAttributeMarks.For(evt);
Assert.That(marks, Does.Contain(EventAttributeMarks.LevelOfEffort2));
Assert.That(marks, Does.Contain(EventAttributeMarks.Individual));
Assert.That(marks, Does.Contain(EventAttributeMarks.OnSite));
Assert.That(marks, Does.Contain(EventAttributeMarks.Regional));
Assert.That(marks, Does.Contain(EventAttributeMarks.Presubmission));
}
[Test]
public void EscapeHtml_MasksTags()
{
Assert.That(PrintTokenMap.EscapeHtml("A <b>"), Is.EqualTo("A &lt;b&gt;"));
}
[Test]
public void For_UsesTheSameMarksAsTheLegend()
{
var evt = EventDefinitionBuilder.Individual("Coding")
.AsOnSite()
.AsRegionalEvent()
.WithPresubmission()
.WithLevelOfEffort(2)
.Build();
var fromCatalog = string.Join(
" ",
EventAttributeMarks.LegendItems.Where(m => m.Applies(evt)).Select(m => m.Symbol));
Assert.That(EventAttributeMarks.For(evt), Is.EqualTo(fromCatalog));
}
[Test]
public void Legend_IncludesRankDotsAndAttributeMarks()
{
var html = PrintRankBadgeHtml.Legend();
Assert.That(html, Does.Contain("print-badge-legend"));
Assert.That(html, Does.Contain("&middot;"));
Assert.That(html, Does.Not.Contain(EventRankLegend.Ordinal(1)));
Assert.That(html, Does.Not.Contain("event-rank-1"));
foreach (var mark in EventAttributeMarks.LegendItems)
{
Assert.That(html, Does.Contain(mark.Symbol));
Assert.That(html, Does.Contain(mark.Label));
}
}
}
+46
View File
@@ -16,6 +16,17 @@ public class PrintTokenMap_Tests
Assert.That(map["Grade"], Is.EqualTo("9"));
}
[Test]
public void Build_BuiltInRankTokenWinsOverImportedSameName()
{
var map = PrintTokenMap.Build(
new Dictionary<string, string?> { ["Rank1"] = "imported" },
new Dictionary<string, string?> { ["Rank1"] = "Coding" },
null);
Assert.That(map["Rank1"], Is.EqualTo("Coding"));
}
[Test]
public void Build_IncludesAllImportedCatalogKeys()
{
@@ -56,4 +67,39 @@ public class PrintTokenMap_Tests
Assert.That(merged, Is.EqualTo("Hi A\\*ria"));
}
[Test]
public void Escape_FlattensNewlinesSoTableRowsStayIntact()
{
var escaped = PrintTokenMap.Escape("Drone Challenge (UAV)\r\n\r\n");
Assert.That(escaped, Is.EqualTo("Drone Challenge (UAV)"));
Assert.That(escaped, Does.Not.Contain('\n'));
Assert.That(escaped, Does.Not.Contain('\r'));
}
[Test]
public void Escape_EscapesPipeForMarkdownTables()
{
Assert.That(PrintTokenMap.Escape("A | B"), Is.EqualTo("A \\| B"));
}
[Test]
public void Merge_DroneNameWithTrailingNewlines_StaysOnOneTableRow()
{
var map = PrintTokenMap.Build(
null,
new Dictionary<string, string?>
{
["Rank1"] = "Drone Challenge (UAV)\n\n",
["Rank2"] = "Off the Grid"
},
null);
var merged = NoteTemplateMerger.Merge(
"| {{Rank1}} | {{Rank2}} |\n| --- | --- |",
map);
Assert.That(merged, Is.EqualTo("| Drone Challenge (UAV) | Off the Grid |\n| --- | --- |"));
}
}
+88
View File
@@ -0,0 +1,88 @@
using Core.Entities;
using Core.Printing;
using Tests.Builders;
namespace Tests.Printing;
[TestFixture]
public class StudentRankTokens_Tests
{
[Test]
public void FromRankings_AlwaysIncludesEveryRankThroughMax()
{
var map = StudentRankTokens.FromRankings([]);
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++)
{
Assert.That(map.ContainsKey(StudentRankTokens.NameToken(rank)), Is.True);
Assert.That(map.ContainsKey(StudentRankTokens.ShortNameToken(rank)), Is.True);
Assert.That(map[StudentRankTokens.NameToken(rank)], Is.Null);
}
}
[Test]
public void FromRankings_FillsNameAndShortNameForPresentRanks()
{
var coding = EventDefinitionBuilder.Individual("Coding")
.WithShortName("Code")
.Build();
var flight = EventDefinitionBuilder.Individual("Flight Endurance")
.WithShortName("Flight")
.Build();
var student = StudentBuilder.Create("Aria", "Cole")
.WithRanking(coding, 1)
.WithRanking(flight, 3)
.Build();
var map = StudentRankTokens.FromRankings(student.EventRankings);
Assert.That(map["Rank1"], Is.EqualTo("Coding"));
Assert.That(map["Rank1.ShortName"], Is.EqualTo("Code"));
Assert.That(map["Rank1.Attributes"], Is.EqualTo(EventAttributeMarks.For(coding)));
Assert.That(map["Rank3"], Is.EqualTo("Flight Endurance"));
Assert.That(map["Rank3.ShortName"], Is.EqualTo("Flight"));
Assert.That(map["Rank2"], Is.Null);
Assert.That(map["Rank2.Attributes"], Is.Null);
}
[Test]
public void FromRankings_IgnoresRanksOutsideOneToMax()
{
var evt = EventDefinitionBuilder.Individual("Coding").Build();
var rankings = new List<StudentEventRanking>
{
new() { EventDefinition = evt, Rank = 0 },
new() { EventDefinition = evt, Rank = StudentEventRanking.MaxRank + 1 }
};
var map = StudentRankTokens.FromRankings(rankings);
Assert.That(map["Rank1"], Is.Null);
}
[Test]
public void Merge_KnownEmptyRankPrintsBlank()
{
var map = PrintTokenMap.Build(null, StudentRankTokens.FromRankings([]), null);
var merged = NoteTemplateMerger.Merge("1. {{Rank1}}", map);
Assert.That(merged, Is.EqualTo("1. "));
}
[Test]
public void Merge_ReplacesRankTokens()
{
var evt = EventDefinitionBuilder.Individual("Video Game Design")
.WithShortName("VGD")
.Build();
var student = StudentBuilder.Create("Aria", "Cole")
.WithRanking(evt, 1)
.Build();
var map = PrintTokenMap.Build(null, StudentRankTokens.FromRankings(student.EventRankings), null);
var merged = NoteTemplateMerger.Merge("{{Rank1}} ({{Rank1.ShortName}})", map);
Assert.That(merged, Is.EqualTo("Video Game Design (VGD)"));
}
}