import { describe, it, expect } from 'vitest' import { compareValues, deriveVisibleRows, paginateRows, resolveColumns, sortRows, tableDataToCsv, } from './helpers' import type { TableColumn, TableRow } from './types' const ROWS: TableRow[] = [ { id: 1, name: 'Alpha', score: 12 }, { id: 2, name: 'beta', score: 7 }, { id: 3, name: 'Gamma', score: 25 }, { id: 4, name: null, score: null }, ] describe('compareValues', () => { it('orders strings via locale compare', () => { expect(compareValues('a', 'b', 'asc')).toBeLessThan(0) expect(compareValues('b', 'a', 'asc')).toBeGreaterThan(0) }) it('orders numbers numerically', () => { expect(compareValues(1, 10, 'asc')).toBeLessThan(0) expect(compareValues(10, 1, 'asc')).toBeGreaterThan(0) }) it('flips comparison for desc', () => { expect(compareValues(1, 10, 'desc')).toBeGreaterThan(0) }) it('sorts null/undefined to the end', () => { expect(compareValues(null, 5, 'asc')).toBeGreaterThan(0) expect(compareValues(5, null, 'asc')).toBeLessThan(0) }) }) describe('sortRows', () => { it('sorts numeric column ascending and descending', () => { expect(sortRows(ROWS, 'score', 'asc').map((r) => r.id)).toEqual([ 2, 1, 3, 4, ]) expect(sortRows(ROWS, 'score', 'desc').map((r) => r.id)).toEqual([ 3, 1, 2, 4, ]) }) it('does not mutate the input', () => { const before = [...ROWS] sortRows(ROWS, 'score', 'asc') expect(ROWS).toEqual(before) }) }) describe('paginateRows', () => { it('returns the requested slice', () => { expect(paginateRows([1, 2, 3, 4, 5], 0, 2)).toEqual([1, 2]) expect(paginateRows([1, 2, 3, 4, 5], 1, 2)).toEqual([3, 4]) expect(paginateRows([1, 2, 3, 4, 5], 2, 2)).toEqual([5]) }) it('returns empty for out-of-range pages', () => { expect(paginateRows([1, 2, 3], 5, 2)).toEqual([]) }) }) describe('resolveColumns', () => { const cols: TableColumn[] = [ { id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C' }, ] it('returns input unchanged when no order is provided', () => { expect(resolveColumns(cols, undefined)).toBe(cols) }) it('reorders by id', () => { expect(resolveColumns(cols, ['c', 'a', 'b']).map((c) => c.id)).toEqual([ 'c', 'a', 'b', ]) }) it('skips unknown ids and appends columns missing from the order', () => { expect(resolveColumns(cols, ['c', 'unknown']).map((c) => c.id)).toEqual([ 'c', 'a', 'b', ]) }) }) describe('deriveVisibleRows', () => { it('paginates without sort when sort columnId is null', () => { const out = deriveVisibleRows(ROWS, { sort: { columnId: null, direction: 'asc' }, page: 0, pageSize: 2, }) expect(out.visible.map((r) => r.id)).toEqual([1, 2]) expect(out.sorted.length).toBe(4) }) it('sorts then paginates', () => { const out = deriveVisibleRows(ROWS, { sort: { columnId: 'score', direction: 'asc' }, page: 0, pageSize: 2, }) expect(out.visible.map((r) => r.id)).toEqual([2, 1]) }) }) describe('compareValues — broader types', () => { it('ranks booleans (false < true)', () => { expect(compareValues(false, true, 'asc')).toBeLessThan(0) expect(compareValues(true, false, 'asc')).toBeGreaterThan(0) expect(compareValues(true, true, 'asc')).toBe(0) }) it('compares objects/arrays via JSON serialization', () => { expect(compareValues({ a: 1 }, { a: 2 }, 'asc')).toBeLessThan(0) expect(compareValues([1, 2], [1, 1], 'desc')).toBeLessThan(0) }) it('returns 0 when both sides are null/undefined', () => { expect(compareValues(null, undefined, 'asc')).toBe(0) expect(compareValues(undefined, null, 'desc')).toBe(0) }) it('returns 0 for mismatched primitive types it does not know how to order', () => { // Not a documented contract but the implementation falls through to cmp = 0 // when types don't match — guard the fallthrough behavior so future // refactors notice if they change it. expect(compareValues(1, 'a', 'asc')).toBe(0) }) }) describe('paginateRows — edge cases', () => { it('returns a copy when pageSize is 0 or negative', () => { const rows = [1, 2, 3] const out = paginateRows(rows, 0, 0) expect(out).toEqual(rows) expect(out).not.toBe(rows) expect(paginateRows(rows, 0, -5)).toEqual(rows) }) }) describe('resolveColumns — edge cases', () => { const cols: TableColumn[] = [ { id: 'a', label: 'A' }, { id: 'b', label: 'B' }, ] it('returns input unchanged when order is empty', () => { expect(resolveColumns(cols, [])).toBe(cols) }) it('deduplicates repeated ids in the order', () => { expect(resolveColumns(cols, ['a', 'a', 'b']).map((c) => c.id)).toEqual([ 'a', 'b', ]) }) }) describe('tableDataToCsv', () => { const cols: TableColumn[] = [ { id: 'name', label: 'Name' }, { id: 'score', label: 'Score' }, ] it('emits header row + cell rows', () => { const csv = tableDataToCsv(ROWS, cols) const lines = csv.split('\n') expect(lines[0]).toBe('Name,Score') expect(lines[1]).toBe('Alpha,12') expect(lines[2]).toBe('beta,7') }) it('escapes commas, newlines, and quotes', () => { const csv = tableDataToCsv([{ id: 1, name: 'a, b "c"\nd', score: 1 }], cols) expect(csv).toContain('"a, b ""c""\nd",1') }) it('renders null cells as empty strings', () => { const csv = tableDataToCsv([ROWS[3]!], cols) expect(csv.split('\n')[1]).toBe(',') }) it('coerces numeric/boolean header labels and skips ReactNode labels', () => { const numericLabel: TableColumn[] = [ { id: 'a', label: 7 as unknown as TableColumn['label'] }, { id: 'b', label: true as unknown as TableColumn['label'] }, // ReactNode (object) labels degrade to empty string by design. { id: 'c', label: { type: 'span' } as unknown as TableColumn['label'] }, ] const csv = tableDataToCsv([], numericLabel) expect(csv).toBe('7,true,') }) it('serialises array and object cell values as JSON', () => { const csv = tableDataToCsv( [{ id: 1, name: ['x', 'y'], score: { z: 1 } }], cols, ) // The JSON contains quotes so the cell wraps + escapes. expect(csv).toContain('"[""x"",""y""]"') expect(csv).toContain('"{""z"":1}"') }) it('guards against formula injection by prefixing leading =,+,-,@', () => { const csv = tableDataToCsv( [ { id: 1, name: '=SUM(A1)', score: '+1' }, { id: 2, name: '-2', score: '@cmd' }, ], cols, ) const lines = csv.split('\n') expect(lines[1]).toBe("'=SUM(A1),'+1") expect(lines[2]).toBe("'-2,'@cmd") }) it('still wraps a formula cell that also contains a comma', () => { const csv = tableDataToCsv([{ id: 1, name: '=A1,B2', score: 1 }], cols) // Prefixed against injection, then quoted for the embedded comma. expect(csv.split('\n')[1]).toBe('"\'=A1,B2",1') }) })