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:
Fusion
2026-05-13 14:54:20 -07:00
committed by gsxdsm
parent a4742da65a
commit 79ca85f8fc
5 changed files with 103 additions and 18 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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);
});
});

View File

@@ -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", () => {