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
9c1f552bca
commit
35d784fdf1
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
|
||||
// generation through the view keeps the template as the
|
||||
// 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");
|
||||
}
|
||||
|
|
@ -370,42 +370,3 @@ namespace Yavsc.ApiControllers
|
|||
|
||||
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
|
||||
// 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,
|
||||
// missing \includegraphics target, …) is surfaced in
|
||||
// GenerationErrorMessage instead of being buried in the .log
|
||||
|
|
@ -218,7 +226,8 @@ namespace Yavsc.Helpers
|
|||
WorkingDirectory = billdir,
|
||||
FileName = "lualatex",
|
||||
Arguments = $"-interaction=nonstopmode -halt-on-error"
|
||||
+ $" -jobname=\"{name}\" -output-directory=\"{billdir}\"",
|
||||
+ $" -jobname=\"{name}\" -output-directory=\"{billdir}\""
|
||||
+ " /dev/stdin",
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = 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