Fix lualatex stdin PDF gen + unit test for signed estimate PDF
GenerateEstimatePdf passed /dev/stdin as the input filename so lualatex treats stdin as a regular input file instead of the terminal — without it, lualatex emergency-stops after the first line of a multi-line document (the real root cause of the production "Emergency stop", not bera/luaotfload which were separate host-package gaps already fixed). EstimatePdf endpoint calls GenerateEstimatePdf directly and streams the returned FileInfo, guarding on fo.Exists. QueryAcceptanceRequest is extracted into its own file under ViewModels/FrontOffice. Add EstimatePdfGenerationTests: unit tests for SignatureToTikz (vector draws of both signatures) and GenerateEstimatePdf on a devis signed by both parties — asserts the PDF exists at the expected DestDir path, is non-empty, and that only the PDF persists (aux/log cleaned). Skips cleanly when lualatex is absent. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
82d1d6f6d6
commit
7e13161f27
4 changed files with 242 additions and 41 deletions
191
src/Yavsc.Api.Test/EstimatePdfGenerationTests.cs
Normal file
191
src/Yavsc.Api.Test/EstimatePdfGenerationTests.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using Yavsc.Helpers;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.ViewModels.Gen;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
// Unit test of the estimate PDF generation pipeline (no HTTP fixture
|
||||||
|
// needed): it exercises TeXHelpers.SignatureToTikz — the vector drawing
|
||||||
|
// of the provider and client signatures — and TeXHelpers.GenerateEstimatePdf
|
||||||
|
// — the lualatex stdin compile that writes <name>.pdf into DestDir. The
|
||||||
|
// TeX source mirrors what the Estimate_tex template emits for a devis
|
||||||
|
// signed by both parties: two side-by-side minipages, each with a
|
||||||
|
// tikzpicture bounded by \useasboundingbox and the strokes drawn by
|
||||||
|
// SignatureToTikz.
|
||||||
|
//
|
||||||
|
// The PDF test requires the system `lualatex` (and the TeX Live packages
|
||||||
|
// listed in the README runtime-deps section) on the host running the
|
||||||
|
// tests. On hosts without lualatex it skips cleanly rather than failing.
|
||||||
|
public sealed class EstimatePdfGenerationTests
|
||||||
|
{
|
||||||
|
// A provider signature: a downstroke, a cross, and a tap (dot).
|
||||||
|
// PostIt wire format — [k, x0,y0, x1,y1, …] per stroke, coords in
|
||||||
|
// [0, 10000], y-down.
|
||||||
|
private static readonly int[] ProStrokes =
|
||||||
|
{
|
||||||
|
4, 2000, 8000, 2000, 5000, 2000, 2000, 2100, 1900,
|
||||||
|
3, 2000, 5000, 3500, 4500, 3500, 5100,
|
||||||
|
1, 4000, 3000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A client signature: a wavy stroke and a shorter one.
|
||||||
|
private static readonly int[] CliStrokes =
|
||||||
|
{
|
||||||
|
5, 1000, 7000, 1500, 4000, 2200, 3000, 3000, 2500, 4000, 1500,
|
||||||
|
2, 3000, 6000, 4200, 5500,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SignatureToTikz_RendersBothSignaturesAsVectorDraws()
|
||||||
|
{
|
||||||
|
var pro = MakeSig(SignatureType.Pro, ProStrokes);
|
||||||
|
var cli = MakeSig(SignatureType.Client, CliStrokes);
|
||||||
|
|
||||||
|
var proTex = pro.SignatureToTikz(5, 2.5).ToString();
|
||||||
|
var cliTex = cli.SignatureToTikz(5, 2.5).ToString();
|
||||||
|
|
||||||
|
Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", proTex);
|
||||||
|
Assert.Contains("\\fill", proTex); // the single-point tap → dot
|
||||||
|
Assert.Contains("(1.000,0.500)", proTex); // first point (2000,8000), y flipped
|
||||||
|
Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", cliTex);
|
||||||
|
Assert.Contains("--", cliTex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SignatureToTikz_EmptySignatureIsEmpty()
|
||||||
|
{
|
||||||
|
var empty = MakeSig(SignatureType.Pro, Array.Empty<int>());
|
||||||
|
Assert.Equal("", empty.SignatureToTikz(5, 2.5).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateEstimatePdf_WithBothSignatures_ProducesPdfInDestDir()
|
||||||
|
{
|
||||||
|
if (!IsLualatexAvailable())
|
||||||
|
throw Xunit.Sdk.SkipException.ForSkip(
|
||||||
|
"lualatex is not installed on this host; skipping PDF generation test. " +
|
||||||
|
"Install the TeX Live runtime deps documented in the README.");
|
||||||
|
|
||||||
|
var destDir = Path.Combine(Path.GetTempPath(),
|
||||||
|
"yavsc-estimate-pdf-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(destDir);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pro = MakeSig(SignatureType.Pro, ProStrokes);
|
||||||
|
var cli = MakeSig(SignatureType.Client, CliStrokes);
|
||||||
|
var baseName = "estimate-test-both-signed";
|
||||||
|
|
||||||
|
var model = new PdfGenerationViewModel
|
||||||
|
{
|
||||||
|
TeXSource = BuildEstimateTeX(pro, cli),
|
||||||
|
BaseFileName = baseName,
|
||||||
|
DestDir = destDir,
|
||||||
|
};
|
||||||
|
|
||||||
|
var fo = model.GenerateEstimatePdf();
|
||||||
|
|
||||||
|
// The PDF must exist, at the expected path, and be non-empty.
|
||||||
|
var expectedPath = Path.Combine(destDir, baseName + ".pdf");
|
||||||
|
Assert.True(fo is not null && fo.Exists,
|
||||||
|
"GenerateEstimatePdf returned a non-existent file. Error: "
|
||||||
|
+ model.GenerationErrorMessage?.Value);
|
||||||
|
Assert.Equal(expectedPath, fo.FullName);
|
||||||
|
Assert.True(fo.Length > 0, "generated PDF is empty");
|
||||||
|
|
||||||
|
// Only the PDF should persist in DestDir — aux/log artifacts
|
||||||
|
// are cleaned up by GenerateEstimatePdf on success.
|
||||||
|
var leftovers = Directory.GetFiles(destDir)
|
||||||
|
.Where(p => !Path.GetFileName(p).Equals(baseName + ".pdf", StringComparison.Ordinal))
|
||||||
|
.ToArray();
|
||||||
|
Assert.Empty(leftovers);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(destDir))
|
||||||
|
Directory.Delete(destDir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Signature MakeSig(SignatureType type, int[] strokes)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
CoordinateMax = 10_000,
|
||||||
|
Strokes = strokes,
|
||||||
|
CapturedAtUtc = new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Builds the LaTeX source the Estimate_tex template would emit for a
|
||||||
|
// devis signed by both parties: same preamble (incl. tikz + bera) and
|
||||||
|
// the two-minipage signature foot, with the strokes drawn by
|
||||||
|
// SignatureToTikz.
|
||||||
|
private static string BuildEstimateTeX(Signature pro, Signature cli)
|
||||||
|
{
|
||||||
|
var fr = CultureInfo.CreateSpecificCulture("fr-FR");
|
||||||
|
var date = new DateTime(2026, 9, 22).ToString("dddd dd MMMM yyyy", fr);
|
||||||
|
var proTikz = pro.SignatureToTikz(5, 2.5).ToString();
|
||||||
|
var cliTikz = cli.SignatureToTikz(5, 2.5).ToString();
|
||||||
|
|
||||||
|
return $@"\documentclass[french,11pt]{{article}}
|
||||||
|
\usepackage{{eurosym}}
|
||||||
|
\usepackage{{babel}}
|
||||||
|
\usepackage[T1]{{fontenc}}
|
||||||
|
\usepackage[utf8]{{inputenc}}
|
||||||
|
\usepackage[a4paper]{{geometry}}
|
||||||
|
\usepackage{{units}}
|
||||||
|
\usepackage{{bera}}
|
||||||
|
\usepackage{{graphicx}}
|
||||||
|
\usepackage{{fancyhdr}}
|
||||||
|
\usepackage{{fp}}
|
||||||
|
\usepackage{{tikz}}
|
||||||
|
\geometry{{verbose,tmargin=4em,bmargin=8em,lmargin=6em,rmargin=6em}}
|
||||||
|
\setlength{{\parindent}}{{0pt}}
|
||||||
|
\begin{{document}}
|
||||||
|
Devis n°1 --- test signe par les deux parties
|
||||||
|
|
||||||
|
\vspace{{2em}}
|
||||||
|
\noindent
|
||||||
|
\begin{{minipage}}[t]{{0.45\textwidth}}
|
||||||
|
\textbf{{Signature du fournisseur}}\\
|
||||||
|
\small Valide le {date}\\
|
||||||
|
\begin{{tikzpicture}}
|
||||||
|
\useasboundingbox (0,0) rectangle (5,2.5);
|
||||||
|
{proTikz}
|
||||||
|
\end{{tikzpicture}}
|
||||||
|
\end{{minipage}}\hfill
|
||||||
|
\begin{{minipage}}[t]{{0.45\textwidth}}
|
||||||
|
\textbf{{Signature du client}}\\
|
||||||
|
\small Valide le {date}\\
|
||||||
|
\begin{{tikzpicture}}
|
||||||
|
\useasboundingbox (0,0) rectangle (5,2.5);
|
||||||
|
{cliTikz}
|
||||||
|
\end{{tikzpicture}}
|
||||||
|
\end{{minipage}}
|
||||||
|
\end{{document}}
|
||||||
|
";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLualatexAvailable()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var p = Process.Start(new ProcessStartInfo("lualatex", "--version")
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
});
|
||||||
|
if (p is null) return false;
|
||||||
|
return p.WaitForExit(5000) && p.ExitCode == 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -140,7 +140,7 @@ namespace Yavsc.ApiControllers
|
||||||
// we stream the generated PDF bytes — but driving the
|
// we stream the generated PDF bytes — but driving the
|
||||||
// generation through the view keeps the template as the
|
// generation through the view keeps the template as the
|
||||||
// output producer, per the original design.
|
// output producer, per the original design.
|
||||||
if (model.GenerateEstimatePdf() is FileInfo fo)
|
if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists)
|
||||||
{
|
{
|
||||||
return File(fo.OpenRead(), "application/pdf");
|
return File(fo.OpenRead(), "application/pdf");
|
||||||
}
|
}
|
||||||
|
|
@ -370,42 +370,3 @@ namespace Yavsc.ApiControllers
|
||||||
|
|
||||||
internal enum ActorRole { None, Provider, Client, Admin }
|
internal enum ActorRole { None, Provider, Client, Admin }
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace Yavsc.ViewModels.FrontOffice
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Body of <c>POST /api/v1/front/query/accept</c> and
|
|
||||||
/// <c>.../reject</c>. The <see cref="Strokes"/> field is optional:
|
|
||||||
/// when present (non-empty) on accept, it is the PostIt wire-format
|
|
||||||
/// signature captured with the acceptance. All fields are optional
|
|
||||||
/// so a caller may post <c>{}</c> to accept/reject without a
|
|
||||||
/// signature.
|
|
||||||
/// </summary>
|
|
||||||
public class QueryAcceptanceRequest
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Wire-format strokes. See
|
|
||||||
/// <c>PostIt.Models.SignaturePadData</c>. Null or empty means
|
|
||||||
/// no signature.
|
|
||||||
/// </summary>
|
|
||||||
public int[]? Strokes { get; set; }
|
|
||||||
|
|
||||||
public int CoordinateMax { get; set; } = 10_000;
|
|
||||||
|
|
||||||
public DateTime? CapturedAtUtc { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Which signature is being submitted:
|
|
||||||
/// <see cref="SignatureType.Pro"/> (the provider) or
|
|
||||||
/// <see cref="SignatureType.Client"/>. When <see cref="Strokes"/>
|
|
||||||
/// is present this declares the side the caller is signing as;
|
|
||||||
/// the server honors it only if the caller is that party
|
|
||||||
/// (<c>query.PerformerId</c> for Pro, <c>query.ClientId</c>
|
|
||||||
/// for Client). This disambiguates the case where one user is
|
|
||||||
/// both parties, and makes "write only for its author"
|
|
||||||
/// explicit. When null, the server falls back to the role
|
|
||||||
/// inferred from the caller's identity.
|
|
||||||
/// </summary>
|
|
||||||
public SignatureType? SignatureType { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,14 @@ namespace Yavsc.Helpers
|
||||||
// the PDF on stdout — so the pdf lands at its final destination
|
// the PDF on stdout — so the pdf lands at its final destination
|
||||||
// and we only clean the transient aux/log artifacts beside it.
|
// and we only clean the transient aux/log artifacts beside it.
|
||||||
//
|
//
|
||||||
|
// We pass "/dev/stdin" as the input filename so lualatex treats
|
||||||
|
// stdin as a regular input *file* rather than the terminal: with
|
||||||
|
// no filename argument it reads stdin in terminal mode, which
|
||||||
|
// emergency-stops after the first line of a multi-line document.
|
||||||
|
// /dev/stdin makes the full multi-line TeX compile cleanly. (The
|
||||||
|
// API host is Linux — the systemd service and lualatex dep are
|
||||||
|
// Linux-only too.)
|
||||||
|
//
|
||||||
// We capture stdout/stderr so a LaTeX error (missing .sty,
|
// We capture stdout/stderr so a LaTeX error (missing .sty,
|
||||||
// missing \includegraphics target, …) is surfaced in
|
// missing \includegraphics target, …) is surfaced in
|
||||||
// GenerationErrorMessage instead of being buried in the .log
|
// GenerationErrorMessage instead of being buried in the .log
|
||||||
|
|
@ -218,7 +226,8 @@ namespace Yavsc.Helpers
|
||||||
WorkingDirectory = billdir,
|
WorkingDirectory = billdir,
|
||||||
FileName = "lualatex",
|
FileName = "lualatex",
|
||||||
Arguments = $"-interaction=nonstopmode -halt-on-error"
|
Arguments = $"-interaction=nonstopmode -halt-on-error"
|
||||||
+ $" -jobname=\"{name}\" -output-directory=\"{billdir}\"",
|
+ $" -jobname=\"{name}\" -output-directory=\"{billdir}\""
|
||||||
|
+ " /dev/stdin",
|
||||||
RedirectStandardInput = true,
|
RedirectStandardInput = true,
|
||||||
RedirectStandardOutput = true,
|
RedirectStandardOutput = true,
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
|
||||||
|
namespace Yavsc.ViewModels.FrontOffice
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Body of <c>POST /api/v1/front/query/accept</c> and
|
||||||
|
/// <c>.../reject</c>. The <see cref="Strokes"/> field is optional:
|
||||||
|
/// when present (non-empty) on accept, it is the PostIt wire-format
|
||||||
|
/// signature captured with the acceptance. All fields are optional
|
||||||
|
/// so a caller may post <c>{}</c> to accept/reject without a
|
||||||
|
/// signature.
|
||||||
|
/// </summary>
|
||||||
|
public class QueryAcceptanceRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-format strokes. See
|
||||||
|
/// <c>PostIt.Models.SignaturePadData</c>. Null or empty means
|
||||||
|
/// no signature.
|
||||||
|
/// </summary>
|
||||||
|
public int[]? Strokes { get; set; }
|
||||||
|
|
||||||
|
public int CoordinateMax { get; set; } = 10_000;
|
||||||
|
|
||||||
|
public DateTime? CapturedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which signature is being submitted:
|
||||||
|
/// <see cref="SignatureType.Pro"/> (the provider) or
|
||||||
|
/// <see cref="SignatureType.Client"/>. When <see cref="Strokes"/>
|
||||||
|
/// is present this declares the side the caller is signing as;
|
||||||
|
/// the server honors it only if the caller is that party
|
||||||
|
/// (<c>query.PerformerId</c> for Pro, <c>query.ClientId</c>
|
||||||
|
/// for Client). This disambiguates the case where one user is
|
||||||
|
/// both parties, and makes "write only for its author"
|
||||||
|
/// explicit. When null, the server falls back to the role
|
||||||
|
/// inferred from the caller's identity.
|
||||||
|
/// </summary>
|
||||||
|
public SignatureType? SignatureType { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue