Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/api/transforms.null-vs-zero.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Null-vs-zero matrix for IC KPI rendering (Refs #1337).
*
* The recurring bug: a metric whose source isn't ingested arrives as NULL and
* gets rendered as a real "0" (or "0%"/"0h"), silently misrepresenting a
* contributor. The transform layer must keep the two distinct:
* - NULL (not ingested) → value stays `null` (the UI renders a "—"/coming
* -soon affordance — see kpi-tile.null.test.tsx).
* - real 0 (measured) → value renders "0" / "0h" per the format.
*
* Pinned across every nullable numeric field × every wire format so a refactor
* can't reintroduce the regression for one field/format and pass the others.
*/
import { describe, expect, it } from "vitest";

import type { CatalogMetric, CatalogResponse } from "./catalog-client";
import type { RawIcAggregateRow } from "./raw-types";
import { transformIcKpis } from "./transforms";

const NULLABLE_FIELDS: (keyof RawIcAggregateRow)[] = [
"loc",
"prs_merged",
"pr_cycle_time_h",
"build_success_pct",
];

const FORMATS = ["integer", "decimal1", "percent", "hours"] as const;
type Fmt = (typeof FORMATS)[number];

// formatValue policy in transforms.ts: whole-number display; hours append "h";
// percent has no "%" suffix at this layer (the unit adds it downstream).
const ZERO_RENDER: Record<Fmt, string> = {
integer: "0",
decimal1: "0",
percent: "0",
hours: "0h",
};

function icKpiRow(bareKey: string, format: Fmt): CatalogMetric {
return {
id: `id-${bareKey}`,
metric_key: `ic_kpis.${bareKey}`,
label: `KPI ${bareKey}`,
sublabel: "src",
description: "desc",
higher_is_better: true,
is_member_scale: false,
source_tags: [],
schema_status: "ok",
format,
thresholds: {
good: 0,
warn: 0,
resolved_from: "product-default",
bounded_by_lock: false,
},
};
}

function catalogWith(metric: CatalogMetric): CatalogResponse {
return {
tenant_id: "t-test",
generated_at: "2026-06-01T00:00:00Z",
metrics: [metric],
links: [],
};
}

function rawIc(overrides: Partial<RawIcAggregateRow>): RawIcAggregateRow {
return {
person_id: "p",
loc: 1,
ai_loc_share_pct: 0,
prs_merged: 1,
pr_cycle_time_h: 1,
focus_time_pct: 0,
tasks_closed: 1,
bugs_fixed: 1,
build_success_pct: 1,
ai_sessions: 0,
...overrides,
};
}

const combos = NULLABLE_FIELDS.flatMap((field) =>
FORMATS.map((fmt) => ({ field, fmt })),
);

describe("transformIcKpis — NULL never renders as 0 (Refs #1337)", () => {
it.each(combos)(
"$field as NULL with format=$fmt → value is null, never a fake 0",
({ field, fmt }) => {
const out = transformIcKpis(
rawIc({ [field]: null } as Partial<RawIcAggregateRow>),
null,
"week",
catalogWith(icKpiRow(String(field), fmt)),
);
const r = out.find((k) => k.metric_key === field);
expect(r, `${field} should be emitted`).toBeDefined();
expect(r?.raw_value).toBeNull();
expect(r?.value).toBeNull();
},
);

it.each(combos)(
"$field as real 0 with format=$fmt → renders the formatted zero (distinct from NULL)",
({ field, fmt }) => {
const out = transformIcKpis(
rawIc({ [field]: 0 } as Partial<RawIcAggregateRow>),
null,
"week",
catalogWith(icKpiRow(String(field), fmt)),
);
const r = out.find((k) => k.metric_key === field);
expect(r?.raw_value).toBe(0);
expect(r?.value).toBe(ZERO_RENDER[fmt]);
},
);
});
73 changes: 73 additions & 0 deletions src/components/widgets/v2/kpi-tile.null.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Component-layer guard for the null-vs-zero bug (Refs #1337).
*
* The transform emits `value: null` for a not-ingested metric (see
* transforms.null-vs-zero.test.ts). This pins that <KpiTile> then renders the
* not-ingested affordance ("—") and NEVER a literal "0" — the user-visible
* symptom we hit: a blank metric showing a confident, wrong zero.
*/
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { authStore } from "@/auth/auth-store";

vi.mock("@/api/catalog-client", async () => {
const actual = await vi.importActual<typeof import("@/api/catalog-client")>(
"@/api/catalog-client",
);
return { ...actual, fetchCatalog: vi.fn() };
});

import * as catalogClient from "@/api/catalog-client";
import {
buildCatalogResponse,
renderWithCatalogClient,
} from "@/test/catalog-test-utils";
import { KpiTile } from "./kpi-tile";
import type { IcKpi, PeriodValue } from "@/types/insight";

const fetchCatalog = catalogClient.fetchCatalog as ReturnType<typeof vi.fn>;

function nullKpi(): IcKpi {
return {
period: "month" as PeriodValue,
metric_key: "prs_merged",
label: "PRs Merged",
value: null, // not ingested → transform emitted null
raw_value: null,
unit: "",
sublabel: "GitHub",
description: "Pull requests merged in the selected period.",
delta: "",
delta_type: "neutral",
};
}

describe("<KpiTile> — not-ingested (null) rendering (Refs #1337)", () => {
beforeEach(() => {
authStore.reset();
authStore.setTenantId("t-1");
fetchCatalog.mockReset();
});

it("renders '—' for a null value and never a literal 0", async () => {
fetchCatalog.mockResolvedValue(
buildCatalogResponse([
{
metric_key: "ic_kpis.prs_merged",
higher_is_better: true,
schema_status: "ok",
},
]),
);
renderWithCatalogClient(
<KpiTile kpi={nullKpi()} median={{ p50: 6, n: 4 }} />,
);

expect(await screen.findByText("—")).toBeTruthy();
// The bug: a not-ingested metric must not show a confident "0".
expect(screen.queryByText("0")).toBeNull();
// And no peer "vs median" bar when there's no value to compare.
expect(screen.queryByText(/vs median/)).toBeNull();
});
});