Skip to content
36 changes: 29 additions & 7 deletions __tests__/components/common/TimePicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,47 @@ import React from 'react';
import TimePicker from '../../../components/common/TimePicker';

describe('TimePicker', () => {
it('changes hours and minutes via inputs', () => {
it('labels hour and minute inputs for accessibility', () => {
const onChange = jest.fn();
render(<TimePicker hours={9} minutes={15} onTimeChange={onChange} />);
fireEvent.change(screen.getByPlaceholderText('HH'), { target: { value: '10' } });

const hoursInput = screen.getByLabelText('Hours');
const minutesInput = screen.getByLabelText('Minutes');

fireEvent.change(hoursInput, { target: { value: '10' } });
expect(onChange).toHaveBeenCalledWith(10, 15);
fireEvent.change(screen.getByPlaceholderText('MM'), { target: { value: '45' } });

fireEvent.change(minutesInput, { target: { value: '45' } });
expect(onChange).toHaveBeenCalledWith(9, 45);
});

it('toggles am/pm respecting minTime', () => {
const onChange = jest.fn();
render(<TimePicker hours={9} minutes={0} onTimeChange={onChange} minTime={{ hours: 8, minutes: 0 }} />);
fireEvent.click(screen.getByText('AM'));
render(
<TimePicker hours={9} minutes={0} onTimeChange={onChange} minTime={{ hours: 8, minutes: 0 }} />
);
fireEvent.click(screen.getByRole('button', { name: 'Toggle AM/PM' }));
expect(onChange).toHaveBeenCalledWith(21, 0);
});

it('disables options before minTime', () => {
it('describes minimum time and disables earlier quick options', () => {
const onChange = jest.fn();
render(<TimePicker hours={9} minutes={0} onTimeChange={onChange} minTime={{ hours: 9, minutes: 30 }} />);
render(
<TimePicker hours={9} minutes={0} onTimeChange={onChange} minTime={{ hours: 9, minutes: 30 }} />
);

const hoursInput = screen.getByLabelText('Hours');
const minutesInput = screen.getByLabelText('Minutes');

const describedBy = hoursInput.getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();

const description = document.getElementById(describedBy ?? '');
expect(description).not.toBeNull();
expect(description).toHaveTextContent('Earliest selectable time is 9:30 AM.');

expect(minutesInput.getAttribute('aria-describedby')).toBe(describedBy);

const early = screen.getByText('9 AM') as HTMLButtonElement;
const later = screen.getByText('12 PM');
expect(early).toBeDisabled();
Expand Down
112 changes: 87 additions & 25 deletions __tests__/components/common/TooltipIconButton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,108 @@
import { render, screen, fireEvent } from '@testing-library/react';
import TooltipIconButton from '../../../components/common/TooltipIconButton';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import TooltipIconButton from "@/components/common/TooltipIconButton";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

jest.mock('@fortawesome/react-fontawesome', () => ({
FontAwesomeIcon: (props: any) => <svg data-testid="icon" {...props} />,
type FontAwesomeProps = {
readonly [key: string]: unknown;
};

jest.mock("@fortawesome/react-fontawesome", () => ({
FontAwesomeIcon: (props: FontAwesomeProps) => (
<svg data-testid="icon" {...props} />
),
}));

describe('TooltipIconButton', () => {
it('shows tooltip on hover and hides on mouse leave', () => {
render(<TooltipIconButton icon={faCheck} tooltipText="info" />);
const wrapper = screen.getByTestId('icon').parentElement as HTMLElement;
expect(screen.queryByText('info')).not.toBeInTheDocument();
describe("TooltipIconButton", () => {
it("shows tooltip on hover and hides on mouse leave", () => {
render(
<TooltipIconButton
icon={faCheck}
tooltipText="info"
aria-describedby="external-id"
/>
);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("type", "button");
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();

fireEvent.mouseEnter(button);
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent("info");
expect(tooltip.className).toContain("tw-bottom-6");

const describedBy = button.getAttribute("aria-describedby") ?? "";
expect(describedBy).toContain("external-id");
expect(describedBy).toContain(tooltip.getAttribute("id") ?? "");

fireEvent.mouseLeave(button);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

it("applies bottom position classes", () => {
render(
<TooltipIconButton
icon={faCheck}
tooltipText="info"
tooltipPosition="bottom"
/>
);
const button = screen.getByRole("button");
fireEvent.mouseEnter(button);
const tooltip = screen.getByRole("tooltip");
expect(tooltip.className).toContain("tw-top-6");
});

it("shows tooltip on focus and hides on blur triggered by keyboard navigation", async () => {
const user = userEvent.setup();
const handleFocus = jest.fn();
const handleBlur = jest.fn();

fireEvent.mouseEnter(wrapper);
const tooltip = screen.getByText('info');
render(
<>
<TooltipIconButton
icon={faCheck}
tooltipText="info"
onFocus={handleFocus}
onBlur={handleBlur}
/>
<button>Next</button>
</>
);

await user.tab();
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toBeInTheDocument();
expect(tooltip.className).toContain('tw-bottom-6');
expect(handleFocus).toHaveBeenCalledTimes(1);

fireEvent.mouseLeave(wrapper);
expect(screen.queryByText('info')).not.toBeInTheDocument();
await user.tab();
expect(handleBlur).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

it('applies bottom position classes', () => {
it("forwards tabIndex to the button", () => {
render(
<TooltipIconButton icon={faCheck} tooltipText="info" tooltipPosition="bottom" />
<TooltipIconButton icon={faCheck} tooltipText="info" tabIndex={-1} />
);
const wrapper = screen.getByTestId('icon').parentElement as HTMLElement;
fireEvent.mouseEnter(wrapper);
const tooltip = screen.getByText('info');
expect(tooltip.className).toContain('tw-top-6');
const button = screen.getByRole("button");
expect(button).toHaveAttribute("tabindex", "-1");
});

it('calls onClick and stops propagation', () => {
it("calls onClick and stops propagation", () => {
const onClick = jest.fn();
const parentClick = jest.fn();

render(
<div onClick={parentClick}>
<TooltipIconButton icon={faCheck} tooltipText="info" onClick={onClick} />
<TooltipIconButton
icon={faCheck}
tooltipText="info"
onClick={onClick}
/>
</div>
);
const wrapper = screen.getByTestId('icon').parentElement as HTMLElement;
fireEvent.click(wrapper);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(onClick).toHaveBeenCalled();
expect(parentClick).not.toHaveBeenCalled();
});
Expand Down
104 changes: 97 additions & 7 deletions __tests__/components/drops/view/part/DropPartMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,48 @@
import DropPartMarkdown from "@/components/drops/view/part/DropPartMarkdown";
/** @jest-environment jsdom */
import React from "react";

jest.mock("next/dynamic", () => {
let mode: "skeleton" | "eager" = "eager";
const calls: any[] = [];
const mock = jest.fn((loader: any, options: any) => {
calls.push([loader, options]);
const Comp = (props: any) => {
if (mode === "skeleton") {
const L = options?.loading as React.ComponentType | undefined;
return L ? <L /> : null;
}
// Eager mode: immediately render the underlying component.
// We know the loader resolves to react-tweet's Tweet, so just render that mock directly.
const { Tweet } = require("react-tweet");
const T = Tweet as React.ComponentType<any>;
return <T {...props} />;
};
(Comp as any).__loader = loader;
(Comp as any).__options = options;
return Comp;
});
const setMode = (m: "skeleton" | "eager") => {
mode = m;
};
return {
__esModule: true,
default: mock,
__calls: calls,
__setMode: setMode,
};
});

// Use this in assertions: dynamicSpy.mock.calls[0] => [loader, options]
const {
default: dynamicSpy,
__calls: dynamicCalls,
__setMode: setDynamicMode,
} = require("next/dynamic") as {
default: jest.Mock;
__calls: any[];
__setMode: (m: "skeleton" | "eager") => void;
};

import { publicEnv } from "@/config/env";
import {
fetchYoutubePreview,
Expand All @@ -7,6 +51,8 @@ import {
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import DropPartMarkdown from "@/components/drops/view/part/DropPartMarkdown";

const FALLBACK_BASE_ENDPOINT = "https://6529.io";
const originalBaseEndpoint = publicEnv.BASE_ENDPOINT;
const originalArtBlocksFlags = {
Expand Down Expand Up @@ -82,8 +128,18 @@ jest.mock("@/components/waves/FarcasterCard", () => ({

jest.mock("@/components/waves/ChatItemHrefButtons", () => ({
__esModule: true,
default: ({ href, relativeHref }: { href: string; relativeHref?: string }) => (
<div data-testid="chat-item-buttons" data-href={href} data-relative-href={relativeHref} />
default: ({
href,
relativeHref,
}: {
href: string;
relativeHref?: string;
}) => (
<div
data-testid="chat-item-buttons"
data-href={href}
data-relative-href={relativeHref}
/>
),
}));

Expand All @@ -100,8 +156,7 @@ afterEach(() => {
describe("DropPartMarkdown", () => {
beforeEach(() => {
jest.clearAllMocks();
publicEnv.BASE_ENDPOINT =
originalBaseEndpoint ?? FALLBACK_BASE_ENDPOINT;
publicEnv.BASE_ENDPOINT = originalBaseEndpoint ?? FALLBACK_BASE_ENDPOINT;
publicEnv.VITE_FEATURE_AB_CARD =
originalArtBlocksFlags.VITE_FEATURE_AB_CARD;
publicEnv.NEXT_PUBLIC_VITE_FEATURE_AB_CARD =
Expand Down Expand Up @@ -201,7 +256,10 @@ describe("DropPartMarkdown", () => {
expect(mockArtBlocksTokenCard).not.toHaveBeenCalled();
expect(mockLinkPreviewCard).not.toHaveBeenCalled();
const link = screen.getByRole("link", { name: "token" });
expect(link).toHaveAttribute("href", "https://www.artblocks.io/project/662000");
expect(link).toHaveAttribute(
"href",
"https://www.artblocks.io/project/662000"
);
});

it("falls back to regular link when Art Blocks card disabled", () => {
Expand All @@ -220,7 +278,10 @@ describe("DropPartMarkdown", () => {
expect(mockArtBlocksTokenCard).not.toHaveBeenCalled();
expect(mockLinkPreviewCard).not.toHaveBeenCalled();
const link = screen.getByRole("link", { name: "token" });
expect(link).toHaveAttribute("href", "https://www.artblocks.io/token/662000");
expect(link).toHaveAttribute(
"href",
"https://www.artblocks.io/token/662000"
);
});

it("renders a fallback link when tweet data is unavailable", async () => {
Expand Down Expand Up @@ -424,4 +485,33 @@ describe("DropPartMarkdown", () => {
const fallbackLink = await screen.findByRole("link", { name: url });
expect(fallbackLink).toHaveAttribute("href", url);
});

it("lazy loads tweet embeds with a loading skeleton", async () => {
setDynamicMode("skeleton");
try {
const content =
"Check this [tweet](https://twitter.com/user/status/1234567890)";

render(
<DropPartMarkdown
mentionedUsers={[]}
referencedNfts={[]}
partContent={content}
onQuoteClick={jest.fn()}
/>
);

expect(screen.getByTestId("tweet-embed-loading")).toBeInTheDocument();

expect(dynamicCalls.length).toBeGreaterThanOrEqual(1);
const [loader, options] = dynamicCalls[0];
expect(options?.ssr).toBe(false);

const TweetComponent = await loader();
const { getByText } = render(<TweetComponent id="abc123" />);
expect(getByText("tweet:abc123")).toBeInTheDocument();
} finally {
setDynamicMode("eager");
}
});
});
2 changes: 1 addition & 1 deletion __tests__/components/gas-royalties/GasRoyalties.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ describe("GasRoyaltiesTokenImage", () => {
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/memes/1");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noreferrer");
expect(link).toHaveAttribute("rel", "noopener noreferrer");

const image = screen.getByAltText("Meme1");
expect(image).toBeInTheDocument();
Expand Down
11 changes: 9 additions & 2 deletions __tests__/components/mintCountdownBox/MintCountdownBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe("MintCountdownBox", () => {
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/the-memes/mint");
expect(link).toHaveAttribute("target", "_self");
expect(link).toHaveAttribute("rel", "noreferrer");
expect(link).not.toHaveAttribute("rel");
});

it("renders multiple buttons correctly", () => {
Expand All @@ -134,8 +134,10 @@ describe("MintCountdownBox", () => {
const links = screen.getAllByRole("link");
expect(links[0]).toHaveAttribute("href", "/the-memes/mint");
expect(links[0]).toHaveAttribute("target", "_self");
expect(links[0]).not.toHaveAttribute("rel");
expect(links[1]).toHaveAttribute("href", "https://external.com/mint");
expect(links[1]).toHaveAttribute("target", "_blank");
expect(links[1]).toHaveAttribute("rel", "noopener noreferrer");
});

it("renders JSX Element as button label", () => {
Expand Down Expand Up @@ -452,7 +454,12 @@ describe("MintCountdownBox", () => {
links.forEach(link => {
expect(link).toBeVisible();
expect(link).toHaveAttribute("href");
expect(link).toHaveAttribute("rel", "noreferrer");
const target = link.getAttribute("target");
if (target === "_blank") {
expect(link).toHaveAttribute("rel", "noopener noreferrer");
} else {
expect(link).not.toHaveAttribute("rel");
}
});
});

Expand Down
Loading