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 taskTimestamp = task.prInfo?.lastCheckedAt ?? task.updatedAt;
|
||||
|
||||
// Compare all three sources and pick the freshest
|
||||
let bestData = pickPreferredBadge<PrInfo>(wsData, wsTimestamp, taskInfo, taskTimestamp);
|
||||
const bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp;
|
||||
let bestData = taskInfo;
|
||||
let bestTimestamp = taskTimestamp;
|
||||
|
||||
if (batchInfo && batchTimestamp) {
|
||||
if (!bestTimestamp || batchTimestamp > bestTimestamp) {
|
||||
bestData = batchInfo;
|
||||
}
|
||||
if (wsData && (!bestTimestamp || (wsTimestamp != null && wsTimestamp >= bestTimestamp))) {
|
||||
bestData = wsData;
|
||||
bestTimestamp = wsTimestamp ?? bestTimestamp;
|
||||
}
|
||||
|
||||
if (batchInfo && (!bestTimestamp || (batchTimestamp != null && batchTimestamp >= bestTimestamp))) {
|
||||
bestData = batchInfo;
|
||||
}
|
||||
|
||||
return bestData;
|
||||
@@ -991,14 +993,16 @@ function TaskCardComponent({
|
||||
const taskInfo = task.issueInfo;
|
||||
const taskTimestamp = task.issueInfo?.lastCheckedAt ?? task.updatedAt;
|
||||
|
||||
// Compare all three sources and pick the freshest
|
||||
let bestData = pickPreferredBadge<IssueInfo>(wsData, wsTimestamp, taskInfo, taskTimestamp);
|
||||
const bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp;
|
||||
let bestData = taskInfo;
|
||||
let bestTimestamp = taskTimestamp;
|
||||
|
||||
if (batchInfo && batchTimestamp) {
|
||||
if (!bestTimestamp || batchTimestamp > bestTimestamp) {
|
||||
bestData = batchInfo;
|
||||
}
|
||||
if (wsData && (!bestTimestamp || (wsTimestamp != null && wsTimestamp >= bestTimestamp))) {
|
||||
bestData = wsData;
|
||||
bestTimestamp = wsTimestamp ?? bestTimestamp;
|
||||
}
|
||||
|
||||
if (batchInfo && (!bestTimestamp || (batchTimestamp != null && batchTimestamp >= bestTimestamp))) {
|
||||
bestData = batchInfo;
|
||||
}
|
||||
|
||||
return bestData;
|
||||
|
||||
@@ -9,12 +9,16 @@ export function pickPreferredBadge<T extends { lastCheckedAt?: string }>(
|
||||
taskValue: T | undefined,
|
||||
taskTimestamp: string | undefined,
|
||||
): T | undefined {
|
||||
if (liveValue === undefined || !liveTimestamp) {
|
||||
if (liveValue == null) {
|
||||
return taskValue;
|
||||
}
|
||||
|
||||
if (!liveTimestamp) {
|
||||
return taskValue ?? liveValue;
|
||||
}
|
||||
|
||||
if (!taskTimestamp || liveTimestamp >= taskTimestamp) {
|
||||
return liveValue ?? undefined;
|
||||
return liveValue;
|
||||
}
|
||||
|
||||
return taskValue;
|
||||
|
||||
@@ -31,6 +31,22 @@ vi.mock("../../hooks/useTaskDiffStats", () => ({
|
||||
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
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
@@ -90,6 +106,9 @@ const highFanout = {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });
|
||||
badgeUpdatesMock.clear();
|
||||
subscribeToBadgeMock.mockReset();
|
||||
unsubscribeFromBadgeMock.mockReset();
|
||||
});
|
||||
|
||||
describe("TaskCard", () => {
|
||||
@@ -279,6 +298,31 @@ describe("TaskCard", () => {
|
||||
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", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
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();
|
||||
});
|
||||
|
||||
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());
|
||||
|
||||
act(() => {
|
||||
@@ -240,7 +240,7 @@ describe("useBadgeWebSocket", () => {
|
||||
});
|
||||
expect(unsubscribeMsg).toBeDefined();
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user