diff --git a/AGENT.md b/AGENT.md index eaed81e..36a0b61 100644 --- a/AGENT.md +++ b/AGENT.md @@ -17,7 +17,7 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md - Build: `dotnet build` dalla radice (soluzione `agent-wiki-ui.slnx`: app in `src/agent-wiki-ui/`, test in `tests/`; `net10.0`, Avalonia) - GUI: `dotnet run --project src/agent-wiki-ui`; CLI: `dotnet run --project src/agent-wiki-ui -- "prompt"` - Publish single-file (uno per sistema): `scripts/publish.sh` (Linux/WSL/macOS) o `scripts/publish.ps1` (Windows PowerShell): creano `dist/win-x64/` e `dist/linux-x64/` (`dist/` è ignorata da git); i due script vanno tenuti allineati, oppure a mano `dotnet publish src/agent-wiki-ui -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64` -- Test: `dotnet test` dalla radice (xUnit v3 + Avalonia.Headless con Skia reale). Per ora coprono solo le immagini del renderer (MD-14, MD-19…MD-30); il resto è pianificato in docs/TESTING.md (Fase 6). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. +- Test: `dotnet test` dalla radice (xUnit v3 + Avalonia.Headless con Skia reale; `tests/FakeClaude` è il `claude` finto per i test di processo). Per ora coprono solo le immagini del renderer (MD-14, MD-19…MD-30); il resto è pianificato in docs/TESTING.md (Fase 6). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. ## Architettura @@ -31,7 +31,7 @@ Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa de - [Core/AppInfo.cs](src/agent-wiki-ui/Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente). - [Gui/MainViewModel.cs](src/agent-wiki-ui/Gui/MainViewModel.cs): stato e logica della chat (invio, apertura/eliminazione, salvataggio subito all'invio e di nuovo alla risposta, riapertura dell'ultima chat all'avvio, interruttore CLI: cambiare motore apre una nuova chat; aggiornamento dei CLI con un solo aggiornamento alla volta, bloccando invio e cambio motore). - [Gui/MainWindow.axaml](src/agent-wiki-ui/Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](src/agent-wiki-ui/Gui/App.axaml): token di colore come risorse di tema chiaro/scuro e stili; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione. -- [Gui/MarkdownView.cs](src/agent-wiki-ui/Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`); le immagini si mostrano solo da file locale (ADR-0006), mai da URL; i percorsi relativi si cercano nella cartella dell'exe e poi nelle superiori (radice della wiki). +- [Gui/MarkdownView.cs](src/agent-wiki-ui/Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`); la parte sulle immagini è in [Gui/MarkdownView.Images.cs](src/agent-wiki-ui/Gui/MarkdownView.Images.cs) (`partial`). Le immagini si mostrano solo da file locale (ADR-0006), mai da URL; i percorsi relativi si cercano nella cartella dell'exe e poi nelle superiori (radice della wiki). - Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig. ## Vincoli non ovvi diff --git a/agent-wiki-ui.slnx b/agent-wiki-ui.slnx index 233e42c..e2255c9 100644 --- a/agent-wiki-ui.slnx +++ b/agent-wiki-ui.slnx @@ -4,5 +4,6 @@ + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 03a642d..b87964e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -65,9 +65,9 @@ Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md). Decisione: [ADR-0003](adr/0003-strategia-di-test.md). Piano, soglie e matrice: [TESTING.md](TESTING.md). Da questa fase in poi vale la regola: ogni nuova funzione arriva con i suoi test. - [ ] Verificare le compatibilità dell'ADR-0003 (xUnit, Avalonia.Headless, coverlet, `dotnet test`) e portarlo ad "Accettata" -- [ ] Ristrutturare in `src/` e `tests/` con soluzione unica e `InternalsVisibleTo` +- [x] Ristrutturare in `src/` e `tests/` con soluzione unica e `InternalsVisibleTo` - [ ] Predisposizioni di testabilità: `ChatStore` a istanza, `IClaudeRunner` -- [ ] `FakeClaude`: eseguibile finto per i test di processo +- [x] `FakeClaude`: eseguibile finto per i test di processo (`tests/FakeClaude`, prove FK-01…FK-05) - [ ] Test `Core/` (CL, ST) e `MainViewModel` (VM) - [ ] Test del renderer markdown (MD) e della GUI headless (UI) - [ ] Test end-to-end e smoke di publish per `win-x64` e `linux-x64` (PUB) diff --git a/docs/TESTING.md b/docs/TESTING.md index 458f78f..bcb85f1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -21,13 +21,27 @@ ADR (se serve) → MVP.md (requisito e criterio) → riga in questa matrice → | **P** — processo | avvio reale di un processo (`Claude`) | `FakeClaude` nel PATH | | **E** — end-to-end | l'eseguibile pubblicato (CLI, 127, exit code) | publish + processo figlio + `FakeClaude` | -## Struttura prevista +## Struttura ``` agent-wiki-ui.slnx src/agent-wiki-ui/ app (Core/, Gui/, Program.cs) -tests/agent-wiki-ui.Tests/ U, R, G, P, E (Trait "Category": Unit | Gui | Process | E2E) -tests/FakeClaude/ eseguibile finto: registra argv/cwd/stdin; testo, stderr, exit code e ritardo da variabili d'ambiente +tests/agent-wiki-ui.Tests/ Support/ (TestApp, PngFactory), Gui/ (R, G), Process/ (P), Core/ (U, quando arrivano), E2E/ (E) +tests/FakeClaude/ eseguibile finto `claude` (vedi sotto) ``` +Una cartella per livello, creata quando c'è il primo test; il livello si dichiara anche con `[Trait("Category", "Unit" | "Gui" | "Process" | "E2E")]`. + +### FakeClaude +Piccolo eseguibile (`AssemblyName` `claude`, nel PATH dei test) che prende il posto del vero motore. Un test lo configura con variabili d'ambiente del processo che avvia: + +| Variabile | Effetto | +|---|---| +| `FAKE_CLAUDE_LOG` | file in cui accoda una riga JSON per invocazione: `{"args": [...], "cwd": "...", "stdin": "..."}` | +| `FAKE_CLAUDE_OUT` | testo scritto su stdout (anche per `--version`) | +| `FAKE_CLAUDE_ERR` | testo scritto su stderr | +| `FAKE_CLAUDE_EXIT` | exit code (predefinito 0) | +| `FAKE_CLAUDE_DELAY_MS` | attesa prima di rispondere | + +Legge stdin fino alla fine e lo registra: se il chiamante non lo chiude, il processo non termina (così CL-05 è verificabile). Il percorso del file compilato lo fornisce l'helper di test `FakeClaudePath`. ## Predisposizioni di testabilità (prerequisito dei test) - [ ] `ChatStore` come istanza con cartella iniettata (oggi statico su `LocalApplicationData`) @@ -242,6 +256,15 @@ Per **ogni** motore, la stessa batteria di contratto: | EN-17 | CLI `--engine "prompt"`; id sconosciuto o prompt mancante ⇒ uso su stderr ed exit 2; senza `--engine` ⇒ Claude Code | E | [ ] | | EN-18 | Rilevamento nel PATH: nome esatto su Linux, `.exe` su Windows; cartella inesistente nel PATH ignorata | U | [ ] | +### FakeClaude — `tests/FakeClaude` +| ID | Requisito | Liv. | Test | | +|---|---|---|---|---| +| FK-01 | Registra argomenti (anche con virgolette e spazi, uno per elemento), cartella di lavoro e stdin | P | `Registra_argomenti_cwd_e_stdin` | [x] | +| FK-02 | Scrive su stdout e stderr il testo configurato | P | `Output_e_stderr_configurati` | [x] | +| FK-03 | L'exit code configurato è quello del processo | P | `ExitCode_configurato` | [x] | +| FK-04 | Il ritardo configurato è rispettato | P | `Ritardo_configurato` | [x] | +| FK-05 | Termina solo dopo la chiusura di stdin | P | `Attende_la_chiusura_di_stdin` | [x] | + ## Come si eseguono (a implementazione avvenuta) Comandi previsti, da confermare con ADR-0003 → "Da verificare": - Tutti i test: `dotnet test` diff --git a/src/agent-wiki-ui/Gui/MarkdownView.Images.cs b/src/agent-wiki-ui/Gui/MarkdownView.Images.cs new file mode 100644 index 0000000..4bb26e0 --- /dev/null +++ b/src/agent-wiki-ui/Gui/MarkdownView.Images.cs @@ -0,0 +1,124 @@ +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace AgentWikiUi.Gui; + +/// Immagini nel markdown (ADR-0006): solo file locali, caricamento in background, ripiego sul testo alternativo. +partial class MarkdownView +{ + // Immagini (ADR-0006): solo file locali, entro questi limiti + const long MaxImageBytes = 20L * 1024 * 1024; + const int MaxDecodeWidth = 1600; + const double MaxInlineImageWidth = 480; + static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]; + + /// Immagini ancora in decodifica (ADR-0006); i test attendono che scenda a zero. + internal int PendingImages { get; private set; } + + /// Cartella da cui si cercano i percorsi relativi delle immagini: l'exe, poi le cartelle superiori (ADR-0006). Modificabile dai test. + internal string BaseDirectory { get; set; } = AppContext.BaseDirectory; + + static string AltText(LinkInline img) => string.Concat(img.OfType().Select(l => l.Content.ToString())); + + /// Percorso locale a cui punta la destinazione di un'immagine; nullo per URL remoti o altri schemi. + /// Un percorso relativo si cerca in e poi nelle cartelle superiori (radice del progetto del motore): vince la prima in cui il file esiste. + internal static string? ResolveImagePath(string? url, string baseDir) + { + if (string.IsNullOrWhiteSpace(url)) return null; + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri.IsFile ? uri.LocalPath : null; + var relative = Uri.UnescapeDataString(url); + var first = Path.GetFullPath(relative, baseDir); + for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent) + { + var candidate = Path.GetFullPath(relative, dir.FullName); + if (File.Exists(candidate)) return candidate; + } + return first; // non esiste da nessuna parte: il controllo successivo lo scarta + } + + /// Controllo rapido e sincrono: percorso locale, formato ammesso, file esistente entro il limite. Non decodifica. + static bool IsImageCandidate(string? path) + { + try + { + if (path is null || !ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) return false; + var info = new FileInfo(path); + return info.Exists && info.Length <= MaxImageBytes; + } + catch { return false; } + } + + /// Decodifica l'immagine (chiamata fuori dal thread della GUI); nullo se illeggibile, mai eccezioni. + static Bitmap? DecodeImage(string path) + { + try + { + using var fs = File.OpenRead(path); + var bmp = new Bitmap(fs); + if (bmp.PixelSize.Width <= MaxDecodeWidth) return bmp; + bmp.Dispose(); + fs.Position = 0; + return Bitmap.DecodeToWidth(fs, MaxDecodeWidth); + } + catch { return null; } + } + + // Segnaposto `[alt]` che diventa l'immagine a decodifica finita; se la decodifica fallisce resta il testo. + Control ImageHost(string path, string alt, double maxWidth) + { + var host = new Decorator { HorizontalAlignment = HorizontalAlignment.Left, Child = new TextBlock { Text = "[" + alt + "]", TextWrapping = TextWrapping.Wrap } }; + PendingImages++; + _ = LoadInto(host, path, alt, maxWidth); + return host; + } + + async Task LoadInto(Decorator host, string path, string alt, double maxWidth) + { + try + { + if (await Task.Run(() => DecodeImage(path)) is { } bmp) host.Child = ImageOf(bmp, alt, maxWidth); + } + finally { PendingImages--; } + } + + static Image ImageOf(Bitmap bmp, string alt, double maxWidth) + { + var img = new Image + { + Source = bmp, Stretch = Stretch.Uniform, StretchDirection = StretchDirection.DownOnly, + MaxWidth = Math.Min(maxWidth, bmp.Size.Width), HorizontalAlignment = HorizontalAlignment.Left, + }; + AutomationProperties.SetName(img, alt); + ToolTip.SetTip(img, alt); + return img; + } + + // Paragrafo fatto solo di immagini: blocchi alla larghezza della colonna. Nullo se non è così o nessuna è plausibile. + Control? ImageParagraph(ParagraphBlock p) + { + if (p.Inline is null) return null; + var images = new List(); + foreach (var node in p.Inline) + { + if (node is LinkInline { IsImage: true } img) images.Add(img); + else if (node is not LineBreakInline && !(node is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) return null; + } + if (images.Count == 0) return null; + var paths = images.Select(i => ResolveImagePath(i.Url, BaseDirectory)).ToList(); + if (!paths.Any(IsImageCandidate)) return null; + var panel = new StackPanel { Spacing = 8 }; + for (var i = 0; i < images.Count; i++) + { + var image = images[i]; + panel.Children.Add(IsImageCandidate(paths[i]) ? ImageHost(paths[i]!, AltText(image), double.PositiveInfinity) + : new SelectableTextBlock { Text = "[" + AltText(image) + "]", TextWrapping = TextWrapping.Wrap }); + } + return panel; + } +} diff --git a/src/agent-wiki-ui/Gui/MarkdownView.cs b/src/agent-wiki-ui/Gui/MarkdownView.cs index a85e37f..f8e4b23 100644 --- a/src/agent-wiki-ui/Gui/MarkdownView.cs +++ b/src/agent-wiki-ui/Gui/MarkdownView.cs @@ -1,12 +1,10 @@ using Avalonia; -using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Controls.Documents; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Layout; using Avalonia.Media; -using Avalonia.Media.Imaging; using Markdig; using Markdig.Extensions.Tables; using Markdig.Syntax; @@ -15,23 +13,12 @@ using Markdig.Syntax.Inlines; namespace AgentWikiUi.Gui; /// Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui. -class MarkdownView : Decorator +partial class MarkdownView : Decorator { public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register(nameof(Source)); static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build(); - // Immagini (ADR-0006): solo file locali, entro questi limiti - const long MaxImageBytes = 20L * 1024 * 1024; - const int MaxDecodeWidth = 1600; - const double MaxInlineImageWidth = 480; - static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]; static readonly FontFamily Mono = new("Cascadia Code, Consolas, DejaVu Sans Mono, Liberation Mono, monospace"); - /// Immagini ancora in decodifica (ADR-0006); i test attendono che scenda a zero. - internal int PendingImages { get; private set; } - - /// Cartella da cui si cercano i percorsi relativi delle immagini: l'exe, poi le cartelle superiori (ADR-0006). Modificabile dai test. - internal string BaseDirectory { get; set; } = AppContext.BaseDirectory; - public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); } public MarkdownView() @@ -135,104 +122,6 @@ class MarkdownView : Decorator return new Border { BorderBrush = Brush("Border"), BorderThickness = new(0.5), CornerRadius = new(6), ClipToBounds = true, Child = grid }; } - static string AltText(LinkInline img) => string.Concat(img.OfType().Select(l => l.Content.ToString())); - - /// Percorso locale a cui punta la destinazione di un'immagine; nullo per URL remoti o altri schemi. - /// Un percorso relativo si cerca in e poi nelle cartelle superiori (radice del progetto del motore): vince la prima in cui il file esiste. - internal static string? ResolveImagePath(string? url, string baseDir) - { - if (string.IsNullOrWhiteSpace(url)) return null; - if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri.IsFile ? uri.LocalPath : null; - var relative = Uri.UnescapeDataString(url); - var first = Path.GetFullPath(relative, baseDir); - for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent) - { - var candidate = Path.GetFullPath(relative, dir.FullName); - if (File.Exists(candidate)) return candidate; - } - return first; // non esiste da nessuna parte: il controllo successivo lo scarta - } - - /// Controllo rapido e sincrono: percorso locale, formato ammesso, file esistente entro il limite. Non decodifica. - static bool IsImageCandidate(string? path) - { - try - { - if (path is null || !ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) return false; - var info = new FileInfo(path); - return info.Exists && info.Length <= MaxImageBytes; - } - catch { return false; } - } - - /// Decodifica l'immagine (chiamata fuori dal thread della GUI); nullo se illeggibile, mai eccezioni. - static Bitmap? DecodeImage(string path) - { - try - { - using var fs = File.OpenRead(path); - var bmp = new Bitmap(fs); - if (bmp.PixelSize.Width <= MaxDecodeWidth) return bmp; - bmp.Dispose(); - fs.Position = 0; - return Bitmap.DecodeToWidth(fs, MaxDecodeWidth); - } - catch { return null; } - } - - // Segnaposto `[alt]` che diventa l'immagine a decodifica finita; se la decodifica fallisce resta il testo. - Control ImageHost(string path, string alt, double maxWidth) - { - var host = new Decorator { HorizontalAlignment = HorizontalAlignment.Left, Child = new TextBlock { Text = "[" + alt + "]", TextWrapping = TextWrapping.Wrap } }; - PendingImages++; - _ = LoadInto(host, path, alt, maxWidth); - return host; - } - - async Task LoadInto(Decorator host, string path, string alt, double maxWidth) - { - try - { - if (await Task.Run(() => DecodeImage(path)) is { } bmp) host.Child = ImageOf(bmp, alt, maxWidth); - } - finally { PendingImages--; } - } - - static Image ImageOf(Bitmap bmp, string alt, double maxWidth) - { - var img = new Image - { - Source = bmp, Stretch = Stretch.Uniform, StretchDirection = StretchDirection.DownOnly, - MaxWidth = Math.Min(maxWidth, bmp.Size.Width), HorizontalAlignment = HorizontalAlignment.Left, - }; - AutomationProperties.SetName(img, alt); - ToolTip.SetTip(img, alt); - return img; - } - - // Paragrafo fatto solo di immagini: blocchi alla larghezza della colonna. Nullo se non è così o nessuna è plausibile. - Control? ImageParagraph(ParagraphBlock p) - { - if (p.Inline is null) return null; - var images = new List(); - foreach (var node in p.Inline) - { - if (node is LinkInline { IsImage: true } img) images.Add(img); - else if (node is not LineBreakInline && !(node is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) return null; - } - if (images.Count == 0) return null; - var paths = images.Select(i => ResolveImagePath(i.Url, BaseDirectory)).ToList(); - if (!paths.Any(IsImageCandidate)) return null; - var panel = new StackPanel { Spacing = 8 }; - for (var i = 0; i < images.Count; i++) - { - var image = images[i]; - panel.Children.Add(IsImageCandidate(paths[i]) ? ImageHost(paths[i]!, AltText(image), double.PositiveInfinity) - : new SelectableTextBlock { Text = "[" + AltText(image) + "]", TextWrapping = TextWrapping.Wrap }); - } - return panel; - } - // Testo con inline; i link sono Run colorati e il clic si risolve con l'hit-test sul testo. SelectableTextBlock Text(ContainerInline? inline, bool bold = false, double size = 0) { diff --git a/tests/FakeClaude/FakeClaude.csproj b/tests/FakeClaude/FakeClaude.csproj new file mode 100644 index 0000000..e076952 --- /dev/null +++ b/tests/FakeClaude/FakeClaude.csproj @@ -0,0 +1,13 @@ + + + + + Exe + net10.0 + claude + FakeClaude + enable + enable + + + diff --git a/tests/FakeClaude/Program.cs b/tests/FakeClaude/Program.cs new file mode 100644 index 0000000..0670dfc --- /dev/null +++ b/tests/FakeClaude/Program.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +// Configurazione e formato del registro: docs/TESTING.md → FakeClaude. +var stdin = Console.In.ReadToEnd(); // resta in attesa finché il chiamante non chiude stdin + +if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_LOG") is { Length: > 0 } log) + File.AppendAllText(log, JsonSerializer.Serialize(new { args, cwd = Directory.GetCurrentDirectory(), stdin }) + "\n"); + +if (int.TryParse(Environment.GetEnvironmentVariable("FAKE_CLAUDE_DELAY_MS"), out var delay) && delay > 0) + Thread.Sleep(delay); + +if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_OUT") is { } o) Console.Out.Write(o); +if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_ERR") is { } e) Console.Error.Write(e); + +return int.TryParse(Environment.GetEnvironmentVariable("FAKE_CLAUDE_EXIT"), out var code) ? code : 0; diff --git a/tests/agent-wiki-ui.Tests/MarkdownImageTests.cs b/tests/agent-wiki-ui.Tests/Gui/MarkdownImageTests.cs similarity index 100% rename from tests/agent-wiki-ui.Tests/MarkdownImageTests.cs rename to tests/agent-wiki-ui.Tests/Gui/MarkdownImageTests.cs diff --git a/tests/agent-wiki-ui.Tests/Process/FakeClaudeTests.cs b/tests/agent-wiki-ui.Tests/Process/FakeClaudeTests.cs new file mode 100644 index 0000000..7d6cd28 --- /dev/null +++ b/tests/agent-wiki-ui.Tests/Process/FakeClaudeTests.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace AgentWikiUi.Tests; + +/// Il doppio di test di claude (docs/TESTING.md → FakeClaude, FK-01…FK-05). +[Trait("Category", "Process")] +public sealed class FakeClaudeTests : IDisposable +{ + readonly string dir = Directory.CreateTempSubdirectory("awui-fake-").FullName; + + public void Dispose() => Directory.Delete(dir, true); + + string LogPath => Path.Combine(dir, "log.jsonl"); + + Process Start(IEnumerable args, Dictionary? env = null, string? cwd = null) + { + var psi = new ProcessStartInfo(FakeClaudePath.Exe) + { + UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + WorkingDirectory = cwd ?? dir, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + psi.Environment["FAKE_CLAUDE_LOG"] = LogPath; + foreach (var (k, v) in env ?? []) psi.Environment[k] = v; + return Process.Start(psi)!; + } + + [Fact] + public void Registra_argomenti_cwd_e_stdin() + { + var cwd = Directory.CreateDirectory(Path.Combine(dir, "lavoro")).FullName; + using var p = Start(["-p", "dì \"ciao\" a tutti", "--model", "m"], cwd: cwd); + p.StandardInput.Write("dati da stdin"); + p.StandardInput.Close(); + Assert.True(p.WaitForExit(10_000)); + + using var line = JsonDocument.Parse(File.ReadAllLines(LogPath).Single()); + var r = line.RootElement; + Assert.Equal(["-p", "dì \"ciao\" a tutti", "--model", "m"], r.GetProperty("args").EnumerateArray().Select(a => a.GetString())); + Assert.Equal(cwd, r.GetProperty("cwd").GetString()); + Assert.Equal("dati da stdin", r.GetProperty("stdin").GetString()); + } + + [Fact] + public void Output_e_stderr_configurati() + { + using var p = Start([], new() { ["FAKE_CLAUDE_OUT"] = "risposta", ["FAKE_CLAUDE_ERR"] = "avviso" }); + p.StandardInput.Close(); + Assert.Equal("risposta", p.StandardOutput.ReadToEnd()); + Assert.Equal("avviso", p.StandardError.ReadToEnd()); + Assert.True(p.WaitForExit(10_000)); + } + + [Theory] + [InlineData(null, 0)] + [InlineData("3", 3)] + [InlineData("127", 127)] + public void ExitCode_configurato(string? configured, int expected) + { + using var p = Start([], configured is null ? null : new() { ["FAKE_CLAUDE_EXIT"] = configured }); + p.StandardInput.Close(); + Assert.True(p.WaitForExit(10_000)); + Assert.Equal(expected, p.ExitCode); + } + + [Fact] + public void Ritardo_configurato() + { + using var p = Start([], new() { ["FAKE_CLAUDE_DELAY_MS"] = "600" }); + var sw = Stopwatch.StartNew(); + p.StandardInput.Close(); + Assert.True(p.WaitForExit(10_000)); + Assert.True(sw.ElapsedMilliseconds >= 500, $"terminato dopo {sw.ElapsedMilliseconds} ms"); + } + + [Fact] + public void Attende_la_chiusura_di_stdin() + { + using var p = Start([]); + try + { + Assert.False(p.WaitForExit(700), "il processo è terminato con stdin ancora aperto"); + p.StandardInput.Close(); + Assert.True(p.WaitForExit(10_000)); + } + finally { if (!p.HasExited) p.Kill(); } + } +} diff --git a/tests/agent-wiki-ui.Tests/Support/FakeClaudePath.cs b/tests/agent-wiki-ui.Tests/Support/FakeClaudePath.cs new file mode 100644 index 0000000..4bccefa --- /dev/null +++ b/tests/agent-wiki-ui.Tests/Support/FakeClaudePath.cs @@ -0,0 +1,17 @@ +namespace AgentWikiUi.Tests; + +/// Percorso del claude finto compilato (tests/FakeClaude), nella stessa configurazione dei test. +static class FakeClaudePath +{ + public static string Exe + { + get + { + // …/tests/agent-wiki-ui.Tests/bin//net10.0/ + var tfm = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)); + var config = tfm.Parent!; + var testsRoot = config.Parent!.Parent!.Parent!; + return Path.Combine(testsRoot.FullName, "FakeClaude", "bin", config.Name, tfm.Name, OperatingSystem.IsWindows() ? "claude.exe" : "claude"); + } + } +} diff --git a/tests/agent-wiki-ui.Tests/PngFactory.cs b/tests/agent-wiki-ui.Tests/Support/PngFactory.cs similarity index 100% rename from tests/agent-wiki-ui.Tests/PngFactory.cs rename to tests/agent-wiki-ui.Tests/Support/PngFactory.cs diff --git a/tests/agent-wiki-ui.Tests/TestApp.cs b/tests/agent-wiki-ui.Tests/Support/TestApp.cs similarity index 100% rename from tests/agent-wiki-ui.Tests/TestApp.cs rename to tests/agent-wiki-ui.Tests/Support/TestApp.cs diff --git a/tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj b/tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj index 6d7e19b..1d15492 100644 --- a/tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj +++ b/tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj @@ -21,6 +21,8 @@ + +