diff --git a/AGENT.md b/AGENT.md index 7134425..4171b0a 100644 --- a/AGENT.md +++ b/AGENT.md @@ -14,24 +14,24 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md ## Comandi -- Build: `dotnet build` (agent-wiki-ui.csproj, `net10.0`, Avalonia) -- GUI: `dotnet run`; CLI: `dotnet run -- "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 -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64` -- Test: non esistono ancora (pianificati, Fase 6: `dotnet test`; il piano è in docs/TESTING.md). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. +- 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; `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 Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa del codice. -- [Program.cs](Program.cs): top-level statements. Con argomenti è la CLI (`EngineRunner.RunInherited`, motore scelto con `--engine ` come primo argomento, default Claude Code); senza avvia Avalonia (`App`). -- [Core/Engine.cs](Core/Engine.cs): astrazione "motore" (ADR-0002): `Engine` (id, nome, eseguibile, modelli, effort, `BuildArgs`, `Parse`, `FetchLatestVersionAsync` per il controllo delle versioni), `ClaudeEngine`, `CodexEngine`, `AgyEngine`, registro `Engines` e ricerca nel PATH (`PathSearch`). Gli elenchi modelli sono fissi, da aggiornare quando ne escono di nuovi; `DefaultModelId` indica il modello più piccolo, scelto all'avvio insieme a effort `low` (se esce un modello più piccolo, aggiornarlo). -- [Core/EngineRunner.cs](Core/EngineRunner.cs): **unico punto di avvio dei motori** (cartella dell'exe, stdin chiuso, `ArgumentList`): CLI con output ereditato, GUI con output catturato e interpretato da `Engine.Parse`, e `RunToolAsync` per i comandi di servizio (`--version`, `update`) con tempo massimo. -- [Core/EngineUpdater.cs](Core/EngineUpdater.cs): controllo delle versioni in sola lettura (`CheckAsync`, `IsNewer`) e aggiornamento di un CLI con il suo `update` ufficiale (ADR-0004): versione prima/dopo, esito leggibile, metodo di installazione dedotto dal percorso reale e comando manuale da suggerire (l'app lo mostra, non lo esegue). -- [Core/ChatStore.cs](Core/ChatStore.cs): cronologia, un JSON per chat in `//chats/.json` (nome file = id della chat, validato come Guid prima dell'uso; campi `Engine` e `SessionId` opzionali: i file vecchi sono chat di Claude Code). -- [Core/AppInfo.cs](Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente). -- [Gui/MainViewModel.cs](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](Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](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](Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`). +- [Program.cs](src/agent-wiki-ui/Program.cs): top-level statements. Con argomenti è la CLI (`EngineRunner.RunInherited`, motore scelto con `--engine ` come primo argomento, default Claude Code); senza avvia Avalonia (`App`). +- [Core/Engine.cs](src/agent-wiki-ui/Core/Engine.cs): astrazione "motore" (ADR-0002): `Engine` (id, nome, eseguibile, modelli, effort, `BuildArgs`, `Parse`, `FetchLatestVersionAsync` per il controllo delle versioni), `ClaudeEngine`, `CodexEngine`, `AgyEngine`, registro `Engines` e ricerca nel PATH (`PathSearch`). Gli elenchi modelli sono fissi, da aggiornare quando ne escono di nuovi; `DefaultModelId` indica il modello più piccolo, scelto all'avvio insieme a effort `low` (se esce un modello più piccolo, aggiornarlo). +- [Core/EngineRunner.cs](src/agent-wiki-ui/Core/EngineRunner.cs): **unico punto di avvio dei motori** (cartella dell'exe, stdin chiuso, `ArgumentList`): CLI con output ereditato, GUI con output catturato e interpretato da `Engine.Parse`, e `RunToolAsync` per i comandi di servizio (`--version`, `update`) con tempo massimo. +- [Core/EngineUpdater.cs](src/agent-wiki-ui/Core/EngineUpdater.cs): controllo delle versioni in sola lettura (`CheckAsync`, `IsNewer`) e aggiornamento di un CLI con il suo `update` ufficiale (ADR-0004): versione prima/dopo, esito leggibile, metodo di installazione dedotto dal percorso reale e comando manuale da suggerire (l'app lo mostra, non lo esegue). +- [Core/ChatStore.cs](src/agent-wiki-ui/Core/ChatStore.cs): cronologia, un JSON per chat in `//chats/.json` (nome file = id della chat, validato come Guid prima dell'uso; campi `Engine` e `SessionId` opzionali: i file vecchi sono chat di Claude Code). +- [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`); la parte sulle immagini è in [Gui/MarkdownView.Images.cs](src/agent-wiki-ui/Gui/MarkdownView.Images.cs) (`partial`); le formule LaTeX (ADR-0008) sono in [Gui/MarkdownView.Math.cs](src/agent-wiki-ui/Gui/MarkdownView.Math.cs) (`partial`, libreria `Sylinko.CSharpMath.Avalonia`). 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/README.md b/README.md index 98f64ce..1e77b70 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,14 @@ Eseguibile C# (.NET 10, Windows e Linux) che lancia Claude Code (`claude -p`), C GUI (nessun argomento): finestra di chat in stile claude.ai, con la conversazione in markdown in alto e il campo messaggio in basso (`Invio` invia, `Maiusc+Invio` va a capo, "Nuova chat" azzera il contesto). Il riquadro **Impostazioni** (in alto a destra) permette di scegliere la **CLI** (Claude Code, Codex o Antigravity), il **modello** e l'**effort** di ragionamento. All'avvio, e a ogni cambio di CLI, sono scelti il modello più piccolo (Haiku 5.5 per Claude Code, GPT-5.6 Luna per Codex, Gemini 3.0 Flash Low per Antigravity) ed effort `low`, per risposte rapide e economiche; "Predefinito" = nessun flag, vale la config del motore. Le chat si salvano su disco e l'ultima si riapre all'avvio. Sempre nel riquadro, la sezione **Aggiornamenti** dice se Claude Code, Codex e Antigravity hanno una versione nuova (pulsante evidenziato) e li aggiorna uno alla volta. ``` -dotnet run +dotnet run --project src/agent-wiki-ui ``` CLI (con argomenti): il prompt viene stampato su terminale, l'exit code è quello del motore (127 se non è nel PATH). Di default usa Claude Code; `--engine codex` usa Codex; `--engine agy` usa Antigravity. ``` -dotnet run -- "spiega questo progetto" -dotnet run -- --engine codex "spiega questo progetto" -dotnet run -- --engine agy "spiega questo progetto" +dotnet run --project src/agent-wiki-ui -- "spiega questo progetto" +dotnet run --project src/agent-wiki-ui -- --engine codex "spiega questo progetto" +dotnet run --project src/agent-wiki-ui -- --engine agy "spiega questo progetto" ``` ## Creare gli eseguibili (Windows e Linux) @@ -26,7 +26,7 @@ Ogni eseguibile è un **unico file** che include il runtime .NET: chi lo usa non ### 1. Installare il .NET SDK 10 - **Windows** (PowerShell o Prompt dei comandi): `winget install Microsoft.DotNet.SDK.10`, oppure il programma di installazione da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). -- **Linux / WSL / macOS**: pacchetto della distribuzione o script ufficiale da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). +- **Linux / WSL / macOS**: pacchetto della distribuzione o script ufficiale da [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). Funziona anche su ARM64 (Raspberry Pi): `curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh && bash /tmp/dotnet-install.sh --channel 10.0`, poi `export DOTNET_ROOT=$HOME/.dotnet PATH=$PATH:$HOME/.dotnet` (da mettere in `~/.bashrc`). - Controllo: `dotnet --version` deve stampare `10.x`. ### 2. Creare gli eseguibili @@ -46,11 +46,11 @@ Risultato (circa 47 MB per file), in `dist/`, ignorata da git: | Windows x64 | `dist/win-x64/agent-wiki-ui.exe` | | Linux x64 | `dist/linux-x64/agent-wiki-ui` | -Altri identificatori (per esempio `win-arm64`, `linux-arm64`) si possono provare allo stesso modo, ma non sono verificati. Lo script è solo una comodità: il comando sotto fa la stessa cosa. +Altri identificatori (per esempio `win-arm64`, `linux-arm64`) si passano allo script allo stesso modo (`scripts/publish.sh linux-arm64`, risultato in `dist/linux-arm64/`), ma non sono verificati. Su un Raspberry Pi servono anche i motori: per Claude Code vedi [docs/INSTALL.md](docs/INSTALL.md). Lo script è solo una comodità: il comando sotto fa la stessa cosa. **Senza script** (identico su qualsiasi sistema; sostituisci `` con `win-x64` o `linux-x64`): ``` -dotnet publish -c Release -r --self-contained -p:PublishSingleFile=true -o dist/ +dotnet publish src/agent-wiki-ui -c Release -r --self-contained -p:PublishSingleFile=true -o dist/ ``` Senza `-o` il risultato è in `bin/Release/net10.0//publish/`. @@ -62,6 +62,12 @@ Verificato: da Linux/WSL entrambi gli eseguibili; da Windows nativo (PowerShell - Al primo avvio le librerie native (Skia, HarfBuzz) vengono estratte in una cartella temporanea: il primo avvio è un po' più lento. - I motori (`claude`, `codex`, `agy`) devono essere installati e nel PATH dello stesso sistema: vedi [docs/INSTALL.md](docs/INSTALL.md). +## Formule nelle risposte +Le formule LaTeX si vedono composte: `$ ... $` nel testo e `$$ ... $$` a blocco ([ADR-0008](docs/adr/0008-formule-latex-nel-markdown.md)). Se il LaTeX non è valido compare il sorgente come testo; i `$` isolati (per esempio «10 $ e 20 $») restano testo. Le formule non sono selezionabili come testo, ma il pulsante **Copia** della risposta copia il markdown con il sorgente. + +## Immagini nelle risposte +L'app mostra il file indicato dal percorso scritto nella risposta, ovunque stia; non conosce né cartelle né cataloghi di immagini. Se la risposta contiene `![didascalia](percorso)` e il file esiste, la chat mostra l'immagine; altrimenti compare il testo `[didascalia]`. Gli URL `http(s)` non si scaricano. Un percorso relativo si cerca nella cartella dell'eseguibile e poi in quelle superiori, quindi funziona anche se l'eseguibile sta in una sottocartella del progetto. Dettagli: [ADR-0006](docs/adr/0006-immagini-nel-markdown.md); per una convenzione di progetto interrogabile con immagini vedi [ADR-0007](docs/adr/0007-schema-wiki-per-query-con-immagini.md). + ## Piattaforme Windows e Linux con lo stesso codice ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md)). Motori supportati: Claude Code, Codex CLI e Antigravity CLI, già installati da terminale: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md). diff --git a/agent-wiki-ui.slnx b/agent-wiki-ui.slnx new file mode 100644 index 0000000..e2255c9 --- /dev/null +++ b/agent-wiki-ui.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 4bd6b95..2b97958 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -8,7 +8,7 @@ Il motore va installato **nello stesso ambiente in cui gira l'app**: | Dove avvii l'app | Dove installare il motore | |---|---| | Linux nativo | nel terminale Linux | -| WSL (anche con `dotnet run`) | nel terminale della distribuzione WSL | +| WSL (anche con `dotnet run --project src/agent-wiki-ui`) | nel terminale della distribuzione WSL | | Windows (`.exe`) | in Windows (PowerShell), non in WSL | Dentro WSL il PATH di Windows è visibile (compare `/mnt/c/…`): se in WSL non c'è un `claude`, `codex` o `agy`, il comando può risolvere nella versione Windows. Controllalo con `command -v claude`, `command -v codex` e `command -v agy`. @@ -31,6 +31,17 @@ Dentro WSL il PATH di Windows è visibile (compare `/mnt/c/…`): se in WSL non L'installazione nativa non mostra avanzamento durante il download. A fine installazione apri un **nuovo** terminale. +**Da zero su Linux, anche ARM64 (per esempio Raspberry Pi 5 con Raspberry Pi OS a 64 bit).** Il metodo nativo riconosce da solo l'architettura (`uname -m` deve dare `aarch64` o `x86_64`): +``` +sudo apt install -y curl # se manca +curl -fsSL https://claude.ai/install.sh | bash +# apri un nuovo terminale, poi: +command -v claude # atteso: ~/.local/bin/claude +claude --version +claude # primo avvio: accesso nel browser +``` +Su un Raspberry senza schermo l'accesso nel browser si fa da un altro dispositivo, seguendo l'indirizzo che `claude` stampa nel terminale (per esempio via SSH); in alternativa usa una API key o un provider cloud, vedi "Accesso". Se `~/.local/bin` non è nel PATH aggiungi `export PATH="$HOME/.local/bin:$PATH"` a `~/.bashrc`. Con la sola architettura ARM64 e Debian 10+ i requisiti sono soddisfatti; il metodo npm è l'alternativa se lo script nativo non funziona. Non provato su un Raspberry reale: le istruzioni vengono dalla guida ufficiale. + **Accesso**: esegui `claude` e segui le istruzioni nel browser. Serve un account Pro, Max, Team, Enterprise o Console (il piano gratuito di claude.ai non include Claude Code). In alternativa Amazon Bedrock, Google Cloud o Microsoft Foundry. **Verifica** diff --git a/docs/MVP.md b/docs/MVP.md index 19bd332..6ccea0a 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -21,7 +21,7 @@ Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile **Struttura della finestra** - **Barra laterale** (sinistra, comprimibile con il pulsante a tre linee): logo e nome dell'app, pulsante **Nuova chat**, elenco delle chat salvate (titolo = inizio del primo messaggio, data/ora dell'ultimo scambio; la chat aperta è evidenziata). Un clic su una chat la riapre; la ✕ (con conferma) la elimina. Elenco vuoto: "Nessuna chat salvata". -- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link. Il rendering è un renderer proprio ([MarkdownView.cs](../Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice. +- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link, immagini, formule LaTeX (`$…$` nel testo, `$$…$$` a blocco; [ADR-0008](adr/0008-formule-latex-nel-markdown.md); se il LaTeX non è valido compare il sorgente). Le immagini `![alt](percorso)` si mostrano se il percorso è locale (assoluto, `file://` o relativo: cercato nella cartella dell'exe e poi nelle cartelle superiori, cioè nella radice del progetto del motore) e il file è un'immagine leggibile entro i limiti (caricata in background: prima compare `[alt]`, poi l'immagine); altrimenti, e sempre per gli URL `http(s)`, compare il testo alternativo `[alt]` e non si scarica nulla ([ADR-0006](adr/0006-immagini-nel-markdown.md)). Il rendering è un renderer proprio ([MarkdownView.cs](../src/agent-wiki-ui/Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice. - **Schermata iniziale** (chat vuota): solo logo, titolo e sottotitolo (nessun suggerimento precompilato). - **Composer** (in basso, scheda arrotondata): campo di testo che cresce fino a ~8 righe e pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda. Non contiene più i selettori di modello ed effort. - **Impostazioni** (in alto a destra, pulsante con icona a cursori, simmetrico a quello della barra laterale): aprono un riquadro a comparsa con l'interruttore **CLI** (Claude Code, Codex, Antigravity; un motore non installato è mostrato "non installato" e non è selezionabile; disabilitato durante l'attesa), poi **Modello** (Predefinito, catalogo del motore, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max). **Valori iniziali**: il modello più piccolo del motore (Claude Code: Haiku 5.5; Codex: GPT-5.6 Luna; Antigravity: Gemini 3.0 Flash Low) ed effort `low`, per risposte rapide e poco costose; sono quelli che si ritrovano all'avvio e a ogni cambio di CLI. Si chiude con `Esc` o con un clic fuori; le scelte restano valide per i messaggi successivi (non sono ancora salvate su disco). Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI. @@ -35,7 +35,7 @@ Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile - Finestra orizzontale con proporzioni 16:10: larga il 75% dell'area utile dello schermo (max 1440 px logici), altezza di conseguenza (max 90% dell'area utile), centrata; minimo 900x600. Su un monitor Full HD 1920x1080 risulta circa 1440x900. ### Design (UI/UX) -Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde). I token vivono come risorse di tema in [Gui/App.axaml](../Gui/App.axaml). +Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde). I token vivono come risorse di tema in [Gui/App.axaml](../src/agent-wiki-ui/Gui/App.axaml). - **Tema**: segue il sistema (chiaro/scuro) tramite `RequestedThemeVariant="Default"`. - **Token** (chiaro / scuro): sfondo `#FFFFFF` / `#0F172A`, superficie composer `#FFFFFF` / `#1E293B`, testo `#0F172A` / `#F8FAFC`, testo secondario `#475569` / `#94A3B8`, bordi `#CBD5E1` / `#334155`, accento `#15803D` / `#22C55E`, errore `#B91C1C` / `#F87171`. Contrasto testo ≥ 4.5:1. - **Tipografia**: font di sistema (Segoe UI Variable/Segoe UI su Windows; Inter, Ubuntu, Cantarell, Noto Sans, DejaVu Sans su Linux; corpo 15.5, titoli fino a 24), Cascadia Code/Consolas/DejaVu Sans Mono per il codice. Nessun font incorporato (funziona offline). @@ -46,11 +46,11 @@ Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile " ## Scelte tecniche Le scelte architetturali sono negli [ADR](README.md#indice-adr). Sintesi: -- .NET 10, un solo progetto `net10.0` per Windows e Linux. Avalonia (GUI), Markdig (parsing markdown). Il codice è diviso in `Core/` (avvio di `claude`, cronologia, nome app) e `Gui/` (finestra, ViewModel, renderer markdown); `Program.cs` sceglie CLI o GUI. +- .NET 10, un solo progetto `net10.0` per Windows e Linux. Avalonia (GUI), Markdig (parsing markdown), CSharpMath (formule LaTeX). Il codice è diviso in `Core/` (avvio di `claude`, cronologia, nome app) e `Gui/` (finestra, ViewModel, renderer markdown); `Program.cs` sceglie CLI o GUI. - `ProcessStartInfo.ArgumentList` per passare il prompt (niente problemi di quoting). - `claude` è un eseguibile nativo (`~/.local/bin`): si avvia direttamente, senza `cmd /c`. Lo stdin è chiuso subito per evitare l'attesa di 3s di claude. - Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante. -- L'avvio dei motori è in un solo punto ([Core/EngineRunner.cs](../Core/EngineRunner.cs)), usato da CLI e GUI; ogni motore descrive argomenti e parser in [Core/Engine.cs](../Core/Engine.cs). +- L'avvio dei motori è in un solo punto ([Core/EngineRunner.cs](../src/agent-wiki-ui/Core/EngineRunner.cs)), usato da CLI e GUI; ogni motore descrive argomenti e parser in [Core/Engine.cs](../src/agent-wiki-ui/Core/Engine.cs). ## Fuori scope (MVP) Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code, Codex e Antigravity ([ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, supporto macOS. I test automatici sono pianificati: [TESTING.md](TESTING.md). diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index af2914f..54985cd 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -42,6 +42,9 @@ Da [MVP.md](MVP.md). Restano non spuntati finché non hanno test automatici (reg | [0003](adr/0003-strategia-di-test.md) Strategia di test | Proposta | | [0004](adr/0004-aggiornamento-dei-cli.md) Aggiornamento dei CLI | Proposta | | [0005](adr/0005-windows-senza-console.md) Windows senza console | Proposta | +| [0006](adr/0006-immagini-nel-markdown.md) Immagini nel markdown (solo file locali) | Proposta (implementata, test verdi) | +| [0007](adr/0007-schema-wiki-per-query-con-immagini.md) Schema di una wiki interrogabile, con immagini | Proposta (solo documentazione; nessun codice) | +| [0008](adr/0008-formule-latex-nel-markdown.md) Formule LaTeX nel markdown | Proposta (implementata) | ## Per chiudere l'MVP Proposta, derivata dai punti aperti sopra; da confermare. diff --git a/docs/README.md b/docs/README.md index 57bf098..90979a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ Indice e regole. Ogni informazione vive in **un solo documento**; gli altri la r Il nome è **`agent-wiki-ui`** (prima `LlmRun`). Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o ``. Se il nome cambia di nuovo, aggiornare solo questi punti: -- [ ] `AssemblyName` in [../agent-wiki-ui.csproj](../agent-wiki-ui.csproj) (e il nome del file csproj): da lì il codice legge il nome (`Core/AppInfo.cs`) per titolo finestra, intestazione della barra laterale e cartella dati; l'eseguibile diventa `.exe` / `` +- [ ] `AssemblyName` in [../src/agent-wiki-ui/agent-wiki-ui.csproj](../src/agent-wiki-ui/agent-wiki-ui.csproj) (e il nome del file csproj): da lì il codice legge il nome (`Core/AppInfo.cs`) per titolo finestra, intestazione della barra laterale e cartella dati; l'eseguibile diventa `.exe` / `` - [ ] `RootNamespace` e namespace C# (`AgentWikiUi.*`, anche in `x:Class` e `xmlns` dei file `.axaml`): il nome del namespace non può avere trattini - [ ] **Cartella dati** `//chats`: cambiando nome le chat salvate non si trovano più. `ChatStore` sposta una vecchia cartella (`LlmRun`) in quella nuova al primo avvio; per un nuovo cambio aggiungere il nome precedente all'elenco `LegacyNames` - [ ] Titolo di [../README.md](../README.md) e riga iniziale di [../AGENT.md](../AGENT.md) (nome del csproj nei comandi) @@ -52,3 +52,6 @@ Non si scrive nulla nei puntatori: ogni modifica va in AGENT.md. Per un altro st | [0003](adr/0003-strategia-di-test.md) | Strategia di test e regola "nuova funzione = nuovi test" | Proposta | | [0004](adr/0004-aggiornamento-dei-cli.md) | Aggiornamento dei CLI dalle Impostazioni | Proposta | | [0005](adr/0005-windows-senza-console.md) | Windows: eseguibile senza finestra di console | Proposta | +| [0006](adr/0006-immagini-nel-markdown.md) | Immagini nel markdown: solo file locali | Proposta | +| [0007](adr/0007-schema-wiki-per-query-con-immagini.md) | Schema di una wiki interrogabile, con immagini | Proposta | +| [0008](adr/0008-formule-latex-nel-markdown.md) | Formule LaTeX nel markdown | Proposta | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6695210..8898e23 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -22,6 +22,9 @@ Dettagli dell'MVP in [MVP.md](MVP.md). Stadio attuale e debiti: [PROJECT_STATE.m ## Fase 3 — Oltre - [x] GUI: cronologia chat (salvataggio JSON, barra laterale, riapertura con `--resume`, eliminazione) - [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, rinomina/ricerca chat +- [x] GUI: immagini nel markdown da percorso locale, con ripiego sul testo alternativo ([ADR-0006](adr/0006-immagini-nel-markdown.md); test MD-14, MD-19…MD-30); immagini remote rimandate +- [x] Schema di una wiki interrogabile con immagini: convenzione per le istruzioni solo-query, l'indice e il catalogo ([ADR-0007](adr/0007-schema-wiki-per-query-con-immagini.md)); da provare con la chat reale su una wiki di esempio +- [x] GUI: formule LaTeX nel markdown, inline `$…$` e a blocco `$$…$$`, con ripiego sul sorgente ([ADR-0008](adr/0008-formule-latex-nel-markdown.md); test MD-31…MD-38) - [ ] Config file accanto all'exe - [ ] Test automatici e CI (vedi Fase 6) @@ -65,9 +68,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 2380e53..69e878e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -21,20 +21,34 @@ 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`) - [ ] `IEngineRunner` dietro `Core/EngineRunner.cs`, iniettato nel `MainViewModel` - [x] `EngineUpdater.CheckAsync` accetta un `HttpClient`: i test usano un gestore finto (rete assente, risposta non valida, versioni di prova) -- [ ] `InternalsVisibleTo` verso il progetto di test -- [ ] Spostamento in `src/` e `tests/` con soluzione unica +- [x] `InternalsVisibleTo` verso il progetto di test +- [x] Spostamento in `src/agent-wiki-ui/` e `tests/` con soluzione unica `agent-wiki-ui.slnx` ## Soglie di copertura (la build fallisce sotto soglia) | Area | Righe | Rami | @@ -181,7 +195,27 @@ Si provano con `FakeClaude`/`FakeCodex` che rispondono a `--version` e `update`; | MD-11 | Link: colore d'accento e sottolineatura; il clic apre solo `http(s)` | R | `Link_http_https` | [ ] | | MD-12 | Link: nessuna apertura se c'è una selezione in corso; cursore a mano sopra il link | R | `Link_con_selezione` | [ ] | | MD-13 | Posizioni dei link corrette con enfasi, codice inline e a capo prima del link | R | `Intervalli_link` | [ ] | -| MD-14 | Immagini: si mostra il testo alternativo, non si scarica nulla | R | `Immagini_come_testo` | [ ] | +| MD-14 | Immagini con URL `http(s)` o altro schema: si mostra il testo alternativo, nessuna richiesta di rete | R | `Immagini_remote_come_testo` | [x] | +| MD-19 | Immagine con percorso assoluto di un file png valido ⇒ controllo immagine con le dimensioni attese (ADR-0006) | R | `Immagine_percorso_assoluto` | [x] | +| MD-20 | Percorso relativo risolto rispetto a `AppContext.BaseDirectory`; anche URI `file://` | R | `Immagine_percorso_relativo_e_file_uri` | [x] | +| MD-21 | File mancante, illeggibile, corrotto o con estensione non ammessa ⇒ testo alternativo, nessuna eccezione | R | `Immagine_non_valida_ripiego` | [x] | +| MD-22 | File oltre il limite di 20 MB ⇒ testo alternativo, file non decodificato | R | `Immagine_oltre_limite` | [x] | +| MD-23 | Immagine più larga della colonna ⇒ ridotta in larghezza mantenendo le proporzioni; mai ingrandita oltre l'originale | R | `Immagine_larghezza_massima` | [x] | +| MD-24 | Il testo alternativo è nome accessibile e suggerimento dell'immagine | R | `Immagine_testo_alternativo_accessibile` | [x] | +| MD-25 | Immagine dentro un link, in una lista o in una tabella ⇒ resa senza rompere intervalli dei link (MD-13) né il layout | R | `Immagine_in_contesti_annidati` | [x] | +| MD-26 | Cambio di tema con immagini presenti ⇒ vista ricostruita, immagini ancora mostrate | R | `Immagine_cambio_tema` | [x] | +| MD-27 | Il caricamento è in background: subito dopo il rendering c'è il segnaposto `[alt]` e un'immagine in attesa; a fine decodifica compare l'immagine; un file corrotto lascia `[alt]` | R | `Immagine_caricamento_in_background` | [x] | +| MD-28 | Percorso relativo non presente nella cartella dell'exe ma in una cartella superiore (radice del progetto, es. `/` con l'exe in una sottocartella) ⇒ immagine mostrata | R | `Immagine_percorso_relativo_alla_radice` | [x] | +| MD-29 | Se il file esiste in più cartelle, vince la più vicina all'exe; se non esiste in nessuna ⇒ testo alternativo | R | `Immagine_cartella_piu_vicina` | [x] | +| MD-30 | Risposta con immagine e, sotto, didascalia e link in markdown: l'immagine compare e la didascalia resta testo; l'app non legge nessun catalogo | R | `Immagine_risposta_con_didascalia` | [x] | +| MD-31 | Formula inline `$…$` ⇒ controllo formula inserito nel testo; il testo prima e dopo resta intatto e non sparisce più (ADR-0008) | R | `Formula_inline` | [x] | +| MD-32 | Formula a blocco `$$…$$` ⇒ controllo formula (non un blocco di codice col sorgente) | R | `Formula_a_blocco` | [x] | +| MD-33 | LaTeX non valido (inline e a blocco) ⇒ sorgente come testo, nessuna eccezione | R | `Formula_non_valida_ripiego` | [x] | +| MD-34 | Costrutti LaTeX comuni (frazioni, apici e pedici, `\mathbf`, `\overline`, `\times`, lettere greche, `\left…\right`, `\sum`) ⇒ nessun errore | R | `Costrutti_latex_comuni` | [x] | +| MD-35 | Cambio di tema con formule presenti ⇒ vista ricostruita, formule ancora mostrate | R | `Formula_cambio_tema` | [x] | +| MD-36 | Formula dentro una lista, una tabella o un link ⇒ resa senza rompere il layout né gli intervalli dei link (MD-13) | R | `Formula_in_contesti_annidati` | [x] | +| MD-37 | Il nome accessibile della formula è il suo sorgente LaTeX | R | `Formula_nome_accessibile` | [x] | +| MD-38 | Simboli `$` isolati (es. prezzi «10 $ e 20 $») restano testo, nessuna formula | R | `Dollari_isolati_restano_testo` | [x] | | MD-15 | HTML grezzo disabilitato: i tag compaiono come testo, non come elementi | R | `Html_disabilitato` | [ ] | | MD-16 | Cambio di tema (chiaro/scuro): i colori si aggiornano ricostruendo la vista | R | `Cambio_tema` | [ ] | | MD-17 | Markdown malformato o blocchi sconosciuti non fanno crashare | R | `Markdown_malformato` | [ ] | @@ -248,6 +282,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/docs/adr/0006-immagini-nel-markdown.md b/docs/adr/0006-immagini-nel-markdown.md new file mode 100644 index 0000000..4de38ba --- /dev/null +++ b/docs/adr/0006-immagini-nel-markdown.md @@ -0,0 +1,47 @@ +# ADR-0006 — Immagini nel markdown: solo file locali + +- **Stato**: Proposta +- **Data**: 2026-10-15 +- **Collegato a**: [ADR-0001](0001-gui-multipiattaforma-avalonia.md) (GUI), [ADR-0003](0003-strategia-di-test.md) (test) + +## Contesto +Il renderer markdown ([Gui/MarkdownView.cs](../../src/agent-wiki-ui/Gui/MarkdownView.cs)) trasforma `![alt](percorso)` nel solo testo alternativo `[alt]`: non carica nulla. Un motore (Claude Code, Codex) può però produrre o citare un'immagine su disco (un grafico, uno screenshot) e rispondere con `![descrizione](percorso)`. Se il percorso è noto e il file esiste, l'utente si aspetta di vederla nella chat. Oggi l'app contatta la rete solo per il controllo delle versioni ([AGENT.md](../../AGENT.md), "Vincoli non ovvi"); aprire le immagini remote cambierebbe questo vincolo. + +## Criteri di decisione +1. Mostrare l'immagine quando il percorso è locale e il file è leggibile. +2. Nessun accesso alla rete oltre a quello già previsto. +3. Nessun crash e nessun blocco della chat per file mancanti, enormi o corrotti: ripiego sul testo alternativo di oggi. +4. Coerenza con il resto del renderer: colori dal tema, testo selezionabile, accessibilità. + +## Opzioni considerate +| Opzione | Esito | Motivo | +|---|---|---| +| Lasciare il testo `[alt]` | Scartata | Il percorso noto resta inutilizzato. | +| **Solo percorsi locali** | **Scelta** | Soddisfa il caso d'uso, nessuna rete, sola lettura. | +| Locali + URL `http(s)` | Rimandata | Una risposta del modello farebbe partire richieste di rete verso indirizzi scelti dal modello (tracciamento, indirizzo IP dell'utente esposto). Si può riprendere con un nuovo ADR e un consenso esplicito. | + +## Decisione +- `![alt](destinazione)` mostra l'immagine se la destinazione è un **percorso locale**: assoluto, relativo oppure un URI `file://`. +- **Percorsi relativi: radice del progetto.** Il motore gira nella cartella dell'exe, ma le istruzioni che segue (il `CLAUDE.md` di una wiki) stanno di solito in una cartella superiore e scrivono i percorsi relativi a *quella* radice (esempio: una wiki con l'exe in `/query/`, accanto a un `CLAUDE.md` solo-query, e istruzioni del tipo «`![Figura N](/)`, percorso relativo alla radice del repo»). Un percorso relativo si cerca quindi nella cartella dell'exe e poi in ciascuna cartella superiore, fino alla radice del filesystem; vince la prima in cui il file esiste (la più vicina all'exe). +- **Nessuno standard imposto (prototipo).** L'app non conosce né una cartella delle immagini né un formato di catalogo: la cartella delle immagini e il catalogo sono la convenzione di ciascuna wiki, non un requisito. Cartella e catalogo possono cambiare; la wiki lo dice al motore nelle sue istruzioni, e l'app mostra qualunque file locale raggiungibile con il percorso scritto nella risposta. +- **Cataloghi e metadati restano del motore.** Struttura tipo di una wiki con figure: un catalogo JSON con i metadati delle figure (file, didascalia stampata e generata, posizione, avvertenze) e una cartella delle immagini. Chi legge il catalogo e sceglie le figure è il motore, guidato dalle istruzioni della wiki; l'app non legge né interpreta il catalogo: mostra le immagini che la risposta cita in markdown, e la didascalia, la posizione e le avvertenze che il motore scrive sotto l'immagine sono testo markdown normale. +- Formati: png, jpg/jpeg, gif (primo fotogramma), webp, bmp. Altre estensioni ⇒ ripiego. +- Limiti: file fino a **20 MB**; se l'immagine è più larga di **1600 px** si decodifica a 1600 px di larghezza, per non occupare memoria inutile. In visualizzazione l'immagine non supera la larghezza della colonna dei messaggi (proporzioni mantenute) e non è mai ingrandita oltre l'originale. +- Un paragrafo fatto solo di immagini le mostra come blocchi; un'immagine dentro una riga di testo (o dentro un link) è inline, con larghezza massima di 480 px. +- **Ripiego** sul testo `[alt]` (come oggi) quando: la destinazione è `http(s)` o altro schema, il file manca o non è leggibile, il formato non è ammesso, il file supera il limite, la decodifica fallisce. Nessun errore a schermo, nessuna eccezione. +- **Caricamento in background**: al rendering un controllo di controllo rapido e sincrono (percorso, estensione, esistenza, dimensione) decide se l'immagine è plausibile; se no, compare subito `[alt]`. Se sì, compare `[alt]` come segnaposto e la decodifica avviene su un altro thread; a fine decodifica il segnaposto diventa l'immagine, se la decodifica fallisce resta `[alt]`. Così la GUI non si blocca con file grandi o dischi lenti. +- Accessibilità: l'immagine espone `alt` come nome per le tecnologie assistive e come suggerimento al passaggio del mouse. +- Nessuna azione al clic (non si apre il file). Il pulsante **Copia** della risposta continua a copiare il markdown originale. + +## Conseguenze +**Positive**: le immagini prodotte dai motori si vedono nella chat; nessuna richiesta di rete in più; ripiego sicuro. +**Negative**: una risposta può far leggere e mostrare un file immagine qualsiasi dell'utente (sola lettura, solo a schermo, nessun invio altrove); le immagini remote restano non supportate e si vedono come testo. + +## Da verificare +- Quali formati decodifica davvero Avalonia 12 con Skia su Windows e Linux (in particolare webp e gif) e se il limite di 20 MB è adeguato. +- Il comportamento dei percorsi Windows (`C:\…`, UNC) e di quelli WSL (`/mnt/c/…`). +- Se la ricerca nelle cartelle superiori debba fermarsi alla prima cartella con un marcatore di progetto (`CLAUDE.md`, `.git`) invece di salire fino alla radice del filesystem. +- Se e quando emergerà uno standard per cataloghi e cartelle delle immagini (nomi, campi minimi come file, didascalia, posizione), valutare un nuovo ADR; per ora è fuori dall'app. + +## Riferimenti +- [MVP.md](../MVP.md) → "GUI (chat)"; matrice MD-14, MD-19…MD-30 in [TESTING.md](../TESTING.md). diff --git a/docs/adr/0007-schema-wiki-per-query-con-immagini.md b/docs/adr/0007-schema-wiki-per-query-con-immagini.md new file mode 100644 index 0000000..c3bfd9b --- /dev/null +++ b/docs/adr/0007-schema-wiki-per-query-con-immagini.md @@ -0,0 +1,62 @@ +# ADR-0007 — Schema di una wiki interrogabile, con immagini (prototipo) + +- **Stato**: Proposta +- **Data**: 2026-10-15 +- **Collegato a**: [ADR-0006](0006-immagini-nel-markdown.md) (l'app mostra le immagini citate nella risposta) + +## Contesto +agent-wiki-ui è il terminale di chat per interrogare una wiki mantenuta da un motore (Claude Code, Codex), per esempio una wiki di studio o di documentazione tecnica: pagine markdown, un indice, un catalogo JSON delle figure con i metadati e una cartella di immagini. Per rispondere in modo strutturato — meglio di un RAG, che recupera frammenti di testo e perde le relazioni — il motore deve sapere **dove cercare** (indice → pagina → tabella o figura) e **come citare** un'immagine perché la chat la stampi. Oggi questo sapere sta in un file scritto per una sola wiki. Non esiste uno standard: la cartella delle immagini e il formato del catalogo cambiano da wiki a wiki, ed è un prototipo. + +## Criteri di decisione +1. Un solo posto per i percorsi delle immagini, senza duplicarli a mano. +2. Le mappe sono derivate dal catalogo (si rigenerano), non scritte a mano. +3. Nessuna cartella o nome di file imposti: la wiki dichiara la sua struttura. +4. Degrado sicuro: un percorso sbagliato dà `[alt]` nella chat (ADR-0006), mai un errore. +5. Nessun codice nell'app: la logica sta nelle istruzioni al motore e negli script della wiki. +6. Pochi campi minimi, perché lo standard possa nascere dall'uso. + +## Opzioni considerate +| Opzione | Esito | Motivo | +|---|---|---| +| Solo RAG sul testo (embedding di `search_text`) | Scartata come unica via | Perde la struttura che rende affidabile la risposta: dato → pagina unica, avvertenze, difetti noti, figura → file. Resta come ripiego (ricerca testuale). | +| L'app legge il catalogo e inserisce le immagini nelle risposte | Scartata | Lega l'app a un formato non standard e duplica la scelta delle figure, che il motore fa già leggendo la domanda. | +| **Schema in tre parti, letto dal motore; l'app stampa** | **Scelta** | Conserva la struttura, non tocca l'app, si adatta a wiki diverse. | + +## Decisione +Una wiki interrogabile ha tre parti. Il formato è una convenzione consigliata, non un requisito dell'app. + +1. **Indice** (`index.md`): catalogo delle pagine, una riga per pagina, `- [[pagina]] — sintesi`. Una pagina con figure aggiunge in coda gli id del catalogo: `· figure: fig-026, fig-027`. L'indice non elenca le figure una per una e non contiene percorsi di file. +2. **Catalogo delle figure** (JSON, nome e posizione dichiarati nello schema della wiki). Campi minimi per figura: + - `id` stabile, `label` (ad es. «Figura 26»); + - `caption_printed` (testo della fonte, alla lettera) e, a parte, ogni testo generato dall'immagine, contrassegnato come non verificato; + - `location` (sezione e riga nella fonte); + - `panels[]` con `file` e `path` (relativo alla cartella del catalogo) e una descrizione; + - `quality`: `flags` e `review_notes`. + Indici facoltativi per sezione, composto, tipo e concetto. L'ordine di preferenza per trovare una figura è: indice dell'indice → pagina → `figures` nel frontmatter → indici del catalogo → ricerca testuale. +3. **Istruzioni per le query**: un file `CLAUDE.md` (per Codex `AGENTS.md`) in una cartella dedicata sotto la radice della wiki, da cui si avvia l'app. Si aggiunge a quello della radice e prevale su di esso. Impone la **sola lettura**, instrada le domande per tipo verso l'indice giusto e fissa le regole per le immagini. + +**Regole per le immagini nella risposta:** +- Il percorso si ricava da catalogo e `path`, si **verifica che esista** e si scrive **relativo alla radice** della wiki, senza `..` (l'app lo cerca nella sua cartella e poi nelle superiori, ADR-0006). +- Subito sotto: didascalia stampata alla lettera, posizione nella fonte, link alla nota della figura; una riga di avvertenza se `flags` o `review_notes`. +- Ciò che deriva dall'immagine (descrizioni, valori letti da un grafico) si dichiara non verificato. Massimo 3-4 immagini per risposta. + +**Percorsi derivati.** Se il catalogo ha percorsi relativi alla propria cartella, la wiki genera con uno script una mappa figura → percorso dalla radice → pagine che la citano (per esempio colonne in più di `figure-indice.md`). Lo script, e un controllo in fase di lint che ogni percorso della mappa esista, sono della wiki, non di questo repo. + +**Chi fa cosa** +| Parte | Compito | +|---|---| +| Wiki | Mantiene indice, catalogo e immagini; rigenera la mappa; controlla che i percorsi esistano | +| Motore | Instrada la domanda, legge indici e pagine, sceglie le figure, scrive il markdown con l'immagine e la didascalia | +| App | Stampa il markdown; mostra l'immagine se il file esiste, altrimenti `[alt]` | + +## Conseguenze +**Positive**: una risposta ripercorre indice → pagina → figura invece di cercare frammenti; i percorsi stanno in un solo posto e si rigenerano; l'app non cambia; wiki diverse adottano lo stesso modello cambiando pochi campi. +**Negative**: il rispetto dello schema dipende dal motore che segue le istruzioni (può sbagliare percorso: l'app lo mostra come `[alt]`); la wiki deve mantenere lo script e il controllo dei percorsi; lo standard è provvisorio. + +## Da verificare +- Che Claude Code, avviato dalla cartella dedicata, legga e rispetti sia il file di quella cartella sia quello della radice (prova con la chat reale su una wiki di esempio). +- L'equivalente per Codex (`AGENTS.md`) e come si comporta con le immagini nei suoi comandi. +- Se il campo minimo `path` debba diventare un percorso dalla radice nel catalogo stesso, eliminando la mappa derivata. + +## Riferimenti +- Uso con le immagini: [README.md](../../README.md), sezione «Immagini nelle risposte». diff --git a/docs/adr/0008-formule-latex-nel-markdown.md b/docs/adr/0008-formule-latex-nel-markdown.md new file mode 100644 index 0000000..71ab44d --- /dev/null +++ b/docs/adr/0008-formule-latex-nel-markdown.md @@ -0,0 +1,45 @@ +# ADR-0008 — Formule LaTeX nel markdown + +- **Stato**: Proposta +- **Data**: 2026-10-20 +- **Collegato a**: [ADR-0006](0006-immagini-nel-markdown.md) (stesso renderer, stessi vincoli di sicurezza), [ADR-0007](0007-schema-wiki-per-query-con-immagini.md) (stesso tipo di risposte) + +## Contesto +Le risposte dei motori possono contenere formule in LaTeX: `$ ... $` nel testo e `$$ ... $$` a blocco. Il renderer markdown ([Gui/MarkdownView.cs](../../src/agent-wiki-ui/Gui/MarkdownView.cs)) usa Markdig con `UseAdvancedExtensions()`, che comprende l'estensione matematica: il parser riconosce le formule ma il renderer non le gestisce. Quelle a blocco finiscono in un blocco di codice con il sorgente LaTeX; quelle nel testo non vengono disegnate (nodo ignorato). L'utente vede sorgente illeggibile o formule mancanti. + +## Criteri di decisione +1. Mostrare le formule composte (frazioni, apici e pedici, lettere greche, sommatorie, sopralineature) come in un testo tipografico. +2. Nessuna rete e nessuna esecuzione di codice: la formula è solo dato da disegnare (come per le immagini, ADR-0006). +3. Degrado sicuro: un LaTeX non valido mostra il sorgente come testo, mai un errore né un blocco della chat. +4. Coerenza con il resto del renderer: colori dal tema, ricostruzione al cambio di tema, testo circostante selezionabile, accessibilità. +5. Funziona su Windows e Linux nel publish single-file, senza runtime aggiuntivi. +6. Dipendenza manutenuta e compatibile con la versione di Avalonia in uso. + +## Opzioni considerate +| Opzione | Esito | Motivo | +|---|---|---| +| Lasciare com'è | Scartata | Il sorgente LaTeX o l'assenza delle formule rendono illeggibili le risposte con formule. | +| Convertire il LaTeX in testo Unicode (`x_1` → `x`+pedice) con un mini-interprete proprio | Scartata | Copre solo i casi semplici; frazioni, `\overline`, `\left…\right` e matrici richiederebbero di riscrivere un motore di composizione. | +| Rendere con un motore web (KaTeX/MathJax in una WebView) | Scartata | Aggiunge un browser incorporato e codice JavaScript, contro il criterio 2 e il peso dell'eseguibile. | +| **Libreria di composizione TeX per Avalonia (`Sylinko.CSharpMath.Avalonia`, licenza MIT, basata su CSharpMath)** | **Scelta** | Disegna LaTeX come controllo Avalonia, senza rete né script; l'ultima versione (12.0.0) è per Avalonia 12, come l'app. | + +## Decisione +- La formula tra `$ … $` è un'**immagine inline** composta dal controllo della libreria, inserita nel testo (`InlineUIContainer`) con allineamento al testo; `$$ … $$` è un **blocco** a sé, allineato a sinistra, con scorrimento orizzontale se più larga della colonna. +- Il colore è quello del testo del tema (`Fg`, o il colore ereditato se il token manca); al cambio di tema la vista si ricostruisce (già così per il resto). +- **Ripiego**: se il LaTeX non è valido la formula compare come testo sorgente (`$…$` con carattere monospazio), senza eccezioni. +- Il nome accessibile del controllo è il sorgente LaTeX; la formula non è selezionabile come testo (è un disegno): il pulsante **Copia** della risposta continua a copiare il markdown originale, quindi anche il sorgente delle formule. +- I simboli `$` isolati, ad esempio un prezzo («10 $ e 20 $»), restano testo. Markdig da solo li prenderebbe per una formula (e toglie gli spazi dal contenuto): il renderer applica la regola di Pandoc sul testo originale, cioè niente spazio subito dopo il `$` di apertura né subito prima di quello di chiusura. Per questo gli intervalli di Markdig si leggono su un testo con fine riga uniformi (`\n`). +- Nessuna rete e nessuna lettura di file: la formula è solo dato per il disegno. + +## Conseguenze +**Positive**: le risposte con formule mostrano le formule composte; nessun cambiamento ai motori né ai prompt. +**Negative**: una dipendenza in più (e le sue librerie di disegno); il pacchetto è un fork per Avalonia 12 e va tenuto d'occhio quando Avalonia aggiorna; le formule non sono selezionabili come testo; il supporto non copre tutto LaTeX (le macro non supportate cadono nel ripiego). + +## Da verificare +- Dimensione dell'eseguibile pubblicato (single-file) dopo l'aggiunta della libreria e dei suoi font. +- Allineamento verticale delle formule inline rispetto al testo con font diversi, su Windows e Linux. +- Quali comandi LaTeX comuni (matrici, ambienti `align`, estensioni a pacchetto) sono supportati. +- Il comportamento con formule molto lunghe in una riga di testo (a capo). + +## Riferimenti +- [MVP.md](../MVP.md) → "GUI (chat)"; matrice MD-31…MD-38 in [TESTING.md](../TESTING.md). diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 index 5994b18..a05692f 100644 --- a/scripts/publish.ps1 +++ b/scripts/publish.ps1 @@ -8,7 +8,7 @@ Set-Location (Split-Path -Parent $PSScriptRoot) foreach ($rid in $Rids) { Write-Host "== $rid" - dotnet publish agent-wiki-ui.csproj -c Release -r $rid --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet + dotnet publish src/agent-wiki-ui/agent-wiki-ui.csproj -c Release -r $rid --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet if ($LASTEXITCODE -ne 0) { throw "publish fallito per $rid" } Get-ChildItem "dist/$rid" -Filter *.pdb -Recurse | Remove-Item # simboli delle librerie native: inutili a chi usa l'app } diff --git a/scripts/publish.sh b/scripts/publish.sh index 70e627a..b12e4b7 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -10,7 +10,7 @@ rids=("$@") for rid in "${rids[@]}"; do echo "== $rid" - dotnet publish agent-wiki-ui.csproj -c Release -r "$rid" --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet + dotnet publish src/agent-wiki-ui/agent-wiki-ui.csproj -c Release -r "$rid" --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet find "dist/$rid" -name '*.pdb' -delete # simboli delle librerie native: inutili a chi usa l'app done diff --git a/Core/AppInfo.cs b/src/agent-wiki-ui/Core/AppInfo.cs similarity index 100% rename from Core/AppInfo.cs rename to src/agent-wiki-ui/Core/AppInfo.cs diff --git a/Core/ChatStore.cs b/src/agent-wiki-ui/Core/ChatStore.cs similarity index 100% rename from Core/ChatStore.cs rename to src/agent-wiki-ui/Core/ChatStore.cs diff --git a/Core/ConsoleHost.cs b/src/agent-wiki-ui/Core/ConsoleHost.cs similarity index 100% rename from Core/ConsoleHost.cs rename to src/agent-wiki-ui/Core/ConsoleHost.cs diff --git a/Core/Engine.cs b/src/agent-wiki-ui/Core/Engine.cs similarity index 100% rename from Core/Engine.cs rename to src/agent-wiki-ui/Core/Engine.cs diff --git a/Core/EngineRunner.cs b/src/agent-wiki-ui/Core/EngineRunner.cs similarity index 100% rename from Core/EngineRunner.cs rename to src/agent-wiki-ui/Core/EngineRunner.cs diff --git a/Core/EngineUpdater.cs b/src/agent-wiki-ui/Core/EngineUpdater.cs similarity index 100% rename from Core/EngineUpdater.cs rename to src/agent-wiki-ui/Core/EngineUpdater.cs diff --git a/Gui/App.axaml b/src/agent-wiki-ui/Gui/App.axaml similarity index 100% rename from Gui/App.axaml rename to src/agent-wiki-ui/Gui/App.axaml diff --git a/Gui/App.axaml.cs b/src/agent-wiki-ui/Gui/App.axaml.cs similarity index 100% rename from Gui/App.axaml.cs rename to src/agent-wiki-ui/Gui/App.axaml.cs diff --git a/Gui/ConfirmDialog.cs b/src/agent-wiki-ui/Gui/ConfirmDialog.cs similarity index 100% rename from Gui/ConfirmDialog.cs rename to src/agent-wiki-ui/Gui/ConfirmDialog.cs diff --git a/Gui/MainViewModel.cs b/src/agent-wiki-ui/Gui/MainViewModel.cs similarity index 100% rename from Gui/MainViewModel.cs rename to src/agent-wiki-ui/Gui/MainViewModel.cs diff --git a/Gui/MainWindow.axaml b/src/agent-wiki-ui/Gui/MainWindow.axaml similarity index 100% rename from Gui/MainWindow.axaml rename to src/agent-wiki-ui/Gui/MainWindow.axaml diff --git a/Gui/MainWindow.axaml.cs b/src/agent-wiki-ui/Gui/MainWindow.axaml.cs similarity index 100% rename from Gui/MainWindow.axaml.cs rename to src/agent-wiki-ui/Gui/MainWindow.axaml.cs 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.Math.cs b/src/agent-wiki-ui/Gui/MarkdownView.Math.cs new file mode 100644 index 0000000..e615197 --- /dev/null +++ b/src/agent-wiki-ui/Gui/MarkdownView.Math.cs @@ -0,0 +1,72 @@ +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; +using Avalonia.Media; +using CSharpMath.Avalonia; +using Markdig.Extensions.Mathematics; +using Markdig.Syntax; + +namespace AgentWikiUi.Gui; + +/// Formule LaTeX nel markdown (ADR-0008): inline $…$ e a blocco $$…$$; se il LaTeX non è valido compare il sorgente. +partial class MarkdownView +{ + const double InlineFormulaSize = 15; + const double BlockFormulaSize = 18; + + /// Sorgente LaTeX di un blocco $$…$$ (righe tra i delimitatori). + static string BlockSource(LeafBlock block) => + string.Join('\n', block.Lines.Lines.Take(block.Lines.Count).Select(l => l.ToString())).Trim(); + + Control FormulaBlock(string latex) + { + var formula = Formula(latex, BlockFormulaSize, "$$"); + // formula più larga della colonna: scorrimento orizzontale invece di tagliarla + return new ScrollViewer + { + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, + HorizontalAlignment = HorizontalAlignment.Left, Content = formula, + }; + } + + // colore del testo del tema (token Fg); senza il token (es. nei test) quello ereditato dal controllo + Color TextColor() => + this.TryFindResource("Fg", ActualThemeVariant, out var v) && v is ISolidColorBrush s ? s.Color + : TextElement.GetForeground(this) is ISolidColorBrush f ? f.Color : Colors.Black; + + /// Regola di Pandoc per $…$: niente spazio dopo l'apertura né prima della chiusura, altrimenti i due $ sono testo (prezzi, importi). Markdig da solo accetterebbe «10 $ e 20 $». + static bool IsDollarText(string source, MathInline math) + { + // Markdig toglie gli spazi dal contenuto: si controlla il testo originale tra i due delimitatori + if (math.DelimiterCount != 1) return false; + var (start, end) = (math.Span.Start, math.Span.End); + if (start < 0 || end >= source.Length || end - start < 2) return false; // intervallo non affidabile: si tratta come formula + return char.IsWhiteSpace(source[start + 1]) || char.IsWhiteSpace(source[end - 1]); + } + + Control FormulaInline(string latex) => Formula(latex, InlineFormulaSize, "$"); + + /// Controllo che disegna la formula; se il LaTeX non è valido, il sorgente come testo monospazio (ripiego sicuro). + Control Formula(string latex, double size, string delimiter) + { + latex = latex.Trim(); + try + { + var view = new MathView + { + LaTeX = latex, FontSize = (float)size, TextColor = TextColor(), DisplayErrorInline = false, + VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Left, + }; + if (latex.Length > 0 && string.IsNullOrEmpty(view.ErrorMessage)) + { + AutomationProperties.SetName(view, latex); + ToolTip.SetTip(view, latex); + return view; + } + } + catch (Exception) { /* LaTeX o disegno non riusciti: ripiego sul sorgente */ } + return new TextBlock { Text = delimiter + latex + delimiter, FontFamily = Mono, FontSize = 13.5, Foreground = Brush("FgMuted"), TextWrapping = TextWrapping.Wrap }; + } +} diff --git a/Gui/MarkdownView.cs b/src/agent-wiki-ui/Gui/MarkdownView.cs similarity index 85% rename from Gui/MarkdownView.cs rename to src/agent-wiki-ui/Gui/MarkdownView.cs index 5e12cc8..65ac6fa 100644 --- a/Gui/MarkdownView.cs +++ b/src/agent-wiki-ui/Gui/MarkdownView.cs @@ -6,6 +6,7 @@ using Avalonia.Input.Platform; using Avalonia.Layout; using Avalonia.Media; using Markdig; +using Markdig.Extensions.Mathematics; using Markdig.Extensions.Tables; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -13,7 +14,7 @@ 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(); @@ -33,7 +34,14 @@ class MarkdownView : Decorator if (change.Property == SourceProperty) Rebuild(); } - void Rebuild() => Child = string.IsNullOrEmpty(Source) ? null : RenderBlocks(Markdig.Markdown.Parse(Source, Pipeline), 10); + string parsed = ""; + + void Rebuild() + { + // fine riga uniformi: gli intervalli di Markdig (Span) devono valere per il testo che si legge qui (ADR-0008) + parsed = Source?.ReplaceLineEndings("\n") ?? ""; + Child = parsed.Length == 0 ? null : RenderBlocks(Markdig.Markdown.Parse(parsed, Pipeline), 10); + } IBrush Brush(string key) => this.TryFindResource(key, ActualThemeVariant, out var v) && v is IBrush b ? b : Brushes.Gray; @@ -47,7 +55,8 @@ class MarkdownView : Decorator Control RenderBlock(Block block) => block switch { HeadingBlock h => Text(h.Inline, bold: true, size: h.Level switch { 1 => 24, 2 => 20, 3 => 18, 4 => 16, _ => 15.5 }), - ParagraphBlock p => Text(p.Inline), + ParagraphBlock p => ImageParagraph(p) ?? Text(p.Inline), + MathBlock m => FormulaBlock(BlockSource(m)), FencedCodeBlock or CodeBlock => Code(string.Join('\n', ((LeafBlock)block).Lines.Lines.Take(((LeafBlock)block).Lines.Count).Select(l => l.ToString()))), QuoteBlock q => new Border { BorderBrush = Brush("Border"), BorderThickness = new(3, 0, 0, 0), Padding = new(12, 0, 0, 0), Child = RenderBlocks(q, 8) }, ListBlock l => List(l), @@ -145,6 +154,16 @@ class MarkdownView : Decorator switch (node) { case LiteralInline lit: Add(target, lit.Content.ToString(), st, ref pos); break; + case MathInline math: + var latex = math.Content.ToString(); + if (IsDollarText(parsed, math)) // es. «10 $ e 20 $»: non è una formula + Add(target, parsed.Substring(math.Span.Start, math.Span.End - math.Span.Start + 1), st, ref pos); + else + { + target.Add(new InlineUIContainer(FormulaInline(latex))); + pos += 1; // un controllo incorporato occupa una posizione nel testo + } + break; case LineBreakInline br: Add(target, br.IsHard ? "\n" : " ", st, ref pos); break; case CodeInline code: target.Add(new Run(code.Content) { FontFamily = Mono, FontSize = 13.5, Background = Brush("Muted") }); @@ -156,7 +175,12 @@ class MarkdownView : Decorator AddInlines(target, em, inner, links, ref pos); break; case LinkInline { IsImage: true } img: - Add(target, "[" + string.Concat(img.OfType().Select(l => l.Content.ToString())) + "]", st, ref pos); + if (ResolveImagePath(img.Url, BaseDirectory) is { } imgPath && IsImageCandidate(imgPath)) + { + target.Add(new InlineUIContainer(ImageHost(imgPath, AltText(img), MaxInlineImageWidth))); + pos += 1; // un controllo incorporato occupa una posizione nel testo + } + else Add(target, "[" + AltText(img) + "]", st, ref pos); break; case LinkInline link: var start = pos; diff --git a/Gui/ObservableObject.cs b/src/agent-wiki-ui/Gui/ObservableObject.cs similarity index 100% rename from Gui/ObservableObject.cs rename to src/agent-wiki-ui/Gui/ObservableObject.cs diff --git a/Program.cs b/src/agent-wiki-ui/Program.cs similarity index 100% rename from Program.cs rename to src/agent-wiki-ui/Program.cs diff --git a/agent-wiki-ui.csproj b/src/agent-wiki-ui/agent-wiki-ui.csproj similarity index 88% rename from agent-wiki-ui.csproj rename to src/agent-wiki-ui/agent-wiki-ui.csproj index 2995468..aeed520 100644 --- a/agent-wiki-ui.csproj +++ b/src/agent-wiki-ui/agent-wiki-ui.csproj @@ -16,11 +16,16 @@ none + + + + + 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/Gui/MarkdownImageTests.cs b/tests/agent-wiki-ui.Tests/Gui/MarkdownImageTests.cs new file mode 100644 index 0000000..3b3ee92 --- /dev/null +++ b/tests/agent-wiki-ui.Tests/Gui/MarkdownImageTests.cs @@ -0,0 +1,265 @@ +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; + +/// Immagini nel markdown (ADR-0006): matrice MD-14 e MD-19…MD-26 in docs/TESTING.md. +[Trait("Category", "Gui")] +public sealed class MarkdownImageTests : IDisposable +{ + readonly string dir = Directory.CreateTempSubdirectory("awui-img-").FullName; + readonly List 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, string? baseDir = null) + { + var view = new MarkdownView(); + if (baseDir is not null) view.BaseDirectory = baseDir; + 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 Images(Visual v) => v.GetVisualDescendants().OfType().ToList(); + + static string Text(Visual v) => string.Concat(v.GetVisualDescendants().OfType() + .Select(t => t.Inlines is { Count: > 0 } ? string.Concat(t.Inlines.OfType().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 ![logo]({url}) 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($"![griglia]({path})"); + var img = Assert.Single(Images(view)); + var bmp = Assert.IsAssignableFrom(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($"![r]({name})"); + Assert.Single(Images(relative)); + + var uri = new Uri(Png("b.png", 12, 12)).AbsoluteUri; + var (_, fileUri) = Show($"![u]({uri})"); + 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($"![alt]({path})"); + 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($"![enorme]({path})"); + Assert.Empty(Images(view)); + Assert.Contains("[enorme]", Text(view)); + } + + [AvaloniaFact] + public void Immagine_larghezza_massima() + { + var (_, wide) = Show($"![larga]({Png("larga.png", 2000, 100)})", 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($"![piccola]({Png("piccola.png", 50, 50)})", 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($"![un grafico]({Png("g.png", 10, 10)})"); + 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 ![in riga]({p}) e ancora testo con [un link](https://example.com). + + [![dentro link]({p})](https://example.com) + + - voce ![in lista]({p}) + + | a | b | + |---|---| + | ![in tabella]({p}) | 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($"![t]({Png("t.png", 10, 10)})"); + 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 = $"![segnaposto]({path})"; + // 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 = $"![guasta]({rotto})"; + WaitForImages(view); + Assert.Empty(Images(view)); + Assert.Contains("[guasta]", Text(view)); + } + + // Struttura tipo wiki: /catalogo.json, /figure/, exe in /query/ + string Wiki(string name, params string[] files) + { + var root = Path.Combine(dir, name); + Directory.CreateDirectory(Path.Combine(root, "figure")); + Directory.CreateDirectory(Path.Combine(root, "query")); + foreach (var f in files) File.WriteAllBytes(Path.Combine(root, "figure", f), PngFactory.Create(24, 12)); + File.WriteAllText(Path.Combine(root, "catalogo.json"), "{\"figures\":[]}"); + return root; + } + + [AvaloniaFact] + public void Immagine_percorso_relativo_alla_radice() + { + var root = Wiki("wiki-a", "fig1.png"); + var (_, view) = Show("![Figura 1](figure/fig1.png)", baseDir: Path.Combine(root, "query")); + Assert.Single(Images(view)); + Assert.DoesNotContain("[Figura 1]", Text(view)); + } + + [AvaloniaFact] + public void Immagine_cartella_piu_vicina() + { + var root = Wiki("wiki-b"); + File.WriteAllBytes(Path.Combine(root, "figure", "x.png"), PngFactory.Create(30, 30)); + var exeDir = Path.Combine(root, "query"); + Directory.CreateDirectory(Path.Combine(exeDir, "figure")); + File.WriteAllBytes(Path.Combine(exeDir, "figure", "x.png"), PngFactory.Create(10, 10)); + + var (_, view) = Show("![x](figure/x.png)", baseDir: exeDir); + var bmp = Assert.IsAssignableFrom(Assert.Single(Images(view)).Source); + Assert.Equal(new PixelSize(10, 10), bmp.PixelSize); // vince la cartella più vicina all'exe + + var (_, nowhere) = Show("![y](figure/non-esiste.png)", baseDir: exeDir); + Assert.Empty(Images(nowhere)); + Assert.Contains("[y]", Text(nowhere)); + } + + [AvaloniaFact] + public void Immagine_risposta_con_didascalia() + { + var root = Wiki("wiki-c", "schema-a.png"); + var risposta = """ + Lo schema è in Figura 1. + + ![Figura 1](figure/schema-a.png) + + Figura 1 - schema generale del sistema · §2.1 r.40 · [[fig-001]] + """; + var (_, view) = Show(risposta, baseDir: Path.Combine(root, "query")); + Assert.Single(Images(view)); + var text = Text(view); + Assert.Contains("Figura 1 - schema generale", text); + Assert.Contains("[[fig-001]]", text); // il link alla nota resta testo: l'app non legge il catalogo JSON + } +} diff --git a/tests/agent-wiki-ui.Tests/Gui/MarkdownMathTests.cs b/tests/agent-wiki-ui.Tests/Gui/MarkdownMathTests.cs new file mode 100644 index 0000000..8575957 --- /dev/null +++ b/tests/agent-wiki-ui.Tests/Gui/MarkdownMathTests.cs @@ -0,0 +1,138 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Headless.XUnit; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.VisualTree; +using CSharpMath.Avalonia; +using AgentWikiUi.Gui; + +namespace AgentWikiUi.Tests; + +/// Formule LaTeX nel markdown (ADR-0008): matrice MD-31…MD-38 in docs/TESTING.md. +[Trait("Category", "Gui")] +public sealed class MarkdownMathTests +{ + static (Window Window, MarkdownView View) Show(string markdown, double width = 600) + { + var view = new MarkdownView(); + var window = new Window { Width = width, Height = 800, Content = view }; + window.Show(); + view.Source = markdown; + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + return (window, view); + } + + static List Formulas(Visual v) => v.GetVisualDescendants().OfType().ToList(); + + static string Text(Visual v) => string.Concat(v.GetVisualDescendants().OfType() + .Select(t => t.Inlines is { Count: > 0 } ? string.Concat(t.Inlines.OfType().Select(r => r.Text)) : t.Text)); + + [AvaloniaFact] + public void Formula_inline() + { + var (_, view) = Show("Il gap è $E_g = E_{LUMO} - E_{HOMO}$ in eV."); + var f = Assert.Single(Formulas(view)); + Assert.Equal("E_g = E_{LUMO} - E_{HOMO}", AutomationProperties.GetName(f)); + Assert.True(string.IsNullOrEmpty(f.ErrorMessage)); + Assert.True(f.Bounds.Width > 0 && f.Bounds.Height > 0); + var text = Text(view); + Assert.Contains("Il gap è", text); + Assert.Contains("in eV.", text); + Assert.DoesNotContain("$", text); + } + + [AvaloniaFact] + public void Formula_a_blocco() + { + var (_, view) = Show("Prima.\n\n$$\n\\frac{a}{b} = c\n$$\n\nDopo."); + var f = Assert.Single(Formulas(view)); + Assert.Equal("\\frac{a}{b} = c", AutomationProperties.GetName(f)); + Assert.True(string.IsNullOrEmpty(f.ErrorMessage)); + Assert.True(f.Bounds.Height > 0); + Assert.DoesNotContain("\\frac", Text(view)); // non è un blocco di codice col sorgente + Assert.Contains("Prima.", Text(view)); + Assert.Contains("Dopo.", Text(view)); + } + + [AvaloniaTheory] + [InlineData("Testo $\\frac{a$ fine")] + [InlineData("$$\n\\unknowncommand{x}\n$$")] + [InlineData("Testo $\\begin{matrix$ fine")] + public void Formula_non_valida_ripiego(string markdown) + { + var (_, view) = Show(markdown); + Assert.Empty(Formulas(view)); + Assert.Contains("$", Text(view)); // il sorgente resta visibile come testo + } + + [AvaloniaTheory] + [InlineData("\\frac{a}{b}")] + [InlineData("x^2 + y_i")] + [InlineData("\\mathbf{A} \\times \\mathbf{B}")] + [InlineData("\\overline{x}")] + [InlineData("\\left( \\frac{1}{2} \\right)")] + [InlineData("\\lambda_{max} \\approx \\pi \\cdot \\tau")] + [InlineData("\\sum_{i=1}^{n} x_i")] + [InlineData("\\text{Mn} = \\chi \\varepsilon")] + public void Costrutti_latex_comuni(string latex) + { + var (_, view) = Show($"Prima $${latex}$$ dopo".Replace("$$", "$")); + var f = Assert.Single(Formulas(view)); + Assert.Equal(latex, AutomationProperties.GetName(f)); + Assert.True(string.IsNullOrEmpty(f.ErrorMessage), f.ErrorMessage); + Assert.True(f.Bounds.Width > 0); + } + + [AvaloniaFact] + public void Formula_cambio_tema() + { + var (window, view) = Show("Gap $E_g$ e\n\n$$\\frac{a}{b}$$"); + Assert.Equal(2, Formulas(view).Count); + window.RequestedThemeVariant = ThemeVariant.Dark; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(2, Formulas(view).Count); + window.RequestedThemeVariant = ThemeVariant.Light; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(2, Formulas(view).Count); + } + + [AvaloniaFact] + public void Formula_in_contesti_annidati() + { + var markdown = """ + Testo $a_1$ e un [link](https://example.com). + + [vai a $b^2$](https://example.com) + + - voce $c_3$ + + | x | y | + |---|---| + | $d_4$ | z | + """; + var (_, view) = Show(markdown); + Assert.Equal(4, Formulas(view).Count); + Assert.Contains("link", Text(view)); + Assert.Contains("vai a", Text(view)); + } + + [AvaloniaFact] + public void Formula_nome_accessibile() + { + var (_, view) = Show("Gap $E_g$."); + var f = Assert.Single(Formulas(view)); + Assert.Equal("E_g", AutomationProperties.GetName(f)); + } + + [AvaloniaFact] + public void Dollari_isolati_restano_testo() + { + var (_, view) = Show("Costa 10 $ e poi 20 $ in totale."); + Assert.Empty(Formulas(view)); + Assert.Contains("10 $ e poi 20 $", Text(view)); + } +} 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/Support/PngFactory.cs b/tests/agent-wiki-ui.Tests/Support/PngFactory.cs new file mode 100644 index 0000000..4a16173 --- /dev/null +++ b/tests/agent-wiki-ui.Tests/Support/PngFactory.cs @@ -0,0 +1,52 @@ +using System.IO.Compression; + +namespace AgentWikiUi.Tests; + +/// Scrive PNG minimali (colore pieno) senza dipendere da Avalonia. +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; + } +} diff --git a/tests/agent-wiki-ui.Tests/Support/TestApp.cs b/tests/agent-wiki-ui.Tests/Support/TestApp.cs new file mode 100644 index 0000000..1cb3723 --- /dev/null +++ b/tests/agent-wiki-ui.Tests/Support/TestApp.cs @@ -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() + .UseSkia() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }); +} diff --git a/tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj b/tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj new file mode 100644 index 0000000..1d15492 --- /dev/null +++ b/tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + AgentWikiUi.Tests + + + + + + + + + + + + + + + + + + + +