Skip to content

Commit da233cf

Browse files
authored
refactor: reduce exported logic in luxon-utils (#30)
1 parent f211c10 commit da233cf

File tree

3 files changed

+71
-141
lines changed

3 files changed

+71
-141
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,29 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
exports[`getIndexCollisions > should return collisions from 3 overlapping pairs with one non-overlapping interval between them 1`] = `
4-
[
5-
[
6-
0,
7-
2,
8-
],
9-
[
10-
3,
11-
5,
12-
],
13-
[
14-
6,
15-
8,
16-
],
17-
]
18-
`;
19-
20-
exports[`getIndexCollisions > should return collisions from 3-tuple of equal intervals 1`] = `
21-
[
22-
[
23-
0,
24-
3,
25-
],
26-
]
27-
`;
28-
29-
exports[`getIndexCollisions > should return collisions from 3-tuple of overlapping intervals with the same end time 1`] = `
30-
[
31-
[
32-
0,
33-
3,
34-
],
35-
]
36-
`;
37-
38-
exports[`getIndexCollisions > should return collisions from 3-tuple of overlapping intervals with the same start time 1`] = `
39-
[
40-
[
41-
0,
42-
3,
43-
],
44-
]
45-
`;
46-
47-
exports[`getIndexCollisions > should return collisions from 3-tuple overlapping intervals after a non-overlapping interval 1`] = `
48-
[
49-
[
50-
1,
51-
4,
52-
],
53-
]
54-
`;
55-
56-
exports[`getIndexCollisions > should return collisions from 3-tuple overlapping intervals before a non-overlapping interval 1`] = `
57-
[
58-
[
59-
0,
60-
3,
61-
],
62-
]
63-
`;
64-
65-
exports[`getIndexCollisions > should return collisions from pair of overlapping intervals 1`] = `
66-
[
67-
[
68-
0,
69-
2,
70-
],
71-
]
72-
`;
73-
74-
exports[`newInvalidError > with message="user-provided message" > should accept 'DateTime' 1`] = `
3+
exports[`assertLuxonValidity > with message="user-provided message" > should reject invalid 'DateTime' 1`] = `
754
[Error: user-provided message
76-
InvalidDateTime: user-provided reason. user-provided explanation]
5+
user-provided reason. user-provided explanation]
776
`;
787

79-
exports[`newInvalidError > with message="user-provided message" > should accept 'Duration' 1`] = `
8+
exports[`assertLuxonValidity > with message="user-provided message" > should reject invalid 'Duration' 1`] = `
809
[Error: user-provided message
81-
InvalidDuration: user-provided reason. user-provided explanation]
10+
user-provided reason. user-provided explanation]
8211
`;
8312

84-
exports[`newInvalidError > with message="user-provided message" > should accept 'Interval' 1`] = `
13+
exports[`assertLuxonValidity > with message="user-provided message" > should reject invalid 'Interval' 1`] = `
8514
[Error: user-provided message
86-
InvalidInterval: user-provided reason. user-provided explanation]
15+
user-provided reason. user-provided explanation]
8716
`;
8817

89-
exports[`newInvalidError > with message="user-provided message" > should accept undefined 1`] = `
18+
exports[`assertLuxonValidity > with message="user-provided message" > should reject undefined 1`] = `
9019
[Error: user-provided message
91-
unspecified error]
20+
undefined luxon value]
9221
`;
9322

94-
exports[`newInvalidError > with message=undefined > should accept 'DateTime' 1`] = `[Error: InvalidDateTime: user-provided reason. user-provided explanation]`;
23+
exports[`assertLuxonValidity > with message=undefined > should reject invalid 'DateTime' 1`] = `[Error: user-provided reason. user-provided explanation]`;
9524

96-
exports[`newInvalidError > with message=undefined > should accept 'Duration' 1`] = `[Error: InvalidDuration: user-provided reason. user-provided explanation]`;
25+
exports[`assertLuxonValidity > with message=undefined > should reject invalid 'Duration' 1`] = `[Error: user-provided reason. user-provided explanation]`;
9726

