diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4f39eb2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug tests", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/node_modules/.bin/mocha", + "runtimeArgs": ["--require", "ts-node/register"], + "args": ["${relativeFile}"], + "outFiles": [ + "${workspaceFolder}/src/**/*.js", + "${workspaceFolder}/test/**/*.js" + ] + } + ] +} diff --git a/package.json b/package.json index 0fef426..a4d1765 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,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", diff --git a/src/index.ts b/src/index.ts index d9ef4ca..f0fca43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import byString from "./sortables/byString"; import byNumber from "./sortables/byNumber"; import byBoolean from "./sortables/byBoolean"; import sortAsync, { AsyncArray } from "./sortables/byAsyncValue"; +import lexicographically from "./sortables/lexicographically"; export default { byAny, @@ -17,6 +18,7 @@ export default { sortAsync, byBoolean, AsyncArray, + lexicographically, }; export { @@ -29,4 +31,5 @@ export { sortAsync, byBoolean, AsyncArray, + lexicographically, }; diff --git a/src/sortables/lexicographically.ts b/src/sortables/lexicographically.ts new file mode 100644 index 0000000..ab65dad --- /dev/null +++ b/src/sortables/lexicographically.ts @@ -0,0 +1,44 @@ +import { SortOption } from "src/interfaces/interfaces"; +import { sortable } from "../types/types"; + +/** + * the sortable to sort **sequences** lexicographically + * + * The values `null` and `undefined` are treated as empty sequences. + * + * @param sortFn the sortable to be applied corresponding items in each sequence + * @param options the option to control whether the sort is descending + */ +function lexicographically( + sortFn: sortable, + options: Omit = {} +): sortable> { + const { desc = false } = options; + const sign = desc ? -1 : 1; + return ( + left: Iterable | null | undefined, + right: Iterable | null | undefined + ): number => { + const leftIter = (left || [])[Symbol.iterator](); + const rightIter = (right || [])[Symbol.iterator](); + for (;;) { + const { done: leftDone, value: leftValue } = leftIter.next(); + const { done: rightDone, value: rightValue } = rightIter.next(); + if (leftDone && rightDone) { + return 0; + } + if (leftDone) { + return -sign; + } + if (rightDone) { + return sign; + } + const v = sortFn(leftValue, rightValue); + if (v !== 0) { + return sign * v; + } + } + }; +} + +export default lexicographically; diff --git a/test/lexicographically.spec.ts b/test/lexicographically.spec.ts new file mode 100644 index 0000000..1c2a2e0 --- /dev/null +++ b/test/lexicographically.spec.ts @@ -0,0 +1,98 @@ +import "mocha"; +import fc from "fast-check"; +import { lexicographically } from "../src"; +import { sortable } from "../src/types/types"; +import { expect } from "chai"; +import { descToSign } from "./utils/sort"; + +function comparators(): fc.Arbitrary<{ + sign: number; + itemCmp: sortable; + seqCmp: sortable; +}> { + return fc.tuple(fc.compareFunc(), fc.boolean()).chain(([itemCmp, desc]) => { + return fc.constant({ + sign: descToSign(desc), + itemCmp, + seqCmp: lexicographically(itemCmp, { desc }), + }); + }); +} + +describe("sorting lexiocgraphically", () => { + it("treats identical sequences as equal", () => { + fc.assert( + fc.property(comparators(), fc.array(fc.integer()), ({ seqCmp }, seq) => { + expect(seqCmp(seq, [...seq])).to.equal(0); + }) + ); + }); + + it("treats treats null or undefined as an empty sequence", () => { + fc.assert( + fc.property( + fc.oneof(fc.constantFrom(null, undefined, [])), + fc.oneof(fc.constantFrom(null, undefined, [])), + fc.array(fc.integer(), { minLength: 1 }), + comparators(), + (empty1, empty2, nonempty, { seqCmp, sign }) => { + expect(seqCmp(empty1, nonempty) * sign).to.be.below(0); + expect(seqCmp(nonempty, empty1) * sign).to.be.above(0); + expect(seqCmp(empty1, empty2)).to.equal(0); + } + ) + ); + }); + + it("sorts a shorter sequence before a longer sequence", () => { + fc.assert( + fc.property( + fc.array(fc.integer()), + fc.integer(), + comparators(), + (prefix, item, { seqCmp, sign }) => { + expect(seqCmp(prefix, [...prefix, item]) * sign).to.be.below(0); + expect(seqCmp([...prefix, item], prefix) * sign).to.be.above(0); + } + ) + ); + }); + + it("sorts sequences like their first differing element", () => { + fc.assert( + fc.property( + fc.array(fc.integer()), + fc.integer(), + fc.integer(), + fc.array(fc.integer()), + fc.array(fc.integer()), + comparators(), + (prefix, item1, item2, suffix1, suffix2, { seqCmp, itemCmp, sign }) => { + fc.pre(itemCmp(item1, item2) !== 0); + expect( + seqCmp( + [...prefix, item1, ...suffix1], + [...prefix, item2, ...suffix2] + ) * sign + ).to.equal(itemCmp(item1, item2)); + } + ) + ); + }); + + it("sorts sequences with a common prefix like their tails", () => { + fc.assert( + fc.property( + fc.array(fc.integer()), + fc.array(fc.integer()), + fc.array(fc.integer()), + comparators(), + (prefix, tail1, tail2, { seqCmp }) => { + expect( + seqCmp([...prefix, ...tail1], [...prefix, ...tail2]) + ).to.be.equal(seqCmp(tail1, tail2)); + } + ) + ); + }); +}); diff --git a/test/utils/sort.ts b/test/utils/sort.ts index 665099a..3aea0cc 100644 --- a/test/utils/sort.ts +++ b/test/utils/sort.ts @@ -5,4 +5,6 @@ const getFirstAndLast = (array: T[]): [T, T] => { const reverse = (array: T[]): T[] => array.concat().reverse(); -export {getFirstAndLast, reverse} +const descToSign = (desc: boolean): number => desc ? -1 : 1; + +export {getFirstAndLast, reverse, descToSign} diff --git a/yarn.lock b/yarn.lock index b209cba..60e2b35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1859,6 +1859,13 @@ execa@^7.1.1: signal-exit "^3.0.7" strip-final-newline "^3.0.0" +fast-check@^3.13.1: + version "3.13.1" + resolved "https://domoreexp.pkgs.visualstudio.com/_packaging/npm-mirror/npm/registry/fast-check/-/fast-check-3.13.1.tgz#32c7a78621098bd30d71e40c0e269a728a3188e2" + integrity sha1-MsenhiEJi9MNceQMDiaacooxiOI= + dependencies: + pure-rand "^6.0.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3436,6 +3443,11 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== +pure-rand@^6.0.0: + version "6.0.4" + resolved "https://domoreexp.pkgs.visualstudio.com/_packaging/npm-mirror/npm/registry/pure-rand/-/pure-rand-6.0.4.tgz#50b737f6a925468679bff00ad20eade53f37d5c7" + integrity sha1-ULc39qklRoZ5v/AK0g6t5T831cc= + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"