refactor(app): extract pr-pane derivations into pure utils and unit tests (#713)
Move getStateLabel and getActivityVerb out of pr-pane.tsx into pr-pane-data.ts so they can be tested without JSDOM, React, or vi.mock. Add exhaustive unit tests for both helpers alongside the existing mapPrPaneData/formatAge/deriveAvatarColor coverage. Delete pr-pane.test.tsx — the component test was asserting on derived label/icon strings that are now covered by pure function tests.
This commit is contained in:
@@ -1,169 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { PrPaneData } from "@/utils/pr-pane-data";
|
|
||||||
import { PrPane } from "./pr-pane";
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
||||||
fontWeight: { normal: "400" },
|
|
||||||
colors: {
|
|
||||||
surfaceSidebar: "#0b0b0b",
|
|
||||||
surfaceSidebarHover: "#151515",
|
|
||||||
border: "#222",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
statusSuccess: "#30d158",
|
|
||||||
statusDanger: "#ff453a",
|
|
||||||
statusWarning: "#f2c945",
|
|
||||||
statusMerged: "#a371f7",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
|
||||||
return {
|
|
||||||
ChevronDown: createIcon("ChevronDown"),
|
|
||||||
ChevronRight: createIcon("ChevronRight"),
|
|
||||||
CircleCheck: createIcon("CircleCheck"),
|
|
||||||
CircleDot: createIcon("CircleDot"),
|
|
||||||
CircleSlash: createIcon("CircleSlash"),
|
|
||||||
CircleX: createIcon("CircleX"),
|
|
||||||
ExternalLink: createIcon("ExternalLink"),
|
|
||||||
GitMerge: createIcon("GitMerge"),
|
|
||||||
GitPullRequest: createIcon("GitPullRequest"),
|
|
||||||
GitPullRequestClosed: createIcon("GitPullRequestClosed"),
|
|
||||||
GitPullRequestDraft: createIcon("GitPullRequestDraft"),
|
|
||||||
MessageSquare: createIcon("MessageSquare"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/utils/open-external-url", () => ({
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal("window", dom.window);
|
|
||||||
vi.stubGlobal("document", dom.window.document);
|
|
||||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
|
||||||
vi.stubGlobal("Node", dom.window.Node);
|
|
||||||
vi.stubGlobal("navigator", dom.window.navigator);
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeData(overrides: Partial<PrPaneData> = {}): PrPaneData {
|
|
||||||
return {
|
|
||||||
number: 1284,
|
|
||||||
title: "fix(server,app): coalesce tool call stream events",
|
|
||||||
state: "open",
|
|
||||||
url: "https://github.com/getpaseo/paseo/pull/1284",
|
|
||||||
reviewDecision: "changes_requested",
|
|
||||||
awaitingReviewers: [],
|
|
||||||
checks: [
|
|
||||||
{ name: "typecheck", workflow: "CI", status: "success", duration: "1m", url: "#" },
|
|
||||||
{ name: "test", workflow: "CI", status: "failure", duration: "2m", url: "#" },
|
|
||||||
],
|
|
||||||
activity: [
|
|
||||||
{
|
|
||||||
kind: "review",
|
|
||||||
author: "alicek",
|
|
||||||
avatarColor: "#8b5cf6",
|
|
||||||
reviewState: "approved",
|
|
||||||
body: "LGTM",
|
|
||||||
age: "2h ago",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "comment",
|
|
||||||
author: "bmartin",
|
|
||||||
avatarColor: "#f97316",
|
|
||||||
body: "Worth a benchmark before/after.",
|
|
||||||
age: "3h ago",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(data: PrPaneData) {
|
|
||||||
act(() => {
|
|
||||||
root?.render(<PrPane data={data} />);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PrPane", () => {
|
|
||||||
it("renders the PR title and 'Open' state label from the data prop", () => {
|
|
||||||
render(makeData());
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("fix(server,app): coalesce tool call stream events");
|
|
||||||
expect(container?.textContent).toContain("Open");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the correct state label and icon for each PR state", () => {
|
|
||||||
render(makeData({ state: "draft" }));
|
|
||||||
expect(container?.textContent).toContain("Draft");
|
|
||||||
expect(container?.querySelector('[data-icon="GitPullRequestDraft"]')).not.toBeNull();
|
|
||||||
|
|
||||||
render(makeData({ state: "merged" }));
|
|
||||||
expect(container?.textContent).toContain("Merged");
|
|
||||||
expect(container?.querySelector('[data-icon="GitMerge"]')).not.toBeNull();
|
|
||||||
|
|
||||||
render(makeData({ state: "closed" }));
|
|
||||||
expect(container?.textContent).toContain("Closed");
|
|
||||||
expect(container?.querySelector('[data-icon="GitPullRequestClosed"]')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives check summary counts from the data prop", () => {
|
|
||||||
render(makeData());
|
|
||||||
const text = container?.textContent ?? "";
|
|
||||||
|
|
||||||
expect(text).toContain("typecheck");
|
|
||||||
expect(text).toContain("test");
|
|
||||||
// 1 passed, 1 failed, 0 pending — so pending pill is hidden.
|
|
||||||
expect(text).toMatch(/1.*1/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives review summary counts and renders activity rows", () => {
|
|
||||||
render(makeData());
|
|
||||||
const text = container?.textContent ?? "";
|
|
||||||
|
|
||||||
expect(text).toContain("alicek");
|
|
||||||
expect(text).toContain("Approved");
|
|
||||||
expect(text).toContain("bmartin");
|
|
||||||
expect(text).toContain("Commented");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
|
||||||
import type {
|
import type {
|
||||||
CheckStatus,
|
CheckStatus,
|
||||||
PrPaneActivity,
|
PrPaneActivity,
|
||||||
@@ -283,13 +284,6 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivityVerb(item: PrPaneActivity): string {
|
|
||||||
if (item.kind === "comment") return "Commented";
|
|
||||||
if (item.reviewState === "approved") return "Approved";
|
|
||||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
|
||||||
return "Reviewed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
||||||
if (state === "open") return theme.colors.statusSuccess;
|
if (state === "open") return theme.colors.statusSuccess;
|
||||||
if (state === "draft") return theme.colors.foregroundMuted;
|
if (state === "draft") return theme.colors.foregroundMuted;
|
||||||
@@ -304,13 +298,6 @@ function getStateIcon(state: PrState) {
|
|||||||
return GitPullRequest;
|
return GitPullRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStateLabel(state: PrState): string {
|
|
||||||
if (state === "draft") return "Draft";
|
|
||||||
if (state === "merged") return "Merged";
|
|
||||||
if (state === "closed") return "Closed";
|
|
||||||
return "Open";
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
root: {
|
root: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import type {
|
|||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
PullRequestTimelineResponse,
|
PullRequestTimelineResponse,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import { deriveAvatarColor, formatAge, mapPrPaneData } from "./pr-pane-data";
|
import {
|
||||||
|
deriveAvatarColor,
|
||||||
|
formatAge,
|
||||||
|
getActivityVerb,
|
||||||
|
getStateLabel,
|
||||||
|
mapPrPaneData,
|
||||||
|
} from "./pr-pane-data";
|
||||||
|
|
||||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||||
@@ -335,3 +341,38 @@ describe("formatAge", () => {
|
|||||||
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
|
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getStateLabel", () => {
|
||||||
|
it.each([
|
||||||
|
["open", "Open"],
|
||||||
|
["draft", "Draft"],
|
||||||
|
["merged", "Merged"],
|
||||||
|
["closed", "Closed"],
|
||||||
|
] as const)("maps %s → %s", (state, expected) => {
|
||||||
|
expect(getStateLabel(state)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getActivityVerb", () => {
|
||||||
|
it("returns Commented for comment kind", () => {
|
||||||
|
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Approved for approved review", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Requested changes for changes_requested review", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
|
||||||
|
"Requested changes",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Reviewed for a commented review with body (generic case)", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Reviewed when reviewState is undefined", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -225,3 +225,17 @@ function hashLogin(login: string): number {
|
|||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStateLabel(state: PrState): string {
|
||||||
|
if (state === "draft") return "Draft";
|
||||||
|
if (state === "merged") return "Merged";
|
||||||
|
if (state === "closed") return "Closed";
|
||||||
|
return "Open";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
|
||||||
|
if (item.kind === "comment") return "Commented";
|
||||||
|
if (item.reviewState === "approved") return "Approved";
|
||||||
|
if (item.reviewState === "changes_requested") return "Requested changes";
|
||||||
|
return "Reviewed";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user