Author SHA1 Message Date
poprhythm f400813667 Update EventOccurrence parsing to use EventOccurrenceParseGroup for improved data structure
This commit refactors the EventOccurrence parsing logic to utilize the EventOccurrenceParseGroup class, enhancing the organization of parsed occurrences by grouping them based on event definitions and optional section levels. The changes include updates to the EventOccurrenceParseResult, EventOccurrenceParser, and EventOccurrenceParserService to accommodate the new grouping structure. Additionally, tests are modified to reflect these changes, ensuring that the parsing functionality remains intact and accurate. This refactor improves data handling and aligns with the overall architecture of the application.
2026-04-04 21:55:44 -04:00
186 changed files with 2580 additions and 10898 deletions
-24
View File
@@ -1,24 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Core.Printing;
namespace Core.Entities;
/// <summary>
/// Saved page-printer recipe: template markdown, entity type, and filters. Merged output is not stored.
/// </summary>
public class PrintPreset
{
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } = null!;
public string TemplateMarkdown { get; set; } = string.Empty;
public PrintEntityType EntityType { get; set; }
public string FiltersJson { get; set; } = "{}";
public DateTime UpdatedAt { get; set; }
}
+4 -16
View File
@@ -17,10 +17,6 @@ public class Student : IEquatable<Student>
[Display(Name = "Last Name")] [Display(Name = "Last Name")]
public string LastName { get; set; } = null!; public string LastName { get; set; } = null!;
[StringLength(50)]
[Display(Name = "Nickname")]
public string? Nickname { get; set; }
[Range(5,12)] [Range(5,12)]
[Display(Name = "Grade")] [Display(Name = "Grade")]
public int Grade { get; set; } public int Grade { get; set; }
@@ -59,17 +55,6 @@ public class Student : IEquatable<Student>
public string FirstNameLastName => $"{FirstName} {LastName}"; 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) public static Tuple<string, string> ParseNameParts(string fullName)
{ {
var match = Match(fullName, @"(.*),\s*(.*)"); var match = Match(fullName, @"(.*),\s*(.*)");
@@ -82,7 +67,10 @@ public class Student : IEquatable<Student>
: new Tuple<string, string>(fullName, string.Empty); : new Tuple<string, string>(fullName, string.Empty);
} }
public override string ToString() => DisplayFirstName; public override string ToString()
{
return FirstName;
}
public bool VotingDelegate => OfficerRole is Entities.OfficerRole.President or Entities.OfficerRole.VicePresident; public bool VotingDelegate => OfficerRole is Entities.OfficerRole.President or Entities.OfficerRole.VicePresident;
+10
View File
@@ -0,0 +1,10 @@
using Core.Entities;
namespace Core.Models;
/// <summary>
/// Groups parsed occurrences by event definition and optional section school level from headers
/// (e.g. "Prepared Speech - HS" vs "Prepared Speech - MS"). The same <see cref="EventDefinition"/>
/// can appear in multiple groups.
/// </summary>
public readonly record struct EventOccurrenceParseGroup(EventDefinition EventDefinition, SchoolLevel? SectionSchoolLevel);
+4 -4
View File
@@ -9,11 +9,11 @@ namespace Core.Models;
public class EventOccurrenceParseResult public class EventOccurrenceParseResult
{ {
/// <summary> /// <summary>
/// Dictionary of parsed event occurrences, keyed by EventDefinition. /// Parsed occurrences keyed by event definition and optional section MS/HS from schedule headers.
/// For special events (GeneralSchedule, MeetTheCandidates, ChapterOfficerMeeting, VotingDelegateMeeting, SocialGathering), /// Special events use <see cref="EventOccurrenceParseGroup.EventDefinition"/> static instances with
/// the EventDefinition key will be the static instance. /// <see cref="EventOccurrenceParseGroup.SectionSchoolLevel"/> typically null.
/// </summary> /// </summary>
public IDictionary<EventDefinition, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<EventOccurrence>>(); public IDictionary<EventOccurrenceParseGroup, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<EventOccurrence>>();
/// <summary> /// <summary>
/// List of parsing errors (critical issues that prevented parsing). /// List of parsing errors (critical issues that prevented parsing).
@@ -1,107 +0,0 @@
using Core.Entities;
namespace Core.Models;
/// <summary>
/// Result of parsing a student event ranking CSV.
/// </summary>
public class StudentEventRankingParseResult
{
/// <summary>
/// Accepted ranking matches, including the raw CSV text and fuzzy scores.
/// </summary>
public List<StudentEventRankingMatch> Matches { get; set; } = [];
/// <summary>
/// Unmatched students, unmatched or ambiguous events, and other row-level issues.
/// </summary>
public List<StudentEventRankingIssue> Issues { get; set; } = [];
/// <summary>
/// Critical errors that prevented parsing (for example a missing header).
/// </summary>
public List<string> Errors { get; set; } = [];
/// <summary>
/// Non-critical warnings about the file as a whole.
/// </summary>
public List<string> Warnings { get; set; } = [];
/// <summary>
/// Accepted rankings without match metadata.
/// </summary>
public IReadOnlyList<StudentEventRanking> Rankings => [.. Matches.Select(m => m.Ranking)];
/// <summary>
/// Number of accepted ranking rows.
/// </summary>
public int TotalParsed => Matches.Count;
/// <summary>
/// Distinct students who have at least one accepted rank.
/// Uses Id when assigned, otherwise first and last name, so unsaved parsed students stay distinct.
/// </summary>
public IReadOnlyList<Student> StudentsWithAcceptedRanks =>
[.. Matches
.Select(m => m.Ranking.Student)
.GroupBy(s => s.Id != 0 ? $"id:{s.Id}" : $"name:{s.FirstName}|{s.LastName}")
.Select(g => g.First())];
/// <summary>
/// True when no critical parse errors were recorded.
/// </summary>
public bool IsSuccess => Errors.Count == 0;
}
/// <summary>
/// A successfully matched ranking cell from the CSV.
/// </summary>
public class StudentEventRankingMatch
{
public required StudentEventRanking Ranking { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public string RawEventName { get; set; } = string.Empty;
public int StudentScore { get; set; }
public int EventScore { get; set; }
public int RowNumber { get; set; }
}
/// <summary>
/// A row-level problem encountered while parsing rankings.
/// </summary>
public class StudentEventRankingIssue
{
public int RowNumber { get; set; }
public int Rank { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public string RawEventName { get; set; } = string.Empty;
public StudentEventRankingIssueType IssueType { get; set; }
public string Message { get; set; } = string.Empty;
public string? SuggestedEventName { get; set; }
public int? Score { get; set; }
}
/// <summary>
/// Types of issues reported while parsing student event rankings.
/// </summary>
public enum StudentEventRankingIssueType
{
UnmatchedStudent,
UnmatchedEvent,
AmbiguousEvent,
DuplicateEvent,
DuplicateRank,
InvalidFormat
}
-48
View File
@@ -1,48 +0,0 @@
using Core.Entities;
using Core.Notes;
namespace Core.Models;
/// <summary>
/// Result of parsing a student notes field CSV.
/// </summary>
public class StudentNotesImportResult
{
public List<StudentNotesImportMatch> Matches { get; set; } = [];
public List<StudentNotesImportIssue> Issues { get; set; } = [];
public List<string> Errors { get; set; } = [];
public List<string> Warnings { get; set; } = [];
public IReadOnlyList<string> FieldNames { get; set; } = [];
public bool IsSuccess => Errors.Count == 0;
public int StudentsWithChanges => Matches.Count(m => m.Merge.Changed);
}
public class StudentNotesImportMatch
{
public required Student Student { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public int RowNumber { get; set; }
public int StudentScore { get; set; }
public List<ImportedField> IncomingFields { get; set; } = [];
public required ImportedFieldsMergeResult Merge { get; set; }
}
public class StudentNotesImportIssue
{
public int RowNumber { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
-307
View File
@@ -1,307 +0,0 @@
using System.Text;
namespace Core.Notes;
/// <summary>
/// Parses and upserts a generic Field/Value markdown table under a stable heading.
/// </summary>
public static class ImportedFieldsTable
{
public const string Heading = "## Additional fields";
public static string NormalizeValue(string? raw)
{
var value = (raw ?? string.Empty).Trim();
if (value.Equals("x", StringComparison.OrdinalIgnoreCase))
return "Yes";
return value;
}
/// <summary>
/// Reads Field/Value rows from the Additional fields section.
/// </summary>
public static List<ImportedField> ParseFields(string? markdown)
{
var section = ExtractSection(markdown);
if (string.IsNullOrWhiteSpace(section))
return [];
List<ImportedField> fields = [];
foreach (var rawLine in section.Split('\n'))
{
var line = rawLine.Trim();
if (!line.StartsWith('|') || line.Contains("---", StringComparison.Ordinal))
continue;
var cells = SplitTableCells(line);
if (cells.Length < 2)
continue;
var field = UnescapeCell(cells[0]);
var value = UnescapeCell(cells[1]);
if (field.Equals("Field", StringComparison.OrdinalIgnoreCase)
&& value.Equals("Value", StringComparison.OrdinalIgnoreCase))
continue;
fields.Add(new ImportedField(field, value));
}
return fields;
}
/// <summary>
/// Upserts incoming fields into the Additional fields section. Incoming values win.
/// Fields not in <paramref name="incoming"/> are kept. Identical values are not changes.
/// </summary>
public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList<ImportedField> incoming)
{
var existing = ParseFields(existingMarkdown);
var merged = existing.ToList();
List<ImportedFieldChange> changes = [];
foreach (var incomingField in incoming)
{
var name = incomingField.Name.Trim();
if (string.IsNullOrEmpty(name))
continue;
var value = NormalizeValue(incomingField.Value);
var index = merged.FindIndex(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
merged.Add(new ImportedField(name, value));
changes.Add(new ImportedFieldChange
{
Field = name,
PreviousValue = null,
NewValue = value,
IsNew = true
});
continue;
}
var current = merged[index];
if (string.Equals(current.Value, value, StringComparison.Ordinal))
continue;
merged[index] = new ImportedField(current.Name, value);
changes.Add(new ImportedFieldChange
{
Field = current.Name,
PreviousValue = current.Value,
NewValue = value,
IsNew = false
});
}
var section = FormatSection(merged);
var markdown = ReplaceSection(existingMarkdown, section);
return new ImportedFieldsMergeResult
{
Markdown = markdown,
Changed = changes.Count > 0,
Changes = changes
};
}
public static string FormatSection(IReadOnlyList<ImportedField> fields)
{
var builder = new StringBuilder();
builder.AppendLine(Heading);
builder.AppendLine();
builder.AppendLine("| Field | Value |");
builder.AppendLine("| --- | --- |");
foreach (var field in fields)
builder.AppendLine($"| {EscapeCell(field.Name)} | {EscapeCell(field.Value)} |");
return builder.ToString().TrimEnd() + Environment.NewLine;
}
public static string? GetFieldValue(string? markdown, string fieldName)
{
var field = ParseFields(markdown)
.FirstOrDefault(f => f.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase));
return field?.Value;
}
/// <summary>
/// Unique additional-field names across notes, first-seen casing, sorted A–Z.
/// </summary>
public static List<string> DistinctFieldNames(IEnumerable<string?> markdowns)
{
Dictionary<string, string> names = new(StringComparer.OrdinalIgnoreCase);
foreach (var markdown in markdowns)
{
foreach (var field in ParseFields(markdown))
{
var name = field.Name.Trim();
if (name.Length == 0)
continue;
names.TryAdd(name, name);
}
}
return [.. names.Values.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)];
}
private static string? ExtractSection(string? markdown)
{
if (string.IsNullOrEmpty(markdown))
return null;
if (!TryFindHeading(markdown, out var start, out var headingLength))
return null;
var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(markdown, afterHeading);
return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading];
}
private static string ReplaceSection(string? existingMarkdown, string section)
{
if (string.IsNullOrWhiteSpace(existingMarkdown))
return section.TrimEnd() + Environment.NewLine;
if (!TryFindHeading(existingMarkdown, out var start, out var headingLength))
{
var prefix = existingMarkdown.TrimEnd();
return string.IsNullOrEmpty(prefix)
? section
: prefix + Environment.NewLine + Environment.NewLine + section;
}
var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(existingMarkdown, afterHeading);
var before = existingMarkdown[..start].TrimEnd();
var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart();
var builder = new StringBuilder();
if (!string.IsNullOrEmpty(before))
{
builder.Append(before);
builder.AppendLine();
builder.AppendLine();
}
builder.Append(section.TrimEnd());
builder.AppendLine();
if (!string.IsNullOrEmpty(after))
{
builder.AppendLine();
builder.Append(after.TrimEnd());
builder.AppendLine();
}
return builder.ToString();
}
/// <summary>
/// First <c>## … fields</c> heading (any prefix). New sections are written as <see cref="Heading"/>.
/// </summary>
private static bool TryFindHeading(string markdown, out int start, out int headingLength)
{
var index = 0;
while (index < markdown.Length)
{
var lineEnd = markdown.IndexOf('\n', index);
var end = lineEnd < 0 ? markdown.Length : lineEnd;
var line = markdown[index..end].TrimEnd('\r');
if (IsFieldsHeading(line))
{
start = index;
headingLength = line.Length;
return true;
}
if (lineEnd < 0)
break;
index = lineEnd + 1;
}
start = -1;
headingLength = 0;
return false;
}
private static bool IsFieldsHeading(string line)
{
var trimmed = line.Trim();
if (!trimmed.StartsWith("## ", StringComparison.Ordinal)
|| trimmed.StartsWith("###", StringComparison.Ordinal))
return false;
var title = trimmed[3..].Trim();
return title.EndsWith(" fields", StringComparison.OrdinalIgnoreCase)
|| title.Equals("fields", StringComparison.OrdinalIgnoreCase);
}
private static int FindNextHeading(string markdown, int startIndex)
{
var index = startIndex;
while (index < markdown.Length)
{
var lineStart = markdown.IndexOf('\n', index);
if (lineStart < 0)
return -1;
lineStart++;
if (lineStart < markdown.Length && markdown[lineStart] == '#' &&
lineStart + 2 < markdown.Length && markdown[lineStart + 1] == '#' &&
markdown[lineStart + 2] == ' ')
return lineStart;
index = lineStart;
}
return -1;
}
/// <summary>
/// Splits a markdown table row into cells. Leading/trailing pipes are ignored so a blank
/// value (e.g. <c>| Teacher Rec 3 | |</c>) is kept instead of dropped.
/// </summary>
private static string[] SplitTableCells(string line)
{
var parts = line.Split('|', StringSplitOptions.TrimEntries);
var start = 0;
var length = parts.Length;
if (length > 0 && parts[0].Length == 0)
{
start = 1;
length--;
}
if (length > 0 && parts[start + length - 1].Length == 0)
length--;
return length <= 0 ? [] : parts[start..(start + length)];
}
private static string EscapeCell(string value) => value.Replace("|", "\\|");
private static string UnescapeCell(string value) => value.Replace("\\|", "|");
}
public record ImportedField(string Name, string Value);
public class ImportedFieldsMergeResult
{
public required string Markdown { get; init; }
public bool Changed { get; init; }
public List<ImportedFieldChange> Changes { get; init; } = [];
}
public class ImportedFieldChange
{
public required string Field { get; init; }
public string? PreviousValue { get; init; }
public required string NewValue { get; init; }
public bool IsNew { get; init; }
}
-14
View File
@@ -1,14 +0,0 @@
namespace Core.Notes;
/// <summary>
/// Default imported-field names used for Students index columns and the CSV template.
/// </summary>
public static class StudentNoteFieldDefaults
{
public static readonly string[] IndexColumns =
[
"Interview Time",
"Application",
"Club Permission Slip"
];
}
+1 -9
View File
@@ -9,10 +9,6 @@ public class AssignmentRequirementParser : CsvParserBase
{ {
} }
public AssignmentRequirementParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
public AssignmentRequirement[] Parse(ICollection<EventDefinition> events, ICollection<Student> students) public AssignmentRequirement[] Parse(ICollection<EventDefinition> events, ICollection<Student> students)
{ {
var assumptions = new List<AssignmentRequirement>(); var assumptions = new List<AssignmentRequirement>();
@@ -24,11 +20,7 @@ public class AssignmentRequirementParser : CsvParserBase
var studentArray = var studentArray =
studentColumns studentColumns
.Select(c => students.FirstOrDefault(s => .Select(c => students.FirstOrDefault(s => s.FirstName == c)).ToArray();
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()) while (CsvReader.Read())
{ {
+10 -8
View File
@@ -1,4 +1,4 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Core.Entities; using Core.Entities;
using Core.Models; using Core.Models;
using EventOccurrenceParsers = Core.Parsers.EventOccurrence; using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
@@ -12,7 +12,7 @@ namespace Core.Parsers;
/// </summary> /// </summary>
public class EventOccurrenceParserResult public class EventOccurrenceParserResult
{ {
public IDictionary<EventDefinition, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<Entities.EventOccurrence>>(); public IDictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>>();
public List<ParsingIssue> Issues { get; set; } = new(); public List<ParsingIssue> Issues { get; set; } = new();
public List<string> SkippedSectionHeaders { get; set; } = new(); public List<string> SkippedSectionHeaders { get; set; } = new();
public int SkippedEventCount { get; set; } public int SkippedEventCount { get; set; }
@@ -296,12 +296,14 @@ public class EventOccurrenceParser
Location = location Location = location
}; };
if (!occurrences.ContainsKey(eventDefinition)) var groupKey = new EventOccurrenceParseGroup(eventDefinition, currentSectionLevel);
occurrences.Add(eventDefinition, []); if (!occurrences.TryGetValue(groupKey, out var groupList))
occurrences[eventDefinition].Add(eventOccurrence); {
groupList = [];
// Reset section level when we successfully parse an occurrence (means we're in a valid section) occurrences[groupKey] = groupList;
currentSectionLevel = null; }
groupList.Add(eventOccurrence);
} }
return result; return result;
-36
View File
@@ -1,36 +0,0 @@
using Core.Entities;
using FuzzySharp;
namespace Core.Parsers;
/// <summary>
/// Fuzzy-matches a CSV or pasted name to existing students.
/// </summary>
public static class FuzzyStudentMatcher
{
public const int MatchThreshold = 90;
public static (Student Student, int Score)? Find(ICollection<Student> students, string name)
{
var ranked = students
.Select(s => (Student: s, Score: Score(s, name)))
.Where(x => x.Score >= MatchThreshold)
.OrderByDescending(x => x.Score)
.ToList();
return ranked.Count == 0 ? null : ranked[0];
}
public static int Score(Student student, string name)
{
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)));
}
}
+45 -191
View File
@@ -1,223 +1,77 @@
using Core.Entities; using Core.Entities;
using Core.Models;
using FuzzySharp; using FuzzySharp;
namespace Core.Parsers; namespace Core.Parsers;
public class StudentEventRankingParser : CsvParserBase public class StudentEventRankingParser : CsvParserBase
{ {
public const int EventMatchThreshold = 70;
public const int EventAmbiguityGap = 8;
/// <summary>
/// Informal names that share too little text with the catalog for fuzzy matching.
/// Keyed by official <see cref="EventDefinition.Name"/>.
/// </summary>
private static readonly Dictionary<string, string[]> EventAliases = new(StringComparer.OrdinalIgnoreCase)
{
["Junior Solar Sprint"] = ["Solar Racer", "Solar Race"],
["Challenging Technology Issues"] = ["Challenging Tech", "Challenging Technology"],
["Digital Photography"] = ["Digital Photo"],
["Forensic Technology"] = ["Forensics", "Forensic"],
["Microcontroller Design"] = ["Micro Controller", "Micro Controller Design"],
["Medical Technology"] = ["Med Tech"],
["Inventions & Innovations"] = ["Innovations & Inventions", "Inventions and Innovations"],
["System Control Technology"] = ["Systems Control Tech", "Systems Control Technology"],
["Structural Engineering"] = ["Structural Eng"],
["Drone Challenge (UAV)"] = ["Drone Challenge", "Drone"],
["Drone Challenge"] = ["Drone Challenge (UAV)", "Drone"],
["TSA Robotics"] = ["Robotics"],
["Audio Podcasting"] = ["Audio Podcast", "Podcasting"]
};
public StudentEventRankingParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines) public StudentEventRankingParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
{ {
} }
public StudentEventRankingParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines) public StudentEventRanking[] Parse(ICollection<Student> students, ICollection<EventDefinition> events)
{ {
} var rankings = new List<StudentEventRanking>();
public StudentEventRankingParseResult Parse(ICollection<Student> students, ICollection<EventDefinition> events)
{
var result = new StudentEventRankingParseResult();
CsvReader.Read(); CsvReader.Read();
CsvReader.ReadHeader(); CsvReader.ReadHeader();
if (CsvReader.HeaderRecord is null || while (CsvReader.Read())
!CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase)) {
{ var name = CsvReader.GetField("Student Name");
result.Errors.Add("CSV must include a 'Student Name' column.");
return result;
}
while (CsvReader.Read())
{
var rowNumber = CsvReader.Context.Parser?.Row ?? 0;
var name = CsvReader.GetField("Student Name")?.Trim();
if (string.IsNullOrEmpty(name)) if (string.IsNullOrEmpty(name))
continue; continue;
var studentMatch = FuzzyStudentMatcher.Find(students, name); var student = students.FirstOrDefault(s => Fuzz.Ratio(s.FirstNameLastName, name) > 90);
if (studentMatch is null) if (student == null)
{
result.Issues.Add(new StudentEventRankingIssue
{
RowNumber = rowNumber,
RawStudentName = name,
IssueType = StudentEventRankingIssueType.UnmatchedStudent,
Message = $"No student matched '{name}'."
});
continue; continue;
}
var (student, studentScore) = studentMatch.Value; var competitiveEvents = new List<EventDefinition>();
var acceptedEventsForStudent = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var acceptedRanksForStudent = new HashSet<int>();
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++) for (var i = 1; i <= 6; i++)
{ {
var eventName = CsvReader.GetField(rank.ToString())?.Trim(); var eventName = CsvReader.GetField(i.ToString());
if (string.IsNullOrEmpty(eventName)) if (string.IsNullOrEmpty(eventName) || eventName == "") continue;
continue;
var eventResolution = ResolveEvent(events, eventName); eventName = eventName.Trim();
if (eventResolution.Status == EventMatchStatus.Unmatched)
if (eventName == "I&I")
eventName = "Inventions & Innovations";
if (eventName == "Med Tech")
eventName = "Medical Technology";
if (eventName.StartsWith("Challenging Tech"))
eventName = "Challenging Technology Issues";
var matches =
(from e in events
let rat = Fuzz.Ratio(e.Name, eventName)
where rat > 90
orderby rat descending
select e).ToList();
if (!matches.Any())
{ {
result.Issues.Add(new StudentEventRankingIssue matches =
{ (from e in events
RowNumber = rowNumber, where e.Name.StartsWith(eventName)
Rank = rank, select e).ToList();
RawStudentName = name,
RawEventName = eventName,
IssueType = StudentEventRankingIssueType.UnmatchedEvent,
Message = $"No event matched '{eventName}' for {student.FirstNameLastName} (rank {rank}).",
SuggestedEventName = eventResolution.BestEvent?.Name,
Score = eventResolution.BestScore
});
continue;
} }
if (eventResolution.Status == EventMatchStatus.Ambiguous) var competitiveEvent = matches.FirstOrDefault();
if (competitiveEvent == null)
{ {
result.Issues.Add(new StudentEventRankingIssue
{ //todo: throw new ArgumentException($"Event named '{eventName}' not found");
RowNumber = rowNumber, continue;
Rank = rank,
RawStudentName = name,
RawEventName = eventName,
IssueType = StudentEventRankingIssueType.AmbiguousEvent,
Message = $"'{eventName}' is ambiguous between '{eventResolution.BestEvent?.Name}' and '{eventResolution.RunnerUpEvent?.Name}' for {student.FirstNameLastName} (rank {rank}).",
SuggestedEventName = eventResolution.BestEvent?.Name,
Score = eventResolution.BestScore
});
continue;
} }
var matchedEvent = eventResolution.BestEvent!; rankings.Add(new StudentEventRanking{
if (!acceptedEventsForStudent.Add($"{matchedEvent.Id}:{matchedEvent.Name}")) Student = student, EventDefinition = competitiveEvent,
{ Rank = i});
result.Issues.Add(new StudentEventRankingIssue
{
RowNumber = rowNumber,
Rank = rank,
RawStudentName = name,
RawEventName = eventName,
IssueType = StudentEventRankingIssueType.DuplicateEvent,
Message = $"{student.FirstNameLastName} already has '{matchedEvent.Name}' in this file.",
SuggestedEventName = matchedEvent.Name,
Score = eventResolution.BestScore
});
continue;
}
if (!acceptedRanksForStudent.Add(rank))
{
result.Issues.Add(new StudentEventRankingIssue
{
RowNumber = rowNumber,
Rank = rank,
RawStudentName = name,
RawEventName = eventName,
IssueType = StudentEventRankingIssueType.DuplicateRank,
Message = $"{student.FirstNameLastName} already has a rank {rank} assignment in this file.",
SuggestedEventName = matchedEvent.Name,
Score = eventResolution.BestScore
});
continue;
}
result.Matches.Add(new StudentEventRankingMatch
{
Ranking = new StudentEventRanking
{
Student = student,
EventDefinition = matchedEvent,
Rank = rank
},
RawStudentName = name,
RawEventName = eventName,
StudentScore = studentScore,
EventScore = eventResolution.BestScore,
RowNumber = rowNumber
});
} }
} }
if (result.Matches.Count == 0 && result.Errors.Count == 0) return rankings.ToArray();
result.Warnings.Add("No rankings were accepted from the CSV.");
return result;
} }
}
private static EventResolution ResolveEvent(ICollection<EventDefinition> events, string eventName)
{
var scored = events
.Select(e => (Event: e, Score: ScoreEvent(e, eventName)))
.OrderByDescending(x => x.Score)
.ToList();
if (scored.Count == 0)
return new EventResolution(EventMatchStatus.Unmatched, null, 0, null, 0);
var best = scored[0];
var runnerUp = scored.Count > 1 ? scored[1] : default;
if (best.Score < EventMatchThreshold)
return new EventResolution(EventMatchStatus.Unmatched, best.Event, best.Score, runnerUp.Event, runnerUp.Score);
if (runnerUp.Event is not null && best.Score - runnerUp.Score < EventAmbiguityGap)
return new EventResolution(EventMatchStatus.Ambiguous, best.Event, best.Score, runnerUp.Event, runnerUp.Score);
return new EventResolution(EventMatchStatus.Matched, best.Event, best.Score, runnerUp.Event, runnerUp.Score);
}
private static int ScoreEvent(EventDefinition evt, string eventName)
{
List<string> names = [evt.Name];
if (!string.IsNullOrWhiteSpace(evt.ShortName))
names.Add(evt.ShortName);
if (EventAliases.TryGetValue(evt.Name, out var aliases))
names.AddRange(aliases);
return names
.Select(n => new[] { Fuzz.Ratio(n, eventName), Fuzz.TokenSetRatio(n, eventName), Fuzz.PartialRatio(n, eventName) }.Max())
.DefaultIfEmpty(0)
.Max();
}
private enum EventMatchStatus
{
Matched,
Ambiguous,
Unmatched
}
private readonly record struct EventResolution(
EventMatchStatus Status,
EventDefinition? BestEvent,
int BestScore,
EventDefinition? RunnerUpEvent,
int RunnerUpScore);
}
-63
View File
@@ -1,63 +0,0 @@
using Core.Notes;
namespace Core.Parsers;
/// <summary>
/// Builds a starter CSV for <c>/students/import</c> with roster columns plus leftover note fields.
/// </summary>
public static class StudentImportCsvTemplate
{
public static readonly string[] RosterHeaders =
[
"Student Name",
"Grade",
"TSA year",
"State ID",
"Regional ID",
"National ID"
];
/// <summary>
/// Returns a CSV with a header row and one example data row.
/// </summary>
public static string Build(IEnumerable<string>? leftoverFieldNames = null)
{
List<string> leftovers = leftoverFieldNames?
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.Where(name => !StudentNotesFieldParser.IsReservedHeader(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? [];
if (leftovers.Count == 0)
leftovers = [.. StudentNoteFieldDefaults.IndexColumns];
var headers = RosterHeaders.Concat(leftovers).ToArray();
var values = headers.Select(ExampleValue).ToArray();
return $"{ToCsvRow(headers)}{Environment.NewLine}{ToCsvRow(values)}{Environment.NewLine}";
}
private static string ExampleValue(string header) => header switch
{
"Student Name" => "Last, First",
"Grade" => "9",
"TSA year" => "1st",
"Interview Time" => "3:20-3:35",
_ when ContainsIgnoreCase(header, "Application")
|| ContainsIgnoreCase(header, "Permission") => "x",
_ => string.Empty
};
private static bool ContainsIgnoreCase(string value, string part) =>
value.Contains(part, StringComparison.OrdinalIgnoreCase);
private static string ToCsvRow(IEnumerable<string> cells) =>
string.Join(",", cells.Select(EscapeCsv));
private static string EscapeCsv(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
-164
View File
@@ -1,164 +0,0 @@
using Core.Entities;
using Core.Models;
using Core.Notes;
namespace Core.Parsers;
/// <summary>
/// Parses leftover CSV columns (not roster or ranking) into student note field merges.
/// </summary>
public class StudentNotesFieldParser : CsvParserBase
{
private static readonly HashSet<string> ReservedHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Student Name",
"Grade",
"TSA year",
"State ID",
"Regional ID",
"National ID",
"Officer",
"TOTAL # OF EVENTS"
};
public StudentNotesFieldParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
{
}
public StudentNotesFieldParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
/// <summary>
/// Roster and ranking columns that must not become imported note fields.
/// </summary>
public static bool IsReservedHeader(string? header)
{
if (string.IsNullOrWhiteSpace(header))
return true;
var trimmed = header.Trim();
if (ReservedHeaders.Contains(trimmed))
return true;
return int.TryParse(trimmed, out var rank)
&& rank >= 1
&& rank <= StudentEventRanking.MaxRank;
}
/// <summary>
/// Leftover field names from a header row after reserved columns are removed.
/// </summary>
public static List<string> GetLeftoverFieldNames(IEnumerable<string?> headers) =>
headers
.Where(h => !IsReservedHeader(h))
.Select(h => h!.Trim())
.ToList();
/// <summary>
/// Reads the header row and returns leftover field names without processing data rows.
/// </summary>
public List<string> PeekLeftoverFieldNames()
{
CsvReader.Read();
CsvReader.ReadHeader();
return GetLeftoverFieldNames(CsvReader.HeaderRecord ?? []);
}
public StudentNotesImportResult Parse(
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
{
var result = new StudentNotesImportResult();
CsvReader.Read();
CsvReader.ReadHeader();
if (CsvReader.HeaderRecord is null ||
!CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase))
{
result.Errors.Add("CSV must include a 'Student Name' column.");
return result;
}
var fieldNames = GetLeftoverFieldNames(CsvReader.HeaderRecord);
result.FieldNames = fieldNames;
if (fieldNames.Count == 0)
result.Warnings.Add("No leftover field columns were found besides roster and ranking columns.");
Dictionary<int, PendingStudentFields> pendingByStudentId = [];
while (CsvReader.Read())
{
var rowNumber = CsvReader.Context.Parser?.Row ?? 0;
var name = CsvReader.GetField("Student Name")?.Trim();
if (string.IsNullOrEmpty(name))
continue;
var studentMatch = FuzzyStudentMatcher.Find(students, name);
if (studentMatch is null)
{
result.Issues.Add(new StudentNotesImportIssue
{
RowNumber = rowNumber,
RawStudentName = name,
Message = $"No student matched '{name}'."
});
continue;
}
var (student, score) = studentMatch.Value;
if (!pendingByStudentId.TryGetValue(student.Id, out var pending))
{
pending = new PendingStudentFields(student);
pendingByStudentId[student.Id] = pending;
}
pending.RawStudentName = name;
pending.RowNumber = rowNumber;
pending.StudentScore = score;
foreach (var fieldName in fieldNames)
{
var raw = CsvReader.GetField(fieldName);
pending.Fields[fieldName.Trim()] = raw ?? string.Empty;
}
}
foreach (var pending in pendingByStudentId.Values)
{
List<ImportedField> incoming = [.. pending.Fields.Select(pair => new ImportedField(pair.Key, pair.Value))];
existingNotesByStudentId.TryGetValue(pending.Student.Id, out var existingMarkdown);
var merge = ImportedFieldsTable.Merge(existingMarkdown, incoming);
result.Matches.Add(new StudentNotesImportMatch
{
Student = pending.Student,
RawStudentName = pending.RawStudentName,
RowNumber = pending.RowNumber,
StudentScore = pending.StudentScore,
IncomingFields = incoming,
Merge = merge
});
}
if (result.Matches.Count == 0 && result.Errors.Count == 0)
result.Warnings.Add("No students were matched from the CSV.");
return result;
}
private sealed class PendingStudentFields(Student student)
{
public Student Student { get; } = student;
public string RawStudentName { get; set; } = string.Empty;
public int RowNumber { get; set; }
public int StudentScore { get; set; }
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}
+1 -7
View File
@@ -56,10 +56,6 @@ namespace Core.Parsers
{ {
} }
public TeamParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
public Team[] Parse(ICollection<EventDefinition> events, ICollection<Student> students) public Team[] Parse(ICollection<EventDefinition> events, ICollection<Student> students)
{ {
var teams = new List<Team>(); var teams = new List<Team>();
@@ -122,9 +118,7 @@ namespace Core.Parsers
{ {
Fuzz.Ratio(s.Name, studentName), Fuzz.Ratio(s.Name, studentName),
Fuzz.Ratio(s.FirstNameLastName, 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() }.Max()
where rat > 90 where rat > 90
orderby rat descending orderby rat descending
-45
View File
@@ -1,45 +0,0 @@
using Core.Entities;
namespace Core.Printing;
public sealed record EventMark(
string Symbol,
string Label,
string Color,
Func<EventDefinition, bool> Applies);
/// <summary>
/// Compact event-attribute marks used on the event-ranking index chip,
/// print badges, and the shared legend.
/// </summary>
public static class EventAttributeMarks
{
public const string LevelOfEffort1 = "○";
public const string LevelOfEffort2 = "◐";
public const string LevelOfEffort3 = "⬤";
public const string Individual = "ⓘ";
public const string OnSite = "ⓐ";
public const string Regional = "ⓡ";
public const string Presubmission = "ⓟ";
public static readonly IReadOnlyList<EventMark> LegendItems =
[
new(LevelOfEffort1, "Level of Effort: 1", "#757575", e => e.LevelOfEffort == 1),
new(LevelOfEffort2, "Level of Effort: 2", "#616161", e => e.LevelOfEffort == 2),
new(LevelOfEffort3, "Level of Effort: 3", "#424242", e => e.LevelOfEffort == 3),
new(Individual, "Individual Event", "#9c27b0", e => e.EventFormat == EventFormat.Individual),
new(OnSite, "On-Site Activity", "#ff9800", e => e.OnSiteActivity),
new(Regional, "Regional Event", "#2196f3", e => e.RegionalEvent),
new(Presubmission, "Presubmission", "#4caf50", e => e.Presubmission)
];
public static string For(EventDefinition? evt)
{
if (evt is null)
return string.Empty;
return string.Join(
" ",
LegendItems.Where(m => m.Applies(evt)).Select(m => m.Symbol));
}
}
-53
View File
@@ -1,53 +0,0 @@
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Rank labels and colors shared by the ranking index, print badges, and legend.
/// </summary>
public static class EventRankLegend
{
public static readonly IReadOnlyList<(int Rank, string Label)> Items =
[
.. Enumerable.Range(1, StudentEventRanking.MaxRank)
.Select(rank => (rank, Ordinal(rank)))
];
public static string Ordinal(int num)
{
if (num <= 0)
return num.ToString();
switch (num % 100)
{
case 11:
case 12:
case 13:
return num + "th";
}
return (num % 10) switch
{
1 => num + "st",
2 => num + "nd",
3 => num + "rd",
_ => num + "th"
};
}
public static string ColorHex(int rank) =>
rank switch
{
1 => "#dd7e6b",
2 => "#ea9999",
3 => "#f9cb9c",
4 => "#ffe599",
5 => "#fff2cc",
6 => "#fffaea",
7 => "#fffefa",
8 => "#fffefc",
9 => "#fffffd",
10 => "#fffffe",
_ => "#ddd"
};
}
-108
View File
@@ -1,108 +0,0 @@
namespace Core.Printing;
/// <summary>
/// Splits a markdown template that contains exactly one table so a roster can
/// share one header and append merged body rows per record.
/// </summary>
public sealed class MarkdownTableStencil
{
public required string Prefix { get; init; }
public required string Header { get; init; }
public required string Body { get; init; }
public required string Suffix { get; init; }
public static bool TryParse(string? template, out MarkdownTableStencil? stencil)
{
stencil = null;
if (string.IsNullOrWhiteSpace(template))
return false;
var lines = SplitLines(template);
if (!TryFindTable(lines, 0, out var headerIndex, out var bodyStart, out var bodyEnd))
return false;
if (bodyStart >= bodyEnd)
return false;
if (TryFindTable(lines, bodyEnd, out _, out _, out _))
return false;
stencil = new MarkdownTableStencil
{
Prefix = JoinLines(lines[..headerIndex]),
Header = JoinLines(lines[headerIndex..bodyStart]),
Body = JoinLines(lines[bodyStart..bodyEnd]),
Suffix = JoinLines(lines[bodyEnd..])
};
return true;
}
public string Stitch(string prefix, IEnumerable<string> bodies, string suffix)
{
ArgumentNullException.ThrowIfNull(bodies);
List<string> parts = [];
AppendPart(parts, prefix);
AppendPart(parts, Header);
foreach (var body in bodies)
AppendPart(parts, body);
AppendPart(parts, suffix);
return parts.Count == 0 ? string.Empty : string.Join('\n', parts) + "\n";
}
private static void AppendPart(List<string> parts, string? part)
{
if (string.IsNullOrEmpty(part))
return;
var trimmed = part.TrimEnd('\r', '\n');
if (trimmed.Length > 0)
parts.Add(trimmed);
}
private static bool TryFindTable(
string[] lines,
int start,
out int headerIndex,
out int bodyStart,
out int bodyEnd)
{
headerIndex = -1;
bodyStart = -1;
bodyEnd = -1;
for (var i = start; i < lines.Length - 1; i++)
{
if (!IsTableLine(lines[i]) || !IsSeparatorLine(lines[i + 1]))
continue;
headerIndex = i;
bodyStart = i + 2;
bodyEnd = bodyStart;
while (bodyEnd < lines.Length && IsTableLine(lines[bodyEnd]) && !IsSeparatorLine(lines[bodyEnd]))
bodyEnd++;
return true;
}
return false;
}
private static bool IsTableLine(string line)
{
var trimmed = line.TrimStart();
return trimmed.StartsWith('|');
}
private static bool IsSeparatorLine(string line) =>
IsTableLine(line) && line.Contains("---", StringComparison.Ordinal);
private static string[] SplitLines(string text) =>
text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
private static string JoinLines(string[] lines) =>
lines.Length == 0 ? string.Empty : string.Join('\n', lines);
}
-110
View File
@@ -1,110 +0,0 @@
using System.Text.RegularExpressions;
namespace Core.Printing;
/// <summary>
/// Replaces <c>{{Token}}</c> placeholders from a case-insensitive map.
/// Unknown tokens are left unchanged. Known empty values become blank.
/// <c>{{PageBreak}}</c> becomes a print page break after HTML conversion.
/// <c>{{AnswerSpace}}</c> becomes ruled write-in space after HTML conversion.
/// <c>{{RankedEvents}}</c> and <c>{{RankedStudents}}</c> become ranking-index badges.
/// <c>{{Legend}}</c> becomes the shared attribute-mark legend.
/// </summary>
public static class NoteTemplateMerger
{
public const string PageBreakToken = "PageBreak";
public const string PageBreakSentinel = "<!--tsa-page-break-->";
public const string PageBreakHtml = "<div class=\"pagebreak\"></div>";
public const string AnswerSpaceToken = "AnswerSpace";
public const string AnswerSpaceSentinel = "<!--tsa-answer-space-->";
public const string AnswerSpaceHtml = "<div class=\"print-answer-space\">&nbsp;</div>";
public const string LegendToken = "Legend";
public const string LegendSentinel = "<!--tsa-legend-->";
public const string RankedEventsToken = "RankedEvents";
public const string RankedStudentsToken = "RankedStudents";
public static readonly string[] HtmlFragmentTokens =
[
RankedEventsToken,
RankedStudentsToken
];
public static string HtmlFragmentSentinel(string name) => $"<!--tsa-html:{name}-->";
private readonly record struct LayoutToken(string Name, string Sentinel, string Html);
private static readonly LayoutToken[] LayoutTokens =
[
new(PageBreakToken, PageBreakSentinel, PageBreakHtml),
new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml),
new(LegendToken, LegendSentinel, PrintRankBadgeHtml.Legend())
];
private static readonly Regex TokenRegex = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled);
public static string Merge(string? template, IReadOnlyDictionary<string, string> tokens)
{
if (string.IsNullOrEmpty(template))
return string.Empty;
ArgumentNullException.ThrowIfNull(tokens);
return TokenRegex.Replace(template, match =>
{
var key = match.Groups[1].Value.Trim();
if (key.Length == 0)
return match.Value;
foreach (var layout in LayoutTokens)
{
if (key.Equals(layout.Name, StringComparison.OrdinalIgnoreCase))
return layout.Sentinel;
}
foreach (var htmlName in HtmlFragmentTokens)
{
if (key.Equals(htmlName, StringComparison.OrdinalIgnoreCase))
return HtmlFragmentSentinel(htmlName);
}
return tokens.TryGetValue(key, out var value)
? value ?? string.Empty
: match.Value;
});
}
/// <summary>
/// Turns layout sentinels into HTML after markdown has been rendered.
/// </summary>
public static string ApplyLayout(
string? html,
IReadOnlyDictionary<string, string>? htmlFragments = null)
{
if (string.IsNullOrEmpty(html))
return string.Empty;
foreach (var layout in LayoutTokens)
{
html = html
.Replace($"<p>{layout.Sentinel}</p>", layout.Html, StringComparison.Ordinal)
.Replace(layout.Sentinel, layout.Html, StringComparison.Ordinal);
}
foreach (var name in HtmlFragmentTokens)
{
var sentinel = HtmlFragmentSentinel(name);
var fragment = htmlFragments is not null
&& htmlFragments.TryGetValue(name, out var value)
? value ?? string.Empty
: string.Empty;
html = html
.Replace($"<p>{sentinel}</p>", fragment, StringComparison.Ordinal)
.Replace(sentinel, fragment, StringComparison.Ordinal);
}
return html;
}
}
-11
View File
@@ -1,11 +0,0 @@
namespace Core.Printing;
/// <summary>
/// Entity a print preset merges a note onto.
/// </summary>
public enum PrintEntityType
{
Student,
Team,
Event
}
-114
View File
@@ -1,114 +0,0 @@
namespace Core.Printing;
/// <summary>
/// Built-in merge token names. Imported student-note field names are supplied at runtime.
/// </summary>
public static class PrintFieldCatalog
{
public static readonly string[] Layout =
[
NoteTemplateMerger.PageBreakToken,
NoteTemplateMerger.AnswerSpaceToken,
NoteTemplateMerger.LegendToken
];
public static readonly string[] Chapter =
[
"Chapter.Name",
"Chapter.ShortName",
"Chapter.CompetitionYear",
"Chapter.YearlyTheme",
"Chapter.StateAbbrev"
];
public static readonly string[] Student =
[
"FirstName",
"LastName",
"Name",
"LastNameFirstName",
"Grade",
"TsaYear",
"Email",
"PhoneNumber",
"StateId",
"RegionalId",
"NationalId",
"OfficerRole"
];
/// <summary>
/// <c>Rank1</c>…<c>Rank10</c> and matching <c>.ShortName</c> tokens from
/// <see cref="StudentRankTokens"/>.
/// </summary>
public static readonly string[] StudentRanks = [.. StudentRankTokens.AllNames];
public static readonly string[] StudentRanks1To6 = [.. RankTokens(1, 6)];
public static readonly string[] StudentRanks7To10 = [.. RankTokens(7, 10)];
public static IReadOnlyList<string> RankTokens(int fromRank, int toRank) =>
[
.. Enumerable.Range(fromRank, toRank - fromRank + 1)
.SelectMany(rank => (string[])
[
StudentRankTokens.NameToken(rank),
StudentRankTokens.ShortNameToken(rank),
StudentRankTokens.AttributesToken(rank)
])
];
public static readonly string[] Team =
[
"Identifier",
"Name",
"EventName",
"EventShortName",
"EventFormat",
"TeamSize",
"NationalEligibility",
"Eligibility",
"RegionalTeamCount",
"StateTeamCount",
"Description",
"Theme",
"EventAttributes"
];
public static readonly string[] Event =
[
"Name",
"ShortName",
"EventFormat",
"TeamSize",
"NationalEligibility",
"Eligibility",
"RegionalTeamCount",
"StateTeamCount",
"LevelOfEffort",
"SemifinalistActivity",
"RegionalEvent",
"Description",
"Theme",
"Documentation",
"Notes",
"EventAttributes",
NoteTemplateMerger.RankedStudentsToken
];
public static IReadOnlyList<string> EntityTokens(PrintEntityType entityType) =>
entityType switch
{
PrintEntityType.Student => Student,
PrintEntityType.Team => Team,
PrintEntityType.Event => Event,
_ => []
};
public static IReadOnlyList<string> BuiltInFor(PrintEntityType entityType) =>
entityType switch
{
PrintEntityType.Student => [.. Chapter, .. Student, .. StudentRanks],
_ => [.. Chapter, .. EntityTokens(entityType)]
};
}
-94
View File
@@ -1,94 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Filter payload stored on a <see cref="PrintPreset"/>. Unused fields stay null.
/// </summary>
public class PrintPresetFilters
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public int? Grade { get; set; }
public int? TsaYear { get; set; }
/// <summary>
/// <c>true</c> officers only, <c>false</c> non-officers only, <c>null</c> any.
/// </summary>
public bool? IsOfficer { get; set; }
public string? TeamIdentifierContains { get; set; }
public string? EventNameContains { get; set; }
public EventFormat? EventFormat { get; set; }
/// <summary>
/// <c>true</c> regional only, <c>false</c> non-regional only, <c>null</c> any.
/// </summary>
public bool? RegionalOnly { get; set; }
/// <summary>
/// When true (default), each merged record starts a new printed page.
/// <c>{{PageBreak}}</c> in the template still works either way.
/// </summary>
public bool NewPagePerRecord { get; set; } = true;
public const int DefaultFontSizePt = 12;
public const int MinFontSizePt = 9;
public const int MaxFontSizePt = 18;
public const int DefaultAnswerSpaceLines = 3;
public const int MinAnswerSpaceLines = 1;
public const int MaxAnswerSpaceLines = 8;
/// <summary>
/// Body font size in points for merged pages.
/// </summary>
public int FontSizePt { get; set; } = DefaultFontSizePt;
/// <summary>
/// Ruled write-in lines for each <c>{{AnswerSpace}}</c>.
/// </summary>
public int AnswerSpaceLines { get; set; } = DefaultAnswerSpaceLines;
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
public static PrintPresetFilters FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new PrintPresetFilters();
var filters = JsonSerializer.Deserialize<PrintPresetFilters>(json, JsonOptions)
?? new PrintPresetFilters();
filters.ClampPrintOptions();
return filters;
}
public void ClampPrintOptions()
{
FontSizePt = Math.Clamp(FontSizePt, MinFontSizePt, MaxFontSizePt);
AnswerSpaceLines = Math.Clamp(AnswerSpaceLines, MinAnswerSpaceLines, MaxAnswerSpaceLines);
}
public static string ToTriState(bool? value) => value switch
{
true => "yes",
false => "no",
_ => "any"
};
public static bool? FromTriState(string? value) => value switch
{
"yes" => true,
"no" => false,
_ => null
};
}
-89
View File
@@ -1,89 +0,0 @@
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Ranking-index style badges: colored rank dot plus a short label.
/// Student pages list ranked events; event pages list students who ranked them.
/// </summary>
public static class PrintRankBadgeHtml
{
public static string ForStudentEvents(IEnumerable<StudentEventRanking>? rankings)
{
if (rankings is null)
return string.Empty;
var badges = rankings
.Where(r => r.Rank is >= 1 and <= StudentEventRanking.MaxRank)
.OrderBy(r => r.Rank)
.Select(EventBadge)
.ToList();
return Wrap(badges);
}
public static string ForEventStudents(IEnumerable<StudentEventRanking>? rankings)
{
if (rankings is null)
return string.Empty;
var badges = rankings
.Where(r => r.Student is not null && r.Rank is >= 1 and <= StudentEventRanking.MaxRank)
.OrderBy(r => r.Rank)
.ThenByDescending(r => r.Student.Grade + r.Student.TsaYear)
.Select(r => StudentBadge(r.Student.FirstName, r.Rank))
.ToList();
return Wrap(badges);
}
internal static string EventBadge(StudentEventRanking ranking)
{
var evt = ranking.EventDefinition;
var label = !string.IsNullOrWhiteSpace(evt?.ShortName)
? evt.ShortName
: evt?.Name;
var attributes = EventAttributeMarks.For(evt);
var attrsHtml = string.IsNullOrEmpty(attributes)
? string.Empty
: $"<span class=\"print-event-attrs\">{PrintTokenMap.EscapeHtml(attributes)}</span>";
return Badge(ranking.Rank, PrintTokenMap.EscapeHtml(label), attrsHtml);
}
internal static string StudentBadge(string? firstName, int rank) =>
Badge(rank, PrintTokenMap.EscapeHtml(firstName), string.Empty);
private static string Badge(int rank, string labelHtml, string extraHtml)
{
var extra = string.IsNullOrEmpty(extraHtml) ? string.Empty : $" {extraHtml}";
return
$"<span class=\"print-rank-badge\">" +
$"<span class=\"print-rank-dot event-rank-{rank}\"></span> " +
$"{labelHtml}{extra}" +
"</span>";
}
public static string Legend()
{
var marks = EventAttributeMarks.LegendItems
.Select(mark =>
"<span class=\"print-legend-mark\">" +
$"<span class=\"print-event-attrs\">{PrintTokenMap.EscapeHtml(mark.Symbol)}</span> " +
PrintTokenMap.EscapeHtml(mark.Label) +
"</span>");
return
"<div class=\"print-badge-legend\">" +
$"<div class=\"print-attr-legend\">{string.Join(" &middot; ", marks)}</div>" +
"</div>";
}
private static string Wrap(IReadOnlyList<string> badges, string separator = " ")
{
if (badges.Count == 0)
return string.Empty;
return $"<div class=\"print-rank-badges\">{string.Join(separator, badges)}</div>";
}
}
-76
View File
@@ -1,76 +0,0 @@
namespace Core.Printing;
/// <summary>
/// Builds a case-insensitive token map. Fill order is imported, then entity, then chapter
/// so built-in names win over an imported column with the same name.
/// </summary>
public static class PrintTokenMap
{
public static Dictionary<string, string> Create() =>
new(StringComparer.OrdinalIgnoreCase);
public static Dictionary<string, string> Build(
IReadOnlyDictionary<string, string?>? imported,
IReadOnlyDictionary<string, string?>? entity,
IReadOnlyDictionary<string, string?>? chapter)
{
var map = Create();
Apply(map, imported);
Apply(map, entity);
Apply(map, chapter);
return map;
}
public static void Apply(IDictionary<string, string> map, IReadOnlyDictionary<string, string?>? values)
{
if (values is null)
return;
foreach (var (key, value) in values)
{
if (string.IsNullOrWhiteSpace(key))
continue;
map[key] = Escape(value);
}
}
/// <summary>
/// Treats substituted values as plain text so they cannot change markdown or inject HTML.
/// Line breaks are flattened so a value cannot end a markdown table row.
/// </summary>
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return FlattenLines(value)
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("|", "\\|", StringComparison.Ordinal)
.Replace("*", "\\*", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal)
.Replace("`", "\\`", StringComparison.Ordinal)
.Replace("[", "\\[", StringComparison.Ordinal)
.Replace("]", "\\]", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
/// <summary>
/// Escapes a value for insertion into generated print HTML (badge labels).
/// </summary>
public static string EscapeHtml(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return FlattenLines(value)
.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal)
.Replace("\"", "&quot;", StringComparison.Ordinal);
}
private static string FlattenLines(string value) =>
string.Join(' ',
value.Split(['\r', '\n', '\u2028', '\u2029'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
-57
View File
@@ -1,57 +0,0 @@
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Per-student event-rank merge tokens. <c>Rank1</c> is the official event name
/// at rank 1; <c>Rank1.ShortName</c> is the catalog short name. Every rank
/// through <see cref="StudentEventRanking.MaxRank"/> is always a map key so a
/// missing preference prints blank instead of leaving <c>{{Rank5}}</c> visible.
/// </summary>
public static class StudentRankTokens
{
public static string NameToken(int rank) => $"Rank{rank}";
public static string ShortNameToken(int rank) => $"Rank{rank}.ShortName";
public static string AttributesToken(int rank) => $"Rank{rank}.Attributes";
public static IReadOnlyList<string> AllNames { get; } =
[
NoteTemplateMerger.RankedEventsToken,
.. Enumerable.Range(1, StudentEventRanking.MaxRank)
.SelectMany(rank => (string[])
[
NameToken(rank),
ShortNameToken(rank),
AttributesToken(rank)
])
];
public static Dictionary<string, string?> FromRankings(IEnumerable<StudentEventRanking>? rankings)
{
var map = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++)
{
map[NameToken(rank)] = null;
map[ShortNameToken(rank)] = null;
map[AttributesToken(rank)] = null;
}
if (rankings is null)
return map;
foreach (var ranking in rankings)
{
if (ranking.Rank < 1 || ranking.Rank > StudentEventRanking.MaxRank)
continue;
var evt = ranking.EventDefinition;
map[NameToken(ranking.Rank)] = evt?.Name;
map[ShortNameToken(ranking.Rank)] = evt?.ShortName;
map[AttributesToken(ranking.Rank)] = EventAttributeMarks.For(evt);
}
return map;
}
}
@@ -65,7 +65,8 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
// Convert parsed occurrences to result format, handling special event types // Convert parsed occurrences to result format, handling special event types
foreach (var kvp in parsedOccurrences) foreach (var kvp in parsedOccurrences)
{ {
var eventDefinition = kvp.Key; var group = kvp.Key;
var eventDefinition = group.EventDefinition;
var occurrences = kvp.Value; var occurrences = kvp.Value;
// Check if this is a special event type (not stored in database) // Check if this is a special event type (not stored in database)
@@ -90,8 +91,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
}; };
} }
// Add to result with the special EventDefinition as key result.Occurrences[group] = occurrences;
result.Occurrences[eventDefinition] = occurrences;
} }
else else
{ {
@@ -102,7 +102,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
occurrence.SpecialEventType = null; occurrence.SpecialEventType = null;
} }
result.Occurrences[eventDefinition] = occurrences; result.Occurrences[group] = occurrences;
} }
} }
-15
View File
@@ -35,19 +35,4 @@ public interface INoteNamingService
/// <param name="noteTitle">The note title to check</param> /// <param name="noteTitle">The note title to check</param>
/// <returns>True if the note is a meeting note, false otherwise</returns> /// <returns>True if the note is a meeting note, false otherwise</returns>
bool IsMeetingNote(string noteTitle); bool IsMeetingNote(string noteTitle);
/// <summary>
/// Gets the title for a student note. Format: "#Student:{id}"
/// </summary>
string GetStudentNoteTitle(int studentId);
/// <summary>
/// Checks if a note title is a student note.
/// </summary>
bool IsStudentNote(string? noteTitle);
/// <summary>
/// Parses the student id from a student note title.
/// </summary>
bool TryParseStudentNoteId(string? noteTitle, out int studentId);
} }
@@ -1,15 +0,0 @@
using Core.Entities;
using Core.Models;
namespace Core.Services;
/// <summary>
/// Parses student event ranking CSV data against existing students and events.
/// </summary>
public interface IStudentEventRankingImportService
{
/// <summary>
/// Parses ranking CSV from a stream. The stream is not disposed.
/// </summary>
StudentEventRankingParseResult Parse(Stream stream, ICollection<Student> students, ICollection<EventDefinition> events);
}
@@ -1,15 +0,0 @@
using Core.Entities;
using Core.Models;
namespace Core.Services;
/// <summary>
/// Parses student field CSVs and merges them into markdown notes.
/// </summary>
public interface IStudentNotesImportService
{
StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId);
}
-23
View File
@@ -8,7 +8,6 @@ public class NoteNamingService : INoteNamingService
{ {
private const string PageNotePrefix = "#"; private const string PageNotePrefix = "#";
private const string MeetingNotePrefix = "#Meeting Notes"; private const string MeetingNotePrefix = "#Meeting Notes";
private const string StudentNotePrefix = "#Student:";
/// <inheritdoc/> /// <inheritdoc/>
public string GetMeetingNoteTitle(DateTime meetingDate) public string GetMeetingNoteTitle(DateTime meetingDate)
@@ -48,26 +47,4 @@ public class NoteNamingService : INoteNamingService
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal); return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
} }
/// <inheritdoc/>
public string GetStudentNoteTitle(int studentId) => $"{StudentNotePrefix}{studentId}";
/// <inheritdoc/>
public bool IsStudentNote(string? noteTitle)
{
if (string.IsNullOrWhiteSpace(noteTitle))
return false;
return noteTitle.StartsWith(StudentNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public bool TryParseStudentNoteId(string? noteTitle, out int studentId)
{
studentId = 0;
if (!IsStudentNote(noteTitle))
return false;
return int.TryParse(noteTitle.AsSpan(StudentNotePrefix.Length), out studentId);
}
} }
@@ -1,19 +0,0 @@
using Core.Entities;
using Core.Models;
using Core.Parsers;
namespace Core.Services;
/// <summary>
/// Wraps <see cref="StudentEventRankingParser"/> for stream-based import.
/// </summary>
public class StudentEventRankingImportService : IStudentEventRankingImportService
{
/// <inheritdoc />
public StudentEventRankingParseResult Parse(Stream stream, ICollection<Student> students, ICollection<EventDefinition> events)
{
var reader = new StreamReader(stream, leaveOpen: true);
using var parser = new StudentEventRankingParser(reader);
return parser.Parse(students, events);
}
}
-54
View File
@@ -1,54 +0,0 @@
using Core.Models;
namespace Core.Services;
/// <summary>
/// Decides which parsed student notes should be created or updated.
/// Unchanged merges are omitted so a re-import does not write history.
/// </summary>
public static class StudentNotesImportPlan
{
/// <summary>
/// Builds persist actions from a parse result. Only matches with field changes are included.
/// </summary>
public static IReadOnlyList<StudentNotePersistAction> Create(
StudentNotesImportResult parseResult,
IReadOnlySet<int> studentIdsWithExistingNotes)
{
ArgumentNullException.ThrowIfNull(parseResult);
ArgumentNullException.ThrowIfNull(studentIdsWithExistingNotes);
List<StudentNotePersistAction> actions = [];
foreach (var match in parseResult.Matches)
{
if (!match.Merge.Changed)
continue;
actions.Add(new StudentNotePersistAction
{
StudentId = match.Student.Id,
Markdown = match.Merge.Markdown,
Kind = studentIdsWithExistingNotes.Contains(match.Student.Id)
? StudentNotePersistKind.Update
: StudentNotePersistKind.Create
});
}
return actions;
}
}
public enum StudentNotePersistKind
{
Create,
Update
}
public class StudentNotePersistAction
{
public required int StudentId { get; init; }
public required string Markdown { get; init; }
public required StudentNotePersistKind Kind { get; init; }
}
@@ -1,22 +0,0 @@
using Core.Entities;
using Core.Models;
using Core.Parsers;
namespace Core.Services;
/// <summary>
/// Wraps <see cref="StudentNotesFieldParser"/> for stream-based import.
/// </summary>
public class StudentNotesImportService : IStudentNotesImportService
{
/// <inheritdoc />
public StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
{
var reader = new StreamReader(stream, leaveOpen: true);
using var parser = new StudentNotesFieldParser(reader);
return parser.Parse(students, existingNotesByStudentId);
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ public static class StudentNameFormatter
if (student == null) if (student == null)
return string.Empty; return string.Empty;
var name = student.DisplayFirstName; var name = student.FirstName;
// Add overlap marker // Add overlap marker
if (options.HasOverlap) if (options.HasOverlap)
+6 -6
View File
@@ -107,7 +107,7 @@ public static class TeamStudentNameFormatter
if (student == null) if (student == null)
return string.Empty; return string.Empty;
var name = student.DisplayFirstName; var name = student.FirstName;
// Add captain indicator (before overlap/absent markers) // Add captain indicator (before overlap/absent markers)
if (options.CaptainIndicator != CaptainIndicatorStyle.None && team != null && team.Captain != null && team.Captain.Equals(student)) 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) // Get the suffix from StudentNameFormatter (overlap/absent markers)
var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions); var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions);
var suffix = baseFormatted.Substring(student.DisplayFirstName.Length); var suffix = baseFormatted.Substring(student.FirstName.Length);
return name + suffix; return name + suffix;
} }
@@ -214,10 +214,10 @@ public static class TeamStudentNameFormatter
{ {
OrderingStyle.CaptainFirst => studentsWithCaptainInfo OrderingStyle.CaptainFirst => studentsWithCaptainInfo
.OrderBy(x => !x.IsCaptain) .OrderBy(x => !x.IsCaptain)
.ThenBy(x => x.Student.DisplayFirstName) .ThenBy(x => x.Student.FirstName)
.Select(x => x.Student), .Select(x => x.Student),
OrderingStyle.Alphabetical => studentsWithCaptainInfo OrderingStyle.Alphabetical => studentsWithCaptainInfo
.OrderBy(x => x.Student.DisplayFirstName) .OrderBy(x => x.Student.FirstName)
.Select(x => x.Student), .Select(x => x.Student),
OrderingStyle.GradeDescending => studentsWithCaptainInfo OrderingStyle.GradeDescending => studentsWithCaptainInfo
.OrderByDescending(x => x.Student.Grade + x.Student.TsaYear) .OrderByDescending(x => x.Student.Grade + x.Student.TsaYear)
@@ -252,8 +252,8 @@ public static class TeamStudentNameFormatter
{ {
OrderingStyle.CaptainFirst => students OrderingStyle.CaptainFirst => students
.OrderBy(s => team.Captain == null || !team.Captain.Equals(s)) .OrderBy(s => team.Captain == null || !team.Captain.Equals(s))
.ThenBy(s => s.DisplayFirstName), .ThenBy(s => s.FirstName),
OrderingStyle.Alphabetical => students.OrderBy(s => s.DisplayFirstName), OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName),
OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear), OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear),
_ => students _ => students
}; };
@@ -1,32 +0,0 @@
using Core.Models;
namespace Core.YearTransition;
/// <summary>
/// Resolves the graduating grade from chapter school level configuration.
/// </summary>
public static class GraduatingGradeResolver
{
/// <summary>
/// Middle school students graduate after grade 8; high school after grade 12.
/// Returns null when school level is unset (both / unspecified).
/// </summary>
public static int? FromSchoolLevel(SchoolLevel? schoolLevel) => schoolLevel switch
{
SchoolLevel.MiddleSchool => 8,
SchoolLevel.HighSchool => 12,
_ => null
};
/// <summary>
/// Human-readable label for wizard display.
/// </summary>
public static string Describe(SchoolLevel schoolLevel, int graduatingGrade) => schoolLevel switch
{
SchoolLevel.MiddleSchool =>
$"Middle school chapter — students graduate after grade {graduatingGrade}",
SchoolLevel.HighSchool =>
$"High school chapter — students graduate after grade {graduatingGrade}",
_ => $"Students graduate after grade {graduatingGrade}"
};
}
@@ -1,298 +0,0 @@
using Core.Entities;
namespace Core.YearTransition;
/// <summary>
/// Inputs for building a year-transition plan.
/// </summary>
public sealed class YearTransitionRequest
{
public required IReadOnlyList<Student> Students { get; init; }
public required IReadOnlySet<int> ReturningStudentIds { get; init; }
public IReadOnlyDictionary<OfficerRole, int?> OfficerAssignments { get; init; }
= new Dictionary<OfficerRole, int?>();
public required int GraduatingGrade { get; init; }
public required string TargetCompetitionYear { get; init; }
public IReadOnlyList<string> PastedNames { get; init; } = [];
}
/// <summary>
/// Preview of a year transition before it is applied.
/// </summary>
public sealed class YearTransitionPlan
{
public required string TargetCompetitionYear { get; init; }
public required int GraduatingGrade { get; init; }
public required IReadOnlyList<StudentPromotion> Promotions { get; init; }
public required IReadOnlyList<Student> StudentsToRemove { get; init; }
public required IReadOnlyList<OfficerAssignmentChange> OfficerChanges { get; init; }
public required IReadOnlyList<string> UnmatchedPastedNames { get; init; }
public required IReadOnlyList<string> AmbiguousPastedNames { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
public int ReturningCount => Promotions.Count;
public int RemovalCount => StudentsToRemove.Count;
}
public sealed class StudentPromotion
{
public required Student Student { get; init; }
public required int PreviousGrade { get; init; }
public required int NewGrade { get; init; }
public required int PreviousTsaYear { get; init; }
public required int NewTsaYear { get; init; }
public OfficerRole? PreviousOfficerRole { get; init; }
public OfficerRole? NewOfficerRole { get; init; }
}
public sealed class OfficerAssignmentChange
{
public required OfficerRole Role { get; init; }
public Student? NewOfficer { get; init; }
public Student? PreviousOfficer { get; init; }
}
/// <summary>
/// Pure planner for year-end student promotion, graduation, and officer assignment.
/// </summary>
public static class YearTransitionPlanner
{
private const int AbsoluteMaxGrade = 12;
/// <summary>
/// Students at or above the graduating grade are suggested as non-returning.
/// </summary>
public static bool SuggestReturning(Student student, int graduatingGrade) =>
student.Grade < graduatingGrade;
/// <summary>
/// Parses pasted name lines and matches them to students.
/// </summary>
public static NameMatchResult MatchPastedNames(
IReadOnlyList<Student> students,
IEnumerable<string> pastedLines)
{
var matchedIds = new HashSet<int>();
var unmatched = new List<string>();
var ambiguous = new List<string>();
foreach (var rawLine in pastedLines)
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line))
continue;
var matches = FindNameMatches(students, line).ToList();
if (matches.Count == 0)
{
unmatched.Add(line);
}
else if (matches.Count > 1)
{
ambiguous.Add(line);
foreach (var match in matches)
matchedIds.Add(match.Id);
}
else
{
matchedIds.Add(matches[0].Id);
}
}
return new NameMatchResult(matchedIds, unmatched, ambiguous);
}
public static YearTransitionPlan Build(YearTransitionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.GraduatingGrade is < 5 or > AbsoluteMaxGrade)
throw new ArgumentOutOfRangeException(nameof(request), "Graduating grade must be between 5 and 12.");
var studentsById = request.Students.ToDictionary(s => s.Id);
var warnings = new List<string>();
var promotions = new List<StudentPromotion>();
var toRemove = new List<Student>();
var nameMatch = MatchPastedNames(request.Students, request.PastedNames);
warnings.AddRange(nameMatch.AmbiguousNames.Select(n =>
$"Pasted name '{n}' matched more than one student."));
// Officer role -> student id from request (null = vacant)
var newOfficerByRole = Enum.GetValues<OfficerRole>()
.ToDictionary(
role => role,
role => request.OfficerAssignments.TryGetValue(role, out var id) ? id : null);
// Detect same student assigned to multiple offices
var assignedStudentIds = newOfficerByRole.Values
.Where(id => id.HasValue)
.Select(id => id!.Value)
.ToList();
var duplicateOfficerStudents = assignedStudentIds
.GroupBy(id => id)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToHashSet();
foreach (var studentId in duplicateOfficerStudents)
{
if (studentsById.TryGetValue(studentId, out var student))
{
warnings.Add($"{student.LastNameFirstName} is assigned to more than one officer role.");
}
}
// Build new officer role lookup for returning students
var newRoleByStudentId = new Dictionary<int, OfficerRole>();
foreach (var (role, studentId) in newOfficerByRole)
{
if (!studentId.HasValue)
continue;
if (!request.ReturningStudentIds.Contains(studentId.Value))
{
var name = studentsById.TryGetValue(studentId.Value, out var s)
? s.LastNameFirstName
: $"Id {studentId.Value}";
warnings.Add($"{role} is assigned to {name}, who is not marked returning.");
continue;
}
if (duplicateOfficerStudents.Contains(studentId.Value))
continue;
newRoleByStudentId[studentId.Value] = role;
}
foreach (var student in request.Students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
if (request.ReturningStudentIds.Contains(student.Id))
{
var newGrade = Math.Min(AbsoluteMaxGrade,
Math.Min(request.GraduatingGrade, student.Grade + 1));
var newTsaYear = student.TsaYear + 1;
if (student.Grade >= request.GraduatingGrade)
{
warnings.Add(
$"{student.LastNameFirstName} is at or above graduating grade {request.GraduatingGrade} but marked returning; grade stays at {newGrade}.");
}
OfficerRole? assignedRole = newRoleByStudentId.TryGetValue(student.Id, out var role)
? role
: null;
promotions.Add(new StudentPromotion
{
Student = student,
PreviousGrade = student.Grade,
NewGrade = newGrade,
PreviousTsaYear = student.TsaYear,
NewTsaYear = newTsaYear,
PreviousOfficerRole = student.OfficerRole,
NewOfficerRole = assignedRole
});
}
else
{
toRemove.Add(student);
}
}
var officerChanges = Enum.GetValues<OfficerRole>()
.Select(role =>
{
var previous = request.Students.FirstOrDefault(s => s.OfficerRole == role);
Student? next = null;
if (newOfficerByRole.TryGetValue(role, out var nextId) &&
nextId.HasValue &&
studentsById.TryGetValue(nextId.Value, out var nextStudent) &&
request.ReturningStudentIds.Contains(nextId.Value) &&
!duplicateOfficerStudents.Contains(nextId.Value))
{
next = nextStudent;
}
return new OfficerAssignmentChange
{
Role = role,
PreviousOfficer = previous,
NewOfficer = next
};
})
.ToList();
return new YearTransitionPlan
{
TargetCompetitionYear = request.TargetCompetitionYear,
GraduatingGrade = request.GraduatingGrade,
Promotions = promotions,
StudentsToRemove = toRemove,
OfficerChanges = officerChanges,
UnmatchedPastedNames = nameMatch.UnmatchedNames,
AmbiguousPastedNames = nameMatch.AmbiguousNames,
Warnings = warnings
};
}
private static IEnumerable<Student> FindNameMatches(IReadOnlyList<Student> students, string line)
{
var comparer = StringComparer.OrdinalIgnoreCase;
// Exact full-name matches first
var fullMatches = students.Where(s =>
comparer.Equals(s.FirstNameLastName, line) ||
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;
var (first, last) = ParseName(line);
if (string.IsNullOrWhiteSpace(first) && string.IsNullOrWhiteSpace(last))
return [];
return students.Where(s =>
comparer.Equals(s.LastName.Trim(), last) &&
(comparer.Equals(s.FirstName.Trim(), first)
|| comparer.Equals(s.DisplayFirstName, first)
|| comparer.Equals(s.Nickname?.Trim(), first)));
}
/// <summary>
/// Parses a name line into (first, last), using <see cref="Student.ParseNameParts"/> for
/// "Last, First" and a last-space split for "First Last".
/// </summary>
private static (string First, string Last) ParseName(string fullName)
{
var trimmed = fullName.Trim();
if (trimmed.Contains(','))
{
var parts = Student.ParseNameParts(trimmed);
return (parts.Item1.Trim(), parts.Item2.Trim());
}
var lastSpace = trimmed.LastIndexOf(' ');
if (lastSpace <= 0)
return (trimmed, string.Empty);
return (trimmed[..lastSpace].Trim(), trimmed[(lastSpace + 1)..].Trim());
}
}
public sealed class NameMatchResult
{
public NameMatchResult(
IReadOnlySet<int> matchedStudentIds,
IReadOnlyList<string> unmatchedNames,
IReadOnlyList<string> ambiguousNames)
{
MatchedStudentIds = matchedStudentIds;
UnmatchedNames = unmatchedNames;
AmbiguousNames = ambiguousNames;
}
public IReadOnlySet<int> MatchedStudentIds { get; }
public IReadOnlyList<string> UnmatchedNames { get; }
public IReadOnlyList<string> AmbiguousNames { get; }
}
+2 -28
View File
@@ -172,31 +172,6 @@ docker-compose logs -f webapp
--- ---
## Data Persistence (SQLite + Backups)
The app stores its SQLite database and runtime config under **`Data/`** (capital D) relative to the content root:
| Path in container | Purpose |
|-------------------|---------|
| `/app/Data/app.db` | Live SQLite database |
| `/app/Data/appsettings.json` | Chapter settings overrides (e.g. CompetitionYear) |
| `/app/Data/backups/pre-rollover-*.db` | Automatic backups created by New Year Rollover |
**Docker volume mount must use capital `Data`:**
```yaml
volumes:
- ./data:/app/Data
```
On Linux, `./data:/app/data` is a **different** path and will not persist the database or rollover backups. After a correct mount, host files appear under `./data/` (for example `./data/app.db` and `./data/backups/`).
Ensure the container user can write to the mounted directory (the image runs as a non-root `APP_UID`). If directory creation for `backups/` fails, year rollover aborts before changing data.
For the rollover workflow itself, see `docs/instructions/year-rollover.md`.
---
## Managing Users ## Managing Users
### Adding a New User ### Adding a New User
@@ -335,11 +310,10 @@ curl http://localhost:8080
- [ ] Set file permissions to 600 - [ ] Set file permissions to 600
- [ ] Configured HTTPS/SSL certificates - [ ] Configured HTTPS/SSL certificates
- [ ] Updated `ASPNETCORE_URLS` for production domain - [ ] Updated `ASPNETCORE_URLS` for production domain
- [ ] Configured volume for database persistence as `./data:/app/Data` (capital D) - [ ] Configured volume for database persistence
- [ ] Verified `app.db` appears on the host under `./data/` after first run
- [ ] Removed development endpoints (already done in code) - [ ] Removed development endpoints (already done in code)
- [ ] Set up log monitoring - [ ] Set up log monitoring
- [ ] Confirmed year-rollover backups write to `./data/backups/` (see `docs/instructions/year-rollover.md`) - [ ] Configured automatic backups
- [ ] Tested login with all user roles - [ ] Tested login with all user roles
- [ ] Tested rate limiting (5 failed attempts) - [ ] Tested rate limiting (5 failed attempts)
- [ ] Documented admin password securely - [ ] Documented admin password securely
-1
View File
@@ -15,7 +15,6 @@ namespace Data
public DbSet<Note> Notes { get; set; } public DbSet<Note> Notes { get; set; }
public DbSet<NoteHistory> NoteHistories { get; set; } public DbSet<NoteHistory> NoteHistories { get; set; }
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; } public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
public DbSet<PrintPreset> PrintPresets { get; set; }
public AppDbContext() public AppDbContext()
{ {
@@ -1,33 +0,0 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Data.Configurations;
public class PrintPresetConfiguration : IEntityTypeConfiguration<PrintPreset>
{
public void Configure(EntityTypeBuilder<PrintPreset> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name)
.IsRequired()
.HasMaxLength(100);
builder.HasIndex(p => p.Name)
.IsUnique();
builder.Property(p => p.EntityType)
.HasConversion<string>()
.HasMaxLength(32)
.IsRequired();
builder.Property(p => p.FiltersJson)
.IsRequired()
.HasColumnType("TEXT");
builder.Property(p => p.TemplateMarkdown)
.IsRequired()
.HasColumnType("TEXT");
}
}
@@ -24,9 +24,6 @@ namespace Data.Configurations
.IsRequired() .IsRequired()
.HasMaxLength(100); .HasMaxLength(100);
builder.Property(s => s.Nickname)
.HasMaxLength(100);
builder.Property(s => s.Email) builder.Property(s => s.Email)
.HasMaxLength(255); .HasMaxLength(255);
@@ -1,60 +0,0 @@
using System;
using Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260830040000_AddPrintPresets")]
public partial class AddPrintPresets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PrintPresets",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
NoteId = table.Column<int>(type: "INTEGER", nullable: false),
EntityType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
FiltersJson = table.Column<string>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PrintPresets", x => x.Id);
table.ForeignKey(
name: "FK_PrintPresets_Notes_NoteId",
column: x => x.NoteId,
principalTable: "Notes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_PrintPresets_Name",
table: "PrintPresets",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PrintPresets_NoteId",
table: "PrintPresets",
column: "NoteId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PrintPresets");
}
}
}
@@ -1,89 +0,0 @@
using System;
using Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260903010000_PrintPresetTemplateMarkdown")]
public partial class PrintPresetTemplateMarkdown : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TemplateMarkdown",
table: "PrintPresets",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.Sql(
"""
UPDATE PrintPresets
SET TemplateMarkdown = COALESCE(
(SELECT Content FROM Notes WHERE Notes.Id = PrintPresets.NoteId),
''
);
""");
migrationBuilder.Sql(
"""
CREATE TABLE "PrintPresets_new" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_PrintPresets" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL,
"TemplateMarkdown" TEXT NOT NULL,
"EntityType" TEXT NOT NULL,
"FiltersJson" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""");
migrationBuilder.Sql(
"""
INSERT INTO "PrintPresets_new" ("Id", "Name", "TemplateMarkdown", "EntityType", "FiltersJson", "UpdatedAt")
SELECT "Id", "Name", "TemplateMarkdown", "EntityType", "FiltersJson", "UpdatedAt"
FROM "PrintPresets";
""");
migrationBuilder.Sql("""DROP TABLE "PrintPresets";""");
migrationBuilder.Sql("""ALTER TABLE "PrintPresets_new" RENAME TO "PrintPresets";""");
migrationBuilder.Sql(
"""CREATE UNIQUE INDEX "IX_PrintPresets_Name" ON "PrintPresets" ("Name");""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
CREATE TABLE "PrintPresets_old" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_PrintPresets" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL,
"NoteId" INTEGER NOT NULL,
"EntityType" TEXT NOT NULL,
"FiltersJson" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""");
migrationBuilder.Sql(
"""
INSERT INTO "PrintPresets_old" ("Id", "Name", "NoteId", "EntityType", "FiltersJson", "UpdatedAt")
SELECT "Id", "Name", 0, "EntityType", "FiltersJson", "UpdatedAt"
FROM "PrintPresets";
""");
migrationBuilder.Sql("""DROP TABLE "PrintPresets";""");
migrationBuilder.Sql("""ALTER TABLE "PrintPresets_old" RENAME TO "PrintPresets";""");
migrationBuilder.Sql(
"""CREATE UNIQUE INDEX "IX_PrintPresets_Name" ON "PrintPresets" ("Name");""");
migrationBuilder.Sql(
"""CREATE INDEX "IX_PrintPresets_NoteId" ON "PrintPresets" ("NoteId");""");
}
}
}
@@ -1,33 +0,0 @@
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");
}
}
}
@@ -177,41 +177,6 @@ namespace Data.Migrations
b.ToTable("EventOccurrences"); b.ToTable("EventOccurrences");
}); });
modelBuilder.Entity("Core.Entities.PrintPreset", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("FiltersJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("TemplateMarkdown")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("PrintPresets");
});
modelBuilder.Entity("Core.Entities.Note", b => modelBuilder.Entity("Core.Entities.Note", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -322,10 +287,6 @@ namespace Data.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("NationalId") b.Property<string>("NationalId")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+6
View File
@@ -8,6 +8,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GoogleSheetsScheduleImport", "tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj", "{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}"
ProjectSection(ProjectDependencies) = postProject ProjectSection(ProjectDependencies) = postProject
{338B8571-2953-4EA3-A680-F000F1431DFF} = {338B8571-2953-4EA3-A680-F000F1431DFF} {338B8571-2953-4EA3-A680-F000F1431DFF} = {338B8571-2953-4EA3-A680-F000F1431DFF}
@@ -35,6 +37,10 @@ Global
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Debug|Any CPU.Build.0 = Debug|Any CPU {B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.Build.0 = Release|Any CPU {B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.Build.0 = Release|Any CPU
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
-8
View File
@@ -12,7 +12,6 @@ public class StudentBuilder
private int _id = _idCounter++; private int _id = _idCounter++;
private string _firstName = "Test"; private string _firstName = "Test";
private string _lastName = "Student"; private string _lastName = "Student";
private string? _nickname = null;
private int _grade = 9; private int _grade = 9;
private string? _email = null; private string? _email = null;
private string? _phoneNumber = null; private string? _phoneNumber = null;
@@ -42,12 +41,6 @@ public class StudentBuilder
return this; return this;
} }
public StudentBuilder WithNickname(string? nickname)
{
_nickname = nickname;
return this;
}
public StudentBuilder WithGrade(int grade) public StudentBuilder WithGrade(int grade)
{ {
_grade = grade; _grade = grade;
@@ -133,7 +126,6 @@ public class StudentBuilder
Id = _id, Id = _id,
FirstName = _firstName, FirstName = _firstName,
LastName = _lastName, LastName = _lastName,
Nickname = _nickname,
Grade = _grade, Grade = _grade,
Email = _email, Email = _email,
PhoneNumber = _phoneNumber, PhoneNumber = _phoneNumber,
-57
View File
@@ -1,57 +0,0 @@
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"));
}
}
@@ -0,0 +1,28 @@
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class GlobalEventDeduplicatorTests
{
[Test]
public void Curfew_same_time_multiple_locations_becomes_one_line_without_location()
{
var lines = new List<ParsedOccurrenceLine>
{
new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room A", 10, 10, 1),
new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room B", 10, 10, 2),
new("Meeting", "April", 9, "9 a.m. - 10 a.m.", "Room A", 5, 5, 1)
};
var result = GlobalEventDeduplicator.Deduplicate(lines);
Assert.Multiple(() =>
{
Assert.That(result.Count, Is.EqualTo(2));
var curfew = result.Single(l => l.Name.Equals("CURFEW", StringComparison.OrdinalIgnoreCase));
Assert.That(curfew.Location, Is.Empty);
Assert.That(result.Any(l => l.Name == "Meeting"), Is.True);
});
}
}
@@ -0,0 +1,40 @@
using Core.Entities;
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class ImportTextEmitterRoundTripTests
{
[Test]
public void EmittedText_Parses_UnderGeneralSchedule()
{
var sheets = new List<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)>
{
("Thursday", "General Schedule", new List<ParsedOccurrenceLine>
{
new(
Name: "Opening Ceremony",
Month: "April",
Day: 3,
TimeRange: "9 a.m. - 10 a.m.",
Location: "Main Hall",
SourceRowStart: 1,
SourceRowEnd: 1,
SourceCol: 1)
})
};
var text = ImportTextEmitter.Build(sheets);
var result = ParserRoundTripValidator.Validate(text, new List<EventDefinition>
{
EventDefinition.GeneralSchedule
});
Assert.Multiple(() =>
{
Assert.That(result.IsSuccess, Is.True, string.Join("; ", result.Errors));
Assert.That(result.TotalParsed, Is.EqualTo(1));
});
}
}
@@ -0,0 +1,29 @@
using Core.Entities;
using Core.Models;
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class OccurrenceDisplayNameReducerTests
{
private static EventDefinition Cyber() =>
new()
{
Id = 1,
Name = "Cybersecurity",
ShortName = "Cyber",
Eligibility = "",
EventFormat = EventFormat.Team
};
[Test]
public void Strips_ms_prefix_and_event_name()
{
var n = OccurrenceDisplayNameReducer.ReduceForSection(
"MS Cybersecurity Semifinals Presentations",
Cyber(),
SchoolLevel.MiddleSchool);
Assert.That(n, Is.EqualTo("Semifinals Presentations"));
}
}
@@ -0,0 +1,39 @@
using Core.Entities;
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class OccurrenceEventMatcherTests
{
private static EventDefinition E(string name, int id) =>
new()
{
Id = id,
Name = name,
ShortName = name,
Eligibility = "",
EventFormat = EventFormat.Team
};
[Test]
public void Ms_prefix_matches_Biotechnology()
{
var events = new List<EventDefinition> { E("Biotechnology", 1), E("Biotechnology Design", 2) };
var ok = OccurrenceEventMatcher.TryMatch("MS Biotechnology Semifinals Interviews April ...", events, out var evt, out var lvl);
Assert.Multiple(() =>
{
Assert.That(ok, Is.True);
Assert.That(evt!.Name, Is.EqualTo("Biotechnology"));
Assert.That(lvl, Is.EqualTo(Core.Models.SchoolLevel.MiddleSchool));
});
}
[Test]
public void No_Clear_prefix_goes_unmatched_or_general_bucket()
{
var events = new List<EventDefinition> { E("Opening Session", 1) };
var ok = OccurrenceEventMatcher.TryMatch("Opening Session April 10 9 a.m.", events, out var evt, out var lvl);
Assert.That(ok, Is.False);
}
}
@@ -0,0 +1,42 @@
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class ScheduleGridExtractorTests
{
[Test]
public void Extract_SingleBlock_OneOccurrenceWithEndTimeFromNextSlot()
{
string?[][] values =
[
[null, "Main Hall"],
["9:00 a.m.", "Opening Ceremony"],
["10:00 a.m.", null]
];
string?[][] bg =
[
[null, null],
[null, null],
[null, null]
];
var grid = new GridSheetModel
{
SheetTitle = "Day1",
Values = values,
BackgroundKeys = bg
};
var warnings = new List<string>();
var lines = ScheduleGridExtractor.Extract(grid, "April", 2, warnings);
Assert.Multiple(() =>
{
Assert.That(lines, Has.Count.EqualTo(1));
Assert.That(lines[0].Name, Is.EqualTo("Opening Ceremony"));
Assert.That(lines[0].Month, Is.EqualTo("April"));
Assert.That(lines[0].Day, Is.EqualTo(2));
Assert.That(lines[0].Location, Is.EqualTo("Main Hall"));
Assert.That(lines[0].TimeRange, Is.EqualTo("9 a.m. - 10 a.m."));
});
}
}
@@ -0,0 +1,14 @@
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class TextNormalizationTests
{
[Test]
public void Collapses_line_separator_and_newlines()
{
var s = "Banquet Room\u2028E";
Assert.That(TextNormalization.ForSheetCell(s), Is.EqualTo("Banquet Room E"));
}
}
-207
View File
@@ -1,207 +0,0 @@
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"));
Assert.That(result.Markdown, Does.Contain(ImportedFieldsTable.Heading));
Assert.That(result.Markdown, Does.Not.Contain("## Imported fields"));
}
[Test]
public void ParseFields_ReadsAnyFieldsHeading()
{
foreach (var heading in new[] { "## Additional fields", "## Imported fields", "## Extra fields" })
{
var markdown = $"""
{heading}
| Field | Value |
| --- | --- |
| Allergies | peanuts |
""";
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Allergies"), Is.EqualTo("peanuts"), heading);
}
var otherHeading = """
## Advisor comments
| Field | Value |
| --- | --- |
| Allergies | peanuts |
""";
Assert.That(ImportedFieldsTable.GetFieldValue(otherHeading, "Allergies"), Is.Null);
}
[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" }));
}
}
@@ -1,9 +1,3 @@
using System.Text;
using Core.Entities;
using Core.Models;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers; namespace Tests.Parsers;
public class AssignmentRequirement_Tests public class AssignmentRequirement_Tests
@@ -22,53 +16,4 @@ public class AssignmentRequirement_Tests
Assert.Pass(); 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);
}
} }
@@ -226,9 +226,10 @@ public class EventOccurrenceParserIssues_Tests
// Verify successful occurrence is still parsed (if any valid lines exist) // Verify successful occurrence is still parsed (if any valid lines exist)
// The "Valid Event" line should parse successfully despite other issues // The "Valid Event" line should parse successfully despite other issues
var validEvent = events.First(e => e.Name == "Valid Event"); var validEvent = events.First(e => e.Name == "Valid Event");
if (result.Occurrences.ContainsKey(validEvent)) var validGroup = new EventOccurrenceParseGroup(validEvent, null);
if (result.Occurrences.ContainsKey(validGroup))
{ {
Assert.That(result.Occurrences[validEvent], Has.Count.EqualTo(1)); Assert.That(result.Occurrences[validGroup], Has.Count.EqualTo(1));
} }
// Note: It's acceptable if the valid event doesn't parse if there are critical issues, // Note: It's acceptable if the valid event doesn't parse if there are critical issues,
// but typically it should still parse since it's a valid line // but typically it should still parse since it's a valid line
@@ -350,11 +351,12 @@ public class EventOccurrenceParserIssues_Tests
// Verify occurrences were parsed correctly (if they were parsed) // Verify occurrences were parsed correctly (if they were parsed)
var testEvent = events.First(e => e.Name == "Test Event"); var testEvent = events.First(e => e.Name == "Test Event");
if (result.Occurrences.ContainsKey(testEvent)) var testGroup = new EventOccurrenceParseGroup(testEvent, null);
if (result.Occurrences.ContainsKey(testGroup))
{ {
Assert.That(result.Occurrences[testEvent], Has.Count.EqualTo(1)); Assert.That(result.Occurrences[testGroup], Has.Count.EqualTo(1));
var occurrence = result.Occurrences[testEvent].First(); var occurrence = result.Occurrences[testGroup].First();
Assert.That(occurrence.Name, Is.EqualTo("Test Event")); Assert.That(occurrence.Name, Is.EqualTo("Test Event"));
Assert.That(occurrence.Location, Is.EqualTo("Room 101")); Assert.That(occurrence.Location, Is.EqualTo("Room 101"));
} }
@@ -362,8 +364,8 @@ public class EventOccurrenceParserIssues_Tests
// The important thing is that the parser doesn't crash and processes the input // The important thing is that the parser doesn't crash and processes the input
// Verify locations are extracted correctly (pattern matching is no longer used) // Verify locations are extracted correctly (pattern matching is no longer used)
var testEventOccurrence = result.Occurrences.ContainsKey(testEvent) var testEventOccurrence = result.Occurrences.ContainsKey(testGroup)
? result.Occurrences[testEvent].FirstOrDefault() ? result.Occurrences[testGroup].FirstOrDefault()
: null; : null;
if (testEventOccurrence != null) if (testEventOccurrence != null)
{ {
@@ -412,10 +414,11 @@ public class EventOccurrenceParserIssues_Tests
// Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14") // Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14")
// General Schedule section uses EventDefinition.GeneralSchedule // General Schedule section uses EventDefinition.GeneralSchedule
Assert.That(result.Occurrences, Does.ContainKey(EventDefinition.GeneralSchedule), var gsGroup = new EventOccurrenceParseGroup(EventDefinition.GeneralSchedule, null);
$"Result should contain GeneralSchedule. Found events: {string.Join(", ", result.Occurrences.Keys.Select(e => e.Name))}"); Assert.That(result.Occurrences, Does.ContainKey(gsGroup),
$"Result should contain GeneralSchedule. Found groups: {string.Join(", ", result.Occurrences.Keys.Select(k => k.EventDefinition.Name))}");
var occurrences = result.Occurrences[EventDefinition.GeneralSchedule]; var occurrences = result.Occurrences[gsGroup];
Assert.That(occurrences, Has.Count.GreaterThan(0), Assert.That(occurrences, Has.Count.GreaterThan(0),
"Should have at least one occurrence in General Schedule"); "Should have at least one occurrence in General Schedule");
@@ -501,7 +504,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert // Assert
Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(events[0])); Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
} }
finally finally
{ {
@@ -528,7 +531,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert // Assert
Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(events[0])); Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
} }
finally finally
{ {
@@ -554,7 +557,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert // Assert
Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(events[0])); Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
} }
finally finally
{ {
@@ -580,7 +583,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert // Assert
Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(events[0])); Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
} }
finally finally
{ {
+84 -59
View File
@@ -107,17 +107,24 @@ public class EventOccurrenceParser_Tests
/// <summary> /// <summary>
/// Writes special events summary to console. /// Writes special events summary to console.
/// </summary> /// </summary>
private static void WriteSpecialEventsSummary(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences) private static void WriteSpecialEventsSummary(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
{ {
Console.WriteLine($"\n--- Special Events Found ---"); Console.WriteLine($"\n--- Special Events Found ---");
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var gs)) static int CountFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
Console.WriteLine($" GeneralSchedule: {gs.Count} occurrences"); occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).Sum(kvp => kvp.Value.Count);
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var mtc))
Console.WriteLine($" MeetTheCandidates: {mtc.Count} occurrences"); var gs = CountFor(occurrences, EventDefinition.GeneralSchedule);
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var com)) if (gs > 0)
Console.WriteLine($" ChapterOfficerMeeting: {com.Count} occurrences"); Console.WriteLine($" GeneralSchedule: {gs} occurrences");
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var vdm)) var mtc = CountFor(occurrences, EventDefinition.MeetTheCandidates);
Console.WriteLine($" VotingDelegateMeeting: {vdm.Count} occurrences"); if (mtc > 0)
Console.WriteLine($" MeetTheCandidates: {mtc} occurrences");
var com = CountFor(occurrences, EventDefinition.ChapterOfficerMeeting);
if (com > 0)
Console.WriteLine($" ChapterOfficerMeeting: {com} occurrences");
var vdm = CountFor(occurrences, EventDefinition.VotingDelegateMeeting);
if (vdm > 0)
Console.WriteLine($" VotingDelegateMeeting: {vdm} occurrences");
} }
/// <summary> /// <summary>
@@ -237,43 +244,26 @@ public class EventOccurrenceParser_Tests
/// <summary> /// <summary>
/// Writes special events to console output. /// Writes special events to console output.
/// </summary> /// </summary>
private static void WriteSpecialEvents(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences) private static void WriteSpecialEvents(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
{ {
static List<Core.Entities.EventOccurrence> ListFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).SelectMany(kvp => kvp.Value).ToList();
Console.WriteLine("General Schedule"); Console.WriteLine("General Schedule");
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var generalSchedule)) foreach (var eo in ListFor(occurrences, EventDefinition.GeneralSchedule).OrderBy(o => o.StartTime))
{ Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
foreach (var eo in generalSchedule.OrderBy(occurrence => occurrence.StartTime))
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
Console.WriteLine("Meet the Candidates"); Console.WriteLine("Meet the Candidates");
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var meetTheCandidates)) foreach (var eo in ListFor(occurrences, EventDefinition.MeetTheCandidates))
{ Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
foreach (var eo in meetTheCandidates)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
Console.WriteLine("Chapter Officer Meeting"); Console.WriteLine("Chapter Officer Meeting");
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var chapterOfficerMeeting)) foreach (var eo in ListFor(occurrences, EventDefinition.ChapterOfficerMeeting))
{ Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
foreach (var eo in chapterOfficerMeeting)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
Console.WriteLine("Voting Delegate Meeting"); Console.WriteLine("Voting Delegate Meeting");
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var votingDelegateMeeting)) foreach (var eo in ListFor(occurrences, EventDefinition.VotingDelegateMeeting))
{ Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
foreach (var eo in votingDelegateMeeting)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
} }
#endregion #endregion
@@ -290,7 +280,11 @@ public class EventOccurrenceParser_Tests
{ {
Console.WriteLine($"{@event.Name}"); Console.WriteLine($"{@event.Name}");
if (!dictionary.TryGetValue(@event, out var eventOccurrences)) var eventOccurrences = dictionary
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
.SelectMany(kvp => kvp.Value)
.ToList();
if (eventOccurrences.Count == 0)
{ {
Console.WriteLine($"!!! eventDefinition not found {@event.Name}"); Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue; continue;
@@ -320,7 +314,11 @@ public class EventOccurrenceParser_Tests
{ {
Console.WriteLine($"{@event.Name}"); Console.WriteLine($"{@event.Name}");
if (!dictionary.TryGetValue(@event, out var eventOccurrences)) var eventOccurrences = dictionary
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
.SelectMany(kvp => kvp.Value)
.ToList();
if (eventOccurrences.Count == 0)
{ {
Console.WriteLine($"!!! eventDefinition not found {@event.Name}"); Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue; continue;
@@ -447,13 +445,13 @@ public class EventOccurrenceParser_Tests
// Total expected MS occurrences: 16 // Total expected MS occurrences: 16
var msEventCount = 0; var msEventCount = 0;
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var csOccurrences)) if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var csOccurrences))
msEventCount += csOccurrences.Count; msEventCount += csOccurrences.Count;
if (coding != null && result.Occurrences.TryGetValue(coding, out var codingOccurrences)) if (coding != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(coding, SchoolLevel.MiddleSchool), out var codingOccurrences))
msEventCount += codingOccurrences.Count; msEventCount += codingOccurrences.Count;
if (communityServiceVideo != null && result.Occurrences.TryGetValue(communityServiceVideo, out var csvOccurrences)) if (communityServiceVideo != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(communityServiceVideo, SchoolLevel.MiddleSchool), out var csvOccurrences))
msEventCount += csvOccurrences.Count; msEventCount += csvOccurrences.Count;
if (constructionChallenge != null && result.Occurrences.TryGetValue(constructionChallenge, out var ccOccurrences)) if (constructionChallenge != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(constructionChallenge, SchoolLevel.MiddleSchool), out var ccOccurrences))
msEventCount += ccOccurrences.Count; msEventCount += ccOccurrences.Count;
// When no school level is set, HS events should be processed (not skipped) // When no school level is set, HS events should be processed (not skipped)
@@ -512,7 +510,7 @@ public class EventOccurrenceParser_Tests
Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format"); Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format");
// Verify specific locations are parsed // Verify specific locations are parsed
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var childrensStoriesOccurrences)) if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var childrensStoriesOccurrences))
{ {
var locations = childrensStoriesOccurrences var locations = childrensStoriesOccurrences
.Select(eo => eo.Location) .Select(eo => eo.Location)
@@ -563,20 +561,17 @@ public class EventOccurrenceParser_Tests
"HS section header should NOT be in SkippedSectionHeaders when no school level is set"); "HS section header should NOT be in SkippedSectionHeaders when no school level is set");
// With no school level filtering, both MS and HS events are processed // With no school level filtering, both MS and HS events are processed
if (result.Occurrences.TryGetValue(biotechnology, out var allOccurrences)) result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.MiddleSchool), out var msOccurrences);
{ result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.HighSchool), out var hsOccurrences);
// With no school level set, we process all occurrences (both MS and HS) msOccurrences ??= [];
// Expected: 2 MS occurrences (Submit Entry, Judging) + 3 HS occurrences (Submit Entry, Judging, Pick-up) = 5 total hsOccurrences ??= [];
Assert.That(allOccurrences, Has.Count.EqualTo(5), Assert.That(msOccurrences, Has.Count.EqualTo(2), "MS section should have 2 occurrences");
"Should have all 5 occurrences (2 MS + 3 HS) when no school level is set. " + Assert.That(hsOccurrences, Has.Count.EqualTo(3), "HS section should have 3 occurrences");
$"Found {allOccurrences.Count} occurrences total.");
var allNames = msOccurrences.Concat(hsOccurrences).Select(o => o.Name).ToList();
// Verify all expected occurrence names are present Assert.That(allNames, Does.Contain("Submit Entry"));
var occurrenceNames = allOccurrences.Select(o => o.Name).ToList(); Assert.That(allNames, Does.Contain("Judging"));
Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences"); Assert.That(allNames, Does.Contain("Pick-up"));
Assert.That(occurrenceNames, Does.Contain("Judging"), "Should have Judging occurrences");
Assert.That(occurrenceNames, Does.Contain("Pick-up"), "Should have Pick-up occurrence");
}
Assert.Pass("All events processed when no school level is set"); Assert.Pass("All events processed when no school level is set");
} }
@@ -585,4 +580,34 @@ public class EventOccurrenceParser_Tests
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile); EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
} }
} }
[Test]
public void Parse_SameEvent_HS_then_MS_ProducesTwoGroups()
{
var testContent = "Prepared Speech - HS\n" +
"Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4\n" +
"Prepared Speech - MS\n" +
"Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10";
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Prepared Speech") };
var parser = new EventOccurrenceParser(tempFile, events);
try
{
var result = parser.Parse();
Assert.That(result.Issues, Has.Count.EqualTo(0));
var def = events[0];
Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.HighSchool), out var hsList), Is.True);
Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.MiddleSchool), out var msList), Is.True);
Assert.That(hsList, Has.Count.EqualTo(1));
Assert.That(msList, Has.Count.EqualTo(1));
Assert.That(hsList![0].Name, Does.Contain("Extemporaneous"));
Assert.That(msList![0].Name, Does.Contain("Prelims"));
}
finally
{
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
}
}
} }
@@ -1,41 +0,0 @@
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);
}
}
@@ -1,275 +0,0 @@
using System.Text;
using Core.Entities;
using Core.Models;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers;
[TestFixture]
public class StudentEventRankingParser_Tests
{
private Student _aria = null!;
private EventDefinition _videoGame = null!;
private EventDefinition _coding = null!;
private EventDefinition _jss = null!;
private EventDefinition _digitalPhoto = null!;
private EventDefinition _biotech = null!;
private EventDefinition _inventions = null!;
private EventDefinition _techBowl = null!;
private EventDefinition _techDesign = null!;
private List<Student> _students = null!;
private List<EventDefinition> _events = null!;
[SetUp]
public void SetUp()
{
BuilderExtensions.ResetAllBuilders();
_aria = StudentBuilder.Create("Aria", "Chittenden").Build();
_videoGame = EventDefinitionBuilder.Team("Video Game Design", 2, 6).WithShortName("Video Game").Build();
_coding = EventDefinitionBuilder.Team("Coding", 2, 2).WithShortName("Coding").Build();
_jss = EventDefinitionBuilder.Team("Junior Solar Sprint", 2, 4).WithShortName("JSS").Build();
_digitalPhoto = EventDefinitionBuilder.Individual("Digital Photography").WithShortName("Digital Photo").Build();
_biotech = EventDefinitionBuilder.Team("Biotechnology", 2, 6).WithShortName("Biotech").Build();
_inventions = EventDefinitionBuilder.Team("Inventions & Innovations", 3, 6).WithShortName("I&I").Build();
_techBowl = EventDefinitionBuilder.Team("Tech Bowl", 3, 3).WithShortName("Tech Bowl").Build();
_techDesign = EventDefinitionBuilder.Team("Technical Design", 2, 2).WithShortName("Tech Design").Build();
_students = [_aria];
_events = [_videoGame, _coding, _jss, _digitalPhoto, _biotech, _inventions, _techBowl, _techDesign];
}
[Test]
public void Parse_LastCommaFirst_MatchesStudent()
{
var result = ParseCsv("""
Student Name,1
"Chittenden, Aria",Coding
""", _students, _events);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_coding));
Assert.That(result.Matches[0].Ranking.Rank, Is.EqualTo(1));
}
[Test]
public void Parse_FirstLast_MatchesStudent()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Coding
""", _students, _events);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
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()
{
var result = ParseCsv("""
Student Name,1,2,3
Aria Chittenden,Video Game,JSS,I&I
""", _students, _events);
Assert.That(result.Issues, Is.Empty);
Assert.That(result.Matches, Has.Count.EqualTo(3));
Assert.That(result.Matches.Select(m => m.Ranking.EventDefinition), Is.EqualTo(new[] { _videoGame, _jss, _inventions }));
}
[Test]
public void Parse_NearMissFullName_MatchesEvent()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Digital Photo
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_digitalPhoto));
Assert.That(result.Matches[0].EventScore, Is.GreaterThanOrEqualTo(StudentEventRankingParser.EventMatchThreshold));
}
[Test]
public void Parse_BiotechShortName_MatchesBiotechnology()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Biotech
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_biotech));
}
[Test]
public void Parse_UnmatchedStudent_IsReported()
{
var result = ParseCsv("""
Student Name,1
Nobody Here,Coding
""", _students, _events);
Assert.That(result.Matches, Is.Empty);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedStudent));
Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here"));
}
[Test]
public void Parse_SolarRacer_MatchesJuniorSolarSprint()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Solar Racer
""", _students, _events);
Assert.That(result.Issues, Is.Empty);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_jss));
}
[TestCase("Solar Racer", "Junior Solar Sprint")]
[TestCase("Solar Race", "Junior Solar Sprint")]
[TestCase("Challenging Tech", "Challenging Technology Issues")]
[TestCase("Digital Photo", "Digital Photography")]
[TestCase("Forensics", "Forensic Technology")]
[TestCase("Micro Controller", "Microcontroller Design")]
[TestCase("Med Tech", "Medical Technology")]
[TestCase("Innovations & Inventions", "Inventions & Innovations")]
[TestCase("Inventions and Innovations", "Inventions & Innovations")]
[TestCase("Systems Control Tech", "System Control Technology")]
[TestCase("Systems Control Technology", "System Control Technology")]
[TestCase("Structural Eng", "Structural Engineering")]
[TestCase("Drone Challenge", "Drone Challenge (UAV)")]
[TestCase("Robotics", "TSA Robotics")]
[TestCase("Audio Podcast", "Audio Podcasting")]
public void Parse_KnownAlias_MatchesOfficialEvent(string alias, string officialName)
{
var events = TestEntityHandler.GetEvents().ToList();
events.AddRange(
[
EventDefinitionBuilder.Team("Drone Challenge (UAV)", 2, 6).WithShortName("Drone").Build(),
EventDefinitionBuilder.Team("TSA Robotics", 2, 6).WithShortName("Robotics").Build(),
EventDefinitionBuilder.Team("Audio Podcasting", 2, 6).WithShortName("Podcasting").Build()
]);
var result = ParseCsv($"""
Student Name,1
Aria Chittenden,{alias}
""", _students, events);
Assert.That(result.Issues, Is.Empty, $"Expected '{alias}' to match '{officialName}'");
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition.Name, Is.EqualTo(officialName));
}
[Test]
public void Parse_UnmatchedEvent_IsReported()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Underwater Basket Weaving
""", _students, _events);
Assert.That(result.Matches, Is.Empty);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedEvent));
Assert.That(result.Issues[0].RawEventName, Is.EqualTo("Underwater Basket Weaving"));
}
[Test]
public void Parse_AmbiguousEvent_IsReported()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Tech
""", _students, _events);
Assert.That(result.Matches, Is.Empty);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.AmbiguousEvent));
}
[Test]
public void Parse_RankColumnsBeyondSix_AreRead()
{
var result = ParseCsv("""
Student Name,1,2,3,4,5,6,7
Aria Chittenden,Coding,,,,,,JSS
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(2));
Assert.That(result.Matches.Select(m => m.Ranking.Rank), Is.EqualTo(new[] { 1, 7 }));
Assert.That(result.Matches[1].Ranking.EventDefinition, Is.SameAs(_jss));
}
[Test]
public void Parse_DuplicateEvent_IsReported()
{
var result = ParseCsv("""
Student Name,1,2
Aria Chittenden,Coding,Coding
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.DuplicateEvent));
}
[Test]
public void Parse_MissingStudentNameHeader_IsError()
{
var result = ParseCsv("""
Name,1
Aria Chittenden,Coding
""", _students, _events);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Errors, Is.Not.Empty);
}
[Test]
public void Parse_2024RankingsFile_MatchesKnownStudentsAndEvents()
{
var events = TestEntityHandler.GetEvents();
var students = TestEntityHandler.GetStudents(events);
var rankings = TestEntityHandler.GetStudentEventRankings(students, events);
Assert.That(students, Has.Length.EqualTo(29));
Assert.That(rankings, Is.Not.Empty);
Assert.That(rankings.Select(r => r.Student.FirstNameLastName).Distinct().Count(), Is.EqualTo(29));
Assert.That(rankings.All(r => r.EventDefinition is not null), Is.True);
Assert.That(rankings.All(r => r.Rank is >= 1 and <= 10), Is.True);
Assert.That(rankings.Count(r => r.Student.FirstName == "First26"), Is.EqualTo(7));
}
private static StudentEventRankingParseResult ParseCsv(
string csv,
ICollection<Student> students,
ICollection<EventDefinition> events)
{
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
using var parser = new StudentEventRankingParser(reader);
return parser.Parse(students, events);
}
}
@@ -1,30 +0,0 @@
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));
}
}
@@ -1,212 +0,0 @@
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_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()
{
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?>());
}
}
-26
View File
@@ -1,7 +1,3 @@
using System.Text;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers; namespace Tests.Parsers;
public class TeamParser_Tests public class TeamParser_Tests
@@ -41,26 +37,4 @@ 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));
}
} }
+3 -3
View File
@@ -69,9 +69,9 @@ public static class TestEntityHandler
public static StudentEventRanking[] GetStudentEventRankings(Student[] students, EventDefinition[] events) public static StudentEventRanking[] GetStudentEventRankings(Student[] students, EventDefinition[] events)
{ {
var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2024 Student Event Rankings.csv"); var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2025 Student Event Rankings.csv");
using var rankingParser = new StudentEventRankingParser(fileInfo); var rankingParser = new StudentEventRankingParser(fileInfo);
return [.. rankingParser.Parse(students, events).Rankings]; return rankingParser.Parse(students, events);
} }
} }
@@ -1,212 +0,0 @@
General Session
Registration April 9 4:00 p.m. - 6:00 p.m. Banquet Room E
TECHSPO April 9 4:00 p.m. - 6:00 p.m. Main Hallway
Tennessee TSA Store April 9 4:00 p.m. - 8:00 p.m. Meeting Room 1
Curfew April 9 11:00 p.m. All Conference Locations
Static Event Turn-In April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Help Desk April 10 10:00 a.m. - 5:00 p.m. CCC Lobby
Mandatory Advisor Meeting April 10 10:00 a.m. - 11:00 a.m. Banquet Room E
TECHSPO April 10 10:00 a.m. - 4:00 p.m. Main Hallway
Tennessee TSA Store April 10 10:00 a.m. - 11:30 a.m. Meeting Room 1
Branching Out Workshop April 10 11:30 a.m. - 12:30 p.m. Banquet Room E
Finding Your Roots Workshop April 10 12:30 p.m. - 1:30 p.m. Banquet Room E
Tennessee TSA Store April 10 12:30 p.m. - 4:00 p.m. Meeting Room 1
Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway
Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway
Chapter Officer Meeting April 10 2:30 p.m. - 3:00 p.m. Banquet Room E
Board Game Night April 10 8:00 p.m. - 10:00 p.m. Meeting Room 14
Dance April 10 8:00 p.m. - 10:00 p.m. Exhibit Hall D
Senior Social April 10 8:00 p.m. - 8:30 p.m. Banquet Room E
Curfew April 10 11:00 p.m. All Conference Locations
Voting Delegate Meeting April 11 8:00 a.m. - 9:00 a.m. Banquet Room E
Help Desk April 11 9:00 a.m. - 5:00 p.m. CCC Lobby
Tennessee TSA Store April 11 9:00 a.m. - 11:30 a.m. Meeting Room 1
Tennessee TSA Store April 11 12:30 p.m. - 4:00 p.m. Meeting Room 1
Business Meeting April 11 5:30 p.m. - 6:30 p.m. Exhibit Hall A
Creating Community through Communication Workshop April 11 11:00 p.m. - 12:00 p.m. Banquet Room E
Curfew April 11 11:00 p.m. All Conference Locations
Awards Ceremony April 12 8:30 a.m. - 12:00 p.m. Exhibit Hall A
Audio Podcasting – MS
Prompt Pick-Up April 9 6:00 p.m. Online
Semifinalist Submissions Due April 11 9:00 a.m. Online
Biotechnology – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 9:30 a.m. - 11:30 a.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
CAD Foundations – MS
Setup, Event, & Interviews April 10 10:30 a.m. - 2:00 p.m. Meeting Room 6
Career Prep – MS
Semifinalist Sign-ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Interviews April 10 1:00 p.m. - 3:00 p.m. Meeting Room 10
Challenging Technology Issues – MS
*NOTE: Preliminary Round Time Sign-ups listed as April 8 in Yapp — verify with event coordinator
Preliminary Round Time Sign-ups April 8 6:00 p.m. - 7:00 p.m. Online
Prelims Presentation – Holding Room April 10 10:30 a.m. - 1:00 p.m. Meeting Room 7
Prelims Presentation – Presentation April 10 10:30 a.m. - 1:00 p.m. Meeting Room 8
Semifinalist Round Time Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentation – Holding Room April 11 9:30 a.m. - 11:00 a.m. Meeting Room 9
Semifinalist Presentation – Presentation April 11 9:30 a.m. - 11:00 a.m. Meeting Room 10
Children's Stories – MS
Submit Entry April 9 6:00 p.m. - 7:00 p.m. Banquet Hall G
Judging April 10 9:00 a.m. - 5:00 p.m. Meeting Room 13
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Reading/Interviews April 11 1:00 p.m. - 4:00 p.m. Meeting Room 18
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Coding – MS
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
On-Site Event April 11 12:30 p.m. - 3:00 p.m. Meeting Room 19
Community Service Video – MS
Semifinalist Sign-ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 10 3:00 p.m. - 4:00 p.m. Meeting Room 17
Construction Challenge – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 10:30 a.m. - 12:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Cybersecurity – MS
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 11 3:30 p.m. - 4:30 p.m. Meeting Room 19
Data Science and Analytics – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Time Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Preparation April 11 3:00 p.m. - 4:30 p.m. Meeting Room 4
Semifinalist Presentations April 11 3:00 p.m. - 4:30 p.m. Meeting Room 5
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Digital Photography – MS
Semifinalist Setup, Onsite Problem April 10 10:00 a.m. - 1:00 p.m. Meeting Room 19
Semifinalist Time Sign-Up April 10 10:00 a.m. - 1:00 p.m. Meeting Room 19
Semifinalist Interviews April 10 4:00 p.m. - 5:00 p.m. Meeting Room 16
Dragster – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall B
Time Trials April 10 10:00 a.m. - 11:00 a.m. Exhibit Hall B
Semifinalist Interview Sign-ups April 10 12:00 p.m. - 12:15 p.m. Exhibit Hall B
Semifinalist Interviews April 10 12:30 p.m. - 1:30 p.m. Exhibit Hall B
Semifinalist Races April 10 2:30 p.m. - 3:00 p.m. Exhibit Hall B
Project Pick-up April 10 5:00 p.m. - 5:30 p.m. Exhibit Hall B
Flight – MS
Submit Entry & Sign Up April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Preliminary Round Testing April 10 10:00 a.m. - 11:30 a.m. Exhibit Hall C
Semifinalist Construction and Flights April 10 1:00 p.m. - 3:30 p.m. Exhibit Hall C
All Event Materials Picked Up April 10 4:00 p.m. - 4:30 p.m. Exhibit Hall C
Forensic Technology – MS
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 11 1:30 p.m. - 4:30 p.m. Meeting Room 9
Inventions and Innovations – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 11:30 a.m. - 1:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Leadership Strategies – MS
Preliminary Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Preliminary Presentation – Delivery April 10 1:30 p.m. - 4:00 p.m. Meeting Room 8
Preliminary Presentation – Holding Room April 10 1:30 p.m. - 4:00 p.m. Meeting Room 7
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinals Presentation – Delivery April 11 11:30 a.m. - 1:00 p.m. Meeting Room 10
Semifinals Presentation – Holding Room April 11 11:30 a.m. - 1:00 p.m. Meeting Room 9
Mass Production – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 12:30 p.m. - 2:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Mechanical Engineering – MS
Submit Entry & Sign Up April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Design Trial April 10 3:00 p.m. - 4:00 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Medical Technology – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 1:30 p.m. - 3:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Microcontroller Design – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Presentation Time Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Presentations/Interviews April 11 2:00 p.m. - 4:30 p.m. Exhibit Hall C
Off the Grid – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 2:30 p.m. - 4:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Prepared Speech – MS
Preliminary Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Preliminary Presentations April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 11 1:30 p.m. - 3:00 p.m. Meeting Room 10
Problem Solving – MS
Peer Kit Check April 11 12:00 p.m. - 12:30 p.m. Exhibit Hall C
Onsite Problem April 11 12:30 p.m. - 3:00 p.m. Exhibit Hall C
Promotional Marketing – MS
Semifinalist Setup, Onsite Problem April 11 9:30 a.m. - 11:00 a.m. Meeting Room 6
TSA Robotics – MS
Preliminary Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Preliminary Round Review April 10 2:00 p.m. - 3:30 p.m. Exhibit Hall B
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Interviews April 11 10:30 a.m. - 11:30 a.m. Exhibit Hall B
STEM Animation – MS
Semifinalist Sign-ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 11 9:30 a.m. - 11:00 a.m. Meeting Room 16
Structural Engineering – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall B
Semifinalist Build April 11 9:00 a.m. - 12:00 p.m. Exhibit Hall B
Semifinalist Testing April 11 3:30 p.m. - 4:00 p.m. Exhibit Hall B
Project Pick-up April 11 4:30 p.m. - 5:00 p.m. Exhibit Hall B
System Control Technology – MS
Set-up, Performance, and Judging April 10 10:00 a.m. - 2:00 p.m. Meeting Room 3
Tech Bowl – MS
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
Bracket Released April 10 6:00 p.m. Online
Semifinalist Competition April 11 9:00 a.m. - 1:00 p.m. Banquet Hall G
Semifinalist Holding April 11 9:00 a.m. - 1:00 p.m. Banquet Hall H
Technical Design – MS
Prompt Release April 10 10:00 a.m. Online
Solution Submit April 11 9:00 a.m. - 10:00 a.m. Online
Judging April 11 10:00 a.m. - 2:00 p.m. CRC
Video Game Design – MS
Semifinalist Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 16
Website Design – MS
Semifinalist Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Interviews April 10 10:00 a.m. - 12:00 p.m. Meeting Room 16
@@ -1,129 +0,0 @@
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class MarkdownTableStencil_Tests
{
private const string Roster = """
| Name | Grade |
| --- | --- |
| {{LastNameFirstName}} | {{Grade}} |
""";
[Test]
public void TryParse_SingleTable_SplitsPrefixHeaderBodySuffix()
{
var template = """
# Rankings
| Name | 1st |
| --- | --- |
| {{LastNameFirstName}} | {{Rank1}} |
{{Legend}}
""";
Assert.That(MarkdownTableStencil.TryParse(template, out var stencil), Is.True);
Assert.That(stencil!.Prefix, Does.Contain("# Rankings"));
Assert.That(stencil.Header, Does.Contain("| Name | 1st |"));
Assert.That(stencil.Header, Does.Contain("| --- | --- |"));
Assert.That(stencil.Body.Trim(), Is.EqualTo("| {{LastNameFirstName}} | {{Rank1}} |"));
Assert.That(stencil.Suffix, Does.Contain("{{Legend}}"));
}
[Test]
public void TryParse_NoTable_Fails()
{
Assert.That(MarkdownTableStencil.TryParse("# Interview\n\n{{FirstName}}", out _), Is.False);
}
[Test]
public void TryParse_TwoTables_Fails()
{
var template = """
| Grade | Time |
| --- | --- |
| {{Grade}} | {{Interview Time}} |
| 1st | 2nd |
| --- | --- |
| {{Rank1}} | {{Rank2}} |
""";
Assert.That(MarkdownTableStencil.TryParse(template, out _), Is.False);
}
[Test]
public void TryParse_HeaderOnly_Fails()
{
Assert.That(MarkdownTableStencil.TryParse("| Name |\n| --- |\n", out _), Is.False);
}
[Test]
public void Stitch_TwoStudents_HeaderOnceAndTwoBodyRows()
{
Assert.That(MarkdownTableStencil.TryParse(Roster, out var stencil), Is.True);
var merged = stencil!.Stitch(
string.Empty,
[
NoteTemplateMerger.Merge(stencil.Body, Map(("LastNameFirstName", "Cole, Aria"), ("Grade", "6"))),
NoteTemplateMerger.Merge(stencil.Body, Map(("LastNameFirstName", "Dean, Lucas"), ("Grade", "7")))
],
string.Empty);
Assert.That(CountOccurrences(merged, "| Name | Grade |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| --- | --- |"), Is.EqualTo(1));
Assert.That(merged, Does.Contain("| Cole, Aria | 6 |"));
Assert.That(merged, Does.Contain("| Dean, Lucas | 7 |"));
}
[Test]
public void Stitch_AttributesRow_RepeatsPerRecord()
{
var template = """
| 1st | 2nd |
| --- | --- |
| {{Rank1}} | {{Rank2}} |
| {{Rank1.Attributes}} | {{Rank2.Attributes}} |
""";
Assert.That(MarkdownTableStencil.TryParse(template, out var stencil), Is.True);
var merged = stencil!.Stitch(
string.Empty,
[
NoteTemplateMerger.Merge(stencil.Body, Map(("Rank1", "Coding"), ("Rank2", "Flight"), ("Rank1.Attributes", "I"), ("Rank2.Attributes", "T"))),
NoteTemplateMerger.Merge(stencil.Body, Map(("Rank1", "Drone"), ("Rank2", "Robotics"), ("Rank1.Attributes", "R"), ("Rank2.Attributes", "O")))
],
string.Empty);
Assert.That(CountOccurrences(merged, "| Coding | Flight |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| I | T |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| Drone | Robotics |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| R | O |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| --- | --- |"), Is.EqualTo(1));
}
private static Dictionary<string, string> Map(params (string Key, string Value)[] pairs)
{
var tokens = PrintTokenMap.Create();
foreach (var (key, value) in pairs)
tokens[key] = value;
return tokens;
}
private static int CountOccurrences(string haystack, string needle)
{
var count = 0;
var index = 0;
while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += needle.Length;
}
return count;
}
}
-184
View File
@@ -1,184 +0,0 @@
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class NoteTemplateMerger_Tests
{
[Test]
public void Merge_ReplacesKnownTokens()
{
var tokens = PrintTokenMap.Create();
tokens["FirstName"] = "Aria";
tokens["LastName"] = "Cole";
var result = NoteTemplateMerger.Merge("Hello {{FirstName}} {{LastName}}", tokens);
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()
{
var tokens = PrintTokenMap.Create();
tokens["Chapter.Name"] = "North TSA";
tokens["Interview Time"] = "3:20-3:35";
var result = NoteTemplateMerger.Merge(
"{{Chapter.Name}} at {{Interview Time}}",
tokens);
Assert.That(result, Is.EqualTo("North TSA at 3:20-3:35"));
}
[Test]
public void Merge_IsCaseAndWhitespaceTolerant()
{
var tokens = PrintTokenMap.Create();
tokens["FirstName"] = "Aria";
var result = NoteTemplateMerger.Merge("{{ firstname }}", tokens);
Assert.That(result, Is.EqualTo("Aria"));
}
[Test]
public void Merge_LeavesUnknownTokensInPlace()
{
var tokens = PrintTokenMap.Create();
tokens["FirstName"] = "Aria";
var result = NoteTemplateMerger.Merge("{{FirstName}} {{Missing}}", tokens);
Assert.That(result, Is.EqualTo("Aria {{Missing}}"));
}
[Test]
public void Merge_KnownEmptyBecomesBlank()
{
var tokens = PrintTokenMap.Create();
tokens["Interview Time"] = string.Empty;
var result = NoteTemplateMerger.Merge("Time: {{Interview Time}}.", tokens);
Assert.That(result, Is.EqualTo("Time: ."));
}
[Test]
public void Merge_EmptyTemplate_IsEmpty()
{
Assert.That(NoteTemplateMerger.Merge(null, PrintTokenMap.Create()), Is.EqualTo(string.Empty));
Assert.That(NoteTemplateMerger.Merge("", PrintTokenMap.Create()), Is.EqualTo(string.Empty));
}
[Test]
public void Merge_PageBreak_IsSentinel()
{
var result = NoteTemplateMerger.Merge(
"Above\n{{PageBreak}}\nBelow",
PrintTokenMap.Create());
Assert.That(result, Does.Contain(NoteTemplateMerger.PageBreakSentinel));
Assert.That(result, Does.Not.Contain("{{PageBreak}}"));
}
[Test]
public void ApplyLayout_ReplacesSentinelAndParagraphWrap()
{
var raw = NoteTemplateMerger.ApplyLayout(
$"x{NoteTemplateMerger.PageBreakSentinel}y");
var wrapped = NoteTemplateMerger.ApplyLayout(
$"<p>{NoteTemplateMerger.PageBreakSentinel}</p>");
Assert.That(raw, Is.EqualTo($"x{NoteTemplateMerger.PageBreakHtml}y"));
Assert.That(wrapped, Is.EqualTo(NoteTemplateMerger.PageBreakHtml));
}
[Test]
public void Merge_AnswerSpace_IsSentinel()
{
var result = NoteTemplateMerger.Merge(
"Q?\n{{AnswerSpace}}\nNext",
PrintTokenMap.Create());
Assert.That(result, Does.Contain(NoteTemplateMerger.AnswerSpaceSentinel));
Assert.That(result, Does.Not.Contain("{{AnswerSpace}}"));
}
[Test]
public void ApplyLayout_ReplacesAnswerSpaceSentinelAndParagraphWrap()
{
var raw = NoteTemplateMerger.ApplyLayout(
$"x{NoteTemplateMerger.AnswerSpaceSentinel}y");
var wrapped = NoteTemplateMerger.ApplyLayout(
$"<p>{NoteTemplateMerger.AnswerSpaceSentinel}</p>");
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"));
}
}
-77
View File
@@ -1,77 +0,0 @@
using Core.Entities;
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class PrintFieldCatalog_Tests
{
[Test]
public void BuiltInFor_StudentIncludesChapterAndStudentTokens()
{
var tokens = PrintFieldCatalog.BuiltInFor(PrintEntityType.Student);
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"));
Assert.That(PrintFieldCatalog.StudentRanks1To6, Does.Contain("Rank1"));
Assert.That(PrintFieldCatalog.StudentRanks1To6, Does.Contain("Rank6.Attributes"));
Assert.That(PrintFieldCatalog.StudentRanks1To6, Does.Not.Contain("Rank7"));
Assert.That(PrintFieldCatalog.StudentRanks7To10, Does.Contain("Rank7"));
Assert.That(PrintFieldCatalog.StudentRanks7To10, Does.Contain("Rank10.ShortName"));
Assert.That(PrintFieldCatalog.StudentRanks7To10, Does.Not.Contain("Rank6"));
Assert.That(PrintFieldCatalog.StudentRanks1To6, Has.Length.EqualTo(6 * 3));
Assert.That(PrintFieldCatalog.StudentRanks7To10, Has.Length.EqualTo(4 * 3));
}
[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.Team), Does.Contain("NationalEligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("Eligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("RegionalTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("StateTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalEvent"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("NationalEligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("Eligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("StateTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain(NoteTemplateMerger.RankedStudentsToken));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("EventAttributes"));
}
[Test]
public void Layout_IncludesPageBreakAndAnswerSpace()
{
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]
public void EntityTokens_MatchesCatalogArrays()
{
Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Student), Is.EqualTo(PrintFieldCatalog.Student));
Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Team), Is.EqualTo(PrintFieldCatalog.Team));
Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Event), Is.EqualTo(PrintFieldCatalog.Event));
}
}
@@ -1,54 +0,0 @@
using Core.Entities;
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class PrintPresetFilters_Tests
{
[Test]
public void JsonRoundTrip_PreservesStudentAndImportedFilters()
{
var filters = new PrintPresetFilters
{
Grade = 9,
TsaYear = 1,
IsOfficer = true,
RegionalOnly = true,
EventFormat = EventFormat.Team,
NewPagePerRecord = false,
FontSizePt = 14,
AnswerSpaceLines = 4
};
var restored = PrintPresetFilters.FromJson(filters.ToJson());
Assert.That(restored.Grade, Is.EqualTo(9));
Assert.That(restored.TsaYear, Is.EqualTo(1));
Assert.That(restored.IsOfficer, Is.True);
Assert.That(restored.RegionalOnly, Is.True);
Assert.That(restored.EventFormat, Is.EqualTo(EventFormat.Team));
Assert.That(restored.NewPagePerRecord, Is.False);
Assert.That(restored.FontSizePt, Is.EqualTo(14));
Assert.That(restored.AnswerSpaceLines, Is.EqualTo(4));
}
[Test]
public void FromJson_EmptyOrInvalid_ReturnsDefaults()
{
Assert.That(PrintPresetFilters.FromJson(null).TsaYear, Is.Null);
Assert.That(PrintPresetFilters.FromJson("{}").Grade, Is.Null);
Assert.That(PrintPresetFilters.FromJson("{}").NewPagePerRecord, Is.True);
Assert.That(PrintPresetFilters.FromJson("{}").FontSizePt, Is.EqualTo(PrintPresetFilters.DefaultFontSizePt));
Assert.That(PrintPresetFilters.FromJson("{}").AnswerSpaceLines, Is.EqualTo(PrintPresetFilters.DefaultAnswerSpaceLines));
}
[Test]
public void FromJson_ClampsPrintOptions()
{
var restored = PrintPresetFilters.FromJson("""{"fontSizePt":99,"answerSpaceLines":0}""");
Assert.That(restored.FontSizePt, Is.EqualTo(PrintPresetFilters.MaxFontSizePt));
Assert.That(restored.AnswerSpaceLines, Is.EqualTo(PrintPresetFilters.MinAnswerSpaceLines));
}
}
-120
View File
@@ -1,120 +0,0 @@
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));
}
}
}
-105
View File
@@ -1,105 +0,0 @@
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class PrintTokenMap_Tests
{
[Test]
public void Build_BuiltInWinsOverImportedSameName()
{
var map = PrintTokenMap.Build(
new Dictionary<string, string?> { ["Grade"] = "imported" },
new Dictionary<string, string?> { ["Grade"] = "9" },
null);
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()
{
var imported = new Dictionary<string, string?>
{
["Interview Time"] = "3:20-3:35",
["Application"] = null
};
var map = PrintTokenMap.Build(imported, null, null);
Assert.That(map.ContainsKey("Interview Time"), Is.True);
Assert.That(map["Interview Time"], Is.EqualTo("3:20-3:35"));
Assert.That(map.ContainsKey("Application"), Is.True);
Assert.That(map["Application"], Is.EqualTo(string.Empty));
}
[Test]
public void Escape_MasksMarkdownAndHtml()
{
var escaped = PrintTokenMap.Escape("A *B* <script>");
Assert.That(escaped, Does.Contain("\\*"));
Assert.That(escaped, Does.Contain("&lt;"));
Assert.That(escaped, Does.Contain("&gt;"));
Assert.That(escaped, Does.Not.Contain("<script>"));
}
[Test]
public void Merge_EscapedAsteriskDoesNotStayRaw()
{
var map = PrintTokenMap.Build(
null,
new Dictionary<string, string?> { ["FirstName"] = "A*ria" },
null);
var merged = NoteTemplateMerger.Merge("Hi {{FirstName}}", map);
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
@@ -1,88 +0,0 @@
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)"));
}
}
-28
View File
@@ -1,28 +0,0 @@
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);
}
}
@@ -1,64 +0,0 @@
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
};
}
+2 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
@@ -18,6 +18,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" /> <ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="Parsers\TestInput\2025 Assumptions.csv"> <Content Include="Parsers\TestInput\2025 Assumptions.csv">
@@ -1,88 +0,0 @@
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"));
}
}
@@ -1,256 +0,0 @@
using Core.Entities;
using Core.Models;
using Core.YearTransition;
using NUnit.Framework;
using Tests.Builders;
namespace Tests.YearTransition;
[TestFixture]
public class GraduatingGradeResolver_Tests
{
[Test]
public void FromSchoolLevel_MiddleSchool_Returns8()
{
Assert.That(GraduatingGradeResolver.FromSchoolLevel(SchoolLevel.MiddleSchool), Is.EqualTo(8));
}
[Test]
public void FromSchoolLevel_HighSchool_Returns12()
{
Assert.That(GraduatingGradeResolver.FromSchoolLevel(SchoolLevel.HighSchool), Is.EqualTo(12));
}
[Test]
public void FromSchoolLevel_Null_ReturnsNull()
{
Assert.That(GraduatingGradeResolver.FromSchoolLevel(null), Is.Null);
}
}
[TestFixture]
public class YearTransitionPlanner_Tests
{
[SetUp]
public void SetUp()
{
StudentBuilder.ResetIdCounter();
}
[Test]
public void SuggestReturning_BelowGraduatingGrade_IsTrue()
{
var student = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build();
Assert.That(YearTransitionPlanner.SuggestReturning(student, graduatingGrade: 8), Is.True);
}
[Test]
public void SuggestReturning_AtGraduatingGrade_IsFalse()
{
var student = StudentBuilder.Create("Ann", "Lee").WithGrade(8).Build();
Assert.That(YearTransitionPlanner.SuggestReturning(student, graduatingGrade: 8), Is.False);
}
[Test]
public void Build_PromotesReturningStudents_MiddleSchool()
{
var returning = StudentBuilder.Create("Ann", "Lee").WithGrade(6).WithTsaYear(1).Build();
var graduating = StudentBuilder.Create("Bob", "Smith").WithGrade(8).WithTsaYear(3).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [returning, graduating],
ReturningStudentIds = new HashSet<int> { returning.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027"
});
Assert.That(plan.ReturningCount, Is.EqualTo(1));
Assert.That(plan.RemovalCount, Is.EqualTo(1));
Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(7));
Assert.That(plan.Promotions[0].NewTsaYear, Is.EqualTo(2));
Assert.That(plan.StudentsToRemove[0].Id, Is.EqualTo(graduating.Id));
}
[Test]
public void Build_CapsGradeAtGraduatingGrade_HighSchool()
{
var senior = StudentBuilder.Create("Chris", "Young").WithGrade(12).WithTsaYear(4).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [senior],
ReturningStudentIds = new HashSet<int> { senior.Id },
GraduatingGrade = 12,
TargetCompetitionYear = "2027"
});
Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(12));
Assert.That(plan.Promotions[0].NewTsaYear, Is.EqualTo(5));
Assert.That(plan.Warnings, Has.Some.Contain("graduating grade"));
}
[Test]
public void Build_CapsGradeAtEight_WhenMiddleSchoolReturnerAtGraduatingGrade()
{
var eighth = StudentBuilder.Create("Dana", "Nguyen").WithGrade(8).WithTsaYear(2).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [eighth],
ReturningStudentIds = new HashSet<int> { eighth.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027"
});
Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(8));
Assert.That(plan.Warnings, Has.Some.Contain("graduating grade"));
}
[Test]
public void Build_AssignsOfficersAndClearsOthers()
{
var president = StudentBuilder.Create("Eve", "Adams").WithGrade(7).AsPresident().Build();
var vp = StudentBuilder.Create("Frank", "Baker").WithGrade(6).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [president, vp],
ReturningStudentIds = new HashSet<int> { president.Id, vp.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
OfficerAssignments = new Dictionary<OfficerRole, int?>
{
[OfficerRole.President] = vp.Id,
[OfficerRole.VicePresident] = president.Id
}
});
var eve = plan.Promotions.Single(p => p.Student.Id == president.Id);
var frank = plan.Promotions.Single(p => p.Student.Id == vp.Id);
Assert.That(eve.PreviousOfficerRole, Is.EqualTo(OfficerRole.President));
Assert.That(eve.NewOfficerRole, Is.EqualTo(OfficerRole.VicePresident));
Assert.That(frank.NewOfficerRole, Is.EqualTo(OfficerRole.President));
var presidentChange = plan.OfficerChanges.Single(c => c.Role == OfficerRole.President);
Assert.That(presidentChange.PreviousOfficer!.Id, Is.EqualTo(president.Id));
Assert.That(presidentChange.NewOfficer!.Id, Is.EqualTo(vp.Id));
}
[Test]
public void Build_WarnsWhenOfficerAssignedToNonReturningStudent()
{
var returning = StudentBuilder.Create("Gina", "Cole").WithGrade(6).Build();
var leaving = StudentBuilder.Create("Hank", "Diaz").WithGrade(8).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [returning, leaving],
ReturningStudentIds = new HashSet<int> { returning.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
OfficerAssignments = new Dictionary<OfficerRole, int?>
{
[OfficerRole.President] = leaving.Id
}
});
Assert.That(plan.Warnings, Has.Some.Contain("not marked returning"));
Assert.That(plan.Promotions[0].NewOfficerRole, Is.Null);
}
[Test]
public void Build_WarnsWhenSameStudentHasTwoOffices()
{
var student = StudentBuilder.Create("Ivy", "Evans").WithGrade(7).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [student],
ReturningStudentIds = new HashSet<int> { student.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
OfficerAssignments = new Dictionary<OfficerRole, int?>
{
[OfficerRole.President] = student.Id,
[OfficerRole.Treasurer] = student.Id
}
});
Assert.That(plan.Warnings, Has.Some.Contain("more than one officer role"));
Assert.That(plan.Promotions[0].NewOfficerRole, Is.Null);
}
[Test]
public void MatchPastedNames_MatchesLastCommaFirstAndFirstLast()
{
var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build();
var b = StudentBuilder.Create("Bob", "Smith").WithGrade(7).Build();
var result = YearTransitionPlanner.MatchPastedNames(
[a, b],
["Lee, Ann", "Bob Smith", "Nobody Here"]);
Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { a.Id, b.Id }));
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()
{
var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build();
var b = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build();
var result = YearTransitionPlanner.MatchPastedNames([a, b], ["Ann Lee"]);
Assert.That(result.AmbiguousNames, Is.EquivalentTo(new[] { "Ann Lee" }));
Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { a.Id, b.Id }));
}
[Test]
public void Build_IncludesUnmatchedAndAmbiguousFromPaste()
{
var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build();
var b = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [a, b],
ReturningStudentIds = new HashSet<int> { a.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
PastedNames = ["Ann Lee", "Zed Zulu"]
});
Assert.That(plan.AmbiguousPastedNames, Has.Member("Ann Lee"));
Assert.That(plan.UnmatchedPastedNames, Has.Member("Zed Zulu"));
Assert.That(plan.Warnings, Has.Some.Contain("matched more than one"));
}
}
+1 -2
View File
@@ -22,11 +22,10 @@
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script> <script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
<script src="js/markdownTablePaste.js"></script> <script src="js/markdownTablePaste.js"></script>
<script src="js/downloadFile.js"></script>
<script src="js/login.js"></script>
</body> </body>
</html> </html>
@@ -134,7 +134,7 @@
if (e.Key == "Enter") if (e.Key == "Enter")
{ {
// Blur the active element to ensure MudTextField bindings update // Blur the active element to ensure MudTextField bindings update
await JS.InvokeVoidAsync("tsaLogin.blurActiveElement"); await JS.InvokeVoidAsync("eval", "document.activeElement.blur()");
// Small delay to allow bindings to process // Small delay to allow bindings to process
await Task.Delay(50); await Task.Delay(50);
@@ -146,11 +146,14 @@
private async Task HandleFormSubmit() private async Task HandleFormSubmit()
{ {
await JS.InvokeVoidAsync( // Update hidden inputs with current model values, then submit the form
"tsaLogin.submitForm", var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl);
_loginModel.Email ?? string.Empty, await JS.InvokeVoidAsync("eval", $@"
_loginModel.Password ?? string.Empty, document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
_loginModel.RememberMe, document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
_returnUrl ?? string.Empty); document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
document.getElementById('returnUrlInput').value = {returnUrlValue};
document.getElementById('loginForm').submit();
");
} }
} }
@@ -1,72 +1 @@
@namespace WebApp.Components.Features.Calendar
@using Core.Entities
<MudDialog>
<DialogContent>
@if (EventOccurrence == null)
{
<MudAlert Severity="Severity.Warning">
Event details are unavailable.
</MudAlert>
}
else
{
<MudStack Spacing="2">
<MudText Typo="Typo.h6">
@(EventDefinition?.Name ?? EventOccurrence.Name)
</MudText>
<MudDivider />
<MudText Typo="Typo.body1">
<strong>Occurrence:</strong> @EventOccurrence.Name
</MudText>
<MudText Typo="Typo.body1">
<strong>Start:</strong> @EventOccurrence.StartTime.ToString("f")
</MudText>
@if (EventOccurrence.EndTime != null)
{
<MudText Typo="Typo.body1">
<strong>End:</strong> @EventOccurrence.EndTime.Value.ToString("f")
</MudText>
}
@if (!string.IsNullOrWhiteSpace(EventOccurrence.Location))
{
<MudText Typo="Typo.body1">
<strong>Location:</strong> @EventOccurrence.Location
</MudText>
}
@if (StudentFirstNames.Any())
{
<MudText Typo="Typo.body1">
<strong>Students:</strong> @string.Join(", ", StudentFirstNames)
</MudText>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudSpacer />
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
public IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public EventOccurrence? EventOccurrence { get; set; }
[Parameter]
public EventDefinition? EventDefinition { get; set; }
[Parameter]
public List<string> StudentFirstNames { get; set; } = [];
private void Close()
{
MudDialog.Close();
}
}
@@ -121,7 +121,7 @@
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0) @if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
{ {
<MudAlert Severity="Severity.Success" Dense="true"> <MudAlert Severity="Severity.Success" Dense="true">
Successfully parsed @_parseResult.TotalParsed occurrence(s) from @_parseResult.Occurrences.Count event definition(s) Successfully parsed @_parseResult.TotalParsed occurrence(s) in @_parseResult.Occurrences.Count group(s)
@if (_parseResult.SkippedEventCount > 0) @if (_parseResult.SkippedEventCount > 0)
{ {
<text> (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)</text> <text> (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)</text>
@@ -201,9 +201,11 @@
{ {
<MudText Typo="Typo.h6" Class="mt-4 mb-2">Occurrences by Event:</MudText> <MudText Typo="Typo.h6" Class="mt-4 mb-2">Occurrences by Event:</MudText>
<MudExpansionPanels Elevation="0"> <MudExpansionPanels Elevation="0">
@foreach (var kvp in _parseResult.Occurrences.OrderBy(x => GetEventName(x.Key))) @foreach (var kvp in _parseResult.Occurrences
.OrderBy(x => GetEventName(x.Key.EventDefinition))
.ThenBy(x => x.Key.SectionSchoolLevel switch { SchoolLevel.MiddleSchool => 0, SchoolLevel.HighSchool => 1, _ => 2 }))
{ {
<MudExpansionPanel Text="@GetEventName(kvp.Key)"> <MudExpansionPanel Text="@GetOccurrenceGroupTitle(kvp.Key)">
<MudTable Items="@kvp.Value" Dense="true" Hover="true" Striped="true"> <MudTable Items="@kvp.Value" Dense="true" Hover="true" Striped="true">
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>Name</MudTh>
@@ -395,6 +397,17 @@
return eventDefinition.Name; return eventDefinition.Name;
} }
private string GetOccurrenceGroupTitle(EventOccurrenceParseGroup group)
{
var title = GetEventName(group.EventDefinition);
return group.SectionSchoolLevel switch
{
SchoolLevel.MiddleSchool => $"{title} (MS)",
SchoolLevel.HighSchool => $"{title} (HS)",
_ => title
};
}
private Color GetIssueTypeColor(ParsingIssueType issueType) private Color GetIssueTypeColor(ParsingIssueType issueType)
{ {
return issueType switch return issueType switch
@@ -14,9 +14,6 @@
<MudTooltip Text="Import"> <MudTooltip Text="Import">
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton> <MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="Schedule handout (print)">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">Schedule handout</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator"> <AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Admin"> <MudTooltip Text="Admin">
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton> <MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
@@ -1,422 +0,0 @@
@page "/calendar/state-schedule-handout"
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Options
@using System.Globalization
@using WebApp.Models
@using WebApp.Utility
@using WebApp.Services
@inject AppDbContext Context
@inject IConfiguration Configuration
@inject IOptionsMonitor<StateScheduleHandoutOptions> HandoutOptionsMonitor
@inject IEventOccurrenceService EventOccurrenceService
<div class="no-print">
<PageHeader
Title="State schedule handout"
Description="Print per-student schedules and the combined master list."
Icon="@Icons.Material.Filled.Print"
ShowBackButton="true"
BackButtonUrl="/calendar" />
</div>
@if (_students == null || _allOccurrences == null)
{
<p><em>Loading...</em></p>
}
else
{
var opts = HandoutOptionsMonitor.CurrentValue;
<MudContainer Class="state-schedule-handout">
@foreach (var student in _students)
{
<MudContainer Class="pagebreak">
<MudText Typo="Typo.h5">
@if (string.IsNullOrWhiteSpace(student.StateId))
{
@student.Name
}
else
{
@($"{student.Name} - {student.StateId}")
}
</MudText>
<MudText Typo="Typo.h6" Class="mb-3">
TSA @_competitionYear @_stateAbbrev State Schedule
</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-1">Events</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-4 nobrk">
<thead>
<tr>
<th>State ID</th>
<th>Event</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
@foreach (var eventRow in GetEventSummaryRows(student))
{
<tr>
<td>@eventRow.StateRegistrationId</td>
<td>@eventRow.EventName</td>
<td>@eventRow.Activity</td>
</tr>
}
</tbody>
</MudSimpleTable>
@{
var scheduleRows = BuildStudentSchedule(student, opts).ToList();
}
<MudText Typo="Typo.subtitle1" Class="mb-1">Schedule</MudText>
@if (scheduleRows.Count == 0)
{
<MudText Class="mud-text-secondary">No schedule entries for imported occurrences.</MudText>
}
else
{
@foreach (var dateGroup in scheduleRows.GroupBy(o => o.StartTime.Date))
{
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Location</th>
</tr>
</thead>
<tbody>
@foreach (var occ in dateGroup.OrderBy(o => o.StartTime))
{
<tr>
<td>@FormatTimeDisplay(occ)</td>
<td>@FormatEventColumn(occ)</td>
<td>@(occ.Location ?? "")</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
}
</MudContainer>
}
<MudContainer Class="pagebreak">
<MudText Typo="Typo.h5" Class="mb-2">Combined schedule</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">Imported occurrences relevant to this chapter.</MudText>
@{
var combinedOccurrences = GetCombinedScheduleOccurrences().ToList();
}
@if (combinedOccurrences.Count == 0)
{
<MudText Class="mud-text-secondary">No relevant event occurrences found for your current team registrations.</MudText>
}
@foreach (var dateGroup in combinedOccurrences.GroupBy(o => o.StartTime.Date))
{
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Location</th>
</tr>
</thead>
<tbody>
@foreach (var tlGroup in dateGroup
.OrderBy(o => o.StartTime)
.GroupBy(o => (FormatTimeDisplay(o), o.Location ?? ""))
.Select(g => g.ToList()))
{
if (tlGroup.Count == 1)
{
var occ = tlGroup[0];
<tr>
<td>@FormatTimeDisplay(occ)</td>
<td>@FormatCombinedScheduleEventCell(occ)</td>
<td>@(occ.Location ?? "")</td>
</tr>
}
else
{
var genericOcc = tlGroup.FirstOrDefault(o => !o.EventDefinitionId.HasValue);
var specificOccs = tlGroup
.Where(o => o.EventDefinitionId.HasValue)
.OrderBy(o => FormatEventColumn(o), StringComparer.OrdinalIgnoreCase)
.ToList();
var rowCount = (genericOcc != null ? 1 : 0) + specificOccs.Count;
var representative = genericOcc ?? specificOccs[0];
if (genericOcc != null)
{
<tr>
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
<td>@FormatCombinedScheduleEventCell(genericOcc)</td>
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
</tr>
@foreach (var sub in specificOccs)
{
<tr>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
</tr>
}
}
else
{
<tr>
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(specificOccs[0])</td>
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
</tr>
@foreach (var sub in specificOccs.Skip(1))
{
<tr>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
</tr>
}
}
}
}
</tbody>
</MudSimpleTable>
}
</MudContainer>
</MudContainer>
}
@code {
private Student[]? _students;
private List<EventOccurrence>? _allOccurrences;
private Dictionary<int, List<Team>> _teamsByEventDefinitionId = new();
private string _competitionYear = "";
private string _stateAbbrev = "";
private string? _chapterStateId;
protected override async Task OnInitializedAsync()
{
_competitionYear = Configuration["ChapterSettings:CompetitionYear"] ?? "";
_stateAbbrev = Configuration["ChapterSettings:StateAbbrev"] ?? "ST";
_chapterStateId = Configuration["ChapterSettings:StateId"];
_allOccurrences = await Context.EventOccurrences
.AsNoTracking()
.Include(eo => eo.EventDefinition)
.OrderBy(eo => eo.StartTime)
.ToListAsync();
var eventDefIds = _allOccurrences
.Where(o => o.EventDefinitionId.HasValue)
.Select(o => o.EventDefinitionId!.Value)
.Distinct()
.ToList();
_teamsByEventDefinitionId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefIds);
// Tracking required: Include Teams->Students creates a graph cycle (Student–Team–Student) that EF disallows with AsNoTracking().
_students = await Context.Students
.Include(s => s.Teams)
.ThenInclude(t => t!.Event)
.Include(s => s.Teams)
.ThenInclude(t => t!.Captain)
.Include(s => s.Teams)
.ThenInclude(t => t!.Students)
.OrderBy(s => s.Nickname ?? s.FirstName)
.ThenBy(s => s.LastName)
.ToArrayAsync();
}
private IEnumerable<EventOccurrence> BuildStudentSchedule(Student student, StateScheduleHandoutOptions opts)
{
var eventIds = student.Teams.Select(t => t.Event.Id).ToHashSet();
var competition = _allOccurrences!
.Where(o => o.EventDefinitionId.HasValue && eventIds.Contains(o.EventDefinitionId.Value))
.Where(o => StateScheduleOccurrenceFilter.IncludeCompetitionOccurrenceForStudent(o, opts));
var special = _allOccurrences!
.Where(o => o.EventDefinitionId == null)
.Where(o => StateScheduleOccurrenceFilter.IncludeSpecialOccurrenceForStudent(o, student, opts));
return competition
.Concat(special)
.OrderBy(o => o.StartTime)
.DistinctBy(o => (o.StartTime, o.Name ?? ""));
}
private IEnumerable<EventOccurrence> GetCombinedScheduleOccurrences()
{
return _allOccurrences!
.Where(o =>
{
// Keep chapter-wide/special schedule rows.
if (!o.EventDefinitionId.HasValue)
return true;
// Keep only competition events where this chapter has registered teams.
return _teamsByEventDefinitionId.TryGetValue(o.EventDefinitionId.Value, out var teams) && teams.Count > 0;
})
.OrderBy(o => o.StartTime);
}
private IEnumerable<EventSummaryRow> GetEventSummaryRows(Student student)
{
foreach (var team in student.Teams.OrderBy(t => t.Event.Name))
{
yield return new EventSummaryRow(
StateRegistrationId: FormatStateRegistrationId(team, student),
EventName: team.Event.Name,
Activity: FormatActivitySummary(team, student));
}
}
/// <summary>
/// Team events: chapter <c>ChapterSettings:StateId</c> + <see cref="Team.Identifier"/> (e.g. 12227-1).
/// Individual events: competitor's <see cref="Student.StateId"/>.
/// </summary>
private string FormatStateRegistrationId(Team team, Student student)
{
if (team.Event.EventFormat == EventFormat.Individual)
{
return string.IsNullOrWhiteSpace(student.StateId)
? "—"
: student.StateId.Trim();
}
var chap = _chapterStateId?.Trim();
var ident = team.Identifier?.Trim();
if (string.IsNullOrEmpty(chap) && string.IsNullOrEmpty(ident))
return "—";
// Already a full registration id (e.g. "12227-1" or state id stored on team)
if (!string.IsNullOrEmpty(ident))
{
if (ident.Contains('-', StringComparison.Ordinal))
return ident;
if (!string.IsNullOrEmpty(chap) && ident.StartsWith(chap, StringComparison.Ordinal))
return ident;
}
if (!string.IsNullOrEmpty(chap) && !string.IsNullOrEmpty(ident))
return $"{chap}-{ident}";
return !string.IsNullOrEmpty(chap) ? chap : ident!;
}
// Activity line comes from event SemifinalistActivity (interview/presentation limits), not Min/MaxTeamSize.
private static string FormatActivitySummary(Team team, Student student)
{
var parts = new List<string>();
if (team.Captain?.Id == student.Id)
parts.Add("(Cpt.)");
if (!string.IsNullOrWhiteSpace(team.Event.SemifinalistActivity))
parts.Add(team.Event.SemifinalistActivity!);
return string.Join(" ", parts).Trim();
}
private static string FormatDateHeading(DateTime date) =>
date.ToString("MMMM d, dddd", CultureInfo.GetCultureInfo("en-US"));
private static string FormatTimeDisplay(EventOccurrence o)
{
if (!string.IsNullOrWhiteSpace(o.Time))
return o.Time.Trim();
return o.StartTime.ToString("g", CultureInfo.GetCultureInfo("en-US"));
}
private static string FormatEventColumn(EventOccurrence o)
{
if (o.EventDefinition != null)
{
var ev = !string.IsNullOrWhiteSpace(o.EventDefinition.ShortName)
? o.EventDefinition.ShortName
: o.EventDefinition.Name;
if (string.IsNullOrWhiteSpace(o.Name))
return ev;
if (o.Name.Contains(ev, StringComparison.OrdinalIgnoreCase))
return o.Name.Trim();
return $"{ev} {o.Name}".Trim();
}
return string.IsNullOrWhiteSpace(o.Name) ? (o.SpecialEventType ?? "") : o.Name.Trim();
}
private string FormatCombinedScheduleEventCell(EventOccurrence occ)
{
var baseText = FormatEventColumn(occ);
if (!occ.EventDefinitionId.HasValue)
return baseText;
if (!_teamsByEventDefinitionId.TryGetValue(occ.EventDefinitionId.Value, out var teams) || teams.Count == 0)
return baseText;
var isIndividual = occ.EventDefinition?.EventFormat == EventFormat.Individual;
var orderedTeams = teams
.OrderBy(t => t, Comparer<Team>.Create((a, b) =>
{
var cmp = CombinedScheduleTeamSortOrder(a, b);
return cmp != 0 ? cmp : a.Id.CompareTo(b.Id);
}))
.ToList();
var rosterStrings = orderedTeams
.Select(t => FormatCombinedScheduleTeamRoster(t, isIndividual))
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
if (rosterStrings.Count == 0)
return baseText;
var suffix = rosterStrings.Count == 1
? rosterStrings[0]
: string.Join(" ", rosterStrings.Select(r => $"[{r}]"));
return $"{baseText} — {suffix}";
}
private static int CombinedScheduleTeamSortOrder(Team a, Team b)
{
var ka = a.Identifier?.Trim() ?? "";
var kb = b.Identifier?.Trim() ?? "";
if (int.TryParse(ka, out var na) && int.TryParse(kb, out var nb))
return na.CompareTo(nb);
return string.Compare(ka, kb, StringComparison.OrdinalIgnoreCase);
}
private static string FormatCombinedScheduleTeamRoster(Team team, bool isIndividual)
{
var students = team.Students?.ToList() ?? [];
if (students.Count == 0)
return "";
if (isIndividual)
{
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.DisplayFirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!)
: students.OrderBy(s => s.DisplayFirstName, StringComparer.OrdinalIgnoreCase);
return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
}
private static string FormatCombinedScheduleStudentSegment(Student student, Team team, bool isIndividual)
{
if (isIndividual)
{
var sid = student.StateId?.Trim();
return !string.IsNullOrEmpty(sid)
? $"{student.DisplayFirstName} ({sid})"
: student.DisplayFirstName;
}
var isCpt = team.Captain?.Id == student.Id;
return isCpt ? $"{student.DisplayFirstName} (Cpt.)" : student.DisplayFirstName;
}
private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity);
}
@@ -1,253 +0,0 @@
@page "/events/import"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using Core.Parsers
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@using WebApp.Models
@inject AppDbContext Context
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject ILogger<EventCatalogImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Event Catalog"
Description="Add new event definitions from CSV. Existing event names are skipped."
Icon="@AppIcons.Events"
ShowBackButton="true"
BackButtonUrl="/events" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required columns: <code>Event</code>, <code>Team Size</code>, <code>State Count</code>.
Optional: <code>Short Name</code>, <code>EventFormat</code>, <code>Level of Effort</code>,
<code>Eligibility</code>, <code>Description</code>, <code>Theme</code>,
<code>Documentation</code>, <code>State Presubmission</code>,
<code>Semifinalist Activity</code>, <code>Regional Notes</code>.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton Variant="Variant.Text" OnClick="HandleClear" Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (!string.IsNullOrEmpty(_parseError))
{
<MudAlert Severity="Severity.Error">@_parseError</MudAlert>
}
else if (_events is null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
<MudAlert Severity="Severity.Success" Dense="true">
@_events.Length event(s) parsed.
@_newEventCount new, @_existingEventCount already in the database.
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@(_isSaving || _newEventCount == 0)">
Save to Database
</MudButton>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
private byte[]? _fileBytes;
private string? _fileName;
private EventDefinition[]? _events;
private int _newEventCount;
private int _existingEventCount;
private string? _parseError;
private bool _isParsing;
private bool _isSaving;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
private async Task HandleFileChanged(InputFileChangeEventArgs args)
{
if (_isDisposed)
return;
try
{
await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024);
await using var memory = new MemoryStream();
await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None);
_fileBytes = memory.ToArray();
_fileName = args.File.Name;
ResetParse();
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error reading event catalog CSV");
if (!_isDisposed)
Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error);
}
}
private async Task HandleParse()
{
if (_fileBytes is null)
{
Snackbar.Add("Please choose a CSV file first", Severity.Warning);
return;
}
_isParsing = true;
_parseError = null;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
using var reader = new StreamReader(new MemoryStream(_fileBytes));
_events = new EventDefinitionParser(reader).Parse();
var existingNames = await Context.Events
.AsNoTracking()
.Select(e => e.Name)
.ToListAsync(token);
var existingSet = existingNames.ToHashSet();
_existingEventCount = _events.Count(e => existingSet.Contains(e.Name));
_newEventCount = _events.Length - _existingEventCount;
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error parsing event catalog CSV");
_events = null;
_parseError = $"Error parsing CSV: {ex.Message}";
if (!_isDisposed)
Snackbar.Add(_parseError, Severity.Error);
}
finally
{
_isParsing = false;
}
}
private async Task HandleSave()
{
if (_events is null)
{
Snackbar.Add("Parse a CSV first", Severity.Warning);
return;
}
_isSaving = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var added = 0;
foreach (var evt in _events)
{
token.ThrowIfCancellationRequested();
var exists = await Context.Events.FirstOrDefaultAsync(e => e.Name == evt.Name, token);
if (exists != null)
continue;
await Context.Events.AddAsync(evt, token);
added++;
}
await Context.SaveChangesAsync(token);
if (_isDisposed)
return;
Snackbar.Add($"Added {added} event(s).", Severity.Success);
NavigationManager.NavigateTo("/events");
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error saving imported events");
if (!_isDisposed)
Snackbar.Add($"Error saving events: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
}
}
private void HandleClear()
{
_fileBytes = null;
_fileName = null;
ResetParse();
}
private void ResetParse()
{
_events = null;
_newEventCount = 0;
_existingEventCount = 0;
_parseError = null;
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -4,7 +4,6 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using WebApp.Models @using WebApp.Models
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@using WebApp.Authentication
@inject AppDbContext Context @inject AppDbContext Context
@inject IDialogService DialogService @inject IDialogService DialogService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -14,11 +13,6 @@
<MudTooltip Text="Create New"> <MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
</MudTooltip> </MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Add new catalog events from CSV. Existing names are skipped.">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/events/import" Variant="Variant.Outlined">Import</MudButton>
</MudTooltip>
</AuthorizeView>
<MudTooltip Text="Printable Descriptions"> <MudTooltip Text="Printable Descriptions">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
</MudTooltip> </MudTooltip>
@@ -7,7 +7,7 @@
<PageHeader <PageHeader
Title="@($"TSA Events {Configuration["ChapterSettings:CompetitionYear"]}")" Title="@($"TSA Events {Configuration["ChapterSettings:CompetitionYear"]}")"
Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" /> Description="Yearly theme: Unity Through Community" />
@if (_events == null) @if (_events == null)
{ {
@@ -66,7 +66,7 @@ else
{ {
<MudItem xs="3"> <MudItem xs="3">
<MudText Class="d-flex py-1"> <MudText Class="d-flex py-1">
<i>Theme for @Configuration["ChapterSettings:CompetitionYear"]:</i> <i>Theme for 2025-26:</i>
</MudText> </MudText>
</MudItem> </MudItem>
<MudItem xs="8"> <MudItem xs="8">
@@ -91,6 +91,83 @@ else
<MudDivider /> <MudDivider />
} }
</MudContainer> </MudContainer>
<MudContainer>
@foreach (var evt in _events)
{
<MudContainer Class="mt-3 mb-1 nobrk">
<MudGrid>
<MudItem xs="4">
<MudStack>
<MudItem>
<MudText Class="d-flex py-1" Typo="Typo.h5">@evt.Name</MudText>
</MudItem>
@if (evt.RegionalEvent)
{
<MudItem>
<MudText Class="d-flex" Typo="Typo.caption"><i>Regional Event</i></MudText>
</MudItem>
}
</MudStack>
</MudItem>
<MudItem xs="2">
<MudText>
@if (evt.EventFormat is EventFormat.Team)
{
<strong>@evt.EventFormat</strong>
<br />
<p>Size: <strong>@evt.TeamSize</strong></p>
}
else
{
<strong>@evt.EventFormat</strong>
}
</MudText>
</MudItem>
<MudItem xs="3">
Eligibility: @evt.Eligibility
</MudItem>
<MudItem xs="1">
<strong> Effort</strong>: @evt.LevelOfEffort
</MudItem>
<MudItem xs="2">
<strong>Activity</strong>: @evt.SemifinalistActivity
</MudItem>
<MudItem xs="12">
<MudText Class="d-flex py-1 pre-wrap-text">@evt.Description</MudText>
</MudItem>
@if (!string.IsNullOrEmpty(evt.Theme))
{
<MudItem xs="3">
<MudText Class="d-flex py-1">
<i>Theme for 2025-26:</i>
</MudText>
</MudItem>
<MudItem xs="8">
<MudText Class="d-flex py-1 pre-wrap-text">@evt.Theme</MudText>
</MudItem>
}
@if (!string.IsNullOrEmpty(evt.Documentation))
{
<MudItem xs="3">
<MudText Class="d-flex py-1">
<i>Materials:</i>
</MudText>
</MudItem>
<MudItem xs="8">
<MudText Class="d-flex py-1 pre-wrap-text">@evt.Documentation</MudText>
</MudItem>
}
</MudGrid>
</MudContainer>
<MudDivider />
}
</MudContainer>
} }
@code { @code {
private EventDefinition[]? _events; private EventDefinition[]? _events;
@@ -321,7 +321,7 @@
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options); var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
var result = await dialog.Result; var result = await dialog.Result;
if (result is { Canceled: false }) if (!result.Canceled)
{ {
// Refresh data if meeting was updated or deleted // Refresh data if meeting was updated or deleted
await RefreshMeetingHistories(); await RefreshMeetingHistories();
@@ -664,7 +664,7 @@
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success); Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
} }
} }
catch (JSException) catch (JSException ex)
{ {
Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error); Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error);
} }
@@ -65,7 +65,7 @@
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap"> <MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@{ @{
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet(); var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.DisplayFirstName); var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName);
} }
@foreach (var student in allStudents) @foreach (var student in allStudents)
{ {
@@ -401,7 +401,7 @@
var result = await dialog.Result; var result = await dialog.Result;
// Refresh meeting history if dialog was saved // Refresh meeting history if dialog was saved
if (result is { Canceled: false } && !_isDisposed) if (!result.Canceled && !_isDisposed)
{ {
await LoadMeetingHistory(); await LoadMeetingHistory();
} }
@@ -11,11 +11,12 @@
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center"> <MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center"> <MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIconButton Icon="@Icons.Material.Filled.Clear" <MudIcon Icon="@Icons.Material.Filled.Clear"
Size="Size.Small" Size="Size.Small"
Class="@(removed ? "" : "d-none")" Class="@(removed ? "" : "d-none")"
OnClick="@(() => OnToggleTeam.InvokeAsync(team))" OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
aria-label="Restore team" /> Style="cursor: pointer;">
</MudIcon>
@{ @{
var teamMembers = TeamStudentNameFormatter.FormatStudentList( var teamMembers = TeamStudentNameFormatter.FormatStudentList(
team, team,
@@ -1,933 +0,0 @@
@page "/print"
@attribute [Authorize]
@implements IAsyncDisposable
@using Core.Printing
@inject INotesService NotesService
@inject INotePrintService NotePrintService
@inject IPrintPresetService PrintPresetService
@inject IConfiguration Configuration
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
@inject MarkdownTablePasteService MarkdownTablePasteService
<div class="no-print">
<PageHeader Title="Page printer"
Description="Write a markdown template, merge it onto students, teams, or events, and print."
Icon="@Icons.Material.Filled.Print" />
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mb-4">
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
<MudGrid>
<MudItem xs="12" md="5">
<MudSelect T="int?"
Label="Print preset"
Value="_selectedPresetId"
ValueChanged="OnPresetSelected"
Clearable="true"
Variant="Variant.Outlined">
@foreach (var preset in _presets)
{
<MudSelectItem T="int?" Value="@preset.Id">@preset.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="7" Class="d-flex align-center gap-2 flex-wrap">
<MudButton Variant="Variant.Outlined"
OnClick="NewTemplate"
Disabled="@_isBusy">
New
</MudButton>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.Save"
OnClick="SavePreset"
Disabled="@_isBusy">
Save
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Error"
OnClick="DeletePreset"
Disabled="@(_isBusy || !_selectedPresetId.HasValue)">
Delete
</MudButton>
</MudItem>
<MudItem xs="12" md="4">
<MudSelect T="PrintEntityType"
Label="Entity"
Value="_entityType"
ValueChanged="OnEntityTypeChanged"
Variant="Variant.Outlined">
<MudSelectItem Value="PrintEntityType.Student">Students</MudSelectItem>
<MudSelectItem Value="PrintEntityType.Team">Teams</MudSelectItem>
<MudSelectItem Value="PrintEntityType.Event">Events</MudSelectItem>
</MudSelect>
</MudItem>
@if (_entityType == PrintEntityType.Student)
{
<MudItem xs="12" sm="4" md="2">
<MudNumericField T="int?"
Label="Grade"
Value="_grade"
ValueChanged="OnGradeChanged"
Variant="Variant.Outlined"
Min="5"
Max="12"
Clearable="true" />
</MudItem>
<MudItem xs="12" sm="4" md="2">
<MudNumericField T="int?"
Label="TSA year"
Value="_tsaYear"
ValueChanged="OnTsaYearChanged"
Variant="Variant.Outlined"
Min="1"
Max="12"
Clearable="true" />
</MudItem>
<MudItem xs="12" sm="4" md="4">
<MudSelect T="string"
Label="Officer"
Value="@_officerChoice"
ValueChanged="OnOfficerChanged"
Variant="Variant.Outlined">
<MudSelectItem Value="@("any")">Any</MudSelectItem>
<MudSelectItem Value="@("yes")">Officers only</MudSelectItem>
<MudSelectItem Value="@("no")">Non-officers only</MudSelectItem>
</MudSelect>
</MudItem>
}
else if (_entityType == PrintEntityType.Team)
{
<MudItem xs="12" md="8">
<MudTextField T="string"
Value="@_teamIdentifierContains"
ValueChanged="OnTeamIdentifierChanged"
Label="Identifier contains"
Variant="Variant.Outlined"
Immediate="true" />
</MudItem>
}
else
{
<MudItem xs="12" sm="4">
<MudTextField T="string"
Value="@_eventNameContains"
ValueChanged="OnEventNameChanged"
Label="Name contains"
Variant="Variant.Outlined"
Immediate="true" />
</MudItem>
<MudItem xs="12" sm="4">
<MudSelect T="EventFormat?"
Label="Event format"
Value="_eventFormat"
ValueChanged="OnEventFormatChanged"
Variant="Variant.Outlined"
Clearable="true">
@foreach (var format in Enum.GetValues<EventFormat>())
{
<MudSelectItem T="EventFormat?" Value="@format">@format</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="4">
<MudSelect T="string"
Label="Regional"
Value="@_regionalChoice"
ValueChanged="OnRegionalChanged"
Variant="Variant.Outlined">
<MudSelectItem Value="@("any")">Any</MudSelectItem>
<MudSelectItem Value="@("yes")">Regional only</MudSelectItem>
<MudSelectItem Value="@("no")">Non-regional only</MudSelectItem>
</MudSelect>
</MudItem>
}
<MudItem xs="12">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="flex-wrap mb-2">
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.DataObject"
OnClick="OpenTokenDialog"
Disabled="@_isBusy">
Insert token
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Visibility"
OnClick="Preview"
Disabled="@_isBusy">
Preview
</MudButton>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.Print"
OnClick="Print"
Disabled="@(_isBusy || _previewStale || _pages.Count == 0)">
Print
</MudButton>
<MudCheckBox T="bool"
Value="@_newPagePerRecord"
ValueChanged="OnNewPagePerRecordChanged"
Label="New page per record"
Dense="true" />
<MudNumericField T="int"
Label="Font size (pt)"
Value="_fontSizePt"
ValueChanged="OnFontSizeChanged"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="PrintPresetFilters.MinFontSizePt"
Max="PrintPresetFilters.MaxFontSizePt"
Style="max-width: 8rem;" />
<MudNumericField T="int"
Label="Answer lines"
Value="_answerSpaceLines"
ValueChanged="OnAnswerSpaceLinesChanged"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="PrintPresetFilters.MinAnswerSpaceLines"
Max="PrintPresetFilters.MaxAnswerSpaceLines"
Style="max-width: 8rem;" />
@if (IsDirty())
{
<MudText Typo="Typo.caption" Color="Color.Warning">Unsaved changes</MudText>
}
@if (!_previewStale && _pages.Count > 0)
{
<MudText Typo="Typo.body2">@_pages.Count page@(_pages.Count == 1 ? "" : "s")</MudText>
}
else if (!_previewStale && _didPreview)
{
<MudText Typo="Typo.body2">No matches</MudText>
}
</MudStack>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">Template (Markdown)</MudText>
<div id="@EditorElementId" @key="_editorGeneration">
<MarkdownEditor Value="@_templateMarkdown"
ValueChanged="OnMarkdownChanged"
Placeholder="Write the printable page. Insert tokens for student, team, or event values."
AutoSaveEnabled="false"
NativeSpellChecker="false"
HideIcons="@HiddenEditorIcons" />
</div>
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-1">Template preview</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown only — tokens are not merged here.</MudText>
@if (string.IsNullOrWhiteSpace(_templateMarkdown))
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">The template will preview here as you type.</MudText>
}
else
{
<MudPaper Elevation="0" Class="pa-3 note-print-page" Style="@($"background-color: var(--mud-palette-background-grey);{PrintPageStyle}")">
<div class="markdown-content">
@((MarkupString)MarkdownHelper.ToHtml(_templateMarkdown))
</div>
</MudPaper>
}
</MudItem>
</MudGrid>
</MudPaper>
</div>
<div class="print-only">
<PrintPageStack Pages="_pages"
NewPagePerRecord="_newPagePerRecord"
PrintPageStyle="@PrintPageStyle" />
</div>
@code {
private const string EditorElementId = "page-printer-editor";
private static readonly string[] HiddenEditorIcons = ["preview", "side-by-side", "fullscreen"];
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
private bool _isLoading = true;
private bool _isBusy;
private bool _previewStale = true;
private bool _didPreview;
private bool _pasteInitialized;
private int _editorGeneration;
private List<PrintPreset> _presets = [];
private List<string> _importedTokenNames = [];
private IReadOnlyList<NotePrintPage> _pages = [];
private IDialogReference? _previewDialog;
private IDisposable? _navigationRegistration;
private int? _selectedPresetId;
private string _templateMarkdown = string.Empty;
private PrintEntityType _entityType = PrintEntityType.Student;
private int? _grade;
private int? _tsaYear;
private string _officerChoice = "any";
private string? _teamIdentifierContains;
private string? _eventNameContains;
private EventFormat? _eventFormat;
private string _regionalChoice = "any";
private bool _newPagePerRecord = true;
private int _fontSizePt = PrintPresetFilters.DefaultFontSizePt;
private int _answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines;
private string _snapshotMarkdown = string.Empty;
private PrintEntityType _snapshotEntityType = PrintEntityType.Student;
private string _snapshotFiltersJson = string.Empty;
private string PrintPageStyle =>
$"--print-font-size:{_fontSizePt}pt;--print-answer-lines:{_answerSpaceLines};";
private bool CanPreview => !string.IsNullOrWhiteSpace(_templateMarkdown);
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
CaptureSnapshot();
_navigationRegistration = NavigationManager.RegisterLocationChangingHandler(OnLocationChanging);
}
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_pasteInitialized || _isDisposed)
return;
await Task.Delay(150);
if (_isDisposed)
return;
await MarkdownTablePasteService.InitializeAsync(EditorElementId);
_pasteInitialized = true;
}
private async Task LoadAsync()
{
if (_isDisposed)
return;
_isLoading = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await RefreshImportedTokenNamesAsync(token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
finally
{
if (!_isDisposed)
_isLoading = false;
}
}
private async Task MarkStaleAsync()
{
_previewStale = true;
_pages = [];
await ClosePreviewDialogAsync();
}
private void MarkStale() => _ = MarkStaleAsync();
private async Task ClosePreviewDialogAsync()
{
var dialog = _previewDialog;
_previewDialog = null;
if (dialog is null)
return;
try
{
dialog.Close();
}
catch (InvalidOperationException)
{
}
}
private async Task OnEntityTypeChanged(PrintEntityType value)
{
_entityType = value;
await MarkStaleAsync();
}
private void OnMarkdownChanged(string? value)
{
_templateMarkdown = value ?? string.Empty;
MarkStale();
}
private void OnGradeChanged(int? value)
{
_grade = value;
MarkStale();
}
private void OnTsaYearChanged(int? value)
{
_tsaYear = value;
MarkStale();
}
private void OnOfficerChanged(string value)
{
_officerChoice = value;
MarkStale();
}
private void OnTeamIdentifierChanged(string? value)
{
_teamIdentifierContains = value;
MarkStale();
}
private void OnEventNameChanged(string? value)
{
_eventNameContains = value;
MarkStale();
}
private void OnEventFormatChanged(EventFormat? value)
{
_eventFormat = value;
MarkStale();
}
private void OnRegionalChanged(string value)
{
_regionalChoice = value;
MarkStale();
}
private void OnNewPagePerRecordChanged(bool value)
{
_newPagePerRecord = value;
}
private void OnFontSizeChanged(int value)
{
_fontSizePt = Math.Clamp(value, PrintPresetFilters.MinFontSizePt, PrintPresetFilters.MaxFontSizePt);
}
private void OnAnswerSpaceLinesChanged(int value)
{
_answerSpaceLines = Math.Clamp(value, PrintPresetFilters.MinAnswerSpaceLines, PrintPresetFilters.MaxAnswerSpaceLines);
}
private async Task RefreshImportedTokenNamesAsync(CancellationToken token)
{
var indexFields = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration);
var discovered = await NotesService.GetImportedFieldNamesAsync(token);
_importedTokenNames =
[
.. indexFields
.Concat(discovered)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
];
}
private async Task FlushEditorAsync()
{
var value = await MarkdownTablePasteService.GetValueAsync(EditorElementId);
if (value is not null && value != _templateMarkdown)
{
_templateMarkdown = value;
await MarkStaleAsync();
}
}
private bool IsDirty()
{
return !string.Equals(_templateMarkdown, _snapshotMarkdown, StringComparison.Ordinal)
|| _entityType != _snapshotEntityType
|| !string.Equals(BuildFilters().ToJson(), _snapshotFiltersJson, StringComparison.Ordinal);
}
private void CaptureSnapshot()
{
_snapshotMarkdown = _templateMarkdown;
_snapshotEntityType = _entityType;
_snapshotFiltersJson = BuildFilters().ToJson();
}
private async Task<bool> ConfirmDiscardIfDirtyAsync()
{
await FlushEditorAsync();
if (!IsDirty())
return true;
var confirmed = await DialogService.ShowMessageBox(
"Unsaved changes",
"Discard unsaved template or filter changes?",
yesText: "Discard",
cancelText: "Stay");
return confirmed == true && !_isDisposed;
}
private async ValueTask OnLocationChanging(LocationChangingContext context)
{
if (_isDisposed)
return;
if (!await ConfirmDiscardIfDirtyAsync())
context.PreventNavigation();
}
private async Task OnPresetSelected(int? id)
{
if (id == _selectedPresetId)
return;
if (!await ConfirmDiscardIfDirtyAsync())
return;
_selectedPresetId = id;
if (!id.HasValue)
{
await MarkStaleAsync();
return;
}
var preset = _presets.FirstOrDefault(p => p.Id == id.Value);
if (preset is null)
return;
ApplyPreset(preset);
CaptureSnapshot();
await MarkStaleAsync();
}
private async Task NewTemplate()
{
if (_isDisposed || !await ConfirmDiscardIfDirtyAsync())
return;
_selectedPresetId = null;
_templateMarkdown = string.Empty;
_entityType = PrintEntityType.Student;
_grade = null;
_tsaYear = null;
_officerChoice = "any";
_teamIdentifierContains = null;
_eventNameContains = null;
_eventFormat = null;
_regionalChoice = "any";
_newPagePerRecord = true;
_fontSizePt = PrintPresetFilters.DefaultFontSizePt;
_answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines;
_editorGeneration++;
_pasteInitialized = false;
CaptureSnapshot();
await MarkStaleAsync();
}
private void ApplyPreset(PrintPreset preset)
{
_entityType = preset.EntityType;
_templateMarkdown = preset.TemplateMarkdown ?? string.Empty;
var filters = PrintPresetFilters.FromJson(preset.FiltersJson);
_grade = filters.Grade;
_tsaYear = filters.TsaYear;
_officerChoice = PrintPresetFilters.ToTriState(filters.IsOfficer);
_teamIdentifierContains = filters.TeamIdentifierContains;
_eventNameContains = filters.EventNameContains;
_eventFormat = filters.EventFormat;
_regionalChoice = PrintPresetFilters.ToTriState(filters.RegionalOnly);
_newPagePerRecord = filters.NewPagePerRecord;
_fontSizePt = filters.FontSizePt;
_answerSpaceLines = filters.AnswerSpaceLines;
_editorGeneration++;
_pasteInitialized = false;
}
private PrintPresetFilters BuildFilters() =>
new()
{
Grade = _entityType == PrintEntityType.Student ? _grade : null,
TsaYear = _entityType == PrintEntityType.Student ? _tsaYear : null,
IsOfficer = _entityType == PrintEntityType.Student ? PrintPresetFilters.FromTriState(_officerChoice) : null,
TeamIdentifierContains = _entityType == PrintEntityType.Team ? _teamIdentifierContains : null,
EventNameContains = _entityType == PrintEntityType.Event ? _eventNameContains : null,
EventFormat = _entityType == PrintEntityType.Event ? _eventFormat : null,
RegionalOnly = _entityType == PrintEntityType.Event ? PrintPresetFilters.FromTriState(_regionalChoice) : null,
NewPagePerRecord = _newPagePerRecord,
FontSizePt = _fontSizePt,
AnswerSpaceLines = _answerSpaceLines
};
private async Task OpenTokenDialog()
{
if (_isDisposed)
return;
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await RefreshImportedTokenNamesAsync(token);
var parameters = new DialogParameters<PrintTokenInsertDialog>
{
{ x => x.EntityType, _entityType },
{ x => x.ImportedFieldNames, _importedTokenNames },
{ x => x.OnInsert, EventCallback.Factory.Create<string>(this, InsertToken) }
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
await DialogService.ShowAsync<PrintTokenInsertDialog>("Insert token", parameters, options);
}
private async Task InsertToken(string tokenName)
{
if (_isDisposed)
return;
await FlushEditorAsync();
var wrapped = "{{" + tokenName + "}}";
var inserted = await MarkdownTablePasteService.InsertAtCursorAsync(EditorElementId, wrapped);
if (!inserted)
{
_templateMarkdown += wrapped;
await MarkdownTablePasteService.SetValueAsync(EditorElementId, _templateMarkdown);
}
else
{
var value = await MarkdownTablePasteService.GetValueAsync(EditorElementId);
if (value is not null)
_templateMarkdown = value;
}
await MarkStaleAsync();
}
private async Task Preview()
{
if (_isDisposed)
return;
await FlushEditorAsync();
if (!CanPreview)
{
if (!_isDisposed)
Snackbar.Add("Write a template before previewing.", Severity.Warning);
return;
}
if (!_previewStale && _didPreview)
{
await OpenPreviewDialogAsync();
return;
}
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await RefreshImportedTokenNamesAsync(token);
_pages = await NotePrintService.PreviewAsync(
new NotePrintRequest
{
EntityType = _entityType,
TemplateMarkdown = _templateMarkdown,
Filters = BuildFilters(),
ImportedFieldCatalog = _importedTokenNames
},
token);
_previewStale = false;
_didPreview = true;
if (!_isDisposed)
await OpenPreviewDialogAsync();
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Preview failed: {ex.Message}", Severity.Error);
}
finally
{
if (!_isDisposed)
_isBusy = false;
}
}
private async Task OpenPreviewDialogAsync()
{
await ClosePreviewDialogAsync();
var parameters = new DialogParameters<PrintPreviewDialog>
{
{ x => x.Pages, _pages },
{ x => x.NewPagePerRecord, _newPagePerRecord },
{ x => x.PrintPageStyle, PrintPageStyle },
{ x => x.OnPrint, EventCallback.Factory.Create(this, Print) }
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.ExtraLarge,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<PrintPreviewDialog>("Print preview", parameters, options);
_previewDialog = dialog;
_ = TrackPreviewDialog(dialog);
}
private async Task TrackPreviewDialog(IDialogReference dialog)
{
try
{
await dialog.Result;
}
catch (TaskCanceledException)
{
}
finally
{
if (_previewDialog == dialog)
_previewDialog = null;
}
}
private async Task Print()
{
if (_isDisposed || _previewStale || _pages.Count == 0)
return;
try
{
await JSRuntime.InvokeVoidAsync("window.print");
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
}
private async Task SavePreset()
{
if (_isDisposed)
return;
await FlushEditorAsync();
if (!CanPreview)
{
if (!_isDisposed)
Snackbar.Add("Write a template before saving.", Severity.Warning);
return;
}
if (_selectedPresetId.HasValue)
{
await UpdateSelectedPresetAsync();
return;
}
var name = await PromptForPresetNameAsync();
if (string.IsNullOrWhiteSpace(name) || _isDisposed)
return;
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
if (await PrintPresetService.NameExistsAsync(name, null, token))
{
Snackbar.Add($"A print preset named '{name}' already exists.", Severity.Warning);
return;
}
var created = await PrintPresetService.CreateAsync(
new PrintPreset
{
Name = name,
TemplateMarkdown = _templateMarkdown,
EntityType = _entityType,
FiltersJson = BuildFilters().ToJson()
},
token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
_selectedPresetId = created.Id;
CaptureSnapshot();
if (!_isDisposed)
Snackbar.Add($"Saved print preset '{name}'.", Severity.Success);
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not save: {ex.Message}", Severity.Error);
}
finally
{
if (!_isDisposed)
_isBusy = false;
}
}
private async Task<string?> PromptForPresetNameAsync()
{
var options = new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<PrintPresetNameDialog>("Save print preset", options);
var result = await dialog.Result;
if (result is null || result.Canceled || result.Data is not string name)
return null;
return name.Trim();
}
private async Task UpdateSelectedPresetAsync()
{
if (_isDisposed || !_selectedPresetId.HasValue)
return;
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var existing = _presets.FirstOrDefault(p => p.Id == _selectedPresetId.Value);
if (existing is null)
return;
await PrintPresetService.UpdateAsync(
new PrintPreset
{
Id = existing.Id,
Name = existing.Name,
TemplateMarkdown = _templateMarkdown,
EntityType = _entityType,
FiltersJson = BuildFilters().ToJson()
},
token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
CaptureSnapshot();
if (!_isDisposed)
Snackbar.Add($"Saved print preset '{existing.Name}'.", Severity.Success);
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not save: {ex.Message}", Severity.Error);
}
finally
{
if (!_isDisposed)
_isBusy = false;
}
}
private async Task DeletePreset()
{
if (_isDisposed || !_selectedPresetId.HasValue)
return;
var existing = _presets.FirstOrDefault(p => p.Id == _selectedPresetId.Value);
if (existing is null)
return;
var confirmed = await DialogService.ShowMessageBox(
"Delete print preset",
(MarkupString)$"Delete <b>{existing.Name}</b>? This cannot be undone.",
yesText: "Delete",
cancelText: "Cancel");
if (confirmed != true || _isDisposed)
return;
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await PrintPresetService.DeleteAsync(existing.Id, token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
_selectedPresetId = null;
CaptureSnapshot();
if (!_isDisposed)
Snackbar.Add($"Deleted print preset '{existing.Name}'.", Severity.Info);
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not delete: {ex.Message}", Severity.Error);
}
finally
{
if (!_isDisposed)
_isBusy = false;
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_navigationRegistration?.Dispose();
_navigationRegistration = null;
await ClosePreviewDialogAsync();
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -1,26 +0,0 @@
@using WebApp.Services
@for (var i = 0; i < Pages.Count; i++)
{
var page = Pages[i];
var isLast = i == Pages.Count - 1;
var pageClass = NewPagePerRecord && !isLast
? "note-print-page pagebreak"
: "note-print-page";
<MudContainer Class="@pageClass" Style="@PrintPageStyle">
<div class="markdown-content">
@((MarkupString)page.Html)
</div>
</MudContainer>
}
@code {
[Parameter, EditorRequired]
public IReadOnlyList<NotePrintPage> Pages { get; set; } = [];
[Parameter]
public bool NewPagePerRecord { get; set; } = true;
[Parameter]
public string PrintPageStyle { get; set; } = string.Empty;
}
@@ -1,37 +0,0 @@
<MudDialog>
<DialogContent>
<MudTextField @bind-Value="_name"
Label="Preset name"
Variant="Variant.Outlined"
Immediate="true"
MaxLength="100"
Autofocus="true" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Save"
Disabled="@string.IsNullOrWhiteSpace(_name)">
Save
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
private string _name = string.Empty;
private void Save()
{
var name = _name.Trim();
if (string.IsNullOrWhiteSpace(name))
return;
MudDialog.Close(DialogResult.Ok(name));
}
private void Cancel() => MudDialog.Cancel();
}
@@ -1,60 +0,0 @@
@using WebApp.Services
<MudDialog Class="no-print">
<DialogContent>
@if (Pages.Count == 0)
{
<MudText Class="mud-text-secondary">No matching records for these filters.</MudText>
}
else
{
@if (Pages.Count > 75)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
This preview has @Pages.Count pages. Printing a large set can be slow.
</MudAlert>
}
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">
@Pages.Count page@(Pages.Count == 1 ? "" : "s")
</MudText>
<PrintPageStack Pages="Pages"
NewPagePerRecord="NewPagePerRecord"
PrintPageStyle="@PrintPageStyle" />
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Print"
OnClick="PrintAsync"
Disabled="@(Pages.Count == 0)">
Print
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public IReadOnlyList<NotePrintPage> Pages { get; set; } = [];
[Parameter]
public bool NewPagePerRecord { get; set; } = true;
[Parameter]
public string PrintPageStyle { get; set; } = string.Empty;
[Parameter]
public EventCallback OnPrint { get; set; }
private async Task PrintAsync()
{
if (OnPrint.HasDelegate)
await OnPrint.InvokeAsync();
}
private void Close() => MudDialog.Close();
}
@@ -1,105 +0,0 @@
@using Core.Printing
@inject ClipboardService ClipboardService
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudTextField @bind-Value="_search"
Label="Search tokens"
Variant="Variant.Outlined"
Immediate="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Class="mb-3" />
@if (!HasAnyMatches)
{
<MudText Class="mud-text-secondary">No tokens match this search.</MudText>
}
else
{
@foreach (var group in TokenGroups)
{
var tokens = Visible(group.Tokens);
if (tokens.Count == 0)
continue;
<MudText Typo="Typo.caption" Class="mud-text-secondary">@group.Label</MudText>
<div class="mb-2">
<TokenChips Tokens="tokens" Insert="Insert" Copy="Copy" />
</div>
}
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public PrintEntityType EntityType { get; set; }
[Parameter]
public IReadOnlyList<string> ImportedFieldNames { get; set; } = [];
[Parameter]
public EventCallback<string> OnInsert { get; set; }
private string _search = string.Empty;
private IEnumerable<(string Label, IReadOnlyList<string> Tokens)> TokenGroups
{
get
{
yield return ("Layout", PrintFieldCatalog.Layout);
yield return ("Chapter", PrintFieldCatalog.Chapter);
yield return (EntityType.ToString(), PrintFieldCatalog.EntityTokens(EntityType));
if (EntityType == PrintEntityType.Student)
yield return ("Event ranks", PrintFieldCatalog.StudentRanks);
if (EntityType == PrintEntityType.Student && ImportedFieldNames.Count > 0)
yield return ("Additional fields", ImportedFieldNames);
}
}
private bool HasAnyMatches =>
TokenGroups.Any(group => Visible(group.Tokens).Count > 0);
private IReadOnlyList<string> Visible(IReadOnlyList<string> tokens) =>
string.IsNullOrWhiteSpace(_search)
? tokens
: [.. tokens.Where(Matches)];
private bool Matches(string token) =>
string.IsNullOrWhiteSpace(_search)
|| token.Contains(_search.Trim(), StringComparison.OrdinalIgnoreCase);
private async Task Insert(string token)
{
if (OnInsert.HasDelegate)
await OnInsert.InvokeAsync(token);
}
private async Task Copy(string token)
{
try
{
await ClipboardService.WriteTextAsync("{{" + token + "}}");
Snackbar.Add("Copied {{" + token + "}}", Severity.Info);
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
Snackbar.Add($"Could not copy: {ex.Message}", Severity.Error);
}
}
private void Close() => MudDialog.Close();
}
@@ -1,27 +0,0 @@
@foreach (var token in Tokens)
{
var name = token;
<span class="d-inline-flex align-center mr-1 mb-1">
<MudChip T="string"
Size="Size.Small"
OnClick="() => Insert.InvokeAsync(name)">
{{@name}}
</MudChip>
<MudTooltip Text="Copy">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Size="Size.Small"
OnClick="() => Copy.InvokeAsync(name)" />
</MudTooltip>
</span>
}
@code {
[Parameter, EditorRequired]
public IReadOnlyList<string> Tokens { get; set; } = [];
[Parameter]
public EventCallback<string> Insert { get; set; }
[Parameter]
public EventCallback<string> Copy { get; set; }
}
@@ -1,104 +0,0 @@
@using Core.Services
@using PSC.Blazor.Components.MarkdownEditor
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject MarkdownTablePasteService MarkdownTablePasteService
@inject ISnackbar Snackbar
<MudText Typo="Typo.h5" Class="mb-4">Notes</MudText>
@if (_isLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else if (ReadOnly)
{
@if (string.IsNullOrWhiteSpace(_content))
{
<MudText Class="mud-text-secondary">No notes yet.</MudText>
}
else
{
@((MarkupString)MarkdownHelper.ToHtml(_content))
}
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown is supported. Additional fields appear in a table and can be edited.</MudText>
<MarkdownEditor Value="@_content"
ValueChanged="@((string? value) => _content = value ?? string.Empty)"
Placeholder="Student notes..."
AutoSaveEnabled="false"
NativeSpellChecker="false" />
}
@code {
[Parameter]
public int StudentId { get; set; }
[Parameter]
public bool ReadOnly { get; set; }
private string _content = string.Empty;
private int? _noteId;
private int _loadedStudentId;
private bool _isLoading = true;
private bool _pasteInitialized;
protected override async Task OnParametersSetAsync()
{
if (StudentId <= 0 || _loadedStudentId == StudentId)
return;
_isLoading = true;
try
{
var note = await NotesService.GetStudentNoteAsync(StudentId);
_noteId = note?.Id;
_content = note?.Content ?? string.Empty;
_loadedStudentId = StudentId;
}
finally
{
_isLoading = false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ReadOnly || _pasteInitialized)
return;
await Task.Delay(150);
await MarkdownTablePasteService.InitializeAsync();
_pasteInitialized = true;
}
public async Task SaveAsync()
{
if (StudentId <= 0)
return;
if (_noteId is null)
{
if (string.IsNullOrWhiteSpace(_content))
return;
var created = await NotesService.CreateNoteAsync(new Note
{
Title = NoteNamingService.GetStudentNoteTitle(StudentId),
Content = _content
});
_noteId = created.Id;
return;
}
var existing = await NotesService.GetNoteAsync(_noteId.Value);
if (existing is null)
return;
if (string.Equals(existing.Content ?? string.Empty, _content, StringComparison.Ordinal))
return;
existing.Content = _content;
await NotesService.UpdateNoteAsync(existing);
}
}
@@ -7,7 +7,7 @@
Label="@Label" Label="@Label"
@bind-Value="_currentStudent" @bind-Value="_currentStudent"
SearchFunc="@SearchStudents" SearchFunc="@SearchStudents"
ToStringFunc="@(s => ShowFullName ? s?.FirstNameLastName : s?.DisplayFirstName)" ToStringFunc="@(s => ShowFullName ? s?.FirstNameLastName : s?.FirstName)"
Immediate="true" Immediate="true"
ResetValueOnEmptyText="true" ResetValueOnEmptyText="true"
CoerceText="false" CoerceText="false"
@@ -21,7 +21,7 @@
} }
else else
{ {
@student.DisplayFirstName @student.FirstName
} }
@if (ShowGrade) @if (ShowGrade)
{ {
@@ -33,11 +33,11 @@
@if (SelectedStudents.Any()) @if (SelectedStudents.Any())
{ {
<MudChipSet T="Student" AllClosable="true" Class="mt-2"> <MudChipSet T="Student" AllClosable="true" Class="mt-2">
@foreach (var student in SelectedStudents.OrderBy(s => s.DisplayFirstName)) @foreach (var student in SelectedStudents.OrderBy(s => s.FirstName))
{ {
<MudChip T="Student" <MudChip T="Student"
Value="@student" Value="@student"
Text="@(ShowFullName ? student.FirstNameLastName : student.DisplayFirstName)" Text="@(ShowFullName ? student.FirstNameLastName : student.FirstName)"
OnClose="@(() => RemoveStudent(student))" /> OnClose="@(() => RemoveStudent(student))" />
} }
</MudChipSet> </MudChipSet>
@@ -91,9 +91,8 @@
return Task.FromResult<IEnumerable<Student>>(Students return Task.FromResult<IEnumerable<Student>>(Students
.Where(s => !SelectedStudents.Contains(s)) .Where(s => !SelectedStudents.Contains(s))
.Where(s => s.FirstName.ToLower().Contains(search) || .Where(s => s.FirstName.ToLower().Contains(search) ||
s.LastName.ToLower().Contains(search) || s.LastName.ToLower().Contains(search))
(s.Nickname != null && s.Nickname.ToLower().Contains(search))) .OrderBy(s => s.FirstName));
.OrderBy(s => s.DisplayFirstName));
} }
private void RemoveStudent(Student student) private void RemoveStudent(Student student)
@@ -9,7 +9,7 @@
ValuesChanged="@OnSelectedStudentsChanged" ValuesChanged="@OnSelectedStudentsChanged"
Vertical="true" Vertical="true"
CheckMark> CheckMark>
@foreach (var student in Students.OrderBy(e => e.DisplayFirstName)) @foreach (var student in Students.OrderBy(e => e.FirstName))
{ {
<MudToggleItem Value="@student" Style="font-size: .75rem;"> <MudToggleItem Value="@student" Style="font-size: .75rem;">
@if (ShowFullName) @if (ShowFullName)
@@ -18,7 +18,7 @@
} }
else else
{ {
@student.DisplayFirstName @student.FirstName
} }
</MudToggleItem> </MudToggleItem>
} }
@@ -31,9 +31,6 @@
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudTextField T="string" Label="Last Name" @bind-Value="Student.LastName" For="@(() => Student.LastName)" Variant="Variant.Outlined"></MudTextField> <MudTextField T="string" Label="Last Name" @bind-Value="Student.LastName" For="@(() => Student.LastName)" Variant="Variant.Outlined"></MudTextField>
</MudItem> </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"> <MudItem xs="12" sm="6">
<MudTextField T="string" Label="Email Address" @bind-Value="Student.Email" For="@(() => Student.Email)" Variant="Variant.Outlined"></MudTextField> <MudTextField T="string" Label="Email Address" @bind-Value="Student.Email" For="@(() => Student.Email)" Variant="Variant.Outlined"></MudTextField>
</MudItem> </MudItem>
@@ -83,7 +80,6 @@
try try
{ {
Student.NormalizeNickname();
Context.Students.Add(Student); Context.Students.Add(Student);
await Context.SaveChangesAsync(); await Context.SaveChangesAsync();

Some files were not shown because too many files have changed in this diff Show More