Implement Calendar page with MonthView and ICS subscription (P2.11)
- Complete MonthView component with calendar grid, DayCell integration, navigation controls (prev/next month, Today button), and phase legend - Implement Calendar page with MonthView, month navigation state, ICS subscription section with URL display, copy, and token regeneration - Add 21 tests for MonthView component (calendar grid, phase colors, navigation, legend, cycle rollover) - Add 23 tests for Calendar page (rendering, navigation, ICS subscription, token regeneration, error handling) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
249
src/components/calendar/month-view.test.tsx
Normal file
249
src/components/calendar/month-view.test.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
// ABOUTME: Unit tests for the MonthView component.
|
||||
// ABOUTME: Tests calendar grid rendering, phase display, navigation, and day cell integration.
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MonthView } from "./month-view";
|
||||
|
||||
describe("MonthView", () => {
|
||||
// Fixed date for consistent testing: January 2026
|
||||
const baseProps = {
|
||||
year: 2026,
|
||||
month: 0, // January (0-indexed)
|
||||
lastPeriodDate: new Date("2026-01-01"),
|
||||
cycleLength: 28,
|
||||
onMonthChange: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock Date.now to return Jan 15, 2026
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-15"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("header rendering", () => {
|
||||
it("renders the month and year in header", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
expect(screen.getByText("January 2026")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders day-of-week headers", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
expect(screen.getByText("Sun")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Wed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Thu")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fri")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sat")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar grid", () => {
|
||||
it("renders all days of the month", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// January 2026 has 31 days
|
||||
for (let day = 1; day <= 31; day++) {
|
||||
expect(screen.getByText(day.toString())).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders day cells with cycle day information", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// With lastPeriodDate = Jan 1 and cycleLength = 28:
|
||||
// Day 1 appears twice (Jan 1 = Day 1, Jan 29 = Day 1 due to cycle rollover)
|
||||
// Day 15 appears once (Jan 15)
|
||||
const day1Elements = screen.getAllByText("Day 1");
|
||||
expect(day1Elements.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Day 15")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights today's date", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Jan 15 is "today" - find the button containing "15"
|
||||
const todayCell = screen.getByRole("button", { name: /^15\s*Day 15/i });
|
||||
expect(todayCell).toHaveClass("ring-2", "ring-black");
|
||||
});
|
||||
|
||||
it("does not highlight non-today dates", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Jan 1 is not today
|
||||
const otherCell = screen.getByRole("button", { name: /^1\s*Day 1/i });
|
||||
expect(otherCell).not.toHaveClass("ring-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase colors", () => {
|
||||
it("applies menstrual phase color to days 1-3", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Days 1-3 are MENSTRUAL (bg-blue-100)
|
||||
const day1 = screen.getByRole("button", { name: /^1\s*Day 1/i });
|
||||
expect(day1).toHaveClass("bg-blue-100");
|
||||
});
|
||||
|
||||
it("applies follicular phase color to days 4-14", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Day 5 is FOLLICULAR (bg-green-100)
|
||||
const day5 = screen.getByRole("button", { name: /^5\s*Day 5/i });
|
||||
expect(day5).toHaveClass("bg-green-100");
|
||||
});
|
||||
|
||||
it("applies ovulation phase color to days 15-16", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Day 15 is OVULATION (bg-purple-100)
|
||||
const day15 = screen.getByRole("button", { name: /^15\s*Day 15/i });
|
||||
expect(day15).toHaveClass("bg-purple-100");
|
||||
});
|
||||
|
||||
it("applies early luteal phase color to days 17-24", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Day 20 is EARLY_LUTEAL (bg-yellow-100)
|
||||
const day20 = screen.getByRole("button", { name: /^20\s*Day 20/i });
|
||||
expect(day20).toHaveClass("bg-yellow-100");
|
||||
});
|
||||
|
||||
it("applies late luteal phase color to days 25-31", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Day 25 is LATE_LUTEAL (bg-red-100)
|
||||
const day25 = screen.getByRole("button", { name: /^25\s*Day 25/i });
|
||||
expect(day25).toHaveClass("bg-red-100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation", () => {
|
||||
it("renders previous month button", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /previous month/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders next month button", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /next month/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Today button", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^today$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onMonthChange with previous month when clicking previous", () => {
|
||||
const onMonthChange = vi.fn();
|
||||
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
|
||||
|
||||
const prevButton = screen.getByRole("button", {
|
||||
name: /previous month/i,
|
||||
});
|
||||
fireEvent.click(prevButton);
|
||||
|
||||
expect(onMonthChange).toHaveBeenCalledWith(2025, 11); // December 2025
|
||||
});
|
||||
|
||||
it("calls onMonthChange with next month when clicking next", () => {
|
||||
const onMonthChange = vi.fn();
|
||||
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next month/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
expect(onMonthChange).toHaveBeenCalledWith(2026, 1); // February 2026
|
||||
});
|
||||
|
||||
it("calls onMonthChange with current month when clicking Today", () => {
|
||||
const onMonthChange = vi.fn();
|
||||
// Render a different month than current (March 2026)
|
||||
render(
|
||||
<MonthView
|
||||
{...baseProps}
|
||||
year={2026}
|
||||
month={2}
|
||||
onMonthChange={onMonthChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const todayButton = screen.getByRole("button", { name: /^today$/i });
|
||||
fireEvent.click(todayButton);
|
||||
|
||||
// Should navigate to January 2026 (month of mocked "today")
|
||||
expect(onMonthChange).toHaveBeenCalledWith(2026, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase legend", () => {
|
||||
it("renders phase legend", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
expect(screen.getByText(/menstrual/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/follicular/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/ovulation/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/early luteal/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/late luteal/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle rollover", () => {
|
||||
it("handles cycle rollover correctly", () => {
|
||||
// Last period was Dec 5, 2025 with 28-day cycle
|
||||
// Jan 1, 2026 = day 28 of cycle
|
||||
// Jan 2, 2026 = day 1 of new cycle
|
||||
render(
|
||||
<MonthView
|
||||
{...baseProps}
|
||||
lastPeriodDate={new Date("2025-12-05")}
|
||||
cycleLength={28}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Jan 1 should be day 28 (late luteal)
|
||||
const jan1 = screen.getByRole("button", { name: /^1\s*Day 28/i });
|
||||
expect(jan1).toHaveClass("bg-red-100"); // LATE_LUTEAL
|
||||
|
||||
// Jan 2 should be day 1 (menstrual)
|
||||
const jan2 = screen.getByRole("button", { name: /^2\s*Day 1/i });
|
||||
expect(jan2).toHaveClass("bg-blue-100"); // MENSTRUAL
|
||||
});
|
||||
});
|
||||
|
||||
describe("different month displays", () => {
|
||||
it("renders February 2026 correctly", () => {
|
||||
render(<MonthView {...baseProps} month={1} />);
|
||||
|
||||
expect(screen.getByText("February 2026")).toBeInTheDocument();
|
||||
// February 2026 has 28 days (not a leap year)
|
||||
expect(screen.getByText("28")).toBeInTheDocument();
|
||||
expect(screen.queryByText("29")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders leap year February correctly", () => {
|
||||
render(<MonthView {...baseProps} year={2024} month={1} />);
|
||||
|
||||
expect(screen.getByText("February 2024")).toBeInTheDocument();
|
||||
// February 2024 has 29 days (leap year)
|
||||
expect(screen.getByText("29")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user