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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 <b>"));
|
||||
}
|
||||
|
||||
[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("·"));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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| --- | --- |"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user