build()->rows; $keys = array_map( static fn (array $row): string => $row['busProId'].'-'.$row['hotel']['busProId'], $rows, ); self::assertSame(['900-1', '900-2', '901-1'], $keys); self::assertSame($keys, array_unique($keys), 'the pair the consumer upserts on must be unique export-wide'); } public function testCarriesTravelAndHotelMasterData(): void { $row = $this->rowFor('900-1'); self::assertSame(900, $row['busProId']); self::assertIsInt($row['busProId']); self::assertSame('SBW261226', $row['code']); self::assertSame('SBW', $row['productCode']); self::assertSame('Semesterende - Portes du Soleil', $row['product'], 'the product label is trimmed'); self::assertSame('2026-12-26', $row['dateFrom']); self::assertSame('2026-12-30', $row['dateTo']); self::assertSame([ 'busProId' => 1, 'code' => 'H1', 'name' => 'Hotel Eins', 'costUnit' => '84 85', 'country' => 'CH', 'countryCode' => 'ch', ], $row['hotel']); } public function testCarriesTheProductCodeOfTheEnclosingTravelRatherThanTheDateCode(): void { $rows = $this->build('')->rows; self::assertSame('DWWMP', $rows[0]['productCode'], 'taken from , trimmed'); self::assertSame('DWWMW010127', $rows[0]['code']); } public function testTheProductCodeIsNullWhenTheTravelCarriesNone(): void { $rows = $this->build('')->rows; self::assertNull($rows[0]['productCode']); } public function testResolvesBothZeitShapesAndRollsAnOvernightPickupOntoTheNextDay(): void { $pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId'); self::assertSame('23:59', $pickups[10]['time']); self::assertSame('2026-12-26T23:59:00', $pickups[10]['datetime']); // Time only, so it defaults to the departure date -- which would put it before the // 23:59 above. The coach cannot board backwards, so it rolls onto the next day. self::assertSame('02:15', $pickups[11]['time']); self::assertSame('2026-12-27T02:15:00', $pickups[11]['datetime']); } public function testEmitsNoOffsetInSummerEither(): void { $pickups = $this->rowFor('901-1')['pickups']; self::assertSame('2027-07-15T08:00:00', $pickups[0]['datetime']); } public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void { // Berlin skips 02:00-02:59 on 28.03.2027; BusPro still plans a stop at 02:45. $pickups = $this->build(<<<'XML' XML)->rows[0]['pickups']; self::assertSame(['23:30', '02:45', '03:45'], array_column($pickups, 'time')); self::assertSame(['2027-03-27T23:30:00', '2027-03-28T02:45:00', '2027-03-28T03:45:00'], array_column($pickups, 'datetime')); } public function testPickupTimesInTheFallBackHourPassThroughWhenRolledOntoTheNextDay(): void { // Berlin runs 02:00-02:59 twice on 31.10.2027; the bare times roll onto it from the 30th. $pickups = $this->build(<<<'XML' XML)->rows[0]['pickups']; self::assertSame(['2027-10-30T23:30:00', '2027-10-31T02:15:00', '2027-10-31T02:59:00'], array_column($pickups, 'datetime')); } public function testATimeOnlyPickupThatAlreadyFollowsTheLastDatedOneKeepsTheDepartureDate(): void { $pickups = array_column($this->rowFor('901-1')['pickups'], null, 'busProId'); self::assertSame('2027-07-15T09:30:00', $pickups[11]['datetime'], 'no roll when the order already holds'); } public function testKeepsAPickupWithoutAZeitAndNullsBothTimeFields(): void { $pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId'); self::assertArrayHasKey(12, $pickups); self::assertNull($pickups[12]['time']); self::assertNull($pickups[12]['datetime']); self::assertSame('Z12', $pickups[12]['code'], 'master data still applies to an entry with no time'); } public function testKeepsAPickupMissingFromMasterDataAndCountsIt(): void { $export = $this->build(); $pickups = array_column($export->rows[0]['pickups'], null, 'busProId'); // Dropping it would hide a real departure point from the disposition UI. self::assertArrayHasKey(99, $pickups); self::assertNull($pickups[99]['code']); self::assertNull($pickups[99]['city']); self::assertNull($pickups[99]['street']); self::assertSame('21:00', $pickups[99]['time'], 'the time is from the travel XML, not master data'); self::assertSame('2026-12-26T21:00:00', $pickups[99]['datetime']); // Counted once, not once per row that references it. self::assertSame(1, $export->pickupsWithoutMasterData); } public function testPickupsKeepSourceOrder(): void { $order = array_column($this->rowFor('900-1')['pickups'], 'busProId'); self::assertSame([10, 11, 12, 99], $order, 'source order, never sorted by time'); } public function testPickupsAreAnEmptyArrayRatherThanNullWhenThereAreNone(): void { $rows = $this->build('')->rows; self::assertSame([], $rows[0]['pickups']); } public function testDropsAndCountsRowsWhoseHotelIsMissingFromMasterData(): void { $export = $this->build(); self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 902 === $r['busProId'])); // One hotel on travel 902, two on the travel with the unparseable date. self::assertSame(3, $export->skipped); } public function testDropsEveryRowOfATravelWhoseDateCannotBeParsed(): void { $export = $this->build(); self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 903 === $r['busProId'])); } public function testCollapsesADuplicatePairLastWins(): void { $export = $this->build(null, ['Ziel_1.xml', 'Ziel_2.xml']); $matching = array_values(array_filter( $export->rows, static fn (array $r): bool => 900 === $r['busProId'] && 1 === $r['hotel']['busProId'], )); self::assertCount(1, $matching); self::assertSame('Zweiter Treffer', $matching[0]['product']); } /** * @return array */ public function testCarriesTheSourceExportTimestamp(): void { $export = $this->build(null, ['Ziel_1.xml'], "22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n"); self::assertSame('2026-09-22T11:10:15+02:00', $export->sourceGeneratedAt?->format('c')); } public function testTheSourceExportTimestampIsNullWithoutAMarker(): void { self::assertNull($this->build()->sourceGeneratedAt); } private function rowFor(string $key): array { foreach ($this->build()->rows as $row) { if ($row['busProId'].'-'.$row['hotel']['busProId'] === $key) { return $row; } } self::fail('No row for '.$key); } /** * @param list $files */ private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): Export { $contents = [ 'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'), 'Ziel_2.xml' => (string) file_get_contents(__DIR__.'/../Resources/travel_export_data_duplicate.xml'), ]; $filesystem = $this->createStub(FilesystemOperator::class); $filesystem->method('listContents')->willReturn(new DirectoryListing(array_map( static fn (string $path): FileAttributes => new FileAttributes($path), $files, ))); $filesystem->method('read')->willReturnCallback( static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $contents[$path] ?? '' ); $filesystem->method('fileExists')->willReturnCallback( static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker ); $hotelLoader = $this->createStub(HotelLoader::class); $hotelLoader->method('loadAll')->willReturn([ 1 => $this->hotel(1, 'H1', 'Hotel Eins', '84 85', 'CH', 'ch'), 2 => $this->hotel(2, 'H2', 'Hotel Zwei', null, 'D', 'de'), ]); $pickupLoader = $this->createStub(PickupLoader::class); $pickupLoader->method('loadAll')->willReturn([ 10 => $this->pickup(10), 11 => $this->pickup(11), 12 => $this->pickup(12), ]); return (new TravelExportBuilder( $hotelLoader, $pickupLoader, new TravelParser(new AgeConstraintParserRegistry([]), false), $filesystem, new TagAwareAdapter(new ArrayAdapter()), new NullLogger(), ))->build(); } private function hotel(int $id, string $code, string $name, ?string $costUnit, string $country, string $countryCode): Hotel { $hotel = new Hotel(); $hotel->id = $id; $hotel->code = $code; $hotel->name = $name; $hotel->costUnit = $costUnit; $hotel->country = $country; $hotel->countryCode = $countryCode; return $hotel; } private function pickup(int $id): Pickup { $pickup = new Pickup(); $pickup->id = $id; $pickup->code = 'Z'.$id; $pickup->city = 'Stadt '.$id; $pickup->street = 'Strasse '.$id; return $pickup; } }