import { describe, expect, test } from "bun:test"; import { groupRowBySuffix, buildSuffixGroupsMap, computeGroupStats, groupByScale, flattenScaleGroups, processChartDataGroups, } from "@/utils/dataProcessing"; import { CHART_CONFIG } from "@/utils/constants"; const DELIM = CHART_CONFIG.SERIES_NAME_DELIMITER; // " | " // --------------------------------------------------------------------------- // groupRowBySuffix // --------------------------------------------------------------------------- describe("groupRowBySuffix", () => { test("passes through timestamp unchanged", () => { const result = groupRowBySuffix({ timestamp: 1.5 }); expect(result.timestamp).toBe(1.5); }); test("keeps single-prefix suffix keys as flat entries with full original name", () => { // `action | 0`, `action | 1`, `action | 2` each have a UNIQUE prefix per suffix, // so they stay flat (no nesting). Nesting only occurs when multiple prefixes // share the same numeric suffix (e.g. state | 0 AND action | 0). const row = { [`action${DELIM}0`]: 0.1, [`action${DELIM}1`]: 0.2, [`action${DELIM}2`]: 0.3, timestamp: 0, }; const result = groupRowBySuffix(row); expect(result[`action${DELIM}0`]).toBe(0.1); expect(result[`action${DELIM}1`]).toBe(0.2); expect(result[`action${DELIM}2`]).toBe(0.3); }); test("keeps keys without delimiter at top level", () => { const row = { progress: 0.75, timestamp: 2.0 }; const result = groupRowBySuffix(row); expect(result["progress"]).toBe(0.75); }); test("preserves single-member suffix as full original key", () => { // A key like "observation.state | 0" that is alone in its suffix group // should remain at the top level with its full original name const row = { [`solo_col${DELIM}joint`]: 1.0 }; const result = groupRowBySuffix(row); expect(result[`solo_col${DELIM}joint`]).toBe(1.0); }); test("groups by suffix when multiple prefixes share the same suffix (v2.x state+action)", () => { // `observation.state | 0` and `action | 0` both have suffix "0", // so they are grouped under the key "0" as a nested object { "observation.state": ..., "action": ... }. const row = { [`observation.state${DELIM}0`]: 0.1, [`observation.state${DELIM}1`]: 0.2, [`action${DELIM}0`]: 0.5, [`action${DELIM}1`]: 0.6, timestamp: 0.5, }; const result = groupRowBySuffix(row); // Both suffix "0" groups: observation.state and action const group0 = result["0"] as Record; const group1 = result["1"] as Record; expect(group0["observation.state"]).toBe(0.1); expect(group0["action"]).toBe(0.5); expect(group1["observation.state"]).toBe(0.2); expect(group1["action"]).toBe(0.6); }); }); // --------------------------------------------------------------------------- // buildSuffixGroupsMap // --------------------------------------------------------------------------- describe("buildSuffixGroupsMap", () => { test("groups keys by their suffix", () => { const keys = [ `action${DELIM}0`, `action${DELIM}1`, `observation.state${DELIM}0`, ]; const map = buildSuffixGroupsMap(keys); expect(map["action"]).toBeUndefined(); // suffix is "0" and "1" expect(map["0"]).toContain(`action${DELIM}0`); expect(map["0"]).toContain(`observation.state${DELIM}0`); expect(map["1"]).toContain(`action${DELIM}1`); }); test("keys without delimiter fall back to the key itself", () => { const map = buildSuffixGroupsMap(["progress"]); expect(map["progress"]).toEqual(["progress"]); }); test("returns empty object for empty input", () => { expect(buildSuffixGroupsMap([])).toEqual({}); }); }); // --------------------------------------------------------------------------- // computeGroupStats // --------------------------------------------------------------------------- describe("computeGroupStats", () => { test("computes correct min and max across all rows for each group", () => { const chartData = [ { "action | 0": 1.0, "action | 1": -2.0 }, { "action | 0": 3.0, "action | 1": 0.5 }, ]; const groups = [["action | 0", "action | 1"]]; const stats = computeGroupStats(chartData, groups); expect(stats["action | 0"].min).toBe(-2.0); expect(stats["action | 0"].max).toBe(3.0); }); test("ignores NaN values", () => { const chartData = [{ col: NaN }, { col: 5 }, { col: 2 }]; const stats = computeGroupStats(chartData, [["col"]]); expect(stats["col"].min).toBe(2); expect(stats["col"].max).toBe(5); }); test("returns Infinity/-Infinity for all-NaN group (group skipped in groupByScale)", () => { const chartData = [{ col: NaN }]; const stats = computeGroupStats(chartData, [["col"]]); expect(stats["col"].min).toBe(Infinity); expect(stats["col"].max).toBe(-Infinity); }); }); // --------------------------------------------------------------------------- // groupByScale // --------------------------------------------------------------------------- describe("groupByScale", () => { test("groups series with similar scale together", () => { // Two series both in range ~[0, 1] — should be grouped const suffixGroups = [["a"], ["b"]]; const stats = { a: { min: 0.1, max: 1.0 }, b: { min: 0.2, max: 0.9 }, }; const result = groupByScale(suffixGroups, stats); const groups = Object.values(result); // Both a and b have similar log-scale range, expect them merged expect(groups.some((g) => g.length === 2)).toBe(true); }); test("keeps series with vastly different scales separate", () => { // One series in [0,1], another in [0, 1000] const suffixGroups = [["small"], ["large"]]; const stats = { small: { min: 0.001, max: 1.0 }, large: { min: 100, max: 1000 }, }; const result = groupByScale(suffixGroups, stats); // Each should be in its own group expect(Object.keys(result).length).toBe(2); }); test("skips groups with non-finite stats", () => { const suffixGroups = [["bad"]]; const stats = { bad: { min: Infinity, max: -Infinity } }; const result = groupByScale(suffixGroups, stats); expect(Object.keys(result).length).toBe(0); }); }); // --------------------------------------------------------------------------- // flattenScaleGroups // --------------------------------------------------------------------------- describe("flattenScaleGroups", () => { test("returns each scale group as a flat array of keys", () => { const scaleGroups = { a: [["a", "b"], ["c"]] }; const result = flattenScaleGroups(scaleGroups); expect(result).toEqual([["a", "b", "c"]]); }); test("splits large groups exceeding MAX_SERIES_PER_GROUP", () => { const MAX = CHART_CONFIG.MAX_SERIES_PER_GROUP; // 6 const bigGroup = Array.from({ length: MAX + 2 }, (_, i) => [`key_${i}`]); const scaleGroups = { key_0: bigGroup }; const result = flattenScaleGroups(scaleGroups); // Should be split into 2 sub-groups expect(result.length).toBe(2); expect(result[0].length).toBe(MAX); expect(result[1].length).toBe(2); }); test("groups with more sub-arrays come first (sorted by length desc)", () => { const scaleGroups = { a: [["a"]], // 1 sub-group b: [["b"], ["c"]], // 2 sub-groups }; const result = flattenScaleGroups(scaleGroups); // b (2 sub-groups) should come before a (1 sub-group) expect(result[0]).toContain("b"); }); }); // --------------------------------------------------------------------------- // processChartDataGroups — end-to-end pipeline // --------------------------------------------------------------------------- describe("processChartDataGroups", () => { test("returns an empty array for empty chart data", () => { const result = processChartDataGroups(["timestamp"], []); expect(result).toEqual([]); }); test("groups v2.x style action+state series correctly", () => { const seriesNames = [ "timestamp", `observation.state${DELIM}0`, `observation.state${DELIM}1`, `action${DELIM}0`, `action${DELIM}1`, ]; const chartData = [ { timestamp: 0, [`observation.state${DELIM}0`]: 0.1, [`observation.state${DELIM}1`]: 0.2, [`action${DELIM}0`]: 0.5, [`action${DELIM}1`]: 0.6, }, { timestamp: 0.1, [`observation.state${DELIM}0`]: 0.15, [`observation.state${DELIM}1`]: 0.25, [`action${DELIM}0`]: 0.55, [`action${DELIM}1`]: 0.65, }, ]; const result = processChartDataGroups(seriesNames, chartData); // All four series share similar scale, so likely merged into 1-2 groups expect(result.length).toBeGreaterThanOrEqual(1); // Each element is an array of keys const allKeys = result.flat(); expect(allKeys).toContain(`observation.state${DELIM}0`); expect(allKeys).toContain(`action${DELIM}0`); }); test("handles single series without delimiter", () => { const seriesNames = ["timestamp", "progress"]; const chartData = [ { timestamp: 0, progress: 0.0 }, { timestamp: 1, progress: 0.5 }, { timestamp: 2, progress: 1.0 }, ]; const result = processChartDataGroups(seriesNames, chartData); expect(result.length).toBe(1); expect(result[0]).toContain("progress"); }); });