Defer artwork comments from initial render
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
@@ -8,16 +7,13 @@ use App\Enums\ReactionType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ArtworkNavigationService;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use App\Services\GroupService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -32,6 +28,7 @@ final class ArtworkPageController extends Controller
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
private readonly ArtworkNavigationService $navigation,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response|InertiaResponse
|
||||
@@ -98,12 +95,13 @@ final class ArtworkPageController extends Controller
|
||||
]), 403);
|
||||
}
|
||||
|
||||
// ── Step 2: full load with all relations ───────────────────────────
|
||||
$artwork = Artwork::with(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat'])
|
||||
->where('id', $id)
|
||||
->public()
|
||||
->published()
|
||||
->firstOrFail();
|
||||
// ── Step 2: hydrate the already-validated model ────────────────────
|
||||
// The initial withTrashed lookup is intentionally retained so that
|
||||
// missing, deleted, private, and unpublished artwork keep their
|
||||
// distinct responses. Reusing that model avoids a second identical
|
||||
// primary-key lookup on the public path.
|
||||
$artwork = $raw;
|
||||
$artwork->load(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat']);
|
||||
|
||||
$this->loadCategoryAncestors($artwork->categories);
|
||||
|
||||
@@ -119,12 +117,15 @@ final class ArtworkPageController extends Controller
|
||||
], 301);
|
||||
}
|
||||
|
||||
$thumbMd = ThumbnailPresenter::present($artwork, 'md');
|
||||
$thumbLg = ThumbnailPresenter::present($artwork, 'lg');
|
||||
$thumbXl = ThumbnailPresenter::present($artwork, 'xl');
|
||||
$thumbSq = ThumbnailPresenter::present($artwork, 'sq');
|
||||
|
||||
$artworkData = (new ArtworkResource($artwork))->toArray($request);
|
||||
$navigationData = $this->navigation->navigationFor($artwork);
|
||||
// ArtworkResource is the canonical thumbnail projection. Reuse its
|
||||
// exact values for page props and SEO instead of presenting each
|
||||
// variant a second time in the controller.
|
||||
$thumbMd = $artworkData['thumbs']['md'] ?? [];
|
||||
$thumbLg = $artworkData['thumbs']['lg'] ?? [];
|
||||
$thumbXl = $artworkData['thumbs']['xl'] ?? [];
|
||||
$thumbSq = $artworkData['thumbs']['sq'] ?? [];
|
||||
$groupSummary = null;
|
||||
|
||||
if ($artwork->group) {
|
||||
@@ -206,84 +207,22 @@ final class ArtworkPageController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$approvedComments = ArtworkComment::query()
|
||||
->with('user.profile')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_approved', true)
|
||||
->orderBy('created_at')
|
||||
->limit(500)
|
||||
->get();
|
||||
|
||||
$commentsByParent = $approvedComments->groupBy(
|
||||
static fn (ArtworkComment $comment): string => $comment->parent_id === null
|
||||
? 'root'
|
||||
: (string) $comment->parent_id
|
||||
);
|
||||
|
||||
// Recursive helper to format a comment and its nested replies.
|
||||
$formatComment = null;
|
||||
$formatComment = function (ArtworkComment $c) use (&$formatComment, $commentsByParent): array {
|
||||
/** @var Collection<int, ArtworkComment> $replies */
|
||||
$replies = $commentsByParent->get((string) $c->id, collect());
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
$canPublishLinks = (int) ($user?->level ?? 1) > 1 && strtolower((string) ($user?->rank ?? 'Newbie')) !== 'newbie';
|
||||
$rawContent = (string) ($c->raw_content ?? $c->content ?? '');
|
||||
$renderedContent = $c->rendered_content;
|
||||
|
||||
if (! is_string($renderedContent) || trim($renderedContent) === '') {
|
||||
$renderedContent = $rawContent !== ''
|
||||
? ContentSanitizer::render($rawContent)
|
||||
: nl2br(e(strip_tags((string) ($c->content ?? ''))));
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'content' => html_entity_decode((string) $c->content, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'rendered_content' => ContentSanitizer::sanitizeRenderedHtml($renderedContent, $canPublishLinks),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'user' => [
|
||||
'id' => $userId,
|
||||
'name' => $user?->name,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
|
||||
'avatar_url' => $avatarHash !== null
|
||||
? AvatarUrl::forUser($userId, $avatarHash, 64)
|
||||
: AvatarUrl::default(),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
'replies' => $replies->map($formatComment)->values()->all(),
|
||||
];
|
||||
};
|
||||
|
||||
$comments = $commentsByParent
|
||||
->get('root', collect())
|
||||
->map($formatComment)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
|
||||
$viewerId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
|
||||
|
||||
$userId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
|
||||
|
||||
return Inertia::render('ArtworkPage', [
|
||||
'artwork' => $artworkData,
|
||||
'navigation' => $navigationData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'related' => $related,
|
||||
'canonicalUrl' => $canonical,
|
||||
'comments' => $comments,
|
||||
'groupSummary' => $groupSummary,
|
||||
'isAuthenticated' => $userId !== null,
|
||||
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $userId),
|
||||
'isAuthenticated' => $viewerId !== null,
|
||||
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $viewerId),
|
||||
'seo' => $seo,
|
||||
])->rootView('artworks.show');
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import ArtworkViewer from '../components/viewer/ArtworkViewer'
|
||||
import ReactionBar from '../components/comments/ReactionBar'
|
||||
import GroupSummaryPanel from '../components/groups/GroupSummaryPanel'
|
||||
import SeoHead from '../components/seo/SeoHead'
|
||||
import { useDeferredSimilarAi } from '../lib/useDeferredSimilarAi'
|
||||
|
||||
function publisherToGroupSummary(publisher) {
|
||||
if (!publisher || publisher.type !== 'group') return null
|
||||
@@ -42,7 +43,7 @@ function publisherToGroupSummary(publisher) {
|
||||
}
|
||||
}
|
||||
|
||||
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [], groupSummary: initialGroupSummary = null, reactionTotals: initialReactionTotals = {}, seo = null }) {
|
||||
function ArtworkPage({ artwork: initialArtwork, navigation: initialNavigation = null, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, groupSummary: initialGroupSummary = null, reactionTotals: initialReactionTotals = {}, seo = null }) {
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
const [showMatureArtwork, setShowMatureArtwork] = useState(false)
|
||||
const openViewer = useCallback(() => setViewerOpen(true), [])
|
||||
@@ -66,12 +67,12 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
const [presentXl, setPresentXl] = useState(initialXl)
|
||||
const [presentSq, setPresentSq] = useState(initialSq)
|
||||
const [related, setRelated] = useState(initialRelated)
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [canonicalUrl, setCanonicalUrl] = useState(initialCanonical)
|
||||
const [groupSummary, setGroupSummary] = useState(initialGroupSummary || publisherToGroupSummary(initialArtwork?.publisher))
|
||||
const [selectedMediaId, setSelectedMediaId] = useState('cover')
|
||||
const [similarRecommendations, setSimilarRecommendations] = useState([])
|
||||
const [trendingRecommendations, setTrendingRecommendations] = useState([])
|
||||
const similarAiAnchorRef = useRef(null)
|
||||
const { items: deferredSimilarRecommendations } = useDeferredSimilarAi(artwork?.id, similarAiAnchorRef)
|
||||
|
||||
// Nav arrow state — populated by ArtworkNavigator once neighbors resolve
|
||||
const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null })
|
||||
@@ -89,32 +90,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
.catch(() => setReactionTotals({}))
|
||||
}, [artwork?.id])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const loadSimilarRecommendations = async () => {
|
||||
if (!artwork?.id) {
|
||||
setSimilarRecommendations([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error('similar fetch failed')
|
||||
const payload = await response.json()
|
||||
if (!isCancelled) setSimilarRecommendations(payload?.data || [])
|
||||
} catch {
|
||||
if (!isCancelled) setSimilarRecommendations([])
|
||||
}
|
||||
}
|
||||
|
||||
loadSimilarRecommendations()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [artwork?.id])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
@@ -165,7 +140,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
setCanonicalUrl(data.canonical_url ?? window.location.href)
|
||||
setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher))
|
||||
setSelectedMediaId('cover')
|
||||
setSimilarRecommendations([])
|
||||
setTrendingRecommendations([])
|
||||
setViewerOpen(false) // close viewer when navigating away
|
||||
setShowMatureArtwork(false)
|
||||
@@ -348,7 +322,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
{/* Comments */}
|
||||
<ArtworkComments
|
||||
artworkId={artwork.id}
|
||||
comments={comments}
|
||||
isLoggedIn={isAuthenticated}
|
||||
loginUrl="/login"
|
||||
/>
|
||||
@@ -371,11 +344,11 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
</div>
|
||||
|
||||
{/* ── Full-width recommendation rails ─────────────────────────── */}
|
||||
<div className="mt-14 w-full max-w-screen-2xl mx-auto min-h-[640px]">
|
||||
<div ref={similarAiAnchorRef} className="mt-14 w-full max-w-screen-2xl mx-auto min-h-[640px]">
|
||||
<ArtworkRecommendationsRails
|
||||
artwork={artwork}
|
||||
related={related}
|
||||
similarApiData={similarRecommendations}
|
||||
similarApiData={deferredSimilarRecommendations}
|
||||
trendingData={trendingRecommendations}
|
||||
/>
|
||||
</div>
|
||||
@@ -384,6 +357,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */}
|
||||
<ArtworkNavigator
|
||||
artworkId={artwork.id}
|
||||
navigation={artwork.navigation || initialNavigation}
|
||||
onNavigate={handleNavigate}
|
||||
onOpenViewer={openViewer}
|
||||
onReady={setNavState}
|
||||
|
||||
@@ -466,23 +466,30 @@ function Skeleton() {
|
||||
|
||||
export default function ArtworkComments({
|
||||
artworkId,
|
||||
comments: initialComments = [],
|
||||
isLoggedIn = false,
|
||||
loginUrl = '/login',
|
||||
}) {
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [comments, setComments] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [lastPage, setLastPage] = useState(1)
|
||||
const [total, setTotal] = useState(initialComments.length)
|
||||
const initialized = useRef(false)
|
||||
const [total, setTotal] = useState(0)
|
||||
const sectionRef = useRef(null)
|
||||
const requestRef = useRef(null)
|
||||
const generationRef = useRef(0)
|
||||
|
||||
const loadComments = useCallback(
|
||||
async (p = 1) => {
|
||||
if (!artworkId) return
|
||||
if (!artworkId || requestRef.current) return
|
||||
const generation = generationRef.current
|
||||
const controller = new AbortController()
|
||||
requestRef.current = controller
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`)
|
||||
const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`, { signal: controller.signal })
|
||||
if (generation !== generationRef.current) return
|
||||
if (p === 1) {
|
||||
setComments(data.data ?? [])
|
||||
} else {
|
||||
@@ -492,24 +499,62 @@ export default function ArtworkComments({
|
||||
setLastPage(data.meta?.last_page ?? 1)
|
||||
setTotal(data.meta?.total ?? 0)
|
||||
} catch {
|
||||
// keep existing
|
||||
if (generation === generationRef.current && !controller.signal.aborted) setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (requestRef.current === controller) {
|
||||
requestRef.current = null
|
||||
if (generation === generationRef.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[artworkId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
generationRef.current += 1
|
||||
requestRef.current?.abort()
|
||||
requestRef.current = null
|
||||
setComments([])
|
||||
setLoading(false)
|
||||
setError(false)
|
||||
setPage(1)
|
||||
setLastPage(1)
|
||||
setTotal(0)
|
||||
|
||||
if (artworkId && initialComments.length === 0) {
|
||||
loadComments(1)
|
||||
} else {
|
||||
setTotal(initialComments.length)
|
||||
const section = sectionRef.current
|
||||
if (!section || !artworkId) return undefined
|
||||
|
||||
let fallbackTimer = null
|
||||
let observer = null
|
||||
let established = false
|
||||
const trigger = () => {
|
||||
observer?.disconnect()
|
||||
if (!requestRef.current) loadComments(1)
|
||||
}
|
||||
}, [artworkId, initialComments.length, loadComments])
|
||||
|
||||
if (typeof window !== 'undefined' && typeof window.IntersectionObserver === 'function') {
|
||||
try {
|
||||
observer = new window.IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) trigger()
|
||||
}, { rootMargin: '1000px 0px' })
|
||||
observer.observe(section)
|
||||
established = true
|
||||
} catch {
|
||||
observer?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
if (!established && typeof window !== 'undefined') {
|
||||
fallbackTimer = window.setTimeout(trigger, 1200)
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer?.disconnect()
|
||||
if (fallbackTimer !== null && typeof window !== 'undefined') window.clearTimeout(fallbackTimer)
|
||||
requestRef.current?.abort()
|
||||
requestRef.current = null
|
||||
}
|
||||
}, [artworkId, loadComments])
|
||||
|
||||
// New top-level comment posted
|
||||
const handlePosted = useCallback((newComment) => {
|
||||
@@ -538,7 +583,7 @@ export default function ArtworkComments({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section aria-label="Comments" className="space-y-6">
|
||||
<section ref={sectionRef} aria-label="Comments" className="space-y-6">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-white sm:text-xl">
|
||||
@@ -554,6 +599,11 @@ export default function ArtworkComments({
|
||||
{/* Comment list */}
|
||||
{loading && comments.length === 0 ? (
|
||||
<Skeleton />
|
||||
) : error && comments.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-8 text-center">
|
||||
<p className="text-sm font-medium text-white/45">Comments could not be loaded.</p>
|
||||
<button type="button" onClick={() => loadComments(1)} className="mt-3 rounded-full border border-white/[0.1] px-4 py-2 text-xs font-medium text-white/65 hover:bg-white/[0.06]">Try again</button>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center">
|
||||
<ChatBubbleIcon />
|
||||
@@ -611,4 +661,3 @@ export default function ArtworkComments({
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import axios from 'axios'
|
||||
import ArtworkComments from './ArtworkComments'
|
||||
|
||||
vi.mock('axios', () => ({ default: { get: vi.fn() } }))
|
||||
vi.mock('../comments/CommentForm', () => ({ default: () => <div data-testid="comment-form" /> }))
|
||||
vi.mock('../comments/ReactionBar', () => ({ default: () => <div data-testid="reaction-bar" /> }))
|
||||
vi.mock('../xp/LevelBadge', () => ({ default: () => null }))
|
||||
vi.mock('../../utils/emojiFlood', () => ({ isFlood: () => false }))
|
||||
|
||||
describe('ArtworkComments deferred loading', () => {
|
||||
let observerCallback
|
||||
|
||||
beforeEach(() => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 1, content: 'Loaded comment', replies: [], user: {}, reactions: {} }],
|
||||
meta: { current_page: 1, last_page: 1, total: 1 },
|
||||
},
|
||||
})
|
||||
window.IntersectionObserver = vi.fn((callback) => {
|
||||
observerCallback = callback
|
||||
return { observe: vi.fn(), disconnect: vi.fn() }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
delete window.IntersectionObserver
|
||||
})
|
||||
|
||||
it('does not fetch until the comments section approaches the viewport', async () => {
|
||||
render(<ArtworkComments artworkId={9357} isLoggedIn={false} />)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(axios.get).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => observerCallback([{ isIntersecting: true }]))
|
||||
|
||||
await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1))
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
'/api/artworks/9357/comments?page=1',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
)
|
||||
expect(screen.getByText('Loaded comment')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('uses the delayed compatibility fallback when IntersectionObserver is unavailable', async () => {
|
||||
delete window.IntersectionObserver
|
||||
vi.useFakeTimers()
|
||||
render(<ArtworkComments artworkId={42} isLoggedIn={false} />)
|
||||
|
||||
expect(axios.get).not.toHaveBeenCalled()
|
||||
await act(async () => vi.advanceTimersByTime(1200))
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resets on artwork changes and ignores stale responses', async () => {
|
||||
let resolveRequest
|
||||
axios.get.mockImplementation(() => new Promise((resolve) => { resolveRequest = resolve }))
|
||||
const { rerender } = render(<ArtworkComments artworkId={1} isLoggedIn={false} />)
|
||||
await act(async () => observerCallback([{ isIntersecting: true }]))
|
||||
await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1))
|
||||
|
||||
rerender(<ArtworkComments artworkId={2} isLoggedIn={false} />)
|
||||
await act(async () => {
|
||||
resolveRequest({ data: { data: [{ id: 1, content: 'stale', replies: [], user: {}, reactions: {} }], meta: {} } })
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByText('stale')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -42,7 +42,7 @@ describe('ArtworkRecommendationsRails', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads recommendation rails after mount', async () => {
|
||||
it('loads normal trending rails after mount without owning Similar-AI loading', async () => {
|
||||
render(
|
||||
<ArtworkRecommendationsRails
|
||||
artwork={{
|
||||
@@ -51,6 +51,13 @@ describe('ArtworkRecommendationsRails', () => {
|
||||
categories: [{ id: 5, name: 'Sci-Fi' }],
|
||||
}}
|
||||
related={[]}
|
||||
trendingData={[{
|
||||
id: 11,
|
||||
title: 'Star map drift',
|
||||
urls: { direct: '/art/11/star-map-drift' },
|
||||
author: { name: 'Pilot' },
|
||||
thumbnail_url: '/thumbs/11.webp',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -58,7 +65,7 @@ describe('ArtworkRecommendationsRails', () => {
|
||||
expect(screen.getByText('Trending in Sci-Fi')).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/art/69827/similar-ai', { credentials: 'same-origin' })
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/rank/category/5?type=trending', { credentials: 'same-origin' })
|
||||
expect(global.fetch).not.toHaveBeenCalledWith('/api/art/69827/similar-ai', { credentials: 'same-origin' })
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,7 @@ describe('Context navigation — useNavContext', () => {
|
||||
})
|
||||
|
||||
it('resolves prev/next IDs from the same-user API', async () => {
|
||||
const apiData = { prev_id: 100, next_id: 300, prev_url: '/art/100', next_url: '/art/300' }
|
||||
const apiData = { prev_id: 100, next_id: 300, prev_url: '/art/100', next_url: '/art/300', prev_slug: 'prev', next_slug: 'next' }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
@@ -99,7 +99,7 @@ describe('Fallback — API navigation when no sessionStorage context', () => {
|
||||
|
||||
it('calls /api/artworks/navigation/{id} when sessionStorage is empty', async () => {
|
||||
mockSessionStorage(null)
|
||||
const apiData = { prev_id: 50, next_id: 150, prev_url: '/art/50', next_url: '/art/150' }
|
||||
const apiData = { prev_id: 50, next_id: 150, prev_url: '/art/50', next_url: '/art/150', prev_slug: 'prev', next_slug: 'next' }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
@@ -143,6 +143,7 @@ describe('Fallback — API navigation when no sessionStorage context', () => {
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('null|null')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
// ─── 3. Keyboard Test ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('omits comments from the initial artwork page and does not query them', function (): void {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'slug' => 'm18h1-deferred-comments',
|
||||
]);
|
||||
ArtworkComment::factory()->count(2)->create(['artwork_id' => $artwork->id]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
|
||||
$response = $this->get(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]))->assertOk();
|
||||
$queries = collect(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
expect($response->getContent())->not->toContain('"comments"')
|
||||
->and($queries->filter(fn (array $query): bool => str_contains(strtolower($query['query']), 'artwork_comments'))->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the existing complete comments API contract', function (): void {
|
||||
$artwork = Artwork::factory()->create();
|
||||
$comment = ArtworkComment::factory()->create(['artwork_id' => $artwork->id]);
|
||||
|
||||
$this->getJson("/api/artworks/{$artwork->id}/comments")
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'data' => [[
|
||||
'id', 'parent_id', 'raw_content', 'rendered_content',
|
||||
'created_at', 'time_ago', 'user', 'reactions', 'replies',
|
||||
]],
|
||||
'meta' => ['current_page', 'last_page', 'total', 'per_page'],
|
||||
])
|
||||
->assertJsonPath('data.0.id', $comment->id);
|
||||
});
|
||||
|
||||
it('measures the initial artwork detail SQL after deferring comments', function (): void {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'slug' => 'm18h1-sql-measurement',
|
||||
]);
|
||||
ArtworkComment::factory()->count(2)->create(['artwork_id' => $artwork->id]);
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
$this->get(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]))
|
||||
->assertOk();
|
||||
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$normalized = collect($queries)->map(fn (array $query): string => strtolower(preg_replace('/\s+/', ' ', $query['query'])));
|
||||
$selects = $normalized->filter(fn (string $query): bool => str_starts_with(ltrim($query), 'select '));
|
||||
$writes = $normalized->reject(fn (string $query): bool => str_starts_with(ltrim($query), 'select '));
|
||||
$commentQueries = $normalized->filter(fn (string $query): bool => str_contains($query, 'artwork_comments'));
|
||||
|
||||
fwrite(STDERR, sprintf(
|
||||
"M18H1_SQL initial_guest_total=%d initial_guest_select=%d initial_guest_write=%d initial_guest_comment_queries=%d\n",
|
||||
count($queries),
|
||||
$selects->count(),
|
||||
$writes->count(),
|
||||
$commentQueries->count(),
|
||||
));
|
||||
|
||||
expect($commentQueries)->toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['resources/js/test/setupTests.js'],
|
||||
include: ['resources/js/**/*.test.{js,jsx}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user