import { renderHook, act } from '@testing-library/react'; import { currency, number, km, percentOf, orDash, matchesQuery, useDebouncedValue, exportRows } from '@/lib/doormileFormat'; /** * `xlsx` is imported on demand inside exportRows, so it is mocked at the module * boundary. The export path itself — column declaration to sheet rows to the * written filename — is exactly what these tests verify, so none of that is * mocked away. */ jest.mock('xlsx', () => ({ __esModule: true, utils: { json_to_sheet: jest.fn(() => ({ sheet: true })), book_new: jest.fn(() => ({ book: true })), book_append_sheet: jest.fn() }, writeFile: jest.fn() })); import * as XLSX from 'xlsx'; /** * Shared display formatting. The reason it lives in one module is that a number * reading ₹1,240 on Orders and 1240 on Reports looks like two different figures * for the same money — so these tests pin the exact rendering contract, and in * particular that 0 is a real value while blank/NaN is a dash. */ describe('doormileFormat', () => { describe('currency', () => { it('should render an amount in rupees with Indian digit grouping', () => { expect(currency(1240)).toMatch(/^₹\s?1,240\.00$/); }); it('should group in lakhs, not thousands', () => { // 12,34,567 — not 1,234,567. The console is an Indian ops tool. expect(currency(1234567)).toMatch(/12,34,567\.00$/); }); it('should render zero as a real amount, not a dash', () => { // A ₹0 delivery fee is a fact; an em dash reads as "unknown". expect(currency(0)).toMatch(/^₹\s?0\.00$/); }); it('should accept a numeric string, as the API returns for money columns', () => { expect(currency('1240')).toMatch(/1,240\.00$/); }); it('should cap the fraction at two digits', () => { expect(currency(10.999)).toMatch(/^₹\s?11$|^₹\s?11\.00$/); expect(currency(10.994)).toMatch(/10\.99$/); }); it('should render a negative amount rather than dropping the sign', () => { expect(currency(-500)).toMatch(/500\.00$/); expect(currency(-500)).toMatch(/-|−|\(/); }); it.each([undefined, 'abc', NaN, Infinity, {}])( 'should fall back to an em dash for the unusable value %p', (value) => { expect(currency(value)).toBe('—'); } ); it('should fall back to an em dash for a null amount', () => { // A nullable money column means UNKNOWN, not zero. Rendering ₹0.00 asserts // the delivery earned nothing, which is the exact confusion the module's // own "0 is a real amount" comment exists to prevent. expect(currency(null)).toBe('—'); }); it('should fall back to an em dash for an empty-string amount', () => { expect(currency('')).toBe('—'); }); it('should honour a caller-supplied fallback', () => { expect(currency('abc', 'n/a')).toBe('n/a'); }); }); describe('number', () => { it('should render a count with Indian grouping', () => { expect(number(1234567)).toBe('12,34,567'); }); it('should render zero as 0', () => { expect(number(0)).toBe('0'); }); it.each([undefined, 'abc', NaN, Infinity])('should fall back to an em dash for %p', (value) => { expect(number(value)).toBe('—'); }); it('should fall back to an em dash for a null count', () => { // Same unknown-vs-zero distinction as `currency`. expect(number(null)).toBe('—'); }); it('should fall back to an em dash for an empty-string count', () => { expect(number('')).toBe('—'); }); }); describe('km', () => { it('should render a distance to exactly one decimal place', () => { expect(km(12)).toBe('12.0 km'); expect(km(12.34)).toBe('12.3 km'); expect(km(12.36)).toBe('12.4 km'); }); it('should render a zero distance rather than a dash', () => { expect(km(0)).toBe('0.0 km'); }); it.each([undefined, 'abc', NaN, Infinity])('should fall back to an em dash for %p', (value) => { expect(km(value)).toBe('—'); }); it('should fall back to an em dash for a null distance', () => { // "0.0 km" for an unknown distance is worse than a dash: it reads as a // measured zero-distance trip. expect(km(null)).toBe('—'); }); }); describe('percentOf', () => { it('should return a whole-number percentage', () => { expect(percentOf(25, 100)).toBe(25); expect(percentOf(1, 3)).toBe(33); expect(percentOf(2, 3)).toBe(67); }); it('should return 0 — not Infinity or NaN — when the total is zero', () => { // This feeds progress bars and chip counts; NaN% renders as garbage. expect(percentOf(5, 0)).toBe(0); }); it.each([ [null, 100], [10, null], ['abc', 100], [10, 'abc'], [undefined, undefined] ])('should return 0 for the non-numeric pair (%p, %p)', (value, total) => { expect(percentOf(value, total)).toBe(0); }); it('should allow a percentage above 100 rather than clamping silently', () => { expect(percentOf(150, 100)).toBe(150); }); }); describe('orDash', () => { it.each([null, undefined, '', ' '])('should render the blank value %p as an em dash', (value) => { expect(orDash(value)).toBe('—'); }); it('should trim surrounding whitespace off a real value', () => { expect(orDash(' Bengaluru ')).toBe('Bengaluru'); }); it('should render 0 and false as text, since they are real values', () => { expect(orDash(0)).toBe('0'); expect(orDash(false)).toBe('false'); }); }); describe('matchesQuery', () => { const row = { customername: 'Anita Rao', city: 'Bengaluru', phone: '9876543210', notes: null }; it('should match a case-insensitive substring on any listed field', () => { expect(matchesQuery(row, ['customername', 'city'], 'anita')).toBe(true); expect(matchesQuery(row, ['customername', 'city'], 'BENGAL')).toBe(true); }); it('should not match a field that was not listed', () => { expect(matchesQuery(row, ['city'], 'anita')).toBe(false); }); it('should support a derived field expressed as a function', () => { expect(matchesQuery(row, [(r) => `${r.customername} ${r.city}`], 'rao beng')).toBe(true); }); it.each(['', ' ', null, undefined])( 'should treat the blank query %p as "match everything"', (query) => { expect(matchesQuery(row, ['customername'], query)).toBe(true); } ); it('should trim the query before matching', () => { expect(matchesQuery(row, ['city'], ' bengaluru ')).toBe(true); }); it('should match against a numeric field coerced to text', () => { expect(matchesQuery({ orderid: 4021 }, ['orderid'], '402')).toBe(true); }); it('should skip null/undefined/empty fields instead of matching them', () => { expect(matchesQuery(row, ['notes'], 'null')).toBe(false); expect(matchesQuery(row, ['missing'], 'undefined')).toBe(false); }); it('should return false for a row with no matching field rather than throwing', () => { expect(matchesQuery(null, ['customername'], 'anita')).toBe(false); expect(matchesQuery(row, [], 'anita')).toBe(false); }); }); describe('useDebouncedValue', () => { beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); it('should return the initial value immediately', () => { const { result } = renderHook(() => useDebouncedValue('a', 250)); expect(result.current).toBe('a'); }); it('should not settle until the delay has fully elapsed', () => { const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 250), { initialProps: { value: 'a' } }); rerender({ value: 'ab' }); act(() => { jest.advanceTimersByTime(249); }); expect(result.current).toBe('a'); act(() => { jest.advanceTimersByTime(1); }); expect(result.current).toBe('ab'); }); it('should collapse a burst of keystrokes into a single settled value', () => { const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 250), { initialProps: { value: '' } }); ['b', 'be', 'ben', 'beng'].forEach((value) => { rerender({ value }); act(() => { jest.advanceTimersByTime(100); }); }); expect(result.current).toBe(''); act(() => { jest.advanceTimersByTime(250); }); expect(result.current).toBe('beng'); }); it('should clear its pending timer on unmount', () => { const clearSpy = jest.spyOn(global, 'clearTimeout'); const { unmount } = renderHook(() => useDebouncedValue('a', 250)); unmount(); expect(clearSpy).toHaveBeenCalled(); }); }); describe('exportRows', () => { it('should build one sheet record per row using the column headers', async () => { const rows = [ { orderid: 1, customername: 'Anita', amount: 240 }, { orderid: 2, customername: 'Vikram', amount: 90 } ]; const columns = [ { key: 'orderid', header: 'Order ID' }, { key: 'customername', header: 'Customer' } ]; await exportRows(rows, columns, 'orders-2026-08-27'); expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([ { 'Order ID': 1, Customer: 'Anita' }, { 'Order ID': 2, Customer: 'Vikram' } ]); expect(XLSX.writeFile).toHaveBeenCalledWith({ book: true }, 'orders-2026-08-27.xlsx'); }); it('should prefer a column value() accessor over the raw key', async () => { await exportRows([{ kms: 12.345 }], [{ key: 'kms', header: 'Distance', value: (r) => `${r.kms.toFixed(1)} km` }], 'x'); expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([{ Distance: '12.3 km' }]); }); it('should write a blank cell — not the word null — for a missing value', async () => { await exportRows([{ a: null, b: undefined }], [{ key: 'a', header: 'A' }, { key: 'b', header: 'B' }], 'x'); expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([{ A: '', B: '' }]); }); it('should preserve a zero value rather than blanking it', async () => { await exportRows([{ amount: 0 }], [{ key: 'amount', header: 'Amount' }], 'x'); expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([{ Amount: 0 }]); }); it.each([[[]], [null], [undefined]])( 'should still produce a (empty) workbook when rows is %p', async (rows) => { await exportRows(rows, [{ key: 'a', header: 'A' }], 'empty'); expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([]); expect(XLSX.writeFile).toHaveBeenCalledWith(expect.anything(), 'empty.xlsx'); } ); it('should name the sheet consistently so downstream parsers can find it', async () => { await exportRows([{ a: 1 }], [{ key: 'a', header: 'A' }], 'x'); expect(XLSX.utils.book_append_sheet).toHaveBeenCalledWith({ book: true }, { sheet: true }, 'Export'); }); }); });