diff --git a/Core/Entities/PrintPreset.cs b/Core/Entities/PrintPreset.cs new file mode 100644 index 0000000..56b234b --- /dev/null +++ b/Core/Entities/PrintPreset.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Core.Printing; + +namespace Core.Entities; + +/// +/// Saved page-printer recipe: template note, entity type, and filters. Merged output is not stored. +/// +public class PrintPreset +{ + public int Id { get; set; } + + [Required] + [StringLength(100)] + public string Name { get; set; } = null!; + + public int NoteId { get; set; } + + public Note Note { get; set; } = null!; + + public PrintEntityType EntityType { get; set; } + + public string FiltersJson { get; set; } = "{}"; + + public DateTime UpdatedAt { get; set; } +} diff --git a/Core/Notes/ImportedFieldsTable.cs b/Core/Notes/ImportedFieldsTable.cs index bfd700e..0a1cc45 100644 --- a/Core/Notes/ImportedFieldsTable.cs +++ b/Core/Notes/ImportedFieldsTable.cs @@ -7,7 +7,7 @@ namespace Core.Notes; /// public static class ImportedFieldsTable { - public const string Heading = "## Imported fields"; + public const string Heading = "## Additional fields"; public static string NormalizeValue(string? raw) { @@ -18,7 +18,7 @@ public static class ImportedFieldsTable } /// - /// Reads Field/Value rows from the Imported fields section. + /// Reads Field/Value rows from the Additional fields section. /// public static List ParseFields(string? markdown) { @@ -50,7 +50,7 @@ public static class ImportedFieldsTable } /// - /// Upserts incoming fields into the Imported fields section. Incoming values win. + /// Upserts incoming fields into the Additional fields section. Incoming values win. /// Fields not in are kept. Identical values are not changes. /// public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList incoming) @@ -126,7 +126,7 @@ public static class ImportedFieldsTable } /// - /// Unique imported field names across notes, first-seen casing, sorted A–Z. + /// Unique additional-field names across notes, first-seen casing, sorted A–Z. /// public static List DistinctFieldNames(IEnumerable markdowns) { @@ -150,11 +150,10 @@ public static class ImportedFieldsTable if (string.IsNullOrEmpty(markdown)) return null; - var start = IndexOfHeading(markdown); - if (start < 0) + if (!TryFindHeading(markdown, out var start, out var headingLength)) return null; - var afterHeading = start + Heading.Length; + var afterHeading = start + headingLength; var nextHeading = FindNextHeading(markdown, afterHeading); return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading]; } @@ -164,8 +163,7 @@ public static class ImportedFieldsTable if (string.IsNullOrWhiteSpace(existingMarkdown)) return section.TrimEnd() + Environment.NewLine; - var start = IndexOfHeading(existingMarkdown); - if (start < 0) + if (!TryFindHeading(existingMarkdown, out var start, out var headingLength)) { var prefix = existingMarkdown.TrimEnd(); return string.IsNullOrEmpty(prefix) @@ -173,7 +171,7 @@ public static class ImportedFieldsTable : prefix + Environment.NewLine + Environment.NewLine + section; } - var afterHeading = start + Heading.Length; + var afterHeading = start + headingLength; var nextHeading = FindNextHeading(existingMarkdown, afterHeading); var before = existingMarkdown[..start].TrimEnd(); var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart(); @@ -199,8 +197,45 @@ public static class ImportedFieldsTable return builder.ToString(); } - private static int IndexOfHeading(string markdown) => - markdown.IndexOf(Heading, StringComparison.Ordinal); + /// + /// First ## … fields heading (any prefix). New sections are written as . + /// + 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) { diff --git a/Core/Printing/NoteTemplateMerger.cs b/Core/Printing/NoteTemplateMerger.cs new file mode 100644 index 0000000..f4ebc69 --- /dev/null +++ b/Core/Printing/NoteTemplateMerger.cs @@ -0,0 +1,73 @@ +using System.Text.RegularExpressions; + +namespace Core.Printing; + +/// +/// Replaces {{Token}} placeholders from a case-insensitive map. +/// Unknown tokens are left unchanged. Known empty values become blank. +/// {{PageBreak}} becomes a print page break after HTML conversion. +/// {{AnswerSpace}} becomes ruled write-in space after HTML conversion. +/// +public static class NoteTemplateMerger +{ + public const string PageBreakToken = "PageBreak"; + public const string PageBreakSentinel = ""; + public const string PageBreakHtml = "
"; + + public const string AnswerSpaceToken = "AnswerSpace"; + public const string AnswerSpaceSentinel = ""; + public const string AnswerSpaceHtml = "
"; + + private readonly record struct LayoutToken(string Name, string Sentinel, string Html); + + private static readonly LayoutToken[] LayoutTokens = + [ + new(PageBreakToken, PageBreakSentinel, PageBreakHtml), + new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml) + ]; + + private static readonly Regex TokenRegex = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled); + + public static string Merge(string? template, IReadOnlyDictionary 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; + } + + return tokens.TryGetValue(key, out var value) + ? value ?? string.Empty + : match.Value; + }); + } + + /// + /// Turns layout sentinels into HTML after markdown has been rendered. + /// + public static string ApplyLayout(string? html) + { + if (string.IsNullOrEmpty(html)) + return string.Empty; + + foreach (var layout in LayoutTokens) + { + html = html + .Replace($"

