Immagini nel markdown da file locale e struttura src/tests
Il renderer mostra le immagini  da file locali (ADR-0006): segnaposto [alt] e decodifica in background, ripiego sul testo per URL, file mancanti, corrotti o troppo grandi. Nessuna richiesta di rete. Primo progetto di test (xUnit v3 + Avalonia Headless, MD-14 e MD-19…MD-27). Il codice passa in src/agent-wiki-ui/, c'è la soluzione agent-wiki-ui.slnx; aggiornati script di publish, comandi e link nei documenti. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af171201b5
commit
15c664007d
32 files changed
+506
-29
No files matched your search
@@ -0,0 +1,208 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Automation;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using AgentWikiUi.Gui;
|
||||
|
||||
namespace AgentWikiUi.Tests;
|
||||
|
||||
/// <summary>Immagini nel markdown (ADR-0006): matrice MD-14 e MD-19…MD-26 in docs/TESTING.md.</summary>
|
||||
[Trait("Category", "Gui")]
|
||||
public sealed class MarkdownImageTests : IDisposable
|
||||
{
|
||||
readonly string dir = Directory.CreateTempSubdirectory("awui-img-").FullName;
|
||||
readonly List<string> inBase = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
foreach (var f in inBase) File.Delete(f);
|
||||
}
|
||||
|
||||
string Png(string name, int w, int h)
|
||||
{
|
||||
var path = Path.Combine(dir, name);
|
||||
File.WriteAllBytes(path, PngFactory.Create(w, h));
|
||||
return path;
|
||||
}
|
||||
|
||||
static (Window Window, MarkdownView View) Show(string markdown, double width = 500)
|
||||
{
|
||||
var view = new MarkdownView();
|
||||
var window = new Window { Width = width, Height = 800, Content = view };
|
||||
window.Show();
|
||||
view.Source = markdown;
|
||||
WaitForImages(view);
|
||||
return (window, view);
|
||||
}
|
||||
|
||||
// il caricamento è in background (ADR-0006): si fa girare la coda della GUI finché tutte le immagini sono decodificate
|
||||
static void WaitForImages(MarkdownView view)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (view.PendingImages > 0 && DateTime.UtcNow < deadline) { Dispatcher.UIThread.RunJobs(); Thread.Sleep(5); }
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
(TopLevel.GetTopLevel(view) as Window)?.UpdateLayout();
|
||||
Assert.Equal(0, view.PendingImages);
|
||||
}
|
||||
|
||||
static List<Image> Images(Visual v) => v.GetVisualDescendants().OfType<Image>().ToList();
|
||||
|
||||
static string Text(Visual v) => string.Concat(v.GetVisualDescendants().OfType<TextBlock>()
|
||||
.Select(t => t.Inlines is { Count: > 0 } ? string.Concat(t.Inlines.OfType<Run>().Select(r => r.Text)) : t.Text));
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData("https://example.com/a.png")]
|
||||
[InlineData("http://example.com/a.png")]
|
||||
[InlineData("ftp://example.com/a.png")]
|
||||
[InlineData("data:image/png;base64,AAAA")]
|
||||
public void Immagini_remote_come_testo(string url)
|
||||
{
|
||||
var (_, view) = Show($"Vedi  qui");
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[logo]", Text(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_percorso_assoluto()
|
||||
{
|
||||
var path = Png("a.png", 40, 20);
|
||||
var (_, view) = Show($"");
|
||||
var img = Assert.Single(Images(view));
|
||||
var bmp = Assert.IsAssignableFrom<Bitmap>(img.Source);
|
||||
Assert.Equal(new PixelSize(40, 20), bmp.PixelSize);
|
||||
Assert.Equal(40, img.Bounds.Width, 0.5);
|
||||
Assert.DoesNotContain("[griglia]", Text(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_percorso_relativo_e_file_uri()
|
||||
{
|
||||
var name = $"awui-{Guid.NewGuid():N}.png";
|
||||
var inBasePath = Path.Combine(AppContext.BaseDirectory, name);
|
||||
File.WriteAllBytes(inBasePath, PngFactory.Create(10, 10));
|
||||
inBase.Add(inBasePath);
|
||||
var (_, relative) = Show($"");
|
||||
Assert.Single(Images(relative));
|
||||
|
||||
var uri = new Uri(Png("b.png", 12, 12)).AbsoluteUri;
|
||||
var (_, fileUri) = Show($"");
|
||||
Assert.Single(Images(fileUri));
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData("manca")]
|
||||
[InlineData("corrotto")]
|
||||
[InlineData("estensione")]
|
||||
public void Immagine_non_valida_ripiego(string caso)
|
||||
{
|
||||
var path = caso switch
|
||||
{
|
||||
"manca" => Path.Combine(dir, "non-esiste.png"),
|
||||
"corrotto" => Write("rotto.png", "questo non è un png"u8.ToArray()),
|
||||
_ => Write("dati.txt", PngFactory.Create(8, 8)),
|
||||
};
|
||||
var (_, view) = Show($"");
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[alt]", Text(view));
|
||||
}
|
||||
|
||||
string Write(string name, byte[] bytes)
|
||||
{
|
||||
var path = Path.Combine(dir, name);
|
||||
File.WriteAllBytes(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_oltre_limite()
|
||||
{
|
||||
var path = Path.Combine(dir, "grande.png");
|
||||
using (var fs = File.Create(path)) { fs.Write(PngFactory.Create(8, 8)); fs.SetLength(20L * 1024 * 1024 + 1); }
|
||||
var (_, view) = Show($"");
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[enorme]", Text(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_larghezza_massima()
|
||||
{
|
||||
var (_, wide) = Show($"})", width: 500);
|
||||
var img = Assert.Single(Images(wide));
|
||||
Assert.True(img.Bounds.Width <= 500, $"larghezza {img.Bounds.Width}");
|
||||
Assert.Equal(img.Bounds.Width / 20, img.Bounds.Height, 1); // proporzioni 20:1
|
||||
|
||||
var (_, small) = Show($"})", width: 500);
|
||||
Assert.Equal(50, Assert.Single(Images(small)).Bounds.Width, 0.5); // mai ingrandita
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_testo_alternativo_accessibile()
|
||||
{
|
||||
var (_, view) = Show($"})");
|
||||
var img = Assert.Single(Images(view));
|
||||
Assert.Equal("un grafico", AutomationProperties.GetName(img));
|
||||
Assert.Equal("un grafico", ToolTip.GetTip(img));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_in_contesti_annidati()
|
||||
{
|
||||
var p = Png("n.png", 10, 10);
|
||||
var markdown = $"""
|
||||
Testo  e ancora testo con [un link](https://example.com).
|
||||
|
||||
[](https://example.com)
|
||||
|
||||
- voce 
|
||||
|
||||
| a | b |
|
||||
|---|---|
|
||||
|  | x |
|
||||
""";
|
||||
var (_, view) = Show(markdown);
|
||||
Assert.Equal(4, Images(view).Count);
|
||||
Assert.Contains("un link", Text(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_cambio_tema()
|
||||
{
|
||||
var (window, view) = Show($"})");
|
||||
window.RequestedThemeVariant = ThemeVariant.Dark;
|
||||
WaitForImages(view);
|
||||
Assert.Single(Images(view));
|
||||
window.RequestedThemeVariant = ThemeVariant.Light;
|
||||
WaitForImages(view);
|
||||
Assert.Single(Images(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_caricamento_in_background()
|
||||
{
|
||||
var path = Png("bg.png", 30, 30);
|
||||
var view = new MarkdownView();
|
||||
var window = new Window { Width = 500, Height = 800, Content = view };
|
||||
window.Show();
|
||||
view.Source = $"";
|
||||
// subito dopo il rendering: immagine in attesa e solo il segnaposto (il completamento passa dalla coda della GUI)
|
||||
Assert.Equal(1, view.PendingImages);
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[segnaposto]", Text(view));
|
||||
WaitForImages(view);
|
||||
Assert.Single(Images(view));
|
||||
Assert.DoesNotContain("[segnaposto]", Text(view));
|
||||
|
||||
// file corrotto: la decodifica fallisce e il segnaposto resta
|
||||
var rotto = Write("rotto2.png", "no"u8.ToArray());
|
||||
view.Source = $"";
|
||||
WaitForImages(view);
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[guasta]", Text(view));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace AgentWikiUi.Tests;
|
||||
|
||||
/// <summary>Scrive PNG minimali (colore pieno) senza dipendere da Avalonia.</summary>
|
||||
static class PngFactory
|
||||
{
|
||||
public static byte[] Create(int width, int height)
|
||||
{
|
||||
var raw = new byte[height * (1 + width * 3)];
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++) raw[y * (1 + width * 3) + 1 + x * 3] = 200; // filtro 0 per riga, canale rosso
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
var ihdr = new byte[13];
|
||||
WriteInt(ihdr, 0, width); WriteInt(ihdr, 4, height);
|
||||
ihdr[8] = 8; ihdr[9] = 2; // 8 bit, RGB
|
||||
Chunk(ms, "IHDR", ihdr);
|
||||
using (var z = new MemoryStream())
|
||||
{
|
||||
using (var zs = new ZLibStream(z, CompressionLevel.Fastest, leaveOpen: true)) zs.Write(raw);
|
||||
Chunk(ms, "IDAT", z.ToArray());
|
||||
}
|
||||
Chunk(ms, "IEND", []);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
static void WriteInt(byte[] b, int at, int v) { b[at] = (byte)(v >> 24); b[at + 1] = (byte)(v >> 16); b[at + 2] = (byte)(v >> 8); b[at + 3] = (byte)v; }
|
||||
|
||||
static void Chunk(Stream s, string type, byte[] data)
|
||||
{
|
||||
var len = new byte[4]; WriteInt(len, 0, data.Length);
|
||||
s.Write(len);
|
||||
var body = new byte[4 + data.Length];
|
||||
for (var i = 0; i < 4; i++) body[i] = (byte)type[i];
|
||||
data.CopyTo(body, 4);
|
||||
s.Write(body);
|
||||
var crc = new byte[4]; WriteInt(crc, 0, (int)Crc32(body));
|
||||
s.Write(crc);
|
||||
}
|
||||
|
||||
static uint Crc32(byte[] data)
|
||||
{
|
||||
var crc = 0xFFFFFFFFu;
|
||||
foreach (var b in data)
|
||||
{
|
||||
crc ^= b;
|
||||
for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1;
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Themes.Fluent;
|
||||
using AgentWikiUi.Tests;
|
||||
|
||||
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
|
||||
|
||||
namespace AgentWikiUi.Tests;
|
||||
|
||||
public class TestApp : Application
|
||||
{
|
||||
public override void Initialize() => Styles.Add(new FluentTheme());
|
||||
}
|
||||
|
||||
public static class TestAppBuilder
|
||||
{
|
||||
// Skia reale (non il disegno finto): serve per decodificare davvero le immagini
|
||||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<TestApp>()
|
||||
.UseSkia()
|
||||
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>AgentWikiUi.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
|
||||
<PackageReference Include="Avalonia.Headless.XUnit" Version="12.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/agent-wiki-ui/agent-wiki-ui.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user