From 4b82df514f3ffd2914504b19fae8a7b06d30a37d Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 10:16:16 -0400 Subject: [PATCH] feat(frontend): serialize/read helpers for plot config Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/crossSubjectChart.js | 59 ++++++++++++++++++++ frontend/tests/crossSubjectChart.test.js | 68 ++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/frontend/src/lib/crossSubjectChart.js b/frontend/src/lib/crossSubjectChart.js index 8443231..88d895c 100644 --- a/frontend/src/lib/crossSubjectChart.js +++ b/frontend/src/lib/crossSubjectChart.js @@ -50,3 +50,62 @@ export function groupLines(lines) { } return order.map((group) => ({ group, subjects: byGroup.get(group) })); } + +// ── Plot config (de)serialization ─────────────────────────────────────────────── +// Translate between ExperimentAnalysisCharts React state and the stored JSONB blob. +// readPlotConfig is defensive: it never throws and fills safe defaults for missing +// or malformed input (unknown keys are ignored). + +// Symmetric with readChart so a serialize→read round-trip is lossless. +function snapshotChart(s = {}) { + const str = (v) => (typeof v === 'string' ? v : ''); + return { + height: Number.isFinite(s.height) ? s.height : undefined, + yMin: str(s.yMin), + yMax: str(s.yMax), + xZoomMin: str(s.xZoomMin), + xZoomMax: str(s.xZoomMax), + xFieldOverride: typeof s.xFieldOverride === 'string' ? s.xFieldOverride : null, + }; +} + +export function serializePlotConfig(state = {}) { + const { xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, counts, rate } = state; + return { + xField, + groupBy, + tickStep, + metric, + hiddenSubjects: [...(hiddenSubjects ?? [])].sort(), + sidebarOpen: !!sidebarOpen, + counts: snapshotChart(counts), + rate: snapshotChart(rate), + }; +} + +function readChart(c) { + const o = c && typeof c === 'object' && !Array.isArray(c) ? c : {}; + const str = (v) => (typeof v === 'string' ? v : ''); + return { + height: Number.isFinite(o.height) ? o.height : undefined, + yMin: str(o.yMin), + yMax: str(o.yMax), + xZoomMin: str(o.xZoomMin), + xZoomMax: str(o.xZoomMax), + xFieldOverride: typeof o.xFieldOverride === 'string' ? o.xFieldOverride : null, + }; +} + +export function readPlotConfig(raw) { + const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; + return { + xField: typeof c.xField === 'string' ? c.xField : undefined, + groupBy: typeof c.groupBy === 'string' ? c.groupBy : undefined, + tickStep: Number.isFinite(c.tickStep) ? c.tickStep : undefined, + metric: typeof c.metric === 'string' ? c.metric : undefined, + hiddenSubjects: Array.isArray(c.hiddenSubjects) ? c.hiddenSubjects.filter((x) => typeof x === 'string') : [], + sidebarOpen: c.sidebarOpen === true, + counts: readChart(c.counts), + rate: readChart(c.rate), + }; +} diff --git a/frontend/tests/crossSubjectChart.test.js b/frontend/tests/crossSubjectChart.test.js index 39f1e60..33d72ab 100644 --- a/frontend/tests/crossSubjectChart.test.js +++ b/frontend/tests/crossSubjectChart.test.js @@ -4,6 +4,8 @@ import { setIdsHidden, groupVisibilityState, groupLines, + serializePlotConfig, + readPlotConfig, } from '../src/lib/crossSubjectChart'; describe('sortPayloadByValueDesc', () => { @@ -80,3 +82,69 @@ describe('groupLines', () => { expect(groupLines([{ id: '1' }])[0].group).toBe('—'); }); }); + +describe('serializePlotConfig', () => { + const STATE = { + xField: '__days__', groupBy: 'g1', tickStep: 2, metric: 'Success', + hiddenSubjects: new Set(['b', 'a']), sidebarOpen: true, + counts: { height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' }, + rate: { height: 160, yMin: '', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: null }, + }; + + it('produces a plain JSON object with hiddenSubjects as a sorted array', () => { + const out = serializePlotConfig(STATE); + expect(out.hiddenSubjects).toEqual(['a', 'b']); + expect(out.sidebarOpen).toBe(true); + expect(out.xField).toBe('__days__'); + expect(out.metric).toBe('Success'); + expect(out.counts).toEqual({ height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' }); + expect(out.rate.xFieldOverride).toBeNull(); + }); + + it('round-trips through readPlotConfig (values preserved)', () => { + const back = readPlotConfig(serializePlotConfig(STATE)); + expect(back.xField).toBe('__days__'); + expect(back.groupBy).toBe('g1'); + expect(back.tickStep).toBe(2); + expect(back.hiddenSubjects).toEqual(['a', 'b']); + expect(back.sidebarOpen).toBe(true); + expect(back.counts.height).toBe(220); + expect(back.counts.xFieldOverride).toBe('f2'); + expect(back.rate.height).toBe(160); + expect(back.rate.xFieldOverride).toBeNull(); + }); + + it('returns an empty hiddenSubjects array when state.hiddenSubjects is null', () => { + expect(serializePlotConfig({ ...STATE, hiddenSubjects: null }).hiddenSubjects).toEqual([]); + }); +}); + +describe('readPlotConfig', () => { + it('returns safe defaults for null', () => { + const c = readPlotConfig(null); + expect(c.hiddenSubjects).toEqual([]); + expect(c.sidebarOpen).toBe(false); + expect(c.xField).toBeUndefined(); + expect(c.counts.yMin).toBe(''); + expect(c.counts.xFieldOverride).toBeNull(); + }); + + it('returns safe defaults for a non-object', () => { + expect(readPlotConfig([1, 2]).hiddenSubjects).toEqual([]); + expect(readPlotConfig('nope').sidebarOpen).toBe(false); + }); + + it('filters non-string hiddenSubjects and ignores wrong-typed fields', () => { + const c = readPlotConfig({ hiddenSubjects: ['a', 5, 'b'], tickStep: 'x', sidebarOpen: 'yes' }); + expect(c.hiddenSubjects).toEqual(['a', 'b']); + expect(c.tickStep).toBeUndefined(); + expect(c.sidebarOpen).toBe(false); + }); + + it('does not throw on malformed nested chart settings', () => { + expect(() => readPlotConfig({ counts: 'bad', rate: [] })).not.toThrow(); + const c = readPlotConfig({ counts: 'bad' }); + expect(c.counts.yMin).toBe(''); + expect(c.counts.height).toBeUndefined(); + }); +});