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
19 changes: 16 additions & 3 deletions app/(routes)/movieForm/MovieFormClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import MovieFormFields from "@/components/features/MovieFormFields";
import { useMovieContext } from "@/contexts/MovieContext";
import { MovieFormData } from "@/types/movie";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";

const INITIAL_FORM_STATE: MovieFormData = {
favouriteMovie: "",
Expand All @@ -20,6 +20,13 @@ const MovieFormClient = () => {

const [currentParticipant, setCurrentParticipant] = useState(1);
const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);

useEffect(() => {
if (currentParticipant > 1) {
headingRef.current?.focus();
}
}, [currentParticipant]);

const {
formData,
Expand Down Expand Up @@ -58,7 +65,9 @@ const MovieFormClient = () => {

return (
<>
<h1 className="text-2xl mb-4 text-center">Person #{currentParticipant}</h1>
<h1 ref={headingRef} tabIndex={-1} className="text-2xl mb-4 text-center">
Person #{currentParticipant}
</h1>
<form onSubmit={handleFormSubmission} className="space-y-6">
<MovieFormFields
formData={formData}
Expand All @@ -71,7 +80,11 @@ const MovieFormClient = () => {
{currentParticipant === totalParticipants ? "Get Movies" : "Next"}
</button>

{error && <p className="text-red-500 text-center">{error}</p>}
{error && (
<p role="alert" className="text-red-500 text-center">
{error}
</p>
)}
</form>
</>
);
Expand Down
30 changes: 20 additions & 10 deletions app/(routes)/recommendations/RecommendationsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,17 @@ export default function RecommendationsClient() {
<>
<div className="mt-6">
{error && (
<div className="flex flex-col items-center justify-center p-8 bg-error/10 border border-error/20 rounded-2xl text-center mb-8 mx-auto max-w-md mt-10">
<div
role="alert"
className="flex flex-col items-center justify-center p-8 bg-error/10 border border-error/20 rounded-2xl text-center mb-8 mx-auto max-w-md mt-10"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-16 w-16 text-error mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
Expand All @@ -163,7 +167,7 @@ export default function RecommendationsClient() {
/>
</svg>
<h3 className="text-2xl font-bold text-error mb-2">Oops! Something went wrong</h3>
<p className="text-base text-gray-400 mb-6">
<p className="text-base text-gray-300 mb-6">
{error.message.includes("All language models exhausted")
? "Our AI movie experts are currently overwhelmed with requests. Please try again in a few minutes!"
: `We ran into a streaming issue: ${error.message}`}
Expand All @@ -172,9 +176,13 @@ export default function RecommendationsClient() {
)}

{!hasRenderableCurrentMovie && isLoading && !error && (
<div className="flex flex-col items-center justify-center h-[35rem] gap-5">
<div
role="status"
aria-live="polite"
className="flex flex-col items-center justify-center h-[35rem] gap-5"
>
<div className="text-3xl">Generating recommendations...</div>
<div className="loading loading-bars loading-lg"></div>
<div className="loading loading-bars loading-lg" aria-hidden="true"></div>
</div>
)}

Expand All @@ -184,7 +192,7 @@ export default function RecommendationsClient() {
className="flex flex-col items-center justify-center p-8 bg-base-200 border border-base-300 rounded-2xl text-center mb-8 mx-auto max-w-md mt-10"
>
<h3 className="text-2xl font-bold mb-2">No strong matches this time</h3>
<p className="text-base text-gray-400">
<p className="text-base text-gray-300">
We could not find a confident recommendation for this group yet. Try broadening the
movie preferences or adding a bit more time, then have another go.
</p>
Expand All @@ -196,7 +204,7 @@ export default function RecommendationsClient() {
<h2 className="text-3xl text-center">
{currentMovieName} {currentMovieReleaseYear ? `(${currentMovieReleaseYear})` : ""}
{isLoading && currentIndex === recommendedMovies.length - 1 && (
<span className="loading loading-spinner loading-sm ml-2"></span>
<span className="loading loading-spinner loading-sm ml-2" aria-hidden="true"></span>
)}
</h2>
{isLoadingPoster ? (
Expand All @@ -219,11 +227,11 @@ export default function RecommendationsClient() {

<div className="text-lg mt-5 text-justify">
{currentMovieSynopsis || (
<span className="animate-pulse text-gray-500">Generating synopsis...</span>
<span className="animate-pulse text-gray-400">Generating synopsis...</span>
)}
</div>

<div className="mt-4 text-sm text-gray-500">
<div className="mt-4 text-sm text-gray-400" role="status" aria-live="polite">
Movie {currentIndex + 1} of {recommendedMovies.length}
</div>
<button
Expand All @@ -248,13 +256,15 @@ export default function RecommendationsClient() {
Start Over
</button>

{isLoading && (
{isLoading && process.env.NODE_ENV === "development" && (
<div
className="fixed bottom-4 right-4 bg-gray-900 border border-gray-700 text-white p-4 rounded-lg text-sm z-50 shadow-xl max-w-sm"
data-testid="stream-debugger"
aria-live="polite"
aria-label="Stream debugger"
>
<div className="flex items-center gap-2 mb-2 font-bold text-success">
<span className="loading loading-spinner loading-xs"></span>
<span className="loading loading-spinner loading-xs" aria-hidden="true"></span>
AI Stream Active
</div>
<div className="text-xs text-gray-300 mb-2">
Expand Down
34 changes: 33 additions & 1 deletion app/(routes)/recommendations/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import Recommendations from "./RecommendationsClient";
import { metadata } from "./page";
import { experimental_useObject } from "@ai-sdk/react";

const originalNodeEnv = process.env.NODE_ENV;

function setNodeEnv(value: string | undefined) {
Object.defineProperty(process.env, "NODE_ENV", {
value,
configurable: true,
writable: true,
});
}

jest.mock("next/navigation", () => ({
useRouter: jest.fn(),
}));
Expand Down Expand Up @@ -50,6 +60,7 @@ describe("Recommendations Component", () => {

beforeEach(() => {
jest.clearAllMocks();
setNodeEnv(originalNodeEnv);
(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace });
(useMovieContext as jest.Mock).mockReturnValue({
participantsData: [{ favouriteMovie: "Matrix" }],
Expand Down Expand Up @@ -180,7 +191,9 @@ describe("Recommendations Component", () => {
});
});

it("shows the stream debugger while loading with movies in the list", async () => {
it("shows the stream debugger while loading with movies in the list in development", async () => {
setNodeEnv("development");

(experimental_useObject as jest.Mock).mockReturnValue({
object: {
recommendedMovies: [{ name: "Test Movie 1", releaseYear: "2023", synopsis: "Syn 1" }],
Expand All @@ -200,6 +213,25 @@ describe("Recommendations Component", () => {
});
});

it("hides the stream debugger outside development", () => {
setNodeEnv("test");

(experimental_useObject as jest.Mock).mockReturnValue({
object: {
recommendedMovies: [{ name: "Test Movie 1", releaseYear: "2023", synopsis: "Syn 1" }],
},
submit: mockSubmit,
isLoading: true,
error: undefined,
clear: mockClear,
stop: mockStop,
});

render(<Recommendations />);

expect(screen.queryByTestId("stream-debugger")).not.toBeInTheDocument();
});

it("uses cached poster on second navigation to the same movie", async () => {
// Start with two movies; navigate to second and back to first to trigger POSTER_CACHE_HIT
(experimental_useObject as jest.Mock).mockReturnValue({
Expand Down
16 changes: 16 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,19 @@ h1 {
font-family: var(--ff-display, Arial, Helvetica, sans-serif);
letter-spacing: -0.02em;
}

*:focus-visible {
outline: 2px solid #51e08a;
outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
20 changes: 14 additions & 6 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,25 @@ export default function RootLayout({
<body
className={`${syne} ${inter} antialiased bg-base-100 min-h-screen flex flex-col justify-center items-center py-4`}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-content focus:rounded focus:text-sm"
>
Skip to main content
</a>
<MovieProvider>
<Header />
<main className="mx-auto px-8 flex flex-col items-center w-96">{children}</main>
<main id="main-content" className="mx-auto px-8 flex flex-col items-center w-96">
{children}
</main>
<footer className="mx-auto text-center mt-8 pb-2 flex flex-col items-center gap-2">
<p className="text-sm text-base-content/80">By Harold Torres Marino</p>
<nav aria-label="Social links" className="flex items-center gap-4">
<a
href="https://www.linkedin.com/in/harold-torres-marino/"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
aria-label="LinkedIn (opens in a new tab)"
className="text-base-content/80 hover:text-primary transition-colors"
>
<svg
Expand All @@ -98,16 +106,16 @@ export default function RootLayout({
href="https://haroldtorres.dev"
target="_blank"
rel="noopener noreferrer"
aria-label="Portfolio website"
aria-label="Portfolio website (opens in a new tab)"
className="text-base-content/80 hover:text-primary transition-colors"
>
<Globe size={18} />
<Globe size={18} aria-hidden="true" />
</a>
<a
href="https://github.com/codehunt101"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
aria-label="GitHub (opens in a new tab)"
className="text-base-content/80 hover:text-primary transition-colors"
>
<svg
Expand All @@ -126,7 +134,7 @@ export default function RootLayout({
aria-label="Send email"
className="text-base-content/80 hover:text-primary transition-colors"
>
<Mail size={18} />
<Mail size={18} aria-hidden="true" />
</a>
</nav>
</footer>
Expand Down
3 changes: 2 additions & 1 deletion components/features/ParticipantsSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ const ParticipantsSetup = () => {
onChange={(e) => setTotalParticipants(Number(e.target.value))}
className="range"
step="1"
aria-valuetext={`${totalParticipants} ${totalParticipants === 1 ? "person" : "people"}`}
/>
<div className="flex w-full justify-between px-2 text-base">
<div className="flex w-full justify-between px-2 text-base" aria-hidden="true">
{Array.from({ length: MAX_PARTICIPANTS }, (_, i) => (
<span key={i + 1}>{i + 1}</span>
))}
Expand Down
71 changes: 71 additions & 0 deletions components/ui/TabGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ describe("TabGroup component", () => {
label: "Test Label",
};

beforeEach(() => {
onChange.mockClear();
});

it("renders label", () => {
const { getByText } = render(<TabGroup {...defaultProps} />);
expect(getByText("Test Label")).toBeInTheDocument();
Expand Down Expand Up @@ -43,4 +47,71 @@ describe("TabGroup component", () => {
const { getByText } = render(<TabGroup {...defaultProps} />);
expect(getByText("Option1")).toBeInTheDocument();
});

it("moves to the next tab on ArrowRight", () => {
const { getByRole } = render(<TabGroup {...defaultProps} />);
const activeTab = getByRole("tab", { name: "Option1" });
fireEvent.keyDown(activeTab, { key: "ArrowRight" });
expect(onChange).toHaveBeenCalledWith("option2");
});

it("wraps to the first tab on ArrowRight from the last tab", () => {
const { getByRole } = render(<TabGroup {...defaultProps} value="option3" />);
const lastTab = getByRole("tab", { name: "Option3" });
fireEvent.keyDown(lastTab, { key: "ArrowRight" });
expect(onChange).toHaveBeenCalledWith("option1");
});

it("moves to the previous tab on ArrowLeft", () => {
const { getByRole } = render(<TabGroup {...defaultProps} value="option2" />);
const tab = getByRole("tab", { name: "Option2" });
fireEvent.keyDown(tab, { key: "ArrowLeft" });
expect(onChange).toHaveBeenCalledWith("option1");
});

it("wraps to the last tab on ArrowLeft from the first tab", () => {
const { getByRole } = render(<TabGroup {...defaultProps} />);
const firstTab = getByRole("tab", { name: "Option1" });
fireEvent.keyDown(firstTab, { key: "ArrowLeft" });
expect(onChange).toHaveBeenCalledWith("option3");
});

it("moves to the first tab on Home", () => {
const { getByRole } = render(<TabGroup {...defaultProps} value="option3" />);
const tab = getByRole("tab", { name: "Option3" });
fireEvent.keyDown(tab, { key: "Home" });
expect(onChange).toHaveBeenCalledWith("option1");
});

it("moves to the last tab on End", () => {
const { getByRole } = render(<TabGroup {...defaultProps} />);
const tab = getByRole("tab", { name: "Option1" });
fireEvent.keyDown(tab, { key: "End" });
expect(onChange).toHaveBeenCalledWith("option3");
});

it("sets tabIndex 0 on the active tab and -1 on inactive tabs", () => {
const { getByRole } = render(<TabGroup {...defaultProps} />);
expect(getByRole("tab", { name: "Option1" })).toHaveAttribute("tabIndex", "0");
expect(getByRole("tab", { name: "Option2" })).toHaveAttribute("tabIndex", "-1");
expect(getByRole("tab", { name: "Option3" })).toHaveAttribute("tabIndex", "-1");
});

it("links the tablist to its label via aria-labelledby", () => {
const { getByRole } = render(<TabGroup {...defaultProps} />);
const tablist = getByRole("tablist");
expect(tablist).toHaveAttribute("aria-labelledby");
});

it("renders an associated tabpanel", () => {
const { getByRole } = render(<TabGroup {...defaultProps} />);
expect(getByRole("tabpanel")).toBeInTheDocument();
});

it("does not call onChange for unrelated keys", () => {
const { getByRole } = render(<TabGroup {...defaultProps} />);
const tab = getByRole("tab", { name: "Option1" });
fireEvent.keyDown(tab, { key: "a" });
expect(onChange).not.toHaveBeenCalled();
});
});
Loading
Loading