diff --git a/CHANGELOG.md b/CHANGELOG.md index 839e322..20fb4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). +## [1.0.0-rc.26] — 2026-09-15 + +### Données animales et suivi vétérinaire + +- suppression sûre de l’ancienne contrainte SQLite limitant les salles à « silver » et « twix » ; +- reconstruction transactionnelle de la table des animaux avec conservation de toutes les fiches, relations, vues et index ; +- ajout d’un test de sauvegarde réelle contrôlant le nombre d’animaux, l’intégrité SQLite et les clés étrangères ; +- suppression des avertissements `open_basedir` pendant la détection de l’outil d’optimisation des images. +- remplacement des anciens états I-CAD par « Inconnu », « Enregistré », « En attente de changement » et « Propriétaire changé » ; +- retrait du champ redondant « Administré par » lors de la saisie d’un vaccin ; +- affichage du cabinet vétérinaire dans le récapitulatif des vaccinations et son export. + ## [1.0.0-rc.25] — 2026-09-15 ### Modification des portées diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 9cb5fa6..8d1f156 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -10,6 +10,11 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation. +## `1.0.0-rc.26` — Salles réellement configurables + +Retrait de la dernière contrainte héritée qui limitait encore les fiches animales aux deux premières salles historiques. La migration conserve les animaux et toutes leurs relations tout en autorisant les salles définies dans la configuration. +Les états d’enregistrement I-CAD suivent désormais le changement de propriétaire, tandis que le suivi vaccinal s’appuie sans doublon sur le cabinet et le vétérinaire de l’annuaire. + ## `1.0.0-rc.25` — Portées modifiables Les portées ne sont plus figées après leur création : parents et date peuvent être corrigés, tandis que les chatons peuvent être ajoutés, modifiés ou détachés sans perte de leur fiche. diff --git a/README.md b/README.md index 0da8de5..4aedc46 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L ## État du projet -La candidate actuelle est `1.0.0-rc.25`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`. +La candidate actuelle est `1.0.0-rc.26`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`. Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md). diff --git a/VERSION b/VERSION index 07923b2..f7b1136 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.25 +1.0.0-rc.26 diff --git a/app/Controllers/AnimalBrowseActions.php b/app/Controllers/AnimalBrowseActions.php index 845e5f9..bc14a65 100644 --- a/app/Controllers/AnimalBrowseActions.php +++ b/app/Controllers/AnimalBrowseActions.php @@ -612,12 +612,11 @@ trait AnimalBrowseActions // Vaccins $stmt = $db->prepare(' - SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name,COALESCE(v.administered_by_name,au.display_name,au.username) AS administrator_name + SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name FROM vaccinations v JOIN ref_vaccines rv ON rv.id = v.vaccine_id LEFT JOIN clinics c ON c.id = v.clinic_id LEFT JOIN directory_contacts dc ON dc.id = v.clinic_contact_id - LEFT JOIN users au ON au.id=v.administered_by_user_id WHERE v.animal_id = :id ORDER BY v.done_date DESC, v.id DESC LIMIT 50 @@ -833,7 +832,7 @@ trait AnimalBrowseActions CASE WHEN COALESCE(v.lot,'')!='' THEN '\nLot : ' || v.lot ELSE '' END || CASE WHEN COALESCE(v.manufacturer,'')!='' THEN '\nFabricant : ' || v.manufacturer ELSE '' END || CASE WHEN COALESCE(v.batch_expires_on,'')!='' THEN '\nExpiration du lot : ' || v.batch_expires_on ELSE '' END || - CASE WHEN COALESCE(v.administered_by_name,'')!='' THEN '\nAdministré par : ' || v.administered_by_name WHEN v.administered_by_user_id IS NOT NULL THEN '\nAdministré par : ' || COALESCE((SELECT display_name FROM users WHERE id=v.administered_by_user_id),(SELECT username FROM users WHERE id=v.administered_by_user_id),'Compte inconnu') ELSE '' END || + CASE WHEN v.clinic_contact_id IS NOT NULL THEN '\nCabinet vétérinaire : ' || COALESCE((SELECT name FROM directory_contacts WHERE id=v.clinic_contact_id),'Cabinet inconnu') WHEN v.clinic_id IS NOT NULL THEN '\nCabinet vétérinaire : ' || COALESCE((SELECT name FROM clinics WHERE id=v.clinic_id),'Cabinet inconnu') ELSE '' END || CASE WHEN COALESCE(v.notes,'')!='' THEN '\nNotes : ' || v.notes ELSE '' END ) AS details, datetime(v.done_date || ' 12:00:00') AS at diff --git a/app/Controllers/AnimalMedicalActions.php b/app/Controllers/AnimalMedicalActions.php index 3cd6172..3f8a04e 100644 --- a/app/Controllers/AnimalMedicalActions.php +++ b/app/Controllers/AnimalMedicalActions.php @@ -630,15 +630,6 @@ trait AnimalMedicalActions echo h(t('error.invalid_batch_expiry')); return; } - $administratorUserId = (int) ($_POST['administered_by_user_id'] ?? 0) ?: null; - $administratorName = trim((string) ($_POST['administered_by_name'] ?? '')); - if ($administratorUserId) { - $check = $db->prepare('SELECT 1 FROM users WHERE id=:id AND active=1'); - $check->execute([':id' => $administratorUserId]); - if (!$check->fetchColumn()) { - $administratorUserId = null; - } - } $notes = trim((string) ($_POST['notes'] ?? '')); $clinic_id = (int) ($_POST['clinic_id'] ?? 0); $clinic_id = $clinic_id > 0 ? $clinic_id : null; @@ -668,8 +659,8 @@ trait AnimalMedicalActions $vet_name = $vetContact['name'] ?? $vet_name; $stmt = $db->prepare(' - INSERT INTO vaccinations(animal_id, vaccine_id, done_date, due_date, lot,manufacturer,batch_expires_on,administered_by_user_id,administered_by_name, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by) - VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:administrator_user,:administrator_name,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by) + INSERT INTO vaccinations(animal_id, vaccine_id, done_date, due_date, lot,manufacturer,batch_expires_on, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by) + VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by) '); $stmt->execute([ ':aid' => $id, @@ -679,8 +670,6 @@ trait AnimalMedicalActions ':lot' => $lot !== '' ? $lot : null, ':manufacturer' => $manufacturer ?: null, ':batch_expiry' => $batchExpiresOn ?: null, - ':administrator_user' => $administratorUserId, - ':administrator_name' => $administratorName ?: null, ':notes' => $notes !== '' ? $notes : null, ':clinic_id' => $clinic_id, ':vet_name' => $vet_name, @@ -707,10 +696,7 @@ trait AnimalMedicalActions ($manufacturer !== '' ? "\nFabricant : $manufacturer" : '') . ($lot !== '' ? "\nLot : $lot" : '') . ($batchExpiresOn !== '' ? "\nExpiration du lot : $batchExpiresOn" : '') . - ($administratorName !== '' || $administratorUserId - ? "\nAdministré par : " . - ($administratorName !== '' ? $administratorName : 'utilisateur #' . $administratorUserId) - : ''), + "\nCabinet vétérinaire : " . ($clinicContact['name'] ?? ''), ); $db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]); diff --git a/app/Controllers/AnimalRecordActions.php b/app/Controllers/AnimalRecordActions.php index 9935cb6..462fb8b 100644 --- a/app/Controllers/AnimalRecordActions.php +++ b/app/Controllers/AnimalRecordActions.php @@ -376,7 +376,10 @@ trait AnimalRecordActions } $identificationDate = trim((string) ($_POST['identification_date'] ?? '')); $identificationRegistration = (string) ($_POST['identification_registration_status'] ?? 'unknown'); - if (!in_array($identificationRegistration, ['unknown', 'pending', 'registered'], true)) { + if ($identificationRegistration === 'pending') { + $identificationRegistration = 'change_pending'; + } + if (!in_array($identificationRegistration, ['unknown', 'registered', 'change_pending', 'owner_changed'], true)) { $identificationRegistration = 'unknown'; } if ($chip_id === '') { diff --git a/app/Services/Asm3MigrationPlanner.php b/app/Services/Asm3MigrationPlanner.php index 90edbc2..b3233f4 100644 --- a/app/Services/Asm3MigrationPlanner.php +++ b/app/Services/Asm3MigrationPlanner.php @@ -152,7 +152,7 @@ final class Asm3MigrationPlanner 'identification_date' => $date($chip !== '' ? $r['IDENTICHIPDATE'] ?? null : $r['TATTOODATE'] ?? null), 'identification_registration_status' => match ((string) ($r['IDENTICHIPSTATUS'] ?? '')) { '1' => 'registered', - '0' => 'pending', + '0' => 'change_pending', default => 'unknown', }, 'sterilization_status' => $yes($r['NEUTERED'] ?? 0) ? 'yes' : 'unknown', diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php index df454b8..1ad5d5b 100644 --- a/app/Services/ExportService.php +++ b/app/Services/ExportService.php @@ -38,7 +38,7 @@ final class ExportService 'vaccinations' => [ 'Vaccinations', 'vaccinations.csv', - 'SELECT v.id,a.internal_code code_animal,a.name animal,rv.name vaccin,v.done_date date_realisation,v.due_date prochain_rappel,v.manufacturer fabricant,v.lot,v.batch_expires_on expiration_lot,COALESCE(v.administered_by_name,au.display_name,au.username) administre_par,COALESCE(cu.display_name,cu.username) saisi_par,vet.name veterinaire,clinic.name cabinet,v.notes FROM vaccinations v JOIN animals a ON a.id=v.animal_id JOIN ref_vaccines rv ON rv.id=v.vaccine_id LEFT JOIN directory_contacts vet ON vet.id=v.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=v.clinic_contact_id LEFT JOIN users au ON au.id=v.administered_by_user_id LEFT JOIN users cu ON cu.id=v.created_by ORDER BY v.done_date,v.id', + 'SELECT v.id,a.internal_code code_animal,a.name animal,rv.name vaccin,v.done_date date_realisation,v.due_date prochain_rappel,v.manufacturer fabricant,v.lot,v.batch_expires_on expiration_lot,clinic.name administre_par,COALESCE(cu.display_name,cu.username) saisi_par,vet.name veterinaire,clinic.name cabinet,v.notes FROM vaccinations v JOIN animals a ON a.id=v.animal_id JOIN ref_vaccines rv ON rv.id=v.vaccine_id LEFT JOIN directory_contacts vet ON vet.id=v.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=v.clinic_contact_id LEFT JOIN users cu ON cu.id=v.created_by ORDER BY v.done_date,v.id', ], 'deaths' => [ 'Décès', diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index d214ef3..89682d6 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -131,7 +131,7 @@ final class ImageService ['/usr/bin/magick', '/usr/local/bin/magick', '/usr/bin/convert', '/usr/local/bin/convert'] as $binary ) { - if (is_executable($binary)) { + if (@is_executable($binary)) { return $binary; } } diff --git a/app/Services/Migrations.php b/app/Services/Migrations.php index 1476068..860a0c6 100644 --- a/app/Services/Migrations.php +++ b/app/Services/Migrations.php @@ -91,13 +91,21 @@ final class Migrations } } - $relaxesForeignKeys = $name === '093_room_rules_and_care_periods.sql'; + $relaxesForeignKeys = in_array( + $name, + ['093_room_rules_and_care_periods.sql', '097_remove_legacy_room_constraint.sql'], + true, + ); if ($relaxesForeignKeys) { $db->exec('PRAGMA foreign_keys = OFF'); } $db->beginTransaction(); try { - $db->exec($sql); + if ($name === '097_remove_legacy_room_constraint.sql') { + self::modernizeAnimalConstraints($db); + } else { + $db->exec($sql); + } $ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))"); $ins->execute([':n' => $name]); if ($relaxesForeignKeys && $db->query('PRAGMA foreign_key_check')->fetchColumn() !== false) { @@ -116,4 +124,62 @@ final class Migrations } } } + + private static function modernizeAnimalConstraints(PDO $db): void + { + $createSql = (string) $db + ->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='animals'") + ->fetchColumn(); + $constraint = "/refuge_room\\s+TEXT\\s+CHECK\\s*\\(\\s*refuge_room\\s+IN\\s*\\(\\s*'silver'\\s*,\\s*'twix'\\s*\\)\\s+OR\\s+refuge_room\\s+IS\\s+NULL\\s*\\)/i"; + $registrationConstraint = "/identification_registration_status\\s+TEXT\\s+NOT\\s+NULL\\s+DEFAULT\\s+'unknown'\\s+CHECK\\s*\\(\\s*identification_registration_status\\s+IN\\s*\\(\\s*'unknown'\\s*,\\s*'pending'\\s*,\\s*'registered'\\s*\\)\\s*\\)/i"; + if ( + $createSql === '' + || (!preg_match($constraint, $createSql) && !preg_match($registrationConstraint, $createSql)) + ) { + return; + } + + $newSql = preg_replace( + '/^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?["`\[]?animals["`\]]?/i', + 'CREATE TABLE animals_room_unrestricted', + $createSql, + 1, + ); + $newSql = preg_replace($constraint, 'refuge_room TEXT', (string) $newSql, 1); + $newSql = preg_replace( + $registrationConstraint, + "identification_registration_status TEXT NOT NULL DEFAULT 'unknown' " . + "CHECK(identification_registration_status IN ('unknown','registered','change_pending','owner_changed','pending'))", + (string) $newSql, + 1, + ); + if (!$newSql || preg_match($constraint, $newSql)) { + throw new RuntimeException('Unable to remove the legacy shelter-room constraint.'); + } + + $objects = $db + ->query("SELECT sql FROM sqlite_master WHERE tbl_name='animals' AND type IN ('index','trigger') AND sql IS NOT NULL") + ->fetchAll(PDO::FETCH_COLUMN); + $views = $db + ->query("SELECT name,sql FROM sqlite_master WHERE type='view' AND sql IS NOT NULL ORDER BY rowid") + ->fetchAll(PDO::FETCH_ASSOC); + $columns = array_column($db->query('PRAGMA table_info(animals)')->fetchAll(PDO::FETCH_ASSOC), 'name'); + $quotedColumns = implode(',', array_map(static fn(string $column): string => '"' . str_replace('"', '""', $column) . '"', $columns)); + + foreach ($views as $view) { + $viewName = '"' . str_replace('"', '""', (string) $view['name']) . '"'; + $db->exec('DROP VIEW ' . $viewName); + } + $db->exec($newSql); + $db->exec("INSERT INTO animals_room_unrestricted ($quotedColumns) SELECT $quotedColumns FROM animals"); + $db->exec("UPDATE animals_room_unrestricted SET identification_registration_status='change_pending' WHERE identification_registration_status='pending'"); + $db->exec('DROP TABLE animals'); + $db->exec('ALTER TABLE animals_room_unrestricted RENAME TO animals'); + foreach ($objects as $objectSql) { + $db->exec((string) $objectSql); + } + foreach ($views as $view) { + $db->exec((string) $view['sql']); + } + } } diff --git a/app/Views/_animal_show_medical.php b/app/Views/_animal_show_medical.php index 75a4211..31b8b95 100644 --- a/app/Views/_animal_show_medical.php +++ b/app/Views/_animal_show_medical.php @@ -410,7 +410,7 @@ ORDER BY created_at ASC - + @@ -424,7 +424,7 @@ ORDER BY created_at ASC
date('d/m/Y', strtotime((string)$v['batch_expires_on']))])) ?>
- + diff --git a/app/Views/_animal_show_modals.php b/app/Views/_animal_show_modals.php index 972364f..82c27a8 100644 --- a/app/Views/_animal_show_modals.php +++ b/app/Views/_animal_show_modals.php @@ -312,38 +312,12 @@ $currentTime = date('H:i');
-
- - -
- -
- - -
-
-
diff --git a/app/Views/animal_show.php b/app/Views/animal_show.php index 05c5b34..fc6b553 100644 --- a/app/Views/animal_show.php +++ b/app/Views/animal_show.php @@ -296,9 +296,9 @@ if ($felvPos) { [t('animal.availability_define'),'bg-secondary-lt'],'not_available' => [t('animals.unavailable_title'),'bg-orange-lt'],'available' => [t('animals.available_title'),'bg-green-lt']]; [$availabilityLabel,$availabilityClass] = $availabilityMeta[$availability] ?? $availabilityMeta['unknown']; -$registrationLabels = ['unknown' => t('animal.registration_unknown'),'pending' => t('animal.registration_pending'),'registered' => t('animal.registration_done')]; +$registrationLabels = ['unknown' => t('animal.registration_unknown'),'pending' => t('animal.registration_change_pending'),'change_pending' => t('animal.registration_change_pending'),'registered' => t('animal.registration_done'),'owner_changed' => t('animal.registration_owner_changed')]; $registration = $animal['identification_registration_status'] ?? 'unknown'; -$registrationClass = $registration === 'registered' ? 'bg-green-lt' : ($registration === 'pending' ? 'bg-yellow-lt' : 'bg-secondary-lt'); ?> +$registrationClass = in_array($registration, ['registered', 'owner_changed'], true) ? 'bg-green-lt' : (in_array($registration, ['pending', 'change_pending'], true) ? 'bg-yellow-lt' : 'bg-secondary-lt'); ?>
- 1.0.0-rc.25 -

