diff --git a/local-moodleappbehat/tests/behat/behat_app.php b/local-moodleappbehat/tests/behat/behat_app.php index 45dbaec51..6422be3ef 100644 --- a/local-moodleappbehat/tests/behat/behat_app.php +++ b/local-moodleappbehat/tests/behat/behat_app.php @@ -681,7 +681,7 @@ class behat_app extends behat_app_helper { if (!is_null($urlpattern)) { $this->getSession()->switchToWindow($windowNames[1]); $windowurl = $this->getSession()->getCurrentUrl(); - $windowhaspattern = preg_match("/$urlpattern/", $windowurl); + $windowhaspattern = !!preg_match("/$urlpattern/", $windowurl); $this->getSession()->switchToWindow($windowNames[0]); if ($not === $windowhaspattern) { diff --git a/src/addons/mod/forum/components/index/index.ts b/src/addons/mod/forum/components/index/index.ts index a9c6fc7e8..33c844ddf 100644 --- a/src/addons/mod/forum/components/index/index.ts +++ b/src/addons/mod/forum/components/index/index.ts @@ -48,7 +48,6 @@ import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { AddonModForumDiscussionOptionsMenuComponent } from '../discussion-options-menu/discussion-options-menu'; import { AddonModForumSortOrderSelectorComponent } from '../sort-order-selector/sort-order-selector'; import { CoreScreen } from '@services/screen'; -import { CoreArray } from '@singletons/array'; import { AddonModForumPrefetchHandler } from '../../services/handlers/prefetch'; import { AddonModForumModuleHandlerService } from '../../services/handlers/module'; import { CoreRatingProvider } from '@features/rating/services/rating'; @@ -561,7 +560,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom } if (this.discussions?.getSource().isOnlineDiscussion(disc)) { - return CoreArray.contains(newDiscussionData.discussionIds ?? [], disc.discussion); + return (newDiscussionData.discussionIds ?? []).includes(disc.discussion); } return false; diff --git a/src/addons/mod/forum/services/forum-sync.ts b/src/addons/mod/forum/services/forum-sync.ts index f033e9d22..f522dc34f 100644 --- a/src/addons/mod/forum/services/forum-sync.ts +++ b/src/addons/mod/forum/services/forum-sync.ts @@ -24,7 +24,6 @@ import { CoreSites } from '@services/sites'; import { CoreSync } from '@services/sync'; import { CoreUtils } from '@services/utils/utils'; import { makeSingleton, Translate } from '@singletons'; -import { CoreArray } from '@singletons/array'; import { CoreEvents } from '@singletons/events'; import { AddonModForum, @@ -90,7 +89,7 @@ export class AddonModForumSyncProvider extends CoreCourseActivitySyncBaseProvide // Do not sync same forum twice. const syncedForumIds: number[] = []; const promises = discussions.map(async discussion => { - if (CoreArray.contains(syncedForumIds, discussion.forumid)) { + if (syncedForumIds.includes(discussion.forumid)) { return; } @@ -123,7 +122,7 @@ export class AddonModForumSyncProvider extends CoreCourseActivitySyncBaseProvide // Do not sync same discussion twice. const syncedDiscussionIds: number[] = []; const promises = replies.map(async reply => { - if (CoreArray.contains(syncedDiscussionIds, reply.discussionid)) { + if (syncedDiscussionIds.includes(reply.discussionid)) { return; } diff --git a/src/addons/mod/forum/tests/behat/basic_usage.feature b/src/addons/mod/forum/tests/behat/basic_usage.feature index ddf3e4990..f2cfc19c1 100755 --- a/src/addons/mod/forum/tests/behat/basic_usage.feature +++ b/src/addons/mod/forum/tests/behat/basic_usage.feature @@ -191,7 +191,6 @@ Feature: Test basic usage of forum activity in app And I press "Save changes" in the app Then I should find "There was a problem connecting to the site. Please check your connection and try again." in the app - # TODO Fix this test in all Moodle versions Scenario: Delete a forum post (only online) Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app When I press "Add discussion topic" in the app @@ -215,9 +214,13 @@ Feature: Test basic usage of forum activity in app And I press "Cancel" in the app And I switch offline mode to "true" And I press "Display options" near "Reply" in the app - Then I should not find "Delete" in the app + Then I should find "Delete" in the app - When I close the popup in the app + When I press "Delete" in the app + Then I should find "There was a problem connecting to the site. Please check your connection and try again." in the app + + When I press "OK" in the app + And I close the popup in the app And I switch offline mode to "false" And I press "Display options" near "Reply" in the app And I press "Delete" in the app @@ -247,7 +250,7 @@ Feature: Test basic usage of forum activity in app And I switch offline mode to "true" And I press "None" near "test2" in the app And I press "0" near "Cancel" in the app - Then I should find "Data stored in the device because it couldn't be sent. It will be sent automatically later." inside the toast in the app + Then I should find "Data stored in the device because it couldn't be sent. It will be sent automatically later." in the app And I should find "Average of ratings: -" in the app And I should find "Average of ratings: 1" in the app diff --git a/src/addons/storagemanager/pages/courses-storage/courses-storage.ts b/src/addons/storagemanager/pages/courses-storage/courses-storage.ts index 0f2e8a215..6a459959d 100644 --- a/src/addons/storagemanager/pages/courses-storage/courses-storage.ts +++ b/src/addons/storagemanager/pages/courses-storage/courses-storage.ts @@ -126,7 +126,7 @@ export class AddonStorageManagerCoursesStoragePage implements OnInit, OnDestroy try { await Promise.all(deletedCourseIds.map((courseId) => CoreCourseHelper.deleteCourseFiles(courseId))); - this.setDownloadedCourses(this.downloadedCourses.filter((course) => !CoreArray.contains(deletedCourseIds, course.id))); + this.setDownloadedCourses(this.downloadedCourses.filter((course) => !deletedCourseIds.includes(course.id))); } catch (error) { CoreDomUtils.showErrorModalDefault(error, Translate.instant('core.errordeletefile')); } finally { diff --git a/src/core/services/ws.ts b/src/core/services/ws.ts index 9d9e270de..38eb55151 100644 --- a/src/core/services/ws.ts +++ b/src/core/services/ws.ts @@ -31,7 +31,6 @@ import { CoreConstants } from '@/core/constants'; import { CoreError } from '@classes/errors/error'; import { CoreInterceptor } from '@classes/interceptor'; import { makeSingleton, Translate, FileTransfer, Http, NativeHttp } from '@singletons'; -import { CoreArray } from '@singletons/array'; import { CoreLogger } from '@singletons/logger'; import { CoreWSError } from '@classes/errors/wserror'; import { CoreAjaxError } from '@classes/errors/ajaxerror'; @@ -282,7 +281,7 @@ export class CoreWSProvider { extension = CoreMimetypeUtils.getFileExtension(path) || ''; // Google Drive extensions will be considered invalid since Moodle usually converts them. - if (!extension || CoreArray.contains(['gdoc', 'gsheet', 'gslides', 'gdraw', 'php'], extension)) { + if (!extension || ['gdoc', 'gsheet', 'gslides', 'gdraw', 'php'].includes(extension)) { // Not valid, get the file's mimetype. const mimetype = await this.getRemoteFileMimeType(url); diff --git a/src/core/singletons/array.ts b/src/core/singletons/array.ts index 03a6ad4d3..5ed86de9b 100644 --- a/src/core/singletons/array.ts +++ b/src/core/singletons/array.ts @@ -23,8 +23,12 @@ export class CoreArray { * @param arr Array. * @param item Item. * @return Whether item is within the array. + * @deprecated since 4.1. Use arr.includes() instead. */ static contains(arr: T[], item: T): boolean { + // eslint-disable-next-line no-console + console.warn('CoreArray.contains is deprecated and will be removed soon. Please use array \'includes\' instead.'); + return arr.indexOf(item) !== -1; } diff --git a/src/core/singletons/colors.ts b/src/core/singletons/colors.ts index ed70b8bdf..6f734bf14 100644 --- a/src/core/singletons/colors.ts +++ b/src/core/singletons/colors.ts @@ -93,7 +93,7 @@ export class CoreColors { * @return Color in hex format. */ static getColorHex(color: string): string { - const rgba = CoreColors.getColorRGBA(color, true); + const rgba = CoreColors.getColorRGBA(color); if (rgba.length === 0) { return ''; } @@ -109,21 +109,20 @@ export class CoreColors { * Returns RGBA color from any color format. * * @param color Color in any format. - * @param createElement Wether create a new element is needed to calculate value. * @return Red, green, blue and alpha. */ - static getColorRGBA(color: string, createElement = false): number[] { - if (createElement) { + static getColorRGBA(color: string): number[] { + if (!color.match(/rgba?\(.*\)/)) { + // Convert the color to RGB format. const d = document.createElement('span'); d.style.color = color; document.body.appendChild(d); - // Color in RGB. color = getComputedStyle(d).color; document.body.removeChild(d); } - const matches = color.match(/\d+/g) || []; + const matches = color.match(/\d+[^.]|\d*\.\d*/g) || []; return matches.map((a, index) => index < 3 ? parseInt(a, 10) : parseFloat(a)); } diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 35f3968b2..4448f4d1b 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -192,8 +192,7 @@ export class CoreEvents { siteId?: string, ): CoreEventObserver { const listener = CoreEvents.on(eventName, (value) => { - setTimeout(() => listener.off(), 0); - + listener.off(); callBack(value); }, siteId); @@ -241,7 +240,7 @@ export class CoreEvents { if (siteId) { Object.assign(data || {}, { siteId }); } - this.observables[eventName].next(data); + this.observables[eventName].next(data || {}); } } diff --git a/src/core/singletons/form.ts b/src/core/singletons/form.ts index f4beefa7c..a40eb91f8 100644 --- a/src/core/singletons/form.ts +++ b/src/core/singletons/form.ts @@ -70,7 +70,7 @@ export class CoreForms { CoreEvents.trigger(CoreEvents.FORM_ACTION, { action: CoreEventFormAction.CANCEL, - form: formRef.nativeElement, + form: formRef.nativeElement || formRef, }, siteId); } diff --git a/src/core/singletons/tests/array.test.ts b/src/core/singletons/tests/array.test.ts new file mode 100644 index 000000000..0aee9eb92 --- /dev/null +++ b/src/core/singletons/tests/array.test.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreArray } from '@singletons/array'; + +describe('CoreArray singleton', () => { + + it('flattens arrays', () => { + expect(CoreArray.flatten([])).toEqual([]); + expect(CoreArray.flatten([[1, 2], [3, 4], [5, 6]])).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('gets array without an item', () => { + const originalArray = ['foo', 'bar', 'baz']; + + expect(CoreArray.withoutItem(originalArray, 'bar')).toEqual(['foo', 'baz']); + expect(CoreArray.withoutItem(originalArray, 'not found')).toEqual(['foo', 'bar', 'baz']); + }); + +}); diff --git a/src/core/singletons/tests/browser.test.ts b/src/core/singletons/tests/browser.test.ts new file mode 100644 index 000000000..dd2c3b7c0 --- /dev/null +++ b/src/core/singletons/tests/browser.test.ts @@ -0,0 +1,68 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreBrowser } from '@singletons/browser'; + +describe('CoreBrowser singleton', () => { + + it('detects if cookie exists', () => { + document.cookie = 'first-cookie=foo'; + document.cookie = 'second-cookie=bar'; + + expect(CoreBrowser.hasCookie('first-cookie')).toBe(true); + expect(CoreBrowser.hasCookie('second-cookie')).toBe(true); + expect(CoreBrowser.hasCookie('third-cookie')).toBe(false); + }); + + it('gets a cookie', () => { + document.cookie = 'first-cookie=foo'; + document.cookie = 'second-cookie=bar'; + + expect(CoreBrowser.getCookie('first-cookie')).toEqual('foo'); + expect(CoreBrowser.getCookie('second-cookie')).toEqual('bar'); + expect(CoreBrowser.getCookie('third-cookie')).toEqual(null); + }); + + it('detects if a localStorage entry exists', () => { + localStorage.setItem('first', 'foo'); + localStorage.setItem('second', 'bar'); + + expect(CoreBrowser.hasLocalStorage('first')).toBe(true); + expect(CoreBrowser.hasLocalStorage('second')).toBe(true); + expect(CoreBrowser.hasLocalStorage('third')).toBe(false); + }); + + it('gets a localStorage entry', () => { + localStorage.setItem('first', 'foo'); + localStorage.setItem('second', 'bar'); + + expect(CoreBrowser.getLocalStorage('first')).toEqual('foo'); + expect(CoreBrowser.getLocalStorage('second')).toEqual('bar'); + expect(CoreBrowser.getLocalStorage('third')).toEqual(null); + }); + + it('sets, gets and removes development settings', () => { + CoreBrowser.setDevelopmentSetting('first', 'foo'); + CoreBrowser.setDevelopmentSetting('second', 'bar'); + + expect(CoreBrowser.getDevelopmentSetting('first')).toEqual('foo'); + expect(CoreBrowser.getDevelopmentSetting('second')).toEqual('bar'); + expect(CoreBrowser.getDevelopmentSetting('third')).toEqual(null); + + CoreBrowser.clearDevelopmentSetting('second'); + + expect(CoreBrowser.getDevelopmentSetting('second')).toEqual(null); + }); + +}); diff --git a/src/core/singletons/tests/colors.test.ts b/src/core/singletons/tests/colors.test.ts new file mode 100644 index 000000000..0cefa4966 --- /dev/null +++ b/src/core/singletons/tests/colors.test.ts @@ -0,0 +1,94 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreColors } from '@singletons/colors'; + +describe('CoreColors singleton', () => { + + it('determines if white contrast is better', () => { + expect(CoreColors.isWhiteContrastingBetter('#000000')).toBe(true); + expect(CoreColors.isWhiteContrastingBetter('#999999')).toBe(true); + expect(CoreColors.isWhiteContrastingBetter('#aaaaaa')).toBe(false); + expect(CoreColors.isWhiteContrastingBetter('#ffffff')).toBe(false); + expect(CoreColors.isWhiteContrastingBetter('#ff0000')).toBe(true); + expect(CoreColors.isWhiteContrastingBetter('#00ff00')).toBe(false); + expect(CoreColors.isWhiteContrastingBetter('#0000ff')).toBe(true); + expect(CoreColors.isWhiteContrastingBetter('#ff00ff')).toBe(true); + expect(CoreColors.isWhiteContrastingBetter('#ffff00')).toBe(false); + }); + + it('makes color darker', () => { + expect(CoreColors.darker('#ffffff', 50)).toEqual('#7f7f7f'); + expect(CoreColors.darker('#ffffff', 20)).toEqual('#cccccc'); + expect(CoreColors.darker('#ffffff', 80)).toEqual('#323232'); + expect(CoreColors.darker('#aabbcc', 40)).toEqual('#66707a'); + }); + + it('makes color lighter', () => { + expect(CoreColors.lighter('#000000', 50)).toEqual('#7f7f7f'); + expect(CoreColors.lighter('#000000', 20)).toEqual('#333333'); + expect(CoreColors.lighter('#000000', 80)).toEqual('#cccccc'); + expect(CoreColors.lighter('#223344', 40)).toEqual('#7a848e'); + }); + + it('gets color hex value', () => { + expect(CoreColors.getColorHex('#123456')).toEqual('#123456'); + expect(CoreColors.getColorHex('rgb(255, 100, 70)')).toEqual('#ff6446'); + expect(CoreColors.getColorHex('rgba(255, 100, 70, 0.5)')).toEqual('#ff6446'); + + // @todo: There are problems when testing color names (e.g. violet) or hsf colors. + // They work fine in real browsers but not in unit tests. + }); + + it('gets color RGBA value', () => { + expect(CoreColors.getColorRGBA('#123456')).toEqual([18, 52, 86]); + expect(CoreColors.getColorRGBA('rgb(255, 100, 70)')).toEqual([255, 100, 70]); + expect(CoreColors.getColorRGBA('rgba(255, 100, 70, 0.5)')).toEqual([255, 100, 70, 0.5]); + + // @todo: There are problems when testing color names (e.g. violet) or hsf colors. + // They work fine in real browsers but not in unit tests. + }); + + it('converts hex to rgb', () => { + expect(CoreColors.hexToRGB('#000000')).toEqual({ + red: 0, + green: 0, + blue: 0, + }); + expect(CoreColors.hexToRGB('#ffffff')).toEqual({ + red: 255, + green: 255, + blue: 255, + }); + expect(CoreColors.hexToRGB('#aabbcc')).toEqual({ + red: 170, + green: 187, + blue: 204, + }); + }); + + it('gets toolbar background color', () => { + document.body.style.setProperty('--core-header-toolbar-background', '#aabbcc'); + expect(CoreColors.getToolbarBackgroundColor()).toEqual('#aabbcc'); + + const header = document.createElement('ion-header'); + const toolbar = document.createElement('ion-toolbar'); + toolbar.style.setProperty('--background', '#123456'); + header.appendChild(toolbar); + document.body.appendChild(header); + + expect(CoreColors.getToolbarBackgroundColor()).toEqual('#123456'); + }); + +}); diff --git a/src/core/singletons/tests/components-registry.test.ts b/src/core/singletons/tests/components-registry.test.ts new file mode 100644 index 000000000..4cdeb231a --- /dev/null +++ b/src/core/singletons/tests/components-registry.test.ts @@ -0,0 +1,104 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { wait } from '@/testing/utils'; +import { CoreComponentsRegistry } from '@singletons/components-registry'; + +const cssClassName = 'core-components-registry-test'; +const createAndRegisterInstance = () => { + const element = document.createElement('div'); + element.classList.add(cssClassName); + const instance = new ComponentsRegistryTestClass(); + + CoreComponentsRegistry.register(element, instance); + + return { element, instance }; +}; + +describe('CoreComponentsRegistry singleton', () => { + + let element: HTMLElement; + let testClassInstance: ComponentsRegistryTestClass; + + beforeEach(() => { + const result = createAndRegisterInstance(); + element = result.element; + testClassInstance = result.instance; + }); + + it('resolves stored instances', () => { + expect(CoreComponentsRegistry.resolve(element)).toEqual(testClassInstance); + expect(CoreComponentsRegistry.resolve(element, ComponentsRegistryTestClass)).toEqual(testClassInstance); + expect(CoreComponentsRegistry.resolve(element, CoreComponentsRegistry)).toEqual(null); + expect(CoreComponentsRegistry.resolve(document.createElement('div'))).toEqual(null); + }); + + it('requires stored instances', () => { + expect(CoreComponentsRegistry.require(element)).toEqual(testClassInstance); + expect(CoreComponentsRegistry.require(element, ComponentsRegistryTestClass)).toEqual(testClassInstance); + expect(() => CoreComponentsRegistry.require(element, CoreComponentsRegistry)).toThrow(); + expect(() => CoreComponentsRegistry.require(document.createElement('div'))).toThrow(); + }); + + it('waits for component ready', async () => { + expect(testClassInstance.isReady).toBe(false); + + await CoreComponentsRegistry.waitComponentReady(element); + + expect(testClassInstance.isReady).toBe(true); + }); + + it('waits for components ready: just one', async () => { + expect(testClassInstance.isReady).toBe(false); + + await CoreComponentsRegistry.waitComponentsReady(element, `.${cssClassName}`); + + expect(testClassInstance.isReady).toBe(true); + }); + + it('waits for components ready: multiple', async () => { + const secondResult = createAndRegisterInstance(); + const thirdResult = createAndRegisterInstance(); + thirdResult.element.classList.remove(cssClassName); // Remove the class so the element and instance aren't treated. + + const parent = document.createElement('div'); + parent.appendChild(element); + parent.appendChild(secondResult.element); + parent.appendChild(thirdResult.element); + + expect(testClassInstance.isReady).toBe(false); + expect(secondResult.instance.isReady).toBe(false); + expect(thirdResult.instance.isReady).toBe(false); + + await CoreComponentsRegistry.waitComponentsReady(parent, `.${cssClassName}`); + + expect(testClassInstance.isReady).toBe(true); + expect(secondResult.instance.isReady).toBe(true); + expect(thirdResult.instance.isReady).toBe(false); + }); + +}); + +class ComponentsRegistryTestClass { + + randomId = Math.random(); + isReady = false; + + async ready(): Promise { + await wait(50); + + this.isReady = true; + } + +} diff --git a/src/core/singletons/tests/events.test.ts b/src/core/singletons/tests/events.test.ts new file mode 100644 index 000000000..3b4d749d9 --- /dev/null +++ b/src/core/singletons/tests/events.test.ts @@ -0,0 +1,110 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreEvents } from '@singletons/events'; + +const eventName = 'my-event'; + +describe('CoreEvents singleton', () => { + + it('can be used to trigger and receive events', () => { + const callback = jest.fn(); + const secondCallback = jest.fn(); + const data = { foo: 'bar' }; + + const listener = CoreEvents.on(eventName, callback); + CoreEvents.on('another-event', secondCallback); + + CoreEvents.trigger(eventName, data); + + expect(callback).toHaveBeenCalledWith(data); + expect(callback).toHaveBeenCalledTimes(1); + expect(secondCallback).not.toHaveBeenCalled(); + + listener.off(); + CoreEvents.trigger(eventName, data); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('only calls the right listeners based on site ID', () => { + const callback = jest.fn(); + const secondCallback = jest.fn(); + const thirdCallback = jest.fn(); + const siteId = 'site-id'; + const data = { foo: 'bar' }; + const dataWithSiteId = { + ...data, + siteId, + }; + + CoreEvents.on(eventName, callback); + CoreEvents.on(eventName, secondCallback, siteId); + CoreEvents.on(eventName, thirdCallback, 'another-site-id'); + + CoreEvents.trigger(eventName, data, siteId); + + expect(callback).toHaveBeenCalledWith(dataWithSiteId); + expect(secondCallback).toHaveBeenCalledWith(dataWithSiteId); + expect(thirdCallback).not.toHaveBeenCalled(); + }); + + it('can call a listener only once', async () => { + const callback = jest.fn(); + + CoreEvents.once(eventName, callback); + CoreEvents.trigger(eventName); + + expect(callback).toHaveBeenCalledTimes(1); + + CoreEvents.trigger(eventName); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('can trigger a unique event', async () => { + const callback = jest.fn(); + const secondCallback = jest.fn(); + + CoreEvents.on(eventName, callback); + + CoreEvents.triggerUnique(eventName, {}); + expect(callback).toHaveBeenCalledTimes(1); + + CoreEvents.on(eventName, secondCallback); + expect(secondCallback).toHaveBeenCalledTimes(1); + + CoreEvents.triggerUnique(eventName, {}); + expect(callback).toHaveBeenCalledTimes(1); + expect(secondCallback).toHaveBeenCalledTimes(1); + }); + + it('allows listening to multiple events with a single call', async () => { + const callback = jest.fn(); + const secondEventName = 'second-event'; + + const listener = CoreEvents.onMultiple([eventName, secondEventName], callback); + + CoreEvents.trigger(eventName); + expect(callback).toHaveBeenCalledTimes(1); + + CoreEvents.trigger(secondEventName); + expect(callback).toHaveBeenCalledTimes(2); + + listener.off(); + + CoreEvents.trigger(eventName); + CoreEvents.trigger(secondEventName); + expect(callback).toHaveBeenCalledTimes(2); + }); + +}); diff --git a/src/core/singletons/tests/form.test.ts b/src/core/singletons/tests/form.test.ts new file mode 100644 index 000000000..bbdb9ca2b --- /dev/null +++ b/src/core/singletons/tests/form.test.ts @@ -0,0 +1,134 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ElementRef } from '@angular/core'; +import { CoreEventFormAction, CoreEvents } from '@singletons/events'; +import { CoreForms } from '@singletons/form'; + +const createInputElement = (type: string, name: string, value = ''): HTMLInputElement => { + const input = document.createElement('input'); + input.type = type; + input.name = name; + input.value = value; + + return input; +}; + +describe('CoreForms singleton', () => { + + it('gets data from form', () => { + // Create several types of inputs. + const textInput = createInputElement('text', 'mytext'); + const firstRadio = createInputElement('radio', 'myradio', 'firstradio'); + const secondRadio = createInputElement('radio', 'myradio', 'secondradio'); + const checkbox = createInputElement('checkbox', 'mycheckbox'); + const hiddenInput = createInputElement('hidden', 'myhidden', 'hiddenvalue'); + const submitInput = createInputElement('submit', 'submit'); + + const textarea = document.createElement('textarea'); + textarea.name = 'mytextarea'; + + const select = document.createElement('select'); + select.name = 'myselect'; + const firstOption = document.createElement('option'); + firstOption.value = 'firstoption'; + const secondOption = document.createElement('option'); + secondOption.value = 'secondoption'; + select.appendChild(firstOption); + select.appendChild(secondOption); + + // Create a form with the inputs. + const form = document.createElement('form'); + form.appendChild(textInput); + form.appendChild(firstRadio); + form.appendChild(secondRadio); + form.appendChild(checkbox); + form.appendChild(hiddenInput); + form.appendChild(submitInput); + form.appendChild(textarea); + form.appendChild(select); + + // Test data is retrieved. + const values: Record = { + mytext: '', + mycheckbox: false, + myhidden: 'hiddenvalue', + mytextarea: '', + myselect: 'firstoption', + }; + + expect(CoreForms.getDataFromForm(form)).toEqual(values); + + // Change some values and test again. + textInput.value = values.mytext = 'a value'; + select.value = values.myselect = 'secondoption'; + firstRadio.checked = true; + values.myradio = 'firstradio'; + checkbox.checked = values.mycheckbox = true; + textarea.value = values.mytextarea = 'textarea value'; + + expect(CoreForms.getDataFromForm(form)).toEqual(values); + }); + + it('triggers form action events', () => { + const form = document.createElement('form'); + const formElRef = new ElementRef(form); + const siteId = 'site-id'; + const callback = jest.fn(); + const secondCallback = jest.fn(); + + CoreEvents.on(CoreEvents.FORM_ACTION, callback, siteId); + CoreEvents.on(CoreEvents.FORM_ACTION, secondCallback, 'another-site'); + + CoreForms.triggerFormCancelledEvent(form, siteId); + expect(callback).toHaveBeenCalledWith({ + action: CoreEventFormAction.CANCEL, + form, + siteId, + }); + + CoreForms.triggerFormCancelledEvent(formElRef, siteId); + expect(callback).toHaveBeenCalledWith({ + action: CoreEventFormAction.CANCEL, + form, + siteId, + }); + + CoreForms.triggerFormSubmittedEvent(form, true, siteId); + expect(callback).toHaveBeenCalledWith({ + action: CoreEventFormAction.SUBMIT, + form, + online: true, + siteId, + }); + + CoreForms.triggerFormSubmittedEvent(form, false, siteId); + expect(callback).toHaveBeenCalledWith({ + action: CoreEventFormAction.SUBMIT, + form, + online: false, + siteId, + }); + + CoreForms.triggerFormSubmittedEvent(formElRef, true, siteId); + expect(callback).toHaveBeenCalledWith({ + action: CoreEventFormAction.SUBMIT, + form, + online: true, + siteId, + }); + expect(secondCallback).not.toHaveBeenCalled(); + }); + +}); diff --git a/src/core/singletons/tests/locutus.test.ts b/src/core/singletons/tests/locutus.test.ts new file mode 100644 index 000000000..4433477e4 --- /dev/null +++ b/src/core/singletons/tests/locutus.test.ts @@ -0,0 +1,49 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Locutus } from '@singletons/locutus'; + +describe('Locutus singleton', () => { + + it('unserializes PHP strings', () => { + expect(Locutus.unserialize('a:3:{s:1:"a";i:1;s:1:"b";i:2;s:3:"foo";s:3:"bar";}')).toEqual({ + a: 1, + b: 2, + foo: 'bar', + }); + + expect(Locutus.unserialize( + 'O:8:"stdClass":3:{s:3:"foo";s:3:"bar";s:5:"lorem";s:5:"ipsum";s:8:"subclass";O:8:"stdClass":1:{s:2:"ok";b:1;}}', + )).toEqual({ + foo: 'bar', + lorem: 'ipsum', + subclass: { + ok: true, + }, + }); + }); + + it('replaces text within a portion of a string', () => { + const originalText = 'A sample text.'; + const newText = 'foo'; + + expect(Locutus.substrReplace(originalText, newText, 0)).toEqual(newText); + expect(Locutus.substrReplace(originalText, newText, 0, originalText.length)).toEqual(newText); + expect(Locutus.substrReplace(originalText, newText, 0, 0)).toEqual(newText + originalText); + expect(Locutus.substrReplace(originalText, newText, 9, -1)).toEqual('A sample foo.'); + expect(Locutus.substrReplace(originalText, newText, -5, -1)).toEqual('A sample foo.'); + expect(Locutus.substrReplace(originalText, newText, 2, 6)).toEqual('A foo text.'); + }); + +}); diff --git a/src/core/singletons/tests/url.test.ts b/src/core/singletons/tests/url.test.ts index e8c7b7755..cc9c410ff 100644 --- a/src/core/singletons/tests/url.test.ts +++ b/src/core/singletons/tests/url.test.ts @@ -17,8 +17,11 @@ import { CoreUrl } from '@singletons/url'; describe('CoreUrl singleton', () => { it('parses standard urls', () => { - expect(CoreUrl.parse('https://my.subdomain.com/path/?query=search#hash')).toEqual({ + expect(CoreUrl.parse('https://u1:pw1@my.subdomain.com/path/?query=search#hash')).toEqual({ protocol: 'https', + credentials: 'u1:pw1', + username: 'u1', + password: 'pw1', domain: 'my.subdomain.com', path: '/path/', query: 'query=search', @@ -43,6 +46,46 @@ describe('CoreUrl singleton', () => { }); }); + it('assembles standard urls', () => { + expect(CoreUrl.assemble({ + protocol: 'https', + credentials: 'u1:pw1', + domain: 'my.subdomain.com', + path: '/path/', + query: 'query=search', + fragment: 'hash', + })).toEqual('https://u1:pw1@my.subdomain.com/path/?query=search#hash'); + }); + + it('guesses the Mooddle domain', () => { + // Check known endpoints first. + expect(CoreUrl.guessMoodleDomain('https://school.edu/moodle/my')).toEqual('school.edu/moodle'); + expect(CoreUrl.guessMoodleDomain('https://school.edu/moodle/?redirect=0')).toEqual('school.edu/moodle'); + expect(CoreUrl.guessMoodleDomain('https://school.edu/moodle/index.php')).toEqual('school.edu/moodle'); + expect(CoreUrl.guessMoodleDomain('https://school.edu/moodle/course/view.php')).toEqual('school.edu/moodle'); + expect(CoreUrl.guessMoodleDomain('https://school.edu/moodle/login/index.php')).toEqual('school.edu/moodle'); + expect(CoreUrl.guessMoodleDomain('https://school.edu/moodle/mod/page/view.php')).toEqual('school.edu/moodle'); + + // Check an unknown endpoint. + expect(CoreUrl.guessMoodleDomain('https://school.edu/moodle/unknown/endpoint.php')).toEqual('school.edu'); + }); + + it('detects valid Moodle urls', () => { + expect(CoreUrl.isValidMoodleUrl('https://school.edu')).toBe(true); + expect(CoreUrl.isValidMoodleUrl('https://school.edu/path/?query=search#hash')).toBe(true); + expect(CoreUrl.isValidMoodleUrl('//school.edu')).toBe(true); + expect(CoreUrl.isValidMoodleUrl('school.edu')).toBe(true); + expect(CoreUrl.isValidMoodleUrl('localhost')).toBe(true); + + expect(CoreUrl.isValidMoodleUrl('some text')).toBe(false); + }); + + it('removes protocol', () => { + expect(CoreUrl.removeProtocol('https://school.edu')).toEqual('school.edu'); + expect(CoreUrl.removeProtocol('ftp://school.edu')).toEqual('school.edu'); + expect(CoreUrl.removeProtocol('school.edu')).toEqual('school.edu'); + }); + it('compares domains and paths', () => { expect(CoreUrl.sameDomainAndPath('https://school.edu', 'https://school.edu')).toBe(true); expect(CoreUrl.sameDomainAndPath('https://school.edu', 'HTTPS://SCHOOL.EDU')).toBe(true); @@ -56,6 +99,18 @@ describe('CoreUrl singleton', () => { expect(CoreUrl.sameDomainAndPath('https://school.edu/moodle', 'https://school.edu/moodle/about')).toBe(false); }); + it('gets the anchor of a URL', () => { + expect(CoreUrl.getUrlAnchor('https://school.edu#foo')).toEqual('#foo'); + expect(CoreUrl.getUrlAnchor('https://school.edu#foo#bar')).toEqual('#foo#bar'); + expect(CoreUrl.getUrlAnchor('https://school.edu')).toEqual(undefined); + }); + + it('removes the anchor of a URL', () => { + expect(CoreUrl.removeUrlAnchor('https://school.edu#foo')).toEqual('https://school.edu'); + expect(CoreUrl.removeUrlAnchor('https://school.edu#foo#bar')).toEqual('https://school.edu'); + expect(CoreUrl.removeUrlAnchor('https://school.edu')).toEqual('https://school.edu'); + }); + it('converts to absolute URLs', () => { expect(CoreUrl.toAbsoluteURL('https://school.edu/foo/bar', 'https://mysite.edu')).toBe('https://mysite.edu'); expect(CoreUrl.toAbsoluteURL('https://school.edu/foo/bar', '//mysite.edu')).toBe('https://mysite.edu'); diff --git a/src/testing/services/behat-dom.ts b/src/testing/services/behat-dom.ts index 761806d69..e0b1af642 100644 --- a/src/testing/services/behat-dom.ts +++ b/src/testing/services/behat-dom.ts @@ -17,6 +17,9 @@ import { NgZone } from '@singletons'; import { TestsBehatBlocking } from './behat-blocking'; import { TestBehatElementLocator } from './behat-runtime'; +// Containers that block containers behind them. +const blockingContainers = ['ION-ALERT', 'ION-POPOVER', 'ION-ACTION-SHEET', 'CORE-USER-TOURS-USER-TOUR', 'ION-PAGE']; + /** * Behat Dom Utils helper functions. */ @@ -251,71 +254,68 @@ export class TestsBehatDomUtils { }; /** - * Function to find top container element. + * Function to find top container elements. * * @param containerName Whether to search inside the a container name. - * @return Found top container element. + * @return Found top container elements. */ - protected static getCurrentTopContainerElement(containerName: string): HTMLElement | null { - let topContainer: HTMLElement | null = null; - let containers: HTMLElement[] = []; - const nonImplementedSelectors = - 'ion-alert, ion-popover, ion-action-sheet, ion-modal, core-user-tours-user-tour.is-active, page-core-mainmenu, ion-app'; + protected static getCurrentTopContainerElements(containerName: string): HTMLElement[] { + const topContainers: HTMLElement[] = []; + let containers = Array.from(document.querySelectorAll([ + 'ion-alert.hydrated', + 'ion-popover.hydrated', + 'ion-action-sheet.hydrated', + 'ion-modal.hydrated', + 'core-user-tours-user-tour.is-active', + 'ion-toast.hydrated', + 'page-core-mainmenu', + 'ion-app', + ].join(', '))); - switch (containerName) { - case 'html': - containers = Array.from(document.querySelectorAll('html')); - break; - case 'toast': - containers = Array.from(document.querySelectorAll('ion-app ion-toast.hydrated')); - containers = containers.map(container => container?.shadowRoot?.querySelector('.toast-container') || container); - break; - case 'alert': - containers = Array.from(document.querySelectorAll('ion-app ion-alert.hydrated')); - break; - case 'action-sheet': - containers = Array.from(document.querySelectorAll('ion-app ion-action-sheet.hydrated')); - break; - case 'modal': - containers = Array.from(document.querySelectorAll('ion-app ion-modal.hydrated')); - break; - case 'popover': - containers = Array.from(document.querySelectorAll('ion-app ion-popover.hydrated')); - break; - case 'user-tour': - containers = Array.from(document.querySelectorAll('core-user-tours-user-tour.is-active')); - break; - default: - // Other containerName or not implemented. - containers = Array.from(document.querySelectorAll(nonImplementedSelectors)); + containers = containers + .filter(container => { + if (container.tagName === 'ION-ALERT') { + // For some reason, in Behat sometimes alerts aren't removed from DOM, the close animation doesn't finish. + // Filter alerts with pointer-events none since that style is set before the close animation starts. + return container.style.pointerEvents !== 'none'; + } + + // Ignore pages that are inside other visible pages. + return container.tagName !== 'ION-PAGE' || !container.closest('.ion-page.ion-page-hidden'); + }) + // Sort them by z-index. + .sort((a, b) => Number(getComputedStyle(b).zIndex) - Number(getComputedStyle(a).zIndex)); + + if (containerName === 'split-view content') { + // Find non hidden pages inside the containers. + containers.some(container => { + if (!container.classList.contains('ion-page')) { + return false; + } + + const pageContainers = Array.from(container.querySelectorAll('.ion-page:not(.ion-page-hidden)')); + let topContainer = pageContainers.find((page) => !page.closest('.ion-page.ion-page-hidden')) ?? null; + + topContainer = (topContainer || container).querySelector('core-split-view ion-router-outlet'); + topContainer && topContainers.push(topContainer); + + return !!topContainer; + }); + + return topContainers; } - if (containers.length > 0) { - // Get the one with more zIndex. - topContainer = - containers.reduce((a, b) => getComputedStyle(a).zIndex > getComputedStyle(b).zIndex ? a : b, containers[0]); - } - - if (!topContainer) { - return null; - } - - if (containerName == 'page' || containerName == 'split-view content') { - // Find non hidden pages inside the container. - let pageContainers = Array.from(topContainer.querySelectorAll('.ion-page:not(.ion-page-hidden)')); - pageContainers = pageContainers.filter((page) => !page.closest('.ion-page.ion-page-hidden')); - - if (pageContainers.length > 0) { - // Get the more general one to avoid failing. - topContainer = pageContainers[0]; + // Get containers until one blocks other views. + containers.find(container => { + if (container.tagName === 'ION-TOAST') { + container = container.shadowRoot?.querySelector('.toast-container') || container; } + topContainers.push(container); - if (containerName == 'split-view content') { - topContainer = topContainer.querySelector('core-split-view ion-router-outlet'); - } - } + return blockingContainers.includes(container.tagName); + }); - return topContainer; + return topContainers; }; /** @@ -337,9 +337,24 @@ export class TestsBehatDomUtils { * @return Found elements */ protected static findElementsBasedOnText(locator: TestBehatElementLocator, containerName = ''): HTMLElement[] { - let topContainer = this.getCurrentTopContainerElement(containerName); + const topContainers = this.getCurrentTopContainerElements(containerName); - let container = topContainer; + return topContainers.reduce((elements, container) => + elements.concat(this.findElementsBasedOnTextInContainer(locator, container)), []); + } + + /** + * Function to find elements based on their text or Aria label. + * + * @param locator Element locator. + * @param container Container to search in. + * @return Found elements + */ + protected static findElementsBasedOnTextInContainer( + locator: TestBehatElementLocator, + topContainer: HTMLElement, + ): HTMLElement[] { + let container: HTMLElement | null = topContainer; if (locator.within) { const withinElements = this.findElementsBasedOnText(locator.within); diff --git a/src/testing/utils.ts b/src/testing/utils.ts index cd9a09696..4f097a35a 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -256,3 +256,16 @@ export async function renderWrapperComponent( export function agnosticPath(unixPath: string): string { return unixPath.replace(/\//g, sep); } + +/** + * Waits a certain time. + * + * @param time Number of milliseconds. + */ +export function wait(time: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, time); + }); +}