{layout.Sentinel}

", layout.Html, StringComparison.Ordinal) + .Replace(layout.Sentinel, layout.Html, StringComparison.Ordinal); + } + + return html; + } +} diff --git a/Core/Printing/PrintEntityType.cs b/Core/Printing/PrintEntityType.cs new file mode 100644 index 0000000..79f23be --- /dev/null +++ b/Core/Printing/PrintEntityType.cs @@ -0,0 +1,11 @@ +namespace Core.Printing; + +/// +/// Entity a print preset merges a note onto. +/// +public enum PrintEntityType +{ + Student, + Team, + Event +} diff --git a/Core/Printing/PrintFieldCatalog.cs b/Core/Printing/PrintFieldCatalog.cs new file mode 100644 index 0000000..63f5953 --- /dev/null +++ b/Core/Printing/PrintFieldCatalog.cs @@ -0,0 +1,79 @@ +namespace Core.Printing; + +/// +/// Built-in merge token names. Imported student-note field names are supplied at runtime. +/// +public static class PrintFieldCatalog +{ + public static readonly string[] Layout = + [ + NoteTemplateMerger.PageBreakToken, + NoteTemplateMerger.AnswerSpaceToken + ]; + + 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" + ]; + + public static readonly string[] Team = + [ + "Identifier", + "Name", + "EventName", + "EventShortName", + "EventFormat", + "TeamSize", + "Eligibility", + "Description", + "Theme" + ]; + + public static readonly string[] Event = + [ + "Name", + "ShortName", + "EventFormat", + "TeamSize", + "Eligibility", + "LevelOfEffort", + "SemifinalistActivity", + "RegionalEvent", + "Description", + "Theme", + "Documentation", + "Notes" + ]; + + public static IReadOnlyList EntityTokens(PrintEntityType entityType) => + entityType switch + { + PrintEntityType.Student => Student, + PrintEntityType.Team => Team, + PrintEntityType.Event => Event, + _ => [] + }; + + public static IReadOnlyList BuiltInFor(PrintEntityType entityType) => + [.. Chapter, .. EntityTokens(entityType)]; +} diff --git a/Core/Printing/PrintPresetFilters.cs b/Core/Printing/PrintPresetFilters.cs new file mode 100644 index 0000000..a794a0d --- /dev/null +++ b/Core/Printing/PrintPresetFilters.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Core.Entities; + +namespace Core.Printing; + +/// +/// Filter payload stored on a . Unused fields stay null. +/// +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; } + + /// + /// true officers only, false non-officers only, null any. + /// + public bool? IsOfficer { get; set; } + + public string? TeamIdentifierContains { get; set; } + + public string? EventNameContains { get; set; } + + public EventFormat? EventFormat { get; set; } + + /// + /// true regional only, false non-regional only, null any. + /// + public bool? RegionalOnly { get; set; } + + /// + /// When true (default), each merged record starts a new printed page. + /// {{PageBreak}} in the template still works either way. + /// + 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; + + /// + /// Body font size in points for merged pages. + /// + public int FontSizePt { get; set; } = DefaultFontSizePt; + + /// + /// Ruled write-in lines for each {{AnswerSpace}}. + /// + 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(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 + }; +} diff --git a/Core/Printing/PrintTokenMap.cs b/Core/Printing/PrintTokenMap.cs new file mode 100644 index 0000000..ca27b76 --- /dev/null +++ b/Core/Printing/PrintTokenMap.cs @@ -0,0 +1,55 @@ +namespace Core.Printing; + +/// +/// 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. +/// +public static class PrintTokenMap +{ + public static Dictionary Create() => + new(StringComparer.OrdinalIgnoreCase); + + public static Dictionary Build( + IReadOnlyDictionary? imported, + IReadOnlyDictionary? entity, + IReadOnlyDictionary? chapter) + { + var map = Create(); + Apply(map, imported); + Apply(map, entity); + Apply(map, chapter); + return map; + } + + public static void Apply(IDictionary map, IReadOnlyDictionary? values) + { + if (values is null) + return; + + foreach (var (key, value) in values) + { + if (string.IsNullOrWhiteSpace(key)) + continue; + map[key] = Escape(value); + } + } + + /// + /// Treats substituted values as plain text so they cannot change markdown or inject HTML. + /// + public static string Escape(string? value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return 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); + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 49f191d..d8254cf 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -15,6 +15,7 @@ namespace Data public DbSet Notes { get; set; } public DbSet NoteHistories { get; set; } public DbSet TeamMeetingHistories { get; set; } + public DbSet PrintPresets { get; set; } public AppDbContext() { diff --git a/Data/Configurations/PrintPresetConfiguration.cs b/Data/Configurations/PrintPresetConfiguration.cs new file mode 100644 index 0000000..59c2a86 --- /dev/null +++ b/Data/Configurations/PrintPresetConfiguration.cs @@ -0,0 +1,35 @@ +using Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Data.Configurations; + +public class PrintPresetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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() + .HasMaxLength(32) + .IsRequired(); + + builder.Property(p => p.FiltersJson) + .IsRequired() + .HasColumnType("TEXT"); + + builder.HasOne(p => p.Note) + .WithMany() + .HasForeignKey(p => p.NoteId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + } +} diff --git a/Data/Migrations/20260830040000_AddPrintPresets.cs b/Data/Migrations/20260830040000_AddPrintPresets.cs new file mode 100644 index 0000000..08b1a1e --- /dev/null +++ b/Data/Migrations/20260830040000_AddPrintPresets.cs @@ -0,0 +1,60 @@ +using System; +using Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.Migrations +{ + /// + [DbContext(typeof(AppDbContext))] + [Migration("20260830040000_AddPrintPresets")] + public partial class AddPrintPresets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PrintPresets", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + NoteId = table.Column(type: "INTEGER", nullable: false), + EntityType = table.Column(type: "TEXT", maxLength: 32, nullable: false), + FiltersJson = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PrintPresets"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 4d4704b..a41b284 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -177,6 +177,42 @@ namespace Data.Migrations b.ToTable("EventOccurrences"); }); + modelBuilder.Entity("Core.Entities.PrintPreset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("FiltersJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NoteId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("NoteId"); + + b.ToTable("PrintPresets"); + }); + modelBuilder.Entity("Core.Entities.Note", b => { b.Property("Id") @@ -467,6 +503,17 @@ namespace Data.Migrations b.Navigation("Note"); }); + modelBuilder.Entity("Core.Entities.PrintPreset", b => + { + b.HasOne("Core.Entities.Note", "Note") + .WithMany() + .HasForeignKey("NoteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Note"); + }); + modelBuilder.Entity("Core.Entities.StudentEventRanking", b => { b.HasOne("Core.Entities.EventDefinition", "EventDefinition") diff --git a/Tests/Notes/ImportedFieldsTable_Tests.cs b/Tests/Notes/ImportedFieldsTable_Tests.cs index baac530..70d2cb0 100644 --- a/Tests/Notes/ImportedFieldsTable_Tests.cs +++ b/Tests/Notes/ImportedFieldsTable_Tests.cs @@ -47,6 +47,34 @@ public class ImportedFieldsTable_Tests 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] diff --git a/Tests/Printing/NoteTemplateMerger_Tests.cs b/Tests/Printing/NoteTemplateMerger_Tests.cs new file mode 100644 index 0000000..a20ec8e --- /dev/null +++ b/Tests/Printing/NoteTemplateMerger_Tests.cs @@ -0,0 +1,119 @@ +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_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( + $"

