feat(frontend): serialize/read helpers for plot config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user