feat: add reusable gallery carousel and ranking feed infrastructure

This commit is contained in:
2026-02-28 07:56:25 +01:00
parent 67ef79766c
commit 6536d4ae78
36 changed files with 3177 additions and 373 deletions

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Ranking;
use App\Models\Artwork;
use App\Models\RankList;
use App\Models\User;
use App\Services\RankingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Tests for the ranking system API endpoints and service logic.
*
* Covered:
* 1. GET /api/rank/global returns artworks in the pre-ranked order.
* 2. RankingService::applyDiversity() enforces max-per-author cap.
* 3. Fallback to latest when no rank_list row exists for a scope.
*/
class RankGlobalTrendingTest extends TestCase
{
use RefreshDatabase;
// ── Test 1: ranked order ───────────────────────────────────────────────
/**
* A stored rank_list drives the response order.
* The controller must return artworks in the same sequence as artwork_ids.
*/
public function test_global_trending_returns_artworks_in_ranked_order(): void
{
$user = User::factory()->create();
// Create three artworks in chronological order (oldest first)
$oldest = Artwork::factory()->for($user)->create([
'published_at' => now()->subDays(3),
]);
$middle = Artwork::factory()->for($user)->create([
'published_at' => now()->subDays(2),
]);
$newest = Artwork::factory()->for($user)->create([
'published_at' => now()->subDays(1),
]);
// The rank list ranks them in a non-chronological order to prove it is respected:
// newest > oldest > middle
$rankedOrder = [$newest->id, $oldest->id, $middle->id];
RankList::create([
'scope_type' => 'global',
'scope_id' => 0,
'list_type' => 'trending',
'model_version' => 'rank_v1',
'artwork_ids' => $rankedOrder,
'computed_at' => now(),
]);
$response = $this->getJson('/api/rank/global?type=trending');
$response->assertOk();
$returnedIds = collect($response->json('data'))
->pluck('slug')
->all();
// Retrieve slugs in the expected order for comparison
$expectedSlugs = Artwork::whereIn('id', $rankedOrder)
->get()
->keyBy('id')
->pipe(fn ($keyed) => array_map(fn ($id) => $keyed[$id]->slug, $rankedOrder));
$this->assertSame($expectedSlugs, $returnedIds,
'Artworks must be returned in the exact pre-ranked order.'
);
// Meta block is present
$response->assertJsonPath('meta.list_type', 'trending');
$response->assertJsonPath('meta.fallback', false);
$response->assertJsonPath('meta.model_version', 'rank_v1');
}
// ── Test 2: diversity constraint ───────────────────────────────────────
/**
* applyDiversity() must cap the number of artworks per author
* at config('ranking.diversity.max_per_author') = 3.
*
* Given 5 artworks from the same author, only 3 should pass through.
*/
public function test_diversity_constraint_caps_items_per_author(): void
{
/** @var RankingService $service */
$service = app(RankingService::class);
$maxPerAuthor = (int) config('ranking.diversity.max_per_author', 3);
$listSize = 50;
// Build fake candidates: 5 from author 1, 3 from author 2
$candidates = [];
for ($i = 1; $i <= 5; $i++) {
$candidates[] = (object) ['artwork_id' => $i, 'user_id' => 1];
}
for ($i = 6; $i <= 8; $i++) {
$candidates[] = (object) ['artwork_id' => $i, 'user_id' => 2];
}
$result = $service->applyDiversity($candidates, $maxPerAuthor, $listSize);
$authorTotals = array_count_values(
array_map(fn ($item) => (int) $item->user_id, $result)
);
foreach ($authorTotals as $authorId => $count) {
$this->assertLessThanOrEqual(
$maxPerAuthor,
$count,
"Author {$authorId} appears {$count} times, but max is {$maxPerAuthor}."
);
}
// Exactly 3 from author 1 + 3 from author 2 = 6 total
$this->assertCount(6, $result);
}
// ── Test 3: fallback to latest ─────────────────────────────────────────
/**
* When no rank_list row exists for the requested scope, the controller
* falls back to latest-published artworks and signals this in the meta.
*/
public function test_global_trending_falls_back_to_latest_when_no_rank_list_exists(): void
{
$user = User::factory()->create();
// Create artworks in a known order so we can verify fallback ordering
$first = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(3)]);
$second = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(2)]);
$third = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(1)]);
// Deliberately leave rank_lists table empty
$response = $this->getJson('/api/rank/global?type=trending');
$response->assertOk();
// Meta must indicate fallback
$response->assertJsonPath('meta.fallback', true);
$response->assertJsonPath('meta.model_version', 'fallback');
// Artworks must appear in published_at DESC order (third, second, first)
$returnedIds = collect($response->json('data'))->pluck('slug')->all();
$this->assertSame(
[$third->slug, $second->slug, $first->slug],
$returnedIds,
'Fallback must return artworks in latest-first order.'
);
}
}