From 958666580f5311757230c9a0d6e4cc9f22d271a5 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Sun, 19 Jul 2026 13:28:32 -0400 Subject: [PATCH] feat(export): toCSV emits context line + subject group cell --- frontend/src/lib/dataExport.js | 33 ++++++++++++++--- frontend/tests/dataExport.test.js | 60 +++++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index 492b47b..173db8a 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -206,12 +206,35 @@ function escapeCsvCell(value) { return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; } -// Serialize a matrix to an RFC-4180-ish CSV string (LF line endings). -// First column is the date; remaining columns follow matrix.columns order. +// Serialize a matrix to an RFC-4180-ish CSV string (LF line endings): +// , +// (blank line) +// [Group,] -- only when groupAxis === 'col' +// [,Group?], +// [,group?], export function toCSV(matrix) { - const header = ['Date', ...matrix.columns.map((c) => c.header)].map(escapeCsvCell).join(','); - const lines = matrix.rows.map((r) => [r.date, ...r.values].map(escapeCsvCell).join(',')); - return [header, ...lines].join('\n'); + const lines = []; + lines.push([matrix.context.label, matrix.context.value].map(escapeCsvCell).join(',')); + lines.push(''); + + if (matrix.groupAxis === 'col') { + const groupRow = ['Group', ...matrix.columns.map((c) => c.group ?? '—')]; + lines.push(groupRow.map(escapeCsvCell).join(',')); + } + + const header = matrix.groupAxis === 'row' + ? ['Group', matrix.corner, ...matrix.columns.map((c) => c.label)] + : [matrix.corner, ...matrix.columns.map((c) => c.label)]; + lines.push(header.map(escapeCsvCell).join(',')); + + for (const r of matrix.rows) { + const lead = matrix.groupAxis === 'row' + ? [r.member.group ?? '—', r.member.label] + : [r.member.label]; + lines.push([...lead, ...r.values].map(escapeCsvCell).join(',')); + } + + return lines.join('\n'); } // Build a download filename from the experiment title and parameter label. diff --git a/frontend/tests/dataExport.test.js b/frontend/tests/dataExport.test.js index e0ea74f..0402b32 100644 --- a/frontend/tests/dataExport.test.js +++ b/frontend/tests/dataExport.test.js @@ -141,20 +141,58 @@ describe('buildMatrix', () => { import { toCSV, csvFilename } from '../src/lib/dataExport'; describe('toCSV', () => { - it('writes a header row of Date + column headers, then data rows', () => { + it('emits context line, blank line, header, data (no group)', () => { const matrix = { - columns: [{ id: 'a1', header: 'Alpha' }, { id: 'a2', header: 'Beta' }], - rows: [{ date: '2026-07-01', values: [5, ''] }, { date: '2026-07-02', values: ['', 9] }], - }; - expect(toCSV(matrix)).toBe('Date,Alpha,Beta\n2026-07-01,5,\n2026-07-02,,9'); - }); - it('escapes commas, quotes, and newlines per RFC 4180', () => { - const matrix = { - columns: [{ id: 'a1', header: 'Note, field' }], - rows: [{ date: '2026-07-01', values: ['he said "hi"'] }, { date: '2026-07-02', values: ['line1\nline2'] }], + corner: 'Date', + context: { label: 'Parameter', value: 'Total attempts' }, + groupAxis: null, + columns: [{ id: 'a1', label: 'Alpha', group: null }, { id: 'a2', label: 'Beta', group: null }], + rows: [{ member: { label: '2026-07-01' }, values: [5, ''] }, { member: { label: '2026-07-02' }, values: ['', 9] }], }; expect(toCSV(matrix)).toBe( - 'Date,"Note, field"\n2026-07-01,"he said ""hi"""\n2026-07-02,"line1\nline2"', + 'Parameter,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,\n2026-07-02,,9', + ); + }); + + it('adds a Group header row when subjects are columns', () => { + const matrix = { + corner: 'Date', + context: { label: 'Parameter', value: 'Total attempts' }, + groupAxis: 'col', + columns: [{ id: 'a1', label: 'Alpha', group: 'Control' }, { id: 'a2', label: 'Beta', group: 'Drug' }], + rows: [{ member: { label: '2026-07-01' }, values: [5, 9] }], + }; + expect(toCSV(matrix)).toBe( + 'Parameter,Total attempts\n\nGroup,Control,Drug\nDate,Alpha,Beta\n2026-07-01,5,9', + ); + }); + + it('adds a leading Group column when subjects are rows', () => { + const matrix = { + corner: 'Subject', + context: { label: 'Date', value: '2026-07-01' }, + groupAxis: 'row', + columns: [{ id: 'p1', label: 'Total attempts', group: 'metrics' }], + rows: [ + { member: { label: 'Alpha', group: 'Control' }, values: [5] }, + { member: { label: 'Beta', group: 'Drug' }, values: [9] }, + ], + }; + expect(toCSV(matrix)).toBe( + 'Date,2026-07-01\n\nGroup,Subject,Total attempts\nControl,Alpha,5\nDrug,Beta,9', + ); + }); + + it('escapes commas, quotes, and newlines per RFC 4180', () => { + const matrix = { + corner: 'Date', + context: { label: 'Parameter', value: 'Note, field' }, + groupAxis: null, + columns: [{ id: 'a1', label: 'he said "hi"', group: null }], + rows: [{ member: { label: '2026-07-01' }, values: ['line1\nline2'] }], + }; + expect(toCSV(matrix)).toBe( + 'Parameter,"Note, field"\n\nDate,"he said ""hi"""\n2026-07-01,"line1\nline2"', ); }); });