diff --git a/cypress/e2e/cps-scheduler.cy.ts b/cypress/e2e/cps-scheduler.cy.ts
new file mode 100644
index 00000000..927bfc66
--- /dev/null
+++ b/cypress/e2e/cps-scheduler.cy.ts
@@ -0,0 +1,279 @@
+describe('CPS Scheduler Component', () => {
+ beforeEach(() => {
+ cy.visit('/scheduler/examples');
+ cy.get('cps-scheduler').should('be.visible');
+ });
+
+ /**
+ * Dropdown selection helper
+ */
+ const selectDropdownOption = (selector: string, optionText: string): void => {
+ // Click the dropdown to open it
+ cy.get(selector).click();
+
+ // Try to find and click the option with the specified text
+ // Use a more generic approach that waits for any clickable element containing the text
+ cy.get('body').contains(optionText).should('be.visible').click();
+ };
+
+ describe('Core Functionality', () => {
+ it('should display scheduler with proper initialization', () => {
+ cy.get('[data-cy="schedule-type-toggle"]').should('be.visible');
+ cy.get('[data-cy="schedule-type-toggle"]').should(
+ 'contain.text',
+ 'Not set'
+ );
+ });
+ });
+
+ describe('Minutes Schedule - Cron Generation', () => {
+ beforeEach(() => {
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Minutes').click();
+ cy.get('[data-cy="minutes-config"]').should('be.visible');
+ });
+
+ it('should generate cron expression for minute intervals', () => {
+ selectDropdownOption('[data-cy="minutes-input"]', '5');
+
+ // Switch to Advanced to see generated cron
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input')
+ .should('have.value', '0/5 * 1/1 * ? *');
+ });
+
+ it('should generate cron expression for 15-minute intervals', () => {
+ selectDropdownOption('[data-cy="minutes-input"]', '15');
+
+ // Switch to Advanced to see generated cron
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input')
+ .should('have.value', '0/15 * 1/1 * ? *');
+ });
+ });
+
+ describe('Weekly Schedule - Cron Generation', () => {
+ beforeEach(() => {
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click();
+ cy.get('[data-cy="weekly-config"]').should('be.visible');
+ });
+
+ it('should generate correct cron for Monday and Wednesday', () => {
+ cy.get('[data-cy="weekly-MON"]').click();
+ cy.get('[data-cy="weekly-WED"]').click();
+
+ // Verify timezone selector appears
+ cy.get('[data-cy="timezone-selector"]').should('be.visible');
+
+ // Verify specific cron expression is generated
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input')
+ .should('have.value', '0 0 ? * MON,WED *');
+ });
+
+ it('should generate correct cron for Friday only', () => {
+ // First ensure Monday is unchecked
+ cy.get('[data-cy="weekly-MON"]').within(() => {
+ cy.get('input[type="checkbox"]').uncheck({ force: true });
+ });
+
+ // Check Friday
+ cy.get('[data-cy="weekly-FRI"]').within(() => {
+ cy.get('input[type="checkbox"]').check({ force: true });
+ });
+
+ // Verify timezone selector appears
+ cy.get('[data-cy="timezone-selector"]').should('be.visible');
+
+ // Verify specific cron expression is generated
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input')
+ .should('have.value', '0 0 ? * FRI *');
+ });
+ });
+
+ describe('Monthly Schedule - Cron Generation', () => {
+ beforeEach(() => {
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Monthly').click();
+ cy.get('[data-cy="monthly-config"]').should('be.visible');
+ });
+
+ it('should generate correct cron for specific weekday (Second Tuesday of every month)', () => {
+ // Select Second week
+ selectDropdownOption('[data-cy="monthly-week-select"]', 'Second');
+
+ // Select Tuesday
+ selectDropdownOption('[data-cy="monthly-weekday-select"]', 'Tuesday');
+
+ // Select starting month as April
+ selectDropdownOption(
+ '[data-cy="monthly-weekday-start-month-select"]',
+ 'April'
+ );
+
+ // Verify timezone selector appears
+ cy.get('[data-cy="timezone-selector"]').should('be.visible');
+
+ // Switch to Advanced to see generated cron
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input')
+ .should('contain.value', '30 9 ? 4/4 TUE#2 *');
+ });
+
+ it('should generate correct cron for specific weekday (Fourth Sunday starting in October)', () => {
+ // Select Fourth week
+ selectDropdownOption('[data-cy="monthly-week-select"]', 'Fourth');
+
+ // Select Sunday
+ selectDropdownOption('[data-cy="monthly-weekday-select"]', 'Sunday');
+
+ // Select starting month as October
+ selectDropdownOption(
+ '[data-cy="monthly-weekday-start-month-select"]',
+ 'October'
+ );
+
+ // Set custom time to 14:45 (2:45 PM)
+ cy.get('[data-cy="monthly-weekday-timepicker"]').within(() => {
+ cy.get('input').eq(1).clear();
+ cy.get('input').eq(1).type('45');
+ cy.get('input').first().clear();
+ cy.get('input').first().type('14');
+ });
+
+ // Switch to Advanced to see generated cron
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input')
+ .should('contain.value', '45 14 ? 10/4 SUN#4 *');
+ });
+ });
+
+ describe('Advanced Schedule - Direct Input', () => {
+ beforeEach(() => {
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+ cy.get('[data-cy="advanced-config"]').should('be.visible');
+ });
+
+ it('should accept valid cron expressions', () => {
+ const testCron = '0 30 14 ? * MON-FRI';
+
+ cy.get('[data-cy="advanced-cron-input"]').find('input, textarea').clear();
+
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input, textarea')
+ .type(testCron);
+
+ // Verify input contains the cron
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input, textarea')
+ .should('have.value', testCron);
+ });
+
+ it('should handle invalid cron expressions', () => {
+ cy.get('[data-cy="advanced-cron-input"]').find('input, textarea').clear();
+
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input, textarea')
+ .type('invalid cron');
+
+ // Component should not crash
+ cy.get('[data-cy="advanced-cron-input"]').should('be.visible');
+
+ // Check validation state
+ cy.get('.ng-invalid, .error').should('exist');
+ });
+ });
+
+ describe('State Management', () => {
+ it('should maintain state when switching between schedule types', () => {
+ // Set up Weekly schedule
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click();
+ cy.get('[data-cy="weekly-MON"]').click();
+
+ // Switch to Advanced to verify cron was generated
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+ cy.get('[data-cy="advanced-cron-input"]')
+ .find('input, textarea')
+ .should('not.have.value', '');
+
+ // Switch back to Weekly
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click();
+ cy.get('[data-cy="weekly-config"]').should('be.visible');
+ });
+
+ it('should reset when selecting Not set', () => {
+ // First set a schedule
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Weekly').click();
+ cy.get('[data-cy="weekly-MON"]').click();
+
+ // Reset to Not set
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Not set').click();
+
+ // Component should be in reset state
+ cy.get('cps-scheduler').should('be.visible');
+ cy.get('[data-cy="schedule-type-toggle"]').should(
+ 'contain.text',
+ 'Not set'
+ );
+ });
+ });
+
+ describe('Timezone Functionality', () => {
+ it('should allow timezone filtering and show autocomplete options', () => {
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+ // Type in the autocomplete to filter timezone options
+ cy.get('[data-cy="timezone-select"]').find('input').clear();
+
+ cy.get('[data-cy="timezone-select"]')
+ .find('input')
+ .type('UTC', { force: true });
+
+ // Verify that autocomplete dropdown appears with options
+ cy.get('.cps-autocomplete-options, .cps-autocomplete-option').should(
+ 'exist'
+ );
+
+ // Verify the input field contains what we typed
+ cy.get('[data-cy="timezone-select"]')
+ .find('input')
+ .should('have.value', 'UTC');
+ });
+
+ it('should maintain typed text in timezone input', () => {
+ cy.get('[data-cy="schedule-type-toggle"]').contains('Advanced').click();
+
+ // Type a specific timezone
+ cy.get('[data-cy="timezone-select"]').find('input').clear();
+
+ cy.get('[data-cy="timezone-select"]')
+ .find('input')
+ .type('Europe/London', { force: true });
+
+ // Verify the text remains in the input
+ cy.get('[data-cy="timezone-select"]')
+ .find('input')
+ .should('have.value', 'Europe/London');
+
+ // Verify timezone selector is still functional
+ cy.get('[data-cy="timezone-select"]').should('be.visible');
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should handle rapid type switching', () => {
+ const types = ['Minutes', 'Hourly', 'Weekly', 'Advanced'] as const;
+
+ types.forEach((type) => {
+ cy.get('[data-cy="schedule-type-toggle"]').contains(type).click();
+ });
+
+ // Should end in valid state
+ cy.get('[data-cy="advanced-config"]').should('be.visible');
+ });
+ });
+});
diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts
index a8783a3c..16d51a31 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts
+++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts
@@ -1,18 +1,18 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
+ discardPeriodicTasks,
fakeAsync,
- tick,
- discardPeriodicTasks
+ tick
} from '@angular/core/testing';
-import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import { CpsAutocompleteComponent } from './cps-autocomplete.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
-import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe';
-import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe';
import { By } from '@angular/platform-browser';
-import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe';
+import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe';
import { CpsMenuHideReason } from '../cps-menu/cps-menu.component';
+import { CpsAutocompleteComponent } from './cps-autocomplete.component';
describe('CpsAutocompleteComponent', () => {
let component: CpsAutocompleteComponent;
diff --git a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html
index 24d5c461..5dec76c6 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html
+++ b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html
@@ -1,5 +1,6 @@
-
+ [class.cps-scheduler-container-disabled]="disabled">
+
-
+
Every
-
+
Every
-
+
-
+
+ [class.cps-scheduler-tab-pane-row-disabled]="
+ state.daily.subTab !== 'everyDays'
+ ">
Every
day(s) at
-
+
+ data-cy="daily-weekday-config"
+ [class.cps-scheduler-tab-pane-row-disabled]="
+ state.daily.subTab !== 'everyWeekDay'
+ ">
Every working day at
-
+
@@ -164,6 +195,7 @@
at
-
+
-
+
+ [class.cps-scheduler-tab-pane-row-disabled]="
+ state.monthly.subTab !== 'specificDay'
+ ">
On the
-
+
+ [class.cps-scheduler-tab-pane-row-disabled]="
+ state.monthly.subTab !== 'specificWeekDay'
+ ">
On the
@@ -272,6 +315,7 @@
[disabled]="disabled || state.monthly.subTab !== 'specificWeekDay'"
[returnObject]="false"
[hideDetails]="true"
+ data-cy="monthly-weekday-start-month-select"
(valueChanged)="regenerateCron()"
[(ngModel)]="state.monthly.specificWeekDay.startMonth"
[options]="selectOptions.months"
@@ -282,6 +326,7 @@
[disabled]="disabled || state.monthly.subTab !== 'specificWeekDay'"
[use24HourTime]="use24HourTime"
[mandatoryValue]="true"
+ data-cy="monthly-weekday-timepicker"
[value]="formatTimeValue(state.monthly.specificWeekDay)"
[hideDetails]="true"
(valueChanged)="
@@ -293,19 +338,23 @@
-
+
-
+
+ [class.cps-scheduler-tab-pane-row-disabled]="
+ state.yearly.subTab !== 'specificMonthDay'
+ ">
Every
on the
-
+
+ [class.cps-scheduler-tab-pane-row-disabled]="
+ state.yearly.subTab !== 'specificMonthWeek'
+ ">
On the
-
+
-
+
+
+ [options]="timeZoneOptions">
+
diff --git a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.spec.ts
new file mode 100644
index 00000000..e8ee5d1f
--- /dev/null
+++ b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.spec.ts
@@ -0,0 +1,828 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { CpsSchedulerComponent } from './cps-scheduler.component';
+
+// Mock the timeZones import
+jest.mock('./cps-scheduler.utils', () => ({
+ timeZones: ['UTC', 'America/New_York', 'Europe/London']
+}));
+
+describe('CpsSchedulerComponent', () => {
+ let component: CpsSchedulerComponent;
+ let fixture: ComponentFixture
;
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ CpsSchedulerComponent,
+ ReactiveFormsModule,
+ NoopAnimationsModule
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CpsSchedulerComponent);
+ component = fixture.componentInstance;
+
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ // Set default inputs
+ component.cron = '';
+ component.showNotSet = true;
+ component.showAdvanced = true;
+ component.showMinutes = true;
+ component.use24HourTime = true;
+
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should create the component', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default values', () => {
+ expect(component.activeScheduleType).toBe('Not set');
+ expect(component.cron).toBe('');
+ expect(component.state).toBeDefined();
+ });
+
+ describe('Component initialization', () => {
+ it('should set up schedule types correctly when all options are enabled', () => {
+ component.showNotSet = true;
+ component.showAdvanced = true;
+ component.showMinutes = true;
+ component.ngOnInit();
+
+ expect(component.scheduleTypes).toEqual([
+ { label: 'Not set', value: 'Not set' },
+ { label: 'Minutes', value: 'Minutes' },
+ { label: 'Hourly', value: 'Hourly' },
+ { label: 'Daily', value: 'Daily' },
+ { label: 'Weekly', value: 'Weekly' },
+ { label: 'Monthly', value: 'Monthly' },
+ { label: 'Yearly', value: 'Yearly' },
+ { label: 'Advanced', value: 'Advanced' }
+ ]);
+ });
+
+ it('should remove Advanced option when showAdvanced is false', () => {
+ component.showAdvanced = false;
+ component.ngOnInit();
+
+ const hasAdvanced = component.scheduleTypes.some(
+ (type) => type.value === 'Advanced'
+ );
+ expect(hasAdvanced).toBe(false);
+ });
+
+ it('should remove Minutes option when showMinutes is false', () => {
+ component.showMinutes = false;
+ component.ngOnInit();
+
+ const hasMinutes = component.scheduleTypes.some(
+ (type) => type.value === 'Minutes'
+ );
+ expect(hasMinutes).toBe(false);
+ });
+
+ it('should remove Not set option and set default when showNotSet is false', () => {
+ component.showNotSet = false;
+ component.cron = '';
+ component.ngOnInit();
+
+ const hasNotSet = component.scheduleTypes.some(
+ (type) => type.value === 'Not set'
+ );
+ expect(hasNotSet).toBe(false);
+ expect(component.cron).toBe('0/1 * 1/1 * ? *');
+ });
+ });
+
+ describe('_handleModelChange method', () => {
+ beforeEach(() => {
+ // Reset isDirty flag before each test
+ (component as any)._isDirty = false;
+ });
+
+ describe('Invalid cron expressions', () => {
+ it('should handle non-string cron values', () => {
+ (component as any)._handleModelChange(null);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Invalid cron value:',
+ null
+ );
+ expect(component.cron).toBe('');
+ });
+
+ it('should handle empty cron when showNotSet is false', () => {
+ component.showNotSet = false;
+ (component as any)._handleModelChange('');
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid cron value:', '');
+ expect(component.cron).toBe('0/1 * 1/1 * ? *');
+ });
+
+ it('should handle cron with wrong number of parts', () => {
+ (component as any)._handleModelChange('0 1 2');
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Invalid cron value:',
+ '0 1 2'
+ );
+ expect(component.cron).toBe('');
+ });
+
+ it('should allow empty cron when showNotSet is true', () => {
+ component.showNotSet = true;
+ (component as any)._handleModelChange('');
+
+ expect(component.activeScheduleType).toBe('Not set');
+ });
+ });
+
+ describe('Dirty flag handling', () => {
+ it('should return early when _isDirty is true', () => {
+ (component as any)._isDirty = true;
+ const initialActiveType = component.activeScheduleType;
+
+ (component as any)._handleModelChange('0 12 * * ? *');
+
+ expect(component.activeScheduleType).toBe(initialActiveType);
+ expect((component as any)._isDirty).toBe(false);
+ });
+
+ it('should process when _isDirty is false', () => {
+ (component as any)._isDirty = false;
+
+ (component as any)._handleModelChange('0/5 * 1/1 * ? *');
+
+ expect(component.activeScheduleType).toBe('Minutes');
+ });
+ });
+
+ describe('Minutes pattern parsing', () => {
+ it('should parse minutes cron expression with 1/1 pattern', () => {
+ (component as any)._handleModelChange('0/5 * 1/1 * ? *');
+
+ expect(component.activeScheduleType).toBe('Minutes');
+ expect(component.state.minutes.minutes).toBe(5);
+ });
+
+ it('should parse single digit minutes', () => {
+ (component as any)._handleModelChange('0/1 * 1/1 * ? *');
+
+ expect(component.activeScheduleType).toBe('Minutes');
+ expect(component.state.minutes.minutes).toBe(1);
+ });
+
+ it('should parse double digit minutes', () => {
+ (component as any)._handleModelChange('0/15 * 1/1 * ? *');
+
+ expect(component.activeScheduleType).toBe('Minutes');
+ expect(component.state.minutes.minutes).toBe(15);
+ });
+ });
+
+ describe('Hourly pattern parsing', () => {
+ it('should parse hourly cron expression with 1/1 pattern', () => {
+ (component as any)._handleModelChange('30 0/2 1/1 * ? *');
+
+ expect(component.activeScheduleType).toBe('Hourly');
+ expect(component.state.hourly.hours).toBe(2);
+ expect(component.state.hourly.minutes).toBe(30);
+ });
+
+ it('should parse hourly with single digit hours', () => {
+ (component as any)._handleModelChange('15 0/1 1/1 * ? *');
+
+ expect(component.activeScheduleType).toBe('Hourly');
+ expect(component.state.hourly.hours).toBe(1);
+ expect(component.state.hourly.minutes).toBe(15);
+ });
+ });
+
+ describe('Daily pattern parsing', () => {
+ it('should parse every N days pattern', () => {
+ (component as any)._handleModelChange('15 9 1/3 * ? *');
+
+ expect(component.activeScheduleType).toBe('Daily');
+ expect(component.state.daily.subTab).toBe('everyDays');
+ expect(component.state.daily.everyDays.days).toBe(3);
+ expect(component.state.daily.everyDays.hours).toBe(9);
+ expect(component.state.daily.everyDays.minutes).toBe(15);
+ });
+
+ it('should parse every single day pattern', () => {
+ (component as any)._handleModelChange('0 8 1/1 * ? *');
+
+ expect(component.activeScheduleType).toBe('Daily');
+ expect(component.state.daily.subTab).toBe('everyDays');
+ expect(component.state.daily.everyDays.days).toBe(1);
+ expect(component.state.daily.everyDays.hours).toBe(8);
+ expect(component.state.daily.everyDays.minutes).toBe(0);
+ });
+
+ it('should parse weekday pattern', () => {
+ (component as any)._handleModelChange('0 8 ? * MON-FRI *');
+
+ expect(component.activeScheduleType).toBe('Daily');
+ expect(component.state.daily.subTab).toBe('everyWeekDay');
+ expect(component.state.daily.everyWeekDay.hours).toBe(8);
+ expect(component.state.daily.everyWeekDay.minutes).toBe(0);
+ });
+
+ it('should handle 12-hour time format for daily pattern', () => {
+ component.use24HourTime = false;
+ (component as any)._handleModelChange('0 14 1/1 * ? *');
+
+ expect(component.activeScheduleType).toBe('Daily');
+ expect(component.state.daily.everyDays.hours).toBe(2);
+ expect(component.state.daily.everyDays.hourType).toBe('PM');
+ });
+ });
+
+ describe('Weekly pattern parsing', () => {
+ it('should parse single day weekly pattern', () => {
+ (component as any)._handleModelChange('0 10 ? * MON *');
+
+ expect(component.activeScheduleType).toBe('Weekly');
+ expect(component.state.weekly.MON).toBe(true);
+ expect(component.state.weekly.TUE).toBe(false);
+ expect(component.state.weekly.hours).toBe(10);
+ expect(component.state.weekly.minutes).toBe(0);
+ });
+
+ it('should parse multiple days weekly pattern', () => {
+ (component as any)._handleModelChange('30 14 ? * MON,WED,FRI *');
+
+ expect(component.activeScheduleType).toBe('Weekly');
+ expect(component.state.weekly.MON).toBe(true);
+ expect(component.state.weekly.TUE).toBe(false);
+ expect(component.state.weekly.WED).toBe(true);
+ expect(component.state.weekly.THU).toBe(false);
+ expect(component.state.weekly.FRI).toBe(true);
+ expect(component.state.weekly.SAT).toBe(false);
+ expect(component.state.weekly.SUN).toBe(false);
+ });
+
+ it('should reset all days before setting new ones', () => {
+ // First set some days manually
+ component.state.weekly = {
+ MON: false,
+ TUE: true,
+ WED: false,
+ THU: true,
+ FRI: false,
+ SAT: true,
+ SUN: false,
+ hours: 12,
+ minutes: 0,
+ hourType: undefined
+ };
+
+ (component as any)._handleModelChange('0 12 ? * MON,FRI *');
+
+ expect(component.state.weekly.MON).toBe(true);
+ expect(component.state.weekly.TUE).toBe(false);
+ expect(component.state.weekly.WED).toBe(false);
+ expect(component.state.weekly.THU).toBe(false);
+ expect(component.state.weekly.FRI).toBe(true);
+ expect(component.state.weekly.SAT).toBe(false);
+ expect(component.state.weekly.SUN).toBe(false);
+ });
+ });
+
+ describe('Monthly pattern parsing', () => {
+ it('should parse specific day monthly pattern', () => {
+ (component as any)._handleModelChange('0 12 15 1/2 ? *');
+
+ expect(component.activeScheduleType).toBe('Monthly');
+ expect(component.state.monthly.subTab).toBe('specificDay');
+ expect(component.state.monthly.specificDay.day).toBe('15');
+ expect(component.state.monthly.specificDay.months).toBe(2);
+ expect(component.state.monthly.runOnWeekday).toBe(false);
+ });
+
+ it('should parse weekday monthly pattern', () => {
+ (component as any)._handleModelChange('0 12 1W 1/1 ? *');
+
+ expect(component.activeScheduleType).toBe('Monthly');
+ expect(component.state.monthly.specificDay.day).toBe('1');
+ expect(component.state.monthly.runOnWeekday).toBe(true);
+ });
+
+ it('should parse specific week day monthly pattern', () => {
+ (component as any)._handleModelChange('0 10 ? 2/3 MON#2 *');
+
+ expect(component.activeScheduleType).toBe('Monthly');
+ expect(component.state.monthly.subTab).toBe('specificWeekDay');
+ expect(component.state.monthly.specificWeekDay.day).toBe('MON');
+ expect(component.state.monthly.specificWeekDay.monthWeek).toBe('#2');
+ expect(component.state.monthly.specificWeekDay.startMonth).toBe(2);
+ expect(component.state.monthly.specificWeekDay.months).toBe(3);
+ });
+
+ it('should parse last occurrence monthly pattern', () => {
+ (component as any)._handleModelChange('0 15 ? 1/1 FRIL *');
+
+ expect(component.activeScheduleType).toBe('Monthly');
+ expect(component.state.monthly.subTab).toBe('specificWeekDay');
+ expect(component.state.monthly.specificWeekDay.day).toBe('FRI');
+ expect(component.state.monthly.specificWeekDay.monthWeek).toBe('L');
+ });
+
+ it('should handle last day of month pattern', () => {
+ (component as any)._handleModelChange('0 12 L 1/1 ? *');
+
+ expect(component.activeScheduleType).toBe('Monthly');
+ expect(component.state.monthly.specificDay.day).toBe('L');
+ });
+ });
+
+ describe('Yearly pattern parsing', () => {
+ it('should parse specific month day yearly pattern', () => {
+ (component as any)._handleModelChange('0 9 25 12 ? *');
+
+ expect(component.activeScheduleType).toBe('Yearly');
+ expect(component.state.yearly.subTab).toBe('specificMonthDay');
+ expect(component.state.yearly.specificMonthDay.day).toBe('25');
+ expect(component.state.yearly.specificMonthDay.month).toBe(12);
+ expect(component.state.yearly.runOnWeekday).toBe(false);
+ });
+
+ it('should parse weekday yearly pattern', () => {
+ (component as any)._handleModelChange('0 9 1W 6 ? *');
+
+ expect(component.activeScheduleType).toBe('Yearly');
+ expect(component.state.yearly.specificMonthDay.day).toBe('1');
+ expect(component.state.yearly.runOnWeekday).toBe(true);
+ });
+
+ it('should parse specific month week yearly pattern', () => {
+ (component as any)._handleModelChange('0 10 ? 11 THU#4 *');
+
+ expect(component.activeScheduleType).toBe('Yearly');
+ expect(component.state.yearly.subTab).toBe('specificMonthWeek');
+ expect(component.state.yearly.specificMonthWeek.day).toBe('THU');
+ expect(component.state.yearly.specificMonthWeek.monthWeek).toBe('#4');
+ expect(component.state.yearly.specificMonthWeek.month).toBe(11);
+ });
+ });
+
+ describe('Advanced pattern parsing', () => {
+ it('should set advanced mode for truly unrecognized patterns', () => {
+ // Use a pattern that doesn't match any of the existing regex patterns
+ const customCron = '5 10 15 * * ?';
+ (component as any)._handleModelChange(customCron);
+
+ expect(component.activeScheduleType).toBe('Advanced');
+ expect(component.form.controls.advanced.value).toBe(customCron);
+ });
+
+ it('should handle complex custom expressions with wildcards', () => {
+ // Pattern that uses wildcards in a way that doesn't match standard patterns
+ const complexCron = '*/15 10-12 * * 1-5 *';
+ (component as any)._handleModelChange(complexCron);
+
+ expect(component.activeScheduleType).toBe('Advanced');
+ expect(component.form.controls.advanced.value).toBe(complexCron);
+ });
+
+ it('should handle invalid but 6-part expressions', () => {
+ const invalidCron = '99 99 99 99 99 99';
+ (component as any)._handleModelChange(invalidCron);
+
+ expect(component.activeScheduleType).toBe('Advanced');
+ expect(component.form.controls.advanced.value).toBe(invalidCron);
+ });
+
+ it('should handle cron with ranges that do not match standard patterns', () => {
+ const rangeCron = '0 8-17 * * 1,3,5 *';
+ (component as any)._handleModelChange(rangeCron);
+
+ expect(component.activeScheduleType).toBe('Advanced');
+ expect(component.form.controls.advanced.value).toBe(rangeCron);
+ });
+
+ it('should handle cron with step values in non-standard positions', () => {
+ const stepCron = '0 */3 */2 * * ?';
+ (component as any)._handleModelChange(stepCron);
+
+ expect(component.activeScheduleType).toBe('Advanced');
+ expect(component.form.controls.advanced.value).toBe(stepCron);
+ });
+
+ it('should handle cron with specific numeric values that do not match patterns', () => {
+ const numericCron = '45 22 5,15,25 * * *';
+ (component as any)._handleModelChange(numericCron);
+
+ expect(component.activeScheduleType).toBe('Advanced');
+ expect(component.form.controls.advanced.value).toBe(numericCron);
+ });
+
+ it('should handle cron expressions with question marks in different positions', () => {
+ const questionCron = '0 12 ? 6 * 2024';
+ (component as any)._handleModelChange(questionCron);
+
+ expect(component.activeScheduleType).toBe('Advanced');
+ expect(component.form.controls.advanced.value).toBe(questionCron);
+ });
+
+ it('should handle expressions with mixed separators', () => {
+ const mixedCron = '0 9 1-15 JAN-DEC ? *';
+ (component as any)._handleModelChange(mixedCron);
+
+ expect(component.activeScheduleType).toBe('Advanced');
+ expect(component.form.controls.advanced.value).toBe(mixedCron);
+ });
+ });
+
+ describe('Time format handling', () => {
+ it('should handle 24-hour format correctly', () => {
+ component.use24HourTime = true;
+ (component as any)._handleModelChange('0 23 ? * MON *');
+
+ expect(component.activeScheduleType).toBe('Weekly');
+ expect(component.state.weekly.hours).toBe(23);
+ expect(component.state.weekly.hourType).toBeUndefined();
+ });
+
+ it('should convert to 12-hour format when use24HourTime is false', () => {
+ component.use24HourTime = false;
+ (component as any)._handleModelChange('0 15 ? * MON *');
+
+ expect(component.activeScheduleType).toBe('Weekly');
+ expect(component.state.weekly.hours).toBe(3);
+ expect(component.state.weekly.hourType).toBe('PM');
+ });
+
+ it('should handle midnight in 12-hour format', () => {
+ component.use24HourTime = false;
+ (component as any)._handleModelChange('0 0 ? * MON *');
+
+ expect(component.activeScheduleType).toBe('Weekly');
+ expect(component.state.weekly.hours).toBe(12);
+ expect(component.state.weekly.hourType).toBe('AM');
+ });
+
+ it('should handle noon in 12-hour format', () => {
+ component.use24HourTime = false;
+ (component as any)._handleModelChange('0 12 ? * MON *');
+
+ expect(component.activeScheduleType).toBe('Weekly');
+ expect(component.state.weekly.hours).toBe(12);
+ expect(component.state.weekly.hourType).toBe('PM');
+ });
+ });
+
+ describe('Edge cases and regex matching', () => {
+ it('should handle minutes pattern with different formats', () => {
+ (component as any)._handleModelChange('0/30 * 1/1 * ? *');
+ expect(component.activeScheduleType).toBe('Minutes');
+ expect(component.state.minutes.minutes).toBe(30);
+ });
+
+ it('should handle hourly pattern with different minute values', () => {
+ (component as any)._handleModelChange('45 0/3 1/1 * ? *');
+ expect(component.activeScheduleType).toBe('Hourly');
+ expect(component.state.hourly.hours).toBe(3);
+ expect(component.state.hourly.minutes).toBe(45);
+ });
+
+ it('should handle daily pattern with large day intervals', () => {
+ (component as any)._handleModelChange('30 15 1/7 * ? *');
+ expect(component.activeScheduleType).toBe('Daily');
+ expect(component.state.daily.everyDays.days).toBe(7);
+ });
+
+ it('should handle all weekdays in weekly pattern', () => {
+ (component as any)._handleModelChange(
+ '0 9 ? * MON,TUE,WED,THU,FRI,SAT,SUN *'
+ );
+ expect(component.activeScheduleType).toBe('Weekly');
+ expect(component.state.weekly.MON).toBe(true);
+ expect(component.state.weekly.TUE).toBe(true);
+ expect(component.state.weekly.WED).toBe(true);
+ expect(component.state.weekly.THU).toBe(true);
+ expect(component.state.weekly.FRI).toBe(true);
+ expect(component.state.weekly.SAT).toBe(true);
+ expect(component.state.weekly.SUN).toBe(true);
+ });
+ });
+ });
+
+ describe('Form validation', () => {
+ it('should validate advanced cron expressions', () => {
+ const advancedControl = component.form.controls.advanced;
+
+ // Valid cron
+ advancedControl.setValue('0 12 1 1 ? *');
+ expect(advancedControl.errors).toBeNull();
+
+ // Invalid cron
+ advancedControl.setValue('invalid cron');
+ expect(advancedControl.errors).toEqual({
+ invalidExpression: 'Invalid cron expression format'
+ });
+
+ // Empty value should be valid
+ advancedControl.setValue('');
+ expect(advancedControl.errors).toBeNull();
+ });
+
+ it('should validate cron with wrong number of parts', () => {
+ const advancedControl = component.form.controls.advanced;
+
+ advancedControl.setValue('0 12 1');
+ expect(advancedControl.errors).toEqual({
+ invalidExpression: 'Invalid cron expression format'
+ });
+ });
+
+ it('should require advanced field when Advanced tab is selected', () => {
+ component.setActiveScheduleType('Advanced');
+ const advancedControl = component.form.controls.advanced;
+
+ advancedControl.setValue('');
+ expect(advancedControl.hasError('required')).toBe(true);
+
+ advancedControl.setValue('0 12 1 1 ? *');
+ expect(advancedControl.hasError('required')).toBe(false);
+ });
+
+ it('should remove required validator when not in Advanced mode', () => {
+ // First set to Advanced to add required validator
+ component.setActiveScheduleType('Advanced');
+ let advancedControl = component.form.controls.advanced;
+ expect(advancedControl.hasError('required')).toBe(true);
+
+ // Then switch to another tab
+ component.setActiveScheduleType('Daily');
+ advancedControl = component.form.controls.advanced;
+ expect(advancedControl.errors).toBeNull();
+ });
+ });
+
+ describe('Event emission', () => {
+ it('should emit cronChange when cron value changes', () => {
+ jest.spyOn(component.cronChange, 'emit');
+
+ component.setActiveScheduleType('Minutes');
+ component.state.minutes.minutes = 10;
+ component.regenerateCron();
+
+ expect(component.cronChange.emit).toHaveBeenCalledWith(
+ '0/10 * 1/1 * ? *'
+ );
+ });
+
+ it('should emit timeZoneChange when timezone changes', () => {
+ jest.spyOn(component.timeZoneChange, 'emit');
+ component.showTimeZone = true;
+
+ component.onTimeZoneChanged('America/New_York');
+
+ expect(component.timeZoneChange.emit).toHaveBeenCalledWith(
+ 'America/New_York'
+ );
+ });
+
+ it('should not emit timeZoneChange when showTimeZone is false', () => {
+ jest.spyOn(component.timeZoneChange, 'emit');
+ component.showTimeZone = false;
+
+ component.onTimeZoneChanged('America/New_York');
+
+ expect(component.timeZoneChange.emit).not.toHaveBeenCalled();
+ });
+
+ it('should not emit cronChange when value is same', () => {
+ jest.spyOn(component.cronChange, 'emit');
+ component.cron = '0/5 * 1/1 * ? *';
+
+ (component as any)._updateCron('0/5 * 1/1 * ? *');
+
+ expect(component.cronChange.emit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Helper methods', () => {
+ it('should format time values correctly', () => {
+ const timeValue = { hours: 9, minutes: 5 };
+ const formatted = component.formatTimeValue(timeValue);
+
+ expect(formatted).toEqual({ hours: '09', minutes: '05' });
+ });
+
+ it('should format time values with double digits', () => {
+ const timeValue = { hours: 23, minutes: 45 };
+ const formatted = component.formatTimeValue(timeValue);
+
+ expect(formatted).toEqual({ hours: '23', minutes: '45' });
+ });
+
+ it('should handle time changes', () => {
+ const timeValue = { hours: '14', minutes: '30' };
+ const target = { hours: 0, minutes: 0 };
+
+ jest.spyOn(component, 'regenerateCron');
+
+ component.onTimeChanged(timeValue, target);
+
+ expect(target.hours).toBe(14);
+ expect(target.minutes).toBe(30);
+ expect(component.regenerateCron).toHaveBeenCalled();
+ });
+
+ it('should handle invalid time strings', () => {
+ const timeValue = { hours: 'invalid', minutes: 'invalid' };
+ const target = { hours: 5, minutes: 10 };
+
+ component.onTimeChanged(timeValue, target);
+
+ expect(target.hours).toBe(0);
+ expect(target.minutes).toBe(0);
+ });
+
+ it('should convert hour to cron format in 24-hour mode', () => {
+ component.use24HourTime = true;
+ const result = (component as any)._hourToCron(15, 'PM');
+ expect(result).toBe(15);
+ });
+
+ it('should convert hour to cron format in 12-hour mode', () => {
+ component.use24HourTime = false;
+ expect((component as any)._hourToCron(2, 'PM')).toBe(14);
+ expect((component as any)._hourToCron(12, 'PM')).toBe(12);
+ expect((component as any)._hourToCron(12, 'AM')).toBe(0);
+ expect((component as any)._hourToCron(1, 'AM')).toBe(1);
+ });
+
+ it('should get AM/PM hour correctly', () => {
+ component.use24HourTime = false;
+ expect((component as any)._getAmPmHour(0)).toBe(12);
+ expect((component as any)._getAmPmHour(12)).toBe(12);
+ expect((component as any)._getAmPmHour(15)).toBe(3);
+ expect((component as any)._getAmPmHour(23)).toBe(11);
+ });
+
+ it('should get hour type correctly', () => {
+ component.use24HourTime = false;
+ expect((component as any)._getHourType(0)).toBe('AM');
+ expect((component as any)._getHourType(11)).toBe('AM');
+ expect((component as any)._getHourType(12)).toBe('PM');
+ expect((component as any)._getHourType(23)).toBe('PM');
+ });
+
+ it('should return undefined hour type in 24-hour mode', () => {
+ component.use24HourTime = true;
+ expect((component as any)._getHourType(15)).toBeUndefined();
+ });
+ });
+
+ describe('Component properties', () => {
+ it('should have correct default values', () => {
+ expect(component.label).toBe('');
+ expect(component.timeZone).toBe(
+ Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
+ );
+ expect(component.showNotSet).toBe(true);
+ expect(component.showAdvanced).toBe(true);
+ expect(component.showMinutes).toBe(true);
+ expect(component.showTimeZone).toBe(false);
+ expect(component.defaultTime).toBe('00:00');
+ expect(component.use24HourTime).toBe(true);
+ expect(component.disabled).toBe(false);
+ });
+
+ it('should have correct select options structure', () => {
+ expect(component.selectOptions).toBeDefined();
+ expect(component.selectOptions.months).toBeDefined();
+ expect(component.selectOptions.days).toBeDefined();
+ expect(component.selectOptions.hours).toBeDefined();
+ expect(component.selectOptions.minutes).toBeDefined();
+ expect(component.selectOptions.monthDays).toBeDefined();
+ expect(component.selectOptions.monthWeeks).toBeDefined();
+ });
+
+ it('should have correct timezone options', () => {
+ expect(component.timeZoneOptions).toEqual([
+ { label: 'UTC', value: 'UTC' },
+ { label: 'America/New_York', value: 'America/New_York' },
+ { label: 'Europe/London', value: 'Europe/London' }
+ ]);
+ });
+
+ it('should have correct month day labels', () => {
+ expect((component as any)._getMonthDayLabel('1')).toBe('1st day');
+ expect((component as any)._getMonthDayLabel('2')).toBe('2nd day');
+ expect((component as any)._getMonthDayLabel('3')).toBe('3rd day');
+ expect((component as any)._getMonthDayLabel('4')).toBe('4th day');
+ expect((component as any)._getMonthDayLabel('11')).toBe('11th day');
+ expect((component as any)._getMonthDayLabel('21')).toBe('21st day');
+ expect((component as any)._getMonthDayLabel('L')).toBe('Last Day');
+ });
+ });
+
+ describe('Cron regeneration', () => {
+ it('should not change cron when disabled', () => {
+ component.disabled = true;
+ const initialCron = component.cron;
+
+ component.setActiveScheduleType('Minutes');
+
+ expect(component.cron).toBe(initialCron);
+ });
+
+ it('should handle tab change parameter', () => {
+ component.setActiveScheduleType('Advanced');
+ component.form.controls.advanced.setValue('0 12 * * ? *');
+
+ component.regenerateCron(true);
+
+ expect(component.form.controls.advanced.value).toBe(component.cron);
+ });
+
+ it('should throw error for invalid daily subtab', () => {
+ component.setActiveScheduleType('Daily');
+ component.state.daily.subTab = 'invalid';
+
+ expect(() => component.regenerateCron()).toThrow(
+ 'Invalid cron daily subtab selection'
+ );
+ });
+
+ it('should throw error for invalid monthly subtab', () => {
+ component.setActiveScheduleType('Monthly');
+ component.state.monthly.subTab = 'invalid';
+
+ expect(() => component.regenerateCron()).toThrow(
+ 'Invalid cron monthly subtab selection'
+ );
+ });
+
+ it('should throw error for invalid yearly subtab', () => {
+ component.setActiveScheduleType('Yearly');
+ component.state.yearly.subTab = 'invalid';
+
+ expect(() => component.regenerateCron()).toThrow(
+ 'Invalid cron yearly subtab selection'
+ );
+ });
+
+ it('should throw error for invalid cron type when showNotSet is false', () => {
+ component.showNotSet = false;
+ component.activeScheduleType = 'Invalid';
+
+ expect(() => component.regenerateCron()).toThrow('Invalid cron type');
+ });
+ });
+
+ describe('ngOnChanges', () => {
+ it('should handle cron changes after first change', () => {
+ jest.spyOn(component as any, '_handleModelChange');
+
+ const changes = {
+ value: {
+ currentValue: '0/5 * 1/1 * ? *',
+ previousValue: '',
+ firstChange: false,
+ isFirstChange: () => false
+ }
+ };
+
+ component.ngOnChanges(changes);
+
+ expect((component as any)._handleModelChange).toHaveBeenCalledWith(
+ component.cron
+ );
+ });
+
+ it('should not handle first change', () => {
+ jest.spyOn(component as any, '_handleModelChange');
+
+ const changes = {
+ value: {
+ currentValue: '0/5 * 1/1 * ? *',
+ previousValue: undefined,
+ firstChange: true,
+ isFirstChange: () => true
+ }
+ };
+
+ component.ngOnChanges(changes);
+
+ expect((component as any)._handleModelChange).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts
index 31b82dba..75092016 100644
--- a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts
+++ b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.ts
@@ -1,14 +1,16 @@
+import { CommonModule } from '@angular/common';
import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
Component,
- OnInit,
- OnChanges,
+ EventEmitter,
+ inject,
Input,
+ OnChanges,
+ OnInit,
Output,
- EventEmitter,
- SimpleChanges,
- ChangeDetectorRef
+ SimpleChanges
} from '@angular/core';
-import { CommonModule } from '@angular/common';
import {
FormBuilder,
FormControl,
@@ -17,21 +19,22 @@ import {
ReactiveFormsModule,
Validators
} from '@angular/forms';
-import { timeZones } from './cps-scheduler.utils';
-import { CpsSelectComponent } from '../cps-select/cps-select.component';
-import { CpsRadioGroupComponent } from '../cps-radio-group/cps-radio-group.component';
-import { CpsRadioComponent } from '../cps-radio-group/cps-radio/cps-radio.component';
-import { CpsCheckboxComponent } from '../cps-checkbox/cps-checkbox.component';
-import { CpsInputComponent } from '../cps-input/cps-input.component';
+import { CronValidationService } from '../../services/cron-validation.service';
import { CpsAutocompleteComponent } from '../cps-autocomplete/cps-autocomplete.component';
import {
CpsButtonToggleComponent,
CpsButtonToggleOption
} from '../cps-button-toggle/cps-button-toggle.component';
+import { CpsCheckboxComponent } from '../cps-checkbox/cps-checkbox.component';
+import { CpsInputComponent } from '../cps-input/cps-input.component';
+import { CpsRadioGroupComponent } from '../cps-radio-group/cps-radio-group.component';
+import { CpsRadioComponent } from '../cps-radio-group/cps-radio/cps-radio.component';
+import { CpsSelectComponent } from '../cps-select/cps-select.component';
import {
- CpsTimepickerComponent,
- CpsTime
+ CpsTime,
+ CpsTimepickerComponent
} from '../cps-timepicker/cps-timepicker.component';
+import { timeZones } from './cps-scheduler.utils';
const Days = {
MON: 'Monday',
@@ -86,6 +89,7 @@ enum Months {
CpsTimepickerComponent,
CpsAutocompleteComponent
],
+ changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './cps-scheduler.component.html',
styleUrl: './cps-scheduler.component.scss'
})
@@ -181,23 +185,21 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
{ label: 'Advanced', value: 'Advanced' }
];
+ private readonly _fb = inject(FormBuilder);
+ private readonly _cdr = inject(ChangeDetectorRef);
+ private readonly _cronValidationService = inject(CronValidationService);
+
activeScheduleType = 'Not set';
selectOptions = this._getSelectOptions();
timeZoneOptions = timeZones.map((tz) => ({ label: tz, value: tz }));
state: any;
form: FormGroup = this._fb.group({
- advanced: ['', [this._validateAdvancedExpr]]
+ advanced: ['', [this._validateAdvancedExpr.bind(this)]]
});
private _isDirty = false;
private _minutesDefault = '0/1 * 1/1 * ? *';
- // eslint-disable-next-line no-useless-constructor
- constructor(
- private _fb: FormBuilder,
- private _cdr: ChangeDetectorRef
- ) {}
-
ngOnInit(): void {
this.state = this._getDefaultState();
@@ -392,7 +394,15 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
this.cronChange.emit(this.cron);
}
- private _isValidCron(cron: string): boolean {
+ /**
+ * Basic structural validation for pattern recognition.
+ * More permissive than _isValidCron to allow unrecognized patterns
+ * to reach Advanced mode (preserving original behavior).
+ *
+ * @param cron - The cron expression to validate structurally
+ * @returns boolean - True if the structure is valid for pattern parsing
+ */
+ private _isValidCronStructure(cron: string): boolean {
if (typeof cron !== 'string') return false;
if (!this.showNotSet) {
@@ -407,32 +417,27 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
return true;
}
- private _validateAdvancedExpr(c: FormControl) {
+ /**
+ * Validates a cron expression using the CronValidationService.
+ *
+ * @param cron - The cron expression to validate
+ * @returns boolean - True if the cron expression is valid
+ */
+ private _isValidCron(cron: string): boolean {
+ return this._cronValidationService.isValidCron(cron, this.showNotSet);
+ }
+
+ private _validateAdvancedExpr(
+ c: FormControl
+ ): { invalidExpression: string } | null {
const cron = c.value;
if (!cron) return null;
- const splits = cron?.split(' ') || [];
- if (splits.length !== 6) return { invalidExpression: 'Invalid expression' };
-
- const cronSeven = `0 ${cron}`;
+ if (!this._isValidCron(cron)) {
+ return { invalidExpression: 'Invalid cron expression format' };
+ }
- return cronSeven.match(/\d+ 0\/\d+ \* 1\/1 \* \? \*/) ||
- cronSeven.match(/\d+ \d+ 0\/\d+ 1\/1 \* \? \*/) ||
- cronSeven.match(/\d+ \d+ \d+ 1\/\d+ \* \? \*/) ||
- cronSeven.match(/\d+ \d+ \d+ \? \* MON-FRI \*/) ||
- cronSeven.match(
- /\d+ \d+ \d+ \? \* (MON|TUE|WED|THU|FRI|SAT|SUN)(,(MON|TUE|WED|THU|FRI|SAT|SUN))* \*/
- ) ||
- cronSeven.match(/\d+ \d+ \d+ (\d+|L|LW|1W) 1\/\d+ \? \*/) ||
- cronSeven.match(
- /\d+ \d+ \d+ \? \d+\/\d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*/
- ) ||
- cronSeven.match(/\d+ \d+ \d+ (\d+|L|LW|1W) \d+ \? \*/) ||
- cronSeven.match(
- /\d+ \d+ \d+ \? \d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*/
- )
- ? null
- : { invalidExpression: 'Invalid expression' };
+ return null;
}
private _getAmPmHour(hour: number): number {
@@ -458,7 +463,7 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
}
private _handleModelChange(cron: string): void {
- if (!this._isValidCron(cron)) {
+ if (!this._isValidCronStructure(cron)) {
console.error('Invalid cron value:', cron);
this.cron = this.showNotSet ? '' : this._minutesDefault;
return;
@@ -478,40 +483,31 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
}
const cronSeven = `0 ${cron}`;
-
const [minutes, hours, dayOfMonth, month, dayOfWeek] = cron.split(' ');
- if (cronSeven.match(/\d+ 0\/\d+ \* 1\/1 \* \? \*/)) {
+ if (this._isMinutesPattern(cronSeven)) {
this.activeScheduleType = 'Minutes';
-
this.state.minutes.minutes = Number(minutes.substring(2));
- } else if (cronSeven.match(/\d+ \d+ 0\/\d+ 1\/1 \* \? \*/)) {
+ } else if (this._isHourlyPattern(cronSeven)) {
this.activeScheduleType = 'Hourly';
-
this.state.hourly.hours = Number(hours.substring(2));
this.state.hourly.minutes = Number(minutes);
- } else if (cronSeven.match(/\d+ \d+ \d+ 1\/\d+ \* \? \*/)) {
+ } else if (this._isDailyEveryDaysPattern(cronSeven)) {
this.activeScheduleType = 'Daily';
-
this.state.daily.subTab = 'everyDays';
this.state.daily.everyDays.days = Number(dayOfMonth.substring(2));
const parsedHours = Number(hours);
this.state.daily.everyDays.hours = this._getAmPmHour(parsedHours);
this.state.daily.everyDays.hourType = this._getHourType(parsedHours);
this.state.daily.everyDays.minutes = Number(minutes);
- } else if (cronSeven.match(/\d+ \d+ \d+ \? \* MON-FRI \*/)) {
+ } else if (this._isDailyWeekdayPattern(cronSeven)) {
this.activeScheduleType = 'Daily';
-
this.state.daily.subTab = 'everyWeekDay';
const parsedHours = Number(hours);
this.state.daily.everyWeekDay.hours = this._getAmPmHour(parsedHours);
this.state.daily.everyWeekDay.hourType = this._getHourType(parsedHours);
this.state.daily.everyWeekDay.minutes = Number(minutes);
- } else if (
- cronSeven.match(
- /\d+ \d+ \d+ \? \* (MON|TUE|WED|THU|FRI|SAT|SUN)(,(MON|TUE|WED|THU|FRI|SAT|SUN))* \*/
- )
- ) {
+ } else if (this._isWeeklyPattern(cronSeven)) {
this.activeScheduleType = 'Weekly';
this.selectOptions.days
.map((d) => d.value)
@@ -523,12 +519,12 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
this.state.weekly.hours = this._getAmPmHour(parsedHours);
this.state.weekly.hourType = this._getHourType(parsedHours);
this.state.weekly.minutes = Number(minutes);
- } else if (cronSeven.match(/\d+ \d+ \d+ (\d+|L|LW|1W) 1\/\d+ \? \*/)) {
+ } else if (this._isMonthlySpecificDayPattern(cronSeven)) {
this.activeScheduleType = 'Monthly';
this.state.monthly.subTab = 'specificDay';
- if (dayOfMonth.indexOf('W') !== -1) {
- this.state.monthly.specificDay.day = dayOfMonth.charAt(0);
+ if (dayOfMonth.includes('W')) {
+ this.state.monthly.specificDay.day = dayOfMonth.replace('W', '');
this.state.monthly.runOnWeekday = true;
} else {
this.state.monthly.specificDay.day = dayOfMonth;
@@ -539,11 +535,7 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
this.state.monthly.specificDay.hours = this._getAmPmHour(parsedHours);
this.state.monthly.specificDay.hourType = this._getHourType(parsedHours);
this.state.monthly.specificDay.minutes = Number(minutes);
- } else if (
- cronSeven.match(
- /\d+ \d+ \d+ \? \d+\/\d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*/
- )
- ) {
+ } else if (this._isMonthlySpecificWeekDayPattern(cronSeven)) {
const day = dayOfWeek.substring(0, 3);
const monthWeek = dayOfWeek.substring(3);
this.activeScheduleType = 'Monthly';
@@ -551,7 +543,7 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
this.state.monthly.specificWeekDay.monthWeek = monthWeek;
this.state.monthly.specificWeekDay.day = day;
- if (month.indexOf('/') !== -1) {
+ if (month.includes('/')) {
const [startMonth, months] = month.split('/').map(Number);
this.state.monthly.specificWeekDay.months = months;
this.state.monthly.specificWeekDay.startMonth = startMonth;
@@ -562,13 +554,13 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
this.state.monthly.specificWeekDay.hourType =
this._getHourType(parsedHours);
this.state.monthly.specificWeekDay.minutes = Number(minutes);
- } else if (cronSeven.match(/\d+ \d+ \d+ (\d+|L|LW|1W) \d+ \? \*/)) {
+ } else if (this._isYearlySpecificMonthDayPattern(cronSeven)) {
this.activeScheduleType = 'Yearly';
this.state.yearly.subTab = 'specificMonthDay';
this.state.yearly.specificMonthDay.month = Number(month);
- if (dayOfMonth.indexOf('W') !== -1) {
- this.state.yearly.specificMonthDay.day = dayOfMonth.charAt(0);
+ if (dayOfMonth.includes('W')) {
+ this.state.yearly.specificMonthDay.day = dayOfMonth.replace('W', '');
this.state.yearly.runOnWeekday = true;
} else {
this.state.yearly.specificMonthDay.day = dayOfMonth;
@@ -579,11 +571,7 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
this.state.yearly.specificMonthDay.hourType =
this._getHourType(parsedHours);
this.state.yearly.specificMonthDay.minutes = Number(minutes);
- } else if (
- cronSeven.match(
- /\d+ \d+ \d+ \? \d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*/
- )
- ) {
+ } else if (this._isYearlySpecificMonthWeekPattern(cronSeven)) {
const day = dayOfWeek.substring(0, 3);
const monthWeek = dayOfWeek.substring(3);
this.activeScheduleType = 'Yearly';
@@ -604,6 +592,49 @@ export class CpsSchedulerComponent implements OnInit, OnChanges {
this._cdr.detectChanges();
}
+ // Enhanced pattern matching methods with support for all EventBridge features
+ private _isMinutesPattern(cronSeven: string): boolean {
+ return /^\d+ 0\/\d+ \* (1\/1|\*) \* \? \*$/.test(cronSeven);
+ }
+
+ private _isHourlyPattern(cronSeven: string): boolean {
+ return /^\d+ \d+ 0\/\d+ (1\/1|\*) \* \? \*$/.test(cronSeven);
+ }
+
+ private _isDailyEveryDaysPattern(cronSeven: string): boolean {
+ return /^\d+ \d+ \d+ 1\/\d+ \* \? \*$/.test(cronSeven);
+ }
+
+ private _isDailyWeekdayPattern(cronSeven: string): boolean {
+ return /^\d+ \d+ \d+ \? \* MON-FRI \*$/.test(cronSeven);
+ }
+
+ private _isWeeklyPattern(cronSeven: string): boolean {
+ return /^\d+ \d+ \d+ \? \* (MON|TUE|WED|THU|FRI|SAT|SUN)(,(MON|TUE|WED|THU|FRI|SAT|SUN))* \*$/.test(
+ cronSeven
+ );
+ }
+
+ private _isMonthlySpecificDayPattern(cronSeven: string): boolean {
+ return /^\d+ \d+ \d+ (\d+|L|LW|\d+W) 1\/\d+ \? \*$/.test(cronSeven);
+ }
+
+ private _isMonthlySpecificWeekDayPattern(cronSeven: string): boolean {
+ return /^\d+ \d+ \d+ \? \d+\/\d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*$/.test(
+ cronSeven
+ );
+ }
+
+ private _isYearlySpecificMonthDayPattern(cronSeven: string): boolean {
+ return /^\d+ \d+ \d+ (\d+|L|LW|\d+W) \d+ \? \*$/.test(cronSeven);
+ }
+
+ private _isYearlySpecificMonthWeekPattern(cronSeven: string): boolean {
+ return /^\d+ \d+ \d+ \? \d+ (MON|TUE|WED|THU|FRI|SAT|SUN)((#[1-5])|L) \*$/.test(
+ cronSeven
+ );
+ }
+
private _getDefaultState() {
const [defaultHours, defaultMinutes] = this.defaultTime
.split(':')
diff --git a/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts
new file mode 100644
index 00000000..bde1c3f2
--- /dev/null
+++ b/projects/cps-ui-kit/src/lib/services/cron-validation.service.spec.ts
@@ -0,0 +1,276 @@
+import { TestBed } from '@angular/core/testing';
+import { CronValidationService } from './cron-validation.service';
+
+describe('CronValidationService', () => {
+ let service: CronValidationService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(CronValidationService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('Basic Structure Validation', () => {
+ it('should validate 6-field cron expressions', () => {
+ expect(service.isValidCron('0 12 * * ? *')).toBe(true);
+ expect(service.isValidCron('0 12 * *')).toBe(false); // Only 4 fields
+ expect(service.isValidCron('0 12 * * ? * extra')).toBe(false); // 7 fields
+ });
+
+ it('should handle empty expressions based on allowEmpty parameter', () => {
+ expect(service.isValidCron('', true)).toBe(true);
+ expect(service.isValidCron('', false)).toBe(false);
+ expect(service.isValidCron('')).toBe(false); // Default allowEmpty = false
+ });
+
+ it('should reject non-string inputs', () => {
+ expect(service.isValidCron(null as any)).toBe(false);
+ expect(service.isValidCron(undefined as any)).toBe(false);
+ expect(service.isValidCron(123 as any)).toBe(false);
+ });
+ });
+
+ describe('Range Pattern Validation', () => {
+ describe('Numeric Ranges', () => {
+ it('should validate simple numeric ranges', () => {
+ expect(service.isValidCron('0 9-17 * * ? *')).toBe(true); // 9am to 5pm
+ expect(service.isValidCron('1-5 * * * ? *')).toBe(true); // Minutes 1-5
+ expect(service.isValidCron('0 0 1-15 * ? *')).toBe(true); // First half of month
+ });
+
+ it('should validate ranges with steps', () => {
+ expect(service.isValidCron('0 9-17/2 * * ? *')).toBe(true); // Every 2nd hour 9-17
+ expect(service.isValidCron('1-30/5 * * * ? *')).toBe(true); // Every 5th minute 1-30
+ expect(service.isValidCron('0 0 1-31/7 * ? *')).toBe(true); // Every 7th day
+ });
+
+ it('should reject invalid numeric ranges', () => {
+ expect(service.isValidCron('0 25-30 * * ? *')).toBe(false); // Hour 25 invalid
+ expect(service.isValidCron('60-65 * * * ? *')).toBe(false); // Minute 60+ invalid
+ expect(service.isValidCron('0 0 32-35 * ? *')).toBe(false); // Day 32+ invalid
+ });
+
+ it('should reject backwards ranges', () => {
+ expect(service.isValidCron('0 17-9 * * ? *')).toBe(false); // End before start
+ expect(service.isValidCron('30-10 * * * ? *')).toBe(false); // Backwards minute range
+ });
+ });
+
+ describe('Named Day Ranges', () => {
+ it('should validate named day ranges', () => {
+ expect(service.isValidCron('0 9 ? * MON-FRI *')).toBe(true); // Weekdays
+ expect(service.isValidCron('0 9 ? * SAT-SUN *')).toBe(true); // Weekend
+ expect(service.isValidCron('0 9 ? * TUE-THU *')).toBe(true); // Tue-Thu
+ });
+
+ it('should validate mixed case day names', () => {
+ expect(service.isValidCron('0 9 ? * mon-fri *')).toBe(true);
+ expect(service.isValidCron('0 9 ? * Mon-Fri *')).toBe(true);
+ });
+
+ it('should validate day ranges with steps', () => {
+ expect(service.isValidCron('0 9 ? * MON-FRI/2 *')).toBe(true); // Every other weekday
+ expect(service.isValidCron('0 9 ? * SUN-SAT/3 *')).toBe(true); // Every 3rd day
+ });
+
+ it('should reject invalid day names', () => {
+ expect(service.isValidCron('0 9 ? * INVALID-FRI *')).toBe(false);
+ expect(service.isValidCron('0 9 ? * MON-INVALID *')).toBe(false);
+ });
+ });
+
+ describe('Named Month Ranges', () => {
+ it('should validate named month ranges', () => {
+ expect(service.isValidCron('0 9 15 JAN-MAR ? *')).toBe(true); // Q1
+ expect(service.isValidCron('0 9 1 APR-JUN ? *')).toBe(true); // Q2
+ expect(service.isValidCron('0 9 * JUL-SEP ? *')).toBe(true); // Q3
+ expect(service.isValidCron('0 9 * OCT-DEC ? *')).toBe(true); // Q4
+ });
+
+ it('should validate month ranges with steps', () => {
+ expect(service.isValidCron('0 9 1 JAN-DEC/3 ? *')).toBe(true); // Quarterly
+ expect(service.isValidCron('0 9 15 JAN-JUN/2 ? *')).toBe(true); // Every other month
+ });
+
+ it('should validate mixed case month names', () => {
+ expect(service.isValidCron('0 9 1 jan-mar ? *')).toBe(true);
+ expect(service.isValidCron('0 9 1 Jan-Mar ? *')).toBe(true);
+ });
+
+ it('should reject invalid month names', () => {
+ expect(service.isValidCron('0 9 1 INVALID-MAR ? *')).toBe(false);
+ expect(service.isValidCron('0 9 1 JAN-INVALID ? *')).toBe(false);
+ });
+ });
+ });
+
+ describe('Step Pattern Validation', () => {
+ it('should validate wildcard step patterns', () => {
+ expect(service.isValidCron('*/15 * * * ? *')).toBe(true); // Every 15 minutes
+ expect(service.isValidCron('0 */4 * * ? *')).toBe(true); // Every 4 hours
+ expect(service.isValidCron('0 0 */5 * ? *')).toBe(true); // Every 5 days
+ });
+
+ it('should validate numeric step patterns', () => {
+ expect(service.isValidCron('5/10 * * * ? *')).toBe(true); // Every 10min from 5
+ expect(service.isValidCron('0 2/6 * * ? *')).toBe(true); // Every 6hr from 2am
+ expect(service.isValidCron('0 0 1/14 * ? *')).toBe(true); // Every 14 days from 1st
+ });
+
+ it('should reject invalid step values', () => {
+ expect(service.isValidCron('*/0 * * * ? *')).toBe(false); // Step 0 invalid
+ expect(service.isValidCron('*/-5 * * * ? *')).toBe(false); // Negative step
+ expect(service.isValidCron('*/abc * * * ? *')).toBe(false); // Non-numeric step
+ });
+ });
+
+ describe('List Pattern Validation', () => {
+ it('should validate numeric lists', () => {
+ expect(service.isValidCron('0,15,30,45 * * * ? *')).toBe(true); // Specific minutes
+ expect(service.isValidCron('0 9,12,17 * * ? *')).toBe(true); // Specific hours
+ expect(service.isValidCron('0 0 1,15 * ? *')).toBe(true); // 1st and 15th
+ });
+
+ it('should validate named day lists', () => {
+ expect(service.isValidCron('0 9 ? * MON,WED,FRI *')).toBe(true);
+ expect(service.isValidCron('0 9 ? * SAT,SUN *')).toBe(true);
+ });
+
+ it('should validate named month lists', () => {
+ expect(service.isValidCron('0 9 1 JAN,APR,JUL,OCT ? *')).toBe(true); // Quarterly
+ expect(service.isValidCron('0 9 * JUN,JUL,AUG ? *')).toBe(true); // Summer
+ });
+
+ it('should validate mixed lists (ranges and values)', () => {
+ expect(service.isValidCron('0 9 1,15,L * ? *')).toBe(true); // 1st, 15th, last day
+ expect(service.isValidCron('0 9 ? * MON-WED,FRI *')).toBe(true); // Mon-Wed + Fri
+ });
+
+ it('should reject lists with invalid values', () => {
+ expect(service.isValidCron('0,70 * * * ? *')).toBe(false); // Minute 70 invalid
+ expect(service.isValidCron('0 25,26 * * ? *')).toBe(false); // Hours 25,26 invalid
+ });
+ });
+
+ describe('Special Characters', () => {
+ describe('L (Last) Character', () => {
+ it('should validate L in day-of-month field', () => {
+ expect(service.isValidCron('0 9 L * ? *')).toBe(true); // Last day of month
+ expect(service.isValidCron('0 9 1,15,L * ? *')).toBe(true); // 1st, 15th, last
+ });
+
+ it('should validate L with day names in day-of-week field', () => {
+ expect(service.isValidCron('0 9 ? * FRIL *')).toBe(true); // Last Friday
+ expect(service.isValidCron('0 9 ? * SUNL *')).toBe(true); // Last Sunday
+ });
+
+ it('should reject L in invalid fields', () => {
+ expect(service.isValidCron('L 9 * * ? *')).toBe(false); // L in minutes
+ expect(service.isValidCron('0 L * * ? *')).toBe(false); // L in hours
+ });
+ });
+
+ describe('W (Weekday) Character', () => {
+ it('should validate W with day numbers', () => {
+ expect(service.isValidCron('0 9 15W * ? *')).toBe(true); // Weekday nearest 15th
+ expect(service.isValidCron('0 9 1W * ? *')).toBe(true); // Weekday nearest 1st
+ });
+
+ it('should validate LW (last weekday)', () => {
+ expect(service.isValidCron('0 9 LW * ? *')).toBe(true);
+ });
+
+ it('should reject W in invalid contexts', () => {
+ expect(service.isValidCron('0 9W * * ? *')).toBe(false); // W without number
+ expect(service.isValidCron('0 9 ? * MONW *')).toBe(false); // W with day name
+ });
+ });
+
+ describe('# (Hash) Character', () => {
+ it('should validate hash with day names', () => {
+ expect(service.isValidCron('0 9 ? * MON#1 *')).toBe(true); // 1st Monday
+ expect(service.isValidCron('0 9 ? * FRI#2 *')).toBe(true); // 2nd Friday
+ expect(service.isValidCron('0 9 ? * SUN#5 *')).toBe(true); // 5th Sunday
+ });
+
+ it('should reject invalid hash patterns', () => {
+ expect(service.isValidCron('0 9 ? * MON#0 *')).toBe(false); // Week 0 invalid
+ expect(service.isValidCron('0 9 ? * MON#6 *')).toBe(false); // Week 6 invalid
+ expect(service.isValidCron('0 9 15#1 * ? *')).toBe(false); // Hash in day-of-month
+ expect(service.isValidCron('0 9 ? * SUN#5,MON#4 *')).toBe(false); // two expressions with a hash for day-of-week field should not be allowed
+ });
+ });
+ });
+
+ describe('Mutual Exclusivity Rules', () => {
+ it('should enforce day-of-month and day-of-week mutual exclusivity', () => {
+ expect(service.isValidCron('0 9 15 * ? *')).toBe(true); // Specific day, any weekday
+ expect(service.isValidCron('0 9 ? * MON *')).toBe(true); // Any day, specific weekday
+ expect(service.isValidCron('0 9 15 * MON *')).toBe(false); // Both specific - invalid
+ });
+
+ it('should allow wildcards in both day fields', () => {
+ expect(service.isValidCron('0 9 * * ? *')).toBe(true);
+ expect(service.isValidCron('0 9 ? * * *')).toBe(true);
+ });
+ });
+
+ describe('Field Range Validation', () => {
+ it('should validate field ranges correctly', () => {
+ // Minutes: 0-59
+ expect(service.isValidCron('0 9 * * ? *')).toBe(true);
+ expect(service.isValidCron('59 9 * * ? *')).toBe(true);
+ expect(service.isValidCron('60 9 * * ? *')).toBe(false);
+
+ // Hours: 0-23
+ expect(service.isValidCron('0 0 * * ? *')).toBe(true);
+ expect(service.isValidCron('0 23 * * ? *')).toBe(true);
+ expect(service.isValidCron('0 24 * * ? *')).toBe(false);
+
+ // Day-of-month: 1-31
+ expect(service.isValidCron('0 9 1 * ? *')).toBe(true);
+ expect(service.isValidCron('0 9 31 * ? *')).toBe(true);
+ expect(service.isValidCron('0 9 32 * ? *')).toBe(false);
+
+ // Month: 1-12
+ expect(service.isValidCron('0 9 * 1 ? *')).toBe(true);
+ expect(service.isValidCron('0 9 * 12 ? *')).toBe(true);
+ expect(service.isValidCron('0 9 * 13 ? *')).toBe(false);
+
+ // Day-of-week: 1-7
+ expect(service.isValidCron('0 9 ? * 1 *')).toBe(true);
+ expect(service.isValidCron('0 9 ? * 7 *')).toBe(true);
+ expect(service.isValidCron('0 9 ? * 8 *')).toBe(false);
+
+ // Year: 1970-2199
+ expect(service.isValidCron('0 9 * * ? 1970')).toBe(true);
+ expect(service.isValidCron('0 9 * * ? 2199')).toBe(true);
+ expect(service.isValidCron('0 9 * * ? 2200')).toBe(false);
+ });
+ });
+
+ describe('Complex Real-World Patterns', () => {
+ it('should validate business hour patterns', () => {
+ expect(service.isValidCron('0 9-17 * * MON-FRI *')).toBe(true);
+ expect(service.isValidCron('*/15 9-17 * * MON-FRI *')).toBe(true);
+ });
+
+ it('should validate quarterly patterns', () => {
+ expect(service.isValidCron('0 9 1 JAN,APR,JUL,OCT ? *')).toBe(true);
+ expect(service.isValidCron('0 9 1 1,4,7,10 ? *')).toBe(true);
+ });
+
+ it('should validate maintenance windows', () => {
+ expect(service.isValidCron('0 2 * * SUN *')).toBe(true); // Sunday 2am
+ expect(service.isValidCron('0 2 L * ? *')).toBe(true); // Last day 2am
+ });
+
+ it('should validate reporting schedules', () => {
+ expect(service.isValidCron('0 8 1,15 * ? *')).toBe(true); // Bi-monthly
+ expect(service.isValidCron('0 8 ? * MON#1 *')).toBe(true); // First Monday
+ });
+ });
+});
diff --git a/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts b/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts
new file mode 100644
index 00000000..f22f1fe6
--- /dev/null
+++ b/projects/cps-ui-kit/src/lib/services/cron-validation.service.ts
@@ -0,0 +1,475 @@
+import { Injectable } from '@angular/core';
+
+/**
+ * Service for validating 6-field cron expressions with extended features.
+ *
+ * This service handles cron validation logic for extended cron expression formats
+ * that support additional features beyond standard Unix cron for more flexible
+ * scheduling capabilities.
+ *
+ * Format: minutes hours day-of-month month day-of-week year
+ *
+ * Key Features:
+ * - Wildcards: asterisk (any value), question mark (any value for day fields)
+ * - Ranges: 1-5, MON-FRI, JAN-MAR
+ * - Steps: asterisk/15, 5/10, 1-5/2
+ * - Lists: 1,3,5, MON,WED,FRI
+ * - Special chars: L (last), W (weekday), hash (nth occurrence)
+ * @see {@link https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#cron-based | AWS EventBridge Scheduler - Cron-based schedules}
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class CronValidationService {
+ /**
+ * Validates a complete 6-field cron expression.
+ *
+ * @param cron - The 6-field cron expression to validate
+ * @param allowEmpty - Whether to allow empty cron expressions
+ * @returns boolean - True if the cron expression is valid
+ */
+ isValidCron(cron: string, allowEmpty = false): boolean {
+ if (typeof cron !== 'string') return false;
+
+ if (allowEmpty && cron === '') return true;
+ if (!cron) return false;
+
+ const parts = cron.split(' ');
+ if (parts.length !== 6) {
+ return false;
+ }
+
+ return this.validateCronFields(parts);
+ }
+
+ /**
+ * Validates all six fields of an extended cron expression.
+ *
+ * Extended cron format uses 6 fields: minutes hours day-of-month month day-of-week year
+ * This method validates each field according to extended cron syntax rules,
+ * which build upon standard Unix cron with additional features.
+ *
+ * Field Ranges and Features:
+ * - Minutes (0-59): Numeric, ranges, steps, lists
+ * - Hours (0-23): Numeric, ranges, steps, lists
+ * - Day-of-month (1-31): Numeric + special chars (L, W, LW)
+ * - Month (1-12): Numeric or named (JAN-DEC), ranges, steps, lists
+ * - Day-of-week (1-7): Numeric or named (SUN-SAT), special chars (L, #)
+ * - Year (1970-2199): Extended range for long-term scheduling
+ *
+ * Key Rules:
+ * - Day-of-month and day-of-week are mutually exclusive (one must be * or ?)
+ * - ? is only valid for day-of-month and day-of-week fields
+ * - Special characters provide advanced scheduling capabilities
+ *
+ * @param parts - Array of 6 cron field strings [minutes, hours, dayOfMonth, month, dayOfWeek, year]
+ * @returns boolean - True if all fields are valid and follow extended cron rules
+ */
+ private validateCronFields(parts: string[]): boolean {
+ const [minutes, hours, dayOfMonth, month, dayOfWeek, year] = parts;
+
+ // Validate minutes (0-59) - use enhanced validation
+ if (!this.validateComplexField(minutes, 0, 59, 'minutes')) return false;
+
+ // Validate hours (0-23) - use enhanced validation
+ if (!this.validateComplexField(hours, 0, 23, 'hours')) return false;
+
+ // Validate day of month (1-31 or wildcards)
+ if (!this.validateDayOfMonth(dayOfMonth)) return false;
+
+ // Validate month (1-12 or JAN-DEC) - use enhanced validation
+ if (!this.validateMonth(month)) return false;
+
+ // Validate day of week (1-7 or SUN-SAT) - enhanced method will handle this
+ if (!this.validateDayOfWeek(dayOfWeek)) return false;
+
+ // Validate year (1970-2199) - use enhanced validation
+ if (!this.validateComplexField(year, 1970, 2199, 'year')) return false;
+
+ // Validate mutual exclusivity of day-of-month and day-of-week
+ if (!this.validateDayMutualExclusivity(dayOfMonth, dayOfWeek)) return false;
+
+ return true;
+ }
+
+ /**
+ * Enhanced validation for complex cron field patterns.
+ * Supports extended cron features including ranges, steps, lists, and special characters.
+ *
+ * This method handles extended cron expression syntax which builds upon
+ * standard Unix cron format with additional features for flexible scheduling.
+ *
+ * Supported patterns:
+ * - Wildcards: asterisk (any value), question mark (any value for day fields)
+ * - Ranges: 1-5 (values 1 through 5), MON-FRI (Monday through Friday)
+ * - Steps: asterisk/10 (every 10th value), 1-5/2 (every 2nd value from 1 to 5)
+ * - Lists: 1,3,5 (specific values), MON,WED,FRI (specific days)
+ * - Special chars: L (last), W (weekday), hash (nth occurrence)
+ *
+ * @param field - The cron field value to validate (examples: "1-5/2", "MON-FRI", "asterisk/10")
+ * @param min - Minimum valid numeric value for this field type
+ * @param max - Maximum valid numeric value for this field type
+ * @param type - The cron field type ('minutes', 'hours', 'dayOfMonth', 'month', 'dayOfWeek', 'year')
+ * @returns boolean - True if the field is valid according to EventBridge Scheduler rules
+ */
+ private validateComplexField(
+ field: string,
+ min: number,
+ max: number,
+ type: string
+ ): boolean {
+ // Handle wildcard characters
+ // '*' means "any value" and is valid for all fields
+ // '?' means "no specific value" and is only valid for day-of-month and day-of-week fields
+ if (field === '*' || field === '?') {
+ return type === 'dayOfMonth' || type === 'dayOfWeek' || field === '*';
+ }
+
+ // Handle comma-separated lists: "1,3,5" or "MON,WED,FRI" or "MON-WED,FRI"
+ // Allows scheduling on multiple specific values within a field
+ if (field.includes(',')) {
+ return field.split(',').every((val) => {
+ const trimmedVal = val.trim();
+ // Recursively validate each part of the list
+ return this.validateComplexField(trimmedVal, min, max, type);
+ });
+ }
+
+ // Handle complex range with step patterns: "1-5/2" (every 2nd value from 1 to 5)
+ // This allows scheduling at intervals within a specific range
+ if (field.includes('-') && field.includes('/')) {
+ const [range, step] = field.split('/');
+ const [start, end] = range.split('-');
+ return this.validateRangeWithStep(start, end, step, min, max, type);
+ }
+
+ // Handle simple range patterns: "1-5" (values from 1 to 5), "MON-FRI" (Monday to Friday)
+ // Extended cron supports both numeric and named ranges for time-based scheduling
+ if (field.includes('-')) {
+ const [start, end] = field.split('-');
+ return this.validateSimpleRange(start, end, min, max, type);
+ }
+
+ // Handle step patterns from start: "5/10" (every 10th value starting from 5)
+ // Extended cron uses this for interval-based scheduling from a specific starting point
+ if (field.includes('/')) {
+ const [start, step] = field.split('/');
+ return this.validateStepField(start, step, min, max, type);
+ }
+
+ // Handle single values and special characters (L, W, #)
+ // These provide advanced scheduling capabilities like "last day of month" or "3rd Tuesday"
+ return this.validateSingleValue(field, min, max, type);
+ }
+
+ /**
+ * Validates single values and extended cron special characters.
+ *
+ * This method handles the validation of individual field values including:
+ * - Numeric values within specified ranges
+ * - Special characters for advanced scheduling
+ * - Named values like month names (JAN, FEB) and day names (SUN, MON)
+ *
+ * Extended Cron Special Characters:
+ * - L: Last day of month (day-of-month) or last occurrence of weekday (day-of-week)
+ * - W: Nearest weekday to the specified day (day-of-month only)
+ * - LW: Last weekday of the month (day-of-month only)
+ * - #: Nth occurrence of weekday (e.g., "3#2" = 3rd Tuesday of month)
+ *
+ * @param value - The single value to validate (e.g., "15", "L", "15W", "MON", "3#2")
+ * @param min - Minimum valid numeric value for this field type
+ * @param max - Maximum valid numeric value for this field type
+ * @param type - The cron field type for context-specific validation
+ * @returns boolean - True if the value is valid for EventBridge Scheduler
+ */
+ private validateSingleValue(
+ value: string,
+ min: number,
+ max: number,
+ type: string
+ ): boolean {
+ // Handle special characters for day of month field
+ // Extended cron supports advanced day-of-month scheduling patterns
+ if (type === 'dayOfMonth') {
+ // 'L' = last day of month, 'LW' = last weekday of month
+ if (value === 'L' || value === 'LW') return true;
+ // '15W' = nearest weekday to the 15th (if 15th is weekend, use closest weekday)
+ if (value.endsWith('W')) {
+ const day = Number(value.slice(0, -1));
+ return day >= 1 && day <= 31;
+ }
+ }
+
+ // Handle special characters for day of week field
+ // Extended cron supports advanced day-of-week scheduling patterns
+ if (type === 'dayOfWeek') {
+ // 'MONL' = last Monday of month (last occurrence)
+ if (value.endsWith('L')) {
+ const day = value.slice(0, -1);
+ return this.isValidDayOfWeek(day);
+ }
+ // '3#2' = 3rd Tuesday of month (3=Tuesday, 2=second occurrence)
+ if (value.includes('#')) {
+ const [day, week] = value.split('#');
+ return (
+ this.isValidDayOfWeek(day) && Number(week) >= 1 && Number(week) <= 5
+ );
+ }
+ // Standard day names: SUN, MON, TUE, etc.
+ if (this.isValidDayOfWeek(value)) return true;
+ }
+
+ // Handle month names (JAN, FEB, MAR, etc.) for month field
+ // Extended cron allows both numeric (1-12) and named month values
+ if (type === 'month' && this.isValidMonthName(value)) return true;
+
+ // Validate numeric values within the specified range
+ // This covers standard numeric scheduling (minutes: 0-59, hours: 0-23, etc.)
+ const num = Number(value);
+ return !isNaN(num) && num >= min && num <= max;
+ }
+
+ /**
+ * Validates range patterns with step intervals.
+ *
+ * This method handles complex range-step patterns like "1-5/2" which means
+ * "every 2nd value from 1 to 5" (resulting in: 1, 3, 5).
+ *
+ * Extended cron uses this pattern for flexible interval scheduling within specific ranges.
+ * For example: "9-17/2" for hours would trigger at 9:00, 11:00, 13:00, 15:00, 17:00.
+ *
+ * @param start - Range start value (numeric or named like "MON")
+ * @param end - Range end value (numeric or named like "FRI")
+ * @param step - Step interval as string (must be positive integer)
+ * @param min - Minimum allowed value for this field type
+ * @param max - Maximum allowed value for this field type
+ * @param type - Field type for context-aware validation (dayOfWeek gets special handling)
+ * @returns boolean - True if the range-step pattern is valid for EventBridge
+ */
+ private validateRangeWithStep(
+ start: string,
+ end: string,
+ step: string,
+ min: number,
+ max: number,
+ type: string
+ ): boolean {
+ // Validate step value - must be positive integer
+ const stepNum = Number(step);
+ if (isNaN(stepNum) || stepNum <= 0) return false;
+
+ // Special handling for day-of-week ranges (supports named days like MON-FRI)
+ // Extended cron allows both numeric (1-7) and named (SUN-SAT) day ranges
+ if (type === 'dayOfWeek') {
+ return this.isValidDayOfWeek(start) && this.isValidDayOfWeek(end);
+ }
+
+ // Special handling for month ranges (supports named months like JAN-DEC)
+ // Extended cron allows both numeric (1-12) and named (JAN-DEC) month ranges
+ if (type === 'month') {
+ const startValid =
+ this.isValidMonthName(start) ||
+ this.validateSingleValue(start, min, max, type);
+ const endValid =
+ this.isValidMonthName(end) ||
+ this.validateSingleValue(end, min, max, type);
+ return startValid && endValid;
+ }
+
+ // Validate numeric ranges for other field types
+ // Ensures range boundaries are valid and logical (start <= end)
+ const startNum = Number(start);
+ const endNum = Number(end);
+ return (
+ !isNaN(startNum) &&
+ !isNaN(endNum) &&
+ startNum >= min &&
+ endNum <= max &&
+ startNum <= endNum
+ );
+ }
+
+ /**
+ * Validates simple range patterns without step intervals.
+ *
+ * This method handles basic range patterns like "1-5" (values 1 through 5) or
+ * "MON-FRI" (Monday through Friday). Extended cron supports both numeric and
+ * named ranges for flexible scheduling.
+ *
+ * Examples:
+ * - "9-17" for hours: triggers every hour from 9:00 to 17:00 (9am to 5pm)
+ * - "MON-FRI" for day-of-week: triggers Monday through Friday
+ * - "JAN-MAR" for months: triggers January through March
+ *
+ * @param start - Range start value (numeric or named)
+ * @param end - Range end value (numeric or named)
+ * @param min - Minimum allowed value for this field type
+ * @param max - Maximum allowed value for this field type
+ * @param type - Field type for validation context (affects named value handling)
+ * @returns boolean - True if the range pattern is valid for EventBridge
+ */
+ private validateSimpleRange(
+ start: string,
+ end: string,
+ min: number,
+ max: number,
+ type: string
+ ): boolean {
+ // Handle day-of-week ranges with named values (MON-FRI, SUN-SAT, etc.)
+ // Extended cron supports both numeric (1-7) and named day ranges
+ if (type === 'dayOfWeek') {
+ return this.isValidDayOfWeek(start) && this.isValidDayOfWeek(end);
+ }
+
+ // Handle month ranges with named values (JAN-DEC, etc.)
+ // Extended cron allows both numeric (1-12) and named month ranges
+ if (type === 'month') {
+ const startValid =
+ this.isValidMonthName(start) ||
+ this.validateSingleValue(start, min, max, type);
+ const endValid =
+ this.isValidMonthName(end) ||
+ this.validateSingleValue(end, min, max, type);
+ return startValid && endValid;
+ }
+
+ // Validate numeric ranges for other field types
+ // Ensures both range boundaries are numeric, within limits, and logical (start <= end)
+ const startNum = Number(start);
+ const endNum = Number(end);
+ return (
+ !isNaN(startNum) &&
+ !isNaN(endNum) &&
+ startNum >= min &&
+ endNum <= max &&
+ startNum <= endNum
+ );
+ }
+
+ /**
+ * Validates step patterns from a starting point.
+ *
+ * This method handles step patterns like "5/10" (every 10th value starting from 5)
+ * or asterisk/15 (every 15th value starting from minimum). Extended cron uses this for
+ * interval-based scheduling from specific starting points.
+ *
+ * Examples:
+ * - "0/15" for minutes: triggers at 0, 15, 30, 45 minutes past the hour
+ * - asterisk/5 for minutes: triggers every 5 minutes (0, 5, 10, 15, ...)
+ * - "2/3" for day-of-month: triggers every 3rd day starting from the 2nd (2, 5, 8, ...)
+ *
+ * @param start - Starting value (can be asterisk for wildcard start or specific value)
+ * @param step - Step interval as string (must be positive integer)
+ * @param min - Minimum allowed value for this field type
+ * @param max - Maximum allowed value for this field type
+ * @param type - Field type for validation context
+ * @returns boolean - True if the step pattern is valid for EventBridge
+ */
+ private validateStepField(
+ start: string,
+ step: string,
+ min: number,
+ max: number,
+ type: string
+ ): boolean {
+ // Validate step value - must be positive integer
+ const stepNum = Number(step);
+ if (isNaN(stepNum) || stepNum <= 0) return false;
+
+ // Handle wildcard start: asterisk/step means start from minimum value with given step
+ // This is the most common pattern for regular intervals (e.g., asterisk/15 = every 15 minutes)
+ if (start === '*') return true;
+
+ // Handle named day values for day-of-week step patterns
+ // Extended cron supports patterns like "MON/2" (every 2nd occurrence starting Monday)
+ if (type === 'dayOfWeek') {
+ return this.isValidDayOfWeek(start);
+ }
+
+ // Validate numeric start values - must be within field's valid range
+ // Extended cron requires the starting point to be a valid value for the field type
+ const startNum = Number(start);
+ return !isNaN(startNum) && startNum >= min && startNum <= max;
+ }
+
+ /**
+ * Validates day-of-month field with special characters.
+ */
+ private validateDayOfMonth(dayOfMonth: string): boolean {
+ return this.validateComplexField(dayOfMonth, 1, 31, 'dayOfMonth');
+ }
+
+ /**
+ * Validates month field with support for named months.
+ */
+ private validateMonth(month: string): boolean {
+ return this.validateComplexField(month, 1, 12, 'month');
+ }
+
+ /**
+ * Validates day-of-week field with support for named days and special characters.
+ */
+ private validateDayOfWeek(dayOfWeek: string): boolean {
+ // Check for multiple hash expressions in day-of-week field
+ if (dayOfWeek.includes(',') && dayOfWeek.includes('#')) {
+ const parts = dayOfWeek.split(',');
+ const hashCount = parts.filter((part) =>
+ part.trim().includes('#')
+ ).length;
+
+ // AWS EventBridge: Only one hash expression allowed per day-of-week field
+ if (hashCount > 1) {
+ return false;
+ }
+ }
+
+ return this.validateComplexField(dayOfWeek, 1, 7, 'dayOfWeek');
+ }
+
+ /**
+ * Validates mutual exclusivity rule for day-of-month and day-of-week fields.
+ * Extended cron requires that one of these fields must be a wildcard.
+ */
+ private validateDayMutualExclusivity(
+ dayOfMonth: string,
+ dayOfWeek: string
+ ): boolean {
+ // Both cannot be specific values - one must be * or ?
+ const dayOfMonthIsWildcard = dayOfMonth === '*' || dayOfMonth === '?';
+ const dayOfWeekIsWildcard = dayOfWeek === '*' || dayOfWeek === '?';
+
+ return dayOfMonthIsWildcard || dayOfWeekIsWildcard;
+ }
+
+ /**
+ * Checks if a value represents a valid day of the week (numeric or named).
+ */
+ private isValidDayOfWeek(day: string): boolean {
+ const validDays = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
+ if (validDays.includes(day.toUpperCase())) return true;
+
+ const num = Number(day);
+ return !isNaN(num) && num >= 1 && num <= 7;
+ }
+
+ /**
+ * Checks if a value represents a valid month name.
+ */
+ private isValidMonthName(month: string): boolean {
+ const validMonths = [
+ 'JAN',
+ 'FEB',
+ 'MAR',
+ 'APR',
+ 'MAY',
+ 'JUN',
+ 'JUL',
+ 'AUG',
+ 'SEP',
+ 'OCT',
+ 'NOV',
+ 'DEC'
+ ];
+ return validMonths.includes(month.toUpperCase());
+ }
+}