presseportale/tests/Feature/ConversionBaselineReportsTest.php
Kevin Adametz 1c38c820f4 reports: Kohortenalter ab Stichtag statt ab --to; legacy:settle-unpaid kennt bereits ersetzte Quellen; dev: Basiswert 0.1 vom Live-System ausgewertet
view-distribution rechnete das Alter der Kohorten ab dem Enddatum des
Fensters, die Zähler stehen aber immer auf heute — mit einem --to in der
Vergangenheit waren alle Kohorten um die Verzögerung des Laufs zu jung.
Das Alter zählt jetzt ab dem Stichtag der Erhebung (Feld stichtag), junge
Kohorten fallen bei altem Enddatum leer aus.

settle-unpaid meldete beim zweiten Lauf einen Posten, dessen Quelle der
erste Lauf schon per MAN-Rechnung ersetzt hatte (superseded), als
Konflikt. Solche Quellen zählen jetzt als already_reissued.

Basiswerte Block 0: Live-Erhebung vom 21.09.2026 (gleiches Fenster,
Median 126/303 statt 98/200) eingetragen, Kohortenzeile mit Alters-
Versatz erklärt, letzter Schritt für 0.1 benannt (Lauf ohne --to).
Protokoll: Nach-Deploy-Schritte, Lesart von Audit und Rechnungslauf.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 15:32:32 +00:00

258 lines
12 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 count from today even when the window ends in the past', function () {
/** @var TestCase $this */
baselineRelease(Portal::Presseecho, 10, ['published_at' => now()->subDays(2)]);
baselineRelease(Portal::Presseecho, 300, ['published_at' => now()->subDays(40)]);
baselineRelease(Portal::Presseecho, 700, ['published_at' => now()->subDays(100)]);
// `hits` steht auf dem Stand von heute — eine vor 40 Tagen veröffentlichte
// Meldung ist 40 Tage alt, auch wenn das Fenster vor 20 Tagen endet. Die
// Meldung von vorgestern liegt außerhalb des Fensters; die junge Kohorte
// fällt leer aus, statt 20 Tage alte Meldungen als „0–6 Tage" zu führen.
$data = app(ViewDistributionReport::class)->build(now()->subDays(120), now()->subDays(20));
$cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts'])
->keyBy('alter');
expect($data['stichtag'])->toBe(now()->toDateString())
->and($cohorts->has('0–6 Tage'))->toBeFalse()
->and($cohorts['28–55 Tage']['anzahl'])->toBe(1)
->and($cohorts['28–55 Tage']['median'])->toBe(300)
->and($cohorts['91+ Tage']['median'])->toBe(700);
});
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();
});