Piantine staccate dai territori, editor a schermo intero, restyle

Le piantine non dipendono piu' da un territorio esistente: territorio_id e'
nullable, la chiave esterna e' nullOnDelete invece di cascadeOnDelete, e `nome`
fa da etichetta finche' il collegamento manca. Si disegna guardando le strade e
si decide dopo di chi e' il disegno; se il territorio non c'e', lo si crea
dall'editor con numero, zona, tipologia e confini — zona e tipologia comprese,
create sul posto quando mancano.

Il cambio di cascata chiude anche una perdita di dati silenziosa: ogni import
XML svuota `territori` in blocco e finora si portava via tutte le piantine.

Il disegno passa su /piantine/editor, a schermo intero con telaio proprio: su
una mappa larga un terzo di schermo un confine si sbaglia, e finisce stampato.
/piantine resta la gestione — area, generazione, collegamenti, storico.

Cartolina S-12 reimpaginata: pagina 1 il modulo con la Localita' (che ora e' il
nome della zona, non un campo duplicato) e i confini scritti nello spazio
centrale, con il corpo del testo che si adatta alla lunghezza; pagina 2 la
piantina sola al 98%. Aggiunta l'anteprima, che compone il PDF al volo senza
toccare pdf_path ne' lo storico.

Immagine: JPEG invece di PNG (0,9 MB contro 5 per territorio), composta alle
tessere dello zoom piu' alto e ridotta con Lanczos, nelle proporzioni della
pagina cosi' riempie il foglio invece di lasciare fasce bianche. Il tratto del
confine e' espresso in pixel dell'immagine finale: si vede il confine e si legge
la via che ci passa sotto. L'attribuzione OSM si dimensiona con l'immagine,
come la ODbL richiede.

Rimossa la rigenerazione massiva: job, stato in cache e interfaccia.

Sistema visivo ripassato su tutta l'applicazione: le scale gray e indigo di
Tailwind sono ridefinite nei token, cosi' le 37 viste si riallineano insieme
invece di essere riscritte una per una. Grigi caldi, un accento solo con quattro
significati, carattere servito dall'applicazione e non da Google Fonts, via il
gradiente della testata. Aggiunto il link "salta al contenuto", che mancava.

Corretto un guasto d'ambiente che bloccava TUTTI gli upload in sviluppo: dalla
1.26 il fastcgi_params di nginx passa HTTP_HOST come $host, che perde la porta,
e ogni URL firmato smetteva di validare — l'endpoint di upload di Livewire
rispondeva 401.

294 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 16:41:22 +02:00
parent 7e4c8aa9b8
commit 9c37c1ad7b
30 changed files with 3032 additions and 982 deletions

View File

