presseportale/tests/Feature/ConversionBaselineReportsTest.php
Kevin Adametz 2391c853e3 reports: Aufrufe nach Alter aufschlüsseln und Basiswerte festhalten
Der Gesamtmedian über ein Zeitfenster taugt nicht als Basiswert. `hits` ist ein
mitlaufender Gesamtzähler, und die Aufrufe wachsen nach der Veröffentlichung
über Wochen weiter — auf dem Bestand liegt der Schnitt in der ersten Woche bei
rund 75 und nach drei Wochen bei rund 230, ohne dass ein Plateau erreicht wäre.
Gemessen wird damit nicht, wie viele Aufrufe eine Meldung bekommt, sondern wie
viel eine zufällig alte Meldung bisher gesammelt hat. Dieser Wert verschiebt
sich mit der Altersmischung des Fensters; zwei Messungen zu verschiedenen
Zeitpunkten wären nicht vergleichbar, und genau das braucht ein Basiswert.

Der Report weist die Aufrufe deshalb zusätzlich nach Alterskohorten aus. Erst
die einzelne Kohorte ("Median der Meldungen im Alter von 28 bis 55 Tagen") ist
in einem halben Jahr identisch erhebbar. Der Altbestand bleibt außen vor — seine
Zähler stammen aus dem Altsystem und haben keine hier nachvollziehbare
Vorgeschichte.

Dazu die am 01.09. auf Live erhobenen Basiswerte und die daraus folgende
Empfehlung zu E2: Rohzahlen. Es gibt keinen Bodensatz, den eine Einordnung
kaschieren müsste (unter 25 Aufrufen liegen 2 % beziehungsweise 0 % der
Meldungen), die Verteilungen sind nahezu symmetrisch, und eine Einordnung gegen
den Portaldurchschnitt wäre wegen der Wachstumskurve sogar falsch — sie
verglich eine zwei Tage alte Meldung gegen einen Schnitt aus drei Monaten.

Die Roadmap führte openPR als zweite Zulaufquelle. Das ist korrigiert: openPR
ist der Presse-API-Verteiler von connektar, also derselbe Zulauf. In den Daten
hat genau ein API-Token je einen Zugriff gemacht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 12:40:51 +00:00

237 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\PressRelease\RejectionRateReport;
use App\Services\PressRelease\ViewDistributionReport;
use Tests\TestCase;
/**
* Basiswerte aus Block 0 der Roadmap. Beide müssen gemessen sein, bevor die
* erste Maßnahme aus Block 1 live geht — ohne Vergleichspunkt wäre jede
* spätere Bewertung eine Vermutung.
*/
function baselineRelease(Portal $portal, int $hits, array $attributes = []): PressRelease
{
return PressRelease::factory()
->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();
});