++ fix e controlli

This commit is contained in:
2026-07-30 17:05:39 +02:00
parent 712336d652
commit 440d63a6b9
54 changed files with 4921 additions and 1322 deletions

View File

@@ -0,0 +1,423 @@
<?php
namespace Tests\Unit;
use App\Services\BackupArchive;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Mockery;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionMethod;
use RuntimeException;
use Tests\TestCase;
use ZipArchive;
/**
* Il pacchetto di backup e' l'unico file di cui non ci si accorge che era rotto
* finche' non serve: se sbaglia, sbaglia in silenzio e lo si scopre il giorno
* del ripristino. Da qui la scelta di cominciare i test da qui.
*
* Non serve database: tutto gira su Storage::fake('public').
*/
class BackupArchiveTest extends TestCase
{
private BackupArchive $archivio;
/** Cartella dei file .zip prodotti dai test, ripulita a ogni giro. */
private string $lavoro;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$this->archivio = new BackupArchive();
$this->lavoro = storage_path('app/testing-backup');
$this->archivio->deleteDirectory($this->lavoro);
$this->archivio->deleteDirectory(storage_path('app/backup-restore'));
mkdir($this->lavoro, 0775, true);
}
protected function tearDown(): void
{
// extract() estrae in storage/app/backup-restore/<uuid>: e' un percorso
// reale, non il disco finto, quindi va ripulito a mano.
$this->archivio->deleteDirectory($this->lavoro);
$this->archivio->deleteDirectory(storage_path('app/backup-restore'));
parent::tearDown();
}
// ─── create() ───────────────────────────────────────────────
public function test_il_pacchetto_creato_si_riestrae_identico(): void
{
Storage::disk('public')->put('territori-pdf/1.pdf', 'contenuto-uno');
Storage::disk('public')->put('territori-thumbnails/1.png', 'miniatura-uno');
Storage::disk('public')->put('logo.svg', 'logo');
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $destinazione);
$this->assertFileExists($destinazione);
$esito = $this->archivio->extract($destinazione);
$this->assertSame('<dataset/>', $esito['xml']);
$this->assertSame(3, $esito['files']);
$this->assertStringEqualsFile($esito['staging'] . '/territori-pdf/1.pdf', 'contenuto-uno');
$this->assertStringEqualsFile($esito['staging'] . '/territori-thumbnails/1.png', 'miniatura-uno');
$this->assertStringEqualsFile($esito['staging'] . '/logo.svg', 'logo');
}
public function test_il_manifest_dichiara_versione_di_formato_e_conteggio(): void
{
Storage::disk('public')->put('territori-pdf/1.pdf', 'x');
Storage::disk('public')->put('territori-pdf/2.pdf', 'y');
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $destinazione);
$zip = new ZipArchive();
$this->assertTrue($zip->open($destinazione) === true);
$manifest = json_decode($zip->getFromName('manifest.json'), true);
$zip->close();
$this->assertSame(BackupArchive::FORMAT_VERSION, $manifest['format_version']);
$this->assertSame('TerManager2', $manifest['application']);
$this->assertSame(2, $manifest['files_count']);
}
public function test_il_pacchetto_di_un_installazione_senza_file_contiene_comunque_i_dati(): void
{
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $destinazione);
$esito = $this->archivio->extract($destinazione);
$this->assertSame('<dataset/>', $esito['xml']);
$this->assertSame(0, $esito['files']);
}
/**
* La barra conta unita' di lavoro finite, non tempo: l'ultimo avanzamento
* deve coincidere col totale, altrimenti resterebbe sotto il 100% a
* operazione conclusa.
*/
public function test_l_avanzamento_arriva_al_totale(): void
{
for ($i = 1; $i <= 25; $i++) {
Storage::disk('public')->put("territori-pdf/{$i}.pdf", "file-{$i}");
}
$avanzamenti = [];
$this->archivio->create(
'<dataset/>',
$this->lavoro . '/backup.zip',
function (int $fatti, int $totale) use (&$avanzamenti) {
$avanzamenti[] = [$fatti, $totale];
}
);
$this->assertNotEmpty($avanzamenti);
$this->assertSame([25, 25], end($avanzamenti));
$precedente = 0;
foreach ($avanzamenti as [$fatti, $totale]) {
$this->assertSame(25, $totale);
$this->assertGreaterThanOrEqual($precedente, $fatti);
$precedente = $fatti;
}
}
/**
* Il bug che questo test presidia: addFile() torna false se il file e'
* sparito o e' illeggibile, e l'archivio finirebbe con un buco di cui
* nessuno si accorge fino al ripristino. Meglio nessun backup che un backup
* che sembra buono, quindi il file va anche rimosso.
*
* Il messaggio si verifica per esteso perche' e' la parte che si perde per
* prima: addFile() emette un warning di PHP prima di restituire false, e
* senza la soppressione in BackupArchive l'amministratore leggerebbe
* "ZipArchive::addFile(): No such file or directory" al posto della frase
* che dice quale file e cosa controllare.
*/
public function test_un_file_illeggibile_fa_fallire_il_backup_senza_lasciare_l_archivio(): void
{
$disco = Mockery::mock(FilesystemAdapter::class);
$disco->shouldReceive('allFiles')->andReturn(['territori-pdf/sparito.pdf']);
$disco->shouldReceive('path')->andReturn($this->lavoro . '/questo-file-non-esiste.pdf');
Storage::set('public', $disco);
$destinazione = $this->lavoro . '/backup.zip';
try {
$this->archivio->create('<dataset/>', $destinazione);
$this->fail('create() avrebbe dovuto sollevare su un file non leggibile.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('territori-pdf/sparito.pdf', $e->getMessage());
$this->assertStringContainsString('non e\' leggibile', $e->getMessage());
}
$this->assertFileDoesNotExist(
$destinazione,
'Un backup incompleto non deve restare sul disco: sembrerebbe utilizzabile.'
);
}
// ─── extract(): rifiuti ─────────────────────────────────────
public function test_l_archivio_senza_manifest_non_e_riconosciuto(): void
{
$zip = $this->zipCon(['data.xml' => '<dataset/>']);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/manifest\.json/');
$this->archivio->extract($zip);
}
public function test_l_archivio_senza_data_xml_e_incompleto(): void
{
$zip = $this->zipCon(['manifest.json' => $this->manifest()]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/data\.xml/');
$this->archivio->extract($zip);
}
public function test_un_data_xml_vuoto_vale_come_mancante(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(),
'data.xml' => " \n ",
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/data\.xml/');
$this->archivio->extract($zip);
}
/**
* Un pacchetto prodotto da una versione futura si rifiuta invece di
* ripristinarlo a meta': i campi che non si sanno leggere andrebbero persi
* in silenzio.
*/
public function test_un_formato_piu_recente_viene_rifiutato(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(BackupArchive::FORMAT_VERSION + 1),
'data.xml' => '<dataset/>',
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/versione piu\'? recente/');
$this->archivio->extract($zip);
}
public function test_un_manifest_illeggibile_viene_rifiutato(): void
{
$zip = $this->zipCon([
'manifest.json' => 'non-e-json',
'data.xml' => '<dataset/>',
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/manifest/');
$this->archivio->extract($zip);
}
public function test_un_entry_con_percorso_assoluto_viene_rifiutata(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(),
'data.xml' => '<dataset/>',
'storage//tmp/malevolo.txt' => 'fuori',
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/non ammesso/');
$this->archivio->extract($zip);
}
/**
* Lo staging va rimosso anche quando l'estrazione fallisce a meta': i file
* gia' estratti resterebbero altrimenti sul volume, senza che nulla li
* mostri e senza che nulla li cancelli.
*/
public function test_lo_staging_non_sopravvive_a_un_estrazione_fallita(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(),
'data.xml' => '<dataset/>',
'storage/territori-pdf/buono.pdf' => 'ok',
'storage//tmp/malevolo.txt' => 'fuori',
]);
try {
$this->archivio->extract($zip);
$this->fail('extract() avrebbe dovuto sollevare sul percorso non ammesso.');
} catch (RuntimeException) {
// atteso
}
$radice = storage_path('app/backup-restore');
$this->assertTrue(
! is_dir($radice) || glob($radice . '/*') === [],
'La cartella di staging e\' rimasta popolata dopo un ripristino fallito.'
);
}
public function test_le_entry_fuori_da_storage_vengono_ignorate(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(),
'data.xml' => '<dataset/>',
'storage/territori-pdf/1.pdf' => 'buono',
'appunti/README.txt' => 'roba estranea al pacchetto',
]);
$esito = $this->archivio->extract($zip);
$this->assertSame(1, $esito['files']);
$this->assertFileExists($esito['staging'] . '/territori-pdf/1.pdf');
$this->assertFileDoesNotExist($esito['staging'] . '/README.txt');
$this->assertDirectoryDoesNotExist($esito['staging'] . '/appunti');
}
// ─── safeRelativePath(): la regola dello zip slip ───────────
#[DataProvider('percorsiNonAmmessi')]
public function test_i_percorsi_che_escono_dalla_destinazione_sono_rifiutati(string $percorso): void
{
$this->assertNull($this->safeRelativePath($percorso));
}
public static function percorsiNonAmmessi(): array
{
return [
'risalita semplice' => ['../fuori.txt'],
'risalita in mezzo' => ['territori-pdf/../../fuori.txt'],
'risalita con backslash' => ['..\\fuori.txt'],
'percorso assoluto' => ['/etc/passwd'],
'unita windows' => ['C:/Windows/system.ini'],
'vuoto' => [''],
'solo spazi' => [' '],
'solo separatori' => ['///'],
'solo punto' => ['.'],
];
}
#[DataProvider('percorsiAmmessi')]
public function test_i_percorsi_interni_vengono_normalizzati(string $percorso, string $atteso): void
{
$this->assertSame($atteso, $this->safeRelativePath($percorso));
}
public static function percorsiAmmessi(): array
{
return [
'semplice' => ['logo.svg', 'logo.svg'],
'annidato' => ['territori-pdf/1.pdf', 'territori-pdf/1.pdf'],
'backslash convertiti' => ['territori-pdf\\1.pdf', 'territori-pdf/1.pdf'],
'separatori doppi' => ['territori-pdf//1.pdf', 'territori-pdf/1.pdf'],
'punto corrente' => ['./territori-pdf/./1.pdf', 'territori-pdf/1.pdf'],
];
}
// ─── restoreFiles() / svuotaDiscoPubblico() ─────────────────
/**
* La sostituzione del disco e' totale, non un merge: i percorsi vivono
* nelle righe che il ripristino ha appena riscritto, quindi un file
* precedente non piu' citato resterebbe orfano per sempre.
*/
public function test_il_ripristino_sostituisce_il_disco_invece_di_fondersi(): void
{
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'da buttare');
Storage::disk('public')->put('logo.svg', 'logo vecchio');
$staging = $this->lavoro . '/staging';
mkdir($staging . '/territori-pdf', 0775, true);
file_put_contents($staging . '/territori-pdf/nuovo.pdf', 'da tenere');
file_put_contents($staging . '/logo.svg', 'logo nuovo');
$ripristinati = $this->archivio->restoreFiles($staging);
$this->assertSame(2, $ripristinati);
$this->assertSame(
['logo.svg', 'territori-pdf/nuovo.pdf'],
collect(Storage::disk('public')->allFiles())->sort()->values()->all()
);
$this->assertSame('logo nuovo', Storage::disk('public')->get('logo.svg'));
}
public function test_lo_svuotamento_conta_i_file_rimossi_e_lascia_il_disco_vuoto(): void
{
Storage::disk('public')->put('territori-pdf/1.pdf', 'a');
Storage::disk('public')->put('territori-pdf/2.pdf', 'b');
Storage::disk('public')->put('logo.svg', 'c');
$rimossi = $this->archivio->svuotaDiscoPubblico();
$this->assertSame(3, $rimossi);
$this->assertSame([], Storage::disk('public')->allFiles());
}
public function test_lo_svuotamento_di_un_disco_gia_vuoto_non_e_un_errore(): void
{
$this->assertSame(0, $this->archivio->svuotaDiscoPubblico());
}
// ─── helper ─────────────────────────────────────────────────
/**
* @param array<string, string> $entries
*/
private function zipCon(array $entries): string
{
$path = $this->lavoro . '/costruito-' . count($entries) . '-' . md5(serialize($entries)) . '.zip';
$zip = new ZipArchive();
$this->assertTrue(
$zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true,
'Impossibile preparare lo zip di prova.'
);
foreach ($entries as $nome => $contenuto) {
$zip->addFromString($nome, $contenuto);
}
$zip->close();
return $path;
}
private function manifest(?int $formatVersion = null): string
{
return json_encode([
'format_version' => $formatVersion ?? BackupArchive::FORMAT_VERSION,
'application' => 'TerManager2',
'generated_at' => '2026-07-30T12:00:00+02:00',
'app_url' => 'http://localhost',
'files_count' => 0,
]);
}
private function safeRelativePath(string $percorso): ?string
{
$metodo = new ReflectionMethod(BackupArchive::class, 'safeRelativePath');
return $metodo->invoke($this->archivio, $percorso);
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace Tests\Unit;
use App\Services\BackupOperationState;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* Il "posto unico": backup, ripristino e azzeramento lavorano tutti sullo
* stesso database e sullo stesso disco, quindi due in parallelo non si
* rallentano soltanto, si sovrascrivono. Qui si verifica che il posto venga
* occupato, rilasciato e — soprattutto — che non resti occupato per sempre.
*
* Gira su cache 'array' (phpunit.xml): nessun Redis in mezzo.
*/
class BackupOperationStateTest extends TestCase
{
private const CHIAVE_CORRENTE = 'backup-op:corrente';
private BackupOperationState $stato;
protected function setUp(): void
{
parent::setUp();
$this->stato = new BackupOperationState();
}
// ─── Il posto unico ─────────────────────────────────────────
public function test_senza_operazioni_il_posto_e_libero(): void
{
$this->assertNull($this->stato->corrente());
}
public function test_avviare_un_operazione_occupa_il_posto(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 10);
$this->assertSame('op-1', $this->stato->corrente());
}
public function test_il_completamento_libera_il_posto(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 10);
$this->stato->markCompleted('op-1', ['file' => 'backup.zip']);
$this->assertNull($this->stato->corrente());
$this->assertSame('completed', $this->stato->get('op-1')['status']);
}
public function test_il_fallimento_libera_il_posto(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESTORE, 'Estrazione', 10);
$this->stato->markFailed('op-1', 'disco pieno');
$this->assertNull($this->stato->corrente());
$finale = $this->stato->get('op-1');
$this->assertSame('failed', $finale['status']);
$this->assertSame('disco pieno', $finale['error']);
$this->assertStringContainsString('ERRORE: disco pieno', end($finale['logs']));
}
/**
* Il caso che il TTL da solo non copre: lo stato dell'operazione e' scaduto
* ma il puntatore no. Senza questa pulizia il posto resterebbe occupato da
* un'operazione che non esiste piu'.
*/
public function test_un_puntatore_orfano_non_tiene_occupato_il_posto(): void
{
Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600);
$this->assertNull($this->stato->corrente());
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE), 'Il puntatore orfano va anche rimosso.');
}
public function test_un_puntatore_a_un_operazione_gia_chiusa_non_tiene_occupato_il_posto(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESET, 'Backup preventivo', 5);
// Chiusura "a mano", come se il worker fosse morto dopo aver scritto lo
// stato ma prima di rilasciare il posto.
$this->stato->update('op-1', function (array $s) {
$s['status'] = 'completed';
return $s;
});
Cache::put(self::CHIAVE_CORRENTE, 'op-1', 3600);
$this->assertNull($this->stato->corrente());
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE));
}
/**
* Senza il confronto sull'id, la chiusura tardiva di un'operazione vecchia
* sbloccherebbe quella appena avviata da qualcun altro — cioe' proprio il
* parallelismo che il posto unico esiste per impedire.
*/
public function test_un_operazione_vecchia_che_si_chiude_non_libera_il_posto_della_nuova(): void
{
$this->stato->initialize('op-vecchia', BackupOperationState::TIPO_BACKUP, 'Preparazione', 3);
$this->stato->initialize('op-nuova', BackupOperationState::TIPO_RESTORE, 'Estrazione', 3);
$this->stato->markCompleted('op-vecchia');
$this->assertSame('op-nuova', $this->stato->corrente());
}
/**
* L'azzeramento si ferma in attesa del download e riparte da un secondo
* clic: in quella pausa il posto deve restare occupato, altrimenti un'altra
* operazione potrebbe infilarsi fra il backup preventivo e la cancellazione.
*/
public function test_l_attesa_del_download_tiene_il_posto_occupato(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESET, 'Backup preventivo', 4);
$this->stato->markAttesaDownload('op-1', ['file' => 'backup.zip']);
$this->assertSame('op-1', $this->stato->corrente());
$corrente = $this->stato->get('op-1');
$this->assertSame(BackupOperationState::FASE_ATTESA_DOWNLOAD, $corrente['fase']);
$this->assertSame('running', $corrente['status']);
$this->assertSame($corrente['total'], $corrente['processed'], 'La barra deve risultare piena.');
}
// ─── Avanzamento ────────────────────────────────────────────
public function test_il_cambio_di_fase_riazzera_l_avanzamento_sul_nuovo_totale(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESTORE, 'Estrazione', 100);
$this->stato->avanza('op-1', 80);
$this->stato->fase('op-1', 'Scrittura file', 7);
$corrente = $this->stato->get('op-1');
$this->assertSame('Scrittura file', $corrente['fase']);
$this->assertSame(7, $corrente['total']);
$this->assertSame(0, $corrente['processed']);
}
public function test_il_completamento_porta_la_barra_al_totale(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Compressione', 12);
$this->stato->avanza('op-1', 9);
$this->stato->markCompleted('op-1');
$this->assertSame(12, $this->stato->get('op-1')['processed']);
}
public function test_il_risultato_del_completamento_non_cancella_quello_gia_registrato(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESET, 'Backup preventivo', 1);
$this->stato->markAttesaDownload('op-1', ['file' => 'backup.zip']);
$this->stato->markCompleted('op-1', ['tabelle' => 9]);
$risultato = $this->stato->get('op-1')['result'];
$this->assertSame('backup.zip', $risultato['file']);
$this->assertSame(9, $risultato['tabelle']);
}
// ─── Log ────────────────────────────────────────────────────
public function test_la_coda_dei_log_tiene_solo_le_ultime_righe(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 0);
for ($i = 1; $i <= 75; $i++) {
$this->stato->log('op-1', "riga {$i}");
}
$logs = $this->stato->get('op-1')['logs'];
// Lo stato viaggia per intero a ogni poll: una coda illimitata
// gonfierebbe ogni risposta di un'operazione lunga.
$this->assertCount(60, $logs);
$this->assertStringContainsString('riga 75', end($logs));
$this->assertStringContainsString('riga 16', $logs[0]);
}
// ─── Bordi ──────────────────────────────────────────────────
public function test_aggiornare_un_operazione_inesistente_non_la_crea(): void
{
$this->assertNull($this->stato->update('mai-esistita', fn (array $s) => $s));
$this->assertNull($this->stato->get('mai-esistita'));
}
public function test_forget_rimuove_lo_stato(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 1);
$this->stato->forget('op-1');
$this->assertNull($this->stato->get('op-1'));
$this->assertNull($this->stato->corrente(), 'Senza stato il posto risulta libero.');
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Guardia contro il ritorno dei vocabolari di stile paralleli.
*
* L'applicazione aveva quattro modi di scrivere un pulsante: style= inline con
* i colori a mano (piu' onmouseover per l'hover), catene di utility Tailwind
* ripetute a memoria, .btn-action* in sei tinte e .btn-primary-* in due. La
* stessa azione cambiava aspetto da una pagina all'altra e nessuno di quei
* pulsanti aveva uno stato di focus: chi naviga da tastiera non vedeva dove si
* trovava.
*
* Adesso c'e' <x-btn> e basta. Questo test non giudica l'estetica: verifica che
* i modi vecchi non rientrino dalla finestra, cosa che succede da sola alla
* prossima pagina scritta copiando una vecchia.
*
* Legge i sorgenti, quindi non serve ne' applicazione ne' database.
*/
class StileUnificatoTest extends TestCase
{
/** Viste dell'applicazione: quelle pubblicate da pacchetti terzi non ci riguardano. */
private const ESCLUSI = [
'resources/views/vendor/',
// I template dei PDF non sono pagine: li impagina dompdf, che di
// Tailwind e delle nostre classi non sa niente.
'resources/views/pdf/',
];
/**
* @return array<int, string> percorsi relativi delle viste da controllare
*/
private static function viste(): array
{
$radice = dirname(__DIR__, 2);
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($radice . '/resources/views', \FilesystemIterator::SKIP_DOTS)
);
$viste = [];
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile() || ! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
$relativo = str_replace('\\', '/', substr($file->getPathname(), strlen($radice) + 1));
foreach (self::ESCLUSI as $escluso) {
if (str_starts_with($relativo, $escluso)) {
continue 2;
}
}
$viste[] = $relativo;
}
sort($viste);
return $viste;
}
public static function vistePerProvider(): array
{
return array_map(fn (string $vista) => [$vista], self::viste());
}
private function contenuto(string $vista): string
{
return (string) file_get_contents(dirname(__DIR__, 2) . '/' . $vista);
}
#[DataProvider('vistePerProvider')]
public function test_nessuna_vista_usa_le_vecchie_classi_dei_pulsanti(string $vista): void
{
$html = $this->rimuoviCommenti($this->contenuto($vista));
$this->assertStringNotContainsString('btn-action', $html, "{$vista}: .btn-action non esiste piu', usa <x-btn>.");
$this->assertStringNotContainsString('btn-primary-', $html, "{$vista}: .btn-primary-* non esiste piu', usa <x-btn>.");
}
/**
* onmouseover/onmouseout servivano a simulare :hover su pulsanti con lo
* stile inline. Non funzionano da tastiera e non lasciano traccia nel
* foglio di stile: se ricompaiono, e' ricomparso anche il pulsante scritto
* a mano.
*/
#[DataProvider('vistePerProvider')]
public function test_nessuna_vista_simula_l_hover_con_javascript_inline(string $vista): void
{
$html = $this->rimuoviCommenti($this->contenuto($vista));
$this->assertStringNotContainsString('onmouseover', $html, "{$vista}: l'hover si scrive nel CSS, non in un attributo.");
$this->assertStringNotContainsString('onmouseout', $html, "{$vista}: l'hover si scrive nel CSS, non in un attributo.");
}
/**
* Un <button> o un <a> con le utility di riempimento addosso e' un pulsante
* scritto a mano: gli manca lo stato di focus e prima o poi diverge dagli
* altri. Il controllo guarda solo il tag di apertura, quindi non inciampa
* nelle celle di tabella o nei contenitori che usano le stesse utility.
*/
#[DataProvider('vistePerProvider')]
public function test_nessun_pulsante_e_riscritto_a_mano(string $vista): void
{
$html = $this->rimuoviCommenti($this->contenuto($vista));
preg_match_all('/<(?:button|a)\s[^>]*>/i', $html, $trovati);
// Molte viste non hanno pulsanti: senza questa riga il test risulta
// "rischioso" (nessuna asserzione) invece che superato.
$this->assertIsArray($trovati[0]);
foreach ($trovati[0] as $tag) {
$riempimento = preg_match('/\bbg-(?:indigo|red|green|emerald|amber|blue|gray)-\d{3}\b/', $tag) === 1;
$sfondoInline = preg_match('/style="[^"]*background\s*:/i', $tag) === 1;
$this->assertFalse(
$riempimento || $sfondoInline,
"{$vista}: pulsante con lo sfondo scritto a mano, usa <x-btn>.\n" . trim($tag)
);
}
}
/**
* Il componente e' l'unico posto in cui le classi .tm-btn si scrivono: se
* compaiono in una vista, qualcuno ha saltato <x-btn> e si e' portato
* dietro solo l'aspetto, non le regole.
*/
#[DataProvider('vistePerProvider')]
public function test_le_classi_del_sistema_non_si_scrivono_a_mano(string $vista): void
{
if ($vista === 'resources/views/components/btn.blade.php') {
$this->assertTrue(true, 'E\' il componente stesso.');
return;
}
$html = $this->rimuoviCommenti($this->contenuto($vista));
$this->assertDoesNotMatchRegularExpression(
'/class="[^"]*\btm-btn\b/',
$html,
"{$vista}: le classi tm-btn le mette <x-btn>, non la vista."
);
}
/**
* I commenti Blade spiegano proprio le regole che questi test verificano, e
* contengono quindi i nomi vietati. Toglierli evita di dover scrivere le
* spiegazioni a mezze parole per non far fallire il controllo.
*/
private function rimuoviCommenti(string $html): string
{
return (string) preg_replace('/\{\{--.*?--\}\}/s', '', $html);
}
}