313 lines
11 KiB
JavaScript
313 lines
11 KiB
JavaScript
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');
|
||
});
|
||
});
|
||
});
|