Release v1.4.0 #146
11 changed files with 442 additions and 1 deletions
47
app/Dashboard/Stats/ArticlesPerFeed.php
Normal file
47
app/Dashboard/Stats/ArticlesPerFeed.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Support\DateRange;
|
||||||
|
|
||||||
|
class ArticlesPerFeed implements BreakdownStat
|
||||||
|
{
|
||||||
|
public function key(): string
|
||||||
|
{
|
||||||
|
return 'articles-per-feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Articles per Feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function for(DateRange $range): BreakdownResult
|
||||||
|
{
|
||||||
|
/** @var array<int, int> $counts */
|
||||||
|
$counts = Article::query()
|
||||||
|
->whereBetween('created_at', [$range->from, $range->to])
|
||||||
|
->selectRaw('feed_id, COUNT(*) as aggregate')
|
||||||
|
->groupBy('feed_id')
|
||||||
|
->pluck('aggregate', 'feed_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
// Zero-fill in PHP; assumes the feed table stays small enough to load whole.
|
||||||
|
$rows = Feed::query()
|
||||||
|
->get()
|
||||||
|
->map(fn (Feed $feed): Breakdown => new Breakdown(
|
||||||
|
$feed->name,
|
||||||
|
(int) ($counts[$feed->id] ?? 0),
|
||||||
|
))
|
||||||
|
->all();
|
||||||
|
|
||||||
|
usort(
|
||||||
|
$rows,
|
||||||
|
fn (Breakdown $a, Breakdown $b): int => [$b->count, $a->label] <=> [$a->count, $b->label],
|
||||||
|
);
|
||||||
|
|
||||||
|
return new BreakdownResult($rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Dashboard/Stats/Breakdown.php
Normal file
11
app/Dashboard/Stats/Breakdown.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
class Breakdown
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $label,
|
||||||
|
public readonly int $count,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
30
app/Dashboard/Stats/BreakdownResult.php
Normal file
30
app/Dashboard/Stats/BreakdownResult.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
class BreakdownResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, Breakdown> $rows
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $rows,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function total(): int
|
||||||
|
{
|
||||||
|
return array_sum(array_map(fn (Breakdown $row): int => $row->count, $this->rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shareOf(Breakdown $row): float
|
||||||
|
{
|
||||||
|
$total = $this->total();
|
||||||
|
|
||||||
|
return $total > 0 ? round(($row->count / $total) * 100, 1) : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmpty(): bool
|
||||||
|
{
|
||||||
|
return $this->total() === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Dashboard/Stats/BreakdownStat.php
Normal file
10
app/Dashboard/Stats/BreakdownStat.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Support\DateRange;
|
||||||
|
|
||||||
|
interface BreakdownStat extends Stat
|
||||||
|
{
|
||||||
|
public function for(DateRange $range): BreakdownResult;
|
||||||
|
}
|
||||||
13
app/Dashboard/Stats/Stat.php
Normal file
13
app/Dashboard/Stats/Stat.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
interface Stat
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Stable identifier, also used as the island name for this stat's panel.
|
||||||
|
*/
|
||||||
|
public function key(): string;
|
||||||
|
|
||||||
|
public function label(): string;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Dashboard\Stats\ArticlesPerFeed;
|
||||||
|
use App\Dashboard\Stats\BreakdownResult;
|
||||||
use App\Services\DashboardStatsService;
|
use App\Services\DashboardStatsService;
|
||||||
use App\Support\DateRange;
|
use App\Support\DateRange;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
|
@ -26,7 +28,7 @@ public function mount(): void
|
||||||
*
|
*
|
||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
private const RANGE_DEPENDENT_ISLANDS = ['article-statistics'];
|
private const RANGE_DEPENDENT_ISLANDS = ['article-statistics', 'articles-per-feed'];
|
||||||
|
|
||||||
public function applyPreset(string $preset): void
|
public function applyPreset(string $preset): void
|
||||||
{
|
{
|
||||||
|
|
@ -110,6 +112,16 @@ public function articleStats(): array
|
||||||
return app(DashboardStatsService::class)->getStats($range);
|
return app(DashboardStatsService::class)->getStats($range);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function articlesPerFeed(): BreakdownResult
|
||||||
|
{
|
||||||
|
$range = $this->range();
|
||||||
|
|
||||||
|
return $range instanceof DateRange
|
||||||
|
? app(ArticlesPerFeed::class)->for($range)
|
||||||
|
: new BreakdownResult([]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, int>
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -182,4 +182,37 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
</div>
|
</div>
|
||||||
@endisland
|
@endisland
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Articles per Feed -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
|
||||||
|
|
||||||
|
@island('articles-per-feed')
|
||||||
|
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
|
||||||
|
@if ($this->articlesPerFeed->rows === [])
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No feeds are configured yet.</p>
|
||||||
|
@else
|
||||||
|
<ul class="space-y-3">
|
||||||
|
@foreach ($this->articlesPerFeed->rows as $row)
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">{{ $row->label }}</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $row->count }}
|
||||||
|
<span class="ml-1 text-xs">({{ $this->articlesPerFeed->shareOf($row) }}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-blue-500"
|
||||||
|
style="width: {{ $this->articlesPerFeed->shareOf($row) }}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endisland
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Livewire\Dashboard;
|
use App\Livewire\Dashboard;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
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;
|
||||||
|
|
@ -178,6 +179,50 @@ public function test_it_refreshes_only_the_range_dependent_island(): void
|
||||||
$this->assertStringNotContainsString('Active Feeds', $fragments);
|
$this->assertStringNotContainsString('Active Feeds', $fragments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_renders_the_articles_per_feed_breakdown_on_mount(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
$feed = Feed::factory()->create(['name' => 'Example Feed']);
|
||||||
|
|
||||||
|
Article::factory()->count(2)->create([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'created_at' => Carbon::parse('2026-07-15 09:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->assertSee('Example Feed')
|
||||||
|
->assertSee('Articles per Feed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_counts_articles_per_feed_for_the_selected_range(): void
|
||||||
|
{
|
||||||
|
$feed = Feed::factory()->create(['name' => 'Example Feed']);
|
||||||
|
|
||||||
|
Article::factory()->count(2)->create([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'created_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->assertMatchesRegularExpression('/Example Feed.*?>\s*2\s*/s', $fragments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_refreshes_the_articles_per_feed_island_when_the_range_changes(): void
|
||||||
|
{
|
||||||
|
Feed::factory()->create(['name' => 'Example Feed']);
|
||||||
|
|
||||||
|
$fragments = $this->islandFragments(
|
||||||
|
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('name=articles-per-feed|', $fragments);
|
||||||
|
$this->assertStringContainsString('Example Feed', $fragments);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_re_renders_the_article_statistics_island_when_a_preset_is_applied(): void
|
public function test_it_re_renders_the_article_statistics_island_when_a_preset_is_applied(): void
|
||||||
{
|
{
|
||||||
Carbon::setTestNow('2026-07-15 13:45:00');
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
|
||||||
149
tests/Unit/Dashboard/Stats/ArticlesPerFeedTest.php
Normal file
149
tests/Unit/Dashboard/Stats/ArticlesPerFeedTest.php
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Dashboard\Stats\ArticlesPerFeed;
|
||||||
|
use App\Dashboard\Stats\Breakdown;
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Support\DateRange;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ArticlesPerFeedTest 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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_counts_articles_per_feed_within_the_range(): void
|
||||||
|
{
|
||||||
|
$busy = Feed::factory()->create(['name' => 'Busy Feed']);
|
||||||
|
$quiet = Feed::factory()->create(['name' => 'Quiet Feed']);
|
||||||
|
|
||||||
|
Article::factory()->count(3)->create([
|
||||||
|
'feed_id' => $busy->id,
|
||||||
|
'created_at' => Carbon::parse('2026-07-10 12:00:00'),
|
||||||
|
]);
|
||||||
|
Article::factory()->create([
|
||||||
|
'feed_id' => $quiet->id,
|
||||||
|
'created_at' => Carbon::parse('2026-07-11 12:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = (new ArticlesPerFeed)->for($this->range())->rows;
|
||||||
|
|
||||||
|
$this->assertSame(['Busy Feed' => 3, 'Quiet Feed' => 1], $this->pluck($rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_zero_for_a_feed_with_no_articles_in_the_range(): void
|
||||||
|
{
|
||||||
|
Feed::factory()->create(['name' => 'Silent Feed']);
|
||||||
|
|
||||||
|
$rows = (new ArticlesPerFeed)->for($this->range())->rows;
|
||||||
|
|
||||||
|
$this->assertSame(['Silent Feed' => 0], $this->pluck($rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_excludes_articles_outside_the_range(): void
|
||||||
|
{
|
||||||
|
$feed = Feed::factory()->create(['name' => 'Feed']);
|
||||||
|
|
||||||
|
Article::factory()->create([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'created_at' => Carbon::parse('2026-06-30 23:59:59'),
|
||||||
|
]);
|
||||||
|
Article::factory()->create([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'created_at' => Carbon::parse('2026-08-01 00:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = (new ArticlesPerFeed)->for($this->range())->rows;
|
||||||
|
|
||||||
|
$this->assertSame(['Feed' => 0], $this->pluck($rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_orders_feeds_by_count_descending(): void
|
||||||
|
{
|
||||||
|
$low = Feed::factory()->create(['name' => 'Low']);
|
||||||
|
$high = Feed::factory()->create(['name' => 'High']);
|
||||||
|
|
||||||
|
Article::factory()->create([
|
||||||
|
'feed_id' => $low->id,
|
||||||
|
'created_at' => Carbon::parse('2026-07-10 12:00:00'),
|
||||||
|
]);
|
||||||
|
Article::factory()->count(5)->create([
|
||||||
|
'feed_id' => $high->id,
|
||||||
|
'created_at' => Carbon::parse('2026-07-10 12:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = (new ArticlesPerFeed)->for($this->range())->rows;
|
||||||
|
|
||||||
|
$this->assertSame(['High', 'Low'], array_map(fn ($row) => $row->label, $rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_breaks_ties_alphabetically(): void
|
||||||
|
{
|
||||||
|
foreach (['Zulu', 'Alpha', 'Mike'] as $name) {
|
||||||
|
$feed = Feed::factory()->create(['name' => $name]);
|
||||||
|
|
||||||
|
Article::factory()->create([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'created_at' => Carbon::parse('2026-07-10 12:00:00'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = (new ArticlesPerFeed)->for($this->range())->rows;
|
||||||
|
|
||||||
|
$this->assertSame(['Alpha', 'Mike', 'Zulu'], array_map(fn ($row) => $row->label, $rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_the_range_total(): void
|
||||||
|
{
|
||||||
|
$feed = Feed::factory()->create();
|
||||||
|
|
||||||
|
Article::factory()->count(4)->create([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'created_at' => Carbon::parse('2026-07-10 12:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(4, (new ArticlesPerFeed)->for($this->range())->total());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_a_zero_total_with_no_feeds(): void
|
||||||
|
{
|
||||||
|
$result = (new ArticlesPerFeed)->for($this->range());
|
||||||
|
|
||||||
|
$this->assertSame(0, $result->total());
|
||||||
|
$this->assertSame([], $result->rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_exposes_a_stable_key_and_label(): void
|
||||||
|
{
|
||||||
|
$stat = new ArticlesPerFeed;
|
||||||
|
|
||||||
|
$this->assertSame('articles-per-feed', $stat->key());
|
||||||
|
$this->assertSame('Articles per Feed', $stat->label());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Breakdown> $rows
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function pluck(array $rows): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$out[$row->label] = $row->count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tests/Unit/Dashboard/Stats/BreakdownResultTest.php
Normal file
58
tests/Unit/Dashboard/Stats/BreakdownResultTest.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Dashboard\Stats\Breakdown;
|
||||||
|
use App\Dashboard\Stats\BreakdownResult;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class BreakdownResultTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_sums_its_rows(): void
|
||||||
|
{
|
||||||
|
$result = new BreakdownResult([
|
||||||
|
new Breakdown('A', 3),
|
||||||
|
new Breakdown('B', 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(7, $result->total());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_a_zero_total_when_empty(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(0, (new BreakdownResult([]))->total());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_calculates_a_row_share_of_the_total(): void
|
||||||
|
{
|
||||||
|
$result = new BreakdownResult([
|
||||||
|
new Breakdown('A', 3),
|
||||||
|
new Breakdown('B', 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(75.0, $result->shareOf($result->rows[0]));
|
||||||
|
$this->assertSame(25.0, $result->shareOf($result->rows[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_a_zero_share_when_nothing_was_counted(): void
|
||||||
|
{
|
||||||
|
$result = new BreakdownResult([new Breakdown('A', 0)]);
|
||||||
|
|
||||||
|
$this->assertSame(0.0, $result->shareOf($result->rows[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_has_no_data_without_rows(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue((new BreakdownResult([]))->isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_has_no_data_when_every_row_is_zero(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue((new BreakdownResult([new Breakdown('A', 0)]))->isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_has_data_when_any_row_is_counted(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse((new BreakdownResult([new Breakdown('A', 1)]))->isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tests/Unit/Dashboard/Stats/StatContractTest.php
Normal file
33
tests/Unit/Dashboard/Stats/StatContractTest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Dashboard\Stats\ArticlesPerFeed;
|
||||||
|
use App\Dashboard\Stats\BreakdownStat;
|
||||||
|
use App\Dashboard\Stats\Stat;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class StatContractTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_the_base_contract_only_promises_identity(): void
|
||||||
|
{
|
||||||
|
$methods = array_map(
|
||||||
|
fn (\ReflectionMethod $method): string => $method->getName(),
|
||||||
|
(new \ReflectionClass(Stat::class))->getMethods(),
|
||||||
|
);
|
||||||
|
|
||||||
|
sort($methods);
|
||||||
|
|
||||||
|
$this->assertSame(['key', 'label'], $methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_breakdown_stat_is_a_stat(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(is_subclass_of(BreakdownStat::class, Stat::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_articles_per_feed_is_a_breakdown_stat(): void
|
||||||
|
{
|
||||||
|
$this->assertInstanceOf(BreakdownStat::class, new ArticlesPerFeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue