Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/features/dashboard/components/ProjectCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { renderWithTheme } from '../../../test/renderWithTheme'
import { ProjectCard, type Project } from './ProjectCard'
import { ProjectCardSkeleton } from './ProjectCardSkeleton'

const project: Project = {
id: 42,
Expand Down Expand Up @@ -66,3 +67,67 @@ describe('ProjectCard', () => {
expect(onClick).not.toHaveBeenCalled()
})
})

describe('ProjectCard Layout Parity', () => {
it('skeleton and real card share the same outer container structure', () => {
const { container: skeletonContainer } = renderWithTheme(<ProjectCardSkeleton />)
const skeletonElement = skeletonContainer.firstElementChild as HTMLElement

const { container: cardContainer } = renderWithTheme(<ProjectCard project={project} />)
const cardElement = cardContainer.firstElementChild as HTMLElement

// Note: Visual regression testing is ideal here, but asserting shared layout classes
// is a good unit-test proxy to prevent layout shifts.
const sharedClasses = [
'w-full',
'text-left',
'backdrop-blur-[30px]',
'rounded-[18px]',
'border',
'p-5',
]
sharedClasses.forEach((cls) => {
expect(skeletonElement).toHaveClass(cls)
expect(cardElement).toHaveClass(cls)
})
})

it('maintains layout bounds even with missing optional fields or long wrapping text', () => {
// Edge case: card with a long title/description wrapping to two lines
// and missing an optional field (e.g. no tags) vs skeleton's fixed placeholders.
const edgeCaseProject = {
...project,
name: 'A very very long project name that will likely wrap into multiple lines if the container is narrow enough to force it',
description:
'An extremely long description that takes up a lot of vertical space and pushes content down, which might cause layout shift if the skeleton does not account for typical wrapping bounds.',
tags: [], // missing optional field
}

const { container: edgeCardContainer } = renderWithTheme(
<ProjectCard project={edgeCaseProject} />
)
const cardElement = edgeCardContainer.firstElementChild as HTMLElement

const { container: skeletonContainer } = renderWithTheme(<ProjectCardSkeleton />)
const skeletonElement = skeletonContainer.firstElementChild as HTMLElement

// Asserting class structure is maintained despite edge case content
const sharedClasses = [
'w-full',
'text-left',
'backdrop-blur-[30px]',
'rounded-[18px]',
'border',
'p-5',
]
sharedClasses.forEach((cls) => {
expect(cardElement).toHaveClass(cls)
expect(skeletonElement).toHaveClass(cls)
})

// Check that tags container is present but empty, verifying structural integrity
const tagContainer = edgeCardContainer.querySelector('.flex.flex-wrap.gap-1\\.5')
expect(tagContainer).toBeInTheDocument()
expect(tagContainer?.children).toHaveLength(0)
})
})
36 changes: 12 additions & 24 deletions src/features/dashboard/components/ProjectCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { useTheme } from '../../../shared/contexts/ThemeContext';
import { SkeletonLoader } from '../../../shared/components/SkeletonLoader';
import { useTheme } from '../../../shared/contexts/ThemeContext'
import { SkeletonLoader } from '../../../shared/components/SkeletonLoader'

export function ProjectCardSkeleton() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const { theme } = useTheme()
const isDark = theme === 'dark'

return (
// CONVENTION: This skeleton must closely mirror the outer structural shape and key
// layout classes of ProjectCard.tsx to prevent layout shifts when data loads.
// If you change the padding, border radius, or grid structure of ProjectCard,
// update this skeleton accordingly.
<div
className={`backdrop-blur-[30px] rounded-[18px] border p-5 ${
isDark
? 'bg-white/[0.08] border-white/15'
: 'bg-white/[0.15] border-white/25'
className={`w-full text-left backdrop-blur-[30px] rounded-[18px] border p-5 ${
isDark ? 'bg-white/[0.08] border-white/15' : 'bg-white/[0.15] border-white/25'
}`}
>
{/* Icon */}
Expand All @@ -20,7 +22,7 @@ export function ProjectCardSkeleton() {

{/* Title */}
<SkeletonLoader className="h-5 w-3/4 mb-2" />

{/* Description */}
<SkeletonLoader className="h-3 w-full mb-1" />
<SkeletonLoader className="h-3 w-5/6 mb-4" />
Expand Down Expand Up @@ -54,19 +56,5 @@ export function ProjectCardSkeleton() {
<SkeletonLoader className="h-6 w-16 rounded-[8px]" />
</div>
</div>
);
)
}














Loading