| 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; |
|
|
| |
| |
| |
| 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", () => { |
| |
| |
| |
| 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", () => { |
| |
| |
| 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)", () => { |
| |
| |
| 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); |
| |
| const group0 = result["0"] as Record<string, number>; |
| const group1 = result["1"] as Record<string, number>; |
| 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); |
| }); |
| }); |
|
|
| |
| |
| |
| 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(); |
| 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({}); |
| }); |
| }); |
|
|
| |
| |
| |
| 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); |
| }); |
| }); |
|
|
| |
| |
| |
| describe("groupByScale", () => { |
| test("groups series with similar scale together", () => { |
| |
| 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); |
| |
| expect(groups.some((g) => g.length === 2)).toBe(true); |
| }); |
|
|
| test("keeps series with vastly different scales separate", () => { |
| |
| const suffixGroups = [["small"], ["large"]]; |
| const stats = { |
| small: { min: 0.001, max: 1.0 }, |
| large: { min: 100, max: 1000 }, |
| }; |
| const result = groupByScale(suffixGroups, stats); |
| |
| 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); |
| }); |
| }); |
|
|
| |
| |
| |
| 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; |
| const bigGroup = Array.from({ length: MAX + 2 }, (_, i) => [`key_${i}`]); |
| const scaleGroups = { key_0: bigGroup }; |
| const result = flattenScaleGroups(scaleGroups); |
| |
| 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"]], |
| b: [["b"], ["c"]], |
| }; |
| const result = flattenScaleGroups(scaleGroups); |
| |
| expect(result[0]).toContain("b"); |
| }); |
| }); |
|
|
| |
| |
| |
| 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); |
| |
| expect(result.length).toBeGreaterThanOrEqual(1); |
| |
| 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"); |
| }); |
| }); |
|
|