98-
exports[`newInvalidError > with message=undefined > should accept 'Interval' 1`] = `[Error: InvalidInterval: user-provided reason. user-provided explanation]`;
27+
exports[`assertLuxonValidity > with message=undefined > should reject invalid 'Interval' 1`] = `[Error: user-provided reason. user-provided explanation]`;
9928

100-
exports[`newInvalidError > with message=undefined > should accept undefined 1`] = `[Error: unspecified error]`;
29+
exports[`assertLuxonValidity > with message=undefined > should reject undefined 1`] = `[Error: undefined luxon value]`;

src/util/__tests__/luxon-utils.ts

+21-27
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,44 @@
11
import { entriesIn } from "lodash";
2-
import { DateTime, Duration, Interval } from "luxon";
2+
import { DateTime, DateTimeMaybeValid, Duration, DurationMaybeValid, Interval, IntervalMaybeValid } from "luxon";
33
import { describe, expect, it } from "vitest";
44

5-
import { assertNoOverlaps, getIndexCollisions, newInvalidError } from "../luxon-utils";
5+
import { assertIntervalsDoNotIntersect, assertLuxonValidity } from "../luxon-utils";
66
import { WITH_OVERLAPPING_INTERVALS, WITHOUT_OVERLAPPING_INTERVALS } from "./luxon-utils.const";
77

