diff --git a/lib/database/utilities/QueryBuilder.js b/lib/database/utilities/QueryBuilder.js index 2f522902e8..a8b01de2f1 100644 --- a/lib/database/utilities/QueryBuilder.js +++ b/lib/database/utilities/QueryBuilder.js @@ -360,18 +360,36 @@ class WhereAssociationQueryBuilder extends WhereQueryBuilder { /** * Sets the operation. + * If an WhereAssociation is already set it will convert to an OR condition * * @param {Object} operation The Sequelize operation to use as where filter. * @returns {QueryBuilder} The current QueryBuilder instance. */ _op(operation) { - this.queryBuilder.include({ - association: this.association, - required: true, - where: { - [this.column]: operation, - }, - }); + // Check if this include association already exists + const existingInclude = this.queryBuilder.options.include?.find((include) => include.association === this.association); + + if (existingInclude && existingInclude.where) { + /* + * Replace existing where operation in the include with OR operation. + * This basically encapsulates the existing where operation in a OR operation together with the new operation. + */ + existingInclude.where = { + [Op.or]: [ + { [this.column]: existingInclude.where[this.column] }, + { [this.column]: operation }, + ], + }; + } else { + // Create new include + this.queryBuilder.include({ + association: this.association, + required: true, + where: { + [this.column]: operation, + }, + }); + } return this.queryBuilder; } diff --git a/lib/domain/dtos/filters/LhcFillsFilterDto.js b/lib/domain/dtos/filters/LhcFillsFilterDto.js index 9db91e46d1..f2664d0cc7 100644 --- a/lib/domain/dtos/filters/LhcFillsFilterDto.js +++ b/lib/domain/dtos/filters/LhcFillsFilterDto.js @@ -19,5 +19,6 @@ exports.LhcFillsFilterDto = Joi.object({ fillNumbers: Joi.string().trim().custom(validateRange).messages({ 'any.invalid': '{{#message}}', }), + runDuration: validateTimeDuration, beamDuration: validateTimeDuration, }); diff --git a/lib/public/components/Filters/LhcFillsFilter/beamDurationFilter.js b/lib/public/components/Filters/LhcFillsFilter/durationFilter.js similarity index 51% rename from lib/public/components/Filters/LhcFillsFilter/beamDurationFilter.js rename to lib/public/components/Filters/LhcFillsFilter/durationFilter.js index 2ef0bbc0af..29a78ed81d 100644 --- a/lib/public/components/Filters/LhcFillsFilter/beamDurationFilter.js +++ b/lib/public/components/Filters/LhcFillsFilter/durationFilter.js @@ -15,17 +15,18 @@ import { comparisonOperatorFilter } from '../common/filters/comparisonOperatorFi import { rawTextFilter } from '../common/filters/rawTextFilter.js'; /** - * Component to filter LHC-fills by beam duration + * Component to filter LHC-fills by duration * - * @param {TextComparisonFilterModel} beamDurationFilterModel beamDurationFilterModel + * @param {TextComparisonFilterModel} durationFilterModel durationFilterModel + * @param {string} id id used for the operand and operator elements, becomes: `${id}-operator` OR `${id}-operand`. * @returns {Component} the text field */ -export const beamDurationFilter = (beamDurationFilterModel) => { - const durationFilter = rawTextFilter( - beamDurationFilterModel.operandInputModel, - { id: 'beam-duration-filter-operand', classes: ['w-100', 'beam-duration-filter'], placeholder: 'e.g 16:14:15 (HH:MM:SS)' }, +export const durationFilter = (durationFilterModel, id) => { + const amountFilter = rawTextFilter( + durationFilterModel.operandInputModel, + { id: `${id}-operand`, classes: ['w-100'], placeholder: 'e.g 16:14:15 (HH:MM:SS)' }, ); - return comparisonOperatorFilter(durationFilter, beamDurationFilterModel.operatorSelectionModel.current, (value) => - beamDurationFilterModel.operatorSelectionModel.select(value), { id: 'beam-duration-filter-operator' }); + return comparisonOperatorFilter(amountFilter, durationFilterModel.operatorSelectionModel.current, (value) => + durationFilterModel.operatorSelectionModel.select(value), { id: `${id}-operator` }); }; diff --git a/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js b/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js index 8d0ef13e1e..277bcb6752 100644 --- a/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js +++ b/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js @@ -25,7 +25,7 @@ import { formatBeamType } from '../../../utilities/formatting/formatBeamType.js' import { frontLink } from '../../../components/common/navigation/frontLink.js'; import { toggleStableBeamOnlyFilter } from '../../../components/Filters/LhcFillsFilter/stableBeamFilter.js'; import { fillNumberFilter } from '../../../components/Filters/LhcFillsFilter/fillNumberFilter.js'; -import { beamDurationFilter } from '../../../components/Filters/LhcFillsFilter/beamDurationFilter.js'; +import { durationFilter } from '../../../components/Filters/LhcFillsFilter/durationFilter.js'; /** * List of active columns for a lhc fills table @@ -109,7 +109,7 @@ export const lhcFillsActiveColumns = { return '-'; }, - filter: (lhcFillModel) => beamDurationFilter(lhcFillModel.filteringModel.get('beamDuration')), + filter: (lhcFillModel) => durationFilter(lhcFillModel.filteringModel.get('beamDuration'), 'beam-duration-filter'), profiles: { lhcFill: true, environment: true, @@ -141,6 +141,7 @@ export const lhcFillsActiveColumns = { visible: true, size: 'w-8', format: (duration) => formatDuration(duration), + filter: (lhcFillModel) => durationFilter(lhcFillModel.filteringModel.get('runDuration'), 'run-duration-filter'), }, efficiency: { name: 'Fill Efficiency', diff --git a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js index fae9b894f8..a3a64d138a 100644 --- a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js +++ b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js @@ -28,7 +28,6 @@ export class LhcFillsOverviewModel extends OverviewPageModel { /** * Constructor * - * @param {model} model global model * @param {boolean} [stableBeamsOnly=false] if true, overview will load stable beam only */ constructor(stableBeamsOnly = false) { @@ -37,6 +36,7 @@ export class LhcFillsOverviewModel extends OverviewPageModel { this._filteringModel = new FilteringModel({ fillNumbers: new RawTextFilterModel(), beamDuration: new TextComparisonFilterModel(), + runDuration: new TextComparisonFilterModel(), hasStableBeams: new StableBeamFilterModel(), }); diff --git a/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js b/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js index d270572fbe..8faf42824e 100644 --- a/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js +++ b/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js @@ -44,8 +44,10 @@ class GetAllLhcFillsUseCase { const queryBuilder = new QueryBuilder(); + let associatedStatisticsRequired = false; + if (filter) { - const { hasStableBeams, fillNumbers, beamDuration } = filter; + const { hasStableBeams, fillNumbers, beamDuration, runDuration } = filter; if (hasStableBeams) { // For now, if a stableBeamsStart is present, then a beam is stable queryBuilder.where('stableBeamsStart').not().is(null); @@ -62,6 +64,24 @@ class GetAllLhcFillsUseCase { : queryBuilder.where('fillNumber').oneOf(...finalFillnumberList); } } + + // Run duration filter and corresponding operator. + if (runDuration?.limit !== undefined && runDuration?.operator) { + associatedStatisticsRequired = true; + // 00:00:00 aka 0 value is saved in the DB as null (bookkeeping.fill_statistics.runs_coverage) + if ((runDuration.operator === '>=' || runDuration.operator === '<=') && Number(runDuration.limit) === 0) { + // Include 00:00:00 = 0 = null AND everything above 00:00:00 which is more or less than 0. + queryBuilder.whereAssociation('statistics', 'runsCoverage').applyOperator(runDuration.operator, 0); + queryBuilder.whereAssociation('statistics', 'runsCoverage').applyOperator('=', null); + } else if ((runDuration.operator === '>' || runDuration.operator === '<') && Number(runDuration.limit) === 0) { + queryBuilder.whereAssociation('statistics', 'runsCoverage').applyOperator(runDuration.operator, 0); + } else if (Number(runDuration.limit) === 0) { + queryBuilder.whereAssociation('statistics', 'runsCoverage').applyOperator(runDuration.operator, null); + } else { + queryBuilder.whereAssociation('statistics', 'runsCoverage').applyOperator(runDuration.operator, runDuration.limit); + } + } + // Beam duration filter, limit and corresponding operator. if (beamDuration?.limit !== undefined && beamDuration?.operator) { queryBuilder.where('stableBeamsDuration').applyOperator(beamDuration.operator, beamDuration.limit); @@ -74,6 +94,11 @@ class GetAllLhcFillsUseCase { where: { definition: RunDefinition.PHYSICS }, required: false, }); + queryBuilder.include({ + association: 'statistics', + required: associatedStatisticsRequired, + }); + queryBuilder.orderBy('fillNumber', 'desc'); queryBuilder.limit(limit); queryBuilder.offset(offset); diff --git a/test/api/lhcFills.test.js b/test/api/lhcFills.test.js index 2983e4dd86..8557945f9e 100644 --- a/test/api/lhcFills.test.js +++ b/test/api/lhcFills.test.js @@ -251,6 +251,274 @@ module.exports = () => { done(); }); }); + + it('should return 200 and an LHCFill array for runs duration filter, = 05:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=05:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(1); + expect(res.body.data[0].fillNumber).to.equal(6); + + done(); + }); + }); + + it('should return 200 and an LHCFill array for runs duration filter, = 5:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=5:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(1); + expect(res.body.data[0].fillNumber).to.equal(6); + + done(); + }); + }); + + it('should return 200 for runs duration filter, = 00:9:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=00:9:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(0); + + done(); + }); + }); + + it('should return 200 for runs duration filter, = 00:00:9', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=00:00:9') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(0); + + done(); + }); + }); + + it('should return 200 for runs duration filter, = 999999:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=999999:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(0); + + done(); + }); + }); + + + it('should return 200 for runs duration filter, = 999999:0:0', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=999999:0:0') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(0); + + done(); + }); + }); + + + + it('should return 400 for wrong runs duration filter, = 44:60:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=44:60:00') + .expect(400) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.errors[0].title).to.equal('Invalid Attribute'); + + done(); + }); + }); + + it('should return 400 for wrong runs duration filter, = 44:00:60', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=44:00:60') + .expect(400) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.errors[0].title).to.equal('Invalid Attribute'); + + done(); + }); + }); + + it('should return 400 for wrong runs duration filter, = -44:30:15', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=-44:30:15') + .expect(400) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.errors[0].title).to.equal('Invalid Attribute'); + + done(); + }); + }); + + + it('should return 200 and an LHCFill array for runs duration filter, < 6:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]=<&filter[runDuration][limit]=6:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(1); + expect(res.body.data[0].fillNumber).to.equal(6); + + done(); + }); + }); + + it('should return 200 and an LHCFill array for runs duration filter, <= 5:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]=<=&filter[runDuration][limit]=5:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(1); + expect(res.body.data[0].fillNumber).to.equal(6); + + done(); + }); + }); + + it('should return 200 and an LHCFill array for runs duration filter, >= 00:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]=>=&filter[runDuration][limit]=00:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(5); + expect(res.body.data[0].fillNumber).to.equal(6); + + done(); + }); + }); + + it('should return 200 and an LHCFill array for runs duration filter, = 00:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]==&filter[runDuration][limit]=00:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(4); + expect(res.body.data[0].fillNumber).to.equal(5); + + done(); + }); + }); + + it('should return 200 and an LHCFill array for runs duration filter, > 00:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]=>&filter[runDuration][limit]=00:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(1); + expect(res.body.data[0].fillNumber).to.equal(6); + + done(); + }); + }); + + it('should return 200 and an LHCFill array for runs duration filter, < 00:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]=<&filter[runDuration][limit]=00:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(0); + + done(); + }); + }); + + it('should return 200 and an LHCFill array for runs duration filter, > 03:00:00', (done) => { + request(server) + .get('/api/lhcFills?page[offset]=0&page[limit]=15&filter[runDuration][operator]=>&filter[runDuration][limit]=03:00:00') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + expect(res.body.data).to.have.lengthOf(1); + expect(res.body.data[0].fillNumber).to.equal(6); + + done(); + }); + }); }); describe('POST /api/lhcFills', () => { it('should return 201 if valid data is provided', async () => { diff --git a/test/lib/usecases/lhcFill/GetAllLhcFillsUseCase.test.js b/test/lib/usecases/lhcFill/GetAllLhcFillsUseCase.test.js index 0d35c81e05..96dc965d4e 100644 --- a/test/lib/usecases/lhcFill/GetAllLhcFillsUseCase.test.js +++ b/test/lib/usecases/lhcFill/GetAllLhcFillsUseCase.test.js @@ -102,7 +102,6 @@ module.exports = () => { }) // Beam duration filter tests - it('should only contain specified stable beam durations, < 12:00:00', async () => { getAllLhcFillsDto.query = { filter: { beamDuration: {limit: '43200', operator: '<'} } }; const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto); @@ -156,4 +155,65 @@ module.exports = () => { expect(lhcFills).to.be.an('array').and.lengthOf(0) }) + + it('should only contain specified total run duration, > 04:00:00', async () => { + getAllLhcFillsDto.query = { filter: { runDuration: {limit: '14400', operator: '>'} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + + expect(lhcFills).to.be.an('array').and.lengthOf(1) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.statistics.runsCoverage).greaterThan(14400) + }); + }) + + it('should only contain specified total run duration, >= 05:00:00', async () => { + getAllLhcFillsDto.query = { filter: { runDuration: {limit: '18000', operator: '>='} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + + expect(lhcFills).to.be.an('array').and.lengthOf(1) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.statistics.runsCoverage).greaterThan(18000) + }); + }) + + it('should only contain specified total run duration, = 05:00:00', async () => { + getAllLhcFillsDto.query = { filter: { runDuration: {limit: '18000', operator: '='} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + + expect(lhcFills).to.be.an('array').and.lengthOf(1) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.statistics.runsCoverage).greaterThan(18000) + }); + }) + + it('should only contain specified total run duration, = 00:00:00', async () => { + // Tests the usecase's ability to replace the request for 0 to a request for null. + getAllLhcFillsDto.query = { filter: { runDuration: {limit: '0', operator: '='} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + + expect(lhcFills).to.be.an('array').and.lengthOf(4) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.statistics.runsCoverage).equals(0) + }); + }) + + it('should only contain specified total run duration, <= 05:00:00', async () => { + getAllLhcFillsDto.query = { filter: { runDuration: {limit: '18000', operator: '<='} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + + expect(lhcFills).to.be.an('array').and.lengthOf(1) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.statistics.runsCoverage).greaterThan(18000) + }); + }) + + it('should only contain specified total run duration, < 06:30:59', async () => { + getAllLhcFillsDto.query = { filter: { runDuration: {limit: '23459', operator: '<'} } }; + const { lhcFills } = await new GetAllLhcFillsUseCase().execute(getAllLhcFillsDto) + + expect(lhcFills).to.be.an('array').and.lengthOf(1) + lhcFills.forEach((lhcFill) => { + expect(lhcFill.statistics.runsCoverage).greaterThan(23459) + }); + }) }; diff --git a/test/public/lhcFills/overview.test.js b/test/public/lhcFills/overview.test.js index 83ad6d996c..47e320b01b 100644 --- a/test/public/lhcFills/overview.test.js +++ b/test/public/lhcFills/overview.test.js @@ -271,7 +271,9 @@ module.exports = () => { const filterSBExpect = { selector: '.stableBeams-filter .w-30', value: 'Stable Beams Only' }; const filterFillNRExpect = {selector: 'div.items-baseline:nth-child(1) > div:nth-child(1)', value: 'Fill #'}; const filterSBDurationExpect = {selector: 'div.items-baseline:nth-child(3) > div:nth-child(1)', value: 'SB Duration'}; - const filterSBDurationPlaceholderExpect = {selector: 'input.w-100:nth-child(2)', value: 'e.g 16:14:15 (HH:MM:SS)'}; + const filterSBDurationPlaceholderExpect = {selector: '#beam-duration-filter-operand', value: 'e.g 16:14:15 (HH:MM:SS)'} + const filterRunDurationExpect = {selector: 'div.flex-row:nth-child(4) > div:nth-child(1)', value: 'Total runs duration'} + const filterRunDurationPlaceholderExpect = {selector: '#run-duration-filter-operand', value: 'e.g 16:14:15 (HH:MM:SS)'}; const filterSBDurationOperatorExpect = { value: true }; @@ -284,6 +286,8 @@ module.exports = () => { await expectInnerText(page, filterFillNRExpect.selector, filterFillNRExpect.value); await expectInnerText(page, filterSBDurationExpect.selector, filterSBDurationExpect.value); await expectAttributeValue(page, filterSBDurationPlaceholderExpect.selector, 'placeholder', filterSBDurationPlaceholderExpect.value); + await expectInnerText(page, filterRunDurationExpect.selector, filterRunDurationExpect.value); + await expectAttributeValue(page, filterRunDurationPlaceholderExpect.selector, 'placeholder', filterRunDurationPlaceholderExpect.value); }); it('should successfully un-apply Stable Beam filter menu', async () => { @@ -314,4 +318,19 @@ module.exports = () => { await fillInput(page, filterSBDurationOperand, '00:01:40', ['change']); await waitForTableLength(page, 4); }); + + it('should successfully apply run duration filter', async () => { + const filterRunDurationOperator= '#run-duration-filter-operator'; + const filterRunDurationOperand= '#run-duration-filter-operand'; + await goToPage(page, 'lhc-fill-overview'); + await waitForTableLength(page, 5); + // Open the filtering panel + await openFilteringPanel(page); + await page.select(filterRunDurationOperator, '<='); + await fillInput(page, filterRunDurationOperand, '00:00:00', ['change']); + await waitForTableLength(page, 4); + await page.select(filterRunDurationOperator, '>='); + await fillInput(page, filterRunDurationOperand, '00:00:00', ['change']); + await waitForTableLength(page, 5); + }); };