Skip to content
Closed
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
7 changes: 4 additions & 3 deletions app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@
return;
}

// Strategy 2: Keyboard fallback (for environments where cmView is not accessible)
if (dispatched === "no-view") {
// Strategy 2: Keyboard fallback (for environments where cmView is not accessible
// or when the view is temporarily readonly during initialization)
if (dispatched === "no-view" || dispatched === "readonly") {
await expect(cm).toHaveAttribute("contenteditable", "true", { timeout: 2_000 });
await cm.click();
await page.keyboard.press("ControlOrMeta+a");
Expand All @@ -162,9 +163,9 @@
return;
}

// Retry-worthy states: no-editor, readonly, dispatch-failed
// Retry-worthy states: no-editor, dispatch-failed
throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`);
}).toPass({ timeout: 20_000 });

Check failure on line 168 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/charts.spec.ts:1086:7 › Column mapping overlay › changing axis mapping updates chart

4) [chromium] › e2e/charts.spec.ts:1086:7 › Column mapping overlay › changing axis mapping updates chart Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:168 166 | // Retry-worthy states: no-editor, dispatch-failed 167 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 168 | }).toPass({ timeout: 20_000 }); | ^ 169 | } 170 | 171 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:168:6) at /home/runner/work/neoboard/neoboard/app/e2e/charts.spec.ts:1099:23
}

/**
Expand Down
122 changes: 119 additions & 3 deletions app/src/lib/__tests__/chart-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1491,21 +1491,137 @@ describe("radar transform", () => {
expect(result.indicators[0].name).toBe("X");
});

it("auto-scales max from data when max column is missing", () => {
it("auto-scales max from data when max column is missing (single indicator)", () => {
const data = [{ indicator: "Speed", value: 80 }];
const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: unknown[] };
// 80 * 1.1 = 88, ceil → 88
expect(result.indicators[0].max).toBe(88);
});

it("handles flat tabular data without indicator column (uses column names as indicators)", () => {
it("uses global max across all indicators for relative comparison", () => {
const data = [
{ indicator: "ACTED_IN", value: 172 },
{ indicator: "PRODUCED", value: 15 },
{ indicator: "DIRECTED", value: 44 },
{ indicator: "WROTE", value: 10 },
{ indicator: "REVIEWED", value: 9 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: Array<{ values: number[] }> };
// Global max: ceil(172 * 1.1) = 190
const globalMax = Math.ceil(172 * 1.1);
expect(result.indicators).toHaveLength(5);
// All indicators should share the same max
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
// The shape should NOT be uniform — values differ significantly
const values = result.series[0].values;
expect(values[0]).toBe(172); // ACTED_IN
expect(values[4]).toBe(9); // REVIEWED
});

it("preserves explicit max column values when provided", () => {
const data = [
{ indicator: "Speed", value: 80, max: 200 },
{ indicator: "Strength", value: 40, max: 150 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
expect(result.indicators[0].max).toBe(200);
expect(result.indicators[1].max).toBe(150);
});

it("uses global max for wide-format tabular data", () => {
const data = [{ Speed: 80, Strength: 60, Agility: 90 }];
const result = transform(data) as { indicators: Array<{ name: string }>; series: Array<{ values: number[] }> };
const result = transform(data) as { indicators: Array<{ name: string; max: number }>; series: Array<{ values: number[] }> };
// Global max: ceil(90 * 1.1) = 99
const globalMax = Math.ceil(90 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
expect(result.indicators.map((i) => i.name)).toContain("Speed");
expect(result.indicators.map((i) => i.name)).toContain("Strength");
expect(result.series[0].values).toHaveLength(3);
});

it("falls back to globalMax when max column contains null/undefined/NaN", () => {
// When the max column exists but values are invalid (null/NaN/0),
// indicators should use globalMax instead of treating 0 or NaN as explicit.
const data = [
{ indicator: "Speed", value: 80, max: null },
{ indicator: "Strength", value: 60, max: undefined },
{ indicator: "Agility", value: 90, max: NaN },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
// globalMax: ceil(90 * 1.1) = 99
const globalMax = Math.ceil(90 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("falls back to globalMax when max column value is 0", () => {
const data = [
{ indicator: "Speed", value: 50, max: 0 },
{ indicator: "Strength", value: 30, max: 0 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
// 0 is not a valid explicit max (not > 0), so globalMax is used
const globalMax = Math.ceil(50 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("falls back to globalMax when max column value is negative", () => {
const data = [
{ indicator: "Speed", value: 50, max: -100 },
{ indicator: "Strength", value: 30, max: -50 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
const globalMax = Math.ceil(50 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("mixes explicit and fallback max when some indicators have valid max", () => {
const data = [
{ indicator: "Speed", value: 80, max: 200 },
{ indicator: "Strength", value: 60, max: null },
{ indicator: "Agility", value: 90, max: 150 },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
const globalMax = Math.ceil(90 * 1.1);
// Speed and Agility have valid explicit max; Strength falls back to globalMax
expect(result.indicators[0].max).toBe(200); // Speed — explicit
expect(result.indicators[1].max).toBe(globalMax); // Strength — fallback
expect(result.indicators[2].max).toBe(150); // Agility — explicit
});

it("falls back to globalMax when max column contains non-numeric strings", () => {
const data = [
{ indicator: "Speed", value: 80, max: "not-a-number" },
{ indicator: "Strength", value: 60, max: "" },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
const globalMax = Math.ceil(80 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("falls back to globalMax when max column contains Infinity", () => {
const data = [
{ indicator: "Speed", value: 80, max: Infinity },
{ indicator: "Strength", value: 60, max: -Infinity },
];
const result = transform(data) as { indicators: Array<{ name: string; max: number }> };
const globalMax = Math.ceil(80 * 1.1);
for (const ind of result.indicators) {
expect(ind.max).toBe(globalMax);
}
});

it("transformWithMapping returns same result as transform", () => {
const data = [{ indicator: "Speed", value: 80, max: 100 }];
const result = chartRegistry.radar.transformWithMapping(data, {});
Expand Down
23 changes: 13 additions & 10 deletions app/src/lib/chart-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,21 +494,23 @@ function transformToRadarData(data: unknown): unknown {
const serName = seriesKey ? String(normalizeValue(r[seriesKey]) ?? "Default") : "Default";

if (maxKey) {
const explicitMax = Number(r[maxKey]) || 100;
if (!indicatorExplicitMax.has(indName)) indicatorExplicitMax.set(indName, explicitMax);
const explicitMax = Number(r[maxKey]);
if (Number.isFinite(explicitMax) && explicitMax > 0 && !indicatorExplicitMax.has(indName)) {
indicatorExplicitMax.set(indName, explicitMax);
}
}
indicatorMaxFromData.set(indName, Math.max(indicatorMaxFromData.get(indName) ?? 0, val));
if (!seriesMap.has(serName)) seriesMap.set(serName, new Map());
seriesMap.get(serName)!.set(indName, val);
}

// Use explicit max if provided, otherwise auto-scale from observed values (+10% headroom)
// Use explicit max if provided, otherwise use a single global max across all
// indicators so relative magnitudes are visible (e.g. 172 vs 9).
const indicatorEntries = Array.from(indicatorMaxFromData.keys());
const globalMax = Math.ceil(Math.max(...indicatorMaxFromData.values()) * 1.1) || 100;
const indicators = indicatorEntries.map((name) => ({
name,
max: maxKey && indicatorExplicitMax.has(name)
? indicatorExplicitMax.get(name)!
: Math.ceil((indicatorMaxFromData.get(name) ?? 100) * 1.1) || 100,
max: indicatorExplicitMax.get(name) ?? globalMax,
}));
const series = Array.from(seriesMap.entries()).map(([name, valMap]) => ({
name,
Expand All @@ -519,17 +521,18 @@ function transformToRadarData(data: unknown): unknown {
}

// Wide-format: each column is an indicator, each row is a series
// Auto-scale max from observed values per column (+10% headroom)
const maxPerCol = new Map<string, number>();
// Use a single global max so all axes share the same scale
let wideGlobalMax = 0;
for (const r of records) {
for (const k of keys) {
const v = Number(r[k]) || 0;
maxPerCol.set(k, Math.max(maxPerCol.get(k) ?? 0, v));
if (v > wideGlobalMax) wideGlobalMax = v;
}
}
const wideMax = Math.ceil(wideGlobalMax * 1.1) || 100;
const indicators = keys.map((k) => ({
name: k,
max: Math.ceil((maxPerCol.get(k) ?? 100) * 1.1) || 100,
max: wideMax,
}));
const series = records.map((r, i) => ({
name: String(i + 1),
Expand Down
80 changes: 63 additions & 17 deletions component/src/charts/__tests__/graph-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* - Click callback wiring
* - Layout mapping
*/
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { GraphChart } from "../graph-chart";
import type { Node as NvlNode, Relationship as NvlRelationship } from "@neo4j-nvl/base";
Expand Down Expand Up @@ -530,29 +530,75 @@ describe("GraphChart", () => {
expect(nvlNodes[0].caption).not.toBe("[object Object]");
});

// --- autoFit ---
// --- Loading overlay / layoutReady ---

describe("autoFit", () => {
afterEach(() => {
vi.restoreAllMocks();
describe("loading overlay", () => {
it("shows loading overlay on initial render when nodes are present", () => {
render(<GraphChart nodes={sampleNodes} edges={sampleEdges} />);
expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument();
});

it("schedules a delayed fit via requestAnimationFrame when autoFit is true", () => {
const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0);
render(<GraphChart nodes={sampleNodes} edges={sampleEdges} autoFit />);
expect(rafSpy).toHaveBeenCalledTimes(1);
it("does not show loading overlay when there are no nodes", () => {
render(<GraphChart nodes={[]} edges={[]} />);
expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument();
});

it("removes loading overlay after onLayoutDone fires", () => {
render(<GraphChart nodes={sampleNodes} edges={sampleEdges} />);
expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument();

// Simulate NVL calling onLayoutDone
const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void };
act(() => { callbacks.onLayoutDone?.(); });

expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument();
});

it("does not call requestAnimationFrame for autoFit when prop is false", () => {
const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0);
render(<GraphChart nodes={sampleNodes} edges={sampleEdges} autoFit={false} />);
expect(rafSpy).not.toHaveBeenCalled();
it("resets loading overlay when nodes change", () => {
const { rerender } = render(<GraphChart nodes={sampleNodes} edges={sampleEdges} />);

// Fire onLayoutDone to clear overlay
const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void };
act(() => { callbacks.onLayoutDone?.(); });
expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument();

// Change nodes — overlay should reappear
const newNodes = [
{ id: "4", label: "Diana", value: 10 },
{ id: "5", label: "Eve", value: 15 },
];
rerender(<GraphChart nodes={newNodes} edges={[]} />);
expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument();
});
});

it("does not call requestAnimationFrame for autoFit when prop is absent", () => {
const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0);
render(<GraphChart nodes={sampleNodes} edges={sampleEdges} />);
expect(rafSpy).not.toHaveBeenCalled();
// --- nvlOptions ---

it("disables web workers in nvlOptions (Next.js bundler compatibility)", () => {
render(<GraphChart nodes={sampleNodes} edges={sampleEdges} />);
const opts = capturedProps.nvlOptions as Record<string, unknown>;
expect(opts.disableWebWorkers).toBe(true);
});

// --- autoFit ---

describe("autoFit", () => {
it("does not call fitGraph before onLayoutDone fires", () => {
// We can't directly spy on fitGraph, but we can verify through the nvlRef.
// The NVL wrapper is mocked, so we check that autoFit alone doesn't
// cause immediate side effects — the overlay should still be visible.
render(<GraphChart nodes={sampleNodes} edges={sampleEdges} autoFit />);
// Overlay is still present — layout hasn't completed
expect(screen.getByTestId("graph-loading-overlay")).toBeInTheDocument();
});

it("calls fitGraph (via onLayoutDone) when autoFit and layout completes", () => {
render(<GraphChart nodes={sampleNodes} edges={sampleEdges} autoFit />);
// Fire onLayoutDone
const callbacks = capturedProps.nvlCallbacks as { onLayoutDone?: () => void };
act(() => { callbacks.onLayoutDone?.(); });
// Overlay should be gone — fitGraph was called
expect(screen.queryByTestId("graph-loading-overlay")).not.toBeInTheDocument();
});
});
});
Loading
Loading