using System.IO.Compression; using LehrerApp.Templating; using Xunit; namespace LehrerApp.Templating.Tests; public sealed class TemplatingTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lavorlage-{Guid.NewGuid():N}"); public TemplatingTests() => Directory.CreateDirectory(_directory); [Fact] public void LayoutParser_LiestAlleElementtypenUndAttribute() { var layout = new LayoutParser().Parse(""" # Beispiel PAGE 210 297 mm BG background.png IMG logo.png 15 15 30 12 scale=50% TEXT 20 45 $Datum|dd.MM.yyyy size=10 TEXTBOX 20 90 170 120 $Brieftext wrap=true FLOWBOX 20 20 170 250 $Brieftext size=11 TABLE 20 215 170 40 $Zeilen size=9 CHART 20 260 170 25 $Werte type=line """); Assert.Equal(7, layout.Elements.Count); Assert.Equal("50%", Assert.IsType(layout.Elements[1]).Attributes["scale"]); Assert.Equal("dd.MM.yyyy", Assert.IsType(layout.Elements[2]).Format); Assert.IsType(layout.Elements[4]); Assert.Equal("line", Assert.IsType(layout.Elements[6]).ChartType); } [Fact] public void Systemvariablen_BrauchenKeineManifestDeklarationUndRendernMitSeitenzahlen() { var manifest = new TemplateManifest { Id = "system", Name = "Systemvariablen" }; var layout = new LayoutParser().Parse(""" PAGE 210 297 mm TEXT 20 10 "Stand: $$today" size=9 TEXT 140 285 "Seite $$curPage von $$maxPageNum" size=9 FLOWBOX 20 20 170 250 "Langer Inhalt für zwei Seiten.\nLanger Inhalt für zwei Seiten." """); var template = new LoadedTemplate(manifest, layout, new Dictionary()); var validation = new TemplateLoader().Validate(template, new Dictionary()); var pdf = new QuestTemplateRenderer().RenderToPdf(template, new DictionaryProvider(new Dictionary())); Assert.True(validation.IsValid); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4)); } [Fact] public void FlowBox_FliesstAufFolgeseitenUndPaketEnthaeltFolgeseitenLayout() { var path = Path.Combine(_directory, "flow.lavorlage"); var manifest = new TemplateManifest { Id = "flow", Name = "Fließtext", ContinuationLayoutFile = "continuation.tpl", Placeholders = [new("Text", PlaceholderType.Multiline, true)], }; var first = "PAGE 210 297 mm\nTEXT 20 10 \"Erste Seite\" size=14\nFLOWBOX 20 25 170 252 $Text size=11"; var continuation = "PAGE 210 297 mm\nTEXT 20 10 \"Folgeseite\" size=10\nFLOWBOX 20 25 170 252 $Text size=11"; TemplatePackage.Create(path, manifest, first, new Dictionary(), continuation); var loaded = new TemplateLoader().LoadFromPackage(path); var longText = string.Join('\n', Enumerable.Repeat( "Ein langer Klassenbucheintrag mit ausreichend Inhalt für den automatischen Seitenumbruch.", 250)); var pdf = new QuestTemplateRenderer().RenderToPdf(loaded, new DictionaryProvider(new Dictionary { ["Text"] = new MultilineValue(longText) })); var source = System.Text.Encoding.ASCII.GetString(pdf); Assert.NotNull(loaded.ContinuationLayout); Assert.True(System.Text.RegularExpressions.Regex.Matches(source, @"/Type\s*/Page\b").Count > 1); } [Fact] public void DrawBox_RendertDeklarativeExterneZeichenbefehleInFesterBox() { var template = new LoadedTemplate(new TemplateManifest { Placeholders = [new("Grafik", PlaceholderType.Drawing, true)], }, new LayoutParser().Parse("PAGE 210 297 mm\nDRAWBOX 20 20 100 60 $Grafik"), new Dictionary()); var drawing = new DrawingValue( [new DrawRectangle(0, 0, 100, 60, "#1D4ED8", 1, "#EFF6FF"), new DrawString(5, 5, "Externer Inhalt", 11, Bold: true), new MoveTo(5, 25), new LineTo(95, 25, "#DC2626", 1.5f), new DrawLine(5, 35, 95, 50, "#059669", 1)], 60); var pdf = new QuestTemplateRenderer().RenderToPdf(template, new DictionaryProvider(new Dictionary { ["Grafik"] = drawing })); Assert.Single(System.Text.RegularExpressions.Regex.Matches( System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b")); } [Fact] public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum() { var template = new LoadedTemplate(new TemplateManifest { Placeholders = [new("Protokoll", PlaceholderType.Drawing, true)], }, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Protokoll"), new Dictionary()); var drawing = new DrawingValue( Enumerable.Range(0, 80).SelectMany(i => (DrawingCommand[]) [new DrawString(0, i * 10, $"Zeile {i + 1}", 8), new DrawLine(0, i * 10 + 9, 160, i * 10 + 9, "#CBD5E1", .3f)]) .ToList(), 800); var pdf = new QuestTemplateRenderer().RenderToPdf(template, new DictionaryProvider(new Dictionary { ["Protokoll"] = drawing })); Assert.True(System.Text.RegularExpressions.Regex.Matches( System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count >= 4); } [Fact] public void FlowDrawBox_CallbackErhaeltSeitenflaecheStateUndSteuertSeitenwechsel() { var template = new LoadedTemplate(new TemplateManifest { Placeholders = [new("Bericht", PlaceholderType.Drawing, true)], }, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Bericht"), new Dictionary()); var invocations = 0; var drawing = new PagedDrawingValue(context => { invocations++; var item = context.State is int value ? value : 0; context.Canvas.DrawRectangle(0, 0, context.Width, 20, "#1D4ED8", .5f, "#EFF6FF"); context.Canvas.DrawStringEx(0, 2, 14, context.Width, $"Untrennbarer Block {item + 1}", DrawingTextAlignment.AlignCenter, 11, "Arial", "#1E3A8A", bold: true); context.State = item + 1; return context.PageNumber == 3; }, InitialState: 0); var pdf = new QuestTemplateRenderer().RenderToPdf(template, new DictionaryProvider(new Dictionary { ["Bericht"] = drawing })); Assert.Equal(3, invocations); Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches( System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count); } [Theory] [InlineData("scale=0%")] [InlineData("scale=-10%")] [InlineData("scale=1001%")] [InlineData("scale=abc")] public void LayoutParser_LehntUngueltigeBildskalierungAb(string attribute) { var exception = Assert.Throws(() => new LayoutParser().Parse($"PAGE 210 297 mm\nIMG logo.png 10 10 20 20 {attribute}")); Assert.Contains(exception.Result.Issues, issue => issue.Message.Contains("scale", StringComparison.OrdinalIgnoreCase) || issue.Message.Contains("keine Zahl", StringComparison.OrdinalIgnoreCase)); } [Fact] public void LayoutParser_MeldetAlleSyntaxfehlerMitZeilen() { var exception = Assert.Throws(() => new LayoutParser().Parse(""" TEXT 1 2 "vor PAGE" PAGE x 297 mm UNBEKANNT 1 """)); Assert.True(exception.Result.Issues.Count >= 3); Assert.Equal(3, exception.Result.Issues.Count(issue => issue.Line is not null)); } [Fact] public void Loader_BlockiertPathTraversal() { var path = Path.Combine(_directory, "unsafe.lavorlage"); using (var archive = ZipFile.Open(path, ZipArchiveMode.Create)) { Write(archive, "../manifest.json", "{}"); Write(archive, "manifest.json", ManifestJson()); Write(archive, "layout.tpl", "PAGE 210 297 mm"); } var exception = Assert.Throws(() => new TemplateLoader().LoadFromPackage(path)); Assert.Contains(exception.Result.Issues, x => x.Message.Contains("Unsicherer Paketpfad")); } [Fact] public void Loader_SammeltUnbekanntenPlatzhalterUndFehlendesBild() { var path = Path.Combine(_directory, "invalid.lavorlage"); using (var archive = ZipFile.Open(path, ZipArchiveMode.Create)) { Write(archive, "manifest.json", ManifestJson()); Write(archive, "layout.tpl", "PAGE 210 297 mm\nBG fehlt.png\nTEXT 10 10 $Unbekannt"); } var exception = Assert.Throws(() => new TemplateLoader().LoadFromPackage(path)); Assert.Equal(2, exception.Result.Issues.Count); } [Fact] public void Validate_SammeltFehlendeUndFalscheDatentypen() { var loaded = new LoadedTemplate(new TemplateManifest { Placeholders = [new("Datum", PlaceholderType.Date, true), new("Text", PlaceholderType.Text, true)], }, new(210, 297, "mm", []), new Dictionary()); var result = new TemplateLoader().Validate(loaded, new Dictionary { ["Datum"] = PlaceholderType.Text }); Assert.False(result.IsValid); Assert.Equal(2, result.Issues.Count); } [Fact] public void Paket_RoundtripUndPdfSindGueltig() { var path = Path.Combine(_directory, "brief.lavorlage"); var manifest = new TemplateManifest { Id = "brief", Name = "Brief", Placeholders = [new("Datum", PlaceholderType.Date, true), new("Text", PlaceholderType.Multiline, true)], }; TemplatePackage.Create(path, manifest, """ PAGE 210 297 mm TEXT 20 20 "Elternbrief" size=18 bold=true color=#1D4ED8 TEXT 20 35 $Datum|dd.MM.yyyy size=10 TEXTBOX 20 50 170 200 $Text size=11 """, new Dictionary()); var loaded = new TemplateLoader().LoadFromPackage(path); var pdf = new QuestTemplateRenderer().RenderToPdf(loaded, new DictionaryProvider(new Dictionary { ["Datum"] = new DateValue(new DateOnly(2026, 8, 30)), ["Text"] = new MultilineValue("Sehr geehrte Eltern,\n\ndies ist ein Testbrief."), })); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4)); Assert.True(pdf.Length > 1_000); } [Fact] public void Paket_RoundtripErhaeltAuchNochNichtVerwendeteBildAssets() { var path = Path.Combine(_directory, "assets.lavorlage"); var png = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="); TemplatePackage.Create(path, new TemplateManifest { Id = "assets", Name = "Assets" }, "PAGE 210 297 mm\nIMG logo.png 10 10 20 20", new Dictionary { ["logo.png"] = png, ["assets/siegel.png"] = png }); var loaded = new TemplateLoader().LoadFromPackage(path); Assert.Equal(2, loaded.Assets.Count); Assert.Contains("assets/siegel.png", loaded.Assets.Keys); } private static string ManifestJson() => """ { "schemaVersion": 1, "id": "test", "name": "Test", "pageSize": { "width": 210, "height": 297, "unit": "mm" }, "layoutFile": "layout.tpl", "placeholders": [] } """; private static void Write(ZipArchive archive, string path, string content) { using var writer = new StreamWriter(archive.CreateEntry(path).Open()); writer.Write(content); } public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } } internal sealed class DictionaryProvider(IReadOnlyDictionary values) : ITemplateDataProvider { public IReadOnlyDictionary GetValues() => values; }