diff --git a/js/src/util/time.js b/js/src/util/time.js index 758f7c889..080babe15 100644 --- a/js/src/util/time.js +++ b/js/src/util/time.js @@ -189,5 +189,5 @@ export const isAmPm = locale => */ export const isValidTime = time => { const d = new Date(`1970-01-01 ${time}`) - return d instanceof Date && d.getTime() + return d instanceof Date && !Number.isNaN(d.getTime()) } diff --git a/js/tests/unit/range-slider.spec.js b/js/tests/unit/range-slider.spec.js index 0af785f50..89f0a38f9 100644 --- a/js/tests/unit/range-slider.spec.js +++ b/js/tests/unit/range-slider.spec.js @@ -2,7 +2,7 @@ import EventHandler from '../../src/dom/event-handler.js' import RangeSlider from '../../src/range-slider.js' import { - clearFixture, createEvent, getFixture, jQueryMock + clearFixture, getFixture, jQueryMock } from '../helpers/fixture.js' describe('RangeSlider', () => { diff --git a/js/tests/unit/rating.spec.js b/js/tests/unit/rating.spec.js new file mode 100644 index 000000000..5b3a96074 --- /dev/null +++ b/js/tests/unit/rating.spec.js @@ -0,0 +1,363 @@ +/* eslint-env jasmine */ + +import Rating from '../../src/rating.js' +import { + getFixture, clearFixture, createEvent, jQueryMock +} from '../helpers/fixture.js' + +describe('Rating', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(Rating.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('DefaultType', () => { + it('should return plugin default type config', () => { + expect(Rating.DefaultType).toEqual(jasmine.any(Object)) + }) + }) + + describe('NAME', () => { + it('should return plugin NAME', () => { + expect(Rating.NAME).toEqual('rating') + }) + }) + + describe('constructor', () => { + it('should create a Rating instance with default config if no config is provided', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + const rating = new Rating(div) + + expect(rating).toBeInstanceOf(Rating) + expect(rating._config).toBeDefined() + expect(rating._element).toEqual(div) + }) + + it('should allow overriding default config', () => { + fixtureEl.innerHTML = '' + + const div = fixtureEl.querySelector('div') + const rating = new Rating(div, { + itemCount: 3, + value: 2, + readOnly: true + }) + + expect(rating._config.itemCount).toEqual(3) + expect(rating._currentValue).toEqual(2) + expect(rating._config.readOnly).toBeTrue() + }) + + it('should apply the "disabled" class when config.disabled = true', () => { + fixtureEl.innerHTML = '' + + const div = fixtureEl.querySelector('div') + // for instance: disabled is set to true + const rating = new Rating(div, { disabled: true }) + expect(rating._element.classList).toContain('disabled') + }) + + it('should create the correct number of rating items', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new Rating(div, { itemCount: 5 }) // eslint-disable-line no-new + + expect(div.querySelectorAll('.rating-item')).toHaveSize(5) + }) + + it('should set the initial checked input if "value" is provided', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new Rating(div, { value: 2 }) // eslint-disable-line no-new + + const checkedInputs = div.querySelectorAll('.rating-item-input:checked') + expect(checkedInputs).toHaveSize(1) + expect(checkedInputs[0].value).toEqual('2') + }) + }) + + describe('update', () => { + it('should update config and re-render the rating UI', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const rating = new Rating(div, { itemCount: 3, value: 1 }) + + const previousHTML = div.innerHTML + rating.update({ itemCount: 5, value: 3 }) + + expect(div.innerHTML).not.toEqual(previousHTML) + expect(div.querySelectorAll('.rating-item')).toHaveSize(5) + const checkedInput = div.querySelector('.rating-item-input:checked') + expect(checkedInput.value).toEqual('3') + }) + }) + + describe('reset', () => { + it('should reset the rating to the new given value', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const rating = new Rating(div, { itemCount: 5, value: 4 }) + + rating.reset(2) + const checkedInput = div.querySelector('.rating-item-input:checked') + expect(checkedInput.value).toEqual('2') + }) + + it('should reset the rating to null if no argument is provided', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const rating = new Rating(div, { value: 3 }) + + rating.reset() + const checkedInput = div.querySelector('.rating-item-input:checked') + expect(checkedInput).toBeNull() + }) + + it('should emit a "change.coreui.rating" event on reset', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const rating = new Rating(div, { value: 3 }) + const listener = jasmine.createSpy('listener') + + div.addEventListener('change.coreui.rating', listener) + rating.reset() + expect(listener).toHaveBeenCalled() + }) + }) + + describe('events', () => { + it('should emit "change.coreui.rating" when a rating input is changed', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new Rating(div, { itemCount: 3 }) // eslint-disable-line no-new + + div.addEventListener('change.coreui.rating', event => { + expect(event.value).toBe('2') + resolve() + }) + + // Simulate clicking the second radio + const input = div.querySelectorAll('.rating-item-label')[1] + input.click() + }) + }) + + it('should clear the rating if "allowClear" is true and the same value is clicked again', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + // eslint-disable-next-line no-new + new Rating(div, { + value: 2, + allowClear: true, + itemCount: 5 + }) + + // Listen for a new change event + div.addEventListener('change.coreui.rating', event => { + expect(event.value).toBeNull() + resolve() + }) + + const input2 = div.querySelectorAll('.rating-item-input')[1] // value="2" + input2.click() + }) + }) + + it('should emit "hover.coreui.rating" on mouseenter with the hovered value', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new Rating(div, { itemCount: 3 }) // eslint-disable-line no-new + + const label = div.querySelectorAll('.rating-item-label')[1] + div.addEventListener('hover.coreui.rating', event => { + expect(event.value).toBe('2') + resolve() + }) + + const mouseover = createEvent('mouseover') + label.dispatchEvent(mouseover) + }) + }) + + it('should remove "active" class from all labels when mouse leaves, unless an input is checked', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new Rating(div, { itemCount: 3, value: 2 }) // eslint-disable-line no-new + + const label = div.querySelectorAll('.rating-item-label')[2] + // first hover: + const mouseenter = createEvent('mouseenter') + label.dispatchEvent(mouseenter) + // all previous labels (0,1,2) active + + const mouseleave = createEvent('mouseleave') + label.dispatchEvent(mouseleave) + + // Because the rating has a checked input for value="2", items 0 & 1 should remain active + const activeLabels = div.querySelectorAll('.rating-item-label.active') + expect(activeLabels).toHaveSize(2) + }) + + it('should remove all "active" classes if no input is checked and mouse leaves', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new Rating(div, { itemCount: 3, value: null }) // eslint-disable-line no-new + + const label = div.querySelectorAll('.rating-item-label')[1] + const mouseover = createEvent('mouseover') + label.dispatchEvent(mouseover) + + let activeLabels = div.querySelectorAll('.rating-item-label.active') + expect(activeLabels).toHaveSize(2) // items[0] and items[1] + + const mouseout = createEvent('mouseout') + label.dispatchEvent(mouseout) + + activeLabels = div.querySelectorAll('.rating-item-label.active') + expect(activeLabels).toHaveSize(0) + }) + }) + + describe('readonly & disabled', () => { + it('should not change or hover if readOnly is true', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new Rating(div, { itemCount: 3, readOnly: true }) // eslint-disable-line no-new + + // Attempt to click on an input + const inputs = div.querySelectorAll('.rating-item-input') + inputs[1].click() + + const checkedInput = div.querySelector('.rating-item-input:checked') + expect(checkedInput).toBeNull() // Did not change + + // Attempt to trigger mouseenter + const label = div.querySelectorAll('.rating-item-label')[1] + const mouseenter = createEvent('mouseenter') + label.dispatchEvent(mouseenter) + + const activeLabels = div.querySelectorAll('.rating-item-label.active') + expect(activeLabels).toHaveSize(0) + }) + + it('should not change or hover if disabled is true', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new Rating(div, { itemCount: 3, disabled: true }) // eslint-disable-line no-new + + // Attempt to click on an input + const inputs = div.querySelectorAll('.rating-item-input') + inputs[1].click() + + const checkedInput = div.querySelector('.rating-item-input:checked') + expect(checkedInput).toBeNull() // Did not change + }) + }) + + describe('data-api', () => { + it('should create rating elements on window load event', () => { + fixtureEl.innerHTML = ` + + ` + const ratingEl = fixtureEl.querySelector('#myRating') + + // Manually trigger the load event + const loadEvent = createEvent('load') + window.dispatchEvent(loadEvent) + + const ratingInstance = Rating.getInstance(ratingEl) + expect(ratingInstance).not.toBeNull() + expect(ratingInstance._config.itemCount).toEqual(4) + expect(ratingInstance._currentValue).toEqual(2) // from data attribute + }) + }) + + describe('jQueryInterface', () => { + it('should create a rating via jQueryInterface', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.rating = Rating.jQueryInterface + jQueryMock.elements = [div] + jQueryMock.fn.rating.call(jQueryMock) + + expect(Rating.getInstance(div)).not.toBeNull() + }) + + it('should throw error on undefined method', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + jQueryMock.fn.rating = Rating.jQueryInterface + jQueryMock.elements = [div] + + expect(() => { + jQueryMock.fn.rating.call(jQueryMock, 'noMethod') + }).toThrowError(TypeError, 'No method named "noMethod"') + }) + }) + + describe('getInstance', () => { + it('should return rating instance', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const rating = new Rating(div) + + expect(Rating.getInstance(div)).toEqual(rating) + expect(Rating.getInstance(div)).toBeInstanceOf(Rating) + }) + + it('should return null when there is no rating instance', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + + expect(Rating.getInstance(div)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return rating instance', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const rating = new Rating(div) + + expect(Rating.getOrCreateInstance(div)).toEqual(rating) + expect(Rating.getInstance(div)).toEqual(Rating.getOrCreateInstance(div, {})) + expect(Rating.getOrCreateInstance(div)).toBeInstanceOf(Rating) + }) + + it('should return new instance when there is no rating instance', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + + expect(Rating.getInstance(div)).toBeNull() + expect(Rating.getOrCreateInstance(div)).toBeInstanceOf(Rating) + }) + + it('should return the same instance when exists, ignoring new configuration', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const rating = new Rating(div, { itemCount: 3 }) + + const rating2 = Rating.getOrCreateInstance(div, { itemCount: 5 }) + expect(rating2).toEqual(rating) + // config should still show itemCount as 3 + expect(rating2._config.itemCount).toEqual(3) + }) + }) +}) diff --git a/js/tests/unit/time-picker.spec.js b/js/tests/unit/time-picker.spec.js new file mode 100644 index 000000000..63d069eab --- /dev/null +++ b/js/tests/unit/time-picker.spec.js @@ -0,0 +1,425 @@ +/* eslint-env jasmine */ + +import TimePicker from '../../src/time-picker.js' +import { + getFixture, clearFixture, createEvent, jQueryMock +} from '../helpers/fixture.js' + +describe('TimePicker', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(TimePicker.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('constructor', () => { + it('should create a time picker instance with default config if no config is provided', () => { + fixtureEl.innerHTML = '' + + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div) + + expect(tp).toBeInstanceOf(TimePicker) + expect(tp._config).toBeDefined() + expect(tp._element).toEqual(div) + }) + + it('should set initial time if provided in config', () => { + fixtureEl.innerHTML = '' + + const div = fixtureEl.querySelector('div') + const timeString = '10:15:30' + const tp = new TimePicker(div, { + time: timeString + }) + + // _date is set internally, check that it was interpreted as 10:15:30 + expect(tp._date).toBeInstanceOf(Date) + expect(tp._date.getHours()).toBe(10) + expect(tp._date.getMinutes()).toBe(15) + expect(tp._date.getSeconds()).toBe(30) + }) + + it('should create input group and dropdown by default (type=dropdown)', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('#myTimePicker') + new TimePicker(div) // eslint-disable-line no-new + + // Check the structure + const inputGroup = div.querySelector('.time-picker-input-group') + expect(inputGroup).not.toBeNull() + + const dropdown = div.querySelector('.time-picker-dropdown') + expect(dropdown).not.toBeNull() + }) + + it('should set size class if config.size is given', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new TimePicker(div, { size: 'lg' }) // eslint-disable-line no-new + + expect(div.classList.contains('time-picker-lg')).toBeTrue() + }) + + it('should add "disabled" class if config.disabled is true', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new TimePicker(div, { disabled: true }) // eslint-disable-line no-new + + expect(div.classList.contains('disabled')).toBeTrue() + }) + + it('should add "is-valid" class if config.valid is true', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new TimePicker(div, { valid: true }) // eslint-disable-line no-new + + expect(div.classList.contains('is-valid')).toBeTrue() + }) + + it('should add "is-invalid" class if config.invalid is true', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new TimePicker(div, { invalid: true }) // eslint-disable-line no-new + + expect(div.classList.contains('is-invalid')).toBeTrue() + }) + }) + + describe('toggle', () => { + it('should show when toggled if not shown, and hide if shown', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div) + + // Initially hidden + expect(div.classList.contains('show')).toBeFalse() + + // Listen for show event + div.addEventListener('shown.coreui.time-picker', () => { + expect(div.classList.contains('show')).toBeTrue() + + // Toggle again to hide + tp.toggle() + }) + + // Listen for hide event + div.addEventListener('hidden.coreui.time-picker', () => { + expect(div.classList.contains('show')).toBeFalse() + resolve() + }) + + // Start toggling + tp.toggle() + }) + }) + }) + + describe('show', () => { + it('should do nothing if already shown', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div) + + // Show it once + tp.show() + // Show it again + tp.show() + + // Should still be shown exactly once + expect(div.classList.contains('show')).toBeTrue() + }) + + it('should set aria-expanded="true" when shown', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div) + + tp.show() + expect(div.getAttribute('aria-expanded')).toBe('true') + }) + + it('should emit show.coreui.time-picker and shown.coreui.time-picker events', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new TimePicker(div) // eslint-disable-line no-new + + let showEventTriggered = false + + div.addEventListener('show.coreui.time-picker', () => { + showEventTriggered = true + }) + + div.addEventListener('shown.coreui.time-picker', () => { + expect(showEventTriggered).toBeTrue() + expect(div.classList.contains('show')).toBeTrue() + resolve() + }) + + TimePicker.getInstance(div).show() + }) + }) + }) + + describe('hide', () => { + it('should emit hide.coreui.time-picker and hidden.coreui.time-picker events', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + new TimePicker(div) // eslint-disable-line no-new + + let hideEventTriggered = false + + div.addEventListener('hide.coreui.time-picker', () => { + hideEventTriggered = true + }) + + div.addEventListener('hidden.coreui.time-picker', () => { + expect(hideEventTriggered).toBeTrue() + expect(div.classList.contains('show')).toBeFalse() + resolve() + }) + + // Show first, then hide + TimePicker.getInstance(div).show() + TimePicker.getInstance(div).hide() + }) + }) + }) + + describe('cancel', () => { + it('should revert to _initialDate and emit timeChange', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const timeString = '11:30:00' + const tp = new TimePicker(div, { time: timeString }) + + tp.show() // sets _initialDate = new Date('1970-01-01 11:30:00') + // Modify the time to something else + tp._date = new Date('1970-01-01T13:00:00') + + div.addEventListener('timeChange.coreui.time-picker', event => { + // Should revert to 11:30:00 + expect(event.date.getHours()).toBe(11) + expect(event.date.getMinutes()).toBe(30) + resolve() + }) + + tp.cancel() + }) + }) + }) + + describe('clear', () => { + it('should set _date to null, clear the input value, emit timeChange', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div, { time: '10:00:00' }) + + div.addEventListener('timeChange.coreui.time-picker', event => { + expect(event.date).toBeNull() + expect(tp._date).toBeNull() + expect(tp._input.value).toBe('') + resolve() + }) + + tp.clear() + }) + }) + }) + + describe('reset', () => { + it('should revert to the config.time, re-render, emit timeChange', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div, { time: '10:00:00' }) + + // Change it to something else + tp._date = new Date('1970-01-01T15:00:00') + expect(tp._date).not.toContain('10:') + + div.addEventListener('timeChange.coreui.time-picker', event => { + // Should revert to '10:00:00' + expect(event.date).toBeInstanceOf(Date) + expect(event.date.getHours()).toBe(10) + resolve() + }) + + tp.reset() + }) + }) + }) + + describe('update', () => { + it('should update config, date, ampm, then re-render time picker selection', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div, { time: '08:00:00' }) + + const oldDate = tp._date + tp.update({ time: '09:30:00' }) + + expect(tp._date).not.toEqual(oldDate) + expect(tp._date.getHours()).toBe(9) + expect(tp._date.getMinutes()).toBe(30) + }) + }) + + describe('dispose', () => { + it('should dispose the TimePicker instance', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div) + + const spy = spyOn(tp, 'dispose').and.callThrough() + + tp.dispose() + expect(spy).toHaveBeenCalled() + expect(TimePicker.getInstance(div)).toBeNull() + }) + }) + + describe('Events', () => { + it('should emit timeChange event when an item is clicked in the roll variant', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div, { + time: '01:00:00', + variant: 'roll' + }) + + // Show so that body is rendered + tp.show() + + // Wait for next tick + setTimeout(() => { + div.addEventListener('timeChange.coreui.time-picker', event => { + expect(event.date.getHours()).toBe(2) + resolve() + }) + + // Find the "2" hours cell in the roll + const hourCells = div.querySelectorAll('[data-coreui-hours]') + // hourCells likely includes "1", "2", "3", etc. + const hourCell2 = Array.from(hourCells).find(cell => + cell.getAttribute('data-coreui-hours') === '2' + ) + hourCell2.click() + }, 10) + }) + }) + }) + + describe('data-api', () => { + it('should initialize timePickers on load event', () => { + fixtureEl.innerHTML = ` + + ` + const tpEl = fixtureEl.querySelector('#tp1') + + // Trigger data-api load + const loadEvent = createEvent('load') + window.dispatchEvent(loadEvent) + + const tp = TimePicker.getInstance(tpEl) + expect(tp).not.toBeNull() + expect(tp._date.getHours()).toBe(9) + }) + }) + + describe('jQueryInterface', () => { + it('should create a time picker via jQueryInterface', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.timePicker = TimePicker.jQueryInterface + jQueryMock.elements = [div] + jQueryMock.fn.timePicker.call(jQueryMock) + + expect(TimePicker.getInstance(div)).not.toBeNull() + }) + + it('should throw error on undefined method', () => { + fixtureEl.innerHTML = '' + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.timePicker = TimePicker.jQueryInterface + jQueryMock.elements = [div] + + expect(() => { + jQueryMock.fn.timePicker.call(jQueryMock, 'noMethod') + }).toThrowError(TypeError, 'No method named "noMethod"') + }) + }) + + describe('getInstance', () => { + it('should return timePicker instance', () => { + fixtureEl.innerHTML = '' + + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div) + + expect(TimePicker.getInstance(div)).toEqual(tp) + expect(TimePicker.getInstance(div)).toBeInstanceOf(TimePicker) + }) + + it('should return null when there is no timePicker instance', () => { + fixtureEl.innerHTML = '' + + const div = fixtureEl.querySelector('div') + expect(TimePicker.getInstance(div)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return the existing instance if it exists', () => { + fixtureEl.innerHTML = '' + + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div) + expect(TimePicker.getOrCreateInstance(div)).toBe(tp) + }) + + it('should create a new instance if it does not exist', () => { + fixtureEl.innerHTML = '' + + const div = fixtureEl.querySelector('div') + expect(TimePicker.getInstance(div)).toBeNull() + + const tp = TimePicker.getOrCreateInstance(div, { + time: '12:45:00' + }) + expect(tp).toBeInstanceOf(TimePicker) + expect(tp._date.getHours()).toBe(12) + expect(tp._date.getMinutes()).toBe(45) + }) + + it('should ignore new config if instance already exists', () => { + fixtureEl.innerHTML = '' + + const div = fixtureEl.querySelector('div') + const tp = new TimePicker(div, { time: '01:00:00' }) + const tp2 = TimePicker.getOrCreateInstance(div, { time: '05:00:00' }) + + expect(tp2).toBe(tp) + // The new time should not override the old config + expect(tp._date.getHours()).toBe(1) + }) + }) +}) diff --git a/js/tests/unit/util/calendar.spec.js b/js/tests/unit/util/calendar.spec.js new file mode 100644 index 000000000..c1d515178 --- /dev/null +++ b/js/tests/unit/util/calendar.spec.js @@ -0,0 +1,541 @@ +/* eslint-env jasmine */ + +import { + convertIsoWeekToDate, + convertToDateObject, + createGroupsInArray, + getCalendarDate, + getDateBySelectionType, + getFirstAvailableDateInRange, + getMonthsNames, + getSelectableDates, + getYears, + getMonthDetails, + getWeekNumber, + isDateDisabled, + isDateInRange, + isDateSelected, + isDisableDateInRange, + isMonthDisabled, + isMonthSelected, + isMonthInRange, + isSameDateAs, + isToday, + isYearDisabled, + isYearSelected, + isYearInRange, + removeTimeFromDate +} from '../../../src/util/calendar.js' + +describe('Calendar Utilities', () => { + describe('convertIsoWeekToDate', () => { + it('should convert a valid ISO week string to the corresponding Monday', () => { + const result = convertIsoWeekToDate('2023W05') + // 2023-W05 starts on Monday, 2023-01-30 + expect(result).toBeInstanceOf(Date) + expect(result.getFullYear()).toBe(2023) + expect(result.getMonth()).toBe(0) // January + expect(result.getDate()).toBe(30) + }) + + it('should handle lowercase "w" in ISO string', () => { + const result = convertIsoWeekToDate('2023w10') + // 2023-W10 starts on Monday, 2023-03-06 + expect(result).toBeInstanceOf(Date) + expect(result.getFullYear()).toBe(2023) + expect(result.getMonth()).toBe(2) // March + expect(result.getDate()).toBe(6) + }) + + it('should return an Invalid Date if the input string is malformed', () => { + const result = convertIsoWeekToDate('abcdW01') + // Some browsers might interpret "NaN" year. Let's check if it's not a valid date + expect(Number.isNaN(result.getTime())).toBeTrue() + }) + }) + + describe('convertToDateObject', () => { + it('should return null if date is null', () => { + const result = convertToDateObject(null, 'day') + expect(result).toBeNull() + }) + + it('should return the same Date object if date is already a Date', () => { + const originalDate = new Date(2023, 0, 1) + const result = convertToDateObject(originalDate, 'day') + expect(result).toEqual(originalDate) + }) + + it('should parse a string date for "day" selectionType', () => { + const result = convertToDateObject('2023-02-15', 'day') + expect(result).toBeInstanceOf(Date) + expect(result.getFullYear()).toBe(2023) + expect(result.getMonth()).toBe(1) // February + expect(result.getDate()).toBe(15) + }) + + it('should call convertIsoWeekToDate for "week" selectionType', () => { + const result = convertToDateObject('2023W12', 'week') + // Check if we got a Monday in the 12th ISO week + // 2023-W12 starts on Monday, 2023-03-20 + expect(result).toBeInstanceOf(Date) + expect(result.getFullYear()).toBe(2023) + expect(result.getMonth()).toBe(2) + expect(result.getDate()).toBe(20) + }) + + it('should adjust timezone offset for "month" selectionType', () => { + // Simulate a date string + const result = convertToDateObject('2023-06-01', 'month') + // We only check it doesn't produce an invalid date + expect(result).toBeInstanceOf(Date) + }) + + it('should parse the string for "year" selectionType', () => { + const result = convertToDateObject('2025-08-15', 'year') + expect(result).toBeInstanceOf(Date) + expect(result.getFullYear()).toBe(2025) + // We only check that it's a valid date + }) + }) + + describe('createGroupsInArray', () => { + it('should create groups of arrays', () => { + const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9] + const result = createGroupsInArray(arr, 3) + // We have 9 items, grouping into 3 => roughly: [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + expect(result).toHaveSize(3) + expect(result[0]).toEqual([1, 2, 3]) + expect(result[1]).toEqual([4, 5, 6]) + expect(result[2]).toEqual([7, 8, 9]) + }) + + it('should handle an empty array', () => { + const result = createGroupsInArray([], 2) + expect(result).toEqual([[], []]) + }) + }) + + describe('getCalendarDate', () => { + const baseDate = new Date(2023, 0, 15) // Jan 15, 2023 + + it('should add month if view is "days"', () => { + const result = getCalendarDate(baseDate, 1, 'days') + expect(result.getFullYear()).toBe(2023) + expect(result.getMonth()).toBe(1) // February + expect(result.getDate()).toBe(1) + }) + + it('should add year if view is "months"', () => { + const result = getCalendarDate(baseDate, 2, 'months') + expect(result.getFullYear()).toBe(2025) + expect(result.getMonth()).toBe(0) + expect(result.getDate()).toBe(1) + }) + + it('should add 12 * order years if view is "years"', () => { + const result = getCalendarDate(baseDate, -1, 'years') + expect(result.getFullYear()).toBe(2023 - 12) + expect(result.getMonth()).toBe(0) + expect(result.getDate()).toBe(1) + }) + + it('should return the original date if order is 0', () => { + const result = getCalendarDate(baseDate, 0, 'days') + expect(result).toBe(baseDate) + }) + }) + + describe('getDateBySelectionType', () => { + it('should return null if date is null', () => { + expect(getDateBySelectionType(null, 'day')).toBeNull() + }) + + it('should return an ISO week string if selectionType is "week"', () => { + // 2023-03-13 is Monday of ISO week 11 + const date = new Date(2023, 2, 13) + const result = getDateBySelectionType(date, 'week') + expect(result).toBe('2023W11') + }) + + it('should return "YYYY-MM" if selectionType is "month"', () => { + const date = new Date(2023, 5, 10) // 2023-06-10 + const result = getDateBySelectionType(date, 'month') + expect(result).toBe('2023-06') + }) + + it('should return "YYYY" if selectionType is "year"', () => { + const date = new Date(2030, 0, 1) + const result = getDateBySelectionType(date, 'year') + expect(result).toBe('2030') + }) + + it('should return the Date object if selectionType is "day"', () => { + const date = new Date(2025, 10, 20) + const result = getDateBySelectionType(date, 'day') + expect(result).toBe(date) + }) + }) + + describe('getFirstAvailableDateInRange', () => { + it('should return the start date if there are no disabled dates', () => { + const start = new Date(2023, 0, 1) + const end = new Date(2023, 0, 5) + const result = getFirstAvailableDateInRange(start, end, null, null, undefined) + expect(result).toEqual(start) + }) + + it('should return the first not-disabled date in the range', () => { + const start = new Date(2023, 0, 1) + const end = new Date(2023, 0, 5) + const disabledDates = [ + new Date(2023, 0, 1), + new Date(2023, 0, 2) + ] + const result = getFirstAvailableDateInRange(start, end, null, null, disabledDates) + // The first available is Jan 3 + expect(result).toEqual(new Date(2023, 0, 3)) + }) + + it('should return null if all dates in the range are disabled', () => { + const start = new Date(2023, 0, 1) + const end = new Date(2023, 0, 2) + const disabledDates = [ + new Date(2023, 0, 1), + new Date(2023, 0, 2) + ] + const result = getFirstAvailableDateInRange(start, end, null, null, disabledDates) + expect(result).toBeNull() + }) + }) + + describe('getMonthsNames', () => { + it('should return an array of 12 month names (short)', () => { + const result = getMonthsNames('en-US', 'short') + expect(result).toHaveSize(12) + expect(result[0]).toBeDefined() + }) + + it('should return an array of 12 month names (long)', () => { + const result = getMonthsNames('en-US', 'long') + expect(result).toHaveSize(12) + expect(result[0]).toBeDefined() + }) + }) + + describe('getSelectableDates', () => { + it('should return all elements that match the default selector inside an element', () => { + const container = document.createElement('tr') + container.innerHTML = ` +