Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@rollup/plugin-commonjs": "^23.0.4",
"@rollup/plugin-node-resolve": "^15.2.1",
"@types/chai": "^4.3.6",
"@types/deep-eql": "^4.0.0",
"@types/mocha": "^10.0.2",
"@types/node": "^20.8.2",
"@typescript-eslint/eslint-plugin": "^6.7.4",
Expand All @@ -31,6 +32,7 @@
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-react": "^7.33.2",
"fast-check": "^3.13.1",
"husky": "^8.0.3",
"mocha": "^10.2.0",
"nyc": "^15.1.0",
Expand Down
56 changes: 54 additions & 2 deletions src/interfaces/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,60 @@
import {datable} from "../types/types";

export interface SortOption {
desc?: boolean;
nullable?: boolean;
desc?: boolean;
nullable?: boolean;
}

/**
* The default value of {@link SortByNumberOption.valuesOrder}.
*/
export const DEFAULT_NUMBER_VALUE_CATEGORY_ORDER = [
"other",
"NaN",
"null",
"undefined",
] as const;

/**
* A kind of value that can be sorted using `byNumber`.
*/
export type NumberValueCategory = (typeof DEFAULT_NUMBER_VALUE_CATEGORY_ORDER)[number];

/**
* Sorting options for `byNumber`.
*/
export interface SortByNumberOption extends SortOption {
/**
* This option controls how various special values are sorted relative to one
* another.
*
* Each possible value may appear at most once. The default order is ["other",
* "NaN", "null", "undefined"], meaning ordinary numbers are sortered first,
* followed by any `NaN`, then all `null` values, and finally all `undefined`
* values.
*
* Omitting "other" is the same as putting it first. Omitting any of "NaN",
* "null", or "undefined" is the same as putting that value last; omitting
* more than one is the same as putting the last in the same order as in the
* default array.
*
* The {@link SortOption.desc} does not affect the order created by this
* option. It only affects how numbers are sorted within the "others"
* category.
*
* Note that `Array.sort` always acts as if "undefined" is last, regardless of
* any sorting options.
*/
valueCategoryOrder?: NumberValueCategory[];

/**
* @deprecated Using this option results in a comparison function that is
* inconistent with the behavior of `Array.prototype.sort`, which always sorts
* `undefined` to the end of the array regardless of the comparison function.
* Futhermore, this option is not useful for controlling the sorting of
* `null`, because `byNumber` always treats `null` as equal to 0.
*/
nullable?: boolean;
}

