diff --git a/lib/public/components/Filters/RunsFilter/durationFilter.js b/lib/public/components/Filters/RunsFilter/durationFilter.js index 1143ff71f2..58c5d16795 100644 --- a/lib/public/components/Filters/RunsFilter/durationFilter.js +++ b/lib/public/components/Filters/RunsFilter/durationFilter.js @@ -11,26 +11,76 @@ * or submit itself to any jurisdiction. */ -import { amountFilter } from '../common/filters/amountFilter.js'; +import { h } from '/js/src/index.js'; +import { comparisonOperatorFilter } from '../common/filters/comparisonOperatorFilter.js'; /** - * Returns the run duration filter component + * Returns the duration filter component * - * @param {RunsOverviewModel} runModel the runs model object - * - * @return {vnode} the duration filter + * @param {DurationFilterModel} durationFilterModel the duration filter model + * @return {Component} the duration filter */ -export const durationFilter = (runModel) => amountFilter( - runModel.runDurationFilter, - (filter) => { - runModel.runDurationFilter = filter; - }, - { - operatorAttributes: { - id: 'duration-operator', - }, - limitAttributes: { - id: 'duration-limit', - }, - }, -); +export const durationFilter = (durationFilterModel) => { + const { durationInputModel, operatorSelectionModel } = durationFilterModel; + + const { hours, minutes, seconds } = durationInputModel.raw; + + /** + * Return oninput handler for given time unit + * + * @param {'hours'|'minutes'|'seconds'} unitName time unit + * @return {function(InputEvent, void)} oninput handler for input for given time unit + */ + const updateInputModelHandlerByTimeUnit = (unitName) => (e) => { + const { value } = e.target; + const parsedValue = Number(value); + if (value.length > 0 || 0 <= parsedValue && (unitName === 'hours' || parsedValue < 60)) { + durationInputModel.update({ [unitName]: value.length > 0 ? parsedValue : null }); + } else { + durationFilterModel.visualChange$.notify(); + } + }; + + const hoursInput = h('input.flex-grow', { + id: 'hours-input', + type: 'number', + min: 0, + value: hours, + oninput: updateInputModelHandlerByTimeUnit('hours'), + }); + const minutesInput = h('input.flex-grow', { + id: 'minutes-input', + type: 'number', + min: 0, + max: 59, + value: minutes, + oninput: updateInputModelHandlerByTimeUnit('minutes'), + }, 'm'); + const secondsInput = h('input.flex-grow', { + id: 'seconds-input', + type: 'number', + min: 0, + max: 59, + value: seconds, + oninput: updateInputModelHandlerByTimeUnit('seconds'), + }, 's'); + + const inputs = h('.flex-row.w-100', [ + hoursInput, + minutesInput, + secondsInput, + h('.flex-row.items-center.p2', [ + h('label', { for: 'hours-input' }, 'h'), + ':', + h('label', { for: 'minutes-input' }, 'm'), + ':', + h('label', { for: 'seconds-input' }, 's'), + ]), + ]); + + return comparisonOperatorFilter( + inputs, + operatorSelectionModel.selected[0], + (operator) => operatorSelectionModel.select(operator), + ); +}; diff --git a/lib/public/components/Filters/common/filters/ComparisonOperatorSelectionModel.js b/lib/public/components/Filters/common/filters/ComparisonOperatorSelectionModel.js new file mode 100644 index 0000000000..8237c54327 --- /dev/null +++ b/lib/public/components/Filters/common/filters/ComparisonOperatorSelectionModel.js @@ -0,0 +1,32 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { SelectionModel } from '../../../common/selection/SelectionModel.js'; + +/** + * Model storing state of a selection of predefined comparison operators + */ +export class ComparisonOperatorSelectionModel extends SelectionModel { + /** + * Constructor + * @param {string} [defaultOperator = '='] one of ['<', '<=', '=', '>=', '>'] operators + */ + constructor(defaultOperator = '=') { + super({ + availableOptions: ['<', '<=', '=', '>=', '>'].map((operator) => ({ value: operator })), + multiple: false, + allowEmpty: false, + defaultSelection: [{ value: defaultOperator }], + }); + } +} diff --git a/lib/public/components/Filters/common/filters/DurationFilterModel.js b/lib/public/components/Filters/common/filters/DurationFilterModel.js new file mode 100644 index 0000000000..7eaeda7fc5 --- /dev/null +++ b/lib/public/components/Filters/common/filters/DurationFilterModel.js @@ -0,0 +1,87 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Observable } from '/js/src/index.js'; + +import { DurationInputModel } from '../../../common/form/inputs/DurationInputModel.js'; +import { ComparisonOperatorSelectionModel } from './ComparisonOperatorSelectionModel.js'; + +/** + * Duration filter model which stores time value and selected operator + */ +export class DurationFilterModel extends Observable { + /** + * Constructor + */ + constructor() { + super(); + this._visualChange$ = new Observable(); + + this._durationInputModel = new DurationInputModel(); + this._durationInputModel.bubbleTo(this); + this._operatorSelectionModel = new ComparisonOperatorSelectionModel(); + this._operatorSelectionModel.observe(() => { + if (this._durationInputModel.value === null) { + this._visualChange$.notify(); + } else { + this.notify(); + } + }); + } + + /** + * Returns the observable notified any time there is a visual change which has no impact on the actual filter value + * + * @return {Observable} the observable + */ + get visualChange$() { + return this._visualChange$; + } + + /** + * Retrun duration input model + * + * @return {DurationInputModel} duration input model + */ + get durationInputModel() { + return this._durationInputModel; + } + + /** + * Return operator selection model + * + * @return {ComparisonOperatorSelectionModel} operator selection model + */ + get operatorSelectionModel() { + return this._operatorSelectionModel; + } + + /** + * States if the filter has been filled + * + * @return {boolean} true if the filter has been filled + */ + isEmpty() { + return this._durationInputModel.value === null; + } + + /** + * Reset the filter to its default value + * + * @return {void} + */ + reset() { + this._durationInputModel.reset(); + this._operatorSelectionModel.reset(); + } +} diff --git a/lib/public/components/common/form/inputs/DurationInputModel.js b/lib/public/components/common/form/inputs/DurationInputModel.js new file mode 100644 index 0000000000..49f77d0b53 --- /dev/null +++ b/lib/public/components/common/form/inputs/DurationInputModel.js @@ -0,0 +1,98 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Observable } from '/js/src/index.js'; + +/** + * @typedef DurationInputRawData + * @property {number} hours the number of hours + * @property {number} minutes the number of minutes, from range [0, 59] + * @property {number} seconds the number of seconds, from range [0, 59] + */ + +/** + * Store the duration input + */ +export class DurationInputModel extends Observable { + /** + * Constructor + */ + constructor() { + super(); + + this._raw = { + hours: null, + minutes: null, + seconds: null, + }; + + /** + * Timestamp (ms) + * @type {number|null} + * @private + */ + this._value = null; + } + + /** + * Update the inputs raw values + * @param {DurationInputRawData} raw the input raw values + * @return {void} + */ + update(raw) { + try { + this._raw = { ...this._raw, ...raw }; + const { hours, minutes, seconds } = this._raw; + if ((hours ?? minutes ?? seconds ?? null) === null) { + this._value = null; + } else { + this._value = (hours || 0) * 60 * 60 * 1000 + + (minutes || 0) * 60 * 1000 + + (seconds || 0) * 1000; + } + } catch { + this._value = null; + } + + this.notify(); + } + + /** + * Reset the inputs to its initial state + * @return {void} + */ + reset() { + this._raw = { + hours: null, + minutes: null, + seconds: null, + }; + this._value = null; + } + + /** + * Returns the raw input values + * @return {DurationInputRawData} the raw values + */ + get raw() { + return this._raw; + } + + /** + * Returns the current date represented by the inputs (null if no valid value is represented) + * @return {number|null} the current value + */ + get value() { + return this._value; + } +} diff --git a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js index b6f35d0f15..d53c156640 100644 --- a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js @@ -308,8 +308,7 @@ export const runsActiveColumns = { noEllipsis: true, format: (_duration, run) => displayRunDuration(run), exportFormat: (_duration, run) => formatRunDuration(run), - filter: durationFilter, - filterTooltip: 'Run duration (in minutes)', + filter: ({ runDurationFilterModel }) => durationFilter(runDurationFilterModel), }, environmentId: { name: 'Environment ID', diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index eaeb86ec54..ef17f46776 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -21,6 +21,7 @@ import { EorReasonFilterModel } from '../../../components/Filters/RunsFilter/Eor import pick from '../../../utilities/pick.js'; import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { getRemoteDataSlice } from '../../../utilities/fetch/getRemoteDataSlice.js'; +import { DurationFilterModel } from '../../../components/Filters/common/filters/DurationFilterModel.js'; import { CombinationOperator } from '../../../components/Filters/common/CombinationOperatorChoiceModel.js'; import { AliceL3AndDipoleFilteringModel } from '../../../components/Filters/RunsFilter/AliceL3AndDipoleFilteringModel.js'; import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; @@ -57,7 +58,11 @@ export class RunsOverviewModel extends OverviewPageModel { this._eorReasonsFilterModel = new EorReasonFilterModel(); this._eorReasonsFilterModel.observe(() => this._applyFilters()); - this._eorReasonsFilterModel.visualChange$.observe(() => this.notify()); + this._eorReasonsFilterModel.visualChange$.bubbleTo(this); + + this._runDurationFilterModel = new DurationFilterModel(); + this._runDurationFilterModel.observe(() => this._applyFilters()); + this._runDurationFilterModel.visualChange$.bubbleTo(this); this._aliceL3AndDipoleCurrentFilter = new AliceL3AndDipoleFilteringModel(); this._aliceL3AndDipoleCurrentFilter.observe(() => this._applyFilters()); @@ -198,8 +203,6 @@ export class RunsOverviewModel extends OverviewPageModel { this.o2endFilterFromTime = '00:00'; this.o2endFilterToTime = '23:59'; - this._runDurationFilter = null; - this._lhcPeriodsFilter = null; this.environmentIdsFilter = ''; @@ -214,6 +217,8 @@ export class RunsOverviewModel extends OverviewPageModel { this.nFlpsFilter = null; + this._runDurationFilterModel.reset(); + this.ddflpFilter = ''; this.dcsFilter = ''; @@ -255,7 +260,7 @@ export class RunsOverviewModel extends OverviewPageModel { || this.o2endFilterTo !== '' || this.o2endFilterToTime !== '23:59' || this.o2endFilterFromTime !== '00:00' - || this._runDurationFilter !== null + || !this._runDurationFilterModel.isEmpty() || this._lhcPeriodsFilter !== null || this.environmentIdsFilter !== '' || this.runQualitiesFilters.length !== 0 @@ -276,6 +281,89 @@ export class RunsOverviewModel extends OverviewPageModel { } /** +<<<<<<< HEAD + * Returns active filters + * @return {array} array of active filters + */ + getActiveFilters() { + this.activeFilters = []; + + if (this.runFilterValues !== '') { + this.activeFilters.push('Run Number'); + } + if (!this._detectorsFilterModel.isEmpty()) { + this.activeFilters.push('Detectors'); + } + if (this._runDefinitionFilter.length > 0) { + this.activeFilters.push('Run Definition'); + } + if (!this._eorReasonsFilterModel.isEmpty()) { + this.activeFilters.push('EOR Reason'); + } + if (!this._listingTagsFilterModel.isEmpty()) { + this.activeFilters.push('Tags'); + } + if (this._listingRunTypesFilterModel.selected.length !== 0) { + this.activeFilters.push('Run Types'); + } + if (this._fillNumbersFilter !== '') { + this.activeFilters.push('Fill Number'); + } + if (this.o2startFilterFrom !== '') { + this.activeFilters.push('O2 Start from'); + } + if (this.o2startFilterTo !== '') { + this.activeFilters.push('O2 Start to'); + } + if (this.o2endFilterFrom !== '') { + this.activeFilters.push('O2 End from'); + } + if (this.o2endFilterTo !== '') { + this.activeFilters.push('O2 End to'); + } + if (!this._runDurationFilterModel.isEmpty()) { + this.activeFilters.push('Run duration'); + } + if (this._lhcPeriodsFilter !== null) { + this.activeFilters.push('LHC Period'); + } + if (this.environmentIdsFilter !== '') { + this.activeFilters.push('Environment Id'); + } + if (this.runQualitiesFilters.length !== 0) { + this.activeFilters.push('Run Quality'); + } + if (this._triggerValuesFilters.size !== 0) { + this.activeFilters.push('Trigger Value'); + } + if (this.nDetectorsFilter !== null) { + this.activeFilters.push('# of detectors'); + } + if (this._nEpnsFilter !== null) { + this.activeFilters.push('# of epns'); + } + if (this.nFlpsFilter !== null) { + this.activeFilters.push('# of flps'); + } + if (this.ddflpFilter !== '') { + this.activeFilters.push('Data Distribution (FLP)'); + } + if (this.dcsFilter !== '') { + this.activeFilters.push('DCS'); + } + if (this.epnFilter !== '') { + this.activeFilters.push('EPN'); + } + if (this._odcTopologyFullNameFilter !== '') { + this.activeFilters.push('Topology'); + } + + return this.activeFilters; + } + + /** +======= +>>>>>>> main * Set the export type parameter of the current export being created * @param {string} selectedExportType Received string from the view * @return {void} @@ -486,23 +574,11 @@ export class RunsOverviewModel extends OverviewPageModel { } /** - * Returns the run duration filter (filter is defined in minutes) - * @return {{operator: string, limit: (number|null)}|null} The current run duration filter - */ - get runDurationFilter() { - return this._runDurationFilter; - } - - /** - * Sets the limit of duration (in minutes) and the comparison operator to filter - * - * @param {{operator: string, limit: (number|null)}|null} newRunDurationFilter The new filter value - * - * @return {void} + * Returns the run duration filter model + * @return {DurationFilterModel} The current run duration filter model */ - set runDurationFilter(newRunDurationFilter) { - this._runDurationFilter = newRunDurationFilter; - this._applyFilters(); + get runDurationFilterModel() { + return this._runDurationFilterModel; } /** @@ -930,10 +1006,9 @@ export class RunsOverviewModel extends OverviewPageModel { 'filter[o2end][to]': new Date(`${this.o2endFilterTo.replace(/\//g, '-')}T${this.o2endFilterToTime}:59.999`).getTime(), }, - ...this._runDurationFilter && this._runDurationFilter.limit !== null && { - 'filter[runDuration][operator]': this._runDurationFilter.operator, - // Convert filter to milliseconds - 'filter[runDuration][limit]': this._runDurationFilter.limit * 60 * 1000, + ...!this._runDurationFilterModel.isEmpty() && { + 'filter[runDuration][operator]': this._runDurationFilterModel.operatorSelectionModel.selected[0], + 'filter[runDuration][limit]': this._runDurationFilterModel.durationInputModel.value, }, ...this._lhcPeriodsFilter && { 'filter[lhcPeriods]': this._lhcPeriodsFilter, diff --git a/test/public/defaults.js b/test/public/defaults.js index 35403816df..6bdd0bc3e5 100644 --- a/test/public/defaults.js +++ b/test/public/defaults.js @@ -679,16 +679,45 @@ module.exports.expectRowValues = async (page, rowId, expectedInnerTextValues) => } }; +/** + * Method to check cells of a row with given id have expected innerText + * + * @param {puppeteer.Page} page the puppeteer page + * @param {stirng} rowId row id + * @param {Object} [expectedInnerTextValues] values expected in the row + * + * @return {Promise} resolve once row's values were checked + */ +module.exports.expectRowValues = async (page, rowId, expectedInnerTextValues) => { + try { + await page.waitForFunction(async (rowId, expectedInnerTextValues) => { + for (const columnId in expectedInnerTextValues) { + const actualValue = (await document.querySelectorAll(`table tbody td:nth-of-type(${rowId}) .column-${columnId}`)).innerText; + if (expectedInnerTextValues[columnId] == actualValue) { + return false; + } + } + return true; + }, rowId, expectedInnerTextValues); + } catch { + const rowInnerTexts = {}; + for (const columnId in expectedInnerTextValues) { + rowInnerTexts[columnId] = (await document.querySelectorAll(`table tbody td:nth-of-type(${rowId}) .column-${columnId}`)).innerText; + } + expect(rowInnerTexts).to.eql(expectedInnerTextValues); + } +}; + /** * Generic method to validate inner text of cells belonging column with given id. * It checks exact match with given values * * @param {puppeteer.Page} page the puppeteer page * @param {string} columnId column id - * @param {string} expectedValuesRegex string that regex constructor `RegExp(expectedValuesRegex)` returns desired regular expression + * @param {string|RegExp} expectedValuesRegex string that regex constructor `RegExp(expectedValuesRegex)` returns desired regular expression * @param {object} options options * @param {'every'|'some'} [options.valuesCheckingMode = 'every'] whether all values are expected to match regex or at least one - * @param {boolean} [options.negation] if true it's expected not to match given regex + * @param {boolean} [options.negation = false] if true it's expected not to match given regex * * @return {Promise} resolved once column values were checked */ @@ -697,13 +726,16 @@ module.exports.checkColumnValuesWithRegex = async (page, columnId, expectedValue valuesCheckingMode = 'every', negation = false, } = options; + + const adjustedRegExp = new RegExp(expectedValuesRegex).toString().slice(1, -1); + await page.waitForFunction((columnId, regexString, valuesCheckingMode, negation) => { // Browser context, be careful when modifying - const names = [...document.querySelectorAll(`table tbody .column-${columnId}`)].map(({ innerText }) => innerText); - return names.length - && names[valuesCheckingMode]((name) => + const innerTexts = [...document.querySelectorAll(`table tbody .column-${columnId}`)].map(({ innerText }) => innerText); + return innerTexts.length + && innerTexts[valuesCheckingMode]((name) => negation ? !RegExp(regexString).test(name) : RegExp(regexString).test(name)); - }, { timeout: 1500 }, columnId, expectedValuesRegex, valuesCheckingMode, negation); + }, { timeout: 1500 }, columnId, adjustedRegExp, valuesCheckingMode, negation); }; /** diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index 96ccef4408..254b0be4dc 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -36,6 +36,7 @@ const { expectInputValue, expectColumnValues, expectUrlParams, + checkColumnValuesWithRegex, } = require('../defaults.js'); const { RUN_QUALITIES, RunQualities } = require('../../../lib/domain/enums/RunQualities.js'); const { runService } = require('../../../lib/server/services/run/RunService.js'); @@ -545,55 +546,32 @@ module.exports = () => { it('should successfully filter on duration', async () => { await goToPage(page, 'run-overview'); - await pressElement(page, '#openFilterToggle'); - await page.waitForSelector('#duration-operator'); - - const runDurationOperatorSelector = '#duration-operator'; - const runDurationOperator = await page.$(runDurationOperatorSelector) || null; - expect(runDurationOperator).to.not.be.null; - expect(await runDurationOperator.evaluate((element) => element.value)).to.equal('='); - - const runDurationLimitSelector = '#duration-limit'; - const runDurationLimit = await page.$(runDurationLimitSelector) || null; - - await page.waitForSelector(runDurationLimitSelector); - expect(runDurationLimit).to.not.be.null; - - await page.focus(runDurationLimitSelector); - await page.keyboard.type('1500'); - await waitForTableLength(page, 3); - - await page.select(runDurationOperatorSelector, '='); - await waitForTableLength(page, 3); - - let runDurationList = await page.evaluate(() => Array.from(document.querySelectorAll('tbody tr')).map((row) => { - const rowId = row.id; - return document.querySelector(`#${rowId}-runDuration-text`)?.innerText; - })); - - expect(runDurationList.every((runDuration) => { - const time = runDuration.replace('*', ''); - return time === '25:00:00'; - })).to.be.true; - - await page.$eval(runDurationLimitSelector, (input) => { - input.value = ''; - }); - await page.focus(runDurationLimitSelector); - await page.keyboard.type('3000'); - await waitForTableLength(page, 0); - - await page.select(runDurationOperatorSelector, '>='); - await waitForTableLength(page, 3); - - // Expect only unknown - runDurationList = await page.evaluate(() => Array.from(document.querySelectorAll('tbody tr')).map((row) => { - const rowId = row.id; - return document.querySelector(`#${rowId}-runDuration-text`)?.innerText; - })); - expect(runDurationList.every((runDuration) => runDuration === 'UNKNOWN')).to.be.true; + const runDurationFilterDivSelector = '.runDuration-filter'; + const operatorSelector = `${runDurationFilterDivSelector} select`; + const hoursSelector = `${runDurationFilterDivSelector} input:nth-of-type(1)`; + const minutesSelector = `${runDurationFilterDivSelector} input:nth-of-type(2)`; + await expectInputValue(page, operatorSelector, '='); + + // Case 1 + await fillInput(page, hoursSelector, 26); + await checkColumnValuesWithRegex(page, 'runDuration', '26:00:00'); + + // Case 2 + await fillInput(page, hoursSelector, 1); + await fillInput(page, minutesSelector, 0); + await checkColumnValuesWithRegex(page, 'runDuration', '01:00:00'); + + // Case 3 + await page.select(operatorSelector, '>='); + await checkColumnValuesWithRegex(page, 'runDuration', /(UNKNOWN)|(([1-9][0-9])|(0[1-9]):[0-5][0-9]:[0-5][0-9])/); + + // Case 4 + await page.select(operatorSelector, '<='); + await fillInput(page, hoursSelector, 10); + await fillInput(page, minutesSelector, 0); + await checkColumnValuesWithRegex(page, 'runDuration', /(10:00:00)|(0[1-9]:[0-5][0-9]:[0-5][0-9])/); }); it('Should successfully filter runs by their run quality', async () => { diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 990ca1d3bf..02c9330e8e 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -371,7 +371,8 @@ module.exports = () => { it('should successfully apply duration filter', async () => { await pressElement(page, '#openFilterToggle'); - await page.select('.runDuration-filter select', '>='); + const runDurationFilterDivSelector = '.runDuration-filter'; + const minutesSelector = `${runDurationFilterDivSelector} input:nth-of-type(2)`; /** * Invocation of page.select and fillInput in case of amountFilter results in two concurrent, @@ -381,7 +382,7 @@ module.exports = () => { await page.select('.runDuration-filter select', '>='); await pressElement(page, '#openFilterToggle'); await pressElement(page, '#openFilterToggle'); - await fillInput(page, '.runDuration-filter input[type=number]', '10'); + await fillInput(page, minutesSelector, 10); await expectColumnValues(page, 'runNumber', ['55', '1']);