Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,487 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Omika.App.Controls;
using Omika.Core.Contracts.Documents;
using Omika.Core.Services;
namespace Omika.Tests.Services;
/// <summary>
/// Tests de COMPATIBILITÉ du corps HTML des courriels WinDev ↔ Omika :
/// <list type="bullet">
/// <item><b>WinDev → Omika</b> (lecture) : le CSS/&lt;head&gt;/&lt;style&gt; ne sont
/// jamais rendus comme texte ; les tables (tbody), le bloc photo imbriqué et les
/// tableaux de contacts sont lus dans l'ordre.</item>
/// <item><b>Omika → WinDev</b> (écriture) : le HTML persisté est conservateur
/// (&lt;table&gt;/&lt;tr&gt;/&lt;td&gt;/&lt;p&gt;/&lt;img&gt;), la photo est empilée
/// (image puis date), les contacts sont en vraies cellules, sans flex/grid.</item>
/// <item><b>Roundtrip</b> : relecture du HTML Omika sans perte structurelle.</item>
/// </list>
/// </summary>
public class EmailHtmlCompatibiliteTests
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private static readonly byte[] OctetsJpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46];
// ------------------------------------------------------------------
// Helpers — lecture (parser)
// ------------------------------------------------------------------
private static IReadOnlyList<HtmlBlock> Parser(string html) => EmailHtmlParser.Parser(html);
private static IEnumerable<HtmlInline> InlinesTous(IReadOnlyList<HtmlBlock> blocs)
{
foreach (HtmlBlock bloc in blocs)
{
switch (bloc)
{
case HtmlParagraph p:
foreach (HtmlInline i in p.Inlines) yield return i;
break;
case HtmlTable t:
foreach (HtmlRow row in t.Rows)
foreach (HtmlCell cell in row.Cells)
foreach (HtmlInline i in cell.Inlines)
yield return i;
break;
}
}
}
private static List<string> Textes(IReadOnlyList<HtmlBlock> blocs)
=> InlinesTous(blocs).OfType<HtmlRun>().Select(r => r.Text).ToList();
private static string ToutLeTexte(string html) => string.Join("\n", Textes(Parser(html)));
// ------------------------------------------------------------------
// Helpers — écriture (DOCX → HTML)
// ------------------------------------------------------------------
/// <summary>DOCX : table 1 colonne / 2 lignes — ligne 2 = 3 paragraphes (image, date, note).</summary>
private static byte[] CreerDocxPhotoBloc(string titre = "Photos")
{
using var stream = new MemoryStream();
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
{
MainDocumentPart main = doc.AddMainDocumentPart();
main.Document = new Document();
Body body = new Body();
var table = new Table();
table.Append(new TableRow(new TableCell(new Paragraph(new Run(new Text(titre))))));
var cellule = new TableCell();
cellule.Append(new Paragraph(new Run(new Text("$TABPHOTO.IMAGE$"))));
cellule.Append(new Paragraph(new Run(new Text("$TABPHOTO.DATEIMAGE$"))));
cellule.Append(new Paragraph(new Run(new Text("$TABPHOTO.NOTEIMAGE$"))));
table.Append(new TableRow(cellule));
body.Append(table);
main.Document.Append(body);
main.Document.Save();
}
return stream.ToArray();
}
/// <summary>DOCX : table contacts 5 colonnes (en-tête + 1 ligne de données).</summary>
private static byte[] CreerDocxContacts()
{
using var stream = new MemoryStream();
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
{
MainDocumentPart main = doc.AddMainDocumentPart();
main.Document = new Document();
Body body = new Body();
var table = new Table();
table.Append(new TableRow(
Cell("$TABCTSITE.NOM$"), Cell("$TABCTSITE.TELFIXE$"), Cell("$TABCTSITE.TELFAXE$"),
Cell("$TABCTSITE.ROLE$"), Cell("$TABCTSITE.ETAGE$")));
table.Append(new TableRow(
Cell("Dupont Jean"), Cell("04 00 00 00 00"), Cell("06 00 00 00 00"),
Cell("Occupant"), Cell("4")));
body.Append(table);
main.Document.Append(body);
main.Document.Save();
}
return stream.ToArray();
static TableCell Cell(string texte) => new(new Paragraph(new Run(new Text(texte))));
}
/// <summary>DOCX : bloc photo (image/date/note empilés) + tableau contacts (5 colonnes).</summary>
private static byte[] CreerDocxPhotoEtContacts()
{
using var stream = new MemoryStream();
using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
{
MainDocumentPart main = doc.AddMainDocumentPart();
main.Document = new Document();
Body body = new Body();
// Bloc photo (1 colonne / 2 lignes ; ligne 2 = 3 paragraphes empilés).
var photo = new Table();
photo.Append(new TableRow(new TableCell(new Paragraph(new Run(new Text("Photos"))))));
var cellulePhoto = new TableCell();
cellulePhoto.Append(new Paragraph(new Run(new Text("$TABPHOTO.IMAGE$"))));
cellulePhoto.Append(new Paragraph(new Run(new Text("$TABPHOTO.DATEIMAGE$"))));
cellulePhoto.Append(new Paragraph(new Run(new Text("$TABPHOTO.NOTEIMAGE$"))));
photo.Append(new TableRow(cellulePhoto));
body.Append(photo);
// Contacts (5 colonnes : en-têtes littéraux + 1 ligne de données déjà résolue).
var contacts = new Table();
contacts.Append(new TableRow(
new TableCell(new Paragraph(new Run(new Text("Nom - Prénom")))),
new TableCell(new Paragraph(new Run(new Text("Tel. 1")))),
new TableCell(new Paragraph(new Run(new Text("Tel. 2")))),
new TableCell(new Paragraph(new Run(new Text("Rôle")))),
new TableCell(new Paragraph(new Run(new Text("Étage"))))));
contacts.Append(new TableRow(
new TableCell(new Paragraph(new Run(new Text("Dupont Jean")))),
new TableCell(new Paragraph(new Run(new Text("04 00 00 00 00")))),
new TableCell(new Paragraph(new Run(new Text("06 00 00 00 00")))),
new TableCell(new Paragraph(new Run(new Text("Occupant")))),
new TableCell(new Paragraph(new Run(new Text("4"))))));
body.Append(contacts);
main.Document.Append(body);
main.Document.Save();
}
return stream.ToArray();
}
private static RapportChampDto TableauPhotos(string json)
=> new()
{
Id = "1",
Rubrique = "$TABPHOTO$",
Type = "Tableau",
ListeDonnesBD = json,
ListeMembre =
[
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.IMAGE$", Type = "ChpImage", TypeMembre = "ChpImage", MembreMob = "cheminComplet" },
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.DATEIMAGE$", Type = "ChpSimpleDate", TypeMembre = "ChpSimpleDate", MembreMob = "saDate" },
new RapportChampSousElementDto { Rubrique = "$TABPHOTO.NOTEIMAGE$", Type = "ChpSimpleChaine", TypeMembre = "ChpSimpleChaine", MembreMob = "saNotes" }
]
};
private static string RendreCorps(IReadOnlyList<RapportChampDto> champs, byte[] docx, out IReadOnlyList<EmailInlineImage> images)
{
var liste = new List<EmailInlineImage>();
byte[] rendu = EmailDocxRenderer.Rendre(docx, champs, liste);
images = liste;
return EmailImageInliner.Inliner(DocxHtmlConverter.Convertir(rendu), liste);
}
private static string JsonUnePhoto() => JsonSerializer.Serialize(new[]
{
// Les propriétés JSON suivent la convention camelCase (après normalisation
// MembreMob : saDate → date, saNotes → notes).
new { cheminComplet = Convert.ToBase64String(OctetsJpeg), date = "30/01/2026", notes = "Note photo" }
}, Json);
// ==================================================================
// §13 — WINDEV → OMIKA (lecture)
// ==================================================================
// TEST 1 : <head><style> jamais rendu comme texte
[Fact]
public void WinDev_CssJamaisRenduCommeTexte()
{
string texte = ToutLeTexte(EmailHtmlFixture.WinDevHtmlComplet());
Assert.DoesNotContain("font-family", texte);
Assert.DoesNotContain("Calibri", texte);
Assert.DoesNotContain("margin-bottom", texte);
Assert.DoesNotContain(".Normal", texte);
Assert.DoesNotContain(".TableauNormal", texte);
Assert.DoesNotContain(".xtab", texte);
Assert.DoesNotContain("{", texte);
Assert.DoesNotContain("}", texte);
}
// TEST 2 : classes .Normal/.TableauNormal/.xtab → contenu métier visible, CSS non visible
[Fact]
public void WinDev_ContenuMetierVisible_CssSourceInvisible()
{
IReadOnlyList<HtmlBlock> blocs = Parser(EmailHtmlFixture.WinDevHtmlComplet());
List<string> textes = Textes(blocs);
string tout = string.Join("\n", textes);
Assert.Contains("Rapport d'intervention", tout);
Assert.Contains("Nom du client", tout);
Assert.Contains("Photos", tout);
Assert.Contains("Dupont Jean", tout);
Assert.Contains("Occupant", tout);
Assert.DoesNotContain("Grilledutableau", tout);
Assert.DoesNotContain("padding-left", tout);
Assert.DoesNotContain("display:block", tout);
}
// TEST 3 : table photo WinDev → image + date, date située après/sous l'image
[Fact]
public void WinDev_TablePhoto_ImagePuisDate()
{
List<HtmlInline> inlines = InlinesTous(Parser(EmailHtmlFixture.WinDevHtmlComplet())).ToList();
int idxImage = inlines.FindIndex(i => i is HtmlImage);
int idxDate = inlines.FindIndex(i => i is HtmlRun r && r.Text.Contains("30/01/2026"));
int idxNote = inlines.FindIndex(i => i is HtmlRun r && r.Text.Contains("Note photo"));
Assert.True(idxImage >= 0, "image absente");
Assert.True(idxDate >= 0, "date absente");
Assert.True(idxNote >= 0, "note absente");
Assert.True(idxImage < idxDate, "la date doit suivre l'image dans la structure");
Assert.True(idxDate < idxNote, "la note doit suivre la date dans la structure");
}
// TEST 4 : table contacts WinDev → toutes les cellules dans le bon ordre
[Fact]
public void WinDev_TableContacts_CellulesDansOrdre()
{
List<string> textes = Textes(Parser(EmailHtmlFixture.WinDevHtmlComplet()));
string[] attendus =
[
"Nom - Prénom", "Tel. 1", "Tel. 2", "Rôle", "Étage",
"Dupont Jean", "04 00 00 00 00", "06 00 00 00 00", "Occupant", "4"
];
foreach (string attendu in attendus)
{
Assert.Contains(attendu, textes);
}
// L'ordre des en-têtes et des cellules est préservé.
Assert.True(textes.IndexOf("Nom - Prénom") < textes.IndexOf("Tel. 1"));
Assert.True(textes.IndexOf("Tel. 1") < textes.IndexOf("Tel. 2"));
Assert.True(textes.IndexOf("Dupont Jean") < textes.IndexOf("Occupant"));
}
// TEST 5 : aucune donnée de <style>/<script> dans le texte final
[Fact]
public void WinDev_AucuneDonneeStyleScript_DansTexteFinal()
{
List<string> textes = Textes(Parser(EmailHtmlFixture.WinDevHtmlComplet()));
Assert.DoesNotContain(textes, t => t.Contains("td *"));
Assert.DoesNotContain(textes, t => t.Contains("text/css"));
Assert.DoesNotContain(textes, t => t.Contains("PCSOFT"));
Assert.DoesNotContain(textes, t => t.Contains("<style"));
Assert.DoesNotContain(textes, t => t.Contains("<script"));
}
// ==================================================================
// §14 — OMIKA → WINDEV (écriture)
// ==================================================================
// TEST 6 : photo → image sur sa ligne, date sur la ligne suivante (paragraphes séparés)
[Fact]
public void Omika_Photo_ImagePuisDate_SurParagraphesSepares()
{
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoBloc(), out IReadOnlyList<EmailInlineImage> images);
Assert.Single(images);
// L'image, la date et la note sont chacune dans leur PROPRE <p> : jamais
// « <img/>30/01/2026 » dans le même bloc.
Assert.Contains("<p><img", html);
Assert.Contains("<p>30/01/2026</p>", html);
Assert.Contains("<p>Note photo</p>", html);
}
// TEST 7 : contacts → vraies lignes/cellules HTML
[Fact]
public void Omika_Contacts_VraiesCellules()
{
string html = RendreCorps(
[TableauPhotos(JsonUnePhoto())],
CreerDocxContacts(),
out _);
Assert.Contains("<table", html);
Assert.Contains("<tr>", html);
Assert.Contains("<td ", html);
Assert.Contains("Dupont Jean", html);
Assert.Contains("04 00 00 00 00", html);
Assert.Contains("Occupant", html);
}
// TEST 8 : le HTML de stockage ne dépend pas de display:flex / display:grid
[Fact]
public void Omika_HtmlStockage_SansFlexGrid()
{
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoBloc(), out _);
Assert.DoesNotContain("display:flex", html);
Assert.DoesNotContain("display: flex", html);
Assert.DoesNotContain("display:grid", html);
Assert.DoesNotContain("display: grid", html);
Assert.DoesNotContain("color-mix", html);
}
// TEST 9 : toutes les données restent présentes (photo + contacts)
[Fact]
public void Omika_ToutesDonneesPresentes()
{
string html = RendreCorps(
[TableauPhotos(JsonUnePhoto())],
CreerDocxPhotoEtContacts(),
out _);
foreach (string attendu in new[]
{
"Photos", "30/01/2026", "Note photo",
"Dupont Jean", "04 00 00 00 00", "06 00 00 00 00", "Occupant", "4"
})
{
Assert.Contains(attendu, html);
}
Assert.DoesNotContain("$TABPHOTO", html);
Assert.DoesNotContain("$TABCTSITE", html);
}
// TEST 10 : les images restent exploitables par le pipeline SMTP (data URI → CID)
[Fact]
public void Omika_Images_ExploitablesParPipelineSmtp()
{
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoBloc(), out IReadOnlyList<EmailInlineImage> images);
Assert.Single(images);
Assert.Contains("<img src=\"data:image/jpeg;base64,", html);
(string htmlSmtp, IReadOnlyList<EmailSmtpInline.Ressource> ressources) = EmailSmtpInline.Convertir(html);
Assert.Contains("cid:", htmlSmtp);
Assert.Single(ressources);
Assert.DoesNotContain("base64", htmlSmtp);
}
// ==================================================================
// §15 — ROUNDTRIP STRUCTUREL (Omika → parser → relecture)
// ==================================================================
[Fact]
public void Roundtrip_Omika_PasDePerteStructurelle()
{
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoBloc(), out _);
// Relecture par le parser Omika (même chemin que l'aperçu).
IReadOnlyList<HtmlBlock> blocs = Parser(html);
List<string> textes = Textes(blocs);
List<HtmlInline> inlines = InlinesTous(blocs).ToList();
Assert.Contains("Photos", textes); // TEXTE conservé
Assert.Contains("30/01/2026", textes); // DATE conservée
Assert.Contains("Note photo", textes); // TEXTE conservé
Assert.Contains(blocs, b => b is HtmlTable); // TABLE conservée
Assert.Contains(inlines, i => i is HtmlImage); // IMAGE conservée
// Aucun CSS/token ne fuite.
string tout = string.Join("\n", textes);
Assert.DoesNotContain("$TABPHOTO", tout);
Assert.DoesNotContain("font-family", tout);
}
// ==================================================================
// §7 — FINITION TABLEAUX (bordures compatibles + espacement)
// ==================================================================
// TEST TABLE A : HTML Omika généré → bordures compatibles + structure cellule.
[Fact]
public void TableA_Omika_TableBordureCompatible()
{
string html = RendreCorps([TableauPhotos(JsonUnePhoto())], CreerDocxPhotoEtContacts(), out _);
// Attributs HTML historiques (indépendants du <style> global).
Assert.Contains("cellspacing=\"0\"", html);
Assert.Contains("cellpadding=\"0\"", html);
// Bordure inline fine sur chaque cellule (style simple, pas de CSS global).
Assert.Contains("border:1pt solid #000000", html);
// Structure réelle table/tr/td conservée.
Assert.Contains("<table", html);
Assert.Contains("<tr>", html);
Assert.Contains("<td ", html);
Assert.DoesNotContain("display:flex", html);
Assert.DoesNotContain("display:grid", html);
}
// TEST TABLE B : fixture WinDev → aucune hauteur fixe de table conservée.
[Fact]
public void TableB_WinDev_AucuneHauteurFixeExcessive()
{
IReadOnlyList<HtmlBlock> blocs = Parser(EmailHtmlFixture.WinDevHtmlComplet());
List<string> textes = Textes(blocs);
// Le modèle structuré n'expose AUCUNE hauteur (table/tr/td) : le
// « height:153.1pt » de WinDev n'est pas traduit en Height fixe Avalonia.
Assert.DoesNotContain(textes, t => t.Contains("153.1pt"));
Assert.DoesNotContain(textes, t => t.Contains("height"));
// Dimensionnement au contenu : photo / date / note toujours présents.
Assert.Contains("Photos", textes);
Assert.Contains("30/01/2026", textes);
Assert.Contains("Note photo", textes);
}
// TEST TABLE C : image WinDev → dimensions width/height préservées.
[Fact]
public void TableC_WinDev_ImageTaillePreservee()
{
List<HtmlInline> inlines = InlinesTous(Parser(EmailHtmlFixture.WinDevHtmlComplet())).ToList();
HtmlImage image = Assert.IsType<HtmlImage>(inlines.Single(i => i is HtmlImage));
Assert.Equal(305, image.Width);
Assert.Equal(206, image.Height);
}
// TEST TABLE D : paragraphes de cellule — ordre conservé, sans espacement excessif.
[Fact]
public void TableD_WinDev_ParagraphesCellule_SansEspacementExcessif()
{
List<HtmlInline> inlines = InlinesTous(Parser(EmailHtmlFixture.WinDevHtmlComplet())).ToList();
int idxImage = inlines.FindIndex(i => i is HtmlImage);
int idxDate = inlines.FindIndex(i => i is HtmlRun r && r.Text.Contains("30/01/2026"));
int idxNote = inlines.FindIndex(i => i is HtmlRun r && r.Text.Contains("Note photo"));
Assert.True(idxImage >= 0, "image absente");
Assert.True(idxDate >= 0, "date absente");
Assert.True(idxNote >= 0, "note absente");
Assert.True(idxImage < idxDate && idxDate < idxNote, "ordre image → date → note non respecté");
// Entre image et date, puis entre date et note : UN SEUL saut de ligne
// (aucun saut doublé → aucune ligne vide cumulée).
IReadOnlyList<HtmlInline> entreImageDate = inlines.Skip(idxImage + 1).Take(idxDate - idxImage - 1).ToList();
Assert.Single(entreImageDate);
Assert.IsType<HtmlLineBreak>(entreImageDate[0]);
IReadOnlyList<HtmlInline> entreDateNote = inlines.Skip(idxDate + 1).Take(idxNote - idxDate - 1).ToList();
Assert.Single(entreDateNote);
Assert.IsType<HtmlLineBreak>(entreDateNote[0]);
}
// TEST TABLE E : aucune régression (CSS invisible, contenu/photo/date présents).
[Fact]
public void TableE_AucuneRegression()
{
string texte = ToutLeTexte(EmailHtmlFixture.WinDevHtmlComplet());
IReadOnlyList<HtmlBlock> blocs = Parser(EmailHtmlFixture.WinDevHtmlComplet());
// WINDEV CSS TEXT = NOT VISIBLE
Assert.DoesNotContain("font-family", texte);
Assert.DoesNotContain(".Normal", texte);
Assert.DoesNotContain("Calibri", texte);
// TABLE CONTENT = PRESENT
Assert.Contains("Nom - Prénom", texte);
Assert.Contains("Dupont Jean", texte);
Assert.Contains(blocs, b => b is HtmlTable);
// PHOTO = PRESENT, DATE = PRESENT
Assert.Contains(InlinesTous(blocs), i => i is HtmlImage);
Assert.Contains("30/01/2026", texte);
}
}