++ scelta di territori e proclamatori con ricerca

Al posto delle tendine (Assegna, Registro, editor delle piantine) un
campo in cui si scrive e compaiono le proposte più vicine: uguale, inizia
con, contiene, errore di battitura ("forse cercavi"); parole in qualsiasi
ordine, senza badare a maiuscole e accenti. Campo vuoto: la tendina
completa. Fatto anche per il telefono (16px, voci da 44px, scelta al tocco).

- Logica pura in resources/js/scelta.js con test node (tests/js).
- Opzioni dal server: Territorio/Proclamatore::opzioneScelta(); i
  territori già assegnati si trovano ("già assegnato a …") ma non si
  scelgono. Nomi cifrati ordinati e cercati decifrati, mai in SQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-19 15:35:56 +02:00
parent 2873424c3c
commit fda428f242
16 changed files with 814 additions and 76 deletions

View File

@@ -0,0 +1,158 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Piantine\PiantineEditor;
use App\Livewire\Registro;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\User;
use App\Models\Zona;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
/**
* I campi di scelta con ricerca (<x-scelta>) per territori e proclamatori:
* le opzioni che il server prepara. Il modo in cui si filtrano e ordinano
* mentre si scrive è provato in tests/js/scelta.test.mjs.
*/
class SceltaRicercaTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Storage::fake('public');
Storage::fake('local');
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
}
private function etichette(array $opzioni): array
{
return array_column($opzioni, 'etichetta');
}
public function test_assegna_usa_i_campi_con_ricerca(): void
{
$this->actingAs($this->admin)
->get(route('assegnazioni.assegna'))
->assertOk()
->assertSee('role="combobox"', false)
->assertSee('id="territorio_id"', false)
->assertSee('id="proclamatore_id"', false)
->assertDontSee('Filtra territori');
}
public function test_un_territorio_gia_assegnato_si_trova_ma_non_si_sceglie(): void
{
$zona = Zona::create(['nome' => 'Zisa', 'attivo' => true]);
$libero = Territorio::create(['numero' => '12', 'attivo' => true, 'zona_id' => $zona->id]);
$occupato = Territorio::create(['numero' => '35', 'attivo' => true]);
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Assegnazione::create([
'territorio_id' => $occupato->id, 'proclamatore_id' => $mario->id,
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
]);
$opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniTerritori');
$perValore = collect($opzioni)->keyBy('valore');
$this->assertSame(['valore' => (string) $libero->id, 'etichetta' => 'N° 12', 'dettaglio' => 'Zisa', 'chiavi' => ['12', 'Zisa', '']], $perValore[(string) $libero->id]);
$this->assertSame('già assegnato a Rossi Mario', $perValore[(string) $occupato->id]['motivo']);
$this->assertArrayNotHasKey('motivo', $perValore[(string) $libero->id]);
}
public function test_i_territori_sono_in_ordine_numerico(): void
{
foreach (['100', '9', '35', '12a', '12'] as $numero) {
Territorio::create(['numero' => $numero, 'attivo' => true]);
}
$opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniTerritori');
$this->assertSame(['N° 9', 'N° 12', 'N° 12a', 'N° 35', 'N° 100'], $this->etichette($opzioni));
}
public function test_i_proclamatori_sono_in_ordine_di_cognome_decifrato(): void
{
// Nel database sono cifrati: un ORDER BY li metterebbe a caso.
Proclamatore::create(['nome' => 'Paolo', 'cognome' => 'Verdi', 'attivo' => true]);
Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Rosselli', 'attivo' => true]);
Proclamatore::create(['nome' => 'Luca', 'cognome' => 'Bianchi', 'attivo' => true]);
$opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniProclamatori');
$this->assertSame(['Bianchi Luca', 'Rosselli Anna', 'Verdi Paolo'], $this->etichette($opzioni));
$this->assertSame(['Bianchi', 'Luca'], $opzioni[0]['chiavi']);
}
public function test_assegna_propone_solo_proclamatori_attivi(): void
{
Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => false]);
$opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniProclamatori');
$this->assertSame(['Rossi Mario'], $this->etichette($opzioni));
}
public function test_si_assegna_con_il_valore_scelto_nel_campo(): void
{
// Il campo manda a Livewire il valore come stringa, come faceva <select>.
Storage::disk('public')->put('territori-pdf/t7.pdf', '%PDF-1.4');
$territorio = Territorio::create(['numero' => '7', 'attivo' => true, 'pdf_path' => 'territori-pdf/t7.pdf']);
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Livewire::actingAs($this->admin)->test(Assegna::class)
->set('territorio_id', (string) $territorio->id)
->set('proclamatore_id', (string) $mario->id)
->call('save')
->assertHasNoErrors();
$this->assertSame($mario->id, Assegnazione::sole()->proclamatore_id);
}
public function test_nel_registro_i_disattivati_ci_sono_ma_non_si_scelgono(): void
{
Territorio::create(['numero' => '5', 'attivo' => false]);
Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => false]);
$pagina = Livewire::actingAs($this->admin)->test(Registro::class);
$this->assertSame('territorio disattivato', $pagina->viewData('opzioniTerritori')[0]['motivo']);
$this->assertSame('non attivo', $pagina->viewData('opzioniProclamatori')[0]['motivo']);
}
public function test_nell_editor_le_voci_fisse_restano_in_cima(): void
{
Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create(['geojson' => json_encode([
'type' => 'Polygon', 'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
])]);
$opzioni = Livewire::actingAs($this->admin)
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->viewData('opzioniTerritori');
$this->assertSame(['— nessun territorio —', '+ Crea un territorio nuovo', 'Territorio 12'], $this->etichette($opzioni));
$this->assertTrue($opzioni[0]['speciale']);
$this->assertSame('', $opzioni[0]['valore']);
}
}

