Repository: harrisiirak/cron-parser (ts)

Issue:
includesDate doesn't properly account for special character expressions
I think the includesDate method may have a bug as it doesnt seems to support nth day expressions

```
const laborDayExpr = '* * * * SEP MON#1'
const interval = CronExpressionParser.parse(laborDayExpr, { strict: true })

const firstMonday = new Date('2025-09-01T23:00:00.000Z') // first monday in september
expect(interval.includesDate(firstMonday)).toBeTrue() ---> pass

const secondMonday = new Date('2025-09-08T23:00:00.000Z') // second monday in september
expect(interval.includesDate(secondMonday)).toBeFalse()  ----> fails since includesDate returns true
```

Similarly, for `L`

```
const memorialDayExpr = '* * * * MAY MONL'
const interval = CronExpressionParser.parse(memorialDayExpr, { strict: true })

const lastMonday = new Date('2025-05-05T23:00:00.000Z') // last monday in may
expect(interval.includesDate(lastMonday)).toBeTrue() ---> pass

const firstMonday = new Date('2025-05-26T23:00:00.000Z') // first monday in may
expect(interval.includesDate(firstMonday)).toBeFalse() ----> fails since includesDate returns true
```

This is because the parser doesn't seem to apply the special characters to the `values`, and the `includesDate` method checks the `values` for a match.

