import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { DEFAULT_DATE_FORMAT } from "@/platform/dateFormat";
import * as openUrl from "@/platform/openUrl";
import { Avatar, AvatarStack, avatarSrc, reviewerRingColor } from "./Avatar";
import { BranchChip, LabelChip, StateTag, labelForeground } from "./chips";
import { ChooserPopup, chooserItems } from "./ChooserPopup";
import { Markdown } from "./Markdown";
import { fullDateTime, prettyDate } from "./prettyDate";
describe("prettyDate", () => {
const o = { ...DEFAULT_DATE_FORMAT, overrideSystem: true, pattern: "dd.MM.yyyy", use24Hour: true };
// Between May and June, so that no date below crosses a DST change in any time zone that has one.
const now = new Date(2026, 5, 30, 9, 2).getTime();
it("formats relative dates like GitHub", () => {
expect(prettyDate(now - 20_000, now, o)).toBe("just now");
expect(prettyDate(now + 20_000, now, o)).toBe("just now");
expect(prettyDate(now - 60_000, now, o)).toBe("1 minute ago");
expect(prettyDate(now - 42 * 60_000, now, o)).toBe("42 minutes ago");
expect(prettyDate(now - 3_600_000, now, o)).toBe("1 hour ago");
expect(prettyDate(new Date(2026, 5, 30, 0, 27).getTime(), now, o)).toBe("8 hours ago");
expect(prettyDate(new Date(2026, 5, 29, 8, 0).getTime(), now, o)).toBe("yesterday");
expect(prettyDate(new Date(2026, 5, 20, 9, 5).getTime(), now, o)).toBe("9 days ago");
expect(prettyDate(new Date(2026, 4, 2, 9, 5).getTime(), now, o)).toBe("on 02.05.2026");
expect(prettyDate("not a date", now, o)).toBe("");
});
it("shows the plain date and time with pretty formatting off", () => {
expect(prettyDate(now - 20_000, now, { ...o, pretty: false })).toBe("30.06.2026 09:01");
expect(fullDateTime(new Date(2026, 5, 30, 0, 27).getTime(), o)).toBe("30.06.2026 00:27");
});
});
describe("Avatar", () => {
it("loads the avatar at twice its size and falls back to the initial", () => {
expect(avatarSrc("https://avatars.githubusercontent.com/u/1?v=4", 20)).toBe(
"https://avatars.githubusercontent.com/u/1?v=4&s=40",
);
expect(avatarSrc("https://example.com/a.png", 20)).toBe("https://example.com/a.png");
const { container } = render();
const img = container.querySelector("img")!;
expect(img).toBeTruthy();
fireEvent.error(img);
expect(container.querySelector("img")).toBeNull();
expect(container.textContent).toBe("O");
expect(container.querySelector("[title='octocat']")).toBeTruthy();
});
it("rings reviewer avatars in their state colour and stacks at most two", () => {
expect(reviewerRingColor("APPROVED")).toContain("approved");
expect(reviewerRingColor("RE_REQUESTED")).toContain("neutral");
const { container } = render(
({ key: n, url: null, name: n, ring: reviewerRingColor("APPROVED") }))}
title="Reviewers"
/>,
);
expect(container.querySelectorAll("[data-avatar]")).toHaveLength(2);
});
});
describe("chips", () => {
it("shows the state tag except for open pull requests", () => {
const { container, rerender } = render();
expect(container.textContent).toBe("");
rerender();
expect(container.textContent).toBe("DRAFT");
});
it("draws labels in their colour with readable text", () => {
expect(labelForeground("ffffff")).toBe("#000000");
expect(labelForeground("0e1a8a")).toBe("#ffffff");
render();
expect(screen.getByText("bug").getAttribute("style")).toContain("background");
render();
expect(screen.getByTitle("feature/x")).toBeTruthy();
});
});
describe("ChooserPopup", () => {
const users = [
{ id: "1", login: "ann", name: "Ann Lee" },
{ id: "2", login: "bob", name: "Robert" },
{ id: "3", login: "cy", name: null },
];
const present = (u: (typeof users)[number]) => ({ text: u.login, detail: u.name, avatarUrl: null });
const getKey = (u: (typeof users)[number]) => u.id;
it("lists the chosen items first, without duplicates", () => {
expect(chooserItems([users[2]!], users, getKey).map((u) => u.login)).toEqual(["cy", "ann", "bob"]);
});
it("chooses one item and closes (single)", () => {
const onChoose = vi.fn();
const onClose = vi.fn();
render(
,
);
const search = screen.getAllByLabelText("Author").find((e) => e.tagName === "INPUT")!;
fireEvent.change(search, { target: { value: "rob" } });
fireEvent.keyDown(search, { key: "Enter" });
expect(onChoose).toHaveBeenCalledWith(users[1]);
expect(onClose).toHaveBeenCalled();
});
it("toggles items and reports them on close (multi)", async () => {
const onClose = vi.fn();
render(
,
);
expect(screen.getByText("Loading…")).toBeTruthy();
const search = screen.getAllByLabelText("Reviewers").find((e) => e.tagName === "INPUT")!;
fireEvent.keyDown(search, { key: "Enter" }); // unchecks ann (first, active)
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" }); // checks bob
await act(async () => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(onClose).toHaveBeenCalledWith([users[1]]);
});
});
describe("Markdown", () => {
it("opens web links in the browser and never navigates the page for other links", async () => {
const open = vi.spyOn(openUrl, "openExternalUrl").mockResolvedValue();
const { container } = render();
const links = await vi.waitFor(() => {
const found = container.querySelectorAll("a");
expect(found).toHaveLength(2);
return found;
});
const click = (a: Element) => {
const e = new MouseEvent("click", { bubbles: true, cancelable: true });
a.dispatchEvent(e);
return e.defaultPrevented;
};
expect(click(links[0]!)).toBe(true);
expect(open).toHaveBeenCalledWith("https://example.com/a");
open.mockClear();
expect(click(links[1]!)).toBe(true);
expect(open).not.toHaveBeenCalled();
});
});