431 lines
15 KiB
PHP
431 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\RecBuildItemPairsFromFavouritesJob;
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkFavourite;
|
|
use App\Models\Category;
|
|
use App\Models\RecArtworkRec;
|
|
use App\Models\RecItemPair;
|
|
use App\Models\Tag;
|
|
use App\Models\User;
|
|
use App\Services\Recommendations\HybridSimilarArtworksService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config(['scout.driver' => 'null']);
|
|
Cache::flush();
|
|
});
|
|
|
|
// ─── Helper ────────────────────────────────────────────────────────────────────
|
|
|
|
function createPublicArtwork(array $attrs = []): Artwork
|
|
{
|
|
return Artwork::withoutEvents(function () use ($attrs) {
|
|
return Artwork::factory()->create(array_merge([
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now()->subHour(),
|
|
], $attrs));
|
|
});
|
|
}
|
|
|
|
// ─── API returns fallback if precomputed list is missing ───────────────────────
|
|
|
|
it('returns fallback results when no precomputed similar list exists', function () {
|
|
$artwork = createPublicArtwork();
|
|
|
|
// Create some other artworks so the trending fallback can find them
|
|
$other1 = createPublicArtwork(['published_at' => now()->subMinutes(10)]);
|
|
$other2 = createPublicArtwork(['published_at' => now()->subMinutes(20)]);
|
|
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork($artwork->id, 12);
|
|
|
|
// Should still return artworks via trending fallback, not an empty set
|
|
expect($result)->toBeInstanceOf(\Illuminate\Support\Collection::class)
|
|
->and($result)->not->toBeEmpty();
|
|
});
|
|
|
|
it('returns empty collection for non-existent artwork', function () {
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork(999999, 12);
|
|
|
|
expect($result)->toBeEmpty();
|
|
});
|
|
|
|
it('returns similar_tags list when hybrid is missing', function () {
|
|
$artwork = createPublicArtwork();
|
|
$similar1 = createPublicArtwork();
|
|
$similar2 = createPublicArtwork();
|
|
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_tags',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$similar1->id, $similar2->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork($artwork->id, 12);
|
|
|
|
expect($result)->toHaveCount(2)
|
|
->and($result->pluck('id')->all())->toEqual([$similar1->id, $similar2->id]);
|
|
});
|
|
|
|
// ─── Ordering is preserved ─────────────────────────────────────────────────────
|
|
|
|
it('preserves precomputed ordering exactly', function () {
|
|
$artwork = createPublicArtwork();
|
|
$a = createPublicArtwork();
|
|
$b = createPublicArtwork();
|
|
$c = createPublicArtwork();
|
|
$d = createPublicArtwork();
|
|
|
|
// Deliberate non-sequential order
|
|
$orderedIds = [$c->id, $a->id, $d->id, $b->id];
|
|
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_hybrid',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => $orderedIds,
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork($artwork->id, 12);
|
|
|
|
expect($result->pluck('id')->all())->toEqual($orderedIds);
|
|
});
|
|
|
|
it('falls through from hybrid to tags preserving order', function () {
|
|
$artwork = createPublicArtwork();
|
|
$a = createPublicArtwork();
|
|
$b = createPublicArtwork();
|
|
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_tags',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$b->id, $a->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork($artwork->id, 12);
|
|
|
|
expect($result->pluck('id')->all())->toEqual([$b->id, $a->id]);
|
|
});
|
|
|
|
// ─── Diversity cap (max per author) is enforced ────────────────────────────────
|
|
|
|
it('enforces author diversity cap at runtime', function () {
|
|
$artwork = createPublicArtwork();
|
|
|
|
// One author with 4 artworks
|
|
$author = User::factory()->create();
|
|
$sameAuthor1 = createPublicArtwork(['user_id' => $author->id]);
|
|
$sameAuthor2 = createPublicArtwork(['user_id' => $author->id]);
|
|
$sameAuthor3 = createPublicArtwork(['user_id' => $author->id]);
|
|
$sameAuthor4 = createPublicArtwork(['user_id' => $author->id]);
|
|
|
|
// Another author with 1 artwork
|
|
$otherAuthor = User::factory()->create();
|
|
$diffAuthor = createPublicArtwork(['user_id' => $otherAuthor->id]);
|
|
|
|
// Put all 5 in the precomputed list — same author dominates
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_hybrid',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [
|
|
$sameAuthor1->id,
|
|
$sameAuthor2->id,
|
|
$sameAuthor3->id,
|
|
$sameAuthor4->id,
|
|
$diffAuthor->id,
|
|
],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
config(['recommendations.similarity.max_per_author' => 2]);
|
|
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork($artwork->id, 12);
|
|
|
|
// Max 2 from same author, 1 from different author = 3 total
|
|
$resultByAuthor = $result->groupBy('user_id');
|
|
foreach ($resultByAuthor as $authorId => $artworks) {
|
|
expect($artworks->count())->toBeLessThanOrEqual(2);
|
|
}
|
|
expect($result)->toHaveCount(3);
|
|
});
|
|
|
|
// ─── Pair building doesn't explode per user ────────────────────────────────────
|
|
|
|
it('caps pairs per user to avoid combinatorial explosion', function () {
|
|
$user = User::factory()->create();
|
|
|
|
// Create exactly 5 artworks with favourites (bypass observers to avoid SQLite GREATEST issue)
|
|
$artworks = [];
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$art = createPublicArtwork();
|
|
$artworks[] = $art;
|
|
DB::table('artwork_favourites')->insert([
|
|
'user_id' => $user->id,
|
|
'artwork_id' => $art->id,
|
|
'created_at' => now()->subMinutes($i),
|
|
'updated_at' => now()->subMinutes($i),
|
|
]);
|
|
}
|
|
|
|
$job = new RecBuildItemPairsFromFavouritesJob();
|
|
$pairs = $job->pairsForUser($user->id, 5);
|
|
|
|
// C(5,2) = 10 pairs max
|
|
expect($pairs)->toHaveCount(10);
|
|
|
|
// Verify each pair is ordered (a < b)
|
|
foreach ($pairs as [$a, $b]) {
|
|
expect($a)->toBeLessThan($b);
|
|
}
|
|
});
|
|
|
|
it('respects the favourites cap for pair generation', function () {
|
|
$user = User::factory()->create();
|
|
|
|
// Create 10 favourites (bypass observers)
|
|
for ($i = 0; $i < 10; $i++) {
|
|
$art = createPublicArtwork();
|
|
DB::table('artwork_favourites')->insert([
|
|
'user_id' => $user->id,
|
|
'artwork_id' => $art->id,
|
|
'created_at' => now()->subMinutes($i),
|
|
'updated_at' => now()->subMinutes($i),
|
|
]);
|
|
}
|
|
|
|
// Cap at 3 → C(3,2) = 3 pairs
|
|
$job = new RecBuildItemPairsFromFavouritesJob();
|
|
$pairs = $job->pairsForUser($user->id, 3);
|
|
|
|
expect($pairs)->toHaveCount(3);
|
|
});
|
|
|
|
it('returns empty pairs for user with only one favourite', function () {
|
|
$user = User::factory()->create();
|
|
$art = createPublicArtwork();
|
|
DB::table('artwork_favourites')->insert([
|
|
'user_id' => $user->id,
|
|
'artwork_id' => $art->id,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$job = new RecBuildItemPairsFromFavouritesJob();
|
|
$pairs = $job->pairsForUser($user->id, 50);
|
|
|
|
expect($pairs)->toBeEmpty();
|
|
});
|
|
|
|
// ─── API endpoint integration ──────────────────────────────────────────────────
|
|
|
|
it('returns JSON response from API endpoint with precomputed data', function () {
|
|
$artwork = createPublicArtwork();
|
|
$similar = createPublicArtwork();
|
|
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_hybrid',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$similar->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson("/api/art/{$artwork->id}/similar");
|
|
|
|
$response->assertOk()
|
|
->assertJsonStructure(['data'])
|
|
->assertJsonCount(1, 'data');
|
|
});
|
|
|
|
it('returns 404 for non-existent artwork in API', function () {
|
|
$response = $this->getJson('/api/art/999999/similar');
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
|
|
// ─── RecArtworkRec model ───────────────────────────────────────────────────────
|
|
|
|
it('stores and retrieves rec list with correct types', function () {
|
|
$artwork = createPublicArtwork();
|
|
$ids = [10, 20, 30];
|
|
|
|
$rec = RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_hybrid',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => $ids,
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$fresh = RecArtworkRec::find($rec->id);
|
|
expect($fresh->recs)->toBeArray()
|
|
->and($fresh->recs)->toEqual($ids)
|
|
->and($fresh->artwork_id)->toBe($artwork->id);
|
|
});
|
|
|
|
// ─── Fallback priority ─────────────────────────────────────────────────────────
|
|
|
|
it('chooses similar_behavior when tags and hybrid are missing', function () {
|
|
$artwork = createPublicArtwork();
|
|
$beh1 = createPublicArtwork();
|
|
$beh2 = createPublicArtwork();
|
|
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_behavior',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$beh1->id, $beh2->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork($artwork->id, 12);
|
|
|
|
expect($result->pluck('id')->all())->toEqual([$beh1->id, $beh2->id]);
|
|
});
|
|
|
|
it('filters out unpublished artworks from precomputed list', function () {
|
|
$artwork = createPublicArtwork();
|
|
$published = createPublicArtwork();
|
|
$unpublished = Artwork::withoutEvents(function () {
|
|
return Artwork::factory()->unpublished()->create();
|
|
});
|
|
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_hybrid',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$unpublished->id, $published->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork($artwork->id, 12);
|
|
|
|
expect($result->pluck('id')->all())->toEqual([$published->id]);
|
|
});
|
|
|
|
// ─── Type query param support (spec §8) ────────────────────────────────────────
|
|
|
|
it('returns specific rec type when ?type=tags is passed', function () {
|
|
$artwork = createPublicArtwork();
|
|
$tagSimilar = createPublicArtwork();
|
|
$behSimilar = createPublicArtwork();
|
|
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_tags',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$tagSimilar->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_behavior',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$behSimilar->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork($artwork->id, 12, 'tags');
|
|
|
|
expect($result->pluck('id')->all())->toEqual([$tagSimilar->id]);
|
|
});
|
|
|
|
it('returns behavior list when ?type=behavior is passed', function () {
|
|
$artwork = createPublicArtwork();
|
|
$behSimilar = createPublicArtwork();
|
|
$tagSimilar = createPublicArtwork();
|
|
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_tags',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$tagSimilar->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_behavior',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$behSimilar->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$service = app(HybridSimilarArtworksService::class);
|
|
$result = $service->forArtwork($artwork->id, 12, 'behavior');
|
|
|
|
expect($result->pluck('id')->all())->toEqual([$behSimilar->id]);
|
|
});
|
|
|
|
it('passes type query param from API endpoint', function () {
|
|
$artwork = createPublicArtwork();
|
|
$tagSimilar = createPublicArtwork();
|
|
|
|
RecArtworkRec::create([
|
|
'artwork_id' => $artwork->id,
|
|
'rec_type' => 'similar_tags',
|
|
'model_version' => 'sim_v1',
|
|
'recs' => [$tagSimilar->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson("/api/art/{$artwork->id}/similar?type=tags");
|
|
|
|
$response->assertOk()
|
|
->assertJsonCount(1, 'data');
|
|
});
|
|
|
|
// ─── Cosine normalized pair weights ────────────────────────────────────────────
|
|
|
|
it('produces cosine-normalized weights in pair builder', function () {
|
|
// User A: likes artwork 1, 2
|
|
$userA = User::factory()->create();
|
|
$art1 = createPublicArtwork();
|
|
$art2 = createPublicArtwork();
|
|
DB::table('artwork_favourites')->insert([
|
|
['user_id' => $userA->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
|
|
['user_id' => $userA->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
|
|
]);
|
|
|
|
// User B: also likes artwork 1, 2
|
|
$userB = User::factory()->create();
|
|
DB::table('artwork_favourites')->insert([
|
|
['user_id' => $userB->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
|
|
['user_id' => $userB->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
|
|
]);
|
|
|
|
$job = new RecBuildItemPairsFromFavouritesJob();
|
|
$job->handle();
|
|
|
|
$pair = RecItemPair::query()
|
|
->where('a_artwork_id', min($art1->id, $art2->id))
|
|
->where('b_artwork_id', max($art1->id, $art2->id))
|
|
->first();
|
|
|
|
expect($pair)->not->toBeNull();
|
|
// co_like = 2 (both users liked both), likes_A = 2, likes_B = 2
|
|
// S_beh = 2 / sqrt(2 * 2) = 2 / 2 = 1.0
|
|
expect($pair->weight)->toBe(1.0);
|
|
});
|