Skip to content

Commit e2e91d6

Browse files
authored
test(marketplace): cover sort ordering for all columns, directions, and edge cases (Liquifact#586)
1 parent 8c8e0e0 commit e2e91d6

1 file changed

Lines changed: 339 additions & 0 deletions

File tree

app/invest/sort.test.tsx

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
/**
2+
* @file app/invest/sort.test.tsx
3+
*
4+
* Deterministic tests for the marketplace sort logic exposed by
5+
* `applySortToList` in `app/invest/page.js`.
6+
*
7+
* Coverage
8+
* ────────
9+
* 1. No-op paths — empty array, non-array input, no column selected
10+
* 2. Amount — asc / desc, tie-break stable order preserved
11+
* 3. Yield — asc / desc, tie-break stable order preserved
12+
* 4. Maturity — asc / desc, tie-break stable order preserved
13+
* 5. Edge cases — single-item list, unknown column, does not mutate input
14+
* 6. Large data set — ordering is correct at scale (50 items)
15+
*/
16+
17+
import { applySortToList } from "./page";
18+
import { DEFAULT_FILTERS } from "@/components/InvoiceFilters";
19+
20+
// ─── Type helper ────────────────────────────────────────────────────────────
21+
22+
type Invoice = {
23+
id: string;
24+
issuer: string;
25+
amount: string;
26+
currency: string;
27+
dueDate: string;
28+
yield: string;
29+
status: string;
30+
};
31+
32+
// ─── Filter factory ──────────────────────────────────────────────────────────
33+
34+
function filtersFor(
35+
sort: string,
36+
sortDir: "asc" | "desc" = "desc"
37+
): typeof DEFAULT_FILTERS {
38+
return { ...DEFAULT_FILTERS, sort, sortDir };
39+
}
40+
41+
// ─── Shared fixtures ─────────────────────────────────────────────────────────
42+
43+
/** Three invoices with clearly distinct amount / yield / maturity values. */
44+
const THREE: Invoice[] = [
45+
{
46+
id: "a",
47+
issuer: "Alpha",
48+
amount: "5,000",
49+
currency: "USD",
50+
dueDate: "2026-09-01",
51+
yield: "6.0%",
52+
status: "Open",
53+
},
54+
{
55+
id: "b",
56+
issuer: "Beta",
57+
amount: "12,500",
58+
currency: "USD",
59+
dueDate: "2026-07-15",
60+
yield: "9.5%",
61+
status: "Open",
62+
},
63+
{
64+
id: "c",
65+
issuer: "Gamma",
66+
amount: "800",
67+
currency: "EUR",
68+
dueDate: "2026-11-30",
69+
yield: "4.2%",
70+
status: "Open",
71+
},
72+
];
73+
74+
// ─── 1. No-op paths ──────────────────────────────────────────────────────────
75+
76+
describe("applySortToList – no-op paths", () => {
77+
it("returns the same empty array reference when list is empty", () => {
78+
const empty: Invoice[] = [];
79+
expect(applySortToList(empty, filtersFor("amount", "asc"))).toBe(empty);
80+
});
81+
82+
it("returns the input unchanged when it is not an array", () => {
83+
// @ts-expect-error — deliberately passing a non-array to test the guard
84+
expect(applySortToList(null, filtersFor("amount"))).toBeNull();
85+
// @ts-expect-error
86+
expect(applySortToList(undefined, filtersFor("yield"))).toBeUndefined();
87+
});
88+
89+
it("returns the original list when no sort column is selected", () => {
90+
const result = applySortToList(THREE, { ...DEFAULT_FILTERS, sort: "" });
91+
expect(result).toBe(THREE);
92+
});
93+
94+
it("returns the original list when sort column is unrecognised", () => {
95+
// parseSortState returns the column verbatim; the comparator falls through
96+
// to diff=0 for unknown columns, so the order must be unchanged.
97+
const result = applySortToList(THREE, filtersFor("unknown_column", "asc"));
98+
expect(result.map((i) => i.id)).toEqual(["a", "b", "c"]);
99+
});
100+
});
101+
102+
// ─── 2. Amount sorting ───────────────────────────────────────────────────────
103+
104+
describe("applySortToList – amount column", () => {
105+
it("sorts ascending: 800 < 5,000 < 12,500", () => {
106+
const result = applySortToList(THREE, filtersFor("amount", "asc"));
107+
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"]);
108+
});
109+
110+
it("sorts descending: 12,500 > 5,000 > 800", () => {
111+
const result = applySortToList(THREE, filtersFor("amount", "desc"));
112+
expect(result.map((i) => i.id)).toEqual(["b", "a", "c"]);
113+
});
114+
115+
it("preserves original relative order for equal amounts (tie-break stable)", () => {
116+
const tied: Invoice[] = [
117+
{ id: "x", issuer: "X", amount: "1,000", currency: "USD", dueDate: "2026-01-01", yield: "5%", status: "Open" },
118+
{ id: "y", issuer: "Y", amount: "1,000", currency: "USD", dueDate: "2026-02-01", yield: "6%", status: "Open" },
119+
{ id: "z", issuer: "Z", amount: "1,000", currency: "USD", dueDate: "2026-03-01", yield: "7%", status: "Open" },
120+
];
121+
const asc = applySortToList(tied, filtersFor("amount", "asc"));
122+
const desc = applySortToList(tied, filtersFor("amount", "desc"));
123+
// All amounts equal — original insertion order must be preserved.
124+
expect(asc.map((i) => i.id)).toEqual(["x", "y", "z"]);
125+
expect(desc.map((i) => i.id)).toEqual(["x", "y", "z"]);
126+
});
127+
128+
it("handles amounts without comma separators", () => {
129+
const noComma: Invoice[] = [
130+
{ id: "p", issuer: "P", amount: "500", currency: "USD", dueDate: "2026-01-01", yield: "5%", status: "Open" },
131+
{ id: "q", issuer: "Q", amount: "2000", currency: "USD", dueDate: "2026-01-01", yield: "5%", status: "Open" },
132+
];
133+
const result = applySortToList(noComma, filtersFor("amount", "asc"));
134+
expect(result.map((i) => i.id)).toEqual(["p", "q"]);
135+
});
136+
137+
it("treats unparseable amount strings as 0", () => {
138+
const withGarbage: Invoice[] = [
139+
{ id: "g", issuer: "G", amount: "N/A", currency: "USD", dueDate: "2026-01-01", yield: "5%", status: "Open" },
140+
{ id: "h", issuer: "H", amount: "500", currency: "USD", dueDate: "2026-01-01", yield: "5%", status: "Open" },
141+
];
142+
const result = applySortToList(withGarbage, filtersFor("amount", "asc"));
143+
// "N/A" → 0 < 500, so g comes first ascending
144+
expect(result.map((i) => i.id)).toEqual(["g", "h"]);
145+
});
146+
});
147+
148+
// ─── 3. Yield sorting ────────────────────────────────────────────────────────
149+
150+
describe("applySortToList – yield column", () => {
151+
it("sorts ascending: 4.2% < 6.0% < 9.5%", () => {
152+
const result = applySortToList(THREE, filtersFor("yield", "asc"));
153+
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"]);
154+
});
155+
156+
it("sorts descending: 9.5% > 6.0% > 4.2%", () => {
157+
const result = applySortToList(THREE, filtersFor("yield", "desc"));
158+
expect(result.map((i) => i.id)).toEqual(["b", "a", "c"]);
159+
});
160+
161+
it("preserves original relative order for equal yields (tie-break stable)", () => {
162+
const tied: Invoice[] = [
163+
{ id: "x", issuer: "X", amount: "100", currency: "USD", dueDate: "2026-01-01", yield: "7.0%", status: "Open" },
164+
{ id: "y", issuer: "Y", amount: "200", currency: "USD", dueDate: "2026-02-01", yield: "7.0%", status: "Open" },
165+
{ id: "z", issuer: "Z", amount: "300", currency: "USD", dueDate: "2026-03-01", yield: "7.0%", status: "Open" },
166+
];
167+
const asc = applySortToList(tied, filtersFor("yield", "asc"));
168+
const desc = applySortToList(tied, filtersFor("yield", "desc"));
169+
expect(asc.map((i) => i.id)).toEqual(["x", "y", "z"]);
170+
expect(desc.map((i) => i.id)).toEqual(["x", "y", "z"]);
171+
});
172+
173+
it("handles yield strings without percent sign", () => {
174+
const noPct: Invoice[] = [
175+
{ id: "p", issuer: "P", amount: "100", currency: "USD", dueDate: "2026-01-01", yield: "3.5", status: "Open" },
176+
{ id: "q", issuer: "Q", amount: "100", currency: "USD", dueDate: "2026-01-01", yield: "8.1", status: "Open" },
177+
];
178+
const result = applySortToList(noPct, filtersFor("yield", "asc"));
179+
expect(result.map((i) => i.id)).toEqual(["p", "q"]);
180+
});
181+
182+
it("treats unparseable yield strings as 0", () => {
183+
const withGarbage: Invoice[] = [
184+
{ id: "g", issuer: "G", amount: "100", currency: "USD", dueDate: "2026-01-01", yield: "TBD", status: "Open" },
185+
{ id: "h", issuer: "H", amount: "100", currency: "USD", dueDate: "2026-01-01", yield: "5%", status: "Open" },
186+
];
187+
const result = applySortToList(withGarbage, filtersFor("yield", "asc"));
188+
expect(result.map((i) => i.id)).toEqual(["g", "h"]);
189+
});
190+
});
191+
192+
// ─── 4. Maturity sorting ─────────────────────────────────────────────────────
193+
194+
describe("applySortToList – maturity column", () => {
195+
it("sorts ascending: earliest date first (Jul < Sep < Nov)", () => {
196+
const result = applySortToList(THREE, filtersFor("maturity", "asc"));
197+
expect(result.map((i) => i.id)).toEqual(["b", "a", "c"]);
198+
});
199+
200+
it("sorts descending: latest date first (Nov > Sep > Jul)", () => {
201+
const result = applySortToList(THREE, filtersFor("maturity", "desc"));
202+
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"]);
203+
});
204+
205+
it("preserves original relative order for equal maturity dates (tie-break stable)", () => {
206+
const tied: Invoice[] = [
207+
{ id: "x", issuer: "X", amount: "100", currency: "USD", dueDate: "2026-06-01", yield: "5%", status: "Open" },
208+
{ id: "y", issuer: "Y", amount: "200", currency: "USD", dueDate: "2026-06-01", yield: "6%", status: "Open" },
209+
{ id: "z", issuer: "Z", amount: "300", currency: "USD", dueDate: "2026-06-01", yield: "7%", status: "Open" },
210+
];
211+
const asc = applySortToList(tied, filtersFor("maturity", "asc"));
212+
const desc = applySortToList(tied, filtersFor("maturity", "desc"));
213+
expect(asc.map((i) => i.id)).toEqual(["x", "y", "z"]);
214+
expect(desc.map((i) => i.id)).toEqual(["x", "y", "z"]);
215+
});
216+
217+
it("correctly orders across year boundaries", () => {
218+
const crossYear: Invoice[] = [
219+
{ id: "a", issuer: "A", amount: "100", currency: "USD", dueDate: "2027-01-01", yield: "5%", status: "Open" },
220+
{ id: "b", issuer: "B", amount: "100", currency: "USD", dueDate: "2026-12-31", yield: "5%", status: "Open" },
221+
];
222+
const asc = applySortToList(crossYear, filtersFor("maturity", "asc"));
223+
expect(asc.map((i) => i.id)).toEqual(["b", "a"]);
224+
});
225+
});
226+
227+
// ─── 5. Edge cases ───────────────────────────────────────────────────────────
228+
229+
describe("applySortToList – edge cases", () => {
230+
it("returns a single-element list unchanged for every column and direction", () => {
231+
const single: Invoice[] = [
232+
{ id: "only", issuer: "Solo", amount: "1,000", currency: "USD", dueDate: "2026-06-01", yield: "5%", status: "Open" },
233+
];
234+
for (const col of ["amount", "yield", "maturity"]) {
235+
for (const dir of ["asc", "desc"] as const) {
236+
const result = applySortToList(single, filtersFor(col, dir));
237+
expect(result.map((i) => i.id)).toEqual(["only"]);
238+
}
239+
}
240+
});
241+
242+
it("does not mutate the original array", () => {
243+
const original = [...THREE];
244+
applySortToList(THREE, filtersFor("amount", "asc"));
245+
expect(THREE).toEqual(original);
246+
});
247+
248+
it("returns a new array reference (not the same object) when a column is active", () => {
249+
const result = applySortToList(THREE, filtersFor("yield", "desc"));
250+
expect(result).not.toBe(THREE);
251+
});
252+
253+
it("returns the same reference when no column is active (no-copy fast path)", () => {
254+
const result = applySortToList(THREE, { ...DEFAULT_FILTERS, sort: "" });
255+
expect(result).toBe(THREE);
256+
});
257+
});
258+
259+
// ─── 6. Large data set ───────────────────────────────────────────────────────
260+
261+
describe("applySortToList – large data set (50 items)", () => {
262+
/**
263+
* Build 50 invoices with deterministic but shuffled values so the sort
264+
* has real work to do and the result can be verified precisely.
265+
*/
266+
function makeLargeSet(count: number): Invoice[] {
267+
return Array.from({ length: count }, (_, i) => ({
268+
id: `inv-${String(i).padStart(3, "0")}`,
269+
issuer: `Issuer ${i}`,
270+
// Shuffle amounts: reverse index so i=0 gets the largest value
271+
amount: String((count - i) * 100),
272+
currency: "USD",
273+
// Maturity: spread across the year
274+
dueDate: `2026-${String((i % 12) + 1).padStart(2, "0")}-${String((i % 28) + 1).padStart(2, "0")}`,
275+
// Yield: ascending with index
276+
yield: `${(i % 10) + 1}.0%`,
277+
status: "Open",
278+
}));
279+
}
280+
281+
const LARGE = makeLargeSet(50);
282+
283+
it("amount asc — first item has smallest amount, last has largest", () => {
284+
const result = applySortToList(LARGE, filtersFor("amount", "asc"));
285+
const amounts = result.map((i) => parseFloat(i.amount.replace(/,/g, "")));
286+
for (let i = 0; i < amounts.length - 1; i++) {
287+
expect(amounts[i]).toBeLessThanOrEqual(amounts[i + 1]);
288+
}
289+
});
290+
291+
it("amount desc — first item has largest amount, last has smallest", () => {
292+
const result = applySortToList(LARGE, filtersFor("amount", "desc"));
293+
const amounts = result.map((i) => parseFloat(i.amount.replace(/,/g, "")));
294+
for (let i = 0; i < amounts.length - 1; i++) {
295+
expect(amounts[i]).toBeGreaterThanOrEqual(amounts[i + 1]);
296+
}
297+
});
298+
299+
it("yield asc — values are non-decreasing", () => {
300+
const result = applySortToList(LARGE, filtersFor("yield", "asc"));
301+
const yields = result.map((i) => parseFloat(i.yield));
302+
for (let i = 0; i < yields.length - 1; i++) {
303+
expect(yields[i]).toBeLessThanOrEqual(yields[i + 1]);
304+
}
305+
});
306+
307+
it("yield desc — values are non-increasing", () => {
308+
const result = applySortToList(LARGE, filtersFor("yield", "desc"));
309+
const yields = result.map((i) => parseFloat(i.yield));
310+
for (let i = 0; i < yields.length - 1; i++) {
311+
expect(yields[i]).toBeGreaterThanOrEqual(yields[i + 1]);
312+
}
313+
});
314+
315+
it("maturity asc — dates are non-decreasing", () => {
316+
const result = applySortToList(LARGE, filtersFor("maturity", "asc"));
317+
const dates = result.map((i) => new Date(i.dueDate).getTime());
318+
for (let i = 0; i < dates.length - 1; i++) {
319+
expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]);
320+
}
321+
});
322+
323+
it("maturity desc — dates are non-increasing", () => {
324+
const result = applySortToList(LARGE, filtersFor("maturity", "desc"));
325+
const dates = result.map((i) => new Date(i.dueDate).getTime());
326+
for (let i = 0; i < dates.length - 1; i++) {
327+
expect(dates[i]).toBeGreaterThanOrEqual(dates[i + 1]);
328+
}
329+
});
330+
331+
it("result contains all original items — no items dropped or duplicated", () => {
332+
for (const col of ["amount", "yield", "maturity"]) {
333+
const result = applySortToList(LARGE, filtersFor(col, "asc"));
334+
expect(result).toHaveLength(LARGE.length);
335+
const ids = new Set(result.map((i) => i.id));
336+
expect(ids.size).toBe(LARGE.length);
337+
}
338+
});
339+
});

0 commit comments

Comments
 (0)