seed(RolesAndPermissionsSeeder::class); Storage::fake('local'); Storage::fake('public'); }); function ogPixel(string $path, int $x, int $y): array { $img = imagecreatefromjpeg(Storage::disk('local')->path($path)); $rgb = imagecolorat($img, $x, $y); imagedestroy($img); return [($rgb >> 16) & 0xFF, ($rgb >> 8) & 0xFF, $rgb & 0xFF]; } test('the og variant carries a darker caption bar at the bottom while the rest stays untouched', function () { $upload = UploadedFile::fake()->image('hell.jpg', 2400, 1260)->size(200); // Ein helles Bild, damit der Balken messbar ist. $white = imagecreatetruecolor(2400, 1260); imagefilledrectangle($white, 0, 0, 2400, 1260, imagecolorallocate($white, 250, 250, 250)); imagejpeg($white, $upload->getRealPath(), 95); imagedestroy($white); $stored = (new ImageService)->storePressReleaseImage($upload, 7, 'local', ogCaption: 'Foto: Anna Beispiel · KI-generiert'); expect($stored['variants'])->toHaveKey('og'); [$w, $h] = getimagesize(Storage::disk('local')->path($stored['variants']['og'])); expect($w)->toBe(1200)->and($h)->toBe(630); $top = ogPixel($stored['variants']['og'], 600, 40); $bar = ogPixel($stored['variants']['og'], 1100, 615); expect($top[0])->toBeGreaterThan(235) ->and($bar[0])->toBeLessThan(200); // Cover-Variante ohne Balken. $coverBottom = ogPixel($stored['variants']['cover'], 1200, 570); expect($coverBottom[0])->toBeGreaterThan(235); }); test('the focus point moves the crop and no_crop keeps the whole image', function () { $upload = UploadedFile::fake()->image('split.jpg', 2000, 1000)->size(200); // Linke Hälfte rot, rechte Hälfte blau. $img = imagecreatetruecolor(2000, 1000); imagefilledrectangle($img, 0, 0, 999, 1000, imagecolorallocate($img, 220, 30, 30)); imagefilledrectangle($img, 1000, 0, 2000, 1000, imagecolorallocate($img, 30, 30, 220)); imagejpeg($img, $upload->getRealPath(), 95); imagedestroy($img); $service = new ImageService; // thumb 320×240 aus 2000×1000: Ausschnitt 1333 px breit. Fokus links → // Ausschnitt 0–1333 (rot bis Pixel 240 der Miniatur), Fokus rechts → // 667–2000 (blau ab Pixel 80). $left = $service->storePressReleaseImage($upload, 1, 'local', focus: ['x' => 0, 'y' => 50]); $leftPixel = ogPixel($left['variants']['thumb'], 100, 120); expect($leftPixel[0])->toBeGreaterThan($leftPixel[2]); $upload2 = UploadedFile::fake()->image('split2.jpg', 2000, 1000)->size(200); copy($upload->getRealPath(), $upload2->getRealPath()); $right = $service->storePressReleaseImage($upload2, 2, 'local', focus: ['x' => 100, 'y' => 50]); $rightPixel = ogPixel($right['variants']['thumb'], 100, 120); expect($rightPixel[2])->toBeGreaterThan($rightPixel[0]); $upload3 = UploadedFile::fake()->image('split3.jpg', 2000, 1000)->size(200); copy($upload->getRealPath(), $upload3->getRealPath()); $boxed = $service->storePressReleaseImage($upload3, 3, 'local', noCrop: true); [$w, $h] = getimagesize(Storage::disk('local')->path($boxed['variants']['cover'])); // 2000×1000 im 1280×580-Rahmen: Bild 1160×580, Ränder links/rechts je 60 px. $edge = ogPixel($boxed['variants']['cover'], 10, 290); expect($w)->toBe(1280)->and($h)->toBe(580) ->and($edge[0])->toBeGreaterThan(230)->and(abs($edge[0] - $edge[2]))->toBeLessThan(15); }); test('a credit correction regenerates the og variant and the detail page uses it', function () { /** @var TestCase $this */ $category = Category::factory()->create(['portal' => Portal::Presseecho]); $category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-og']); $company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]); $pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([ 'slug' => 'og-test', 'published_ever_at' => now()->subDay(), ]); $upload = UploadedFile::fake()->image('foto.jpg', 1600, 900)->size(100); $stored = (new ImageService)->storePressReleaseImage($upload, $pr->id, 'local', ogCaption: 'Foto: Alt'); $image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([ 'disk' => 'local', 'path' => $stored['path'], 'variants' => $stored['variants'], 'copyright' => 'Alt', 'published_ever_at' => now()->subDay(), ]); $before = md5((string) Storage::disk('local')->get($stored['variants']['og'])); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); app(ImageCorrectionService::class)->correctCredit($image, $admin, 'Neuer sehr langer Nachweis der gekürzt werden muss weil er nicht in den Balken passt Anna Beispiel Musterfirma GmbH', 'Einwand'); $after = md5((string) Storage::disk('local')->get($image->fresh()->variants['og'])); expect($after)->not->toBe($before); $this->get('https://presseecho.test/de/press-release/og-test') ->assertSuccessful() ->assertSee('', false); }); test('teasers carry the credit in alt and title and the hero shows a caption', function () { /** @var TestCase $this */ $category = Category::factory()->create(['portal' => Portal::Presseecho]); $category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-teaser']); $company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]); $pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([ 'title' => 'Teaser-Meldung', 'published_at' => now()->subHour(), 'published_ever_at' => now()->subHour(), ]); PressReleaseImage::factory()->for($pr)->rulesV2()->create(['copyright' => 'Anna Beispiel', 'ai_declaration' => 'ai_generated']); expect($pr->fresh()->load('images', 'company')->cardImageCredit())->toBe('Foto: Anna Beispiel · KI-generiert'); $this->get('https://presseecho.test/de/category/energie-teaser') ->assertSuccessful() ->assertSee('alt="Teaser-Meldung · Foto: Anna Beispiel · KI-generiert"', false) ->assertSee('data-image-credit-teaser', false); }); test('the regenerate command builds missing og variants for the existing stock with the credit line', function () { /** @var TestCase $this */ $company = Company::factory()->presseecho()->create(['name' => 'Bestand GmbH', 'is_active' => true]); $pr = PressRelease::factory()->published()->for($company)->create(['published_ever_at' => now()->subDay()]); $upload = UploadedFile::fake()->image('alt.jpg', 1600, 900)->size(100); $stored = (new ImageService)->storePressReleaseImage($upload, $pr->id, 'local'); $variants = $stored['variants']; Storage::disk('local')->delete($variants['og']); unset($variants['og']); $image = PressReleaseImage::factory()->for($pr)->legacy()->create(['disk' => 'local', 'path' => $stored['path'], 'variants' => $variants]); $this->artisan('press-release-images:regenerate-variants', ['--only' => 'og', '--published' => true]) ->expectsOutputToContain('Trockenlauf: OG-Varianten für 1 von 1 Bildern.') ->assertSuccessful(); expect($image->fresh()->variants)->not->toHaveKey('og'); $this->artisan('press-release-images:regenerate-variants', ['--only' => 'og', '--published' => true, '--execute' => true]) ->expectsOutputToContain('OG-Varianten erzeugt: 1') ->assertSuccessful(); $og = $image->fresh()->variants['og'] ?? null; expect($og)->not->toBeNull() ->and(Storage::disk('local')->exists($og))->toBeTrue() ->and(getimagesize(Storage::disk('local')->path($og))[0])->toBe(1200); });