Pull request:
Improve handling and support for special characters (L, #) in the includesDate method
This MR includes a couple of changes that improve overall performance when using the last-day-of-month/week syntax and adds support for the `L` and `#` characters in the `includesDate` method.

List of changes:

- 63f3bc6ad2dc8ce44e0d7f9ef50fdf4eb6112f88 - Add `L` and `#` characters in the `includesDate` method.
- ab983f5ed2dfabf3d22be7492b663d0916215f1c - `#` caching during `CronField` initialization
 e3d64587d2725c8c2f000fac9de615fbddd905de - Refactor `CronDate.isLastDayOfMonth` and `CronDate.isLastWeekdayOfMonth` methods to use more optimized approach 

Closes #372

Patch:
diff --git a/coverage/badge-branches.svg b/coverage/badge-branches.svg
index 5bb55be..1b3eb31 100644
--- a/coverage/badge-branches.svg
+++ b/coverage/badge-branches.svg
@@ -1,1 +1,1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
\ No newline at end of file
diff --git a/coverage/badge-functions.svg b/coverage/badge-functions.svg
index 5bb55be..1b3eb31 100644
--- a/coverage/badge-functions.svg
+++ b/coverage/badge-functions.svg
@@ -1,1 +1,1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
\ No newline at end of file
diff --git a/coverage/badge-lines.svg b/coverage/badge-lines.svg
index 5bb55be..1b3eb31 100644
--- a/coverage/badge-lines.svg
+++ b/coverage/badge-lines.svg
@@ -1,1 +1,1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
\ No newline at end of file
diff --git a/coverage/badge-statements.svg b/coverage/badge-statements.svg
index 5bb55be..1b3eb31 100644
--- a/coverage/badge-statements.svg
+++ b/coverage/badge-statements.svg
@@ -1,1 +1,1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
\ No newline at end of file
diff --git a/src/CronDate.ts b/src/CronDate.ts
index ee12e53..2d5af91 100644
--- a/src/CronDate.ts
+++ b/src/CronDate.ts
@@ -18,6 +18,8 @@ type VerbMap = {
   [key in TimeUnit]: () => void;
 };
 
+export const DAYS_IN_MONTH: readonly number[] = Object.freeze([31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]);
+
 /**
  * CronDate class that wraps the Luxon DateTime object to provide
  * a consistent API for working with dates and times in the context of cron.
@@ -86,6 +88,16 @@ export class CronDate {
     }
   }
 
+  /**
+   * Determines if the given year is a leap year.
+   * @param {number} year - The year to check
+   * @returns {boolean} - True if the year is a leap year, false otherwise
+   * @private
+   */
+  static #isLeapYear(year: number): boolean {
+    return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+  }
+
   /**
    * Returns daylight savings start time.
    * @returns {number | null}
@@ -472,8 +484,16 @@ export class CronDate {
    * @returns {boolean}
    */
   isLastDayOfMonth(): boolean {
-    const newDate = this.#date.plus({ days: 1 }).startOf('day');
-    return this.#date.month !== newDate.month;
+    const { day, month } = this.#date;
+
+    // Special handling for February in leap years
+    if (month === 2) {
+      const isLeap = CronDate.#isLeapYear(this.#date.year);
+      return day === DAYS_IN_MONTH[month - 1] - (isLeap ? 0 : 1);
+    }
+
+    // For other months, check against the static map
+    return day === DAYS_IN_MONTH[month - 1];
   }
 
   /**
@@ -481,8 +501,19 @@ export class CronDate {
    * @returns {boolean}
    */
   isLastWeekdayOfMonth(): boolean {
-    const newDate = this.#date.plus({ days: 7 }).startOf('day');
-    return this.#date.month !== newDate.month;
+    const { day, month } = this.#date;
+
+    // Get the last day of the current month
+    let lastDay: number;
+    if (month === 2) {
+      // Special handling for February
+      lastDay = DAYS_IN_MONTH[month - 1] - (CronDate.#isLeapYear(this.#date.year) ? 0 : 1);
+    } else {
+      lastDay = DAYS_IN_MONTH[month - 1];
+    }
+
+    // Check if the current day is within 7 days of the end of the month
+    return day > lastDay - 7;
   }
 
   /**
diff --git a/src/CronExpression.ts b/src/CronExpression.ts
index 85235fa..a422b4c 100644
--- a/src/CronExpression.ts
+++ b/src/CronExpression.ts
@@ -1,8 +1,6 @@
-import { DateTime } from 'luxon';
-
 import { CronDate, DateMathOp, TimeUnit } from './CronDate';
 import { CronFieldCollection } from './CronFieldCollection';
-import { CronFieldType, DayOfMonthRange, DayOfWeekRange, HourRange, MonthRange, SixtyRange } from './fields';
+import { CronFieldType, HourRange, MonthRange, SixtyRange } from './fields';
 
 export type CronExpressionOptions = {
   currentDate?: Date | string | number | CronDate;
@@ -81,23 +79,6 @@ export class CronExpression {
     return sequence.some((element) => element === value);
   }
 
-  /**
-   * Checks if the 'L' character is present in any of the given expressions.
-   *
-   * @param {Array} expressions - An array of expressions to be checked.
-   * @returns {boolean} - True if the 'L' character is present in any of the expressions; otherwise, false.
-   * @memberof CronExpression
-   * @private
-   */
-  static #isLInExpressions(expressions: (number | string)[]): boolean {
-    return (
-      expressions.length > 0 &&
-      expressions.some((expression: number | string) => {
-        return typeof expression === 'string' && expression.indexOf('L') >= 0;
-      })
-    );
-  }
-
   /**
    * Determines if the current date matches the last specified weekday of the month.
    *
@@ -108,12 +89,8 @@ export class CronExpression {
    * @private
    */
   static #isLastWeekdayOfMonthMatch(expressions: (number | string)[], currentDate: CronDate): boolean {
+    const isLastWeekdayOfMonth = currentDate.isLastWeekdayOfMonth();
     return expressions.some((expression: number | string) => {
-      // There might be multiple expressions and not all of them will contain the "L".
-      if (!CronExpression.#isLInExpressions([expression])) {
-        return false;
-      }
-
       // The first character represents the weekday
       const weekday = parseInt(expression.toString().charAt(0), 10) % 7;
       if (Number.isNaN(weekday)) {
@@ -121,7 +98,7 @@ export class CronExpression {
       }
 
       // Check if the current date matches the last specified weekday of the month
-      return currentDate.getDay() === weekday && currentDate.isLastWeekdayOfMonth();
+      return currentDate.getDay() === weekday && isLastWeekdayOfMonth;
     });
   }
 