98
tests/js/scelta.test.mjs Normal file
View File

@@ -0,0 +1,98 @@
// Prove della ricerca nei campi di scelta (resources/js/scelta.js).
// Si eseguono con: node --test tests/js/
// Gli esempi sono quelli concordati con l'utente il 19 ottobre 2026.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { proponi, normalizza, distanza } from '../../resources/js/scelta.js';
const territorio = (numero, zona = 'Montepellegrino', tipologia = 'Residenziale', motivo) => ({
valore: numero, etichetta: `N° ${numero}`, dettaglio: `${zona} (${tipologia})`,
chiavi: [numero, zona, tipologia], ...(motivo ? { motivo } : {}),
});
const proclamatore = (cognome, nome, motivo) => ({
valore: `${cognome}-${nome}`, etichetta: `${cognome} ${nome}`, chiavi: [cognome, nome], ...(motivo ? { motivo } : {}),
});
const etichette = (risultati) => risultati.map((o) => o.etichetta);
const territori = [
territorio('12', 'Zisa'), territorio('35'), territorio('135', 'Noce'), territorio('235', 'Cuba'),
territorio('350'), territorio('351'), territorio('36'),
];
const proclamatori = [
proclamatore('Bianchi', 'Rosa'), proclamatore('Grosso', 'Luca'), proclamatore('Neri', 'Nicolò'),
proclamatore('Rosselli', 'Anna'), proclamatore('Rossi', 'Mario'), proclamatore('Verdi', 'Paolo'),
];
test('esempio 1: "35" mette prima l\'uguale, poi chi inizia, poi chi contiene', () => {
assert.deepEqual(etichette(proponi(territori, '35')), ['N° 35', 'N° 350', 'N° 351', 'N° 135', 'N° 235']);
});
test('i numeri brevi non hanno tolleranza: "35" non trova il 36', () => {
assert.ok(!etichette(proponi(territori, '35')).includes('N° 36'));
});
test('esempio 1: "monte" trova i territori della zona Montepellegrino', () => {
assert.deepEqual(etichette(proponi(territori, 'monte')), ['N° 35', 'N° 350', 'N° 351', 'N° 36']);
});
test('esempio 2: "ros" mette prima il cognome, poi il nome, poi chi contiene', () => {
assert.deepEqual(etichette(proponi(proclamatori, 'ros')), ['Rosselli Anna', 'Rossi Mario', 'Bianchi Rosa', 'Grosso Luca']);
});
test('maiuscole e accenti non contano', () => {
assert.deepEqual(etichette(proponi(proclamatori, 'NICOLO')), ['Neri Nicolò']);
assert.equal(normalizza(' Nicolò '), 'nicolo');
});
test('nome e cognome in qualsiasi ordine', () => {
assert.deepEqual(etichette(proponi(proclamatori, 'mario ros')), ['Rossi Mario']);
assert.deepEqual(etichette(proponi(proclamatori, 'rossi mario')), ['Rossi Mario']);
});
test('esempio 3: "rosi" propone Rossi come "forse cercavi"', () => {
const risultati = proponi(proclamatori, 'rosi');
const rossi = risultati.find((o) => o.etichetta === 'Rossi Mario');
assert.ok(rossi, 'Rossi Mario deve essere proposto');
assert.equal(rossi.forse, true);
});
test('una corrispondenza vera viene prima di un errore di battitura', () => {
const elenco = [proclamatore('Rossi', 'Mario'), proclamatore('Rosi', 'Carla')];
const risultati = proponi(elenco, 'rosi');
assert.equal(risultati[0].etichetta, 'Rosi Carla');
assert.equal(risultati[0].forse, false);
});
test('campo vuoto: la tendina completa, senza le voci non sceglibili', () => {
const elenco = [territorio('7'), territorio('8', 'Zisa', 'Residenziale', 'già assegnato a Rossi Mario')];
assert.deepEqual(etichette(proponi(elenco, '')), ['N° 7']);
assert.deepEqual(etichette(proponi(elenco, ' ')), ['N° 7']);
});
test('cercando, le voci non sceglibili compaiono con il motivo', () => {
const elenco = [territorio('7'), territorio('8', 'Zisa', 'Residenziale', 'già assegnato a Rossi Mario')];
const [occupato] = proponi(elenco, '8');
assert.equal(occupato.etichetta, 'N° 8');
assert.equal(occupato.motivo, 'già assegnato a Rossi Mario');
});
test('le opzioni speciali restano sempre in cima', () => {
const elenco = [
{ valore: '', etichetta: '— nessun territorio —', speciale: true },
{ valore: 'nuovo', etichetta: '+ Crea un territorio nuovo', speciale: true },
territorio('35'), territorio('12', 'Zisa'),
];
assert.deepEqual(etichette(proponi(elenco, 'zisa')), ['— nessun territorio —', '+ Crea un territorio nuovo', 'N° 12']);
});
test('nessun risultato: elenco vuoto', () => {
assert.deepEqual(proponi(proclamatori, 'zzzz'), []);
});
test('distanza di modifica', () => {
assert.equal(distanza('rosi', 'rossi'), 1);
assert.equal(distanza('kitten', 'sitting', 3), 3);
assert.equal(distanza('abc', 'abcdef', 1), 2); // oltre il limite: limite + 1
});