diff --git a/src/shared/rate-normalizer.ts b/src/shared/rate-normalizer.ts new file mode 100644 index 0000000..47d7826 --- /dev/null +++ b/src/shared/rate-normalizer.ts @@ -0,0 +1,88 @@ +type ComparableRate = { + isLessThan: boolean; + numeric: number; +}; + +export function normalizeRateDisplay(value: string): string { + const trimmedValue = value.trim(); + const rangeMatch = trimmedValue.match( + /^([0-9]+(?:\.[0-9]+)?)\s*%?\s*-\s*([0-9]+(?:\.[0-9]+)?)\s*%$/ + ); + + if (rangeMatch) { + const [, lowerBound, upperBound] = rangeMatch; + return `${lowerBound}% - ${upperBound}%`; + } + + return trimmedValue.replace(/\s+/g, ""); +} + +export function parseRateLowerBound(value: string | null | undefined): number | null { + const comparableRate = toComparableRate(value); + return comparableRate?.numeric ?? null; +} + +export function compareRateValues( + leftValue: string | null | undefined, + rightValue: string | null | undefined +): number { + const leftComparable = toComparableRate(leftValue); + const rightComparable = toComparableRate(rightValue); + + if (!leftComparable && !rightComparable) { + return 0; + } + + if (!leftComparable) { + return 1; + } + + if (!rightComparable) { + return -1; + } + + if (leftComparable.numeric !== rightComparable.numeric) { + return leftComparable.numeric - rightComparable.numeric; + } + + if (leftComparable.isLessThan === rightComparable.isLessThan) { + return 0; + } + + return leftComparable.isLessThan ? -1 : 1; +} + +function toComparableRate(value: string | null | undefined): ComparableRate | null { + if (!value) { + return null; + } + + const normalizedValue = normalizeRateDisplay(value); + const lessThanMatch = normalizedValue.match(/^<\s*([0-9]+(?:\.[0-9]+)?)%$/); + if (lessThanMatch) { + return { + isLessThan: true, + numeric: Number(lessThanMatch[1]) + }; + } + + const rangeMatch = normalizedValue.match( + /^([0-9]+(?:\.[0-9]+)?)%\s*-\s*([0-9]+(?:\.[0-9]+)?)%$/ + ); + if (rangeMatch) { + return { + isLessThan: false, + numeric: Number(rangeMatch[1]) + }; + } + + const exactMatch = normalizedValue.match(/^([0-9]+(?:\.[0-9]+)?)%$/); + if (exactMatch) { + return { + isLessThan: false, + numeric: Number(exactMatch[1]) + }; + } + + return null; +} diff --git a/tests/rate-normalizer.test.ts b/tests/rate-normalizer.test.ts new file mode 100644 index 0000000..138158e --- /dev/null +++ b/tests/rate-normalizer.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; + +import { + compareRateValues, + normalizeRateDisplay, + parseRateLowerBound +} from "../src/shared/rate-normalizer"; + +describe("rate-normalizer", () => { + test("normalizes compact ranges", () => { + expect(normalizeRateDisplay("0.5%-1%")).toBe("0.5% - 1%"); + }); + + test("normalizes ranges with missing percent on the lower bound", () => { + expect(normalizeRateDisplay("0.02 - 0.1%")).toBe("0.02% - 0.1%"); + }); + + test("parses the lower bound of a range", () => { + expect(parseRateLowerBound("0.02% - 0.1%")).toBe(0.02); + }); + + test("treats less-than values as smaller than the boundary range", () => { + expect(compareRateValues("<0.02%", "0.02% - 0.1%")).toBeLessThan(0); + }); + + test("orders missing values after real values", () => { + expect(compareRateValues(null, "0.02% - 0.1%")).toBeGreaterThan(0); + }); +});