export interface SortByStringOption extends SortOption {
Expand Down
117 changes: 105 additions & 12 deletions src/sortables/byNumber.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,58 @@
import {getSorter} from "../sort";
import {SortOption} from "../interfaces/interfaces";
import {sortable, sortableWithOption} from "../types/types";
import { getSorter } from "../sort";
import {
DEFAULT_NUMBER_VALUE_CATEGORY_ORDER,
NumberValueCategory,
SortByNumberOption,
} from "../interfaces/interfaces";
import { sortable, sortableWithOption } from "../types/types";


export type NumberLike = number | null | undefined;

export function normalizeNumberValueCategoryOrder(order: NumberValueCategory[]): void {
if (new Set(order).size !== order.length) {
throw new TypeError("valuesOrder may not contain duplicate values");
}
if (!order.includes("other")) {
order.unshift("other");
}
DEFAULT_NUMBER_VALUE_CATEGORY_ORDER.forEach((kind) => {
if (!order.includes(kind)) {
order.push(kind);
}
});
}

type ValueCategoryOrderRecord = Record<NumberValueCategory, number>;

function makeValuesOrderRecord(
inputOrder: readonly NumberValueCategory[]
): ValueCategoryOrderRecord {
const order: NumberValueCategory[] = [...inputOrder];
normalizeNumberValueCategoryOrder(order);

const result: Partial<ValueCategoryOrderRecord> = {};
order.forEach((kind, index) => {
result[kind] = index;
});
return result as Required<typeof result>;
}

function numberValueCategoryIndex(
n: NumberLike,
record: ValueCategoryOrderRecord
): number {
if (n !== n) {
return record.NaN;
}
if (n == null) {
if (n === null) {
return record.null;
}
return record.undefined;
}
return record.other;
}

/**
* the sortable to sort the **number primitive**
Expand All @@ -9,15 +61,56 @@ import {sortable, sortableWithOption} from "../types/types";
* {@link https://sort-es.netlify.app/by-number byNumber docs}
* @version 1.0.0
*/
const byNumber: sortableWithOption<number, SortOption> = (
options: SortOption = {desc: false, nullable: false}
): sortable<number> => {
const sorter = getSorter(options);

return (first, second): number =>
options.nullable
? sorter((first || 0) - (second || 0) || 0)
: sorter((first! - second!) || 0);
const byNumber: sortableWithOption<NumberLike, SortByNumberOption> = (
options: SortByNumberOption = {
desc: false,
nullable: false,
}
): sortable<NumberLike> => {
// Convert the options to primitive numbers in local variables.
const sign = getSorter(options)(1);
const valuesOrderRecord = makeValuesOrderRecord(
options.valueCategoryOrder ?? []
);

// This is the basic comparison function used as-if unless `nullable` is set.
const compare = (first: NumberLike, second: NumberLike): number => {
if (first != null && second != null) {
// Start with a naive comparison.
const delta = first - second;

// This branch handles all cases except those involving NaN or infinites of
// the same sign. We use the === operator to detect NaN because it's
// probably faster than calling Math.isNaN.
if (delta === delta) {
return sign * delta;
}

// This branch handles infinities with the same sign, as well as comparing
// undefined to itself.
if (first === second) {
return 0;
}
}

// At this point, either `first`, `second`, or both must be NaN, null, or undefined.
return (
numberValueCategoryIndex(first, valuesOrderRecord) -
numberValueCategoryIndex(second, valuesOrderRecord)
);
};

// If requested, wrap the comparison function so it treats falsy values as
// equal to 0. Despite the name, this option has no effect on the treatment
// of null, because ordinary subtraction treats null as equal to 0. It also
// has no effect on how undefined is sorted by JavaScript's native sort
// algorithm, because undefined is always sorted last regardless of the
// comparison function.
if (options.nullable) {
return (first: NumberLike, second: NumberLike) => compare(first || 0, second || 0);
}

return compare;
};

export default byNumber;
167 changes: 124 additions & 43 deletions test/byNumber.spec.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,135 @@
import { expect } from "chai";
import fc from "fast-check";
import "mocha";
import {expect} from "chai";
import {byNumber} from "../src/index";
import {getFirstAndLast, reverse} from "./utils/sort";
import {expectObjectToBeEquals} from "./utils/expectFns";

const arrayUnsorted = [44, 12, 34, 124, 21.5];
const correctArraySorted = [12, 21.5, 34, 44, 124];

describe("ByNumber sorting", () => {
it("Does sort an array by number", () => {
const arraySorted = arrayUnsorted.sort(byNumber());

const [first, last] = getFirstAndLast(arraySorted);

expect(first).to.equal(12);

expect(last).to.equal(124);

expectObjectToBeEquals(arraySorted, correctArraySorted);
import {
DEFAULT_NUMBER_VALUE_CATEGORY_ORDER,
NumberValueCategory,
SortByNumberOption,
} from "../src/interfaces/interfaces";
import byNumber, {
NumberLike,
normalizeNumberValueCategoryOrder,
} from "../src/sortables/byNumber";

function someCategory(): fc.Arbitrary<NumberValueCategory> {
return fc.constantFrom(...DEFAULT_NUMBER_VALUE_CATEGORY_ORDER);
}

function someCategoryOrder(): fc.Arbitrary<NumberValueCategory[]> {
return fc.shuffledSubarray(Array.from(DEFAULT_NUMBER_VALUE_CATEGORY_ORDER));
}

function someInstanceOfCategory(
category: NumberValueCategory
): fc.Arbitrary<NumberLike> {
switch (category) {
case "undefined":
return fc.constant(undefined);
case "NaN":
return fc.constant(NaN);
case "null":
return fc.constant(null);
case "other":
return fc.double({ noNaN: true });
}
}

function someCategoryAndValue(): fc.Arbitrary<
[NumberValueCategory, NumberLike]
> {
return someCategory().chain((category) =>
fc.tuple(fc.constant(category), someInstanceOfCategory(category))
);
}

function someSortByNumberOption(): fc.Arbitrary<SortByNumberOption> {
return fc
.tuple(someCategoryOrder(), fc.boolean())
.map(([valueCategoryOrder, desc]) => ({
valueCategoryOrder,
desc,
}));
}

function normOrder(
order: readonly NumberValueCategory[]
): NumberValueCategory[] {
const newOrder = Array.from(order);
normalizeNumberValueCategoryOrder(newOrder);
return newOrder;
}

describe("normalizeCategoryOrder", () => {
it("creates the right number of unique values", () => {
fc.assert(
fc.property(
someCategoryOrder(),
(order) =>
new Set(normOrder(order)).size ===
DEFAULT_NUMBER_VALUE_CATEGORY_ORDER.length
)
);
});
});

describe("ByNumber sorting desc", () => {
it("Does sort an array by string descending", () => {
const arraySorted = arrayUnsorted.sort(byNumber({desc: true}));

const [first, last] = getFirstAndLast(arraySorted);

expect(first).to.equal(124);
it("normalizes an empty array into the default order", () => {
expect(normOrder([])).deep.equals(DEFAULT_NUMBER_VALUE_CATEGORY_ORDER);
});

expect(last).to.equal(12);
it("puts 'other' first", () => {
fc.assert(
fc.property(
someCategoryOrder().filter((order) => !order.includes("other")),
(rawOrder) => normOrder(rawOrder)[0] === "other"
)
);
});

expectObjectToBeEquals(arraySorted, reverse(correctArraySorted));
it("puts other categories last", () => {
fc.assert(
fc.property(
someCategoryOrder().filter((order) => order.includes("other")),
(rawOrder) => {
expect(normOrder(rawOrder).slice(0, rawOrder.length)).deep.equals(
rawOrder
);
}
)
);
});
});

describe("ByNumber sorting", () => {
it("sorts correctly by category", () => {
fc.assert(
fc.property(
someSortByNumberOption(),
someCategoryAndValue(),
someCategoryAndValue(),
(sortOpts, [cat1, val1], [cat2, val2]) => {
fc.pre(cat1 !== cat2);
const order = normOrder(sortOpts.valueCategoryOrder!);
expect(Math.sign(byNumber(sortOpts)(val1, val2))).equals(
Math.sign(order.indexOf(cat1) - order.indexOf(cat2))
);
}
)
);
});

describe("ByNumber sorting with infinite values", () => {
it("Does sort an array by number", () => {
const arrayUnsortedWithInfinity = [44, 12, 34, 124, 21.5, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
const correctArraySortedWithInfinity = [Number.NEGATIVE_INFINITY, 12, 21.5, 34, 44, 124, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];

const arraySorted = arrayUnsortedWithInfinity.sort(byNumber());

const [first, last] = getFirstAndLast(arraySorted);

expect(first).to.equal(Number.NEGATIVE_INFINITY);

expect(last).to.equal(Number.POSITIVE_INFINITY);

expectObjectToBeEquals(arraySorted, correctArraySortedWithInfinity);
it("sorts ordinary numbers correctly", () => {
fc.assert(
fc.property(
someSortByNumberOption(),
fc.double({ noNaN: true }),
fc.double({ noNaN: true }),
(sortOpts, val1, val2) => {
const sign = sortOpts.desc ? -1 : 1;
return (
Math.sign(byNumber(sortOpts)(val1, val2)) ===
sign * Math.sign(val1 - val2)
);
}
)
);
});
});
6 changes: 3 additions & 3 deletions test/utils/sort.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
const getFirstAndLast = <T>(array: T[]): [T, T] => {
const {0: first, [array.length - 1]: last} = array;
return [first, last]
const { 0: first, [array.length - 1]: last } = array;
return [first, last];
};

const reverse = <T>(array: T[]): T[] => array.concat().reverse();

export {getFirstAndLast, reverse}
export { getFirstAndLast, reverse };
Loading