Release v1.4.0 #146
10 changed files with 331 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -26,3 +26,4 @@ yarn-error.log
|
|||
/.php-cs-fixer.dist.php
|
||||
/.php-cs-fixer.cache
|
||||
/.codewhale
|
||||
.aider*
|
||||
|
|
|
|||
22
app/Dashboard/Stats/DailySeriesStat.php
Normal file
22
app/Dashboard/Stats/DailySeriesStat.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Dashboard\Stats;
|
||||
|
||||
use App\Support\DateRange;
|
||||
|
||||
abstract class DailySeriesStat implements SeriesStat
|
||||
{
|
||||
final public function for(DateRange $range): SeriesResult
|
||||
{
|
||||
if (! $range->isBucketableByDay()) {
|
||||
return SeriesResult::tooWide();
|
||||
}
|
||||
|
||||
return $this->series($range, $range->days());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $days
|
||||
*/
|
||||
abstract protected function series(DateRange $range, array $days): SeriesResult;
|
||||
}
|
||||
14
app/Dashboard/Stats/Series.php
Normal file
14
app/Dashboard/Stats/Series.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Dashboard\Stats;
|
||||
|
||||
class Series
|
||||
{
|
||||
/**
|
||||
* @param array<int, int|float|null> $values
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly array $values,
|
||||
) {}
|
||||
}
|
||||
45
app/Dashboard/Stats/SeriesResult.php
Normal file
45
app/Dashboard/Stats/SeriesResult.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Dashboard\Stats;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class SeriesResult
|
||||
{
|
||||
private bool $tooWide = false;
|
||||
|
||||
/**
|
||||
* @param array<int, string> $labels
|
||||
* @param array<int, Series> $series
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $labels,
|
||||
public readonly array $series,
|
||||
) {
|
||||
foreach ($series as $one) {
|
||||
if (count($one->values) !== count($labels)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Series [{$one->name}] has ".count($one->values).' values for '.count($labels).' labels.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function tooWide(): self
|
||||
{
|
||||
$result = new self([], []);
|
||||
$result->tooWide = true;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function isTooWide(): bool
|
||||
{
|
||||
return $this->tooWide;
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return ! $this->tooWide && $this->labels === [];
|
||||
}
|
||||
}
|
||||
10
app/Dashboard/Stats/SeriesStat.php
Normal file
10
app/Dashboard/Stats/SeriesStat.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Dashboard\Stats;
|
||||
|
||||
use App\Support\DateRange;
|
||||
|
||||
interface SeriesStat extends Stat
|
||||
{
|
||||
public function for(DateRange $range): SeriesResult;
|
||||
}
|
||||
|
|
@ -46,6 +46,11 @@ public static function presets(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function isBucketableByDay(): bool
|
||||
{
|
||||
return $this->from->copy()->startOfDay()->diffInDays($this->to->copy()->startOfDay()) < self::MAX_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day the range touches, as Y-m-d, so callers can zero-fill empty buckets.
|
||||
*
|
||||
|
|
@ -57,7 +62,7 @@ public function days(): array
|
|||
$cursor = $this->from->copy()->startOfDay();
|
||||
$last = $this->to->copy()->startOfDay();
|
||||
|
||||
if ($cursor->diffInDays($last) >= self::MAX_DAYS) {
|
||||
if (! $this->isBucketableByDay()) {
|
||||
throw new InvalidArgumentException(
|
||||
'A range wider than '.self::MAX_DAYS.' days cannot be bucketed by day; bucket by month instead.'
|
||||
);
|
||||
|
|
|
|||
121
tests/Unit/Dashboard/Stats/DailySeriesStatTest.php
Normal file
121
tests/Unit/Dashboard/Stats/DailySeriesStatTest.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Dashboard\Stats;
|
||||
|
||||
use App\Dashboard\Stats\DailySeriesStat;
|
||||
use App\Dashboard\Stats\Series;
|
||||
use App\Dashboard\Stats\SeriesResult;
|
||||
use App\Support\DateRange;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DailySeriesStatTest extends TestCase
|
||||
{
|
||||
private function bucketableRange(): DateRange
|
||||
{
|
||||
return new DateRange(
|
||||
Carbon::parse('2026-07-01 00:00:00'),
|
||||
Carbon::parse('2026-07-07 23:59:59'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_for_calls_series_and_returns_its_result_on_a_bucketable_range(): void
|
||||
{
|
||||
$range = $this->bucketableRange();
|
||||
$expected = new SeriesResult($range->days(), [new Series('Approved', [1, 2, 3, 4, 5, 6, 7])]);
|
||||
|
||||
$stat = new class($expected) extends DailySeriesStat
|
||||
{
|
||||
public bool $seriesWasCalled = false;
|
||||
|
||||
public function __construct(private readonly SeriesResult $result) {}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'fake';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Fake';
|
||||
}
|
||||
|
||||
protected function series(DateRange $range, array $days): SeriesResult
|
||||
{
|
||||
$this->seriesWasCalled = true;
|
||||
|
||||
return $this->result;
|
||||
}
|
||||
};
|
||||
|
||||
$result = $stat->for($range);
|
||||
|
||||
$this->assertTrue($stat->seriesWasCalled);
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
public function test_for_returns_a_too_wide_result_without_calling_series_on_a_non_bucketable_range(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||
$range = DateRange::preset('all');
|
||||
|
||||
$stat = new class extends DailySeriesStat
|
||||
{
|
||||
public bool $seriesWasCalled = false;
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'fake';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Fake';
|
||||
}
|
||||
|
||||
protected function series(DateRange $range, array $days): SeriesResult
|
||||
{
|
||||
$this->seriesWasCalled = true;
|
||||
|
||||
return new SeriesResult([], []);
|
||||
}
|
||||
};
|
||||
|
||||
$result = $stat->for($range);
|
||||
|
||||
$this->assertFalse($stat->seriesWasCalled);
|
||||
$this->assertTrue($result->isTooWide());
|
||||
}
|
||||
|
||||
public function test_for_passes_the_ranges_days_through_to_series(): void
|
||||
{
|
||||
$range = $this->bucketableRange();
|
||||
|
||||
$stat = new class extends DailySeriesStat
|
||||
{
|
||||
/** @var array<int, string>|null */
|
||||
public ?array $receivedDays = null;
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'fake';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Fake';
|
||||
}
|
||||
|
||||
protected function series(DateRange $range, array $days): SeriesResult
|
||||
{
|
||||
$this->receivedDays = $days;
|
||||
|
||||
return new SeriesResult($days, []);
|
||||
}
|
||||
};
|
||||
|
||||
$stat->for($range);
|
||||
|
||||
$this->assertSame($range->days(), $stat->receivedDays);
|
||||
}
|
||||
}
|
||||
63
tests/Unit/Dashboard/Stats/SeriesResultTest.php
Normal file
63
tests/Unit/Dashboard/Stats/SeriesResultTest.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Dashboard\Stats;
|
||||
|
||||
use App\Dashboard\Stats\Series;
|
||||
use App\Dashboard\Stats\SeriesResult;
|
||||
use InvalidArgumentException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SeriesResultTest extends TestCase
|
||||
{
|
||||
public function test_a_series_result_aligns_every_series_to_the_shared_label_axis(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new SeriesResult(
|
||||
['2026-07-01', '2026-07-02', '2026-07-03'],
|
||||
[new Series('Approved', [1, 2])],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_a_series_result_accepts_multiple_series_that_all_align(): void
|
||||
{
|
||||
$result = new SeriesResult(
|
||||
['2026-07-01', '2026-07-02'],
|
||||
[
|
||||
new Series('Approved', [1, 2]),
|
||||
new Series('Rejected', [0, 1]),
|
||||
],
|
||||
);
|
||||
|
||||
$this->assertSame(['2026-07-01', '2026-07-02'], $result->labels);
|
||||
$this->assertCount(2, $result->series);
|
||||
}
|
||||
|
||||
public function test_a_too_wide_result_reports_itself_as_too_wide(): void
|
||||
{
|
||||
$result = SeriesResult::tooWide();
|
||||
|
||||
$this->assertTrue($result->isTooWide());
|
||||
}
|
||||
|
||||
public function test_a_too_wide_result_is_not_reported_as_merely_empty(): void
|
||||
{
|
||||
$result = SeriesResult::tooWide();
|
||||
|
||||
$this->assertFalse($result->isEmpty());
|
||||
}
|
||||
|
||||
public function test_an_empty_but_valid_result_is_not_reported_as_too_wide(): void
|
||||
{
|
||||
$result = new SeriesResult([], []);
|
||||
|
||||
$this->assertFalse($result->isTooWide());
|
||||
}
|
||||
|
||||
public function test_an_empty_but_valid_result_reports_itself_as_empty(): void
|
||||
{
|
||||
$result = new SeriesResult([], []);
|
||||
|
||||
$this->assertTrue($result->isEmpty());
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Dashboard\Stats\ArticlesPerFeed;
|
||||
use App\Dashboard\Stats\BreakdownStat;
|
||||
use App\Dashboard\Stats\SeriesStat;
|
||||
use App\Dashboard\Stats\Stat;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -30,4 +31,9 @@ public function test_articles_per_feed_is_a_breakdown_stat(): void
|
|||
{
|
||||
$this->assertInstanceOf(BreakdownStat::class, new ArticlesPerFeed);
|
||||
}
|
||||
|
||||
public function test_a_series_stat_is_a_stat(): void
|
||||
{
|
||||
$this->assertTrue(is_subclass_of(SeriesStat::class, Stat::class));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,4 +183,47 @@ public function test_it_refuses_to_list_days_for_the_all_preset(): void
|
|||
|
||||
DateRange::preset('all')->days();
|
||||
}
|
||||
|
||||
public function test_it_is_bucketable_by_day_for_a_normal_range(): void
|
||||
{
|
||||
$this->assertTrue(DateRange::preset('week')->isBucketableByDay());
|
||||
$this->assertTrue(DateRange::preset('month')->isBucketableByDay());
|
||||
}
|
||||
|
||||
public function test_it_is_bucketable_by_day_at_the_maximum_span(): void
|
||||
{
|
||||
$from = Carbon::parse('2026-01-01 00:00:00');
|
||||
$range = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS - 1)->endOfDay());
|
||||
|
||||
$this->assertTrue($range->isBucketableByDay());
|
||||
}
|
||||
|
||||
public function test_it_is_not_bucketable_by_day_one_day_beyond_the_maximum_span(): void
|
||||
{
|
||||
$from = Carbon::parse('2026-01-01 00:00:00');
|
||||
$range = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS)->endOfDay());
|
||||
|
||||
$this->assertFalse($range->isBucketableByDay());
|
||||
}
|
||||
|
||||
public function test_it_is_not_bucketable_by_day_for_the_all_preset(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||
|
||||
$this->assertFalse(DateRange::preset('all')->isBucketableByDay());
|
||||
}
|
||||
|
||||
public function test_is_bucketable_by_day_agrees_with_days_at_every_boundary(): void
|
||||
{
|
||||
$from = Carbon::parse('2026-01-01 00:00:00');
|
||||
|
||||
$withinBounds = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS - 1)->endOfDay());
|
||||
$withinBounds->days();
|
||||
$this->assertTrue($withinBounds->isBucketableByDay());
|
||||
|
||||
$beyondBounds = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS)->endOfDay());
|
||||
$this->assertFalse($beyondBounds->isBucketableByDay());
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$beyondBounds->days();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue