diff --git a/src/utils/__tests__/sortCommitments.test.ts b/src/utils/__tests__/sortCommitments.test.ts index efbf5654..8187c2c3 100644 --- a/src/utils/__tests__/sortCommitments.test.ts +++ b/src/utils/__tests__/sortCommitments.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { sortCommitments, SortOption } from '../sortCommitments'; -import { Commitment } from '@/types/commitment'; +import { sortCommitments, type SortOption } from '../sortCommitments'; +import type { Commitment } from '@/types/commitment'; const mockCommitments: Commitment[] = [ { @@ -127,18 +127,130 @@ describe('sortCommitments', () => { it('returns the input order unchanged for an unrecognized sortBy value', () => { const sorted = sortCommitments(mockCommitments, 'UnrecognizedOption' as SortOption); expect(sorted).toEqual(mockCommitments); + expect(sorted).toBe(mockCommitments); }); - it('handles malformed numeric field gracefully without breaking other elements ordering', () => { + it('returns empty array unchanged for an unrecognized sortBy value', () => { + const empty: Commitment[] = []; + const sorted = sortCommitments(empty, 'UnrecognizedOption' as SortOption); + expect(sorted).toEqual([]); + expect(sorted).toBe(empty); + }); + + it('preserves the original array immutability during sorting', () => { + const original = [...mockCommitments]; + const sorted = sortCommitments(original, 'Oldest'); + expect(original[0].id).toBe('CMT-1'); + expect(sorted).not.toBe(original); + }); + + it('handles malformed string amount gracefully in ValueHighLow and ValueLowHigh sorts', () => { const malformedCommitments: Commitment[] = [ - { ...mockCommitments[0], amount: 'invalid-amount' }, - { ...mockCommitments[1], amount: '20,000' }, - { ...mockCommitments[2], amount: '10,000' }, + { ...mockCommitments[0], id: 'CMT-MALFORMED', amount: 'invalid-amount' }, + { ...mockCommitments[1], id: 'CMT-20K', amount: '20,000' }, + { ...mockCommitments[2], id: 'CMT-10K', amount: '10,000' }, ]; - // 'invalid-amount' parsed as 0. Sorted High to Low should be: 20k (CMT-2), 10k (CMT-3), 0 (CMT-1) - const sorted = sortCommitments(malformedCommitments, 'ValueHighLow'); - expect(sorted[0].id).toBe('CMT-2'); - expect(sorted[1].id).toBe('CMT-3'); - expect(sorted[2].id).toBe('CMT-1'); + const highLow = sortCommitments(malformedCommitments, 'ValueHighLow'); + expect(highLow[0].id).toBe('CMT-20K'); + expect(highLow[1].id).toBe('CMT-10K'); + expect(highLow[2].id).toBe('CMT-MALFORMED'); + + const lowHigh = sortCommitments(malformedCommitments, 'ValueLowHigh'); + expect(lowHigh[0].id).toBe('CMT-MALFORMED'); + expect(lowHigh[1].id).toBe('CMT-10K'); + expect(lowHigh[2].id).toBe('CMT-20K'); + }); + + it('handles non-string and numeric amount types gracefully', () => { + const variedAmountCommitments: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-NUMERIC', amount: 300000 as unknown as string }, + { ...mockCommitments[1], id: 'CMT-NULL', amount: null as unknown as string }, + { ...mockCommitments[2], id: 'CMT-UNDEFINED', amount: undefined as unknown as string }, + { ...mockCommitments[2], id: 'CMT-NAN-NUM', amount: NaN as unknown as string }, + ]; + const sorted = sortCommitments(variedAmountCommitments, 'ValueHighLow'); + expect(sorted[0].id).toBe('CMT-NUMERIC'); + expect(sorted.slice(1).map((c) => c.id)).toContain('CMT-NULL'); + expect(sorted.slice(1).map((c) => c.id)).toContain('CMT-UNDEFINED'); + expect(sorted.slice(1).map((c) => c.id)).toContain('CMT-NAN-NUM'); + }); + + it('handles malformed daysRemaining in MaturitySoonest and MaturityLatest sorts', () => { + const malformedDays: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-VALID-30', daysRemaining: 30 }, + { ...mockCommitments[1], id: 'CMT-NULL-DAYS', daysRemaining: null as unknown as number }, + { ...mockCommitments[2], id: 'CMT-NAN-DAYS', daysRemaining: NaN as unknown as number }, + { + ...mockCommitments[0], + id: 'CMT-UNDEF-DAYS', + daysRemaining: undefined as unknown as number, + }, + { ...mockCommitments[1], id: 'CMT-STR-DAYS', daysRemaining: 'invalid' as unknown as number }, + ]; + const soonest = sortCommitments(malformedDays, 'MaturitySoonest'); + expect(soonest[soonest.length - 1].id).toBe('CMT-VALID-30'); + + const latest = sortCommitments(malformedDays, 'MaturityLatest'); + expect(latest[0].id).toBe('CMT-VALID-30'); + }); + + it('handles malformed complianceScore in ComplianceHighLow and ComplianceLowHigh sorts', () => { + const malformedCompliance: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-SCORE-99', complianceScore: 99 }, + { ...mockCommitments[1], id: 'CMT-SCORE-NULL', complianceScore: null as unknown as number }, + { + ...mockCommitments[2], + id: 'CMT-SCORE-UNDEF', + complianceScore: undefined as unknown as number, + }, + { + ...mockCommitments[0], + id: 'CMT-SCORE-STR', + complianceScore: 'bad-score' as unknown as number, + }, + { ...mockCommitments[1], id: 'CMT-SCORE-NAN', complianceScore: NaN as unknown as number }, + ]; + const highLow = sortCommitments(malformedCompliance, 'ComplianceHighLow'); + expect(highLow[0].id).toBe('CMT-SCORE-99'); + + const lowHigh = sortCommitments(malformedCompliance, 'ComplianceLowHigh'); + expect(lowHigh[lowHigh.length - 1].id).toBe('CMT-SCORE-99'); + }); + + it('handles malformed changePercent in YieldHighLow and YieldLowHigh sorts', () => { + const malformedYield: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-YIELD-25', changePercent: 25.5 }, + { ...mockCommitments[1], id: 'CMT-YIELD-NULL', changePercent: null as unknown as number }, + { + ...mockCommitments[2], + id: 'CMT-YIELD-UNDEF', + changePercent: undefined as unknown as number, + }, + { + ...mockCommitments[0], + id: 'CMT-YIELD-STR', + changePercent: 'invalid-yield' as unknown as number, + }, + { ...mockCommitments[1], id: 'CMT-YIELD-NAN', changePercent: NaN as unknown as number }, + ]; + const highLow = sortCommitments(malformedYield, 'YieldHighLow'); + expect(highLow[0].id).toBe('CMT-YIELD-25'); + + const lowHigh = sortCommitments(malformedYield, 'YieldLowHigh'); + expect(lowHigh[lowHigh.length - 1].id).toBe('CMT-YIELD-25'); + }); + + it('handles malformed createdDate in Newest and Oldest sorts', () => { + const malformedDates: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-DATE-RECENT', createdDate: '2026-03-01' }, + { ...mockCommitments[1], id: 'CMT-DATE-INVALID', createdDate: 'not-a-valid-date' }, + { ...mockCommitments[2], id: 'CMT-DATE-NULL', createdDate: null as unknown as string }, + { ...mockCommitments[0], id: 'CMT-DATE-UNDEF', createdDate: undefined as unknown as string }, + ]; + const newest = sortCommitments(malformedDates, 'Newest'); + expect(newest[0].id).toBe('CMT-DATE-RECENT'); + + const oldest = sortCommitments(malformedDates, 'Oldest'); + expect(oldest[oldest.length - 1].id).toBe('CMT-DATE-RECENT'); }); }); diff --git a/src/utils/sortCommitments.ts b/src/utils/sortCommitments.ts index 21316edd..e0bddbd8 100644 --- a/src/utils/sortCommitments.ts +++ b/src/utils/sortCommitments.ts @@ -1,5 +1,8 @@ -import { Commitment } from '@/types/commitment'; +import type { Commitment } from '@/types/commitment'; +/** + * Supported sort options for ordering commitments. + */ export type SortOption = | 'Newest' | 'Oldest' @@ -13,27 +16,47 @@ export type SortOption = | 'YieldLowHigh'; function parseAmount(amount: unknown): number { - if (typeof amount !== 'string') return 0; + if (typeof amount === 'number') { + return isNaN(amount) ? 0 : amount; + } + if (typeof amount !== 'string') { + return 0; + } const parsed = Number(amount.replace(/,/g, '')); return isNaN(parsed) ? 0 : parsed; } function parseNumeric(val: unknown): number { - if (val === undefined || val === null) return 0; + if (val === undefined || val === null) { + return 0; + } const parsed = Number(val); return isNaN(parsed) ? 0 : parsed; } +function parseDate(dateVal: unknown): number { + if (typeof dateVal !== 'string') { + return 0; + } + const timestamp = new Date(dateVal).getTime(); + return isNaN(timestamp) ? 0 : timestamp; +} + +/** + * Sorts an array of commitments according to the specified sort option. + * Returns a new sorted array without mutating the original input array. + * If an unrecognized sort option is supplied, the original array is returned unchanged. + * + * @param commitments - The list of commitments to sort. + * @param sortBy - The sort option to apply. + * @returns A sorted copy of commitments, or the original array if sortBy is unrecognized. + */ export function sortCommitments(commitments: Commitment[], sortBy: SortOption): Commitment[] { switch (sortBy) { case 'Newest': - return [...commitments].sort( - (a, b) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime(), - ); + return [...commitments].sort((a, b) => parseDate(b.createdDate) - parseDate(a.createdDate)); case 'Oldest': - return [...commitments].sort( - (a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(), - ); + return [...commitments].sort((a, b) => parseDate(a.createdDate) - parseDate(b.createdDate)); case 'ValueHighLow': return [...commitments].sort((a, b) => parseAmount(b.amount) - parseAmount(a.amount)); case 'ValueLowHigh': diff --git a/src/utils/tests/sortCommitments.test.ts b/src/utils/tests/sortCommitments.test.ts new file mode 100644 index 00000000..8187c2c3 --- /dev/null +++ b/src/utils/tests/sortCommitments.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; +import { sortCommitments, type SortOption } from '../sortCommitments'; +import type { Commitment } from '@/types/commitment'; + +const mockCommitments: Commitment[] = [ + { + id: 'CMT-1', + type: 'Safe', + status: 'Active', + asset: 'XLM', + amount: '50,000', + currentValue: '52,600', + changePercent: 5.2, + durationProgress: 75, + daysRemaining: 15, + complianceScore: 95, + maxLoss: '2%', + currentDrawdown: '0.8%', + createdDate: 'Jan 10, 2026', + expiryDate: 'Feb 9, 2026', + }, + { + id: 'CMT-2', + type: 'Balanced', + status: 'Active', + asset: 'USDC', + amount: '100,000', + currentValue: '112,500', + changePercent: 12.5, + durationProgress: 30, + daysRemaining: 42, + complianceScore: 88, + maxLoss: '8%', + currentDrawdown: '3.2%', + createdDate: 'Dec 15, 2025', + expiryDate: 'Feb 13, 2026', + }, + { + id: 'CMT-3', + type: 'Aggressive', + status: 'Active', + asset: 'XLM', + amount: '250,000', + currentValue: '296,750', + changePercent: 18.7, + durationProgress: 17, + daysRemaining: 75, + complianceScore: 76, + maxLoss: 'No limit', + currentDrawdown: '12.5%', + createdDate: 'Nov 20, 2025', + expiryDate: 'Feb 10, 2026', + }, +]; + +describe('sortCommitments', () => { + it('sorts by newest created date', () => { + const sorted = sortCommitments(mockCommitments, 'Newest'); + expect(sorted[0].id).toBe('CMT-1'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-3'); + }); + + it('sorts by oldest created date', () => { + const sorted = sortCommitments(mockCommitments, 'Oldest'); + expect(sorted[0].id).toBe('CMT-3'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-1'); + }); + + it('sorts by value high to low', () => { + const sorted = sortCommitments(mockCommitments, 'ValueHighLow'); + expect(sorted[0].id).toBe('CMT-3'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-1'); + }); + + it('sorts by value low to high', () => { + const sorted = sortCommitments(mockCommitments, 'ValueLowHigh'); + expect(sorted[0].id).toBe('CMT-1'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-3'); + }); + + it('sorts by maturity soonest', () => { + const sorted = sortCommitments(mockCommitments, 'MaturitySoonest'); + expect(sorted[0].id).toBe('CMT-1'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-3'); + }); + + it('sorts by maturity latest', () => { + const sorted = sortCommitments(mockCommitments, 'MaturityLatest'); + expect(sorted[0].id).toBe('CMT-3'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-1'); + }); + + it('sorts by compliance high to low', () => { + const sorted = sortCommitments(mockCommitments, 'ComplianceHighLow'); + expect(sorted[0].id).toBe('CMT-1'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-3'); + }); + + it('sorts by compliance low to high', () => { + const sorted = sortCommitments(mockCommitments, 'ComplianceLowHigh'); + expect(sorted[0].id).toBe('CMT-3'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-1'); + }); + + it('sorts by yield high to low', () => { + const sorted = sortCommitments(mockCommitments, 'YieldHighLow'); + expect(sorted[0].id).toBe('CMT-3'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-1'); + }); + + it('sorts by yield low to high', () => { + const sorted = sortCommitments(mockCommitments, 'YieldLowHigh'); + expect(sorted[0].id).toBe('CMT-1'); + expect(sorted[1].id).toBe('CMT-2'); + expect(sorted[2].id).toBe('CMT-3'); + }); + + it('returns the input order unchanged for an unrecognized sortBy value', () => { + const sorted = sortCommitments(mockCommitments, 'UnrecognizedOption' as SortOption); + expect(sorted).toEqual(mockCommitments); + expect(sorted).toBe(mockCommitments); + }); + + it('returns empty array unchanged for an unrecognized sortBy value', () => { + const empty: Commitment[] = []; + const sorted = sortCommitments(empty, 'UnrecognizedOption' as SortOption); + expect(sorted).toEqual([]); + expect(sorted).toBe(empty); + }); + + it('preserves the original array immutability during sorting', () => { + const original = [...mockCommitments]; + const sorted = sortCommitments(original, 'Oldest'); + expect(original[0].id).toBe('CMT-1'); + expect(sorted).not.toBe(original); + }); + + it('handles malformed string amount gracefully in ValueHighLow and ValueLowHigh sorts', () => { + const malformedCommitments: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-MALFORMED', amount: 'invalid-amount' }, + { ...mockCommitments[1], id: 'CMT-20K', amount: '20,000' }, + { ...mockCommitments[2], id: 'CMT-10K', amount: '10,000' }, + ]; + const highLow = sortCommitments(malformedCommitments, 'ValueHighLow'); + expect(highLow[0].id).toBe('CMT-20K'); + expect(highLow[1].id).toBe('CMT-10K'); + expect(highLow[2].id).toBe('CMT-MALFORMED'); + + const lowHigh = sortCommitments(malformedCommitments, 'ValueLowHigh'); + expect(lowHigh[0].id).toBe('CMT-MALFORMED'); + expect(lowHigh[1].id).toBe('CMT-10K'); + expect(lowHigh[2].id).toBe('CMT-20K'); + }); + + it('handles non-string and numeric amount types gracefully', () => { + const variedAmountCommitments: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-NUMERIC', amount: 300000 as unknown as string }, + { ...mockCommitments[1], id: 'CMT-NULL', amount: null as unknown as string }, + { ...mockCommitments[2], id: 'CMT-UNDEFINED', amount: undefined as unknown as string }, + { ...mockCommitments[2], id: 'CMT-NAN-NUM', amount: NaN as unknown as string }, + ]; + const sorted = sortCommitments(variedAmountCommitments, 'ValueHighLow'); + expect(sorted[0].id).toBe('CMT-NUMERIC'); + expect(sorted.slice(1).map((c) => c.id)).toContain('CMT-NULL'); + expect(sorted.slice(1).map((c) => c.id)).toContain('CMT-UNDEFINED'); + expect(sorted.slice(1).map((c) => c.id)).toContain('CMT-NAN-NUM'); + }); + + it('handles malformed daysRemaining in MaturitySoonest and MaturityLatest sorts', () => { + const malformedDays: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-VALID-30', daysRemaining: 30 }, + { ...mockCommitments[1], id: 'CMT-NULL-DAYS', daysRemaining: null as unknown as number }, + { ...mockCommitments[2], id: 'CMT-NAN-DAYS', daysRemaining: NaN as unknown as number }, + { + ...mockCommitments[0], + id: 'CMT-UNDEF-DAYS', + daysRemaining: undefined as unknown as number, + }, + { ...mockCommitments[1], id: 'CMT-STR-DAYS', daysRemaining: 'invalid' as unknown as number }, + ]; + const soonest = sortCommitments(malformedDays, 'MaturitySoonest'); + expect(soonest[soonest.length - 1].id).toBe('CMT-VALID-30'); + + const latest = sortCommitments(malformedDays, 'MaturityLatest'); + expect(latest[0].id).toBe('CMT-VALID-30'); + }); + + it('handles malformed complianceScore in ComplianceHighLow and ComplianceLowHigh sorts', () => { + const malformedCompliance: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-SCORE-99', complianceScore: 99 }, + { ...mockCommitments[1], id: 'CMT-SCORE-NULL', complianceScore: null as unknown as number }, + { + ...mockCommitments[2], + id: 'CMT-SCORE-UNDEF', + complianceScore: undefined as unknown as number, + }, + { + ...mockCommitments[0], + id: 'CMT-SCORE-STR', + complianceScore: 'bad-score' as unknown as number, + }, + { ...mockCommitments[1], id: 'CMT-SCORE-NAN', complianceScore: NaN as unknown as number }, + ]; + const highLow = sortCommitments(malformedCompliance, 'ComplianceHighLow'); + expect(highLow[0].id).toBe('CMT-SCORE-99'); + + const lowHigh = sortCommitments(malformedCompliance, 'ComplianceLowHigh'); + expect(lowHigh[lowHigh.length - 1].id).toBe('CMT-SCORE-99'); + }); + + it('handles malformed changePercent in YieldHighLow and YieldLowHigh sorts', () => { + const malformedYield: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-YIELD-25', changePercent: 25.5 }, + { ...mockCommitments[1], id: 'CMT-YIELD-NULL', changePercent: null as unknown as number }, + { + ...mockCommitments[2], + id: 'CMT-YIELD-UNDEF', + changePercent: undefined as unknown as number, + }, + { + ...mockCommitments[0], + id: 'CMT-YIELD-STR', + changePercent: 'invalid-yield' as unknown as number, + }, + { ...mockCommitments[1], id: 'CMT-YIELD-NAN', changePercent: NaN as unknown as number }, + ]; + const highLow = sortCommitments(malformedYield, 'YieldHighLow'); + expect(highLow[0].id).toBe('CMT-YIELD-25'); + + const lowHigh = sortCommitments(malformedYield, 'YieldLowHigh'); + expect(lowHigh[lowHigh.length - 1].id).toBe('CMT-YIELD-25'); + }); + + it('handles malformed createdDate in Newest and Oldest sorts', () => { + const malformedDates: Commitment[] = [ + { ...mockCommitments[0], id: 'CMT-DATE-RECENT', createdDate: '2026-03-01' }, + { ...mockCommitments[1], id: 'CMT-DATE-INVALID', createdDate: 'not-a-valid-date' }, + { ...mockCommitments[2], id: 'CMT-DATE-NULL', createdDate: null as unknown as string }, + { ...mockCommitments[0], id: 'CMT-DATE-UNDEF', createdDate: undefined as unknown as string }, + ]; + const newest = sortCommitments(malformedDates, 'Newest'); + expect(newest[0].id).toBe('CMT-DATE-RECENT'); + + const oldest = sortCommitments(malformedDates, 'Oldest'); + expect(oldest[oldest.length - 1].id).toBe('CMT-DATE-RECENT'); + }); +});