diff --git a/lib/database/adapters/DataPassAdapter.js b/lib/database/adapters/DataPassAdapter.js index e88749eb19..5051ea954c 100644 --- a/lib/database/adapters/DataPassAdapter.js +++ b/lib/database/adapters/DataPassAdapter.js @@ -38,6 +38,8 @@ class DataPassAdapter { lastRunNumber, } = databaseObject; + const runsCount = databaseObject.get('runsCount'); + return { id, name, @@ -45,6 +47,7 @@ class DataPassAdapter { outputSize, reconstructedEventsCount, lastRunNumber, + runsCount, }; } } diff --git a/lib/public/Model.js b/lib/public/Model.js index d694902c87..2ed5c299d2 100644 --- a/lib/public/Model.js +++ b/lib/public/Model.js @@ -192,6 +192,9 @@ export default class Model extends Observable { case 'runs-per-lhc-period': this.runs.loadPerLhcPeriodOverview(this.router.params); break; + case 'runs-per-data-pass': + this.runs.loadPerDataPassOverview(this.router.params); + break; case 'run-detail': this.runs.loadDetails(this.router.params); break; diff --git a/lib/public/view.js b/lib/public/view.js index 11e7bbc3fc..cd9b57b348 100644 --- a/lib/public/view.js +++ b/lib/public/view.js @@ -37,6 +37,7 @@ import { LhcPeriodsOverviewPage } from './views/lhcPeriods/Overview/LhcPeriodsOv import { RunsPerLhcPeriodOverviewPage } from './views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js'; import { HomePage } from './views/Home/Overview/HomePage.js'; import { DataPassesPerLhcPeriodOverviewPage } from './views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewView.js'; +import { RunsPerDataPassOverviewPage } from './views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js'; /** * Main view layout @@ -64,6 +65,7 @@ export default (model) => { 'run-overview': RunsOverviewPage, 'run-detail': RunDetailsPage, 'runs-per-lhc-period': RunsPerLhcPeriodOverviewPage, + 'runs-per-data-pass': RunsPerDataPassOverviewPage, statistics: StatisticsPage, diff --git a/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js b/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js index 1fe46dce10..b2e1d0fb1d 100644 --- a/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js +++ b/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js @@ -11,7 +11,9 @@ * or submit itself to any jurisdiction. */ +import { h } from '/js/src/index.js'; import { textFilter } from '../../../components/Filters/common/filters/textFilter.js'; +import { frontLink } from '../../../components/common/navigation/frontLink.js'; /** * List of active columns for a generic data passes table @@ -20,12 +22,27 @@ export const dataPassesActiveColumns = { name: { name: 'Name', visible: true, - format: (name) => name, sortable: true, filter: ({ namesFilterModel }) => textFilter( namesFilterModel, { class: 'w-75 mt1', placeholder: 'e.g. LHC22a_apass1, ...' }, ), + classes: 'w-20', + balloon: true, + }, + + associatedRuns: { + name: 'Runs', + visible: true, + format: (_, { id, runsCount }) => + runsCount === 0 + ? 'No runs' + : frontLink( + h('.flex-row.g3', [h('.f6.badge.bg-gray-light.black', runsCount), 'Runs']), + 'runs-per-data-pass', + { dataPassId: id }, + ), + classes: 'w-10', }, description: { @@ -39,6 +56,7 @@ export const dataPassesActiveColumns = { format: (reconstructedEventsCount) => reconstructedEventsCount ? reconstructedEventsCount.toLocaleString('en-US') : '-', visible: true, sortable: true, + classes: 'w-15', }, outputSize: { @@ -46,6 +64,7 @@ export const dataPassesActiveColumns = { visible: true, format: (outputSize) => outputSize ? outputSize.toLocaleString('en-US') : '-', sortable: true, + classes: 'w-15', }, }; diff --git a/lib/public/views/Runs/ActiveColumns/runDetectorsActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runDetectorsActiveColumns.js index 80529cdfff..f992ec4c75 100644 --- a/lib/public/views/Runs/ActiveColumns/runDetectorsActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runDetectorsActiveColumns.js @@ -15,13 +15,15 @@ import { formatDetectorQuality } from '../format/formatDetectorQuality.js'; /** * Factory for detectors related active columns configuration * @param {Detector[]} detectors detectors list + * @param {object} [options] additional options + * @param {object|string|string[]} [options.profiles] profiles to which the column is restricted to * @return {object} active columns configuration */ -export const createRunDetectorsActiveColumns = (detectors) => Object.fromEntries(detectors?.map(({ name: detectorName }) => [ +export const createRunDetectorsActiveColumns = (detectors, { profiles } = {}) => Object.fromEntries(detectors?.map(({ name: detectorName }) => [ detectorName, { name: detectorName.toUpperCase(), visible: true, format: (_, run) => formatDetectorQuality(run.detectorsQualities.find(({ name }) => name === detectorName)?.quality), - profiles: ['runsPerLhcPeriod'], + profiles, }, ]) ?? []); diff --git a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js index 726fcc0f46..c44df43bef 100644 --- a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js @@ -84,12 +84,15 @@ export const runsActiveColumns = { ], ), profiles: { + home: true, lhcFill: true, environment: true, runsPerLhcPeriod: { classes: 'w-7 f6 w-wrapped', }, - home: true, + runsPerDataPass: { + classes: 'w-7 f6 w-wrapped', + }, }, }, detectors: { @@ -120,6 +123,9 @@ export const runsActiveColumns = { runsPerLhcPeriod: { classes: 'w-2 f6', }, + runsPerDataPass: { + classes: 'w-2 f6', + }, }, classes: 'w-5 f6', format: (fill, run) => fill !== null && run.lhcBeamMode === BeamModes.STABLE_BEAMS @@ -152,6 +158,10 @@ export const runsActiveColumns = { runsPerLhcPeriod: { classes: 'f6 w-wrapped', }, + runsPerDataPass: { + classes: 'f6 w-wrapped', + }, + }, }, timeO2End: { @@ -172,6 +182,9 @@ export const runsActiveColumns = { runsPerLhcPeriod: { classes: 'f6 w-wrapped', }, + runsPerDataPass: { + classes: 'f6 w-wrapped', + }, }, }, timeTrgStart: { @@ -185,6 +198,10 @@ export const runsActiveColumns = { visible: true, classes: 'f6 w-wrapped', }, + runsPerDataPass: { + visible: true, + classes: 'f6 w-wrapped', + }, }, }, timeTrgEnd: { @@ -198,6 +215,10 @@ export const runsActiveColumns = { visible: true, classes: 'f6 w-wrapped', }, + runsPerDataPass: { + visible: true, + classes: 'f6 w-wrapped', + }, }, }, timeSincePreviousRun: { @@ -405,12 +426,12 @@ export const runsActiveColumns = { name: 'L3 [A]', visible: true, format: (_, run) => formatAliceCurrent(run.aliceL3Polarity, run.aliceL3Current), - profiles: ['runsPerLhcPeriod'], + profiles: ['runsPerLhcPeriod', 'runsPerDataPass'], }, dipoleCurrent: { name: 'Dipole [A]', visible: true, format: (_, run) => formatAliceCurrent(run.aliceDipolePolarity, run.aliceDipoleCurrent), - profiles: ['runsPerLhcPeriod'], + profiles: ['runsPerLhcPeriod', 'runsPerDataPass'], }, }; diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js new file mode 100644 index 0000000000..41e11c573f --- /dev/null +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js @@ -0,0 +1,113 @@ +/** + * @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 { RemoteData } from '/js/src/index.js'; +import { buildUrl } from '../../../utilities/fetch/buildUrl.js'; +import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; +import { RunsOverviewModel } from '../Overview/RunsOverviewModel.js'; +import { ObservableData } from '../../../utilities/ObservableData.js'; +import { getRemoteDataSlice } from '../../../utilities/fetch/getRemoteDataSlice.js'; + +/** + * Runs Per Data Pass overview model + */ +export class RunsPerDataPassOverviewModel extends RunsOverviewModel { + /** + * Constructor + * @param {Model} model global model + */ + constructor(model) { + super(model); + this._detectors = new ObservableData(RemoteData.notAsked()); + this._detectors.bubbleTo(this); + this._dataPass = new ObservableData(RemoteData.notAsked()); + this._dataPass.bubbleTo(this); + } + + /** + * Retrieve a list of detector types from detectorsProvider + * + * @return {Promise} resolves once the data has been fetched + * @private + */ + async _fetchDetectors() { + this._detectors.setCurrent(RemoteData.loading()); + try { + this._detectors.setCurrent(RemoteData.success(await detectorsProvider.getAll())); + } catch (error) { + this._detectors.setCurrent(RemoteData.failure(error)); + } + } + + /** + * Fetch data pass data which runs are fetched + * @return {Promise} promise + */ + async _fetchDataPass() { + this._dataPass.setCurrent(RemoteData.loading()); + try { + const { items: [dataPass] = [] } = await getRemoteDataSlice(`/api/dataPasses?filter[ids][]=${this._dataPassId}`); + this._dataPass.setCurrent(RemoteData.success(dataPass)); + } catch (error) { + this._dataPass.setCurrent(RemoteData.failure(error)); + } + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritdoc + */ + async load() { + if (!this._dataPassId) { + return; + } + this._fetchDetectors(); + this._fetchDataPass(); + super.load(); + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritdoc + */ + getRootEndpoint() { + const endpint = super.getRootEndpoint(); + return buildUrl(endpint, { filter: { + dataPassIds: [this._dataPassId], + runQualities: 'good', + definitions: 'PHYSICS', + } }); + } + + /** + * Set id of data pass which runs are to be fetched + * @param {number} dataPassId id of Data Pass + */ + set dataPassId(dataPassId) { + this._dataPassId = dataPassId; + } + + /** + * Get current data pass which runs are fetched + */ + get dataPass() { + return this._dataPass.getCurrent(); + } + + /** + * Get all detectors + * @return {RemoteData} detectors + */ + get detectors() { + return this._detectors.getCurrent(); + } +} diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js new file mode 100644 index 0000000000..561551f779 --- /dev/null +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -0,0 +1,64 @@ +/** + * @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 { h } from '/js/src/index.js'; +import { table } from '../../../components/common/table/table.js'; +import { createRunDetectorsActiveColumns } from '../ActiveColumns/runDetectorsActiveColumns.js'; +import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; +import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; +import { exportRunsTriggerAndModal } from '../Overview/exportRunsTriggerAndModal.js'; +import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; +import spinner from '../../../components/common/spinner.js'; + +const TABLEROW_HEIGHT = 59; +// Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; +const PAGE_USED_HEIGHT = 215; + +/** + * Render Runs Per LHC Period overview page + * @param {Model} model The overall model object. + * @param {Model} [model.runs.perDataPassOverviewModel] model holding state for of the page + * @return {Component} The overview page + */ +export const RunsPerDataPassOverviewPage = ({ runs: { perDataPassOverviewModel }, modalModel }) => { + perDataPassOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); + + const { items: runs, detectors, dataPass } = perDataPassOverviewModel; + + const activeColumns = { + ...runsActiveColumns, + ...createRunDetectorsActiveColumns(detectors.match({ + Success: (payload) => payload, + Other: () => [], + }), { profiles: 'runsPerDataPass' }), + }; + + return h('', [ + h('.flex-row.justify-between.items-center', [ + dataPass.match({ + Success: (payload) => h('h2', `Good, physics runs of ${payload.name}`), + Failure: () => h('h2.danger', 'Failed to fetch Data Pass information'), + Loading: () => h('.p1', spinner({ size: 2, absolute: false })), + NotAsked: () => h('h2', 'Good, physics runs'), + }), + exportRunsTriggerAndModal(perDataPassOverviewModel, modalModel), + ]), + h('.flex-column.w-100', [ + table(runs, activeColumns, null, { profile: 'runsPerDataPass' }), + paginationComponent(perDataPassOverviewModel.pagination), + ]), + ]); +}; diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js index 65808f69ec..8b6d245e52 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js @@ -14,6 +14,7 @@ import { RemoteData } from '/js/src/index.js'; import { buildUrl } from '../../../utilities/fetch/buildUrl.js'; import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { RunsOverviewModel } from '../Overview/RunsOverviewModel.js'; +import { ObservableData } from '../../../utilities/ObservableData.js'; /** * Runs Per LHC Period overview model @@ -25,7 +26,8 @@ export class RunsPerLhcPeriodOverviewModel extends RunsOverviewModel { */ constructor(model) { super(model); - this._detectors = RemoteData.notAsked(); + this._detectors = new ObservableData(RemoteData.notAsked()); + this._detectors.bubbleTo(this); } /** @@ -35,17 +37,12 @@ export class RunsPerLhcPeriodOverviewModel extends RunsOverviewModel { * @private */ async _fetchDetectors() { - this._detectors = RemoteData.loading(); - this.notify(); - + this._detectors.setCurrent(RemoteData.loading()); try { - const detectors = await detectorsProvider.getAll(); - this._detectors = RemoteData.success(detectors); + this._detectors.setCurrent(RemoteData.success(await detectorsProvider.getAll())); } catch (error) { - this._detectors = RemoteData.failure(error); + this._detectors.setCurrent(RemoteData.failure(error)); } - - this.notify(); } // eslint-disable-next-line valid-jsdoc @@ -86,6 +83,6 @@ export class RunsPerLhcPeriodOverviewModel extends RunsOverviewModel { * @return {RemoteData} detectors */ get detectors() { - return this._detectors; + return this._detectors.getCurrent(); } } diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js index 57db930521..e3b7c102b5 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js @@ -26,7 +26,7 @@ const PAGE_USED_HEIGHT = 215; /** * Render Runs Per LHC Period overview page * @param {Model} model The overall model object. - * @param {Model} [model.runsPerPeriodModel] model holding state for of the page + * @param {Model} [model.runs.perLhcPeriodOverviewModel] model holding state for of the page * @return {Component} The overview page */ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel }, modalModel }) => { @@ -42,7 +42,7 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel ...createRunDetectorsActiveColumns(detectors.match({ Success: (payload) => payload, Other: () => [], - })), + }), { profiles: 'runsPerLhcPeriod' }), }; return h('', [ diff --git a/lib/public/views/Runs/RunsModel.js b/lib/public/views/Runs/RunsModel.js index 9b5ea51d04..20a1077167 100644 --- a/lib/public/views/Runs/RunsModel.js +++ b/lib/public/views/Runs/RunsModel.js @@ -15,6 +15,7 @@ import { Observable } from '/js/src/index.js'; import { RunDetailsModel } from './Details/RunDetailsModel.js'; import { RunsOverviewModel } from './Overview/RunsOverviewModel.js'; import { RunsPerLhcPeriodOverviewModel } from './RunPerPeriod/RunsPerLhcPeriodOverviewModel.js'; +import { RunsPerDataPassOverviewModel } from './RunPerDataPass/RunsPerDataPassOverviewModel.js'; /** * Model representing handlers for runs page @@ -34,6 +35,8 @@ export class RunsModel extends Observable { this._overviewModel.bubbleTo(this); this._perLhcPeriodOverviewModel = new RunsPerLhcPeriodOverviewModel(model); this._perLhcPeriodOverviewModel.bubbleTo(this); + this._perDataPassOverviewModel = new RunsPerDataPassOverviewModel(model); + this._perDataPassOverviewModel.bubbleTo(this); } /** @@ -92,4 +95,23 @@ export class RunsModel extends Observable { get perLhcPeriodOverviewModel() { return this._perLhcPeriodOverviewModel; } + + /** + * Load runs overview data + * @return {void} + */ + loadPerDataPassOverview({ dataPassId }) { + if (!this._perDataPassOverviewModel.pagination.isInfiniteScrollEnabled) { + this._perDataPassOverviewModel.dataPassId = dataPassId; + this._perDataPassOverviewModel.load(); + } + } + + /** + * Returns the model for the overview page + * @return {RunsPerDataPassOverviewModel} the overview model + */ + get perDataPassOverviewModel() { + return this._perDataPassOverviewModel; + } } diff --git a/lib/server/services/dataPasses/DataPassService.js b/lib/server/services/dataPasses/DataPassService.js index 438a7a445a..72012793a4 100644 --- a/lib/server/services/dataPasses/DataPassService.js +++ b/lib/server/services/dataPasses/DataPassService.js @@ -17,6 +17,20 @@ const { dataPassAdapter } = require('../../../database/adapters'); const { BadParameterError } = require('../../errors/BadParameterError'); const { NotFoundError } = require('../../errors/NotFoundError'); +/** + * Subquery to calculate number of runs associated with given LHC Period + */ +const RUNS_COUNT_SUBQUERY = ` + (SELECT COUNT(r.run_number) + FROM data_passes_runs as dpr + INNER JOIN runs as r + ON r.run_number = dpr.run_number + AND r.definition = 'PHYSICS' + AND r.run_quality = 'good' + WHERE dpr.data_pass_id = DataPass.id +) +`; + /** * @typedef DataPassIdentifier * @param {number} id @@ -42,6 +56,11 @@ class DataPassService { throw new BadParameterError('Can not find without LHC Period id or name'); } + queryBuilder.includeAttribute({ + alias: 'runsCount', + query: RUNS_COUNT_SUBQUERY, + }); + const dataPass = await DataPassRepository.findOne(queryBuilder); return dataPass ? dataPassAdapter.toEntity(dataPass) : null; } @@ -108,6 +127,11 @@ class DataPassService { } } + queryBuilder.includeAttribute({ + alias: 'runsCount', + query: RUNS_COUNT_SUBQUERY, + }); + const { count, rows } = await DataPassRepository.findAndCountAll(queryBuilder); return { diff --git a/test/api/dataPasses.test.js b/test/api/dataPasses.test.js index 295e16be68..075f84997a 100644 --- a/test/api/dataPasses.test.js +++ b/test/api/dataPasses.test.js @@ -23,6 +23,7 @@ const LHC22b_apass1 = { reconstructedEventsCount: 50948694, outputSize: 56875682112600, lastRunNumber: 108, + runsCount: 0, }; const LHC22b_apass2 = { @@ -32,6 +33,7 @@ const LHC22b_apass2 = { reconstructedEventsCount: 50848604, outputSize: 55765671112610, lastRunNumber: 55, + runsCount: 1, }; const LHC22a_apass1 = { @@ -41,6 +43,7 @@ const LHC22a_apass1 = { reconstructedEventsCount: 50848111, outputSize: 55761110122610, lastRunNumber: 105, + runsCount: 3, }; module.exports = () => { diff --git a/test/lib/server/services/dataPasses/DataPassesService.test.js b/test/lib/server/services/dataPasses/DataPassesService.test.js index 1569c490eb..e75a48b6db 100644 --- a/test/lib/server/services/dataPasses/DataPassesService.test.js +++ b/test/lib/server/services/dataPasses/DataPassesService.test.js @@ -24,6 +24,7 @@ const LHC22b_apass1 = { reconstructedEventsCount: 50948694, outputSize: 56875682112600, lastRunNumber: 108, + runsCount: 0, }; const LHC22b_apass2 = { @@ -33,6 +34,7 @@ const LHC22b_apass2 = { reconstructedEventsCount: 50848604, outputSize: 55765671112610, lastRunNumber: 55, + runsCount: 1, }; const LHC22a_apass1 = { @@ -42,6 +44,7 @@ const LHC22a_apass1 = { reconstructedEventsCount: 50848111, outputSize: 55761110122610, lastRunNumber: 105, + runsCount: 3, }; module.exports = () => { diff --git a/test/public/dataPasses/index.js b/test/public/dataPasses/index.js index c522904fe9..dee6dab0ba 100644 --- a/test/public/dataPasses/index.js +++ b/test/public/dataPasses/index.js @@ -11,8 +11,8 @@ * or submit itself to any jurisdiction. */ -const OverviewSuite = require('./overview.test'); +const OverviewSuite = require('./overviewPerLhcPeriod.test.js'); module.exports = () => { - describe('Overview Page', OverviewSuite); + describe('Overview Per LHC Period Page', OverviewSuite); }; diff --git a/test/public/dataPasses/overview.test.js b/test/public/dataPasses/overviewPerLhcPeriod.test.js similarity index 89% rename from test/public/dataPasses/overview.test.js rename to test/public/dataPasses/overviewPerLhcPeriod.test.js index 444b119fbe..6962aff72d 100644 --- a/test/public/dataPasses/overview.test.js +++ b/test/public/dataPasses/overviewPerLhcPeriod.test.js @@ -48,16 +48,14 @@ module.exports = () => { }); it('shows correct datatypes in respective columns', async () => { - const allowedBeamTypesDisplayes = new Set(['-', 'XeXe', 'PbPb', 'pp']); // Expectations of header texts being of a certain datatype const headerDatatypes = { name: (name) => periodNameRegex.test(name), - year: (year) => !isNaN(year), - beamType: (beamType) => allowedBeamTypesDisplayes.has(beamType), - avgCenterOfMassEnergy: (avgCenterOfMassEnergy) => !isNaN(avgCenterOfMassEnergy), - distinctEnergies: (distinctEnergies) => (distinctEnergies === '-' ? [] : distinctEnergies) - .split(',') - .every((energy) => !isNaN(energy)), + associatedRuns: (display) => /(No runs)|(\d+\nRuns)/.test(display), + description: (description) => /(-)|(.+)/.test(description), + reconstructedEventsCount: (reconstructedEventsCount) => !isNaN(reconstructedEventsCount.replace(/,/g, '')) + || reconstructedEventsCount === '-', + outputSize: (outputSize) => !isNaN(outputSize.replace(/,/g, '')) || outputSize === '-', }; // We find the headers matching the datatype keys @@ -192,15 +190,8 @@ module.exports = () => { await page.waitForTimeout(100); - /** - * As @see getAllDataFields returns innerText from cells, in case of lhcPeriod.name column, text from inner buttons is also taken. - * @param {string[]} periodNames list of names - * @return {string[]} cells content - */ - const appendButtonsText = (periodNames) => periodNames.map((p) => `${p}`); - let allDataPassesNames = await getAllDataFields(page, 'name'); - expect(allDataPassesNames).to.has.all.deep.members(appendButtonsText(['LHC22b_apass1'])); + expect(allDataPassesNames).to.has.all.deep.members(['LHC22b_apass1']); const resetFiltersButton = await page.$('#reset-filters'); expect(resetFiltersButton).to.not.be.null; @@ -208,6 +199,6 @@ module.exports = () => { await page.waitForTimeout(100); allDataPassesNames = await getAllDataFields(page, 'name'); - expect(allDataPassesNames).to.has.all.deep.members(appendButtonsText(['LHC22b_apass1', 'LHC22b_apass2'])); + expect(allDataPassesNames).to.has.all.deep.members(['LHC22b_apass1', 'LHC22b_apass2']); }); }; diff --git a/test/public/runs/index.js b/test/public/runs/index.js index d115435320..3ad4c670b7 100644 --- a/test/public/runs/index.js +++ b/test/public/runs/index.js @@ -14,9 +14,11 @@ const OverviewSuite = require('./overview.test'); const DetailSuite = require('./detail.test'); const RunsPerPeriodOverviewSuite = require('./runsPerPeriod.overview.test'); +const RunsPerDataPassOverviewPage = require('./runsPerDataPass.overview.test'); module.exports = () => { describe('Overview Page', OverviewSuite); describe('Detail Page', DetailSuite); - describe('Runs Per Period Overview Page', RunsPerPeriodOverviewSuite); + describe('Runs Per LHC Period Overview Page', RunsPerPeriodOverviewSuite); + describe('Runs Per Data Pass Overview Page', RunsPerDataPassOverviewPage); }; diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index b4d6ae369c..0dac397acd 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -22,7 +22,6 @@ const { getFirstRow, goToPage, checkColumnBalloon, - reloadPage, waitForNetworkIdleAndRedraw, } = require('../defaults'); const { RunDefinition } = require('../../../lib/server/services/run/getRunDefinition.js'); @@ -150,7 +149,7 @@ module.exports = () => { it('can switch to infinite mode in amountSelector', async () => { const INFINITE_SCROLL_CHUNK = 19; - await reloadPage(page); + await goToPage(page, 'run-overview'); // Wait fot the table to be loaded, it should have at least 2 rows (not loading) but less than 19 rows (which is infinite scroll chunk) await page.waitForSelector('table tbody tr:nth-child(2)'); @@ -180,7 +179,7 @@ module.exports = () => { }); it('can set how many runs are available per page', async () => { - await reloadPage(page); + await goToPage(page, 'run-overview'); const amountSelectorId = '#amountSelector'; const amountSelectorButtonSelector = `${amountSelectorId} button`; @@ -701,7 +700,7 @@ module.exports = () => { expect(await page.$eval(runNumberInputSelector, (input) => input.value)).to.equal(inputValue); // Test if it works in the filter tab. - await reloadPage(page); + await goToPage(page, 'run-overview'); await page.$eval('#openFilterToggle', (element) => element.click()); // Run the same test sequence on the filter tab. @@ -747,7 +746,7 @@ module.exports = () => { expect(await page.$eval(runNumberInputSelector, (input) => input.value)).to.equal(inputValue); // Test if it works in the filter tab. - await reloadPage(page); + await goToPage(page, 'run-overview'); await page.$eval('#openFilterToggle', (element) => element.click()); // Run the same test sequence on the filter tab. @@ -771,7 +770,7 @@ module.exports = () => { }); it('should successfully filter on a list of environment ids and inform the user about it', async () => { - await reloadPage(page); + await goToPage(page, 'run-overview'); await page.evaluate(() => window.model.disableInputDebounce()); await page.$eval('#openFilterToggle', (element) => element.click()); @@ -1013,7 +1012,7 @@ module.exports = () => { const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; it('should successfully display runs export button', async () => { - await reloadPage(page); + await goToPage(page, 'run-overview'); await page.waitForSelector(EXPORT_RUNS_TRIGGER_SELECTOR); const runsExportButton = await page.$(EXPORT_RUNS_TRIGGER_SELECTOR); expect(runsExportButton).to.be.not.null; @@ -1031,7 +1030,7 @@ module.exports = () => { }); it('should successfully display information when export will be truncated', async () => { - await reloadPage(page); + await goToPage(page, 'run-overview'); await page.waitForTimeout(200); await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); @@ -1044,7 +1043,7 @@ module.exports = () => { }); it('should successfully display disabled runs export button when there is no runs available', async () => { - await reloadPage(page); + await goToPage(page, 'run-overview'); await page.waitForTimeout(200); await pressElement(page, '#openFilterToggle'); @@ -1081,12 +1080,13 @@ module.exports = () => { // First export await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); await page.waitForSelector('#export-runs-modal'); - exportModal = await page.$('#export-runs-modal'); - expect(exportModal).to.not.be.null; + await page.waitForSelector('#send:disabled'); + await page.waitForSelector('.form-control'); + await page.select('.form-control', 'runQuality', 'runNumber'); + await page.waitForSelector('#send:enabled'); const exportButtonText = await page.$eval('#send', (button) => button.innerText); expect(exportButtonText).to.be.eql('Export'); - await page.select('.form-control', 'runQuality', 'runNumber'); await page.$eval('#send', (button) => button.click()); await waitForDownload(session); @@ -1099,9 +1099,7 @@ module.exports = () => { expect(runs).to.be.lengthOf(100); expect(runs.every(({ runQuality, runNumber, ...otherProps }) => runQuality && runNumber && Object.keys(otherProps).length === 0)).to.be.true; - downloadFilesNames = fs.readdirSync(downloadPath); fs.unlinkSync(path.resolve(downloadPath, targetFileName)); - downloadFilesNames = fs.readdirSync(downloadPath); // Second export @@ -1112,16 +1110,18 @@ module.exports = () => { await pressElement(page, '#openFilterToggle'); await page.waitForSelector(badFilterSelector); await page.$eval(badFilterSelector, (element) => element.click()); - await page.waitForSelector('div.atom-spinner'); + await page.waitForSelector('.atom-spinner'); + await page.waitForSelector('tbody tr:nth-child(2)'); await page.waitForSelector(EXPORT_RUNS_TRIGGER_SELECTOR); ///// Download await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); await page.waitForSelector('#export-runs-modal'); - exportModal = await page.$('#export-runs-modal'); expect(exportModal).to.not.be.null; + await page.waitForSelector('.form-control'); await page.select('.form-control', 'runQuality', 'runNumber'); + await page.waitForSelector('#send:enabled'); await page.$eval('#send', (button) => button.click()); await waitForDownload(session); @@ -1134,7 +1134,7 @@ module.exports = () => { }); it('should successfully navigate to the LHC fill details page', async () => { - await reloadPage(page); + await goToPage(page, 'run-overview'); // Run 106 has a fill attached const runId = 108; diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js new file mode 100644 index 0000000000..48cda06f4c --- /dev/null +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -0,0 +1,254 @@ +/** + * @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. + */ + +const path = require('path'); +const fs = require('fs'); +const chai = require('chai'); +const { + defaultBefore, + defaultAfter, + expectInnerText, + pressElement, + getFirstRow, + goToPage, + reloadPage, +} = require('../defaults'); +const { RUN_QUALITIES } = require('../../../lib/domain/enums/RunQualities.js'); +const { waitForDownload } = require('../../utilities/waitForDownload'); + +const { expect } = chai; + +const DETECTORS = [ + 'CPV', + 'EMC', + 'FDD', + 'FT0', + 'FV0', + 'HMP', + 'ITS', + 'MCH', + 'MFT', + 'MID', + 'PHS', + 'TOF', + 'TPC', + 'TRD', + 'TST', + 'ZDC', +]; + +module.exports = () => { + let page; + let browser; + + let table; + let firstRowId; + + before(async () => { + [page, browser] = await defaultBefore(page, browser); + await page.setViewport({ + width: 1200, + height: 720, + deviceScaleFactor: 1, + }); + }); + + after(async () => { + [page, browser] = await defaultAfter(page, browser); + }); + + it('loads the page successfully', async () => { + const response = await goToPage(page, 'runs-per-data-pass', { queryParameters: { dataPassId: 3 } }); + + // We expect the page to return the correct status code, making sure the server is running properly + expect(response.status()).to.equal(200); + + // We expect the page to return the correct title, making sure there isn't another server running on this port + const title = await page.title(); + expect(title).to.equal('AliceO2 Bookkeeping'); + + await page.waitForSelector('h2'); + const viewTitle = await page.$eval('h2', (element) => element.innerText); + expect(viewTitle).to.be.eql('Good, physics runs of LHC22a_apass1'); + }); + + it('shows correct datatypes in respective columns', async () => { + await reloadPage(page); + table = await page.$$('tr'); + firstRowId = await getFirstRow(table, page); + + // Expectations of header texts being of a certain datatype + const headerDatatypes = { + runNumber: (number) => typeof number == 'number', + fillNumber: (number) => typeof number == 'number', + timeO2Start: (date) => !isNaN(Date.parse(date)), + timeO2End: (date) => !isNaN(Date.parse(date)), + timeTrgStart: (date) => !isNaN(Date.parse(date)), + timeTrgEnd: (date) => !isNaN(Date.parse(date)), + aliceL3Current: (current) => !isNaN(Number(current)), + aliceL3Dipole: (current) => !isNaN(Number(current)), + ...Object.fromEntries(DETECTORS.map((detectorName) => [detectorName, (quality) => expect(quality).oneOf([...RUN_QUALITIES, ''])])), + }; + + // We find the headers matching the datatype keys + const headers = await page.$$('th'); + const headerIndices = {}; + for (const [index, header] of headers.entries()) { + const headerContent = await page.evaluate((element) => element.id, header); + const matchingDatatype = Object.keys(headerDatatypes).find((key) => headerContent === key); + if (matchingDatatype !== undefined) { + headerIndices[index] = matchingDatatype; + } + } + + // We expect every value of a header matching a datatype key to actually be of that datatype + const firstRowCells = await page.$$(`#${firstRowId} td`); + for (const [index, cell] of firstRowCells.entries()) { + if (Object.keys(headerIndices).includes(index)) { + const cellContent = await page.evaluate((element) => element.innerText, cell); + const expectedDatatype = headerDatatypes[headerIndices[index]](cellContent); + expect(expectedDatatype).to.be.true; + } + } + }); + + it('Should display the correct items counter at the bottom of the page', async () => { + await reloadPage(page); + await page.waitForTimeout(1000); + + expect(await page.$eval('#firstRowIndex', (element) => parseInt(element.innerText, 10))).to.equal(1); + expect(await page.$eval('#lastRowIndex', (element) => parseInt(element.innerText, 10))).to.equal(3); + expect(await page.$eval('#totalRowsCount', (element) => parseInt(element.innerText, 10))).to.equal(3); + }); + + it('successfully switch to raw timestamp display', async () => { + await reloadPage(page); + const rawTimestampToggleSelector = '#preferences-raw-timestamps'; + expect(await page.evaluate(() => document.querySelector('#row56 td:nth-child(3)').innerText)).to.equal('08/08/2019\n20:00:00'); + expect(await page.evaluate(() => document.querySelector('#row56 td:nth-child(4)').innerText)).to.equal('08/08/2019\n21:00:00'); + await page.$eval(rawTimestampToggleSelector, (element) => element.click()); + expect(await page.evaluate(() => document.querySelector('#row56 td:nth-child(3)').innerText)).to.equal('1565294400000'); + expect(await page.evaluate(() => document.querySelector('#row56 td:nth-child(4)').innerText)).to.equal('1565298000000'); + // Go back to normal + await page.$eval(rawTimestampToggleSelector, (element) => element.click()); + }); + + it('can set how many runs are available per page', async () => { + await reloadPage(page); + + const amountSelectorId = '#amountSelector'; + const amountSelectorButtonSelector = `${amountSelectorId} button`; + await pressElement(page, amountSelectorButtonSelector); + + const amountSelectorDropdown = await page.$(`${amountSelectorId} .dropup-menu`); + expect(Boolean(amountSelectorDropdown)).to.be.true; + + const amountItems5 = `${amountSelectorId} .dropup-menu .menu-item:first-child`; + await pressElement(page, amountItems5); + await page.waitForTimeout(600); + + // Expect the amount of visible runs to reduce when the first option (5) is selected + const tableRows = await page.$$('table tr'); + expect(tableRows.length - 1).to.equal(3); + + // Expect the custom per page input to have red border and text color if wrong value typed + const customPerPageInput = await page.$(`${amountSelectorId} input[type=number]`); + await customPerPageInput.evaluate((input) => input.focus()); + await page.$eval(`${amountSelectorId} input[type=number]`, (el) => { + el.value = '1111'; + el.dispatchEvent(new Event('input')); + }); + await page.waitForTimeout(100); + expect(Boolean(await page.$(`${amountSelectorId} input:invalid`))).to.be.true; + }); + + it('notifies if table loading returned an error', async () => { + await reloadPage(page); + await page.waitForTimeout(100); + // eslint-disable-next-line no-return-assign, no-undef + await page.evaluate(() => model.runs.perDataPassOverviewModel.pagination.itemsPerPage = 200); + await page.waitForTimeout(100); + + // We expect there to be a fitting error message + const expectedMessage = 'Invalid Attribute: "query.page.limit" must be less than or equal to 100'; + await expectInnerText(page, '.alert-danger', expectedMessage); + + // Revert changes for next test + await page.evaluate(() => { + // eslint-disable-next-line no-undef + model.runs.perDataPassOverviewModel.pagination.itemsPerPage = 10; + }); + await page.waitForTimeout(100); + }); + + it('can navigate to a run detail page', async () => { + await reloadPage(page); + await page.waitForTimeout(100); + await page.waitForSelector('tbody tr'); + + const expectedRunNumber = await page.evaluate(() => document.querySelector('tbody tr:first-of-type a').innerText); + + await page.evaluate(() => document.querySelector('tbody tr:first-of-type a').click()); + await page.waitForTimeout(100); + const redirectedUrl = await page.url(); + + const urlParameters = redirectedUrl.slice(redirectedUrl.indexOf('?') + 1).split('&'); + + expect(urlParameters).to.contain('page=run-detail'); + expect(urlParameters).to.contain(`runNumber=${expectedRunNumber}`); + }); + + it('should successfully export runs', async () => { + await goToPage(page, 'runs-per-data-pass', { queryParameters: { dataPassId: 3 } }); + const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-runs-trigger'; + + const downloadPath = path.resolve('./download'); + + // Check accessibility on frontend + const session = await page.target().createCDPSession(); + await session.send('Browser.setDownloadBehavior', { + behavior: 'allow', + downloadPath: downloadPath, + eventsEnabled: true, + }); + + const targetFileName = 'runs.json'; + + // First export + await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); + await page.waitForSelector('#export-runs-modal'); + await page.waitForSelector('#send:disabled'); + await page.waitForSelector('.form-control'); + await page.select('.form-control', 'runQuality', 'runNumber'); + await page.waitForSelector('#send:enabled'); + const exportButtonText = await page.$eval('#send', (button) => button.innerText); + expect(exportButtonText).to.be.eql('Export'); + + await page.$eval('#send', (button) => button.click()); + + await waitForDownload(session); + + // Check download + const downloadFilesNames = fs.readdirSync(downloadPath); + expect(downloadFilesNames.filter((name) => name == targetFileName)).to.be.lengthOf(1); + const runs = JSON.parse(fs.readFileSync(path.resolve(downloadPath, targetFileName))); + + expect(runs).to.be.lengthOf(3); + expect(runs).to.have.deep.all.members([ + { runNumber: 56, runQuality: 'good' }, + { runNumber: 54, runQuality: 'good' }, + { runNumber: 49, runQuality: 'good' }, + ]), + fs.unlinkSync(path.resolve(downloadPath, targetFileName)); + }); +}; diff --git a/test/utilities/waitForDownload.js b/test/utilities/waitForDownload.js index 073a307171..174cf59812 100644 --- a/test/utilities/waitForDownload.js +++ b/test/utilities/waitForDownload.js @@ -21,9 +21,9 @@ async function waitForDownload(session) { return new Promise((resolve, reject) => { session.on('Browser.downloadProgress', (event) => { if (event.state === 'completed') { - resolve(); + resolve('download completed'); } else if (event.state === 'canceled') { - reject(); + reject('download canceled'); } }); });