309 lines
12 KiB
JavaScript
309 lines
12 KiB
JavaScript
import {
|
||
parseCSVHeaders,
|
||
parseCSV,
|
||
getUniqueNoteValues,
|
||
runAnalysis,
|
||
extractNumber,
|
||
} from '../src/lib/csvAnalysis';
|
||
|
||
// ── Fixtures ───────────────────────────────────────────────────────────────────
|
||
|
||
// Minimal CSV mimicking the real experiment file structure
|
||
const FIXTURE_CSV = `timestamp,frame_number,frame_line_status,note
|
||
0.0,1,10,
|
||
1.0,2,10,f
|
||
2.0,3,10,s
|
||
3.0,4,10,s
|
||
4.0,5,10,f
|
||
5.0,6,10,
|
||
6.0,7,10,s
|
||
7.0,8,10,f
|
||
8.0,9,10,f
|
||
9.0,10,10,s`;
|
||
|
||
// Same data but with Windows-style line endings
|
||
const FIXTURE_CSV_CRLF = FIXTURE_CSV.replace(/\n/g, '\r\n');
|
||
|
||
// CSV with quoted field containing a comma
|
||
const FIXTURE_CSV_QUOTED = `timestamp,note\n1.0,"hello, world"\n2.0,plain`;
|
||
|
||
// Out-of-order timestamps (should be sorted)
|
||
const FIXTURE_UNSORTED = `timestamp,note
|
||
3.0,s
|
||
1.0,f
|
||
2.0,s`;
|
||
|
||
const CLASSIFICATIONS = { f: 'Failure', s: 'Success' };
|
||
|
||
// ── parseCSVHeaders ────────────────────────────────────────────────────────────
|
||
|
||
describe('parseCSVHeaders', () => {
|
||
test('returns column names from first line', () => {
|
||
expect(parseCSVHeaders(FIXTURE_CSV)).toEqual([
|
||
'timestamp', 'frame_number', 'frame_line_status', 'note',
|
||
]);
|
||
});
|
||
|
||
test('handles CRLF line endings', () => {
|
||
expect(parseCSVHeaders(FIXTURE_CSV_CRLF)).toEqual([
|
||
'timestamp', 'frame_number', 'frame_line_status', 'note',
|
||
]);
|
||
});
|
||
|
||
test('handles single-line CSV (no newline)', () => {
|
||
expect(parseCSVHeaders('a,b,c')).toEqual(['a', 'b', 'c']);
|
||
});
|
||
});
|
||
|
||
// ── parseCSV ──────────────────────────────────────────────────────────────────
|
||
|
||
describe('parseCSV', () => {
|
||
test('returns headers and rows', () => {
|
||
const { headers, rows } = parseCSV(FIXTURE_CSV);
|
||
expect(headers).toEqual(['timestamp', 'frame_number', 'frame_line_status', 'note']);
|
||
expect(rows).toHaveLength(10);
|
||
});
|
||
|
||
test('row values are keyed by header name', () => {
|
||
const { rows } = parseCSV(FIXTURE_CSV);
|
||
expect(rows[0]).toEqual({ timestamp: '0.0', frame_number: '1', frame_line_status: '10', note: '' });
|
||
expect(rows[1]).toEqual({ timestamp: '1.0', frame_number: '2', frame_line_status: '10', note: 'f' });
|
||
});
|
||
|
||
test('handles CRLF line endings', () => {
|
||
const { rows } = parseCSV(FIXTURE_CSV_CRLF);
|
||
expect(rows).toHaveLength(10);
|
||
expect(rows[1].note).toBe('f');
|
||
});
|
||
|
||
test('handles quoted fields containing commas', () => {
|
||
const { rows } = parseCSV(FIXTURE_CSV_QUOTED);
|
||
expect(rows[0].note).toBe('hello, world');
|
||
expect(rows[1].note).toBe('plain');
|
||
});
|
||
|
||
test('skips blank lines', () => {
|
||
const csv = 'a,b\n1,2\n\n3,4\n';
|
||
const { rows } = parseCSV(csv);
|
||
expect(rows).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
// ── getUniqueNoteValues ────────────────────────────────────────────────────────
|
||
|
||
describe('getUniqueNoteValues', () => {
|
||
test('returns sorted unique non-empty values', () => {
|
||
const { rows } = parseCSV(FIXTURE_CSV);
|
||
expect(getUniqueNoteValues(rows, 'note')).toEqual(['f', 's']);
|
||
});
|
||
|
||
test('ignores empty strings', () => {
|
||
const rows = [{ note: '' }, { note: 'a' }, { note: '' }, { note: 'b' }];
|
||
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
|
||
});
|
||
|
||
test('trims whitespace before deduplication', () => {
|
||
const rows = [{ note: ' a ' }, { note: 'a' }, { note: 'b' }];
|
||
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
|
||
});
|
||
|
||
test('returns empty array when no non-empty notes', () => {
|
||
const rows = [{ note: '' }, { note: '' }];
|
||
expect(getUniqueNoteValues(rows, 'note')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ── runAnalysis ────────────────────────────────────────────────────────────────
|
||
|
||
describe('runAnalysis', () => {
|
||
let result;
|
||
|
||
beforeEach(() => {
|
||
const { rows } = parseCSV(FIXTURE_CSV);
|
||
result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
|
||
});
|
||
|
||
// Fixture note sequence (sorted by timestamp, empty rows excluded):
|
||
// t=1 f, t=2 s, t=3 s, t=4 f, t=6 s, t=7 f, t=8 f, t=9 s
|
||
// Categories: F S S F S F F S
|
||
|
||
test('totalNoteRows counts only rows with non-empty note', () => {
|
||
expect(result.totalNoteRows).toBe(8);
|
||
});
|
||
|
||
test('sequence preserves order and maps to categories', () => {
|
||
const notes = result.sequence.map(r => r.note);
|
||
expect(notes).toEqual(['f', 's', 's', 'f', 's', 'f', 'f', 's']);
|
||
|
||
const cats = result.sequence.map(r => r.category);
|
||
expect(cats).toEqual(['Failure', 'Success', 'Success', 'Failure', 'Success', 'Failure', 'Failure', 'Success']);
|
||
});
|
||
|
||
test('categoryCounts totals are correct', () => {
|
||
expect(result.categoryCounts['Failure'].total).toBe(4);
|
||
expect(result.categoryCounts['Success'].total).toBe(4);
|
||
});
|
||
|
||
test('categoryCounts byNote breakdown is correct', () => {
|
||
expect(result.categoryCounts['Failure'].byNote).toEqual({ f: 4 });
|
||
expect(result.categoryCounts['Success'].byNote).toEqual({ s: 4 });
|
||
});
|
||
|
||
test('runs (run-length encoding) are correct', () => {
|
||
// F S S F S F F S → F×1, S×2, F×1, S×1, F×2, S×1
|
||
expect(result.runs).toEqual([
|
||
{ category: 'Failure', length: 1 },
|
||
{ category: 'Success', length: 2 },
|
||
{ category: 'Failure', length: 1 },
|
||
{ category: 'Success', length: 1 },
|
||
{ category: 'Failure', length: 2 },
|
||
{ category: 'Success', length: 1 },
|
||
]);
|
||
});
|
||
|
||
test('consecutiveStats totalRuns', () => {
|
||
expect(result.consecutiveStats['Failure'].totalRuns).toBe(3);
|
||
expect(result.consecutiveStats['Success'].totalRuns).toBe(3);
|
||
});
|
||
|
||
test('consecutiveStats maxRun', () => {
|
||
expect(result.consecutiveStats['Failure'].maxRun).toBe(2);
|
||
expect(result.consecutiveStats['Success'].maxRun).toBe(2);
|
||
});
|
||
|
||
test('consecutiveStats avgRun', () => {
|
||
// Failure runs: [1,1,2] → avg = 4/3
|
||
expect(result.consecutiveStats['Failure'].avgRun).toBeCloseTo(4 / 3);
|
||
// Success runs: [2,1,1] → avg = 4/3
|
||
expect(result.consecutiveStats['Success'].avgRun).toBeCloseTo(4 / 3);
|
||
});
|
||
|
||
test('consecutiveStats runLengths array', () => {
|
||
expect(result.consecutiveStats['Failure'].runLengths).toEqual([1, 1, 2]);
|
||
expect(result.consecutiveStats['Success'].runLengths).toEqual([2, 1, 1]);
|
||
});
|
||
});
|
||
|
||
describe('runAnalysis — sorting', () => {
|
||
test('sorts rows by timestamp before building sequence', () => {
|
||
const { rows } = parseCSV(FIXTURE_UNSORTED);
|
||
// Input order: s(3), f(1), s(2) — sorted order: f(1), s(2), s(3)
|
||
const result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
|
||
expect(result.sequence.map(r => r.note)).toEqual(['f', 's', 's']);
|
||
});
|
||
});
|
||
|
||
describe('runAnalysis — unclassified notes', () => {
|
||
test('unclassified notes appear in sequence with null category', () => {
|
||
const { rows } = parseCSV(FIXTURE_CSV);
|
||
// Only classify 'f'; leave 's' unclassified
|
||
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
|
||
const nullCats = result.sequence.filter(r => r.category === null);
|
||
expect(nullCats.length).toBe(4); // 4 's' notes
|
||
});
|
||
|
||
test('unclassified notes are excluded from categoryCounts', () => {
|
||
const { rows } = parseCSV(FIXTURE_CSV);
|
||
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
|
||
expect(result.categoryCounts['Success']).toBeUndefined();
|
||
});
|
||
|
||
test('unclassified notes do not break or extend runs', () => {
|
||
// Sequence with 'x' unclassified: f x f → should still be one Failure run of 2
|
||
const csv = 'ts,note\n1.0,f\n2.0,x\n3.0,f';
|
||
const { rows } = parseCSV(csv);
|
||
// 'x' is unclassified, 'f' → Failure
|
||
// After skipping 'x': f, f → one run of Failure×2
|
||
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||
expect(result.runs).toEqual([{ category: 'Failure', length: 2 }]);
|
||
});
|
||
});
|
||
|
||
describe('runAnalysis — multiple note values per category', () => {
|
||
test('groups multiple note values under one category', () => {
|
||
// The user's example: ss and s both belong to Success
|
||
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,s';
|
||
const { rows } = parseCSV(csv);
|
||
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success' });
|
||
expect(result.categoryCounts['Success'].total).toBe(3);
|
||
expect(result.categoryCounts['Success'].byNote).toEqual({ ss: 1, s: 2 });
|
||
});
|
||
|
||
test('run-length encoding with multiple note values in same category', () => {
|
||
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,f';
|
||
const { rows } = parseCSV(csv);
|
||
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success', f: 'Failure' });
|
||
// ss and s are both Success → run of 2, then Failure run of 1
|
||
expect(result.runs).toEqual([
|
||
{ category: 'Success', length: 2 },
|
||
{ category: 'Failure', length: 1 },
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('runAnalysis — edge cases', () => {
|
||
test('empty rows returns zero totals', () => {
|
||
const result = runAnalysis([], 'timestamp', 'note', CLASSIFICATIONS);
|
||
expect(result.totalNoteRows).toBe(0);
|
||
expect(result.sequence).toEqual([]);
|
||
expect(result.runs).toEqual([]);
|
||
expect(result.categoryCounts).toEqual({});
|
||
expect(result.consecutiveStats).toEqual({});
|
||
});
|
||
|
||
test('all notes unclassified', () => {
|
||
const { rows } = parseCSV(FIXTURE_CSV);
|
||
const result = runAnalysis(rows, 'timestamp', 'note', {});
|
||
expect(result.categoryCounts).toEqual({});
|
||
expect(result.runs).toEqual([]);
|
||
});
|
||
|
||
test('single note row', () => {
|
||
const csv = 'ts,note\n1.0,f';
|
||
const { rows } = parseCSV(csv);
|
||
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||
expect(result.totalNoteRows).toBe(1);
|
||
expect(result.runs).toEqual([{ category: 'Failure', length: 1 }]);
|
||
expect(result.consecutiveStats['Failure'].maxRun).toBe(1);
|
||
expect(result.consecutiveStats['Failure'].avgRun).toBe(1);
|
||
});
|
||
|
||
test('all same category', () => {
|
||
const csv = 'ts,note\n1.0,f\n2.0,f\n3.0,f';
|
||
const { rows } = parseCSV(csv);
|
||
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||
expect(result.runs).toEqual([{ category: 'Failure', length: 3 }]);
|
||
expect(result.consecutiveStats['Failure'].maxRun).toBe(3);
|
||
expect(result.consecutiveStats['Failure'].totalRuns).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('extractNumber', () => {
|
||
test('extracts positive decimal from prefixed string', () => {
|
||
expect(extractNumber('L25.5')).toBe(25.5);
|
||
});
|
||
test('extracts negative integer with letter prefix', () => {
|
||
expect(extractNumber('R-12')).toBe(-12);
|
||
});
|
||
test('extracts integer from underscore-separated label', () => {
|
||
expect(extractNumber('trial_007')).toBe(7);
|
||
});
|
||
test('does not parse scientific notation as one number', () => {
|
||
// Accepted limitation: regex matches "3.2", strips "e2"
|
||
expect(extractNumber('3.2e2')).toBe(3.2);
|
||
});
|
||
test('strips trailing units', () => {
|
||
expect(extractNumber('42 ms')).toBe(42);
|
||
});
|
||
test('returns null for purely alphabetic string', () => {
|
||
expect(extractNumber('abc')).toBeNull();
|
||
});
|
||
test('returns null for empty string', () => {
|
||
expect(extractNumber('')).toBeNull();
|
||
});
|
||
test('returns null for null/undefined', () => {
|
||
expect(extractNumber(null)).toBeNull();
|
||
expect(extractNumber(undefined)).toBeNull();
|
||
});
|
||
});
|