feat(FN-4370): complete Step 1 — fix badge null-merge semantics
Fusion-Task-Id: FN-4370 Fusion-Task-Lineage: c18188d5-c478-4233-943f-943b0125b271
This commit is contained in:
@@ -970,14 +970,16 @@ function TaskCardComponent({
|
|||||||
const taskInfo = task.prInfo;
|
const taskInfo = task.prInfo;
|
||||||
const taskTimestamp = task.prInfo?.lastCheckedAt ?? task.updatedAt;
|
const taskTimestamp = task.prInfo?.lastCheckedAt ?? task.updatedAt;
|
||||||
|
|
||||||
// Compare all three sources and pick the freshest
|
let bestData = taskInfo;
|
||||||
let bestData = pickPreferredBadge<PrInfo>(wsData, wsTimestamp, taskInfo, taskTimestamp);
|
let bestTimestamp = taskTimestamp;
|
||||||
const bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp;
|
|
||||||
|
|
||||||
if (batchInfo && batchTimestamp) {
|
if (wsData && (!bestTimestamp || (wsTimestamp != null && wsTimestamp >= bestTimestamp))) {
|
||||||
if (!bestTimestamp || batchTimestamp > bestTimestamp) {
|
bestData = wsData;
|
||||||
bestData = batchInfo;
|
bestTimestamp = wsTimestamp ?? bestTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batchInfo && (!bestTimestamp || (batchTimestamp != null && batchTimestamp >= bestTimestamp))) {
|
||||||
|
bestData = batchInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestData;
|
return bestData;
|
||||||
@@ -991,14 +993,16 @@ function TaskCardComponent({
|
|||||||
const taskInfo = task.issueInfo;
|
const taskInfo = task.issueInfo;
|
||||||
const taskTimestamp = task.issueInfo?.lastCheckedAt ?? task.updatedAt;
|
const taskTimestamp = task.issueInfo?.lastCheckedAt ?? task.updatedAt;
|
||||||
|
|
||||||
// Compare all three sources and pick the freshest
|
let bestData = taskInfo;
|
||||||
let bestData = pickPreferredBadge<IssueInfo>(wsData, wsTimestamp, taskInfo, taskTimestamp);
|
let bestTimestamp = taskTimestamp;
|
||||||
const bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp;
|
|
||||||
|
|
||||||
if (batchInfo && batchTimestamp) {
|
if (wsData && (!bestTimestamp || (wsTimestamp != null && wsTimestamp >= bestTimestamp))) {
|
||||||
if (!bestTimestamp || batchTimestamp > bestTimestamp) {
|
bestData = wsData;
|
||||||
bestData = batchInfo;
|
bestTimestamp = wsTimestamp ?? bestTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batchInfo && (!bestTimestamp || (batchTimestamp != null && batchTimestamp >= bestTimestamp))) {
|
||||||
|
bestData = batchInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestData;
|
return bestData;
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ export function pickPreferredBadge<T extends { lastCheckedAt?: string }>(
|
|||||||
taskValue: T | undefined,
|
taskValue: T | undefined,
|
||||||
taskTimestamp: string | undefined,
|
taskTimestamp: string | undefined,
|
||||||
): T | undefined {
|
): T | undefined {
|
||||||
if (liveValue === undefined || !liveTimestamp) {
|
if (liveValue == null) {
|
||||||
return taskValue;
|
return taskValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!liveTimestamp) {
|
||||||
|
return taskValue ?? liveValue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!taskTimestamp || liveTimestamp >= taskTimestamp) {
|
if (!taskTimestamp || liveTimestamp >= taskTimestamp) {
|
||||||
return liveValue ?? undefined;
|
return liveValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return taskValue;
|
return taskValue;
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ vi.mock("../../hooks/useTaskDiffStats", () => ({
|
|||||||
useTaskDiffStats: (...args: any[]) => useTaskDiffStatsMock(...args),
|
useTaskDiffStats: (...args: any[]) => useTaskDiffStatsMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const badgeUpdatesMock = new Map<string, any>();
|
||||||
|
const subscribeToBadgeMock = vi.fn();
|
||||||
|
const unsubscribeFromBadgeMock = vi.fn();
|
||||||
|
vi.mock("../../hooks/useBadgeWebSocket", () => ({
|
||||||
|
useBadgeWebSocket: () => ({
|
||||||
|
badgeUpdates: badgeUpdatesMock,
|
||||||
|
isConnected: true,
|
||||||
|
subscribeToBadge: subscribeToBadgeMock,
|
||||||
|
unsubscribeFromBadge: unsubscribeFromBadgeMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
|
||||||
|
getFreshBatchData: vi.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the api module
|
// Mock the api module
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchTaskDetail: vi.fn(),
|
fetchTaskDetail: vi.fn(),
|
||||||
@@ -90,6 +106,9 @@ const highFanout = {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });
|
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });
|
||||||
|
badgeUpdatesMock.clear();
|
||||||
|
subscribeToBadgeMock.mockReset();
|
||||||
|
unsubscribeFromBadgeMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskCard", () => {
|
describe("TaskCard", () => {
|
||||||
@@ -279,6 +298,31 @@ describe("TaskCard", () => {
|
|||||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders GitHub badge from live websocket data even when task payload has no badge fields", () => {
|
||||||
|
badgeUpdatesMock.set("default:FN-001", {
|
||||||
|
prInfo: {
|
||||||
|
url: "https://github.com/owner/repo/pull/77",
|
||||||
|
number: 77,
|
||||||
|
status: "open",
|
||||||
|
title: "Live PR",
|
||||||
|
headBranch: "feature/live",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
},
|
||||||
|
timestamp: "2026-05-13T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "in-review", prInfo: undefined, issueInfo: undefined })}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("link", { name: "#77" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("clicking issue badge text does not open the task detail modal", () => {
|
it("clicking issue badge text does not open the task detail modal", () => {
|
||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pickPreferredBadge } from "../TaskCardBadge";
|
||||||
|
|
||||||
|
describe("pickPreferredBadge", () => {
|
||||||
|
const taskValue = { number: 1, lastCheckedAt: "2026-05-13T10:00:00.000Z" };
|
||||||
|
const liveValue = { number: 2, lastCheckedAt: "2026-05-13T11:00:00.000Z" };
|
||||||
|
|
||||||
|
it("returns undefined when both values are missing", () => {
|
||||||
|
expect(pickPreferredBadge(undefined, undefined, undefined, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when live is null and task is missing", () => {
|
||||||
|
expect(pickPreferredBadge(null, "2026-05-13T12:00:00.000Z", undefined, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps task value when live is null", () => {
|
||||||
|
expect(
|
||||||
|
pickPreferredBadge(null, "2026-05-13T12:00:00.000Z", taskValue, "2026-05-13T10:00:00.000Z"),
|
||||||
|
).toEqual(taskValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers live when newer", () => {
|
||||||
|
expect(
|
||||||
|
pickPreferredBadge(liveValue, "2026-05-13T12:00:00.000Z", taskValue, "2026-05-13T10:00:00.000Z"),
|
||||||
|
).toEqual(liveValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers task when newer", () => {
|
||||||
|
expect(
|
||||||
|
pickPreferredBadge(liveValue, "2026-05-13T10:00:00.000Z", taskValue, "2026-05-13T12:00:00.000Z"),
|
||||||
|
).toEqual(taskValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -207,7 +207,7 @@ describe("useBadgeWebSocket", () => {
|
|||||||
expect(subscribeMsg).toBeDefined();
|
expect(subscribeMsg).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends unsubscribe, clears cached state, and closes the socket when the final subscription is removed", () => {
|
it("sends unsubscribe, retains cached state, and closes the socket when the final subscription is removed", () => {
|
||||||
const { result } = renderHook(() => useBadgeWebSocket());
|
const { result } = renderHook(() => useBadgeWebSocket());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -240,7 +240,7 @@ describe("useBadgeWebSocket", () => {
|
|||||||
});
|
});
|
||||||
expect(unsubscribeMsg).toBeDefined();
|
expect(unsubscribeMsg).toBeDefined();
|
||||||
expect(MockWebSocket.instances[0].close).toHaveBeenCalled();
|
expect(MockWebSocket.instances[0].close).toHaveBeenCalled();
|
||||||
expect(result.current.badgeUpdates.has("default:FN-063")).toBe(false);
|
expect(result.current.badgeUpdates.has("default:FN-063")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shares a single websocket and ref-counted subscription across hook instances", () => {
|
it("shares a single websocket and ref-counted subscription across hook instances", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user