@@ -239,16 +216,32 @@ export class CronExpression {
    * @returns {boolean}
    */
   includesDate(date: Date | CronDate): boolean {
-    const { second, minute, hour, dayOfMonth, month, dayOfWeek } = this.#fields;
-    const dt = DateTime.fromISO(date.toISOString()!, { zone: this.#tz });
-    return (
-      dayOfMonth.values.includes(<DayOfMonthRange>dt.day) &&
-      dayOfWeek.values.includes(<DayOfWeekRange>dt.weekday) &&
-      month.values.includes(<MonthRange>dt.month) &&
-      hour.values.includes(<HourRange>dt.hour) &&
-      minute.values.includes(<SixtyRange>dt.minute) &&
-      second.values.includes(<SixtyRange>dt.second)
-    );
+    const { second, minute, hour, month } = this.#fields;
+    const dt = new CronDate(date, this.#tz);
+
+    // Check basic time fields first
+    if (
+      !second.values.includes(<SixtyRange>dt.getSeconds()) ||
+      !minute.values.includes(<SixtyRange>dt.getMinutes()) ||
+      !hour.values.includes(<HourRange>dt.getHours()) ||
+      !month.values.includes(<MonthRange>(dt.getMonth() + 1))
+    ) {
+      return false;
+    }
+
+    // Check day of month and day of week using the same logic as #findSchedule
+    if (!this.#matchDayOfMonth(dt)) {
+      return false;
+    }
+
+    // Check nth day of week if specified
+    if (this.#nthDayOfWeek > 0) {
+      const weekInMonth = Math.ceil(dt.getDate() / 7);
+      if (weekInMonth !== this.#nthDayOfWeek) {
+        return false;
+      }
+    }
+    return true;
   }
 
   /**
@@ -284,10 +277,10 @@ export class CronExpression {
     // Calculate if the current date matches the day of month and day of week fields.
     const matchedDOM =
       CronExpression.#matchSchedule(currentDate.getDate(), this.#fields.dayOfMonth.values) ||
-      (CronExpression.#isLInExpressions(this.#fields.dayOfMonth.values) && currentDate.isLastDayOfMonth());
+      (this.#fields.dayOfMonth.hasLastChar && currentDate.isLastDayOfMonth());
     const matchedDOW =
       CronExpression.#matchSchedule(currentDate.getDay(), this.#fields.dayOfWeek.values) ||
-      (CronExpression.#isLInExpressions(this.#fields.dayOfWeek.values) &&
+      (this.#fields.dayOfWeek.hasLastChar &&
         CronExpression.#isLastWeekdayOfMonthMatch(this.#fields.dayOfWeek.values, currentDate));
 
     // Rule 1: Both "day of month" and "day of week" are restricted; one or both must match the current day.
diff --git a/src/fields/CronField.ts b/src/fields/CronField.ts
index db49cf7..90fd5a6 100644
--- a/src/fields/CronField.ts
+++ b/src/fields/CronField.ts
@@ -11,6 +11,7 @@ export type SerializedCronField = {
  * @class CronField
  */
 export abstract class CronField {
+  readonly #hasLastChar: boolean = false;
   readonly #wildcard: boolean = false;
   readonly #values: (number | string)[] = [];
 
@@ -71,6 +72,9 @@ export abstract class CronField {
     }
     this.#values = values.sort(CronField.sorter);
     this.#wildcard = wildcard;
+    this.#hasLastChar = values.some((expression: number | string) => {
+      return typeof expression === 'string' && expression.indexOf('L') >= 0;
+    });
   }
 
   /**
@@ -100,6 +104,14 @@ export abstract class CronField {
     return (this.constructor as typeof CronField).chars;
   }
 
+  /**
+   * Indicates whether this field has a "last" character.
+   * @returns {boolean}
+   */
+  get hasLastChar(): boolean {
+    return this.#hasLastChar;
+  }
+
   /**
    * Indicates whether this field is a wildcard.
    * @returns {boolean}
diff --git a/src/fields/CronMonth.ts b/src/fields/CronMonth.ts
index d858536..75c2460 100644
--- a/src/fields/CronMonth.ts
+++ b/src/fields/CronMonth.ts
@@ -1,3 +1,4 @@
+import { DAYS_IN_MONTH } from '../CronDate';
 import { CronField } from './CronField';
 import { CronChars, CronMax, CronMin, MonthRange } from './types';
 
@@ -5,8 +6,6 @@ const MIN_MONTH = 1;
 const MAX_MONTH = 12;
 const MONTH_CHARS: readonly CronChars[] = Object.freeze([]);
 
-const DAYS_IN_MONTH: readonly number[] = Object.freeze([31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]);
-
 /**
  * Represents the "day of the month" field within a cron expression.
  * @class CronDayOfMonth


Test patch:
diff --git a/tests/CronDate.test.ts b/tests/CronDate.test.ts
index 953ca6e..fd3fea0 100644
--- a/tests/CronDate.test.ts
+++ b/tests/CronDate.test.ts
@@ -4,12 +4,16 @@ import { DateTime } from 'luxon';
 describe('CronDate', () => {
   test('is the last weekday of the month', () => {
     // Last monday of september
-    const date1 = new CronDate(new Date(2021, 8, 27));
-    expect(date1.isLastWeekdayOfMonth()).toBe(true);
+    expect(new CronDate(new Date(2021, 8, 27)).isLastWeekdayOfMonth()).toBeTruthy();
 
     // Second-to-last monday of september
-    const date2 = new CronDate(new Date(2021, 8, 20));
-    expect(date2.isLastWeekdayOfMonth()).toBe(false);
+    expect(new CronDate(new Date(2021, 8, 20)).isLastWeekdayOfMonth()).toBeFalsy();
+
+    // Last friday of leap year february
+    expect(new CronDate(new Date(2020, 1, 28)).isLastWeekdayOfMonth()).toBeTruthy();
+
+    // Last friday of non-leap year february
+    expect(new CronDate(new Date(2021, 1, 26)).isLastWeekdayOfMonth()).toBeTruthy();
   });
 
   test('addSecond should succeed', () => {
diff --git a/tests/CronExpression.test.ts b/tests/CronExpression.test.ts
index 82b4672..1e6165e 100644
--- a/tests/CronExpression.test.ts
+++ b/tests/CronExpression.test.ts
@@ -73,12 +73,6 @@ describe('CronExpression', () => {
     expect(nextDate).toBe('2023-01-08T00:00:00.000Z');
   });
 
-  test('should check if the cron expression includes a given date', () => {
-    const cronExpression = new CronExpression(fields, options);
-    const date = new Date('2023-01-01T00:00:00.000Z');
-    expect(cronExpression.includesDate(date)).toBe(true);
-  });
-
   test('should return the string representation of the cron expression', () => {
     const cronExpression = new CronExpression(fields, options);
     expect(cronExpression.toString()).toBe('0 0 0 1 1 0');
@@ -365,4 +359,34 @@ describe('CronExpression', () => {
       );
     });
   });
+
+  describe('includesDate', () => {
+    test('should check if the cron expression includes a given date', () => {
+      const cronExpression = new CronExpression(fields, options);
+      expect(cronExpression.includesDate(new Date('2023-01-01T00:00:00.000Z'))).toBeTruthy();
+      expect(cronExpression.includesDate(new Date('2024-01-01T00:00:00.000Z'))).toBeTruthy();
+      expect(cronExpression.includesDate(new Date('2024-02-01T00:00:00.000Z'))).toBeFalsy();
+    });
+
+    test('should support last weekday of month', () => {
+      const cronExpression = CronExpressionParser.parse('0 0 0 * * 1L', {
+        currentDate: new Date('2023-01-01T00:00:00Z'),
+      });
+
+      expect(cronExpression.includesDate(new Date('2023-01-30T00:00:00Z'))).toBeTruthy();
+      expect(cronExpression.includesDate(new Date('2023-01-23T00:00:00Z'))).toBeFalsy();
+      expect(cronExpression.includesDate(new Date('2023-01-31T00:00:00Z'))).toBeFalsy();
+    });
+
+    test('should support nth day of week', () => {
+      const cronExpression = CronExpressionParser.parse('0 0 0 * * 1#3', {
+        currentDate: new Date('2023-01-01T00:00:00Z'),
+      });
+
+      expect(cronExpression.includesDate(new Date('2023-01-02T00:00:00Z'))).toBeFalsy();
+      expect(cronExpression.includesDate(new Date('2023-01-09T00:00:00Z'))).toBeFalsy();
+      expect(cronExpression.includesDate(new Date('2023-01-16T00:00:00Z'))).toBeTruthy();
+      expect(cronExpression.includesDate(new Date('2023-01-23T00:00:00Z'))).toBeFalsy();
+    });
+  });
 });