83 - Add an approval rate chart and rework the dashboard grid
This commit is contained in:
parent
df5013f86d
commit
cc45b74a63
12 changed files with 430 additions and 28 deletions
53
app/Dashboard/Stats/ApprovalRate.php
Normal file
53
app/Dashboard/Stats/ApprovalRate.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Enums\ApprovalStatusEnum;
|
||||||
|
use App\Models\RouteArticle;
|
||||||
|
use App\Support\DateRange;
|
||||||
|
|
||||||
|
class ApprovalRate extends DailySeriesStat
|
||||||
|
{
|
||||||
|
public function key(): string
|
||||||
|
{
|
||||||
|
return 'approval-rate';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Approval Rate';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function series(DateRange $range, array $days): SeriesResult
|
||||||
|
{
|
||||||
|
$decisions = RouteArticle::query()
|
||||||
|
->toBase()
|
||||||
|
->whereNotNull('decided_at')
|
||||||
|
->whereBetween('decided_at', [$range->from, $range->to])
|
||||||
|
->whereIn('approval_status', ApprovalStatusEnum::decidedValues())
|
||||||
|
->selectRaw(
|
||||||
|
'DATE(decided_at) as bucket, COUNT(*) as total, SUM(CASE WHEN approval_status = ? THEN 1 ELSE 0 END) as approved',
|
||||||
|
[ApprovalStatusEnum::APPROVED->value],
|
||||||
|
)
|
||||||
|
->groupBy('bucket')
|
||||||
|
->get()
|
||||||
|
->keyBy('bucket');
|
||||||
|
|
||||||
|
$values = array_map(
|
||||||
|
function (string $day) use ($decisions): ?float {
|
||||||
|
$decision = $decisions->get($day);
|
||||||
|
|
||||||
|
if ($decision === null || (int) $decision->total === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(((int) $decision->approved / (int) $decision->total) * 100, 1);
|
||||||
|
},
|
||||||
|
$days,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new SeriesResult($days, [
|
||||||
|
new Series('Approval Rate', $values, zeroIsMeaningful: true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,26 @@ class Series
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<int, int|float|null> $values
|
* @param array<int, int|float|null> $values
|
||||||
|
* @param bool $zeroIsMeaningful A rate of 0 is a real measurement; a count of 0 is an absence.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $name,
|
public readonly string $name,
|
||||||
public readonly array $values,
|
public readonly array $values,
|
||||||
|
public readonly bool $zeroIsMeaningful = false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public function hasData(): bool
|
||||||
|
{
|
||||||
|
foreach ($this->values as $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->zeroIsMeaningful || $value != 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,16 +44,14 @@ public function isEmpty(): bool
|
||||||
return ! $this->tooWide && $this->labels === [];
|
return ! $this->tooWide && $this->labels === [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when every value is null or zero — an axis exists but nothing happened on it. */
|
/** True when no series carries a measurement — an axis exists but nothing happened on it. */
|
||||||
public function hasNoData(): bool
|
public function hasNoData(): bool
|
||||||
{
|
{
|
||||||
foreach ($this->series as $one) {
|
foreach ($this->series as $one) {
|
||||||
foreach ($one->values as $value) {
|
if ($one->hasData()) {
|
||||||
if ($value !== null && $value != 0) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,20 @@ enum ApprovalStatusEnum: string
|
||||||
case PENDING = 'pending';
|
case PENDING = 'pending';
|
||||||
case APPROVED = 'approved';
|
case APPROVED = 'approved';
|
||||||
case REJECTED = 'rejected';
|
case REJECTED = 'rejected';
|
||||||
|
|
||||||
|
public function isDecided(): bool
|
||||||
|
{
|
||||||
|
return $this !== self::PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function decidedValues(): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (self $status): string => $status->value,
|
||||||
|
array_filter(self::cases(), fn (self $status): bool => $status->isDecided()),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Dashboard\Stats\ApprovalRate;
|
||||||
use App\Dashboard\Stats\ArticlesPerFeed;
|
use App\Dashboard\Stats\ArticlesPerFeed;
|
||||||
use App\Dashboard\Stats\ArticlesTrend;
|
use App\Dashboard\Stats\ArticlesTrend;
|
||||||
use App\Dashboard\Stats\BreakdownResult;
|
use App\Dashboard\Stats\BreakdownResult;
|
||||||
|
|
@ -35,6 +36,7 @@ public function mount(): void
|
||||||
private const RANGE_DEPENDENT_ISLANDS = [
|
private const RANGE_DEPENDENT_ISLANDS = [
|
||||||
'article-statistics',
|
'article-statistics',
|
||||||
'articles-trend',
|
'articles-trend',
|
||||||
|
'approval-rate',
|
||||||
'articles-per-feed',
|
'articles-per-feed',
|
||||||
'publications-per-channel',
|
'publications-per-channel',
|
||||||
];
|
];
|
||||||
|
|
@ -131,6 +133,16 @@ public function articlesTrend(): SeriesResult
|
||||||
: new SeriesResult([], []);
|
: new SeriesResult([], []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function approvalRate(): SeriesResult
|
||||||
|
{
|
||||||
|
$range = $this->range();
|
||||||
|
|
||||||
|
return $range instanceof DateRange
|
||||||
|
? app(ApprovalRate::class)->for($range)
|
||||||
|
: new SeriesResult([], []);
|
||||||
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function articlesPerFeed(): BreakdownResult
|
public function articlesPerFeed(): BreakdownResult
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ Chart.register(
|
||||||
|
|
||||||
const palette = ['#3b82f6', '#10b981'];
|
const palette = ['#3b82f6', '#10b981'];
|
||||||
|
|
||||||
export default function trendChart({ labels = [], series = [] } = {}) {
|
export default function trendChart({ labels = [], series = [], suffix = '', max = null } = {}) {
|
||||||
return {
|
return {
|
||||||
chart: null,
|
chart: null,
|
||||||
|
|
||||||
|
|
@ -46,7 +46,21 @@ export default function trendChart({ labels = [], series = [] } = {}) {
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
scales: {
|
scales: {
|
||||||
y: { beginAtZero: true, ticks: { precision: 0 } },
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max,
|
||||||
|
ticks: {
|
||||||
|
precision: 0,
|
||||||
|
callback: (value) => `${value}${suffix}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.dataset.label}: ${ctx.formattedValue}${suffix}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -184,12 +184,13 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Trend and breakdowns -->
|
<!-- Trend and breakdowns -->
|
||||||
<div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-4">
|
<div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<div class="lg:col-span-2">
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
|
||||||
|
|
||||||
@island('articles-trend')
|
@island('articles-trend')
|
||||||
@include('livewire.partials.trend-panel', [
|
@include('livewire.partials.trend-panel', [
|
||||||
|
'island' => 'articles-trend',
|
||||||
'result' => $this->articlesTrend,
|
'result' => $this->articlesTrend,
|
||||||
'emptyMessage' => 'No articles or publications in this range.',
|
'emptyMessage' => 'No articles or publications in this range.',
|
||||||
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
|
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
|
||||||
|
|
@ -197,6 +198,22 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
@endisland
|
@endisland
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Approval Rate</h2>
|
||||||
|
|
||||||
|
@island('approval-rate')
|
||||||
|
@include('livewire.partials.trend-panel', [
|
||||||
|
'island' => 'approval-rate',
|
||||||
|
'result' => $this->approvalRate,
|
||||||
|
'emptyMessage' => 'No approval decisions in this range.',
|
||||||
|
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
|
||||||
|
'suffix' => '%',
|
||||||
|
'max' => 100,
|
||||||
|
])
|
||||||
|
@endisland
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
|
||||||
|
|
||||||
|
|
@ -219,4 +236,5 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
@endisland
|
@endisland
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@php($payload = ['labels' => $result->labels, 'series' => collect($result->series)->map(fn ($series) => ['name' => $series->name, 'values' => $series->values])->all()])
|
@php($payload = ['labels' => $result->labels, 'series' => collect($result->series)->map(fn ($series) => ['name' => $series->name, 'values' => $series->values])->all(), 'suffix' => $suffix ?? '', 'max' => $max ?? null])
|
||||||
|
|
||||||
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
|
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
|
||||||
@if ($result->isTooWide())
|
@if ($result->isTooWide())
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $emptyMessage }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $emptyMessage }}</p>
|
||||||
@else
|
@else
|
||||||
<div
|
<div
|
||||||
wire:key="trend-{{ md5(json_encode($payload)) }}"
|
wire:key="chart-{{ md5($island.json_encode($payload)) }}"
|
||||||
x-data="trendChart({{ Js::from($payload) }})"
|
x-data="trendChart({{ Js::from($payload) }})"
|
||||||
class="relative h-72"
|
class="relative h-72"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
namespace Tests\Feature\Livewire;
|
namespace Tests\Feature\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\ApprovalStatusEnum;
|
||||||
use App\Livewire\Dashboard;
|
use App\Livewire\Dashboard;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\RouteArticle;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
|
|
@ -330,14 +332,79 @@ public function test_it_keys_the_chart_on_its_data_so_a_range_change_replaces_it
|
||||||
Livewire::test(Dashboard::class)->call('applyRange', '2026-08-01', '2026-08-31')
|
Livewire::test(Dashboard::class)->call('applyRange', '2026-08-01', '2026-08-31')
|
||||||
);
|
);
|
||||||
|
|
||||||
preg_match('/wire:key="(trend-[a-f0-9]+)"/', $july, $julyKey);
|
preg_match('/wire:key="(chart-[a-f0-9]+)"/', $july, $julyKey);
|
||||||
preg_match('/wire:key="(trend-[a-f0-9]+)"/', $august, $augustKey);
|
preg_match('/wire:key="(chart-[a-f0-9]+)"/', $august, $augustKey);
|
||||||
|
|
||||||
$this->assertNotEmpty($julyKey);
|
$this->assertNotEmpty($julyKey);
|
||||||
$this->assertNotEmpty($augustKey);
|
$this->assertNotEmpty($augustKey);
|
||||||
$this->assertNotSame($julyKey[1], $augustKey[1]);
|
$this->assertNotSame($julyKey[1], $augustKey[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_keys_each_chart_separately_when_their_payloads_match(): void
|
||||||
|
{
|
||||||
|
$fragments = $this->islandFragments(
|
||||||
|
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
|
||||||
|
);
|
||||||
|
|
||||||
|
preg_match_all('/wire:key="(chart-[a-f0-9]+)"/', $fragments, $keys);
|
||||||
|
|
||||||
|
$this->assertSame($keys[1], array_unique($keys[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_renders_the_approval_rate_chart_on_mount(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::APPROVED,
|
||||||
|
'decided_at' => Carbon::parse('2026-07-15 09:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->assertSee('Approval Rate')
|
||||||
|
->assertSee('trendChart(', false)
|
||||||
|
->assertSee('x-ref="canvas"', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_refreshes_the_approval_rate_island_when_the_range_changes(): void
|
||||||
|
{
|
||||||
|
RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::APPROVED,
|
||||||
|
'decided_at' => Carbon::parse('2026-07-10 12:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fragments = $this->islandFragments(
|
||||||
|
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('name=approval-rate|', $fragments);
|
||||||
|
$this->assertStringContainsString('Approval Rate', $fragments);
|
||||||
|
$this->assertStringContainsString('2026-07-10', $fragments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_charts_a_day_where_everything_was_rejected(): void
|
||||||
|
{
|
||||||
|
RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
|
'decided_at' => Carbon::parse('2026-07-10 12:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fragments = $this->islandFragments(
|
||||||
|
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('No approval decisions in this range.', $fragments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_a_range_with_no_approval_decisions(): void
|
||||||
|
{
|
||||||
|
$fragments = $this->islandFragments(
|
||||||
|
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('No approval decisions in this range.', $fragments);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_renders_an_empty_trend_for_an_invalid_range(): void
|
public function test_it_renders_an_empty_trend_for_an_invalid_range(): void
|
||||||
{
|
{
|
||||||
Livewire::test(Dashboard::class)
|
Livewire::test(Dashboard::class)
|
||||||
|
|
|
||||||
149
tests/Unit/Dashboard/Stats/ApprovalRateTest.php
Normal file
149
tests/Unit/Dashboard/Stats/ApprovalRateTest.php
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Dashboard\Stats\ApprovalRate;
|
||||||
|
use App\Dashboard\Stats\DailySeriesStat;
|
||||||
|
use App\Enums\ApprovalStatusEnum;
|
||||||
|
use App\Models\RouteArticle;
|
||||||
|
use App\Support\DateRange;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ApprovalRateTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function range(): DateRange
|
||||||
|
{
|
||||||
|
return new DateRange(
|
||||||
|
Carbon::parse('2026-07-01 00:00:00'),
|
||||||
|
Carbon::parse('2026-07-31 23:59:59'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, float|null>
|
||||||
|
*/
|
||||||
|
private function values(): array
|
||||||
|
{
|
||||||
|
$result = (new ApprovalRate)->for($this->range());
|
||||||
|
|
||||||
|
return array_combine($result->labels, $result->series[0]->values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decision(ApprovalStatusEnum $status, string $decidedAt): void
|
||||||
|
{
|
||||||
|
RouteArticle::factory()->create([
|
||||||
|
'approval_status' => $status,
|
||||||
|
'decided_at' => Carbon::parse($decidedAt),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_computes_the_approval_share_of_daily_decisions(): void
|
||||||
|
{
|
||||||
|
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 09:00:00');
|
||||||
|
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 10:00:00');
|
||||||
|
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 11:00:00');
|
||||||
|
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 12:00:00');
|
||||||
|
|
||||||
|
$this->assertSame(75.0, $this->values()['2026-07-10']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_excludes_pending_articles_from_the_denominator(): void
|
||||||
|
{
|
||||||
|
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 09:00:00');
|
||||||
|
|
||||||
|
RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(100.0, $this->values()['2026-07-10']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_no_rate_for_a_day_with_no_decisions(): void
|
||||||
|
{
|
||||||
|
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 09:00:00');
|
||||||
|
|
||||||
|
$this->assertNull($this->values()['2026-07-11']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_a_zero_rate_when_everything_was_rejected(): void
|
||||||
|
{
|
||||||
|
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 09:00:00');
|
||||||
|
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 10:00:00');
|
||||||
|
|
||||||
|
$this->assertSame(0.0, $this->values()['2026-07-10']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_buckets_by_decision_time_not_creation_time(): void
|
||||||
|
{
|
||||||
|
RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::APPROVED,
|
||||||
|
'created_at' => Carbon::parse('2026-07-05 09:00:00'),
|
||||||
|
'decided_at' => Carbon::parse('2026-07-20 09:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$values = $this->values();
|
||||||
|
|
||||||
|
$this->assertNull($values['2026-07-05']);
|
||||||
|
$this->assertSame(100.0, $values['2026-07-20']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_ignores_articles_decided_before_the_column_existed(): void
|
||||||
|
{
|
||||||
|
RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::APPROVED,
|
||||||
|
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNull($this->values()['2026-07-10']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_excludes_decisions_outside_the_range(): void
|
||||||
|
{
|
||||||
|
$this->decision(ApprovalStatusEnum::APPROVED, '2026-06-30 23:59:59');
|
||||||
|
$this->decision(ApprovalStatusEnum::APPROVED, '2026-08-01 00:00:00');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
[],
|
||||||
|
array_filter($this->values(), fn (?float $value): bool => $value !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_a_meaningful_zero_as_data(): void
|
||||||
|
{
|
||||||
|
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 09:00:00');
|
||||||
|
|
||||||
|
$this->assertFalse((new ApprovalRate)->for($this->range())->hasNoData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_counts_every_decided_status_in_the_denominator(): void
|
||||||
|
{
|
||||||
|
$decided = array_filter(
|
||||||
|
ApprovalStatusEnum::cases(),
|
||||||
|
fn (ApprovalStatusEnum $status): bool => $status->isDecided(),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($decided as $status) {
|
||||||
|
$this->decision($status, '2026-07-10 09:00:00');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
round(100 / count($decided), 1),
|
||||||
|
$this->values()['2026-07-10'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_is_a_daily_series_stat(): void
|
||||||
|
{
|
||||||
|
$stat = new ApprovalRate;
|
||||||
|
|
||||||
|
$this->assertInstanceOf(DailySeriesStat::class, $stat);
|
||||||
|
$this->assertTrue($stat->for(DateRange::preset('all'))->isTooWide());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -91,11 +91,31 @@ public function test_a_result_of_only_nulls_has_no_data(): void
|
||||||
$this->assertTrue($result->hasNoData());
|
$this->assertTrue($result->hasNoData());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_zero_values_are_indistinguishable_from_absent_ones(): void
|
public function test_a_zero_count_is_an_absence_of_data(): void
|
||||||
{
|
{
|
||||||
$result = new SeriesResult(
|
$result = new SeriesResult(
|
||||||
['2026-07-01', '2026-07-02'],
|
['2026-07-01', '2026-07-02'],
|
||||||
[new Series('Approval Rate', [null, 0.0])],
|
[new Series('Fetched', [null, 0])],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue($result->hasNoData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_zero_is_data_when_the_series_says_zero_is_meaningful(): void
|
||||||
|
{
|
||||||
|
$result = new SeriesResult(
|
||||||
|
['2026-07-01', '2026-07-02'],
|
||||||
|
[new Series('Approval Rate', [null, 0.0], zeroIsMeaningful: true)],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFalse($result->hasNoData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_meaningful_zero_series_of_only_nulls_still_has_no_data(): void
|
||||||
|
{
|
||||||
|
$result = new SeriesResult(
|
||||||
|
['2026-07-01', '2026-07-02'],
|
||||||
|
[new Series('Approval Rate', [null, null], zeroIsMeaningful: true)],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertTrue($result->hasNoData());
|
$this->assertTrue($result->hasNoData());
|
||||||
|
|
|
||||||
38
tests/Unit/Enums/ApprovalStatusEnumTest.php
Normal file
38
tests/Unit/Enums/ApprovalStatusEnumTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Enums;
|
||||||
|
|
||||||
|
use App\Enums\ApprovalStatusEnum;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ApprovalStatusEnumTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_pending_is_not_a_decision(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(ApprovalStatusEnum::PENDING->isDecided());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_approved_and_rejected_are_decisions(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(ApprovalStatusEnum::APPROVED->isDecided());
|
||||||
|
$this->assertTrue(ApprovalStatusEnum::REJECTED->isDecided());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decided_values_lists_every_decided_case(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(['approved', 'rejected'], ApprovalStatusEnum::decidedValues());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_decided_values_stays_in_step_with_the_cases(): void
|
||||||
|
{
|
||||||
|
$expected = array_values(array_map(
|
||||||
|
fn (ApprovalStatusEnum $status): string => $status->value,
|
||||||
|
array_filter(
|
||||||
|
ApprovalStatusEnum::cases(),
|
||||||
|
fn (ApprovalStatusEnum $status): bool => $status->isDecided(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertSame($expected, ApprovalStatusEnum::decidedValues());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue