diff --git a/QualityControl/lib/dtos/LayoutDto.js b/QualityControl/lib/dtos/LayoutDto.js index a285ddb94..f7354f5fb 100644 --- a/QualityControl/lib/dtos/LayoutDto.js +++ b/QualityControl/lib/dtos/LayoutDto.js @@ -26,6 +26,7 @@ const ALLOWED_LAYOUT_FIELDS = [ 'owner_id', 'owner_name', 'tabs', + 'labels', ]; /** @@ -71,6 +72,7 @@ export const LayoutDto = Joi.object({ id: Joi.string().required(), name: Joi.string().min(3).max(40).required(), tabs: Joi.array().min(1).max(45).items(TabsDto).required(), + labels: Joi.array().items(Joi.string()).default([]), owner_id: Joi.number().min(0).required(), owner_name: Joi.string().required(), description: Joi.string().min(0).max(100).optional(), diff --git a/QualityControl/lib/repositories/LayoutRepository.js b/QualityControl/lib/repositories/LayoutRepository.js index 266245016..5baa972aa 100644 --- a/QualityControl/lib/repositories/LayoutRepository.js +++ b/QualityControl/lib/repositories/LayoutRepository.js @@ -13,6 +13,8 @@ import { NotFoundError } from '@aliceo2/web-ui'; import { BaseRepository } from './BaseRepository.js'; +import { addLabelsToLayout } from '../utils/layout/addLabelsToLayout.js'; +import { trimLayoutPerRequiredFields } from '../utils/layout/trimLayoutPerRequiredFields.js'; /** * LayoutRepository class to handle CRUD operations for Layouts. @@ -26,20 +28,18 @@ export class LayoutRepository extends BaseRepository { * @param {object} [options.filter] - Filter layouts by containing filter.objectPath, case insensitive * @returns {Array} Array of layout objects matching the filters, containing only the specified fields */ - listLayouts({ name, fields = [], filter } = {}) { + listLayouts({ name, fields, filter } = {}) { const { layouts } = this._jsonFileService.data; const filteredLayouts = this._filterLayouts(layouts, { ...filter, name }); - if (fields.length === 0) { - return filteredLayouts; - } - return filteredLayouts.map((layout) => { - const layoutObj = {}; - fields.forEach((field) => { - layoutObj[field] = layout[field]; + const trimmedAndLabelledLayouts = filteredLayouts + .map((layout) => { + const labeledLayout = addLabelsToLayout(layout); + const trimmedLayout = trimLayoutPerRequiredFields(labeledLayout, fields); + return trimmedLayout; }); - return layoutObj; - }); + + return trimmedAndLabelledLayouts; } /** @@ -79,25 +79,27 @@ export class LayoutRepository extends BaseRepository { * @throws {NotFoundError} - if the layout is not found */ readLayoutById(layoutId) { - const foundLayout = this._jsonFileService.data.layouts.find((layout) => layout.id === layoutId); - if (!foundLayout) { + const layout = this._jsonFileService.data.layouts.find((layout) => layout.id === layoutId); + if (!layout) { throw new NotFoundError(`layout (${layoutId}) not found`); } - return foundLayout; + const labeledLayout = addLabelsToLayout(layout); + return labeledLayout; } /** * Given a string, representing layout name, retrieve the layout if it exists * @param {string} layoutName - name of the layout to retrieve * @returns {Layout} - object with layout information - * @throws + * @throws {NotFoundError} - if the layout is not found */ readLayoutByName(layoutName) { const layout = this._jsonFileService.data.layouts.find((layout) => layout.name === layoutName); if (!layout) { throw new NotFoundError(`Layout (${layoutName}) not found`); } - return layout; + const labeledLayout = addLabelsToLayout(layout); + return labeledLayout; } /** @@ -127,9 +129,17 @@ export class LayoutRepository extends BaseRepository { * @param {string} layoutId - id of the layout to be updated * @param {LayoutDto} newData - layout new data * @returns {string} id of the layout updated + * @throws {NotFoundError} - if the layout is not found */ async updateLayout(layoutId, newData) { - const layout = this.readLayoutById(layoutId); + if (newData.labels) { + // labels are retrieved on front-end and might be send as PATCH/PUT if forgotten by developer + delete newData.labels; + } + const layout = this._jsonFileService.data.layouts.find((layout) => layout.id === layoutId); + if (!layout) { + throw new NotFoundError(`layout (${layoutId}) not found`); + } Object.assign(layout, newData); await this._jsonFileService.writeToFile(); return layoutId; @@ -139,10 +149,13 @@ export class LayoutRepository extends BaseRepository { * Delete a single layout by its id * @param {string} layoutId - id of the layout to be removed * @returns {string} id of the layout deleted + * @throws {NotFoundError} - if the layout is not found */ async deleteLayout(layoutId) { - const layout = this.readLayoutById(layoutId); - const index = this._jsonFileService.data.layouts.indexOf(layout); + const index = this._jsonFileService.data.layouts.findIndex((layout) => layout.id === layoutId); + if (index === -1) { + throw new NotFoundError(`layout (${layoutId}) not found`); + } this._jsonFileService.data.layouts.splice(index, 1); await this._jsonFileService.writeToFile(); return layoutId; diff --git a/QualityControl/lib/utils/layout/addLabelsToLayout.js b/QualityControl/lib/utils/layout/addLabelsToLayout.js new file mode 100644 index 000000000..e2cf1f196 --- /dev/null +++ b/QualityControl/lib/utils/layout/addLabelsToLayout.js @@ -0,0 +1,30 @@ +/** + * @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. + */ + +/** + * Method to identify the unique prefix (encountering first '/') of objects and add it as a set of labels to layout + * @param {LayoutDto} layout - layout object to which labels will be added + * @returns {{LayoutDto, labels: string[]}} - layout object with added labels + */ +export const addLabelsToLayout = (layout) => { + const labelsSet = new Set(); + layout.tabs?.forEach((tab) => { + tab.objects?.forEach((obj) => { + if (obj.name) { + const [prefix] = obj.name.split('/'); + labelsSet.add(prefix); + } + }); + }); + return { ...layout, labels: Array.from(labelsSet) }; +}; diff --git a/QualityControl/lib/utils/layout/trimLayoutPerRequiredFields.js b/QualityControl/lib/utils/layout/trimLayoutPerRequiredFields.js new file mode 100644 index 000000000..085f099e7 --- /dev/null +++ b/QualityControl/lib/utils/layout/trimLayoutPerRequiredFields.js @@ -0,0 +1,31 @@ +/** + * @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. + */ + +/** + * Trims a layout object to only include requested fields + * @param {LayoutDto} layout - layout object to be trimmed + * @param {string[]} [fields = []] - Array of field names to include in the returned layout object + * @returns {Partial} - Trimmed layout object + */ +export const trimLayoutPerRequiredFields = (layout, fields = []) => { + if (fields.length === 0) { + return layout; + } + const trimmedLayout = {}; + for (const field of fields) { + if (field in layout) { + trimmedLayout[field] = layout[field]; + } + } + return trimmedLayout; +}; diff --git a/QualityControl/public/common/RequestFields.enum.js b/QualityControl/public/common/RequestFields.enum.js index 8f9b95e45..81b28ec5b 100644 --- a/QualityControl/public/common/RequestFields.enum.js +++ b/QualityControl/public/common/RequestFields.enum.js @@ -1,3 +1,3 @@ export const RequestFields = Object.freeze({ - LAYOUT_CARD: 'id,name,owner_id,owner_name,description,isOfficial', + LAYOUT_CARD: 'id,name,owner_id,owner_name,description,isOfficial,labels', }); diff --git a/QualityControl/test/api/layouts/api-get-layout.test.js b/QualityControl/test/api/layouts/api-get-layout.test.js index 068b338bb..2a5c99f07 100644 --- a/QualityControl/test/api/layouts/api-get-layout.test.js +++ b/QualityControl/test/api/layouts/api-get-layout.test.js @@ -17,6 +17,7 @@ import { OWNER_TEST_TOKEN, URL_ADDRESS } from '../config.js'; import request from 'supertest'; import { deepStrictEqual } from 'node:assert'; import { LAYOUT_MOCK_4, LAYOUT_MOCK_5, LAYOUT_MOCK_6 } from '../../demoData/layout/layout.mock.js'; +import { addLabelsToLayout } from '../../../lib/utils/layout/addLabelsToLayout.js'; export const apiGetLayoutsTests = () => { suite('GET /layouts', () => { @@ -43,7 +44,9 @@ export const apiGetLayoutsTests = () => { if (!Array.isArray(res.body)) { throw new Error('Expected array of layouts'); } - + res.body.forEach((layout) => { + delete layout.labels; // remove labels for deep comparison + }); deepStrictEqual(res.body, [LAYOUT_MOCK_4, LAYOUT_MOCK_5], 'Unexpected Layout structure was returned'); }); }); @@ -80,10 +83,11 @@ export const apiGetLayoutsTests = () => { suite('GET /layout/:id', () => { test('should return a single layout by id', async () => { const layoutId = '671b8c22402408122e2f20dd'; + const expectedLayout = addLabelsToLayout(LAYOUT_MOCK_6); await request(`${URL_ADDRESS}/api/layout/${layoutId}`) .get(`?token=${OWNER_TEST_TOKEN}`) .expect(200) - .expect((res) => deepStrictEqual(res.body, LAYOUT_MOCK_6, 'Unexpected Layout structure was returned')); + .expect((res) => deepStrictEqual(res.body, expectedLayout, 'Unexpected Layout structure was returned')); }); test('should return 400 when id parameter is an empty string', async () => { @@ -103,19 +107,22 @@ export const apiGetLayoutsTests = () => { suite('GET /layout?name=', () => { test('should return layout by name', async () => { const layoutName = 'a-test'; + const expectedLayout = addLabelsToLayout(LAYOUT_MOCK_5); await request(`${URL_ADDRESS}/api/layout`) .get(`?token=${OWNER_TEST_TOKEN}&name=${layoutName}`) .expect(200) - .expect((res) => deepStrictEqual(res.body, LAYOUT_MOCK_5, 'Unexpected Layout structure was returned')); + .expect((res) => deepStrictEqual(res.body, expectedLayout, 'Unexpected Layout structure was returned')); }); test('should return layout by runDefinition', async () => { const runDefinition = 'a-test'; + const expectedLayout = addLabelsToLayout(LAYOUT_MOCK_5); await request(`${URL_ADDRESS}/api/layout`) .get(`?token=${OWNER_TEST_TOKEN}&runDefinition=${runDefinition}`) .expect(200) - .expect((res) => deepStrictEqual(res.body, LAYOUT_MOCK_5, 'Unexpected Layout structure was returned')); + .expect((res) => deepStrictEqual(res.body, expectedLayout, 'Unexpected Layout structure was returned')); }); + test('should return layout by runDefinition and pdpBeamType combination', async () => { const runDefinition = 'rundefinition'; const pdpBeamType = 'pdpBeamType'; diff --git a/QualityControl/test/lib/controllers/LayoutController.test.js b/QualityControl/test/lib/controllers/LayoutController.test.js index f6411ce91..2c3265a2e 100644 --- a/QualityControl/test/lib/controllers/LayoutController.test.js +++ b/QualityControl/test/lib/controllers/LayoutController.test.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { ok, throws, doesNotThrow, AssertionError } from 'node:assert'; +import { ok, deepStrictEqual, throws, doesNotThrow, AssertionError } from 'node:assert'; import { suite, test, beforeEach } from 'node:test'; import sinon from 'sinon'; @@ -416,6 +416,7 @@ export const layoutControllerTestSuite = async () => { tabs: [{ name: 'tab', id: '1', columns: 2, objects: [] }], owner_id: 1, owner_name: 'one', + labels: [], collaborators: [], displayTimestamp: false, autoTabChange: 0, @@ -467,6 +468,7 @@ export const layoutControllerTestSuite = async () => { tabs: [{ name: 'tab', id: '1', columns: 2, objects: [] }], owner_id: 1, owner_name: 'one', + labels: [], collaborators: [], displayTimestamp: false, autoTabChange: 0, @@ -643,6 +645,7 @@ export const layoutControllerTestSuite = async () => { owner_name: 'admin', tabs: [{ id: '123', name: 'tab', columns: 2, objects: [] }], collaborators: [], + labels: [], displayTimestamp: false, autoTabChange: 0, }; @@ -671,6 +674,7 @@ export const layoutControllerTestSuite = async () => { owner_id: 1, owner_name: 'admin', tabs: [{ id: '123', name: 'tab', columns: 2, objects: [] }], + labels: [], collaborators: [], displayTimestamp: false, autoTabChange: 0, @@ -682,7 +686,10 @@ export const layoutControllerTestSuite = async () => { status: 500, title: 'Unknown Error', }), 'DataConnector error message is incorrect'); - ok(jsonStub.createLayout.calledWith(expected), 'New layout body was not used in data connector call'); + + // Log what was actually called for debugging + const actualCall = jsonStub.createLayout.getCall(0)?.args[0]; + deepStrictEqual(expected, actualCall); }); }); diff --git a/QualityControl/test/lib/repositories/LayoutRepository.test.js b/QualityControl/test/lib/repositories/LayoutRepository.test.js index 84309ac09..c3a84aade 100644 --- a/QualityControl/test/lib/repositories/LayoutRepository.test.js +++ b/QualityControl/test/lib/repositories/LayoutRepository.test.js @@ -12,11 +12,12 @@ */ import { suite, test, before, beforeEach } from 'node:test'; -import { LayoutRepository } from '../../../lib/repositories/LayoutRepository.js'; import { deepEqual, deepStrictEqual, ok, rejects, strictEqual, throws } from 'node:assert'; import { NotFoundError } from '@aliceo2/web-ui'; import sinon from 'sinon'; import { initTest } from '../../setup/testRepositorySetup.js'; +import { LayoutRepository } from '../../../lib/repositories/LayoutRepository.js'; +import { addLabelsToLayout } from '../../../lib/utils/layout/addLabelsToLayout.js'; /** * @typedef {import('../../../lib/services/JsonFileService.js').JsonFileService} JsonFileService @@ -37,18 +38,18 @@ export const layoutRepositoryTest = async () => { jsonFileServiceMock.writeToFile.resetHistory(); }); - beforeEach(() => { - jsonFileServiceMock.writeToFile.resetHistory(); - }); - test('should initialize LayoutRepository successfully', () => { ok(layoutRepository); }); suite('list layouts', () => { - test('should list all layouts without filter', async () => { + test('should list all layouts without filter', () => { const result = layoutRepository.listLayouts(); strictEqual(result.length, 3, 'Length of list of layouts is not correct'); + result.forEach((layout) => { + ok(layout.labels, 'Each layout should have labels field'); + delete layout.labels; // remove labels for deep comparison + }); deepStrictEqual(result, jsonFileServiceMock.data.layouts, 'List of layouts filtered do not match the filters'); }); @@ -57,15 +58,15 @@ export const layoutRepositoryTest = async () => { const result = layoutRepository.listLayouts({ filter: { owner_id: ownerId } }); strictEqual(result.length, 2, 'number of layouts is incorrect'); - result.forEach((layout) => { + result.forEach((layout, index) => { + delete layout.labels; // remove labels for deep comparison strictEqual(layout.owner_id, ownerId, `Layout owner_id should be ${ownerId}`); + deepStrictEqual( + layout, + jsonFileServiceMock.data.layouts[index], + 'Filtered layout does not match expected layout', + ); }); - - deepStrictEqual( - result[0], - jsonFileServiceMock.data.layouts[0], - 'First layout should match the expected layout', - ); }); test('should return only layout with specified filter.objectPath', () => { @@ -73,7 +74,7 @@ export const layoutRepositoryTest = async () => { const result = layoutRepository.listLayouts({ filter: { objectPath, } }); - ok(result.length === 1, "listLayouts's filter.objectPath should only return one layout"); + strictEqual(result.length, 1, "listLayouts's filter.objectPath should only return one layout"); }); test('should return layouts with specified partial filter.objectPath', () => { @@ -81,7 +82,7 @@ export const layoutRepositoryTest = async () => { const result = layoutRepository.listLayouts({ filter: { objectPath, } }); - ok(result.length === 2, "listLayouts's filter.objectPath should only return 2 layouts"); + strictEqual(result.length, 2, "listLayouts's filter.objectPath should only return 2 layouts"); }); test('should return all layouts when filter.objectPath is empty string', () => { @@ -89,12 +90,12 @@ export const layoutRepositoryTest = async () => { const result = layoutRepository.listLayouts({ filter: { objectPath, } }); - ok(result.length === 3, "listLayouts's filter.objectPath should only return 3 (all) layouts"); + strictEqual(result.length, 3, "listLayouts's filter.objectPath should only return 3 (all) layouts"); }); test('should return all layouts when filter is an empty object', () => { const result = layoutRepository.listLayouts({ filter: {} }); - ok(result.length === 3, "listLayouts's empty filter object should only return 3 (all) layouts"); + strictEqual(result.length, 3, "listLayouts's empty filter object should only return 3 (all) layouts"); }); test('should return only specified fields when fields array is provided', () => { @@ -103,7 +104,22 @@ export const layoutRepositoryTest = async () => { result.forEach((layout) => { const actualKeys = Object.keys(layout); - deepStrictEqual(actualKeys.sort(), fields); + strictEqual(actualKeys.length, 2, 'Should have exactly 2 fields'); + deepStrictEqual(actualKeys.sort(), fields.sort()); + }); + + strictEqual(result.length, jsonFileServiceMock.data.layouts.length); + }); + + test('should include labels field when requested in fields array', () => { + const fields = ['id', 'name', 'labels']; + const result = layoutRepository.listLayouts({ fields }); + + result.forEach((layout) => { + const actualKeys = Object.keys(layout); + strictEqual(actualKeys.length, 3, 'Should have exactly 3 fields'); + ok(layout.labels, 'Should have labels field'); + ok(Array.isArray(layout.labels), 'Labels should be an array'); }); strictEqual(result.length, jsonFileServiceMock.data.layouts.length); @@ -116,10 +132,11 @@ export const layoutRepositoryTest = async () => { layoutRepository.readLayoutById('999'); }, NotFoundError); }); + test('should return a layout if it is found', () => { const layoutId = '671b95883d23cd0d67bdc787'; const layout = layoutRepository.readLayoutById(layoutId); - const expectedLayout = jsonFileServiceMock.data.layouts.find((l) => l.id === layoutId); + const expectedLayout = addLabelsToLayout(jsonFileServiceMock.data.layouts.find((l) => l.id === layoutId)); strictEqual(layout.id, layoutId); deepStrictEqual(layout, expectedLayout); strictEqual(layout.name, expectedLayout.name); @@ -169,16 +186,17 @@ export const layoutRepositoryTest = async () => { suite('update layouts', () => { test('should update a single layout by its id', async () => { + const idOfLayoutToUpdate = '671b8c22402408122e2f20dd'; const newLayout = { - id: '671b8c22402408122e2f20dd', + id: idOfLayoutToUpdate, name: 'Test Layout Updated', owner_id: 'user1', tabs: [{ name: 'Tab1', objects: [{ id: '1', name: 'Object1' }] }], }; - const idOfLayoutUpdated = await layoutRepository.updateLayout('671b8c22402408122e2f20dd', newLayout); - strictEqual(idOfLayoutUpdated, '671b8c22402408122e2f20dd'); + const idOfLayoutUpdated = await layoutRepository.updateLayout(idOfLayoutToUpdate, newLayout); + strictEqual(idOfLayoutUpdated, idOfLayoutToUpdate); - const updatedLayout = jsonFileServiceMock.data.layouts.find((l) => l.id === newLayout.id); + const updatedLayout = addLabelsToLayout(jsonFileServiceMock.data.layouts.find((l) => l.id === newLayout.id)); strictEqual(updatedLayout.id, newLayout.id); strictEqual(updatedLayout.name, newLayout.name); strictEqual(updatedLayout.owner_id, newLayout.owner_id); diff --git a/QualityControl/test/lib/utils/layout/addLabelsToLayout.test.js b/QualityControl/test/lib/utils/layout/addLabelsToLayout.test.js new file mode 100644 index 000000000..10d344d3b --- /dev/null +++ b/QualityControl/test/lib/utils/layout/addLabelsToLayout.test.js @@ -0,0 +1,189 @@ +/** + * @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 { suite, test } from 'node:test'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import { addLabelsToLayout } from '../../../../lib/utils/layout/addLabelsToLayout.js'; + +export const addLabelsToLayoutTestSuite = () => { + suite('\'addLabelsToLayout\' test suite', () => { + test('should extract unique labels from multiple objects with different prefixes', () => { + const layout = { + id: '2', + name: 'Multi Prefix Layout', + tabs: [ + { + name: 'Tab1', + objects: [ + { id: 'obj1', name: 'qc/MCH/QO/Check1' }, + { id: 'obj2', name: 'qc_async/System/Monitor' }, + { id: 'obj3', name: 'qc_mc/Processing/Status' }, + ], + }, + ], + }; + + const result = addLabelsToLayout(layout); + + deepStrictEqual(result.labels, ['qc', 'qc_async', 'qc_mc'], 'Should contain all 3 labels'); + }); + + test('should handle duplicate prefixes and return unique labels only', () => { + const layout = { + id: '3', + name: 'Duplicate Prefix Layout', + tabs: [ + { + name: 'Tab1', + objects: [ + { id: 'obj1', name: 'qc/MCH/QO/Check1' }, + { id: 'obj2', name: 'qc/TPC/QO/Check2' }, + { id: 'obj3', name: 'qc/ITS/QO/Check3' }, + ], + }, + ], + }; + + const result = addLabelsToLayout(layout); + + deepStrictEqual(result.labels, ['qc'], 'Should extract "qc" as the only label'); + }); + + test('should handle multiple tabs with objects', () => { + const layout = { + id: '4', + name: 'Multi Tab Layout', + tabs: [ + { + name: 'Tab1', + objects: [{ id: 'obj1', name: 'qc/MCH/QO/Check1' }], + }, + { + name: 'Tab2', + objects: [{ id: 'obj2', name: 'qc/System/Monitor' }], + }, + ], + }; + + const result = addLabelsToLayout(layout); + + deepStrictEqual(result.labels, ['qc'], 'Should include "qc" label from Tab2'); + }); + + test('should return empty labels array when layout has no tabs', () => { + const layout = { + id: '5', + name: 'No Tabs Layout', + }; + + const result = addLabelsToLayout(layout); + + ok(Array.isArray(result.labels), 'Labels should be an array'); + strictEqual(result.labels.length, 0, 'Should have empty labels array'); + }); + + test('should return empty labels array when tabs have no objects', () => { + const layout = { + id: '6', + name: 'Empty Tabs Layout', + tabs: [{ name: 'Tab1' }], + }; + + const result = addLabelsToLayout(layout); + + ok(Array.isArray(result.labels), 'Labels should be an array'); + strictEqual(result.labels.length, 0, 'Should have empty labels array'); + }); + + test('should return empty labels array when objects have no names', () => { + const layout = { + id: '7', + name: 'No Names Layout', + tabs: [ + { + name: 'Tab1', + objects: [{ id: 'obj1' }, { id: 'obj2' }], + }, + ], + }; + + const result = addLabelsToLayout(layout); + + ok(Array.isArray(result.labels), 'Labels should be an array'); + strictEqual(result.labels.length, 0, 'Should have empty labels array when objects have no names'); + }); + + test('should handle objects with names that do not contain slashes', () => { + const layout = { + id: '8', + name: 'No Slash Layout', + tabs: [ + { + name: 'Tab1', + objects: [ + { id: 'obj1', name: 'simpleObject' }, + { id: 'obj2', name: 'anotherSimpleObject' }, + ], + }, + ], + }; + + const result = addLabelsToLayout(layout); + + strictEqual(result.labels.length, 2, 'Should extract full names as labels'); + ok(result.labels.includes('simpleObject'), 'Should include "simpleObject" as label'); + ok(result.labels.includes('anotherSimpleObject'), 'Should include "anotherSimpleObject" as label'); + }); + + test('should handle mixed objects with and without names', () => { + const layout = { + id: '9', + name: 'Mixed Objects Layout', + tabs: [ + { + name: 'Tab1', + objects: [ + { id: 'obj1', name: 'qc/MCH/QO/Check1' }, + { id: 'obj2' }, + { id: 'obj3', name: 'qc/System/Monitor' }, + ], + }, + ], + }; + + const result = addLabelsToLayout(layout); + + deepStrictEqual(result.labels, ['qc'], 'Should include "qc" label'); + }); + + test('should not mutate the original layout object', () => { + const layout = { + id: '10', + name: 'Original Layout', + tabs: [ + { + name: 'Tab1', + objects: [{ id: 'obj1', name: 'qc/MCH/QO/Check1' }], + }, + ], + }; + + const originalTabsReference = layout.tabs; + const result = addLabelsToLayout(layout); + + ok(!('labels' in layout), 'Original layout should not have labels property added'); + strictEqual(layout.tabs, originalTabsReference, 'Original layout tabs should not be mutated'); + ok(result.labels, 'Result should have labels property'); + }); + }); +}; diff --git a/QualityControl/test/lib/utils/layout/trimLayoutPerRequiredFields.test.js b/QualityControl/test/lib/utils/layout/trimLayoutPerRequiredFields.test.js new file mode 100644 index 000000000..457720e3c --- /dev/null +++ b/QualityControl/test/lib/utils/layout/trimLayoutPerRequiredFields.test.js @@ -0,0 +1,227 @@ +/** + * @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 { suite, test } from 'node:test'; +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import { trimLayoutPerRequiredFields } from '../../../../lib/utils/layout/trimLayoutPerRequiredFields.js'; + +export const trimLayoutPerRequiredFieldsTestSuite = () => { + suite('\'trimLayoutPerRequiredFields\' test suite', () => { + test('should return labeled layout with all fields when fields array is empty', () => { + const layout = { + id: '1', + name: 'Test Layout', + owner_id: 'user1', + tabs: [ + { + name: 'Tab1', + objects: [ + { id: 'obj1', name: 'qc/MCH/QO/Check1' }, + { id: 'obj2', name: 'daq/System/Monitor' }, + ], + }, + ], + }; + + const result = trimLayoutPerRequiredFields(layout, []); + + strictEqual(result.id, layout.id, 'Should have id field'); + strictEqual(result.name, layout.name, 'Should have name field'); + strictEqual(result.owner_id, layout.owner_id, 'Should have owner_id field'); + deepStrictEqual(result.tabs, layout.tabs, 'Should have tabs field'); + }); + + test('should return only specified fields when fields array is provided', () => { + const layout = { + id: '2', + name: 'Test Layout', + owner_id: 'user2', + tabs: [ + { + name: 'Tab1', + objects: [{ id: 'obj1', name: 'qc/MCH/QO/Check1' }], + }, + ], + }; + const fields = ['id', 'name']; + + const result = trimLayoutPerRequiredFields(layout, fields); + + const resultKeys = Object.keys(result); + strictEqual(resultKeys.length, 2, 'Should have exactly 2 fields'); + deepStrictEqual(resultKeys.sort(), fields.sort(), 'Should have only specified fields'); + strictEqual(result.id, layout.id, 'Should have correct id value'); + strictEqual(result.name, layout.name, 'Should have correct name value'); + ok(!result.owner_id, 'Should not have owner_id field'); + ok(!result.tabs, 'Should not have tabs field'); + }); + + test('should not include labels field when explicitly requested if not in layout', () => { + const layout = { + id: '3', + name: 'Test Layout', + owner_id: 'user3', + tabs: [ + { + name: 'Tab1', + objects: [ + { id: 'obj1', name: 'qc/MCH/QO/Check1' }, + { id: 'obj2', name: 'daq/System/Monitor' }, + ], + }, + ], + }; + const fields = ['id', 'name', 'labels']; + const result = trimLayoutPerRequiredFields(layout, fields); + + const resultKeys = Object.keys(result); + strictEqual(resultKeys.length, 2, 'Should have exactly 3 fields'); + ok(!result.labels, 'Should have labels field'); + strictEqual(result.id, layout.id, 'Should have correct id value'); + strictEqual(result.name, layout.name, 'Should have correct name value'); + }); + + test('should handle fields array with non-existent field names', () => { + const layout = { + id: '4', + name: 'Test Layout', + tabs: [ + { + name: 'Tab1', + objects: [{ id: 'obj1', name: 'qc/MCH/QO/Check1' }], + }, + ], + }; + const fields = ['id', 'name', 'nonExistentField', 'anotherNonExistent']; + + const result = trimLayoutPerRequiredFields(layout, fields); + + const resultKeys = Object.keys(result); + strictEqual(resultKeys.length, 2, 'Should only include existing fields'); + ok(result.id, 'Should have id field'); + ok(result.name, 'Should have name field'); + ok(!result.nonExistentField, 'Should not have nonExistentField'); + ok(!result.anotherNonExistent, 'Should not have anotherNonExistent'); + }); + + test('should handle layout with no tabs', () => { + const layout = { + id: '5', + name: 'No Tabs Layout', + owner_id: 'user5', + }; + const fields = ['id', 'name']; + + const result = trimLayoutPerRequiredFields(layout, fields); + + strictEqual(result.id, layout.id, 'Should have id field'); + strictEqual(result.name, layout.name, 'Should have name field'); + }); + + test('should handle layout with tabs but no objects', () => { + const layout = { + id: '6', + name: 'Empty Tabs Layout', + owner_id: 'user6', + tabs: [{ name: 'Tab1' }], + }; + const fields = ['id']; + + const result = trimLayoutPerRequiredFields(layout, fields); + + strictEqual(result.id, layout.id, 'Should have id field'); + ok(!result.name, 'Should not have name field'); + ok(!result.tabs, 'Should not have tabs field'); + }); + + test('should not mutate the original layout object', () => { + const layout = { + id: '7', + name: 'Original Layout', + owner_id: 'user7', + tabs: [ + { + name: 'Tab1', + objects: [{ id: 'obj1', name: 'qc/MCH/QO/Check1' }], + }, + ], + }; + const fields = ['id', 'name']; + const originalTabsReference = layout.tabs; + + const result = trimLayoutPerRequiredFields(layout, fields); + + strictEqual(layout.tabs, originalTabsReference, 'Original layout tabs should not be mutated'); + strictEqual(layout.owner_id, 'user7', 'Original layout owner_id should be unchanged'); + strictEqual(Object.keys(result).length, 2, 'Result should have only requested fields'); + }); + + test('should correctly trim complex layout with multiple tabs and objects', () => { + const layout = { + id: '8', + name: 'Complex Layout', + owner_id: 'user8', + createdAt: '2026-01-17', + updatedAt: '2026-01-17', + tabs: [ + { + name: 'Tab1', + objects: [ + { id: 'obj1', name: 'qc/MCH/QO/Check1' }, + { id: 'obj2', name: 'qc/TPC/QO/Check2' }, + ], + }, + { + name: 'Tab2', + objects: [{ id: 'obj3', name: 'daq/System/Monitor' }], + }, + ], + }; + const fields = ['id', 'name', 'owner_id']; + + const result = trimLayoutPerRequiredFields(layout, fields); + + strictEqual(Object.keys(result).length, 3, 'Should have exactly 3 fields'); + strictEqual(result.id, layout.id); + strictEqual(result.name, layout.name); + strictEqual(result.owner_id, layout.owner_id); + ok(!result.tabs, 'Should not include tabs field'); + ok(!result.createdAt, 'Should not include createdAt field'); + ok(!result.updatedAt, 'Should not include updatedAt field'); + }); + + test('should handle single field selection', () => { + const layout = { + id: '9', + name: 'Single Field Layout', + owner_id: 'user9', + tabs: [ + { + name: 'Tab1', + objects: [{ id: 'obj1', name: 'qc/MCH/QO/Check1' }], + }, + ], + }; + const fields = ['name']; + + const result = trimLayoutPerRequiredFields(layout, fields); + + strictEqual(Object.keys(result).length, 1, 'Should have exactly 1 field'); + strictEqual(result.name, layout.name, 'Should have correct name value'); + ok(!result.id, 'Should not have id field'); + ok(!result.owner_id, 'Should not have owner_id field'); + ok(!result.labels, 'Should not have labels field'); + ok(!result.tabs, 'Should not have tabs field'); + }); + }); +}; diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 87c60915d..393d00184 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -43,6 +43,8 @@ import { aboutPageTests } from './public/pages/about-page.test.js'; */ import { errorHandlerTestSuite } from './lib/utils/errorHandler.test.js'; import { httpRequestsTestSuite } from './lib/utils/httpRequests.test.js'; +import { addLabelsToLayoutTestSuite } from './lib/utils/layout/addLabelsToLayout.test.js'; +import { trimLayoutPerRequiredFieldsTestSuite } from './lib/utils/layout/trimLayoutPerRequiredFields.test.js'; /** * Controllers @@ -65,6 +67,7 @@ import { bookkeepingServiceTestSuite } from './lib/services/BookkeepingService.t */ import { baseRepositoryTestSuite } from './lib/database/repositories/BaseRepository.test.js'; import { layoutRepositoryTestSuite } from './lib/database/repositories/LayoutRepository.test.js'; +import { layoutRepositoryTest } from './lib/repositories/LayoutRepository.test.js'; import { userRepositoryTestSuite } from './lib/database/repositories/UserRepository.test.js'; import { chartRepositoryTestSuite } from './lib/database/repositories/ChartRepository.test.js'; import { chartOptionsRepositoryTestSuite } from './lib/database/repositories/ChartOptionsRepository.test.js'; @@ -232,6 +235,8 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn suite('Lib - Test Suite', async () => { suite('Utility "errorHandler" methods test suite', async () => await errorHandlerTestSuite()); suite('Utility "httpRequests" methods test suite', async () => await httpRequestsTestSuite()); + suite('Layout Utils - calculateLabelsForLayout test suite', () => addLabelsToLayoutTestSuite()); + suite('Layout Utils - trimLayoutPerRequiredFields test suite', () => trimLayoutPerRequiredFieldsTestSuite()); }); suite('Common Library - Test Suite', () => { @@ -241,7 +246,8 @@ suite('All Tests - QCG', { timeout: FRONT_END_TIMEOUT + BACK_END_TIMEOUT }, asyn suite('Repositories - Test Suite', async () => { suite('Base Repository - Test Suite', async () => await baseRepositoryTestSuite()); - suite('Layout Repository - Test Suite', async () => await layoutRepositoryTestSuite()); + suite('Layout Repository - Database Test Suite', async () => await layoutRepositoryTestSuite()); + suite('Layout Repository - Test Suite', async () => await layoutRepositoryTest()); suite('User Repository - Test Suite', async () => await userRepositoryTestSuite()); suite('Chart Repository - Test Suite', async () => await chartRepositoryTestSuite()); suite('Chart Options Repository - Test Suite', async () => await chartOptionsRepositoryTestSuite());