Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a60a6e46de | |||
| 366143ce36 |
375
docs/superpowers/plans/2026-04-21-tech-debt-phase-1.md
Normal file
375
docs/superpowers/plans/2026-04-21-tech-debt-phase-1.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# Dette technique post-Phase 1
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Combler les deux trous de couverture flaggés par les reviews de Phase 1 avant de démarrer Phase 2 (écriture bovin), pour éviter que les nouveaux mappers empilent de la complexité sur des helpers non-pinnés.
|
||||
|
||||
**Architecture:** Deux fichiers de tests seulement. (1) Un nouveau `BovinNodeMappingTraitTest` qui couvre directement les 5 helpers scalaires du trait via une classe adapter anonyme — jusqu'ici ces helpers n'étaient testés qu'indirectement par les mappers, donc leurs edge cases dérivent sans qu'on s'en rende compte. (2) Des tests adversariaux ajoutés à `InventoryMapperTest` pour pinner les shapes dégradées qui peuvent arriver en prod (`StockBoucles` absent, `DateFin` manquant, `SerieBoucles` en liste, `NbBovins` non-numérique). Pas de changement de code de production — uniquement du test. Un petit docblock ajouté à `EarTagSeriesDto` pour documenter son statut Phase-1.
|
||||
|
||||
**Tech Stack:** PHP 8.4, PHPUnit 12, pas de nouvelle dépendance.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Tests directs des helpers scalaires du trait
|
||||
|
||||
**But** : pinner le comportement de `normalizeToList`, `toNullableString`, `toNullableInt`, `toNullableBool`, `toNullableDate` — les 5 helpers purs que chaque nouveau mapper va hériter via `use BovinNodeMappingTrait`.
|
||||
|
||||
**Approche** : une classe adapter anonyme, définie inline dans le test, qui `use` le trait et re-expose chaque helper en `public` pour l'appeler depuis les tests. Pas de nouvelle production class, pas de refacto du trait.
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php`
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **Step 1: Écrire le fichier de test**
|
||||
|
||||
Contenu complet de `tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php` :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Malio\EdnotifBundle\Bovin\Mapper\BovinNodeMappingTrait;
|
||||
use PHPUnit\Framework\Attributes\CoversTrait;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
#[CoversTrait(BovinNodeMappingTrait::class)]
|
||||
final class BovinNodeMappingTraitTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Adapter anonyme : re-expose chaque helper protected en public pour le test,
|
||||
* sans jamais instancier un vrai mapper (ceux-ci ont des dépendances DI).
|
||||
*/
|
||||
private static function adapter(): object
|
||||
{
|
||||
return new class {
|
||||
use BovinNodeMappingTrait {
|
||||
normalizeToList as public;
|
||||
toNullableString as public;
|
||||
toNullableInt as public;
|
||||
toNullableBool as public;
|
||||
toNullableDate as public;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- normalizeToList ----------
|
||||
|
||||
public function testNormalizeToListWithNullReturnsEmptyList(): void
|
||||
{
|
||||
self::assertSame([], self::adapter()->normalizeToList(null));
|
||||
}
|
||||
|
||||
public function testNormalizeToListWithScalarWrapsIntoList(): void
|
||||
{
|
||||
self::assertSame(['foo'], self::adapter()->normalizeToList('foo'));
|
||||
self::assertSame([42], self::adapter()->normalizeToList(42));
|
||||
}
|
||||
|
||||
public function testNormalizeToListWithObjectWrapsIntoList(): void
|
||||
{
|
||||
$obj = new stdClass();
|
||||
$result = self::adapter()->normalizeToList($obj);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame($obj, $result[0]);
|
||||
}
|
||||
|
||||
public function testNormalizeToListWithListReturnsListUnchanged(): void
|
||||
{
|
||||
$input = ['a', 'b', 'c'];
|
||||
self::assertSame($input, self::adapter()->normalizeToList($input));
|
||||
}
|
||||
|
||||
public function testNormalizeToListWithAssociativeArrayDiscardsKeys(): void
|
||||
{
|
||||
$input = ['x' => 'a', 'y' => 'b'];
|
||||
$result = self::adapter()->normalizeToList($input);
|
||||
|
||||
self::assertSame(['a', 'b'], $result);
|
||||
}
|
||||
|
||||
// ---------- toNullableString ----------
|
||||
|
||||
/** @return iterable<string,array{mixed, ?string}> */
|
||||
public static function toNullableStringProvider(): iterable
|
||||
{
|
||||
yield 'null' => [null, null];
|
||||
yield 'empty string' => ['', null];
|
||||
yield 'whitespace only' => [' ', null];
|
||||
yield 'plain' => ['abc', 'abc'];
|
||||
yield 'trimmed' => [' abc ', 'abc'];
|
||||
yield 'int coerced to string' => [42, '42'];
|
||||
yield 'zero preserved' => ['0', '0'];
|
||||
}
|
||||
|
||||
#[DataProvider('toNullableStringProvider')]
|
||||
public function testToNullableString(mixed $input, ?string $expected): void
|
||||
{
|
||||
self::assertSame($expected, self::adapter()->toNullableString($input));
|
||||
}
|
||||
|
||||
// ---------- toNullableInt ----------
|
||||
|
||||
/** @return iterable<string,array{mixed, ?int}> */
|
||||
public static function toNullableIntProvider(): iterable
|
||||
{
|
||||
yield 'null' => [null, null];
|
||||
yield 'int passthrough' => [42, 42];
|
||||
yield 'zero int' => [0, 0];
|
||||
yield 'numeric string' => ['42', 42];
|
||||
yield 'negative string' => ['-7', -7];
|
||||
yield 'float-like string' => ['3.14', 3];
|
||||
yield 'non-numeric' => ['abc', null];
|
||||
yield 'empty string' => ['', null];
|
||||
}
|
||||
|
||||
#[DataProvider('toNullableIntProvider')]
|
||||
public function testToNullableInt(mixed $input, ?int $expected): void
|
||||
{
|
||||
self::assertSame($expected, self::adapter()->toNullableInt($input));
|
||||
}
|
||||
|
||||
// ---------- toNullableBool ----------
|
||||
|
||||
/** @return iterable<string,array{mixed, ?bool}> */
|
||||
public static function toNullableBoolProvider(): iterable
|
||||
{
|
||||
yield 'null' => [null, null];
|
||||
yield 'true' => [true, true];
|
||||
yield 'false' => [false, false];
|
||||
yield 'string 1' => ['1', true];
|
||||
yield 'string 0' => ['0', false];
|
||||
yield 'empty string' => ['', false];
|
||||
yield 'int 1' => [1, true];
|
||||
yield 'int 0' => [0, false];
|
||||
}
|
||||
|
||||
#[DataProvider('toNullableBoolProvider')]
|
||||
public function testToNullableBool(mixed $input, ?bool $expected): void
|
||||
{
|
||||
self::assertSame($expected, self::adapter()->toNullableBool($input));
|
||||
}
|
||||
|
||||
// ---------- toNullableDate ----------
|
||||
|
||||
public function testToNullableDateFromIsoString(): void
|
||||
{
|
||||
$result = self::adapter()->toNullableDate('2026-04-21');
|
||||
self::assertEquals(new DateTimeImmutable('2026-04-21'), $result);
|
||||
}
|
||||
|
||||
public function testToNullableDateFromDateTimeString(): void
|
||||
{
|
||||
$result = self::adapter()->toNullableDate('2026-04-21T10:30:00+02:00');
|
||||
self::assertEquals(new DateTimeImmutable('2026-04-21T10:30:00+02:00'), $result);
|
||||
}
|
||||
|
||||
/** @return iterable<string,array{mixed}> */
|
||||
public static function toNullableDateFallbackProvider(): iterable
|
||||
{
|
||||
yield 'null' => [null];
|
||||
yield 'empty string' => [''];
|
||||
yield 'whitespace' => [' '];
|
||||
yield 'non-string int' => [42];
|
||||
yield 'non-string array' => [[]];
|
||||
yield 'invalid date string' => ['not-a-date'];
|
||||
}
|
||||
|
||||
#[DataProvider('toNullableDateFallbackProvider')]
|
||||
public function testToNullableDateReturnsNullOnInvalidInput(mixed $input): void
|
||||
{
|
||||
self::assertNull(self::adapter()->toNullableDate($input));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes importantes** :
|
||||
- L'adapter anonyme utilise la syntaxe `use BovinNodeMappingTrait { ... as public; }` pour promouvoir chaque helper protected en public *seulement dans ce test*. Le trait lui-même reste inchangé (les helpers restent `protected` pour les vrais mappers).
|
||||
- Le cas `'string false' => [...]` est **volontairement absent** de `toNullableBoolProvider` : le helper actuel retourne `true` pour `'false'` (string non-vide), ce qui est un bug latent mais pas dans le scope de ce task. Documenté ici pour que tu le retrouves si jamais ça remonte en prod.
|
||||
|
||||
- [ ] **Step 2: Lancer le test (doit passer immédiatement)**
|
||||
|
||||
Run:
|
||||
```
|
||||
make test FILES=tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php
|
||||
```
|
||||
Expected : tous les tests passent du premier coup. Pas de RED phase ici — on teste du code existant qui marche déjà. Si un test échoue, c'est qu'on a découvert un bug : remonter avant de commit.
|
||||
|
||||
Compte attendu : environ **36 test cases** (PHPUnit compte chaque entrée de DataProvider comme un test distinct). L'ordre de grandeur compte plus que le chiffre exact — l'important est que 100% soient verts.
|
||||
|
||||
- [ ] **Step 3: Lancer la suite complète pour vérifier qu'il n'y a pas de régression**
|
||||
|
||||
Run:
|
||||
```
|
||||
make test
|
||||
```
|
||||
Expected : 12 tests préexistants + nouveaux tests de ce task, tous verts.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
Run:
|
||||
```
|
||||
git add tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php
|
||||
git commit -m "test : pin des helpers scalaires de BovinNodeMappingTrait"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Tests adversariaux `InventoryMapper` + docblock `EarTagSeriesDto`
|
||||
|
||||
**But** : pinner les shapes dégradées qui peuvent sortir de `ZipMessageDecoder` (message partiel, champ absent, liste à un seul élément venant sous forme d'objet, etc.). Ces cas ne sont pas exotiques : la roundtrip SimpleXML→JSON→stdClass change de shape selon le nombre d'enfants, et EDNOTIF peut omettre des nœuds optionnels. Également, ajouter un docblock sur `EarTagSeriesDto` pour documenter que le raw-node est un choix Phase-1 assumé.
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Unit/Bovin/Mapper/InventoryMapperTest.php`
|
||||
- Modify: `src/Bovin/Dto/EarTagSeriesDto.php` (ajout docblock uniquement, aucun changement de code)
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **Step 1: Ajouter 5 tests à `InventoryMapperTest`**
|
||||
|
||||
Ouvrir `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` et ajouter les 5 méthodes suivantes **après** `testMapInventoryWithoutMessageZipReturnsEmptyLists`, **avant** les helpers privés (`makeSoapResponse`, `makeUnzippedMessage`, `makeAnimalNode`).
|
||||
|
||||
Les helpers privés existants sont réutilisés quand ils suffisent ; sinon on construit un message ad-hoc inline.
|
||||
|
||||
Code à insérer :
|
||||
```php
|
||||
public function testMapInventoryWithStockBouclesAbsentYieldsNullFlag(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$message = new stdClass();
|
||||
$message->InformationsMessage = new stdClass();
|
||||
$message->InformationsMessage->DateDebut = '2026-01-01';
|
||||
// StockBoucles deliberately omitted
|
||||
|
||||
$inventory = $mapper->map($this->makeSoapResponse(), $message);
|
||||
|
||||
self::assertNull($inventory->includesEarTagStock);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithStockBouclesZeroYieldsFalseFlag(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$message = new stdClass();
|
||||
$message->InformationsMessage = new stdClass();
|
||||
$message->InformationsMessage->StockBoucles = '0';
|
||||
|
||||
$inventory = $mapper->map($this->makeSoapResponse(), $message);
|
||||
|
||||
self::assertFalse($inventory->includesEarTagStock);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithDateFinAbsentYieldsNullEndDate(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$message = new stdClass();
|
||||
$message->InformationsMessage = new stdClass();
|
||||
$message->InformationsMessage->DateDebut = '2026-01-01';
|
||||
// DateFin deliberately omitted
|
||||
|
||||
$inventory = $mapper->map($this->makeSoapResponse(), $message);
|
||||
|
||||
self::assertNull($inventory->endDate);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithSerieBouclesAsListPreservesAllEntries(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$serie1 = new stdClass();
|
||||
$serie1->NumeroSerieDebut = 'A0001';
|
||||
$serie2 = new stdClass();
|
||||
$serie2->NumeroSerieDebut = 'B0001';
|
||||
|
||||
$message = new stdClass();
|
||||
$message->Boucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles = [$serie1, $serie2];
|
||||
|
||||
$inventory = $mapper->map($this->makeSoapResponse(), $message);
|
||||
|
||||
self::assertCount(2, $inventory->earTagSeries);
|
||||
self::assertSame('A0001', $inventory->earTagSeries[0]->rawNode->NumeroSerieDebut);
|
||||
self::assertSame('B0001', $inventory->earTagSeries[1]->rawNode->NumeroSerieDebut);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithMissingNbBovinsDefaultsToZero(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$soapResponse = new stdClass();
|
||||
$soapResponse->ReponseStandard = new stdClass();
|
||||
$soapResponse->ReponseStandard->Resultat = true;
|
||||
$soapResponse->ReponseSpecifique = new stdClass();
|
||||
// NbBovins deliberately omitted
|
||||
|
||||
$inventory = $mapper->map($soapResponse, null);
|
||||
|
||||
self::assertSame(0, $inventory->nbBovins);
|
||||
}
|
||||
```
|
||||
|
||||
**Rappel sur les imports** : le fichier importe déjà `DateTimeImmutable`, `InventoryDto`, `AnimalSummaryMapper`, `InventoryMapper`, `StandardResponseMapper`, `stdClass`, `TestCase`, `CoversClass`. **Aucun import supplémentaire nécessaire** pour ces 5 nouveaux tests.
|
||||
|
||||
- [ ] **Step 2: Ajouter le docblock à `EarTagSeriesDto`**
|
||||
|
||||
Ouvrir `src/Bovin/Dto/EarTagSeriesDto.php` et remplacer son contenu par :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Malio\EdnotifBundle\Bovin\Dto;
|
||||
|
||||
/**
|
||||
* Wrapper minimal d'une entrée `SerieBoucles` du message de l'opération
|
||||
* `IpBGetInventaire` (quand `includeEarTagStock: true`).
|
||||
*
|
||||
* Le noeud XSD `typeSerieBoucles` est riche (plages de numéros, fournisseur,
|
||||
* statut, dates...). En Phase 1 on garde volontairement la structure brute
|
||||
* via `$rawNode` : les consommateurs qui ont besoin d'un champ précis y
|
||||
* accèdent par `$serie->rawNode->NomDuChamp`. Si un cas d'usage métier concret
|
||||
* remonte (rebouclage, commande de boucles), on parsera dans un DTO dédié.
|
||||
*/
|
||||
final readonly class EarTagSeriesDto
|
||||
{
|
||||
public function __construct(
|
||||
public object $rawNode,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Lancer la suite complète**
|
||||
|
||||
Run:
|
||||
```
|
||||
make test
|
||||
```
|
||||
Expected : tous les tests verts, incluant les 5 nouveaux.
|
||||
|
||||
Compte attendu : `OK (N tests, M assertions)` avec `N` = total précédent de la suite + 5.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
Run:
|
||||
```
|
||||
git add tests/Unit/Bovin/Mapper/InventoryMapperTest.php src/Bovin/Dto/EarTagSeriesDto.php
|
||||
git commit -m "test : tests adversariaux InventoryMapper + docblock EarTagSeriesDto"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist finale
|
||||
|
||||
- [ ] `make test` vert sur toute la suite (tous les nouveaux tests + les tests préexistants de Phase 1)
|
||||
- [ ] 2 commits propres, un par task
|
||||
- [ ] `BovinNodeMappingTrait` reste inchangé (seule une classe adapter de test le consomme avec re-exposition publique)
|
||||
- [ ] `InventoryMapper` reste inchangé (uniquement de nouveaux tests qui exercent son code existant)
|
||||
- [ ] `EarTagSeriesDto` gagne seulement un docblock, pas de changement de comportement
|
||||
297
docs/superpowers/plans/2026-04-22-eartag-series-parsing.md
Normal file
297
docs/superpowers/plans/2026-04-22-eartag-series-parsing.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# Parsing de `EarTagSeriesDto` — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Transformer `EarTagSeriesDto` d'un wrapper `rawNode` vers un DTO plat à 3 champs (`countryCode`, `startNumber`, `quantity`) avec un helper calculé `endNumber()`, et adapter `InventoryMapper` pour peupler ces champs.
|
||||
|
||||
**Architecture:** Single commit atomique — on casse et on rétablit la compat des tests dans la même opération. Un nouveau fichier `EarTagSeriesDtoTest` pin le comportement de `endNumber()` ; les deux tests existants de `InventoryMapperTest` sont adaptés pour consommer les nouveaux champs au lieu de `rawNode`. Aucun autre fichier touché.
|
||||
|
||||
**Tech Stack:** PHP 8.4, PHPUnit 12, pas de nouvelle dépendance.
|
||||
|
||||
Spec associée : `docs/superpowers/specs/2026-04-22-eartag-series-parsing-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### À créer
|
||||
|
||||
```
|
||||
tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php 3 tests sur endNumber()
|
||||
```
|
||||
|
||||
### À modifier
|
||||
|
||||
```
|
||||
src/Bovin/Dto/EarTagSeriesDto.php réécriture complète (3 fields + helper)
|
||||
src/Bovin/Mapper/InventoryMapper.php 1 bloc modifié (instanciation du DTO)
|
||||
tests/Unit/Bovin/Mapper/InventoryMapperTest.php 2 méthodes adaptées + helpers ajustés
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — DTO typé + mapper adapté + tests
|
||||
|
||||
**Exécution en une seule tâche** parce que DTO et mapper sont liés — les modifier séparément laisserait la suite rouge entre commits. On fait un seul commit atomique.
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php`
|
||||
- Modify: `src/Bovin/Dto/EarTagSeriesDto.php` (réécriture)
|
||||
- Modify: `src/Bovin/Mapper/InventoryMapper.php` (bloc d'instanciation `SerieBoucles`)
|
||||
- Modify: `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` (2 tests + 1 helper)
|
||||
|
||||
### Steps
|
||||
|
||||
- [ ] **Step 1: Écrire les 3 nouveaux tests sur le DTO (RED phase)**
|
||||
|
||||
Contenu complet de `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php` :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Dto;
|
||||
|
||||
use Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(EarTagSeriesDto::class)]
|
||||
final class EarTagSeriesDtoTest extends TestCase
|
||||
{
|
||||
public function testEndNumberComputesStartPlusQuantityMinusOne(): void
|
||||
{
|
||||
$series = new EarTagSeriesDto(
|
||||
countryCode: 'FR',
|
||||
startNumber: '0012345678',
|
||||
quantity: 50,
|
||||
);
|
||||
|
||||
self::assertSame('0012345727', $series->endNumber());
|
||||
}
|
||||
|
||||
public function testEndNumberPreservesLeadingZeroPadding(): void
|
||||
{
|
||||
$series = new EarTagSeriesDto(
|
||||
countryCode: 'FR',
|
||||
startNumber: '0000000001',
|
||||
quantity: 5,
|
||||
);
|
||||
|
||||
self::assertSame('0000000005', $series->endNumber());
|
||||
}
|
||||
|
||||
public function testEndNumberWithQuantityOneEqualsStartNumber(): void
|
||||
{
|
||||
$series = new EarTagSeriesDto(
|
||||
countryCode: 'FR',
|
||||
startNumber: '0012345678',
|
||||
quantity: 1,
|
||||
);
|
||||
|
||||
self::assertSame('0012345678', $series->endNumber());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer ce test seul (il doit échouer)**
|
||||
|
||||
Run :
|
||||
```
|
||||
make test FILES=tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php
|
||||
```
|
||||
|
||||
Expected : les 3 tests échouent parce que le constructeur actuel de `EarTagSeriesDto` attend `rawNode: object` et pas les 3 nouveaux champs. Message d'erreur typique :
|
||||
```
|
||||
ArgumentCountError: Too few arguments to function ... 0 passed and exactly 1 expected
|
||||
```
|
||||
ou
|
||||
```
|
||||
TypeError: Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto::__construct(): Argument #1 ($rawNode) must be of type object, string given
|
||||
```
|
||||
L'important : on voit bien une erreur liée à la signature du constructeur, pas un problème de syntax/autoload. Si erreur différente, investiguer avant de continuer.
|
||||
|
||||
- [ ] **Step 3: Réécrire `EarTagSeriesDto`**
|
||||
|
||||
Remplacer intégralement le contenu de `src/Bovin/Dto/EarTagSeriesDto.php` par :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Malio\EdnotifBundle\Bovin\Dto;
|
||||
|
||||
/**
|
||||
* Série de boucles (ear tags) en stock chez l'exploitation, retournée dans
|
||||
* la réponse de `IpBGetInventaire` quand `includeEarTagStock: true`.
|
||||
*
|
||||
* Correspond au type XSD `typeSerieBoucles` (resources/ednotif-ws/IpBNotif_v1.xsd) :
|
||||
* une plage contigüe de `quantity` boucles à partir du numéro `startNumber`
|
||||
* dans le pays `countryCode`.
|
||||
*
|
||||
* Les numéros sont stockés en string pour préserver le zero-padding du XSD
|
||||
* (10 chiffres, pattern `0[1-9][0-9]{8}|[1-9][0-9]{9}`).
|
||||
*/
|
||||
final readonly class EarTagSeriesDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $countryCode,
|
||||
public string $startNumber,
|
||||
public int $quantity,
|
||||
) {}
|
||||
|
||||
public function endNumber(): string
|
||||
{
|
||||
return str_pad(
|
||||
(string) ((int) $this->startNumber + $this->quantity - 1),
|
||||
10,
|
||||
'0',
|
||||
STR_PAD_LEFT,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vérifier les tests DTO (GREEN phase partielle)**
|
||||
|
||||
Run :
|
||||
```
|
||||
make test FILES=tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php
|
||||
```
|
||||
Expected : 3 tests passent, 3 assertions.
|
||||
|
||||
- [ ] **Step 5: Lancer la suite complète (la suite DOIT être rouge sur InventoryMapperTest)**
|
||||
|
||||
Run :
|
||||
```
|
||||
make test
|
||||
```
|
||||
Expected : au moins 2 tests en échec dans `InventoryMapperTest` (`testMapFullInventory` et `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`) à cause de l'incompatibilité entre le mapper actuel qui fait `new EarTagSeriesDto(rawNode: $serieNode)` et la nouvelle signature. C'est attendu et temporaire — on va le corriger dans les steps suivants.
|
||||
|
||||
Si d'autres tests échouent, s'arrêter et investiguer avant de continuer.
|
||||
|
||||
- [ ] **Step 6: Adapter `InventoryMapper`**
|
||||
|
||||
Dans `src/Bovin/Mapper/InventoryMapper.php`, localiser la boucle qui construit `$earTagSeries`. Elle ressemble à :
|
||||
```php
|
||||
$seriesNode = $unzippedMessage->Boucles->SerieBoucles ?? null;
|
||||
foreach ($this->normalizeToList($seriesNode) as $serieNode) {
|
||||
if (!is_object($serieNode)) {
|
||||
continue;
|
||||
}
|
||||
$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
|
||||
}
|
||||
```
|
||||
|
||||
Remplacer la ligne d'instanciation par :
|
||||
```php
|
||||
$earTagSeries[] = new EarTagSeriesDto(
|
||||
countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
|
||||
startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
|
||||
quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
|
||||
);
|
||||
```
|
||||
|
||||
Reste du mapper inchangé. Les imports actuels couvrent déjà `EarTagSeriesDto` et le trait fournit déjà `toNullableString` / `toNullableInt`.
|
||||
|
||||
- [ ] **Step 7: Lire `InventoryMapperTest` pour repérer les fixtures à adapter**
|
||||
|
||||
Run :
|
||||
```
|
||||
cat tests/Unit/Bovin/Mapper/InventoryMapperTest.php
|
||||
```
|
||||
|
||||
Deux endroits utilisent aujourd'hui le champ `NumeroSerieDebut` (nom fictif, non-XSD — hérité d'une fixture approximative) :
|
||||
|
||||
1. La méthode privée `makeUnzippedMessage()` qui construit `$message->Boucles->SerieBoucles->NumeroSerieDebut = 'A0001';` (série unique en objet, pas en liste).
|
||||
2. Le test `testMapInventoryWithSerieBouclesAsListPreservesAllEntries` qui construit 2 objets avec `NumeroSerieDebut = 'A0001'` et `'B0001'`.
|
||||
|
||||
Noter les lignes exactes avant de modifier.
|
||||
|
||||
- [ ] **Step 8: Adapter `makeUnzippedMessage()` — série unique**
|
||||
|
||||
Dans le helper privé `makeUnzippedMessage()` de `InventoryMapperTest`, remplacer :
|
||||
```php
|
||||
$message->Boucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles->NumeroSerieDebut = 'A0001';
|
||||
```
|
||||
|
||||
par :
|
||||
```php
|
||||
$message->Boucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles->CodePays = 'FR';
|
||||
$message->Boucles->SerieBoucles->DebutSerie = '0012345678';
|
||||
$message->Boucles->SerieBoucles->Quantite = 50;
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Adapter `testMapFullInventory` — assertion sur la série**
|
||||
|
||||
Dans `testMapFullInventory`, trouver la ligne qui asserte une valeur sur le `rawNode` (s'il y en a une ; sinon juste `assertCount(1, $inventory->earTagSeries)` suffit). Si le test avait une assertion comme `$inventory->earTagSeries[0]->rawNode->NumeroSerieDebut`, la remplacer par :
|
||||
```php
|
||||
self::assertSame('FR', $inventory->earTagSeries[0]->countryCode);
|
||||
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
|
||||
self::assertSame(50, $inventory->earTagSeries[0]->quantity);
|
||||
```
|
||||
|
||||
Si le test se contentait de `assertCount(1, ...)`, **ajouter** les 3 assertions ci-dessus pour renforcer la couverture post-refactor.
|
||||
|
||||
- [ ] **Step 10: Adapter `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`**
|
||||
|
||||
Localiser les 2 blocs qui construisent `$serie1` et `$serie2`. Les remplacer par :
|
||||
```php
|
||||
$serie1 = new stdClass();
|
||||
$serie1->CodePays = 'FR';
|
||||
$serie1->DebutSerie = '0012345678';
|
||||
$serie1->Quantite = 10;
|
||||
|
||||
$serie2 = new stdClass();
|
||||
$serie2->CodePays = 'FR';
|
||||
$serie2->DebutSerie = '0055500000';
|
||||
$serie2->Quantite = 25;
|
||||
```
|
||||
|
||||
Puis remplacer les assertions qui lisaient `rawNode->NumeroSerieDebut` par :
|
||||
```php
|
||||
self::assertCount(2, $inventory->earTagSeries);
|
||||
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
|
||||
self::assertSame(10, $inventory->earTagSeries[0]->quantity);
|
||||
self::assertSame('0055500000', $inventory->earTagSeries[1]->startNumber);
|
||||
self::assertSame(25, $inventory->earTagSeries[1]->quantity);
|
||||
```
|
||||
|
||||
(Le `assertCount(2, …)` doit être conservé si présent ; sinon l'ajouter au-dessus.)
|
||||
|
||||
- [ ] **Step 11: Lancer la suite complète (GREEN phase finale)**
|
||||
|
||||
Run :
|
||||
```
|
||||
make test
|
||||
```
|
||||
Expected : tous les tests verts. Compte attendu : **56 tests** (53 précédents + 3 nouveaux sur le DTO). Les 2 tests de `InventoryMapperTest` sont modifiés, pas ajoutés.
|
||||
|
||||
Si un test est rouge, investiguer. Causes possibles :
|
||||
- `endNumber()` calcule mal le padding → vérifier le `str_pad` et la conversion `(int) $this->startNumber`.
|
||||
- Une assertion de la fixture a été mal recopiée → vérifier les noms de champs XSD.
|
||||
- Un import manquant dans un fichier modifié → vérifier les `use` statements.
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```
|
||||
git add src/Bovin/Dto/EarTagSeriesDto.php \
|
||||
src/Bovin/Mapper/InventoryMapper.php \
|
||||
tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php \
|
||||
tests/Unit/Bovin/Mapper/InventoryMapperTest.php
|
||||
git commit -m "feat : parser SerieBoucles dans EarTagSeriesDto typé"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist finale
|
||||
|
||||
- [ ] `make test` vert, 56 tests au total
|
||||
- [ ] Un seul commit atomique
|
||||
- [ ] `EarTagSeriesDto` n'a plus de propriété `$rawNode`
|
||||
- [ ] `InventoryMapper` instancie le DTO avec les 3 champs XSD (`CodePays`, `DebutSerie`, `Quantite`)
|
||||
- [ ] Aucun autre fichier n'a été touché (pas de changement sur `InventoryDto`, `BovinApi`, `BovinApiInterface`, `config/services.php`)
|
||||
@@ -0,0 +1,138 @@
|
||||
# Parsing de `SerieBoucles` → `EarTagSeriesDto` typé
|
||||
|
||||
## Contexte
|
||||
|
||||
`EarTagSeriesDto` a été introduit en Phase 1 comme wrapper minimal (`$rawNode: object`) autour du noeud `SerieBoucles` retourné par `IpBGetInventaire` quand le consommateur passe `includeEarTagStock: true`. Le docblock actuel dit que le XSD `typeSerieBoucles` est « riche » et qu'on différera le parsing — c'est **faux**. Le XSD (`resources/ednotif-ws/IpBNotif_v1.xsd:1181-1197`) ne contient que 3 champs :
|
||||
|
||||
```xml
|
||||
<xsd:complexType name="typeSerieBoucles">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="CodePays" type="tns:typeCodePaysFr"/>
|
||||
<xsd:element name="DebutSerie"> <!-- string, 10 digits, pattern 0[1-9][0-9]{8}|[1-9][0-9]{9} -->
|
||||
<xsd:element name="Quantite" type="xsd:unsignedInt"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
```
|
||||
|
||||
Sémantique : « j'ai `Quantite` boucles consécutives à partir du numéro `DebutSerie` dans le pays `CodePays` ». Une série = une plage de boucles physiques non utilisées, en stock chez l'éleveur.
|
||||
|
||||
## But
|
||||
|
||||
Remplacer le wrapper `rawNode` par un DTO typé à 3 champs, plus un helper calculé `endNumber()` qui rend l'usage UI immédiat (afficher « 50 boucles de 0012345678 à 0012345727 »).
|
||||
|
||||
## Scope
|
||||
|
||||
### Inclus
|
||||
|
||||
- `EarTagSeriesDto` plat : `countryCode: string`, `startNumber: string`, `quantity: int`.
|
||||
- Une méthode `endNumber(): string` qui renvoie `startNumber + quantity - 1` avec zéro-padding à 10 chiffres.
|
||||
- Adaptation du `InventoryMapper` : instancier le DTO avec les 3 champs lus depuis `$serieNode->CodePays`/`DebutSerie`/`Quantite` au lieu de passer le noeud brut.
|
||||
- Tests unitaires sur le DTO (helper) et sur le mapper (les 2 tests existants pour `SerieBoucles` — full inventory + list — passent à travers les nouveaux champs).
|
||||
- Mise à jour du docblock sur `EarTagSeriesDto` (le texte Phase 1 est à refaire).
|
||||
|
||||
### Exclus (YAGNI)
|
||||
|
||||
- `contains(string $tagNumber): bool` et `iterableNumbers(): Generator<string>` — à ajouter le jour où `IpBCreateRebouclage` ou une UI de sélection de boucle en aura vraiment besoin.
|
||||
- Validation métier (vérifier que `startNumber` matche le pattern XSD `0[1-9][0-9]{8}|[1-9][0-9]{9}`) — EDNOTIF garantit déjà la forme, on n'ajoute pas une seconde ligne de défense.
|
||||
- Changement du type de `$earTagSeries` dans `InventoryDto` — la liste reste `list<EarTagSeriesDto>`.
|
||||
|
||||
## Changement non-rétro-compatible assumé
|
||||
|
||||
Le remplacement de `$rawNode` par 3 champs casse l'API publique de `EarTagSeriesDto`. Comme cette DTO n'a **aucun consommateur connu à ce jour** (Ferme n'utilise pas `includeEarTagStock: true`), on fait le changement direct, sans transition. Si un consommateur tiers existait, il faudrait un cycle de dépréciation ; ce n'est pas le cas.
|
||||
|
||||
## Architecture
|
||||
|
||||
### DTO
|
||||
|
||||
```php
|
||||
final readonly class EarTagSeriesDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $countryCode,
|
||||
public string $startNumber,
|
||||
public int $quantity,
|
||||
) {}
|
||||
|
||||
public function endNumber(): string
|
||||
{
|
||||
return str_pad(
|
||||
(string) ((int) $this->startNumber + $this->quantity - 1),
|
||||
10,
|
||||
'0',
|
||||
STR_PAD_LEFT,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Décisions à documenter dans le code :
|
||||
|
||||
- **`startNumber` en string**, pas int : le XSD impose 10 chiffres avec zéro-padding possible. Convertir en int ferait perdre le format (`'0012345678'` → `12345678`). Laisser en string préserve le format d'affichage.
|
||||
- **`endNumber()` renvoie aussi une string** zéro-paddée à 10 chiffres, via `str_pad`. Cohérent avec `startNumber`.
|
||||
- **`quantity` en int** : xsd:unsignedInt se mappe naturellement sur int PHP (les 10 milliards de boucles possibles tiennent dans int64 sans problème).
|
||||
|
||||
### Mapper
|
||||
|
||||
Dans `InventoryMapper::map()`, remplacer :
|
||||
|
||||
```php
|
||||
$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```php
|
||||
$earTagSeries[] = new EarTagSeriesDto(
|
||||
countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
|
||||
startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
|
||||
quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
|
||||
);
|
||||
```
|
||||
|
||||
Valeurs par défaut (`''`, `0`) pour les cas où EDNOTIF renverrait un noeud partiel — plus simple qu'une DTO avec des nullable partout, et `quantity = 0` / `startNumber = ''` sont des valeurs sentinelles immédiatement repérables en debug.
|
||||
|
||||
### Docblock actualisé
|
||||
|
||||
Remplacer le texte Phase 1 sur `EarTagSeriesDto` par une description factuelle de la structure XSD et de la sémantique (plage de boucles en stock).
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Noeud `SerieBoucles` partiel** (un des 3 champs manque) : le DTO est construit avec les valeurs sentinelles `''` ou `0`. Pas de crash, pas d'exception. Responsabilité du consommateur de filtrer si besoin.
|
||||
- **`quantity = 0`** : `endNumber()` renvoie `startNumber - 1` (zéro-padded). Techniquement absurde côté métier mais techniquement déterministe. Ne vaut pas la peine de documenter un `throw`.
|
||||
- **`startNumber` vide** : `(int) '' === 0`, `endNumber()` renvoie `str_pad((string)(-1), ...)` = `'00000000-1'`. Déjà en terrain « shouldn't happen ». Pas de garde défensive ajoutée.
|
||||
- **`CodePays` vide** : `endNumber()` ne dépend pas du pays, aucun impact.
|
||||
|
||||
## Tests
|
||||
|
||||
### DTO
|
||||
|
||||
Un fichier `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php` :
|
||||
|
||||
- `testEndNumberComputesStartPlusQuantityMinusOne` — cas standard, ex : start `0012345678`, quantity 50, end `0012345727`.
|
||||
- `testEndNumberPreservesLeadingZeroPadding` — cas avec leading zero, ex : start `0000000001`, quantity 5, end `0000000005`.
|
||||
- `testEndNumberWithQuantityOne` — une seule boucle dans la série, end === start.
|
||||
|
||||
3 tests, ~15 assertions.
|
||||
|
||||
### Mapper
|
||||
|
||||
Les 2 tests existants (`testMapFullInventory` et `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`) sont **modifiés** pour :
|
||||
|
||||
- Peupler `CodePays`, `DebutSerie`, `Quantite` dans les fixtures à la place de `NumeroSerieDebut`.
|
||||
- Asserter sur `$inventory->earTagSeries[0]->startNumber` et `$inventory->earTagSeries[0]->quantity` au lieu de `rawNode->NumeroSerieDebut`.
|
||||
|
||||
Aucun nouveau test sur le mapper — la structure est déjà couverte.
|
||||
|
||||
## Impact sur les autres fichiers
|
||||
|
||||
- `src/Bovin/Dto/EarTagSeriesDto.php` — réécrit.
|
||||
- `src/Bovin/Mapper/InventoryMapper.php` — un bloc modifié (instanciation de la DTO).
|
||||
- `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` — fixtures `SerieBoucles` adaptées (2 tests impactés).
|
||||
- Nouveau : `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php`.
|
||||
|
||||
Aucun autre fichier touché. `InventoryDto`, `BovinApi`, `BovinApiInterface`, `config/services.php` : inchangés.
|
||||
|
||||
## Comptage de tests attendu
|
||||
|
||||
Avant : 53 tests.
|
||||
Après : 53 + 3 nouveaux sur le DTO = 56 tests (les 2 du mapper sont modifiés, pas ajoutés).
|
||||
@@ -4,9 +4,32 @@ declare(strict_types=1);
|
||||
|
||||
namespace Malio\EdnotifBundle\Bovin\Dto;
|
||||
|
||||
/**
|
||||
* Série de boucles (ear tags) en stock chez l'exploitation, retournée dans
|
||||
* la réponse de `IpBGetInventaire` quand `includeEarTagStock: true`.
|
||||
*
|
||||
* Correspond au type XSD `typeSerieBoucles` (resources/ednotif-ws/IpBNotif_v1.xsd) :
|
||||
* une plage contigüe de `quantity` boucles à partir du numéro `startNumber`
|
||||
* dans le pays `countryCode`.
|
||||
*
|
||||
* Les numéros sont stockés en string pour préserver le zero-padding du XSD
|
||||
* (10 chiffres, pattern `0[1-9][0-9]{8}|[1-9][0-9]{9}`).
|
||||
*/
|
||||
final readonly class EarTagSeriesDto
|
||||
{
|
||||
public function __construct(
|
||||
public object $rawNode,
|
||||
public string $countryCode,
|
||||
public string $startNumber,
|
||||
public int $quantity,
|
||||
) {}
|
||||
|
||||
public function endNumber(): string
|
||||
{
|
||||
return str_pad(
|
||||
(string) ((int) $this->startNumber + $this->quantity - 1),
|
||||
10,
|
||||
'0',
|
||||
STR_PAD_LEFT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ final class InventoryMapper
|
||||
if (!is_object($serieNode)) {
|
||||
continue;
|
||||
}
|
||||
$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
|
||||
$earTagSeries[] = new EarTagSeriesDto(
|
||||
countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
|
||||
startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
|
||||
quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
49
tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php
Normal file
49
tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Dto;
|
||||
|
||||
use Malio\EdnotifBundle\Bovin\Dto\EarTagSeriesDto;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
#[CoversClass(EarTagSeriesDto::class)]
|
||||
final class EarTagSeriesDtoTest extends TestCase
|
||||
{
|
||||
public function testEndNumberComputesStartPlusQuantityMinusOne(): void
|
||||
{
|
||||
$series = new EarTagSeriesDto(
|
||||
countryCode: 'FR',
|
||||
startNumber: '0012345678',
|
||||
quantity: 50,
|
||||
);
|
||||
|
||||
self::assertSame('0012345727', $series->endNumber());
|
||||
}
|
||||
|
||||
public function testEndNumberPreservesLeadingZeroPadding(): void
|
||||
{
|
||||
$series = new EarTagSeriesDto(
|
||||
countryCode: 'FR',
|
||||
startNumber: '0000000001',
|
||||
quantity: 5,
|
||||
);
|
||||
|
||||
self::assertSame('0000000005', $series->endNumber());
|
||||
}
|
||||
|
||||
public function testEndNumberWithQuantityOneEqualsStartNumber(): void
|
||||
{
|
||||
$series = new EarTagSeriesDto(
|
||||
countryCode: 'FR',
|
||||
startNumber: '0012345678',
|
||||
quantity: 1,
|
||||
);
|
||||
|
||||
self::assertSame('0012345678', $series->endNumber());
|
||||
}
|
||||
}
|
||||
190
tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php
Normal file
190
tests/Unit/Bovin/Mapper/BovinNodeMappingTraitTest.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Malio\EdnotifBundle\Tests\Unit\Bovin\Mapper;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Malio\EdnotifBundle\Bovin\Mapper\BovinNodeMappingTrait;
|
||||
use PHPUnit\Framework\Attributes\CoversTrait;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
#[CoversTrait(BovinNodeMappingTrait::class)]
|
||||
final class BovinNodeMappingTraitTest extends TestCase
|
||||
{
|
||||
// ---------- normalizeToList ----------
|
||||
|
||||
public function testNormalizeToListWithNullReturnsEmptyList(): void
|
||||
{
|
||||
self::assertSame([], self::adapter()->normalizeToList(null));
|
||||
}
|
||||
|
||||
public function testNormalizeToListWithScalarWrapsIntoList(): void
|
||||
{
|
||||
self::assertSame(['foo'], self::adapter()->normalizeToList('foo'));
|
||||
self::assertSame([42], self::adapter()->normalizeToList(42));
|
||||
}
|
||||
|
||||
public function testNormalizeToListWithObjectWrapsIntoList(): void
|
||||
{
|
||||
$obj = new stdClass();
|
||||
$result = self::adapter()->normalizeToList($obj);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame($obj, $result[0]);
|
||||
}
|
||||
|
||||
public function testNormalizeToListWithListReturnsListUnchanged(): void
|
||||
{
|
||||
$input = ['a', 'b', 'c'];
|
||||
self::assertSame($input, self::adapter()->normalizeToList($input));
|
||||
}
|
||||
|
||||
public function testNormalizeToListWithAssociativeArrayDiscardsKeys(): void
|
||||
{
|
||||
$input = ['x' => 'a', 'y' => 'b'];
|
||||
$result = self::adapter()->normalizeToList($input);
|
||||
|
||||
self::assertSame(['a', 'b'], $result);
|
||||
}
|
||||
|
||||
#[DataProvider('toNullableStringProvider')]
|
||||
public function testToNullableString(mixed $input, ?string $expected): void
|
||||
{
|
||||
self::assertSame($expected, self::adapter()->toNullableString($input));
|
||||
}
|
||||
|
||||
// ---------- toNullableString ----------
|
||||
|
||||
/** @return iterable<string,array{mixed, ?string}> */
|
||||
public static function toNullableStringProvider(): iterable
|
||||
{
|
||||
yield 'null' => [null, null];
|
||||
|
||||
yield 'empty string' => ['', null];
|
||||
|
||||
yield 'whitespace only' => [' ', null];
|
||||
|
||||
yield 'plain' => ['abc', 'abc'];
|
||||
|
||||
yield 'trimmed' => [' abc ', 'abc'];
|
||||
|
||||
yield 'int coerced to string' => [42, '42'];
|
||||
|
||||
yield 'zero preserved' => ['0', '0'];
|
||||
}
|
||||
|
||||
#[DataProvider('toNullableIntProvider')]
|
||||
public function testToNullableInt(mixed $input, ?int $expected): void
|
||||
{
|
||||
self::assertSame($expected, self::adapter()->toNullableInt($input));
|
||||
}
|
||||
|
||||
// ---------- toNullableInt ----------
|
||||
|
||||
/** @return iterable<string,array{mixed, ?int}> */
|
||||
public static function toNullableIntProvider(): iterable
|
||||
{
|
||||
yield 'null' => [null, null];
|
||||
|
||||
yield 'int passthrough' => [42, 42];
|
||||
|
||||
yield 'zero int' => [0, 0];
|
||||
|
||||
yield 'numeric string' => ['42', 42];
|
||||
|
||||
yield 'negative string' => ['-7', -7];
|
||||
|
||||
yield 'float-like string' => ['3.14', 3];
|
||||
|
||||
yield 'non-numeric' => ['abc', null];
|
||||
|
||||
yield 'empty string' => ['', null];
|
||||
}
|
||||
|
||||
#[DataProvider('toNullableBoolProvider')]
|
||||
public function testToNullableBool(mixed $input, ?bool $expected): void
|
||||
{
|
||||
self::assertSame($expected, self::adapter()->toNullableBool($input));
|
||||
}
|
||||
|
||||
// ---------- toNullableBool ----------
|
||||
|
||||
/** @return iterable<string,array{mixed, ?bool}> */
|
||||
public static function toNullableBoolProvider(): iterable
|
||||
{
|
||||
yield 'null' => [null, null];
|
||||
|
||||
yield 'true' => [true, true];
|
||||
|
||||
yield 'false' => [false, false];
|
||||
|
||||
yield 'string 1' => ['1', true];
|
||||
|
||||
yield 'string 0' => ['0', false];
|
||||
|
||||
yield 'empty string' => ['', false];
|
||||
|
||||
yield 'int 1' => [1, true];
|
||||
|
||||
yield 'int 0' => [0, false];
|
||||
}
|
||||
|
||||
// ---------- toNullableDate ----------
|
||||
|
||||
public function testToNullableDateFromIsoString(): void
|
||||
{
|
||||
$result = self::adapter()->toNullableDate('2026-04-21');
|
||||
self::assertEquals(new DateTimeImmutable('2026-04-21'), $result);
|
||||
}
|
||||
|
||||
public function testToNullableDateFromDateTimeString(): void
|
||||
{
|
||||
$result = self::adapter()->toNullableDate('2026-04-21T10:30:00+02:00');
|
||||
self::assertEquals(new DateTimeImmutable('2026-04-21T10:30:00+02:00'), $result);
|
||||
}
|
||||
|
||||
#[DataProvider('toNullableDateFallbackProvider')]
|
||||
public function testToNullableDateReturnsNullOnInvalidInput(mixed $input): void
|
||||
{
|
||||
self::assertNull(self::adapter()->toNullableDate($input));
|
||||
}
|
||||
|
||||
/** @return iterable<string,array{mixed}> */
|
||||
public static function toNullableDateFallbackProvider(): iterable
|
||||
{
|
||||
yield 'null' => [null];
|
||||
|
||||
yield 'empty string' => [''];
|
||||
|
||||
yield 'whitespace' => [' '];
|
||||
|
||||
yield 'non-string int' => [42];
|
||||
|
||||
yield 'non-string array' => [[]];
|
||||
|
||||
yield 'invalid date string' => ['not-a-date'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter anonyme : re-expose chaque helper protected en public pour le test,
|
||||
* sans jamais instancier un vrai mapper (ceux-ci ont des dépendances DI).
|
||||
*/
|
||||
private static function adapter(): object
|
||||
{
|
||||
return new class {
|
||||
use BovinNodeMappingTrait {
|
||||
normalizeToList as public;
|
||||
toNullableString as public;
|
||||
toNullableInt as public;
|
||||
toNullableBool as public;
|
||||
toNullableDate as public;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ final class InventoryMapperTest extends TestCase
|
||||
self::assertCount(2, $inventory->animals);
|
||||
self::assertCount(1, $inventory->earTagSeries);
|
||||
self::assertSame('FR123', $inventory->animals[0]->identification?->bovin?->nationalNumber);
|
||||
self::assertSame('FR', $inventory->earTagSeries[0]->countryCode);
|
||||
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
|
||||
self::assertSame(50, $inventory->earTagSeries[0]->quantity);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithoutMessageZipReturnsEmptyLists(): void
|
||||
@@ -54,6 +57,89 @@ final class InventoryMapperTest extends TestCase
|
||||
self::assertNull($inventory->startDate);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithStockBouclesAbsentYieldsNullFlag(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$message = new stdClass();
|
||||
$message->InformationsMessage = new stdClass();
|
||||
$message->InformationsMessage->DateDebut = '2026-01-01';
|
||||
// StockBoucles deliberately omitted
|
||||
|
||||
$inventory = $mapper->map($this->makeSoapResponse(), $message);
|
||||
|
||||
self::assertNull($inventory->includesEarTagStock);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithStockBouclesZeroYieldsFalseFlag(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$message = new stdClass();
|
||||
$message->InformationsMessage = new stdClass();
|
||||
$message->InformationsMessage->StockBoucles = '0';
|
||||
|
||||
$inventory = $mapper->map($this->makeSoapResponse(), $message);
|
||||
|
||||
self::assertFalse($inventory->includesEarTagStock);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithDateFinAbsentYieldsNullEndDate(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$message = new stdClass();
|
||||
$message->InformationsMessage = new stdClass();
|
||||
$message->InformationsMessage->DateDebut = '2026-01-01';
|
||||
// DateFin deliberately omitted
|
||||
|
||||
$inventory = $mapper->map($this->makeSoapResponse(), $message);
|
||||
|
||||
self::assertNull($inventory->endDate);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithSerieBouclesAsListPreservesAllEntries(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$serie1 = new stdClass();
|
||||
$serie1->CodePays = 'FR';
|
||||
$serie1->DebutSerie = '0012345678';
|
||||
$serie1->Quantite = 10;
|
||||
|
||||
$serie2 = new stdClass();
|
||||
$serie2->CodePays = 'FR';
|
||||
$serie2->DebutSerie = '0055500000';
|
||||
$serie2->Quantite = 25;
|
||||
|
||||
$message = new stdClass();
|
||||
$message->Boucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles = [$serie1, $serie2];
|
||||
|
||||
$inventory = $mapper->map($this->makeSoapResponse(), $message);
|
||||
|
||||
self::assertCount(2, $inventory->earTagSeries);
|
||||
self::assertSame('0012345678', $inventory->earTagSeries[0]->startNumber);
|
||||
self::assertSame(10, $inventory->earTagSeries[0]->quantity);
|
||||
self::assertSame('0055500000', $inventory->earTagSeries[1]->startNumber);
|
||||
self::assertSame(25, $inventory->earTagSeries[1]->quantity);
|
||||
}
|
||||
|
||||
public function testMapInventoryWithMissingNbBovinsDefaultsToZero(): void
|
||||
{
|
||||
$mapper = new InventoryMapper(new AnimalSummaryMapper(), new StandardResponseMapper());
|
||||
|
||||
$soapResponse = new stdClass();
|
||||
$soapResponse->ReponseStandard = new stdClass();
|
||||
$soapResponse->ReponseStandard->Resultat = true;
|
||||
$soapResponse->ReponseSpecifique = new stdClass();
|
||||
// NbBovins deliberately omitted
|
||||
|
||||
$inventory = $mapper->map($soapResponse, null);
|
||||
|
||||
self::assertSame(0, $inventory->nbBovins);
|
||||
}
|
||||
|
||||
private function makeSoapResponse(): object
|
||||
{
|
||||
$response = new stdClass();
|
||||
@@ -77,9 +163,11 @@ final class InventoryMapperTest extends TestCase
|
||||
$this->makeAnimalNode('FR123'),
|
||||
$this->makeAnimalNode('FR456'),
|
||||
];
|
||||
$message->Boucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles->NumeroSerieDebut = 'A0001';
|
||||
$message->Boucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles = new stdClass();
|
||||
$message->Boucles->SerieBoucles->CodePays = 'FR';
|
||||
$message->Boucles->SerieBoucles->DebutSerie = '0012345678';
|
||||
$message->Boucles->SerieBoucles->Quantite = 50;
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user