Skip to content
Merged
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"dead-exports:check": "node --experimental-strip-types scripts/check-dead-exports.ts",
"publishable-deps:check": "node --experimental-strip-types scripts/check-publishable-deps.ts",
"release-commit-parsing:check": "tsx scripts/check-release-commit-parsing.ts",
"releasable-commit-types:check": "tsx scripts/check-releasable-commit-types.ts",
"checkers-wired:check": "node --experimental-strip-types scripts/check-checkers-wired.ts",
"maintainer-associations:check": "node --experimental-strip-types scripts/check-maintainer-association-copies.ts",
"regate-sort-key:check": "node --experimental-strip-types scripts/check-regate-sort-key.ts",
Expand Down Expand Up @@ -146,7 +147,7 @@
"test:smoke:browser:install": "playwright install chromium",
"test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts",
"pretest:ci": "npm run check-node-version",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run turbo-inputs:check && npm run workspace-dep-ranges:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:contract-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run control-plane:contract:check && npm run control-plane:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run dead-exports:check && npm run publishable-deps:check && npm run fixture-clock-races:check && npm run typecheck-coverage:check && npm run checkers-wired:check && npm run release-commit-parsing:check && npm run regate-sort-key:check && npm run maintainer-associations:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run turbo-inputs:check && npm run workspace-dep-ranges:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:contract-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run control-plane:contract:check && npm run control-plane:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run dead-exports:check && npm run publishable-deps:check && npm run fixture-clock-races:check && npm run typecheck-coverage:check && npm run checkers-wired:check && npm run release-commit-parsing:check && npm run releasable-commit-types:check && npm run regate-sort-key:check && npm run maintainer-associations:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:release": "npm run test:ci && npm run changelog:check",
"test:release:mcp": "npm run test:ci",
"test:watch": "vitest",
Expand Down
135 changes: 135 additions & 0 deletions packages/loopover-ui-kit/src/components/chart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

// ResponsiveContainer measures its parent through a ResizeObserver, and under jsdom that measurement never
// resolves -- it renders NOTHING, discarding its children entirely. Every assertion below would then fail, and
// the "renders nothing" ones would pass for entirely the wrong reason.
//
// Only that one measuring wrapper is replaced, with a plain passthrough; everything else -- including
// ChartContainer, which is the context provider under test -- stays real. Stubbing the whole module would mean
// asserting against a mock rather than against `chart.tsx`.
vi.mock("recharts", async (importOriginal) => ({
...(await importOriginal<typeof import("recharts")>()),
ResponsiveContainer: ({ children }: { children?: React.ReactNode }) => <div data-testid="responsive">{children}</div>,
}));

import {
ChartContainer,
ChartLegendContent,
ChartStyle,
ChartTooltipContent,
type ChartConfig,
} from "./chart.js";

// #9937: `chart.tsx` is the one file the recharts v3 migration (#8610) rewrote, and it shipped with no tests
// at all -- so the migration's load-bearing behaviours were resting on the type-checker alone.
//
// What v3 actually changed here: `payload` and `label` moved OFF Tooltip's own props and are supplied through
// context, and the render-prop shape is published as `TooltipContentProps`. Everything below drives
// ChartTooltipContent the way recharts now drives it -- as a component receiving that shape -- inside the real
// ChartContainer, so the context wiring the migration touched is exercised rather than assumed.

const config = {
revenue: { label: "Revenue", color: "#0ea5e9" },
cost: { label: "Cost", theme: { light: "#111827", dark: "#f9fafb" } },
} satisfies ChartConfig;

const payload = [
{ dataKey: "revenue", name: "revenue", value: 120, color: "#0ea5e9", payload: { month: "Jan", revenue: 120 } },
{ dataKey: "cost", name: "cost", value: 80, color: "#111827", payload: { month: "Jan", cost: 80 } },
];

/** ChartTooltipContent calls useChart(), so it must render inside a provider. ChartContainer is that
* provider and is exercised deliberately rather than mocked. Queries are scoped to the slot, never the whole
* container: ChartContainer also emits a <style> element, so a container-wide "rendered nothing" assertion
* would be comparing against that stylesheet. */
function renderInChart(node: React.ReactNode) {
const result = render(
<ChartContainer config={config}>
<div data-testid="slot">{node}</div>
</ChartContainer>,
);
// Scoped through this render's OWN container, not the shared document: a test that renders twice would
// otherwise match both slots and fail on "found multiple elements".
return { ...result, slot: () => result.container.querySelector('[data-testid="slot"]')! };
}

