Merge pull request #3295 from dpalou/MOBILE-4069

Mobile 4069
main
Pau Ferrer Ocaña 2022-05-30 08:01:35 +02:00 committed by GitHub
commit c535b723ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 757 additions and 82 deletions

View File

@ -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) {

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

@ -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 {

View File

@ -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);

View File

@ -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<T>(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;
}

View File

@ -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));
}

View File

@ -192,8 +192,7 @@ export class CoreEvents {
siteId?: string,
): CoreEventObserver {
const listener = CoreEvents.on<Fallback, Event>(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 || {});
}
}

View File

@ -70,7 +70,7 @@ export class CoreForms {
CoreEvents.trigger(CoreEvents.FORM_ACTION, {
action: CoreEventFormAction.CANCEL,
form: formRef.nativeElement,
form: formRef.nativeElement || formRef,
}, siteId);
}

View File

@ -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<number>([[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']);
});
});

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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<void> {
await wait(50);
this.isReady = true;
}
}

View File

@ -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);
});
});

View File

@ -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<string, string | boolean> = {
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();
});
});

View File

@ -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.');
});
});

View File

@ -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');

View File

@ -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<HTMLElement>([
'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<HTMLElement>('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<HTMLElement>(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<HTMLElement>('.ion-page:not(.ion-page-hidden)'));
let topContainer = pageContainers.find((page) => !page.closest('.ion-page.ion-page-hidden')) ?? null;
topContainer = (topContainer || container).querySelector<HTMLElement>('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<HTMLElement>('.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<HTMLElement>('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)), <HTMLElement[]> []);
}
/**
* 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);

View File

@ -256,3 +256,16 @@ export async function renderWrapperComponent<T>(
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<void> {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, time);
});
}