@@ -69,6 +69,71 @@ class PiantinaPdfServiceTest extends TestCase
$this->assertStringContainsString('/Count 2', $pdf);
}
/**
* La "Località" del modulo è il nome della zona: un dato solo, in un posto
* solo, che si aggiorna quando la congregazione riorganizza il lavoro.
*/
public function test_la_localita_stampata_e_il_nome_della_zona(): void
{
[$territorio, $mappa] = $this->territorioConImmagine();
$zona = \App\Models\Zona::create(['nome' => 'Palermo Montepellegrino', 'attivo' => true]);
$territorio->update(['zona_id' => $zona->id]);
$testo = $this->testoDel($this->servizio()->genera($territorio->fresh(), $mappa));
$this->assertStringContainsString('Palermo Montepellegrino', $testo);
}
public function test_senza_zona_l_intestazione_resta_vuota_senza_rompere_nulla(): void
{
[$territorio, $mappa] = $this->territorioConImmagine();
$pdf = $this->servizio()->genera($territorio, $mappa);
$this->assertStringStartsWith('%PDF', $pdf);
}
/** I confini vanno sulla cartolina, non su una pagina a parte. */
public function test_i_confini_finiscono_nel_pdf(): void
{
[$territorio, $mappa] = $this->territorioConImmagine();
$testo = $this->testoDel($this->servizio()->genera($territorio, $mappa));
$this->assertStringContainsString('Nord: via Roma', $testo);
$this->assertStringContainsString('Sud: via Milano', $testo);
}
public function test_senza_confini_il_pdf_lo_dichiara(): void
{
[$territorio, $mappa] = $this->territorioConImmagine();
$territorio->update(['confini' => null]);
$testo = $this->testoDel($this->servizio()->genera($territorio->refresh(), $mappa));
$this->assertStringContainsString('Confini non ancora descritti', $testo);
}
/**
* Il testo scritto da FPDF vive dentro i flussi compressi delle pagine:
* per leggerlo vanno estratti e decompressi.
*/
private function testoDel(string $pdf): string
{
$testo = '';
if (preg_match_all('/stream\r?\n(.*?)\r?\nendstream/s', $pdf, $trovati)) {
foreach ($trovati[1] as $flusso) {
$decompresso = @gzuncompress($flusso);
if ($decompresso !== false) {
$testo .= $decompresso;
}
}
}
return $testo;
}
public function test_genera_senza_immagine_si_ferma_con_un_messaggio_chiaro(): void
{
[$territorio, $mappa] = $this->territorioConImmagine();

View File

@@ -38,19 +38,56 @@ class PiantinaRendererTest extends TestCase
]);
}
public function test_un_rendering_riuscito_salva_il_png_e_aggiorna_la_mappa(): void
public function test_un_rendering_riuscito_salva_il_jpeg_e_aggiorna_la_mappa(): void
{
Http::fake(['*/render' => Http::response("\x89PNG\r\n\x1a\ncontenuto-finto")]);
Http::fake(['*/render' => Http::response("\xFF\xD8\xFFcontenuto-finto")]);
$mappa = $this->mappa();
$path = (new PiantinaRenderer())->render($mappa);
Storage::disk('public')->assertExists($path);
$this->assertStringEndsWith('.jpg', $path);
$mappa->refresh();
$this->assertSame($path, $mappa->image_path);
$this->assertNotNull($mappa->rendered_at);
}
/**
* Formato e risoluzione non sono dettagli: la piantina finisce su una
* cartolina da 146 mm, e sono i due numeri che decidono se il PDF pesa
* mezzo megabyte o cinque.
*/
public function test_al_renderer_arrivano_formato_qualita_e_lato_massimo(): void
{
Http::fake(['*/render' => Http::response("\xFF\xD8\xFFcontenuto-finto")]);
(new PiantinaRenderer())->render($this->mappa());
Http::assertSent(function ($request) {
return $request['format'] === 'jpeg'
&& $request['quality'] === config('piantine.qualita')
&& $request['maxSide'] === config('piantine.max_side');
});
}
/**
* Passando da PNG a JPEG il vecchio file ha un'altra estensione: senza
* rimozione esplicita resterebbe sul volume senza che niente lo referenzi.
*/
public function test_il_file_di_un_formato_precedente_viene_rimosso(): void
{
Http::fake(['*/render' => Http::response("\xFF\xD8\xFFcontenuto-finto")]);
$mappa = $this->mappa();
$vecchio = 'territori-mappe/piantina-' . $mappa->id . '.png';
Storage::disk('public')->put($vecchio, 'vecchio-png');
$mappa->update(['image_path' => $vecchio]);
(new PiantinaRenderer())->render($mappa->refresh());
Storage::disk('public')->assertMissing($vecchio);
}
public function test_un_errore_del_renderer_diventa_un_messaggio_leggibile(): void
{
Http::fake(['*/render' => Http::response(['error' => 'area troppo estesa'], 413)]);
@@ -61,7 +98,7 @@ class PiantinaRendererTest extends TestCase
(new PiantinaRenderer())->render($this->mappa());
}
public function test_una_risposta_che_non_e_un_png_viene_rifiutata(): void
public function test_una_risposta_che_non_e_un_immagine_viene_rifiutata(): void
{
Http::fake(['*/render' => Http::response('<html>proxy error</html>')]);

View File

@@ -100,18 +100,4 @@ XML;
$this->assertSame('Polygon', $area->area()['features'][0]['geometry']['type']);
}
public function test_la_localita_esplicita_vince_sul_nome_congregazione(): void
{
\App\Models\Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
$area = new PiantineArea();
$this->assertSame('Congregazione di prova', $area->localita());
$area->impostaLocalita('Palermo Nord');
$this->assertSame('Palermo Nord', $area->localita());
// Svuotarla riporta al nome della congregazione.
$area->impostaLocalita(' ');
$this->assertSame('Congregazione di prova', $area->localita());
}
}