{NoteTemplateMerger.PageBreakSentinel}

"); + + 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( + $"

{NoteTemplateMerger.AnswerSpaceSentinel}

"); + + Assert.That(raw, Is.EqualTo($"x{NoteTemplateMerger.AnswerSpaceHtml}y")); + Assert.That(wrapped, Is.EqualTo(NoteTemplateMerger.AnswerSpaceHtml)); + } +} diff --git a/Tests/Printing/PrintFieldCatalog_Tests.cs b/Tests/Printing/PrintFieldCatalog_Tests.cs new file mode 100644 index 0000000..b181bd7 --- /dev/null +++ b/Tests/Printing/PrintFieldCatalog_Tests.cs @@ -0,0 +1,39 @@ +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.Not.Contain("Interview Time")); + } + + [Test] + public void BuiltInFor_TeamAndEventHaveExpectedNames() + { + Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventName")); + Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalEvent")); + } + + [Test] + public void Layout_IncludesPageBreakAndAnswerSpace() + { + Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.PageBreakToken)); + Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.AnswerSpaceToken)); + } + + [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)); + } +} diff --git a/Tests/Printing/PrintPresetFilters_Tests.cs b/Tests/Printing/PrintPresetFilters_Tests.cs new file mode 100644 index 0000000..6c8ee77 --- /dev/null +++ b/Tests/Printing/PrintPresetFilters_Tests.cs @@ -0,0 +1,54 @@ +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)); + } +} diff --git a/Tests/Printing/PrintTokenMap_Tests.cs b/Tests/Printing/PrintTokenMap_Tests.cs new file mode 100644 index 0000000..57aba76 --- /dev/null +++ b/Tests/Printing/PrintTokenMap_Tests.cs @@ -0,0 +1,59 @@ +using Core.Printing; + +namespace Tests.Printing; + +[TestFixture] +public class PrintTokenMap_Tests +{ + [Test] + public void Build_BuiltInWinsOverImportedSameName() + { + var map = PrintTokenMap.Build( + new Dictionary { ["Grade"] = "imported" }, + new Dictionary { ["Grade"] = "9" }, + null); + + Assert.That(map["Grade"], Is.EqualTo("9")); + } + + [Test] + public void Build_IncludesAllImportedCatalogKeys() + { + var imported = new Dictionary + { + ["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*