8-
describe(newInvalidError.name, () => {
8+
describe(assertLuxonValidity.name, () => {
99
const reason = "user-provided reason";
1010
const explanation = "user-provided explanation";
1111

12-
const _throw = (e: Error) => {
13-
throw e;
14-
};
15-
1612
describe.each(["user-provided message", undefined])("with message=%j", (message) => {
1713
it.each([
18-
DateTime.invalid(reason, explanation),
19-
Duration.invalid(reason, explanation),
20-
Interval.invalid(reason, explanation),
21-
])("should accept $constructor.name", (invalidResult) => {
22-
expect(() => _throw(newInvalidError(invalidResult, message))).toThrowErrorMatchingSnapshot();
14+
DateTime.fromISO("2025-03-05"),
15+
Duration.fromDurationLike({ days: 1 }),
16+
Interval.after(DateTime.now(), { days: 1 }),
17+
])("should accept valid $constructor.name", (value) => {
18+
expect(() => assertLuxonValidity(value, message)).not.toThrow();
2319
});
2420

25-
it("should accept undefined", () => {
26-
expect(() => _throw(newInvalidError(undefined, message))).toThrowErrorMatchingSnapshot();
21+
it.each([
22+
DateTime.invalid(reason, explanation) as DateTimeMaybeValid,
23+
Duration.invalid(reason, explanation) as DurationMaybeValid,
24+
Interval.invalid(reason, explanation) as IntervalMaybeValid,
25+
])("should reject invalid $constructor.name", (value) => {
26+
expect(() => assertLuxonValidity(value, message)).toThrowErrorMatchingSnapshot();
27+
});
28+
29+
it("should reject undefined", () => {
30+
expect(() => assertLuxonValidity(undefined, message)).toThrowErrorMatchingSnapshot();
2731
});
2832
});
2933
});
3034

31-
describe(assertNoOverlaps.name, () => {
35+
describe(assertIntervalsDoNotIntersect.name, () => {
3236
it.each(entriesIn(WITHOUT_OVERLAPPING_INTERVALS))("should accept %j", (_, intervals) => {
33-
expect(() => assertNoOverlaps(intervals)).not.toThrow();
37+
expect(() => assertIntervalsDoNotIntersect(intervals)).not.toThrow();
3438
});
3539

3640
it.each(entriesIn(WITH_OVERLAPPING_INTERVALS))("should reject %j", (_, intervals) => {
3741
// TODO: Want to use toThrowErrorMatchingSnapshot(), but the snapshot fails on CI due to different paths in the stack traces.
38-
expect(() => assertNoOverlaps(intervals)).toThrowError();
39-
});
40-
});
41-
42-
describe(getIndexCollisions.name, () => {
43-
it.each(entriesIn(WITHOUT_OVERLAPPING_INTERVALS))("should return nothing from %s", (_, intervals) => {
44-
expect(getIndexCollisions(intervals)).toEqual([]);
45-
});
46-
47-
it.each(entriesIn(WITH_OVERLAPPING_INTERVALS))("should return collisions from %s", (_, intervals) => {
48-
expect(getIndexCollisions(intervals)).toMatchSnapshot();
42+
expect(() => assertIntervalsDoNotIntersect(intervals)).toThrowError();
4943
});
5044
});

src/util/luxon-utils.ts

+38-31
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,50 @@
11
import AggregateError from "aggregate-error";
2-
import { isString, sortBy } from "lodash";
2+
import { sortBy } from "lodash";
33
import { DateTime, Duration, Interval } from "luxon";
44

5+
/** Union of luxon types that can be checked for validity. */
6+
export type LuxonValue<IsValid extends boolean> = DateTime<IsValid> | Duration<IsValid> | Interval<IsValid>;
7+
8+
/**
9+
* @param value - the {@link LuxonValue} to check.
10+
* @param message - the message to use in the error.
11+
* @throws error if value is invalid.
12+
*/
13+
export function assertLuxonValidity(
14+
value?: LuxonValue<true> | LuxonValue<false>,
15+
message?: string,
16+
): asserts value is LuxonValue<true> {
17+
const lines = message ? [message] : [];
18+
if (!value) {
19+
lines.push("undefined luxon value");
20+
} else if (!value.isValid) {
21+
lines.push(`${value.invalidReason}. ${value.invalidExplanation}`);
22+
} else {
23+
return;
24+
}
25+
throw new Error(lines.join("\n"));
26+
}
27+
528
/**
6-
* @param obj - the invalid luxon object.
7-
* @param message - optional message included with the error.
8-
* @returns error with debug information extracted from the "invalid" input.
29+
* @param unsorted - the {@link Interval}s to check.
30+
* @throws error if any of the intervals intersect with each other.
931
*/
10-
export function newInvalidError(obj?: DateTime<false> | Duration<false> | Interval<false>, message?: string): Error {
11-
const lines = isString(message) ? [message] : [];
12-
lines.push(
13-
obj ?
14-
`Invalid${obj.constructor.name}: ${obj.invalidReason}. ${obj.invalidExplanation}`.trim()
15-
: `unspecified error`,
16-
);
17-
return new Error(lines.join("\n"));
32+
export function assertIntervalsDoNotIntersect(unsorted: readonly Interval<true>[]): void {
33+
const sorted = sortBy(unsorted, "start", "end");
34+
const errors = getIntersectionsFromSortedIntervals(sorted).map(([lo, hi]) => {
35+
return `overlapping intervals within index range [${lo}, ${hi}) of ${JSON.stringify(sorted)}`;
36+
});
37+
38+
if (errors.length > 0) {
39+
throw new AggregateError(errors);
40+
}
1841
}
1942

2043
/**
21-
* @param sorted - an array of intervals sorted by start time (primary) and end time (secondary).
22-
* @returns [index inclusive, index exclusive) pairs for slices in the array with overlapping intervals.
44+
* @param sorted - an array of sorted intervals by start time (primary) and end time (secondary).
45+
* @returns [index inclusive, index exclusive) pairs representing slices of the array that intersect with each other.
2346
*/
24-
export function getIndexCollisions(sorted: readonly Interval<true>[]): [number, number][] {
47+
function getIntersectionsFromSortedIntervals(sorted: readonly Interval<true>[]): [number, number][] {
2548
const collisions: [number, number][] = [];
2649
let startIncl = 0;
2750

@@ -41,19 +64,3 @@ export function getIndexCollisions(sorted: readonly Interval<true>[]): [number,
4164

4265
return collisions;
4366
}
44-
45-
/**
46-
* Asserts that none of the intervals are overlapping.
47-
* @param unsorted - the array of intervals to check.
48-
* @throws error if the array has overlapping intervals.
49-
*/
50-
export function assertNoOverlaps(unsorted: readonly Interval<true>[]): void {
51-
const sorted = sortBy(unsorted, "start", "end");
52-
const errors = getIndexCollisions(sorted).map(([lo, hi]) => {
53-
return `unexpected overlapping intervals in the index range [${lo}, ${hi}) of ${JSON.stringify(sorted)}`;
54-
});
55-
56-
if (errors.length > 0) {
57-
throw new AggregateError(errors);
58-
}
59-
}

0 commit comments

Comments
 (0)