Files
LehrerApp/LehrerApp.Templating.Tests/TemplatingTests.cs
T
admin 861f2c76ca
CI / build-and-test (push) Waiting to run
fix: Umbruchlogik
2026-09-16 02:21:21 +02:00

376 lines
17 KiB
C#

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<ImageElement>(layout.Elements[1]).Attributes["scale"]);
Assert.Equal("dd.MM.yyyy", Assert.IsType<TextElement>(layout.Elements[2]).Format);
Assert.IsType<FlowBoxElement>(layout.Elements[4]);
Assert.Equal("line", Assert.IsType<ChartElement>(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<string, byte[]>());
var validation = new TemplateLoader().Validate(template, new Dictionary<string, PlaceholderType>());
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue>()));
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<string, byte[]>(), 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<string, PlaceholderValue> { ["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<string, byte[]>());
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<string, PlaceholderValue> { ["Grafik"] = drawing }));
Assert.Single(System.Text.RegularExpressions.Regex.Matches(
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
}
// Regression: RenderFlow fensterte Seiten bisher nur über einen SVG-viewBox-Y-Offset, den
// QuestPDFs Svg()-Renderer nicht respektiert - dadurch landete auf jeder Folgeseite (nahezu)
// der komplette Zeicheninhalt erneut, statt nur des jeweiligen Ausschnitts (siehe Slice() in
// QuestTemplateRenderer.DrawingElementRenderer). Prüft per echter PDF-Textextraktion, dass
// seitenspezifische Marker nur auf ihrer jeweiligen Seite auftauchen.
[Fact]
public void FlowDrawBox_ZerschneidetInhaltEchtStattIhnAufFolgeseitenZuWiederholen()
{
var template = new LoadedTemplate(new TemplateManifest
{
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker"),
new Dictionary<string, byte[]>());
var drawing = new DrawingValue(
[
new DrawStringEx(0, 0, 12, 170, "MARKERTOP", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 90, 12, 170, "MARKERNEARBOTTOM", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 105, 12, 170, "MARKERAFTERBOUNDARY", DrawingTextAlignment.AlignLeft, 10),
new DrawStringEx(0, 190, 12, 170, "MARKERBOTTOM", DrawingTextAlignment.AlignLeft, 10),
], 200);
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing }));
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
var pages = document.GetPages().Select(p => p.Text).ToList();
Assert.Equal(2, pages.Count);
Assert.Contains("MARKERTOP", pages[0]);
Assert.Contains("MARKERNEARBOTTOM", pages[0]);
Assert.DoesNotContain("MARKERAFTERBOUNDARY", pages[0]);
Assert.DoesNotContain("MARKERBOTTOM", pages[0]);
Assert.DoesNotContain("MARKERTOP", pages[1]);
Assert.DoesNotContain("MARKERNEARBOTTOM", pages[1]);
Assert.Contains("MARKERAFTERBOUNDARY", pages[1]);
Assert.Contains("MARKERBOTTOM", pages[1]);
}
[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<string, byte[]>());
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<string, PlaceholderValue> { ["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<string, byte[]>());
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<string, PlaceholderValue> { ["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<TemplateValidationException>(() =>
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<TemplateValidationException>(() => 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 LayoutParser_LiestSeitentypenSlotsUndContentFlows()
{
var layout = new LayoutParser().Parse("""
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
TEXT 20 20 "Briefkopf"
#pragma flow-slot body x=20 y=80 w=170 h=190
#pragma end-page-template
#pragma page-template continuation
#pragma flow-slot body x=20 y=25 w=170 h=245
#pragma end-page-template
#pragma content-flow body
TEXTBOX $Text size=11 overflow=continue
TEXT "Gruß" gap=8 keep-with-next=true
TEXT $Name
#pragma end-content-flow
""");
Assert.Equal(3, layout.FormatVersion);
Assert.Equal(2, layout.PageTemplates.Count);
Assert.Equal(80, layout.PageTemplates[0].FlowSlots.Single().Y);
Assert.Equal(3, layout.ContentFlows.Single().Elements.Count);
Assert.IsType<TextBoxElement>(layout.ContentFlows.Single().Elements[0]);
}
[Fact]
public void Renderer_FliesstLangenTextAufFolgeseitenWeiter()
{
var manifest = new TemplateManifest
{
Id = "flow", Name = "Flow",
Placeholders = [new("Text", PlaceholderType.Multiline, true), new("Name", PlaceholderType.Text, true)],
};
var layout = new LayoutParser().Parse("""
PAGE 210 297 mm
#pragma format-version 3
#pragma page-template first
TEXT 20 15 "Erste Seite" size=16
#pragma flow-slot body x=20 y=55 w=170 h=215
#pragma end-page-template
#pragma page-template continuation
TEXT 20 12 "Folgeseite" size=9
#pragma flow-slot body x=20 y=25 w=170 h=245
#pragma end-page-template
#pragma content-flow body
TEXTBOX $Text size=11 overflow=continue
TEXT "Mit freundlichen Grüßen" gap=8 keep-with-next=true
TEXT $Name gap=3
#pragma end-content-flow
""");
var loaded = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
var longText = string.Join('\n', Enumerable.Repeat("Dies ist eine ausreichend lange Textzeile für den Seitenumbruch.", 180));
var pages = new QuestTemplateRenderer().RenderPagesToPng(loaded,
new DictionaryProvider(new Dictionary<string, PlaceholderValue>
{
["Text"] = new MultilineValue(longText), ["Name"] = new TextValue("M. Mustermann"),
}), 40);
Assert.True(pages.Count >= 3);
Assert.All(pages, page => Assert.True(page.Length > 500));
}
[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<TemplateValidationException>(() => 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<TemplateValidationException>(() => 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<string, byte[]>());
var result = new TemplateLoader().Validate(loaded,
new Dictionary<string, PlaceholderType> { ["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<string, byte[]>());
var loaded = new TemplateLoader().LoadFromPackage(path);
var pdf = new QuestTemplateRenderer().RenderToPdf(loaded, new DictionaryProvider(new Dictionary<string, PlaceholderValue>
{
["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<string, byte[]> { ["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<string, PlaceholderValue> values) : ITemplateDataProvider
{
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
}