create(array_merge([ 'user_id' => $user->id, 'title' => $title, 'visibility' => Artwork::VISIBILITY_PUBLIC, 'is_public' => true, 'is_approved' => true, 'artwork_status' => 'published', 'published_at' => Carbon::parse($publishedAt), ], $overrides)); } it('syncs a primary artwork evolution relation from the studio save endpoint', function (): void { $user = User::factory()->create(); $original = makeEvolutionPublishedArtwork($user, 'Original version', '2021-04-11 09:00:00'); $updated = makeEvolutionPublishedArtwork($user, 'Updated version', '2025-04-11 09:00:00'); actingAs($user); putJson('/api/studio/artworks/' . $updated->id, [ 'evolution_target_artwork_id' => $original->id, 'evolution_relation_type' => ArtworkRelation::TYPE_REMASTER_OF, 'evolution_note' => 'Much cleaner materials and a stronger silhouette.', ]) ->assertOk() ->assertJsonPath('artwork.evolution_relation.relation_type', ArtworkRelation::TYPE_REMASTER_OF) ->assertJsonPath('artwork.evolution_relation.target_artwork.id', $original->id) ->assertJsonPath('artwork.evolution_relation.note', 'Much cleaner materials and a stronger silhouette.'); $this->assertDatabaseHas('artwork_relations', [ 'source_artwork_id' => $updated->id, 'target_artwork_id' => $original->id, 'relation_type' => ArtworkRelation::TYPE_REMASTER_OF, 'note' => 'Much cleaner materials and a stronger silhouette.', 'created_by_user_id' => $user->id, ]); }); it('rejects linking an artwork the actor does not manage', function (): void { $owner = User::factory()->create(); $otherUser = User::factory()->create(); $updated = makeEvolutionPublishedArtwork($owner, 'My newer piece', '2025-04-11 09:00:00'); $foreignOriginal = makeEvolutionPublishedArtwork($otherUser, 'Someone else original', '2020-04-11 09:00:00'); actingAs($owner); putJson('/api/studio/artworks/' . $updated->id, [ 'evolution_target_artwork_id' => $foreignOriginal->id, 'evolution_relation_type' => ArtworkRelation::TYPE_REMAKE_OF, ]) ->assertStatus(422) ->assertJsonPath('errors.evolution_target_artwork_id.0', 'You can only link artworks that you are allowed to manage.'); $this->assertDatabaseCount('artwork_relations', 0); }); it('limits evolution options to older manageable public artworks', function (): void { $user = User::factory()->create(); $otherUser = User::factory()->create(); $source = makeEvolutionPublishedArtwork($user, 'Current artwork', '2025-04-11 09:00:00'); $eligible = makeEvolutionPublishedArtwork($user, 'Older eligible version', '2021-04-11 09:00:00'); $tooNew = makeEvolutionPublishedArtwork($user, 'Too new version', '2026-04-11 09:00:00'); makeEvolutionPublishedArtwork($user, 'Private original', '2020-04-11 09:00:00', [ 'visibility' => Artwork::VISIBILITY_PRIVATE, 'is_public' => false, ]); makeEvolutionPublishedArtwork($otherUser, 'Other user original', '2020-04-11 09:00:00'); actingAs($user); getJson('/api/studio/artworks/' . $source->id . '/evolution-options') ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $eligible->id) ->assertJsonMissing(['id' => $tooNew->id]); }); it('sorts evolution options by vector similarity when available', function (): void { config()->set('vision.vector_gateway.enabled', true); config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net'); config()->set('vision.vector_gateway.api_key', 'test-key'); config()->set('vision.vector_gateway.search_endpoint', '/vectors/search'); config()->set('cdn.files_url', 'https://files.skinbase.org'); $user = User::factory()->create(); $source = makeEvolutionPublishedArtwork($user, 'Current artwork', '2025-04-11 09:00:00', [ 'hash' => 'sourcehash1234', 'thumb_ext' => 'webp', ]); $recentButLessSimilar = makeEvolutionPublishedArtwork($user, 'Recent candidate', '2024-04-11 09:00:00'); $olderButMoreSimilar = makeEvolutionPublishedArtwork($user, 'Most similar original', '2021-04-11 09:00:00'); Http::fake([ 'https://vision.klevze.net/vectors/search' => Http::response([ 'results' => [ ['id' => $source->id, 'score' => 1.0], ['id' => $olderButMoreSimilar->id, 'score' => 0.98123], ['id' => $recentButLessSimilar->id, 'score' => 0.61234], ], ], 200), ]); actingAs($user); getJson('/api/studio/artworks/' . $source->id . '/evolution-options') ->assertOk() ->assertJsonPath('data.0.id', $olderButMoreSimilar->id) ->assertJsonPath('data.0.sort_source', 'vector_similarity') ->assertJsonPath('data.0.similarity_score', 0.98123) ->assertJsonPath('data.1.id', $recentButLessSimilar->id); }); it('keeps deeper vector matches ahead of recency fallback in evolution options', function (): void { config()->set('vision.vector_gateway.enabled', true); config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net'); config()->set('vision.vector_gateway.api_key', 'test-key'); config()->set('vision.vector_gateway.search_endpoint', '/vectors/search'); config()->set('cdn.files_url', 'https://files.skinbase.org'); $user = User::factory()->create(); $source = makeEvolutionPublishedArtwork($user, 'Current artwork', '2025-04-11 09:00:00', [ 'hash' => 'sourcehash5678', 'thumb_ext' => 'webp', ]); $recentFallback = makeEvolutionPublishedArtwork($user, 'Recent fallback candidate', '2024-04-11 09:00:00'); $deepSimilarMatch = makeEvolutionPublishedArtwork($user, 'Deep similar original', '2020-04-11 09:00:00'); $results = collect(range(1, 28))->map(fn (int $offset): array => [ 'id' => 900000 + $offset, 'score' => 0.95 - ($offset * 0.01), ])->all(); array_unshift($results, ['id' => $source->id, 'score' => 1.0]); $results[] = ['id' => $deepSimilarMatch->id, 'score' => 0.73123]; $results[] = ['id' => $recentFallback->id, 'score' => 0.51234]; Http::fake([ 'https://vision.klevze.net/vectors/search' => Http::response([ 'results' => $results, ], 200), ]); actingAs($user); getJson('/api/studio/artworks/' . $source->id . '/evolution-options') ->assertOk() ->assertJsonPath('data.0.id', $deepSimilarMatch->id) ->assertJsonPath('data.0.sort_source', 'vector_similarity') ->assertJsonPath('data.1.id', $recentFallback->id); }); it('includes bidirectional artwork evolution payloads on the public page api', function (): void { $user = User::factory()->create(); $original = makeEvolutionPublishedArtwork($user, 'Original scene', '2019-04-11 09:00:00'); $updated = makeEvolutionPublishedArtwork($user, 'Updated scene', '2025-04-11 09:00:00'); ArtworkRelation::query()->create([ 'source_artwork_id' => $updated->id, 'target_artwork_id' => $original->id, 'relation_type' => ArtworkRelation::TYPE_REMAKE_OF, 'note' => 'Rebuilt from scratch with a new lighting pass.', 'sort_order' => 0, 'created_by_user_id' => $user->id, ]); getJson('/api/artworks/' . $updated->id . '/page') ->assertOk() ->assertJsonPath('evolution.primary.before.id', $original->id) ->assertJsonPath('evolution.primary.after.id', $updated->id) ->assertJsonPath('evolution.primary.heading', 'Then & Now'); getJson('/api/artworks/' . $original->id . '/page') ->assertOk() ->assertJsonPath('evolution.updates.0.before.id', $original->id) ->assertJsonPath('evolution.updates.0.after.id', $updated->id) ->assertJsonPath('evolution.updates.0.heading', 'Updated Version'); });