Portées modifiables et fiables

-

La création d’une fratrie ne dépend plus d’un ancien traitement de photo et - son historique reste compatible avec les bases existantes.

+ 1.0.0-rc.26 +

Données animales fiabilisées

+

Les salles configurables, les états I-CAD et le suivi vaccinal restent + cohérents sur les installations neuves comme sur les bases historiques.

diff --git a/migrations/097_remove_legacy_room_constraint.sql b/migrations/097_remove_legacy_room_constraint.sql new file mode 100644 index 0000000..0291a07 --- /dev/null +++ b/migrations/097_remove_legacy_room_constraint.sql @@ -0,0 +1,3 @@ +-- La reconstruction conditionnelle est réalisée par Migrations::run afin de +-- préserver toutes les colonnes des bases historiques. +SELECT 1; diff --git a/resources/lang/en.php b/resources/lang/en.php index b947b62..76d8dfd 100644 --- a/resources/lang/en.php +++ b/resources/lang/en.php @@ -210,4 +210,9 @@ return [ 'litter.detach'=>'Detach', 'litter.detach_confirm'=>'Remove this cat from the litter? Its record and data will be preserved.', 'litter.parent_is_kitten'=>'A parent cannot also be a kitten in this litter.', + 'animal.form.change_pending'=>'Ownership change pending', + 'animal.form.owner_changed'=>'Owner changed', + 'animal.registration_change_pending'=>'I-CAD: ownership change pending', + 'animal.registration_owner_changed'=>'I-CAD: owner changed', + 'animal.veterinary_clinic'=>'Veterinary clinic', ]; diff --git a/resources/lang/fr.php b/resources/lang/fr.php index 12f4b89..75b6400 100644 --- a/resources/lang/fr.php +++ b/resources/lang/fr.php @@ -210,4 +210,9 @@ return [ 'litter.detach'=>'Détacher', 'litter.detach_confirm'=>'Retirer ce chat de la portée ? Sa fiche et ses données seront conservées.', 'litter.parent_is_kitten'=>'Un parent ne peut pas être également chaton de cette portée.', + 'animal.form.change_pending'=>'En attente de changement', + 'animal.form.owner_changed'=>'Propriétaire changé', + 'animal.registration_change_pending'=>'I-CAD : en attente de changement', + 'animal.registration_owner_changed'=>'I-CAD : propriétaire changé', + 'animal.veterinary_clinic'=>'Clinique vétérinaire', ]; diff --git a/tests/run.php b/tests/run.php index 55b5a7d..8b7051d 100644 --- a/tests/run.php +++ b/tests/run.php @@ -167,6 +167,37 @@ try { $assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'F', 'fiv' => 1])); $assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'M', 'fiv' => 0])); }); + $test('Une salle configurable peut être enregistrée sur un animal', function () use ($db, $assert): void { + $schema = (string) $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='animals'")->fetchColumn(); + $assert(!str_contains($schema, "refuge_room IN ('silver', 'twix')"), 'Ancienne contrainte de salle encore active'); + $code = 'TEST-ROOM-' . bin2hex(random_bytes(3)); + $db->prepare("INSERT INTO animals(name,internal_code,refuge_room) VALUES('Test salle',:code,'nouvelle-salle')")->execute([':code' => $code]); + $id = (int) $db->lastInsertId(); + $assert((string) $db->query('SELECT refuge_room FROM animals WHERE id=' . $id)->fetchColumn() === 'nouvelle-salle'); + $db->prepare('DELETE FROM animals WHERE id=:id')->execute([':id' => $id]); + }); + $test('Les quatre états I-CAD sont acceptés', function () use ($db, $assert): void { + $code = 'TEST-ICAD-' . bin2hex(random_bytes(3)); + $db->prepare("INSERT INTO animals(name,internal_code,identification_registration_status) VALUES('Test I-CAD',:code,'unknown')") + ->execute([':code' => $code]); + $id = (int) $db->lastInsertId(); + foreach (['unknown', 'registered', 'change_pending', 'owner_changed'] as $status) { + $db->prepare('UPDATE animals SET identification_registration_status=:status WHERE id=:id') + ->execute([':status' => $status, ':id' => $id]); + $assert( + (string) $db->query('SELECT identification_registration_status FROM animals WHERE id=' . $id)->fetchColumn() === $status, + 'État I-CAD refusé : ' . $status, + ); + } + $db->prepare('DELETE FROM animals WHERE id=:id')->execute([':id' => $id]); + }); + $test('La vaccination repose sur le cabinet vétérinaire', function () use ($assert): void { + $modal = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_modals.php'); + $summary = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_medical.php'); + $assert(!str_contains($modal, 'name="administered_by_user_id"')); + $assert(!str_contains($modal, 'name="administered_by_name"')); + $assert(str_contains($summary, "\$v['clinic_name']")); + }); $test('Les paramètres sont enregistrés et relus', function () use ($assert): void { AppSettings::save(['association_name' => 'Refuge Test', 'app_language' => 'en'], null); $assert(AppSettings::get('association_name') === 'Refuge Test');