diff --git a/package.json b/package.json index 39cc814..f4d1cb8 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "description": "Small TypeScript utility library used as a TaskBounty Coverage Uplift dogfood subject. Intentionally low-coverage at ~30% to demonstrate a real lift to 80%.", "type": "module", "scripts": { - "test": "vitest run", - "test:coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.include='src/**/*.ts' --coverage.exclude='src/**/*.test.ts'" + "test": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=json --coverage.reporter=text-summary --coverage.include='src/**/*.ts' --coverage.exclude='src/**/*.test.ts'", + "posttest": "ln -sf coverage/lcov.info lcov.info 2>/dev/null; ln -sf coverage/coverage-final.json coverage.json 2>/dev/null; echo 'Linked coverage files for TaskBounty sandbox compatibility'", + "test:coverage": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=json --coverage.reporter=text-summary --coverage.include='src/**/*.ts' --coverage.exclude='src/**/*.test.ts'" }, "devDependencies": { "@vitest/coverage-v8": "^2.0.0", diff --git a/src/arrays.test.ts b/src/arrays.test.ts new file mode 100644 index 0000000..6d39b53 --- /dev/null +++ b/src/arrays.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { chunk, dedupe, groupBy } from "./arrays"; + +describe("chunk", () => { + it("splits array into chunks of given size", () => { + expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + + it("returns empty array for size <= 0", () => { + expect(chunk([1, 2, 3], 0)).toEqual([]); + expect(chunk([1, 2, 3], -1)).toEqual([]); + }); + + it("returns single chunk if size >= array length", () => { + expect(chunk([1, 2, 3], 10)).toEqual([[1, 2, 3]]); + }); + + it("handles empty array", () => { + expect(chunk([], 2)).toEqual([]); + }); + + it("works with exact division", () => { + expect(chunk([1, 2, 3, 4], 2)).toEqual([[1, 2], [3, 4]]); + }); +}); + +describe("dedupe", () => { + it("removes duplicate numbers", () => { + expect(dedupe([1, 2, 2, 3, 1])).toEqual([1, 2, 3]); + }); + + it("removes duplicate strings", () => { + expect(dedupe(["a", "b", "a", "c"])).toEqual(["a", "b", "c"]); + }); + + it("returns same array if no duplicates", () => { + expect(dedupe([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it("handles empty array", () => { + expect(dedupe([])).toEqual([]); + }); + + it("handles single element", () => { + expect(dedupe([42])).toEqual([42]); + }); +}); + +describe("groupBy", () => { + it("groups by string key", () => { + const items = [ + { type: "fruit", name: "apple" }, + { type: "fruit", name: "banana" }, + { type: "veg", name: "carrot" }, + ]; + const result = groupBy(items, (x) => x.type); + expect(result).toEqual({ + fruit: [{ type: "fruit", name: "apple" }, { type: "fruit", name: "banana" }], + veg: [{ type: "veg", name: "carrot" }], + }); + }); + + it("groups by numeric key", () => { + const items = [10, 15, 20, 25, 30]; + const result = groupBy(items, (x) => (x >= 20 ? "high" : "low")); + expect(result).toEqual({ + low: [10, 15], + high: [20, 25, 30], + }); + }); + + it("handles empty array", () => { + expect(groupBy([], (x: number) => "a")).toEqual({}); + }); + + it("preserves order within groups", () => { + const items = [3, 1, 2, 1, 3]; + const result = groupBy(items, (x) => x); + expect(result[1]).toEqual([1, 1]); + expect(result[2]).toEqual([2]); + expect(result[3]).toEqual([3, 3]); + }); +}); diff --git a/src/dates.test.ts b/src/dates.test.ts new file mode 100644 index 0000000..a910b41 --- /dev/null +++ b/src/dates.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { daysBetween, isWeekend, addDays, startOfDayUTC } from "./dates"; + +describe("daysBetween", () => { + it("calculates days between same date", () => { + const d = new Date("2024-01-01"); + expect(daysBetween(d, d)).toBe(0); + }); + + it("calculates days between different dates", () => { + const a = new Date("2024-01-01T00:00:00Z"); + const b = new Date("2024-01-10T00:00:00Z"); + expect(daysBetween(a, b)).toBe(9); + }); + + it("always returns positive (uses Math.abs)", () => { + const a = new Date("2024-01-01"); + const b = new Date("2024-01-10"); + expect(daysBetween(b, a)).toBe(9); + }); + + it("rounds correctly for partial days", () => { + const a = new Date("2024-01-01T00:00:00Z"); + const b = new Date("2024-01-01T12:00:00Z"); + expect(daysBetween(a, b)).toBe(1); + }); +}); + +describe("isWeekend", () => { + it("returns true for Saturday (day 6)", () => { + expect(isWeekend(new Date("2024-01-06T00:00:00Z"))).toBe(true); + }); + + it("returns true for Sunday (day 0)", () => { + expect(isWeekend(new Date("2024-01-07T00:00:00Z"))).toBe(true); + }); + + it("returns false for Monday", () => { + expect(isWeekend(new Date("2024-01-08T00:00:00Z"))).toBe(false); + }); + + it("returns false for Wednesday", () => { + expect(isWeekend(new Date("2024-01-10T00:00:00Z"))).toBe(false); + }); + + it("returns false for Friday", () => { + expect(isWeekend(new Date("2024-01-05T00:00:00Z"))).toBe(false); + }); +}); + +describe("addDays", () => { + it("adds positive days", () => { + const d = new Date("2024-01-01"); + const result = addDays(d, 5); + expect(result.getDate()).toBe(6); + }); + + it("adds negative days", () => { + const d = new Date("2024-01-10"); + const result = addDays(d, -3); + expect(result.getDate()).toBe(7); + }); + + it("does not mutate original date", () => { + const d = new Date("2024-01-01"); + addDays(d, 5); + expect(d.getDate()).toBe(1); + }); + + it("handles month boundary", () => { + const d = new Date("2024-01-30"); + const result = addDays(d, 5); + expect(result.getMonth()).toBe(1); // February + expect(result.getDate()).toBe(4); + }); + + it("returns a new Date instance", () => { + const d = new Date("2024-01-01"); + const result = addDays(d, 1); + expect(result).not.toBe(d); + }); +}); + +describe("startOfDayUTC", () => { + it("truncates time to midnight UTC", () => { + const d = new Date("2024-01-15T14:30:45.123Z"); + const result = startOfDayUTC(d); + expect(result.toISOString()).toBe("2024-01-15T00:00:00.000Z"); + }); + + it("handles date already at midnight", () => { + const d = new Date("2024-01-15T00:00:00.000Z"); + const result = startOfDayUTC(d); + expect(result.toISOString()).toBe("2024-01-15T00:00:00.000Z"); + }); + + it("handles end of month", () => { + const d = new Date("2024-01-31T23:59:59.999Z"); + const result = startOfDayUTC(d); + expect(result.toISOString()).toBe("2024-01-31T00:00:00.000Z"); + }); +}); diff --git a/src/money.test.ts b/src/money.test.ts new file mode 100644 index 0000000..6112bde --- /dev/null +++ b/src/money.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { formatCents, platformFeeCents, netPayoutCents, splitEvenly } from "./money"; + +describe("formatCents", () => { + it("formats USD cents with $", () => { + expect(formatCents(100)).toBe("$1.00"); + }); + + it("formats EUR cents with €", () => { + expect(formatCents(150, "EUR")).toBe("€1.50"); + }); + + it("formats other currencies with prefix", () => { + expect(formatCents(200, "GBP")).toBe("GBP 2.00"); + }); + + it("handles zero", () => { + expect(formatCents(0)).toBe("$0.00"); + }); + + it("handles large values", () => { + expect(formatCents(999999)).toBe("$9999.99"); + }); +}); + +describe("platformFeeCents", () => { + it("calculates fee correctly", () => { + // 1000 cents * 250 bps / 10000 = 25 cents + expect(platformFeeCents(1000, 250)).toBe(25); + }); + + it("returns 0 for grossCents <= 0", () => { + expect(platformFeeCents(0, 250)).toBe(0); + expect(platformFeeCents(-10, 250)).toBe(0); + }); + + it("returns 0 for bps <= 0", () => { + expect(platformFeeCents(1000, 0)).toBe(0); + expect(platformFeeCents(1000, -10)).toBe(0); + }); + + it("rounds to integer", () => { + // 99 * 12.5 bps = 1.2375 → Math.round = 1 + expect(platformFeeCents(99, 125)).toBe(1); + }); +}); + +describe("netPayoutCents", () => { + it("calculates net after fee", () => { + // 1000 - platformFeeCents(1000, 250) = 1000 - 25 = 975 + expect(netPayoutCents(1000, 250)).toBe(975); + }); + + it("returns 0 for zero gross", () => { + expect(netPayoutCents(0, 250)).toBe(0); + }); +}); + +describe("splitEvenly", () => { + it("splits evenly with no remainder", () => { + expect(splitEvenly(100, 5)).toEqual([20, 20, 20, 20, 20]); + }); + + it("distributes remainder to first N recipients", () => { + expect(splitEvenly(10, 3)).toEqual([4, 3, 3]); + }); + + it("returns empty array for n <= 0", () => { + expect(splitEvenly(100, 0)).toEqual([]); + expect(splitEvenly(100, -1)).toEqual([]); + }); + + it("handles single recipient", () => { + expect(splitEvenly(50, 1)).toEqual([50]); + }); + + it("handles all remainder", () => { + expect(splitEvenly(5, 5)).toEqual([1, 1, 1, 1, 1]); + }); + + it("sum of parts equals grossCents", () => { + const result = splitEvenly(100, 7); + const sum = result.reduce((a, b) => a + b, 0); + expect(sum).toBe(100); + }); +}); diff --git a/src/strings.test.ts b/src/strings.test.ts index daeb1cc..06459f0 100644 --- a/src/strings.test.ts +++ b/src/strings.test.ts @@ -1,7 +1,111 @@ import { describe, it, expect } from "vitest"; -import { slugify } from "./strings"; +import { slugify, truncate, titleCase, escapeHtml } from "./strings"; + describe("slugify", () => { it("lowercases and dashifies basic input", () => { expect(slugify("Hello World")).toBe("hello-world"); }); + + it("removes special characters", () => { + expect(slugify("Hello! @World#")).toBe("hello-world"); + }); + + it("handles multiple spaces and dashes", () => { + expect(slugify(" Hello World ")).toBe("hello-world"); + }); + + it("handles empty string", () => { + expect(slugify("")).toBe(""); + }); + + it("handles already-dashed input", () => { + expect(slugify("hello-world-test")).toBe("hello-world-test"); + }); + + it("trims leading and trailing dashes", () => { + expect(slugify("-hello-world-")).toBe("hello-world"); + }); +}); + +describe("truncate", () => { + it("returns full string if shorter than max", () => { + expect(truncate("hello", 10)).toBe("hello"); + }); + + it("truncates with default suffix", () => { + expect(truncate("hello world this is long", 10)).toBe("hello wor…"); + }); + + it("returns empty string for max <= 0", () => { + expect(truncate("hello", 0)).toBe(""); + expect(truncate("hello", -1)).toBe(""); + }); + + it("uses custom suffix", () => { + expect(truncate("hello world", 8, ">>>")).toBe("hello>>>"); + }); + + it("handles max exactly equal to input length", () => { + expect(truncate("hello", 5)).toBe("hello"); + }); + + it("handles empty input", () => { + expect(truncate("", 5)).toBe(""); + }); +}); + +describe("titleCase", () => { + it("capitalizes first letter of each word", () => { + expect(titleCase("hello world")).toBe("Hello World"); + }); + + it("handles single word", () => { + expect(titleCase("hello")).toBe("Hello"); + }); + + it("lowercases the rest of each word", () => { + expect(titleCase("HELLO WORLD")).toBe("Hello World"); + }); + + it("handles mixed case", () => { + expect(titleCase("hElLo WoRlD")).toBe("Hello World"); + }); + + it("handles empty string", () => { + expect(titleCase("")).toBe(""); + }); + + it("collapses multiple spaces into one", () => { + expect(titleCase("hello world")).toBe("Hello World"); + }); +}); + +describe("escapeHtml", () => { + it("escapes &", () => { + expect(escapeHtml("a & b")).toBe("a & b"); + }); + + it("escapes < and >", () => { + expect(escapeHtml("