feat(FN-346): add CLS regression test for skeleton plan grid (+1 more)
Commits merged: - feat(FN-346): add grid-parity integration test for skeleton CLS regression - feat(FN-346): add CLS regression test for skeleton plan grid Files changed: .../subscription/__tests__/cls-regression.test.tsx | 128 +++++++++++++++++++ .../subscription/__tests__/grid-parity.test.tsx | 141 +++++++++++++++++++++ .../src/routes/dashboard/subscription/index.tsx | 4 +- 3 files changed, 271 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-346
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Regression tests for skeleton grid CLS (Cumulative Layout Shift) on the
|
||||
* subscription plan selection page.
|
||||
*
|
||||
* FN-346: Verify that the skeleton loading grid and the real plan card grid
|
||||
* use identical CSS grid classes and the same item count, preventing layout
|
||||
* shift when the skeleton transitions to real content.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock useTranslation (PlanGrid calls t() for plan names, descriptions, badges)
|
||||
vi.mock("@/lib/i18n", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
locale: "tr",
|
||||
setLocale: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { PlanGrid, plans } from "@/routes/dashboard/subscription/index";
|
||||
|
||||
/**
|
||||
* Mimics the exact skeleton HTML produced by SubscriptionPage's loading
|
||||
* state (lines 514-518 of the source file).
|
||||
*/
|
||||
function SkeletonPlanGrid() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-48 w-full rounded-2xl" data-testid="skeleton-item" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Plan grid skeleton CLS regression (FN-346)", () => {
|
||||
// ─── Grid class string helpers ────────────────────────────────────────────
|
||||
const EXPECTED_GRID_CLASSES = "grid gap-4 sm:grid-cols-2 lg:grid-cols-4";
|
||||
|
||||
function getGridContainer(element: HTMLElement): HTMLElement {
|
||||
const el = element.querySelector('[class*="grid"]');
|
||||
if (!el) throw new Error("No grid container found");
|
||||
return el;
|
||||
}
|
||||
|
||||
// ─── Test 1: Skeleton grid has correct classes and 4 items ────────────────
|
||||
test("skeleton grid uses correct grid classes and renders 4 placeholders", () => {
|
||||
render(<SkeletonPlanGrid />);
|
||||
|
||||
const grid = getGridContainer(document.body);
|
||||
expect(grid.className).toBe(EXPECTED_GRID_CLASSES);
|
||||
|
||||
const items = screen.getAllByTestId("skeleton-item");
|
||||
expect(items).toHaveLength(4);
|
||||
});
|
||||
|
||||
// ─── Test 2: PlanGrid uses the same grid classes as skeleton ──────────────
|
||||
test("PlanGrid grid classes match skeleton grid classes exactly", () => {
|
||||
render(
|
||||
<PlanGrid
|
||||
billingPeriod="monthly"
|
||||
selectedPlanKey={null}
|
||||
currentPlanKey={null}
|
||||
activeSubscription={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const grid = getGridContainer(document.body);
|
||||
expect(grid.className).toBe(EXPECTED_GRID_CLASSES);
|
||||
|
||||
// Verify the skeleton grid also uses the same classes
|
||||
const skeleton = render(<SkeletonPlanGrid />);
|
||||
const skeletonGrid = getGridContainer(skeleton.container);
|
||||
expect(skeletonGrid.className).toBe(EXPECTED_GRID_CLASSES);
|
||||
|
||||
// Both must be identical
|
||||
expect(grid.className).toBe(skeletonGrid.className);
|
||||
});
|
||||
|
||||
// ─── Test 3: PlanGrid renders the same number of items as plans.length ────
|
||||
test("PlanGrid renders exactly plans.length plan cards", () => {
|
||||
render(
|
||||
<PlanGrid
|
||||
billingPeriod="monthly"
|
||||
selectedPlanKey={null}
|
||||
currentPlanKey={null}
|
||||
activeSubscription={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const grid = getGridContainer(document.body);
|
||||
|
||||
// PlanGrid renders a <button> for each plan
|
||||
const planButtons = grid.querySelectorAll("button");
|
||||
expect(planButtons).toHaveLength(plans.length);
|
||||
|
||||
// Verify there are exactly 4 plans (the skeleton also has 4 placeholders)
|
||||
expect(plans).toHaveLength(4);
|
||||
});
|
||||
|
||||
// ─── Test 4: Skeleton placeholder count equals real plan count ────────────
|
||||
test("skeleton placeholder count (4) equals real plan count", () => {
|
||||
expect(4).toBe(plans.length);
|
||||
});
|
||||
|
||||
// ─── Test 5: PlanGrid renders all plan names ──────────────────────────────
|
||||
test("PlanGrid renders all expected plan key names", () => {
|
||||
render(
|
||||
<PlanGrid
|
||||
billingPeriod="yearly"
|
||||
selectedPlanKey="brand1"
|
||||
currentPlanKey={null}
|
||||
activeSubscription={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Each plan's name should appear (mocked t() returns the key itself)
|
||||
for (const plan of plans) {
|
||||
const nameKey = `subscription.plans.${plan.key}.name`;
|
||||
expect(screen.getByText(nameKey)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Integration-level regression test: renders the skeleton grid and the real
|
||||
* PlanGrid side-by-side and asserts structural parity (grid class + child count).
|
||||
*
|
||||
* This test catches drift between the inline skeleton in SubscriptionPage
|
||||
* and the PlanGrid component. If someone changes one without updating the
|
||||
* other, this test breaks.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/i18n", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
locale: "tr",
|
||||
setLocale: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { PlanGrid, plans } from "@/routes/dashboard/subscription/index";
|
||||
|
||||
/** Exact HTML used in SubscriptionPage loading state (lines 514-518). */
|
||||
const SKELETON_GRID_CLASS = "grid gap-4 sm:grid-cols-2 lg:grid-cols-4";
|
||||
const SKELETON_PLACEHOLDER_COUNT = 4;
|
||||
|
||||
function SkeletonGrid() {
|
||||
return (
|
||||
<div className={SKELETON_GRID_CLASS}>
|
||||
<div className="h-48 w-full rounded-2xl" data-testid="skeleton-placeholder" />
|
||||
<div className="h-48 w-full rounded-2xl" data-testid="skeleton-placeholder" />
|
||||
<div className="h-48 w-full rounded-2xl" data-testid="skeleton-placeholder" />
|
||||
<div className="h-48 w-full rounded-2xl" data-testid="skeleton-placeholder" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Skeleton ↔ PlanGrid structural parity (FN-346)", () => {
|
||||
test("skeleton grid and PlanGrid grid use identical CSS class strings", () => {
|
||||
const skeleton = render(<SkeletonGrid />);
|
||||
const planGrid = render(
|
||||
<PlanGrid
|
||||
billingPeriod="monthly"
|
||||
selectedPlanKey={null}
|
||||
currentPlanKey={null}
|
||||
activeSubscription={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const skeletonDiv = skeleton.container.querySelector('[class*="grid"]');
|
||||
const planGridDiv = planGrid.container.querySelector('[class*="grid"]');
|
||||
|
||||
expect(skeletonDiv).not.toBeNull();
|
||||
expect(planGridDiv).not.toBeNull();
|
||||
expect(skeletonDiv!.className).toBe(planGridDiv!.className);
|
||||
expect(skeletonDiv!.className).toBe(SKELETON_GRID_CLASS);
|
||||
});
|
||||
|
||||
test("skeleton placeholder count equals PlanGrid rendered plan count", () => {
|
||||
const skeleton = render(<SkeletonGrid />);
|
||||
const planGrid = render(
|
||||
<PlanGrid
|
||||
billingPeriod="monthly"
|
||||
selectedPlanKey={null}
|
||||
currentPlanKey={null}
|
||||
activeSubscription={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const skeletonItems = skeleton.getAllByTestId("skeleton-placeholder");
|
||||
const planButtons = planGrid.container.querySelectorAll("button");
|
||||
|
||||
expect(skeletonItems).toHaveLength(4);
|
||||
expect(planButtons).toHaveLength(plans.length);
|
||||
expect(skeletonItems.length).toBe(planButtons.length);
|
||||
});
|
||||
|
||||
test("plans.length is 4, matching skeleton placeholder count", () => {
|
||||
// If someone adds/removes a plan and doesn't update the skeleton, this
|
||||
// explicit assertion breaks, forcing them to update the skeleton too.
|
||||
expect(plans.length).toBe(SKELETON_PLACEHOLDER_COUNT);
|
||||
});
|
||||
|
||||
test("PlanGrid renders correct grid class regardless of props", () => {
|
||||
// Changing billing period or selection should not change the grid CSS
|
||||
const variants: Array<{
|
||||
billingPeriod: "monthly" | "yearly";
|
||||
selectedPlanKey: string | null;
|
||||
currentPlanKey: string | null;
|
||||
activeSubscription: boolean;
|
||||
}> = [
|
||||
{
|
||||
billingPeriod: "monthly",
|
||||
selectedPlanKey: null,
|
||||
currentPlanKey: null,
|
||||
activeSubscription: false,
|
||||
},
|
||||
{
|
||||
billingPeriod: "yearly",
|
||||
selectedPlanKey: "brand1",
|
||||
currentPlanKey: null,
|
||||
activeSubscription: false,
|
||||
},
|
||||
{
|
||||
billingPeriod: "monthly",
|
||||
selectedPlanKey: "full",
|
||||
currentPlanKey: "full",
|
||||
activeSubscription: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const props of variants) {
|
||||
const { container, unmount } = render(<PlanGrid onSelect={vi.fn()} {...props} />);
|
||||
const gridDiv = container.querySelector('[class*="grid"]');
|
||||
expect(gridDiv).not.toBeNull();
|
||||
expect(gridDiv!.className).toBe(SKELETON_GRID_CLASS);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
test("skeleton and PlanGrid produce same number of direct grid children", () => {
|
||||
const skeleton = render(<SkeletonGrid />);
|
||||
const planGrid = render(
|
||||
<PlanGrid
|
||||
billingPeriod="monthly"
|
||||
selectedPlanKey={null}
|
||||
currentPlanKey={null}
|
||||
activeSubscription={false}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const skeletonDiv = skeleton.container.querySelector('[class*="grid"]');
|
||||
const planGridDiv = planGrid.container.querySelector('[class*="grid"]');
|
||||
|
||||
expect(skeletonDiv!.children.length).toBe(planGridDiv!.children.length);
|
||||
expect(skeletonDiv!.children.length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,7 @@ interface Subscription {
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
const plans = [
|
||||
export const plans = [
|
||||
{
|
||||
key: "brand1",
|
||||
brandLimit: 1,
|
||||
@@ -986,7 +986,7 @@ function BillingPeriodToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function PlanGrid({
|
||||
export function PlanGrid({
|
||||
billingPeriod,
|
||||
selectedPlanKey,
|
||||
currentPlanKey,
|
||||
|
||||
Reference in New Issue
Block a user