Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f400813667 |
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -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())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
occurrences[groupKey] = groupList;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset section level when we successfully parse an occurrence (means we're in a valid section)
|
groupList.Add(eventOccurrence);
|
||||||
currentSectionLevel = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 acceptedEventsForStudent = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
var acceptedRanksForStudent = new HashSet<int>();
|
|
||||||
|
|
||||||
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++)
|
var competitiveEvents = new List<EventDefinition>();
|
||||||
|
|
||||||
|
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);
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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\"> </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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace Core.Printing;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entity a print preset merges a note onto.
|
|
||||||
/// </summary>
|
|
||||||
public enum PrintEntityType
|
|
||||||
{
|
|
||||||
Student,
|
|
||||||
Team,
|
|
||||||
Event
|
|
||||||
}
|
|
||||||
@@ -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)]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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(" · ", 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>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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("<", "<", StringComparison.Ordinal)
|
|
||||||
.Replace(">", ">", 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("&", "&", StringComparison.Ordinal)
|
|
||||||
.Replace("<", "<", StringComparison.Ordinal)
|
|
||||||
.Replace(">", ">", StringComparison.Ordinal)
|
|
||||||
.Replace("\"", """, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FlattenLines(string value) =>
|
|
||||||
string.Join(' ',
|
|
||||||
value.Split(['\r', '\n', '\u2028', '\u2029'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.");
|
|
||||||
|
|
||||||
// Verify all expected occurrence names are present
|
var allNames = msOccurrences.Concat(hsOccurrences).Select(o => o.Name).ToList();
|
||||||
var occurrenceNames = allOccurrences.Select(o => o.Name).ToList();
|
Assert.That(allNames, Does.Contain("Submit Entry"));
|
||||||
Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences");
|
Assert.That(allNames, Does.Contain("Judging"));
|
||||||
Assert.That(occurrenceNames, Does.Contain("Judging"), "Should have Judging occurrences");
|
Assert.That(allNames, Does.Contain("Pick-up"));
|
||||||
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?>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 <b>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void For_UsesTheSameMarksAsTheLegend()
|
|
||||||
{
|
|
||||||
var evt = EventDefinitionBuilder.Individual("Coding")
|
|
||||||
.AsOnSite()
|
|
||||||
.AsRegionalEvent()
|
|
||||||
.WithPresubmission()
|
|
||||||
.WithLevelOfEffort(2)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var fromCatalog = string.Join(
|
|
||||||
" ",
|
|
||||||
EventAttributeMarks.LegendItems.Where(m => m.Applies(evt)).Select(m => m.Symbol));
|
|
||||||
|
|
||||||
Assert.That(EventAttributeMarks.For(evt), Is.EqualTo(fromCatalog));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Legend_IncludesRankDotsAndAttributeMarks()
|
|
||||||
{
|
|
||||||
var html = PrintRankBadgeHtml.Legend();
|
|
||||||
|
|
||||||
Assert.That(html, Does.Contain("print-badge-legend"));
|
|
||||||
Assert.That(html, Does.Contain("·"));
|
|
||||||
Assert.That(html, Does.Not.Contain(EventRankLegend.Ordinal(1)));
|
|
||||||
Assert.That(html, Does.Not.Contain("event-rank-1"));
|
|
||||||
foreach (var mark in EventAttributeMarks.LegendItems)
|
|
||||||
{
|
|
||||||
Assert.That(html, Does.Contain(mark.Symbol));
|
|
||||||
Assert.That(html, Does.Contain(mark.Label));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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("<"));
|
|
||||||
Assert.That(escaped, Does.Contain(">"));
|
|
||||||
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| --- | --- |"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,84 @@ 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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="8">
|
<MudItem xs="8">
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user