published() ->forPortal($portal) ->create(array_merge([ 'hits' => $hits, 'published_at' => now()->subDays(10), ], $attributes)); } function baselineLog(PressRelease $release, string $toStatus, string $source, ?User $user, string $at): void { PressReleaseStatusLog::query()->create([ 'press_release_id' => $release->getKey(), 'to_status' => $toStatus, 'source' => $source, 'changed_by_user_id' => $user?->getKey(), 'created_at' => $at, ]); } // ---------------------------------------------------------------- 0.1 Aufrufe test('the view distribution reports median and quartiles per portal', function () { /** @var TestCase $this */ foreach ([10, 20, 30, 40, 50] as $hits) { baselineRelease(Portal::Presseecho, $hits); } $data = app(ViewDistributionReport::class)->build(now()->subMonths(3)); $presseecho = collect($data['portals'])->firstWhere('portal', 'presseecho'); $stats = $presseecho['segments'][0]['stats']; expect($stats['anzahl'])->toBe(5) ->and($stats['min'])->toBe(10) ->and($stats['median'])->toBe(30) ->and($stats['max'])->toBe(50) ->and($stats['mittelwert'])->toBe(30.0); }); test('the view distribution counts a both-portal release on both portals', function () { /** @var TestCase $this */ baselineRelease(Portal::Both, 77); $data = app(ViewDistributionReport::class)->build(now()->subMonths(3)); // Der Zähler ist einer, die Sichtbarkeit doppelt — für die Einordnung // eines Absenders zählt die Meldung auf beiden Portalen mit. foreach ($data['portals'] as $portal) { expect($portal['segments'][0]['stats']['median'])->toBe(77); } }); test('the view distribution leaves out removed releases', function () { /** @var TestCase $this */ baselineRelease(Portal::Presseecho, 100); baselineRelease(Portal::Presseecho, 999, ['gone_at' => now()]); $data = app(ViewDistributionReport::class)->build(now()->subMonths(3)); $stats = collect($data['portals'])->firstWhere('portal', 'presseecho')['segments'][0]['stats']; // Eine mit 410 entfernte Meldung ist nicht mehr öffentlich und darf den // Portalschnitt nicht mitbestimmen. expect($stats['anzahl'])->toBe(1) ->and($stats['max'])->toBe(100); }); test('the view distribution separates legacy stock from post-relaunch releases', function () { /** @var TestCase $this */ baselineRelease(Portal::Presseecho, 20, ['legacy_portal' => Portal::Presseecho->value, 'legacy_id' => 4711]); baselineRelease(Portal::Presseecho, 200); $data = app(ViewDistributionReport::class)->build(now()->subMonths(3)); $segments = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['segments']) ->keyBy('segment'); // Beide Zähler haben verschiedene Vorgeschichten; eine Einordnung, die sie // vermischt, vergleicht Äpfel mit Birnen. expect($segments['seit Relaunch']['stats']['median'])->toBe(200) ->and($segments['Altbestand']['stats']['median'])->toBe(20); }); test('the view distribution ignores releases published outside the window', function () { /** @var TestCase $this */ baselineRelease(Portal::Presseecho, 50); baselineRelease(Portal::Presseecho, 5000, ['published_at' => now()->subYear()]); $data = app(ViewDistributionReport::class)->build(now()->subMonths(3)); expect(collect($data['portals'])->firstWhere('portal', 'presseecho')['segments'][0]['stats']['anzahl'])->toBe(1); }); test('the view distribution command renders without data', function () { /** @var TestCase $this */ $this->artisan('press-releases:view-distribution')->assertSuccessful(); }); test('the view distribution groups releases into age cohorts', function () { /** @var TestCase $this */ baselineRelease(Portal::Presseecho, 10, ['published_at' => now()->subDays(2)]); baselineRelease(Portal::Presseecho, 90, ['published_at' => now()->subDays(10)]); baselineRelease(Portal::Presseecho, 300, ['published_at' => now()->subDays(40)]); $data = app(ViewDistributionReport::class)->build(now()->subMonths(3)); $cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts']) ->keyBy('alter'); // `hits` wächst nach der Veröffentlichung über Wochen weiter. Nur innerhalb // einer Alterskohorte ist der Wert zwischen zwei Messzeitpunkten // vergleichbar — der Gesamtmedian hängt an der Altersmischung des Fensters. expect($cohorts['0–6 Tage']['median'])->toBe(10) ->and($cohorts['7–13 Tage']['median'])->toBe(90) ->and($cohorts['28–55 Tage']['median'])->toBe(300); }); test('the age cohorts leave out legacy stock', function () { /** @var TestCase $this */ baselineRelease(Portal::Presseecho, 50, ['published_at' => now()->subDays(3)]); baselineRelease(Portal::Presseecho, 5000, [ 'published_at' => now()->subDays(3), 'legacy_portal' => Portal::Presseecho->value, 'legacy_id' => 4712, ]); $data = app(ViewDistributionReport::class)->build(now()->subMonths(3)); $cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts']) ->keyBy('alter'); // Die Zähler des Altbestands stammen aus dem Altsystem und haben keine // hier nachvollziehbare Vorgeschichte — sie würden die Kurve verfälschen. expect($cohorts['0–6 Tage']['anzahl'])->toBe(1) ->and($cohorts['0–6 Tage']['median'])->toBe(50); }); // ------------------------------------------------------- 0.2 Ablehnungsquote test('the rejection rate is reported per submitting source', function () { /** @var TestCase $this */ $api = User::factory()->create(['agency_name' => 'connektar.de Presseverteiler']); $customer = User::factory()->create(); foreach (range(1, 3) as $i) { $release = baselineRelease(Portal::Presseecho, 0); baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDays(5)->toDateTimeString()); baselineLog($release, $i === 1 ? PressReleaseStatus::Rejected->value : PressReleaseStatus::Published->value, 'ki', null, now()->subDays(4)->toDateTimeString()); } $release = baselineRelease(Portal::Presseecho, 0); baselineLog($release, PressReleaseStatus::Review->value, 'customer', $customer, now()->subDays(5)->toDateTimeString()); baselineLog($release, PressReleaseStatus::Published->value, 'ki', null, now()->subDays(4)->toDateTimeString()); $data = app(RejectionRateReport::class)->build(now()->subMonths(3)); $sources = collect($data['sources'])->keyBy('key'); // Gruppiert wird nach dem EINREICHENDEN, nicht nach dem Entscheider — die // Entscheidung trifft immer die KI-Prüfung und wäre als Gruppierung wertlos. expect($sources['api:'.$api->id]['label'])->toContain('connektar.de Presseverteiler') ->and($sources['api:'.$api->id]['eingereicht'])->toBe(3) ->and($sources['api:'.$api->id]['abgelehnt'])->toBe(1) ->and($sources['api:'.$api->id]['quote'])->toBe(33.3) ->and($sources['customer']['label'])->toBe('direkt (Kundenbereich)') ->and($sources['customer']['quote'])->toBe(0.0) ->and($data['totals']['quote'])->toBe(25.0); }); test('a resubmission after rejection counts as its own submission', function () { /** @var TestCase $this */ $api = User::factory()->create(['agency_name' => 'connektar.de Presseverteiler']); $release = baselineRelease(Portal::Presseecho, 0); baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDays(9)->toDateTimeString()); baselineLog($release, PressReleaseStatus::Rejected->value, 'ki', null, now()->subDays(8)->toDateTimeString()); baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDays(3)->toDateTimeString()); baselineLog($release, PressReleaseStatus::Published->value, 'ki', null, now()->subDays(2)->toDateTimeString()); $data = app(RejectionRateReport::class)->build(now()->subMonths(3)); // Genau diese Größe soll die Infoseite (1d) verändern: dieselbe Meldung, // überarbeitet, mit eigenem Ausgang. expect($data['totals']['eingereicht'])->toBe(2) ->and($data['totals']['abgelehnt'])->toBe(1) ->and($data['totals']['veroeffentlicht'])->toBe(1) ->and($data['totals']['quote'])->toBe(50.0); }); test('an undecided submission is counted as open, not as published', function () { /** @var TestCase $this */ $api = User::factory()->create(); $release = baselineRelease(Portal::Presseecho, 0); baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDay()->toDateTimeString()); $data = app(RejectionRateReport::class)->build(now()->subMonths(3)); // Offene Prüfungen dürfen nicht in den Nenner — sonst sänke die Quote // allein dadurch, dass der Zeitraum bis heute reicht. expect($data['totals']['offen'])->toBe(1) ->and($data['totals']['quote'])->toBeNull(); }); test('a decision without a submission in the window is reported, not swallowed', function () { /** @var TestCase $this */ $release = baselineRelease(Portal::Presseecho, 0); baselineLog($release, PressReleaseStatus::Review->value, 'api', null, now()->subMonths(6)->toDateTimeString()); baselineLog($release, PressReleaseStatus::Rejected->value, 'ki', null, now()->subDay()->toDateTimeString()); $data = app(RejectionRateReport::class)->build(now()->subMonths(3)); // Ein zu kurz gewählter Zeitraum sähe sonst wie eine niedrige Quote aus. expect($data['entscheidungen_ohne_einreichung'])->toBe(1) ->and($data['totals']['eingereicht'])->toBe(0); }); test('the rejection rate command reports an empty window plainly', function () { /** @var TestCase $this */ $this->artisan('press-releases:rejection-rate') ->expectsOutputToContain('Keine Einreichungen im Zeitraum.') ->assertSuccessful(); });