blob: ab0a8cf4a587549378d16cccd6c5d368be71e181 [file]
/** EventDialog: the details panel and the create/edit form. */
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/svelte";
import EventDialog from "../components/EventDialog.svelte";
import { draftFromEvent, newDraft } from "../lib/drafts";
import { makeCalendars, makeEvent } from "./factories";
const noop = () => {};
function props(overrides: Record<string, unknown> = {}) {
return {
event: null,
draft: null,
calendars: makeCalendars(),
saving: false,
error: null,
editable: false,
onclose: noop,
onedit: noop,
onsave: noop,
ondelete: noop,
...overrides,
};
}
describe("viewing an event", () => {
const event = makeEvent({
title: "Release party",
location: "Berlin",
description: "Cake in the atrium",
url: "https://apache.org",
});
it("shows the details", () => {
render(EventDialog, { props: props({ event }) });
expect(screen.getByText("Release party")).toBeInTheDocument();
expect(screen.getByText("Berlin")).toBeInTheDocument();
expect(screen.getByText("Cake in the atrium")).toBeInTheDocument();
expect(screen.getByText("https://apache.org")).toHaveAttribute("href", "https://apache.org");
});
it("formats a timed event as a single day with a range", () => {
render(EventDialog, { props: props({ event }) });
expect(screen.getByText("10 March 2026, 09:00 - 10:00")).toBeInTheDocument();
});
it("formats an all-day event without times", () => {
const allDay = makeEvent({
all_day: true,
start: "2026-03-10T00:00:00Z",
end: "2026-03-11T00:00:00Z",
});
render(EventDialog, { props: props({ event: allDay }) });
expect(screen.getByText("10 March 2026 (all day)")).toBeInTheDocument();
});
it("formats a multi-day all-day event as a span", () => {
const allDay = makeEvent({
all_day: true,
start: "2026-03-10T00:00:00Z",
end: "2026-03-13T00:00:00Z",
});
render(EventDialog, { props: props({ event: allDay }) });
expect(screen.getByText("10 March 2026 - 12 March 2026 (all day)")).toBeInTheDocument();
});
it("shows the shortlink and an ics download", () => {
render(EventDialog, { props: props({ event }) });
expect(screen.getByText(event.shortlink_url)).toHaveAttribute("href", event.shortlink_url);
expect(screen.getByText("Download .ics")).toHaveAttribute("href", `/api/events/${event.id}.ics`);
});
it("hides the edit button when the user may not edit", () => {
render(EventDialog, { props: props({ event, editable: false }) });
expect(screen.queryByText("Edit")).not.toBeInTheDocument();
});
it("offers editing when the user may", async () => {
const onedit = vi.fn();
render(EventDialog, { props: props({ event, editable: true, onedit }) });
await fireEvent.click(screen.getByText("Edit"));
expect(onedit).toHaveBeenCalledOnce();
});
it("closes on the Close button and on Escape", async () => {
const onclose = vi.fn();
render(EventDialog, { props: props({ event, onclose }) });
await fireEvent.click(screen.getByText("Close"));
expect(onclose).toHaveBeenCalledOnce();
await fireEvent.keyDown(window, { key: "Escape" });
expect(onclose).toHaveBeenCalledTimes(2);
});
it("shows an error passed in from the app", () => {
render(EventDialog, { props: props({ event, error: "Something went wrong" }) });
expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong");
});
});
describe("creating an event", () => {
const draft = newDraft(new Date(2026, 2, 10, 14, 0), makeCalendars());
it("shows an empty form", () => {
render(EventDialog, { props: props({ draft }) });
expect(screen.getByText("New event")).toBeInTheDocument();
expect(screen.getByPlaceholderText("What is happening?")).toHaveValue("");
});
it("offers only the calendars the user can write to", () => {
render(EventDialog, { props: props({ draft }) });
const options = [...screen.getByLabelText("Calendar").querySelectorAll("option")].map(
(option) => option.textContent,
);
expect(options).toEqual(["Personal", "Project"]);
});
it("offers the private option to a committee member", () => {
render(EventDialog, { props: props({ draft }) });
const options = [...screen.getByLabelText("Visibility").querySelectorAll("option")].map(
(option) => option.value,
);
expect(options).toEqual(["public", "private"]);
});
it("hands the filled-in draft back on submit", async () => {
const onsave = vi.fn();
const { container } = render(EventDialog, { props: props({ draft, onsave }) });
await fireEvent.input(screen.getByPlaceholderText("What is happening?"), {
target: { value: "Release party" },
});
await fireEvent.submit(container.querySelector("form")!);
expect(onsave).toHaveBeenCalledOnce();
expect(onsave.mock.calls[0][0].title).toBe("Release party");
expect(onsave.mock.calls[0][0].category).toBe("project");
});
it("switches to date inputs for an all-day event", async () => {
const { container } = render(EventDialog, { props: props({ draft }) });
expect(container.querySelectorAll('input[type="datetime-local"]')).toHaveLength(2);
await fireEvent.click(screen.getByLabelText("All day"));
expect(container.querySelectorAll('input[type="date"]')).toHaveLength(2);
});
it("has no delete button for an event that does not exist yet", () => {
render(EventDialog, { props: props({ draft }) });
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
});
it("disables the buttons while saving", () => {
render(EventDialog, { props: props({ draft, saving: true }) });
expect(screen.getByText("Saving...")).toBeDisabled();
expect(screen.getByText("Cancel")).toBeDisabled();
});
});
describe("editing an event", () => {
const event = makeEvent({ title: "Original" });
it("pre-fills the form and offers deletion", () => {
render(EventDialog, { props: props({ event, draft: draftFromEvent(event), editable: true }) });
expect(screen.getByText("Edit event")).toBeInTheDocument();
expect(screen.getByPlaceholderText("What is happening?")).toHaveValue("Original");
expect(screen.getByText("Delete")).toBeInTheDocument();
});
it("asks the app to delete", async () => {
const ondelete = vi.fn();
render(EventDialog, {
props: props({ event, draft: draftFromEvent(event), editable: true, ondelete }),
});
await fireEvent.click(screen.getByText("Delete"));
expect(ondelete).toHaveBeenCalledOnce();
});
it("hides the project picker for a foundation event", () => {
const foundationEvent = makeEvent({ category: "foundation", project: null });
const calendars = makeCalendars({
is_member: true,
can_create: { personal: true, project: ["httpd"], project_private: ["httpd"], foundation: true },
});
render(EventDialog, {
props: props({ event: foundationEvent, draft: draftFromEvent(foundationEvent), calendars }),
});
expect(screen.queryByLabelText("Project")).not.toBeInTheDocument();
});
it("hides the visibility picker for a personal event", () => {
const personal = makeEvent({ category: "personal", project: null, visibility: "private" });
render(EventDialog, { props: props({ event: personal, draft: draftFromEvent(personal) }) });
expect(screen.queryByLabelText("Visibility")).not.toBeInTheDocument();
});
});
describe("timezones in the form", () => {
const calendars = makeCalendars();
function berlinDraft() {
return {
...newDraft(new Date(2026, 6, 10, 12, 0), calendars),
timezone: "Europe/Berlin",
// 13:00 UTC is 15:00 in Berlin in July.
start: "2026-07-10T13:00:00Z",
end: "2026-07-10T14:00:00Z",
};
}
it("offers a timezone picker for a timed event", () => {
render(EventDialog, { props: props({ draft: berlinDraft() }) });
const picker = screen.getByLabelText(/Timezone of these times/);
expect(picker).toHaveValue("Europe/Berlin");
});
it("hides the picker for an all-day event, which is a date not a moment", async () => {
render(EventDialog, { props: props({ draft: berlinDraft() }) });
await fireEvent.click(screen.getByLabelText("All day"));
expect(screen.queryByLabelText(/Timezone of these times/)).not.toBeInTheDocument();
});
it("shows the times on the event's own clock, not the viewer's", () => {
const { container } = render(EventDialog, { props: props({ draft: berlinDraft() }) });
const [start, end] = container.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]');
expect(start.value).toBe("2026-07-10T15:00");
expect(end.value).toBe("2026-07-10T16:00");
});
it("moving the event to another zone keeps the clock and changes the moment", async () => {
const onsave = vi.fn();
const { container } = render(EventDialog, { props: props({ draft: berlinDraft(), onsave }) });
await fireEvent.change(screen.getByLabelText(/Timezone of these times/), {
target: { value: "Asia/Tokyo" },
});
const [start] = container.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]');
expect(start.value).toBe("2026-07-10T15:00"); // still 15:00...
await fireEvent.submit(container.querySelector("form")!);
// ...but 15:00 in Tokyo, which is 06:00 UTC.
expect(onsave.mock.calls[0][0].start).toBe("2026-07-10T06:00:00Z");
expect(onsave.mock.calls[0][0].timezone).toBe("Asia/Tokyo");
});
it("interprets a typed time in the chosen zone", async () => {
const onsave = vi.fn();
const { container } = render(EventDialog, { props: props({ draft: berlinDraft(), onsave }) });
const [start] = container.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]');
await fireEvent.change(start, { target: { value: "2026-07-10T09:30" } });
await fireEvent.submit(container.querySelector("form")!);
expect(onsave.mock.calls[0][0].start).toBe("2026-07-10T07:30:00Z");
});
it("keeps all-day events on whole UTC days", async () => {
const onsave = vi.fn();
const { container } = render(EventDialog, { props: props({ draft: berlinDraft(), onsave }) });
await fireEvent.click(screen.getByLabelText("All day"));
const [start, end] = container.querySelectorAll<HTMLInputElement>('input[type="date"]');
await fireEvent.change(start, { target: { value: "2026-07-10" } });
await fireEvent.change(end, { target: { value: "2026-07-12" } });
await fireEvent.submit(container.querySelector("form")!);
const saved = onsave.mock.calls[0][0];
expect(saved.start).toBe("2026-07-10T00:00:00Z");
// The stored end is exclusive: the day after the last day.
expect(saved.end).toBe("2026-07-13T00:00:00Z");
});
});
describe("timezones in the details", () => {
const berlinEvent = makeEvent({
title: "Berlin call",
timezone: "Europe/Berlin",
start: "2026-07-10T13:00:00Z",
end: "2026-07-10T14:00:00Z",
});
function clocks(container: HTMLElement): string[] {
return [...container.querySelectorAll(".clocks li")].map((row) =>
(row.textContent ?? "").replace(/\s+/g, " ").trim(),
);
}
it("shows the viewer's clock as the headline", () => {
render(EventDialog, { props: props({ event: berlinEvent, zone: "UTC" }) });
expect(screen.getByText(/10 July 2026, 13:00 - 14:00/)).toBeInTheDocument();
});
it("adds the organiser's own clock when it differs", () => {
const { container } = render(EventDialog, { props: props({ event: berlinEvent, zone: "UTC" }) });
expect(clocks(container)).toEqual(["Berlin (organiser) 15:00 - 16:00"]);
});
it("does not repeat itself when the clocks agree", () => {
const utcEvent = makeEvent({ timezone: "UTC" });
const { container } = render(EventDialog, { props: props({ event: utcEvent, zone: "UTC" }) });
expect(clocks(container)).toEqual([]);
});
it("says nothing about zones for an all-day event", () => {
const allDay = makeEvent({
all_day: true,
start: "2026-07-10T00:00:00Z",
end: "2026-07-11T00:00:00Z",
timezone: "UTC",
});
const { container } = render(EventDialog, { props: props({ event: allDay, zone: "UTC" }) });
expect(clocks(container)).toEqual([]);
});
it("lists every comparison clock the viewer has asked for", () => {
const { container } = render(
EventDialog,
{
props: props({
event: berlinEvent,
zone: "UTC",
compareZones: ["Asia/Tokyo", "America/New_York"],
}),
},
);
expect(clocks(container)).toEqual([
"Berlin (organiser) 15:00 - 16:00",
"Tokyo 22:00 - 23:00",
"New York 09:00 - 10:00",
]);
});
it("does not list a comparison clock that is already the organiser's", () => {
const { container } = render(
EventDialog,
{ props: props({ event: berlinEvent, zone: "UTC", compareZones: ["Europe/Berlin"] }) },
);
expect(clocks(container)).toEqual(["Berlin (organiser) 15:00 - 16:00"]);
});
it("names the local clock as local", () => {
const { container } = render(
EventDialog,
{ props: props({ event: berlinEvent, zone: "Asia/Tokyo", compareZones: ["local"] }) },
);
// The suite runs in UTC, so "local" reads 13:00.
expect(clocks(container)).toContain("Local - UTC 13:00 - 14:00");
});
});