describe("ChartTooltipContent (recharts v3, #8610)", () => {
it("renders one row per payload entry, labelled from the chart config", () => {
// The config's `label` -- not the raw dataKey -- is what a reader sees. Regressing to the dataKey is the
// most likely silent breakage, and it still "renders fine".
const chart = renderInChart(<ChartTooltipContent active payload={payload} label="Jan" />);

expect(chart.getByText("Revenue")).toBeTruthy();
expect(chart.getByText("Cost")).toBeTruthy();
expect(chart.getByText("120")).toBeTruthy();
expect(chart.getByText("80")).toBeTruthy();
});

it("INVARIANT: renders nothing when inactive or when the payload is empty", () => {
// A tooltip that paints on an empty payload follows the cursor around an empty chart. Both arms, because
// `!active || !payload?.length` is two conditions and only testing one leaves the other free to invert.
const inactive = renderInChart(<ChartTooltipContent active={false} payload={payload} />);
expect(inactive.slot().textContent).toBe("");

const empty = renderInChart(<ChartTooltipContent active payload={[]} />);
expect(empty.slot().textContent).toBe("");
});

it("hides the label when asked, and formats it through labelFormatter otherwise", () => {
const formatted = renderInChart(<ChartTooltipContent active payload={payload} label="Jan" labelFormatter={(value) => `Month: ${String(value)}`} />);
expect(formatted.getByText("Month: Jan")).toBeTruthy();

const hidden = renderInChart(<ChartTooltipContent active payload={payload} label="Jan" hideLabel />);
expect(hidden.queryByText("Jan")).toBeNull();
});

it("routes the value through a custom formatter when one is supplied", () => {
const chart = renderInChart(<ChartTooltipContent active payload={[payload[0]!]} formatter={(value) => `$${String(value)}`} />);
expect(chart.getByText("$120")).toBeTruthy();
});

it("resolves a series by nameKey when the payload's own key is not the config key", () => {
const chart = renderInChart(<ChartTooltipContent active payload={[{ ...payload[0]!, dataKey: "unmapped", name: "unmapped" }]} nameKey="revenue" />);
expect(chart.getByText("Revenue")).toBeTruthy();
});
});

describe("ChartLegendContent (recharts v3, #8610)", () => {
it("labels each legend entry from the config", () => {
const chart = renderInChart(<ChartLegendContent payload={[{ value: "revenue", dataKey: "revenue", color: "#0ea5e9" }]} />);
expect(chart.getByText("Revenue")).toBeTruthy();
});

it("renders nothing without a payload", () => {
const legend = renderInChart(<ChartLegendContent payload={[]} />);
expect(legend.slot().textContent).toBe("");
});
});

describe("ChartStyle", () => {
it("emits per-theme CSS variables only for series that declare a colour", () => {
// The `theme` and `color` arms of ChartConfig are a union, and both have to reach the stylesheet -- a
// series configured with `theme` produces one variable per theme selector.
const { container } = render(<ChartStyle id="chart-test" config={config} />);
const css = container.querySelector("style")?.innerHTML ?? "";

expect(css).toContain("--color-revenue: #0ea5e9");
expect(css).toContain("--color-cost: #111827");
expect(css).toContain("--color-cost: #f9fafb");
expect(css).toContain("[data-chart=chart-test]");
});

it("emits no stylesheet at all when no series declares a colour", () => {
const { container } = render(<ChartStyle id="chart-empty" config={{ plain: { label: "Plain" } }} />);
expect(container.querySelector("style")).toBeNull();
});
});

describe("useChart", () => {
it("REGRESSION: throws a named error outside a ChartContainer rather than reading null", () => {
// Without the guard this is a `Cannot read properties of null` deep inside a tooltip render, which is a
// materially harder thing to diagnose than the message the component actually throws.
expect(() => render(<ChartTooltipContent active payload={payload} />)).toThrow(/useChart must be used within a <ChartContainer \/>/);
});
});
Loading
Loading