From 6cdd92212ab7fa5cc8653934beb7740dfe547e1b Mon Sep 17 00:00:00 2001 From: Deaponn Date: Tue, 2 Dec 2025 14:04:39 +0100 Subject: [PATCH 01/11] feature: add Restrictions for arrays --- .../lib/adapters/QCConfigurationAdapter.js | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/Control/lib/adapters/QCConfigurationAdapter.js b/Control/lib/adapters/QCConfigurationAdapter.js index a43fcec85..0996c11f2 100644 --- a/Control/lib/adapters/QCConfigurationAdapter.js +++ b/Control/lib/adapters/QCConfigurationAdapter.js @@ -18,6 +18,17 @@ const { LogManager } = require('@aliceo2/web-ui'); * which is a set of restrictions based on the values contained in this configuration */ class QCConfigurationAdapter { + /** + * Returns the Restrictions in case the array they are calculated for is empty + * and we can not derive the type of values held in there + * Restrictions for an array always has the following structure + * At index 0 there are Restrictions for every value currently in the array + * At index 1 there are Restrictions in case the user adds a new item to the array + */ + static get emptyArrayRestrictions() { + return [[], {}]; + } + /** * Derive type of value for every key-val pair * of given configuration object @@ -44,7 +55,8 @@ class QCConfigurationAdapter { // then combine it with Types of value[1], value[2] and so on to get the overall Type of values held in the array if (Array.isArray(value)) { return 'array'; } if (value instanceof Object) { return QCConfigurationAdapter.computeRestrictions(value); } - if (value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false') { + if (typeof value === 'number') { return 'number'; } + if (typeof value === 'boolean' || value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false') { return 'boolean'; } if (!Number.isNaN(Number(value))) { return 'number'; } @@ -53,6 +65,66 @@ class QCConfigurationAdapter { logger.warnMessage(`Unknown value encountered while calculating restrictions from a configuration: ${value}`); return 'unknown'; }; + + /** + * Derive the type of values in an array + * When deriving restrictions for an array, we gather two pieces of data: + * - itemsRestrictions: a list of types for every object held inside + * - newItemRestrictions: a blueprint for a new item in the array + * @param {Array} array for which we calculate the Restrictions + * @returns {ArrayRestrictions} value describing the nature of objects held in an array + */ + static deriveArrayType = (array) => { + if (array.length === 0) { + return QCConfigurationAdapter.emptyArrayRestrictions; + } + + let maximumIntersection = QCConfigurationAdapter.deriveValueType(array[0]); + const itemsRestrictions = [maximumIntersection]; + for (let i = 1; i < array.length; i++) { + const currentItemRestrictions = QCConfigurationAdapter.deriveValueType(array[i]); + itemsRestrictions.push(currentItemRestrictions); + maximumIntersection = QCConfigurationAdapter.getRestrictionIntersection( + maximumIntersection, + currentItemRestrictions + ); + } + + return [itemsRestrictions, maximumIntersection]; + } + + /** + * Function which finds maximum intersection for two different Restrictions + * @param {Restrictions} first + * @param {Restrictions} second + */ + static getRestrictionIntersection = (first, second) => { + if (typeof first === 'string' && typeof second === 'string') { + return first === second ? first : null; // primitive types differ + } + if (typeof first === 'string' || typeof second === 'string') { + // `first` is primitive or `second` is primitive but not both + return null; + } + + if (Array.isArray(first) && Array.isArray(second)) { + return QCConfigurationAdapter.emptyArrayRestrictions; + } + if (Array.isArray(first) || Array.isArray(second)) { + // `first` is an array or `second` is an array but not both + return null; + } + + // from now on, `first` and `second` can only describe objects + const restrictions = {}; + Object.entries(first).forEach(([key, val]) => { + if (!(key in second)) { return; } + const maximumIntersection = QCConfigurationAdapter.getRestrictionIntersection(val, second[key]); + if (maximumIntersection === null) { return; } + restrictions[key] = maximumIntersection; + }); + return restrictions; + } } module.exports = QCConfigurationAdapter; From d2e96ccf2763645f33e9783e7c0adf691827836c Mon Sep 17 00:00:00 2001 From: Deaponn Date: Thu, 4 Dec 2025 10:15:15 +0100 Subject: [PATCH 02/11] refactor: improve code and docs --- .../lib/adapters/QCConfigurationAdapter.js | 100 +++++++++++------- Control/lib/typedefs/Restrictions.js | 35 ++++-- 2 files changed, 92 insertions(+), 43 deletions(-) diff --git a/Control/lib/adapters/QCConfigurationAdapter.js b/Control/lib/adapters/QCConfigurationAdapter.js index 0996c11f2..9db9a71a5 100644 --- a/Control/lib/adapters/QCConfigurationAdapter.js +++ b/Control/lib/adapters/QCConfigurationAdapter.js @@ -24,20 +24,34 @@ class QCConfigurationAdapter { * Restrictions for an array always has the following structure * At index 0 there are Restrictions for every value currently in the array * At index 1 there are Restrictions in case the user adds a new item to the array + * + * For example, for array like: + * [ + * { name: 'flp1', strength: 10, isActive: true }, + * { name: 'flp2', strength: 100000, isActive: 'inactive' } + * ] + * + * The ArrayRestrictions will be: + * [ + * [ + * { name: 'string', strength: 'number', isActive: 'boolean' }, + * { name: 'string', strength: 'number', isActive: 'string' } + * ], + * { name: 'string', strength: 'number' } + * ] */ static get emptyArrayRestrictions() { return [[], {}]; } /** - * Derive type of value for every key-val pair - * of given configuration object + * Derive type of value for every key-val pair of given configuration object * @param {Object} configuration object we want to get restrictions of * @returns {Restrictions} derived restrictions for a given configuration */ static computeRestrictions = (value) => { const restrictions = {}; - if (typeof value !== 'object' || Array.isArray(value) || value === null) { + if (typeof value !== 'object' || value === null) { return restrictions; } Object.entries(value).forEach(([key, val]) => (restrictions[key] = QCConfigurationAdapter.deriveValueType(val))); @@ -45,21 +59,20 @@ class QCConfigurationAdapter { }; /** - * Derive the type of value and return it as a string - * possible types are 'string', 'boolean', 'number', 'array<`${NestedRestrictions}`>', `${NestedRestrictions}` - * @param {string | Array | Object} value that we want to get the Restrictions of - * @returns {string | Restrictions} derived type from the given value, could be a string, or further nested Restrictions + * Derive the type of a value and return it as a string + * possible types are Restrictions or ArrayRestrictions + * @param {string | number | boolean | Array | Object} value that we want to get the Restrictions of + * @returns {Restrictions | ArrayRestrictions} type derived from the given value, + * could be a string, Restrictions object, or ArrayRestrictions object */ static deriveValueType = (value) => { - // TODO OGUI-1803: implement function _combineTypes, so we can derive Type of value[0] and - // then combine it with Types of value[1], value[2] and so on to get the overall Type of values held in the array - if (Array.isArray(value)) { return 'array'; } - if (value instanceof Object) { return QCConfigurationAdapter.computeRestrictions(value); } - if (typeof value === 'number') { return 'number'; } - if (typeof value === 'boolean' || value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false') { + if (Array.isArray(value)) { return QCConfigurationAdapter.deriveArrayType(value); } + if (typeof value === 'object' && value !== null) { return QCConfigurationAdapter.computeRestrictions(value); } + if (typeof value === 'boolean' || typeof value === 'string' && + (value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false')) { return 'boolean'; } - if (!Number.isNaN(Number(value))) { return 'number'; } + if (typeof value === 'number' || (!isNaN(Number(value)) && value.trim() !== '')) { return 'number'; } if (typeof value === 'string') { return 'string'; } const logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'cog'}/qc-conf-adapter`); logger.warnMessage(`Unknown value encountered while calculating restrictions from a configuration: ${value}`); @@ -90,40 +103,55 @@ class QCConfigurationAdapter { ); } + if (Array.isArray(maximumIntersection)) { + // we only want to save the blueprint of a nested array as a blueprint + maximumIntersection = [[], maximumIntersection[1]]; + } + return [itemsRestrictions, maximumIntersection]; } /** * Function which finds maximum intersection for two different Restrictions - * @param {Restrictions} first - * @param {Restrictions} second + * @param {Restrictions | ArrayRestrictions} first + * @param {Restrictions | ArrayRestrictions} second */ static getRestrictionIntersection = (first, second) => { - if (typeof first === 'string' && typeof second === 'string') { - return first === second ? first : null; // primitive types differ - } - if (typeof first === 'string' || typeof second === 'string') { - // `first` is primitive or `second` is primitive but not both - return null; + if (QCConfigurationAdapter.bothArePrimitive(first, second)) { + // the intersection returns the value type, or null if types are different + return first === second ? first : null; } - if (Array.isArray(first) && Array.isArray(second)) { - return QCConfigurationAdapter.emptyArrayRestrictions; + if (QCConfigurationAdapter.bothAreArrays(first, second)) { + // intersection of two ArrayRestrictions objects is an empty array + // with blueprint calculated by intersecting the Restrictions + return [[], QCConfigurationAdapter.getRestrictionIntersection(first[1], second[1])]; } - if (Array.isArray(first) || Array.isArray(second)) { - // `first` is an array or `second` is an array but not both - return null; + + if (QCConfigurationAdapter.bothAreObjects(first, second)) { + const restrictions = {}; + Object.entries(first).forEach(([key, val]) => { + if (!(key in second)) { return; } + const maximumIntersection = QCConfigurationAdapter.getRestrictionIntersection(val, second[key]); + if (maximumIntersection === null) { return; } + restrictions[key] = maximumIntersection; + }); + return restrictions; } - // from now on, `first` and `second` can only describe objects - const restrictions = {}; - Object.entries(first).forEach(([key, val]) => { - if (!(key in second)) { return; } - const maximumIntersection = QCConfigurationAdapter.getRestrictionIntersection(val, second[key]); - if (maximumIntersection === null) { return; } - restrictions[key] = maximumIntersection; - }); - return restrictions; + return null; // first and second differ + } + + static bothArePrimitive = (first, second) => { + return typeof first === 'string' && typeof second === 'string'; + } + + static bothAreArrays = (first, second) => { + return Array.isArray(first) && Array.isArray(second); + } + + static bothAreObjects = (first, second) => { + return typeof first === 'object' && first !== null && typeof second === 'object' && second !== null; } } diff --git a/Control/lib/typedefs/Restrictions.js b/Control/lib/typedefs/Restrictions.js index 272733c04..8343074aa 100644 --- a/Control/lib/typedefs/Restrictions.js +++ b/Control/lib/typedefs/Restrictions.js @@ -12,11 +12,10 @@ */ /** - * @typedef {Object.} Restrictions - * + * @typedef {RestrictionsEntry | Object.} Restrictions * Object which is a map of types. * Keys are taken from existing configuration. - * Values are describing what is expected type of value held there. + * Values are describing what is expected type of value held under that key. * * For example for the given configuration: * ``` @@ -36,7 +35,8 @@ { name: 'CTP Config', path: 'CTP/Config/Config', - active: 'true' + active: 'true', + count: '5' }, { name: 'CTP Scalers', @@ -62,6 +62,19 @@ * } * }, * dataSources: [ + * [ + * { + * name: 'string', + * path: 'string', + * active: 'boolean', + * count: 'number' + * }, + * { + * name: 'string', + * path: 'string', + * active: 'boolean' + * } + * ], * { * name: 'string', * path: 'string', @@ -74,8 +87,16 @@ /** * A value in a `Restrictions` object can be: - * - a string literal 'string', 'boolean', 'number' or 'array' + * - a string literal describing a primitive: 'string', 'boolean' or 'number' * - nested Restrictions - * - * @typedef { 'string' | 'boolean' | 'number' | Restrictions | Restrictions[] } RestrictionsEntry + * - ArrayRestrictions object + * @typedef { 'string' | 'boolean' | 'number' | Restrictions | ArrayRestrictions } RestrictionsEntry + */ + +/** + * ArrayRestrictions is a data structure which holds the info about objects held in an array. + * It always is of length two: + * - at index 0 there is a nested array which describes Restrictions of each object held in input array + * - at index 1 there is a 'blueprint' Restrictions in case user decides to add next item to the source array + * @typedef { [Array, Restrictions] } ArrayRestrictions */ From b59f0110a3022e70f9f33f731e7b0dd5d112f940 Mon Sep 17 00:00:00 2001 From: Deaponn Date: Thu, 4 Dec 2025 13:07:21 +0100 Subject: [PATCH 03/11] feat: implement rendering of Arrays --- .../webapp/app/components/form/Form.tsx | 19 ++++-- .../webapp/app/components/form/Widget.tsx | 63 +++++++++++++++++-- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/Configuration/webapp/app/components/form/Form.tsx b/Configuration/webapp/app/components/form/Form.tsx index 0cf66ec37..27347ddc9 100644 --- a/Configuration/webapp/app/components/form/Form.tsx +++ b/Configuration/webapp/app/components/form/Form.tsx @@ -22,10 +22,21 @@ import { Typography } from '@mui/material'; export type FormItem = { [key: string]: string | object | FormItem }; +type PrimitiveRestrictions = 'string' | 'number' | 'boolean'; + +export type ArrayRestrictions = [ + Array, + Restrictions, +]; + +export type WidgetRestrictions = PrimitiveRestrictions | ArrayRestrictions; + export type FormRestrictions = { - [key: string]: 'string' | 'number' | 'boolean' | 'array' | FormRestrictions; + [key: string]: Restrictions; }; +export type Restrictions = PrimitiveRestrictions | ArrayRestrictions | FormRestrictions; + interface FormProps extends PropsWithChildren { sectionTitle: string; items: FormItem; @@ -36,13 +47,13 @@ interface FormProps extends PropsWithChildren { * Function which returns false if the given object * which describes restrictions is the leaf (string, number, bool, array) * or returns true if the given object describes restrictions recursively - * @param {'string' | 'number' | 'boolean' | 'array' | FormRestrictions} obj + * @param {Restrictions} obj * the object which describes restrictions * @returns {boolean} value which indicates if the restrictions are recursive * or if this is the leaf of the FormRestrictions tree */ -function isFormRestrictions(obj: FormRestrictions[string]): obj is FormRestrictions { - return obj instanceof Object && !(obj instanceof Array); +export function isFormRestrictions(obj: FormRestrictions[string]): obj is FormRestrictions { + return obj instanceof Object && obj !== null && !(Array.isArray(obj)); } export const Form: FC = ({ sectionTitle, items, itemsRestrictions }) => { diff --git a/Configuration/webapp/app/components/form/Widget.tsx b/Configuration/webapp/app/components/form/Widget.tsx index 4b7fcf700..7d5b33744 100644 --- a/Configuration/webapp/app/components/form/Widget.tsx +++ b/Configuration/webapp/app/components/form/Widget.tsx @@ -12,17 +12,72 @@ * or submit itself to any jurisdiction. */ -import { type FC, type PropsWithChildren, type ReactElement } from 'react'; +import { useState, type FC, type PropsWithChildren, type ReactElement } from 'react'; import FormControlLabel from '@mui/material/FormControlLabel'; import TextField from '@mui/material/TextField'; import Switch from '@mui/material/Switch'; +import { + Form, + isFormRestrictions, + type ArrayRestrictions, + type FormItem, + type WidgetRestrictions, +} from './Form'; +import { Accordion, AccordionDetails, Stack, Typography } from '@mui/material'; +import { AccordionHeader } from './AccordionHeader'; interface WidgetProps extends PropsWithChildren { title: string; - type: 'string' | 'number' | 'boolean' | 'array'; + type: WidgetRestrictions; value: unknown; } +type ArrayWidgetProps = Omit & { type: ArrayRestrictions }; + +const ArrayWidget = ({ title, type, value }: ArrayWidgetProps): ReactElement => { + const [viewForm, setViewForm] = useState(true); + const items = value as Array; + const [itemsRestrictions] = type; + + return ( + + setViewForm((v) => !v)} + /> + + {viewForm ? ( + + {items.map((item, idx) => { + if (isFormRestrictions(itemsRestrictions[idx])) { + return ( +
+ ); + } + return ( + + ); + })} + + ) : ( + {JSON.stringify(items, null, 2)} + )} + + + ); +}; + export const Widget: FC = ({ title, type, value }): ReactElement => { switch (type) { case 'string': @@ -33,7 +88,7 @@ export const Widget: FC = ({ title, type, value }): ReactElement => return ( } label={title} /> ); - case 'array': - return <>array not implemented; // TODO OGUI-1803: add implementation after the decision is made + default: + return ; } }; From 657364dcec26a20dc03b46f2eb0cddc3b45db804 Mon Sep 17 00:00:00 2001 From: Deaponn Date: Thu, 4 Dec 2025 22:08:09 +0100 Subject: [PATCH 04/11] test: add tests for the new functions and update docs --- .../webapp/app/components/form/Widget.tsx | 25 ++- .../lib/adapters/QCConfigurationAdapter.js | 71 +++----- Control/lib/typedefs/Restrictions.js | 4 +- ...mocha-consul-configuration.adapter.test.js | 166 ++++++++++++++---- 4 files changed, 177 insertions(+), 89 deletions(-) diff --git a/Configuration/webapp/app/components/form/Widget.tsx b/Configuration/webapp/app/components/form/Widget.tsx index 7d5b33744..bea689ab0 100644 --- a/Configuration/webapp/app/components/form/Widget.tsx +++ b/Configuration/webapp/app/components/form/Widget.tsx @@ -49,26 +49,23 @@ const ArrayWidget = ({ title, type, value }: ArrayWidgetProps): ReactElement => {viewForm ? ( - {items.map((item, idx) => { - if (isFormRestrictions(itemsRestrictions[idx])) { - return ( - - ); - } - return ( + {items.map((item, idx) => + isFormRestrictions(itemsRestrictions[idx]) ? ( + + ) : ( - ); - })} + ), + )} ) : ( {JSON.stringify(items, null, 2)} diff --git a/Control/lib/adapters/QCConfigurationAdapter.js b/Control/lib/adapters/QCConfigurationAdapter.js index 9db9a71a5..fb5182a5a 100644 --- a/Control/lib/adapters/QCConfigurationAdapter.js +++ b/Control/lib/adapters/QCConfigurationAdapter.js @@ -23,14 +23,14 @@ class QCConfigurationAdapter { * and we can not derive the type of values held in there * Restrictions for an array always has the following structure * At index 0 there are Restrictions for every value currently in the array - * At index 1 there are Restrictions in case the user adds a new item to the array - * + * At index 1 there are Restrictions in case the user adds a new object to the array + * * For example, for array like: * [ * { name: 'flp1', strength: 10, isActive: true }, * { name: 'flp2', strength: 100000, isActive: 'inactive' } * ] - * + * * The ArrayRestrictions will be: * [ * [ @@ -51,7 +51,7 @@ class QCConfigurationAdapter { */ static computeRestrictions = (value) => { const restrictions = {}; - if (typeof value !== 'object' || value === null) { + if (typeof value !== 'object' || Array.isArray(value) || value === null) { return restrictions; } Object.entries(value).forEach(([key, val]) => (restrictions[key] = QCConfigurationAdapter.deriveValueType(val))); @@ -66,7 +66,7 @@ class QCConfigurationAdapter { * could be a string, Restrictions object, or ArrayRestrictions object */ static deriveValueType = (value) => { - if (Array.isArray(value)) { return QCConfigurationAdapter.deriveArrayType(value); } + if (Array.isArray(value)) { return QCConfigurationAdapter.computeArrayRestrictions(value); } if (typeof value === 'object' && value !== null) { return QCConfigurationAdapter.computeRestrictions(value); } if (typeof value === 'boolean' || typeof value === 'string' && (value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false')) { @@ -87,7 +87,7 @@ class QCConfigurationAdapter { * @param {Array} array for which we calculate the Restrictions * @returns {ArrayRestrictions} value describing the nature of objects held in an array */ - static deriveArrayType = (array) => { + static computeArrayRestrictions = (array) => { if (array.length === 0) { return QCConfigurationAdapter.emptyArrayRestrictions; } @@ -109,50 +109,37 @@ class QCConfigurationAdapter { } return [itemsRestrictions, maximumIntersection]; - } + }; /** * Function which finds maximum intersection for two different Restrictions - * @param {Restrictions | ArrayRestrictions} first - * @param {Restrictions | ArrayRestrictions} second + * only if they describe objects and it returns null otherwise + * @param {Restrictions} first + * @param {Restrictions} second */ static getRestrictionIntersection = (first, second) => { - if (QCConfigurationAdapter.bothArePrimitive(first, second)) { - // the intersection returns the value type, or null if types are different - return first === second ? first : null; - } - - if (QCConfigurationAdapter.bothAreArrays(first, second)) { - // intersection of two ArrayRestrictions objects is an empty array - // with blueprint calculated by intersecting the Restrictions - return [[], QCConfigurationAdapter.getRestrictionIntersection(first[1], second[1])]; - } - - if (QCConfigurationAdapter.bothAreObjects(first, second)) { - const restrictions = {}; - Object.entries(first).forEach(([key, val]) => { - if (!(key in second)) { return; } - const maximumIntersection = QCConfigurationAdapter.getRestrictionIntersection(val, second[key]); - if (maximumIntersection === null) { return; } - restrictions[key] = maximumIntersection; - }); - return restrictions; - } - - return null; // first and second differ - } - - static bothArePrimitive = (first, second) => { - return typeof first === 'string' && typeof second === 'string'; - } + if (!QCConfigurationAdapter.bothAreObjects(first, second)) { return null; } - static bothAreArrays = (first, second) => { - return Array.isArray(first) && Array.isArray(second); - } + const restrictions = {}; + Object.entries(first).forEach(([key, val]) => { + if (!(key in second)) { return; } + const maximumIntersection = QCConfigurationAdapter.getRestrictionIntersection(val, second[key]); + if (maximumIntersection === null) { return; } + restrictions[key] = maximumIntersection; + }); + return restrictions; + }; static bothAreObjects = (first, second) => { - return typeof first === 'object' && first !== null && typeof second === 'object' && second !== null; - } + return ( + typeof first === 'object' && + typeof second === 'object' && + first !== null && + second !== null && + !Array.isArray(first) && + !Array.isArray(second) + ); + }; } module.exports = QCConfigurationAdapter; diff --git a/Control/lib/typedefs/Restrictions.js b/Control/lib/typedefs/Restrictions.js index 8343074aa..1cc10cda2 100644 --- a/Control/lib/typedefs/Restrictions.js +++ b/Control/lib/typedefs/Restrictions.js @@ -97,6 +97,8 @@ * ArrayRestrictions is a data structure which holds the info about objects held in an array. * It always is of length two: * - at index 0 there is a nested array which describes Restrictions of each object held in input array - * - at index 1 there is a 'blueprint' Restrictions in case user decides to add next item to the source array + * - at index 1 there is a 'blueprint' Restrictions in case user decides to create a new object or array + * If user creates an object on the frontend, it is pre-populated according to the blueprint + * If user creates an array on the frontend, its blueprint is populated with the current blueprint * @typedef { [Array, Restrictions] } ArrayRestrictions */ diff --git a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js index 3cfa1cba8..3457eda4e 100644 --- a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js +++ b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js @@ -13,43 +13,145 @@ */ const assert = require('assert'); +const sinon = require('sinon'); -const { - computeRestrictions, -} = require('../../../lib/adapters/QCConfigurationAdapter.js'); +const QCConfigurationAdapter = require('../../../lib/adapters/QCConfigurationAdapter.js'); +const { computeRestrictions, computeArrayRestrictions, deriveValueType } = + QCConfigurationAdapter; describe(`'QCConfigurationAdapter' test suite`, () => { - it('should work for minimal input', () => { - const configuration = {}; - const restrictions = {}; - - assert.deepStrictEqual(computeRestrictions(configuration), restrictions); + describe('test computeRestrictions function', () => { + it('should work for minimal input', () => { + const configuration = {}; + const restrictions = {}; + + assert.deepStrictEqual(computeRestrictions(configuration), restrictions); + }); + + it('should return restrictions for a big configuration', () => { + const configuration = { + key1: 'value1', + key2: '10', + key3: [{ key1: 'string' }, { key1: 'true' }], + key4: 'false', + key5: { key1: 'nested', key2: 'false' }, + }; + const restrictions = { + key1: 'string', + key2: 'number', + key3: [[{ key1: 'string' }, { key1: 'boolean' }], {}], + key4: 'boolean', + key5: { key1: 'string', key2: 'boolean' }, + }; + + assert.deepStrictEqual(computeRestrictions(configuration), restrictions); + }); + + it('should not throw for bad input', () => { + assert.deepStrictEqual(computeRestrictions(), {}); + assert.deepStrictEqual(computeRestrictions(undefined), {}); + assert.deepStrictEqual(computeRestrictions(null), {}); + assert.deepStrictEqual(computeRestrictions(0), {}); + assert.deepStrictEqual(computeRestrictions(''), {}); + assert.deepStrictEqual( + computeRestrictions([{ wrong: 'array input' }]), + {} + ); + }); }); - it('should return restrictions for a big configuration', () => { - const configuration = { - key1: 'value1', - key2: '10', - key3: [{ key1: 'string' }, { key1: 'true' }], - key4: 'false', - key5: { key1: 'nested', key2: 'false' }, - }; - const restrictions = { - key1: 'string', - key2: 'number', - key3: 'array', - key4: 'boolean', - key5: { key1: 'string', key2: 'boolean' }, - }; - - assert.deepStrictEqual(computeRestrictions(configuration), restrictions); + describe('test deriveValueType function', () => { + it('should handle string arguments properly', () => { + assert.equal(deriveValueType('test'), 'string'); + assert.equal(deriveValueType(''), 'string'); + assert.equal(deriveValueType(' '), 'string'); + assert.equal(deriveValueType('{}'), 'string'); + assert.equal(deriveValueType('{ thisIs: stringStill }'), 'string'); + assert.equal(deriveValueType('[]'), 'string'); + assert.equal(deriveValueType('[test, 10]'), 'string'); + }); + + it('should handle boolean arguments properly', () => { + assert.equal(deriveValueType(true), 'boolean'); + assert.equal(deriveValueType(false), 'boolean'); + assert.equal(deriveValueType('true'), 'boolean'); + assert.equal(deriveValueType('false'), 'boolean'); + assert.equal(deriveValueType('True'), 'boolean'); + assert.equal(deriveValueType('fALse'), 'boolean'); + }); + + it('should handle numeric arguments properly', () => { + assert.equal(deriveValueType(0), 'number'); + assert.equal(deriveValueType(1), 'number'); + assert.equal(deriveValueType(-3), 'number'); + assert.equal(deriveValueType('15'), 'number'); + assert.equal(deriveValueType('1e-3'), 'number'); + assert.equal(deriveValueType('0.3'), 'number'); + + it('should handle object arguments properly', () => { + const computeRestrictionsSpy = sinon.spy( + QCConfigurationAdapter, + 'computeRestrictions' + ); + // for object arguments, the computeRestrictions function should be called + deriveValueType(0); + assert.equal(computeRestrictionsSpy.calledOnce, false); + deriveValueType({}); + assert.equal(computeRestrictionsSpy.calledOnce, true); + QCConfigurationAdapter.computeRestrictions.restore(); + }); + }); + + it('should handle array arguments properly', () => { + const computeArrayRestrictionsSpy = sinon.spy( + QCConfigurationAdapter, + 'computeArrayRestrictions' + ); + // for array arguments, the computeArrayRestrictions function should be called + deriveValueType(0); + assert.equal(computeArrayRestrictionsSpy.calledOnce, false); + deriveValueType([]); + assert.equal(computeArrayRestrictionsSpy.calledOnce, true); + QCConfigurationAdapter.computeArrayRestrictions.restore(); + }); }); + describe('test computeArrayRestrictions function', () => { + it('should return base case for empty array', () => { + assert.deepStrictEqual(computeArrayRestrictions([]), [[], {}]); + }); + + it('should return type of every object in an array and a proper intersection', () => { + const inputArray = [ + { type: 'flp', active: true, id: 0 }, + { type: 'ctp', active: 'inactive', id: '1' }, + { type: 'list', id: '-1e+3', list: ['text', 99, true] }, + ]; + + const expectedRestrictions = [ + [ + { type: 'string', active: 'boolean', id: 'number' }, + { type: 'string', active: 'string', id: 'number' }, + { type: 'string', id: 'number', list: [['string', 'number', 'boolean'], {}] } + ], + { type: 'string', id: 'number' }, + ]; - it('should not throw for bad input', () => { - assert.deepStrictEqual(computeRestrictions(), {}); - assert.deepStrictEqual(computeRestrictions(undefined), {}); - assert.deepStrictEqual(computeRestrictions(null), {}); - assert.deepStrictEqual(computeRestrictions(0), {}); - assert.deepStrictEqual(computeRestrictions(''), {}); - }) + assert.deepStrictEqual(computeArrayRestrictions(inputArray), expectedRestrictions); + }); + + it('should ignore primitives when finding an intersection of types included in the array', () => { + const expectedRestrictions = [[{ type: 'string'}, 'string'], { type: 'string'}]; + assert.deepStrictEqual(computeArrayRestrictions([0, false, 'text']), [['number', 'boolean', 'string'], {}]); + assert.deepStrictEqual(computeArrayRestrictions([{ type: 'flp'}, 'text']), expectedRestrictions); + }); + + it('should intersect blueprints of directly nested arrays', () => { + const firstArray = [{ name: 'flp', active: 'yes' }]; + const firstArrayRestrictions = [[{ name: 'string', active: 'string' }], { name: 'string', active: 'string'}]; + const secondArray = [{ name: 'also-flp', active: true }]; + const secondArrayRestrictions = [[{ name: 'string', active: 'boolean' }], { name: 'string', active: 'boolean'}]; + const expectedRestrictions = [[firstArrayRestrictions, secondArrayRestrictions], { name: 'string' }]; + assert.deepStrictEqual(computeArrayRestrictions([firstArray, secondArray]), expectedRestrictions); + }); + }); }); From 39b87d4076e0e53f6f1a10ed86765d7caa2fa3f5 Mon Sep 17 00:00:00 2001 From: Deaponn Date: Fri, 5 Dec 2025 12:24:53 +0100 Subject: [PATCH 05/11] test: add more tests --- .../lib/adapters/QCConfigurationAdapter.js | 135 +++++++--- Control/lib/typedefs/Restrictions.js | 29 ++- ...mocha-consul-configuration.adapter.test.js | 238 ++++++++++++++++-- 3 files changed, 339 insertions(+), 63 deletions(-) diff --git a/Control/lib/adapters/QCConfigurationAdapter.js b/Control/lib/adapters/QCConfigurationAdapter.js index fb5182a5a..63b0381f0 100644 --- a/Control/lib/adapters/QCConfigurationAdapter.js +++ b/Control/lib/adapters/QCConfigurationAdapter.js @@ -24,11 +24,12 @@ class QCConfigurationAdapter { * Restrictions for an array always has the following structure * At index 0 there are Restrictions for every value currently in the array * At index 1 there are Restrictions in case the user adds a new object to the array + * At index 2 there are Restrictions in case user creates new, directly nested array * * For example, for array like: * [ * { name: 'flp1', strength: 10, isActive: true }, - * { name: 'flp2', strength: 100000, isActive: 'inactive' } + * { name: 'flp2', strength: 100000, isActive: 'inactive' }, * ] * * The ArrayRestrictions will be: @@ -37,11 +38,15 @@ class QCConfigurationAdapter { * { name: 'string', strength: 'number', isActive: 'boolean' }, * { name: 'string', strength: 'number', isActive: 'string' } * ], - * { name: 'string', strength: 'number' } + * { name: 'string', strength: 'number' }, + * null // because input array does not contain nested arrays * ] + * + * To see an example of calculating the Restrictions at index 2, see tests + * because writing it in here would bloat the example */ static get emptyArrayRestrictions() { - return [[], {}]; + return [[], null, null]; } /** @@ -81,9 +86,10 @@ class QCConfigurationAdapter { /** * Derive the type of values in an array - * When deriving restrictions for an array, we gather two pieces of data: + * When deriving restrictions for an array, we gather three pieces of data: * - itemsRestrictions: a list of types for every object held inside - * - newItemRestrictions: a blueprint for a new item in the array + * - newObjectRestrictions: a blueprint for a new item in the array + * - newArrayRestrictions: a blueprints to pass into directly nested newly created array * @param {Array} array for which we calculate the Restrictions * @returns {ArrayRestrictions} value describing the nature of objects held in an array */ @@ -92,23 +98,55 @@ class QCConfigurationAdapter { return QCConfigurationAdapter.emptyArrayRestrictions; } - let maximumIntersection = QCConfigurationAdapter.deriveValueType(array[0]); - const itemsRestrictions = [maximumIntersection]; + const firstItemRestrictions = QCConfigurationAdapter.deriveValueType(array[0]); + let innerObjectBlueprint = QCConfigurationAdapter.isObjectOnly(array[0]) ? + firstItemRestrictions : null; + let [innerArrayObjectBlueprint, innerArrayArrayBlueprint] = Array.isArray(array[0]) ? + [firstItemRestrictions[1], firstItemRestrictions[2]] : null; + const itemsRestrictions = [firstItemRestrictions]; + for (let i = 1; i < array.length; i++) { const currentItemRestrictions = QCConfigurationAdapter.deriveValueType(array[i]); itemsRestrictions.push(currentItemRestrictions); - maximumIntersection = QCConfigurationAdapter.getRestrictionIntersection( - maximumIntersection, - currentItemRestrictions - ); - } - if (Array.isArray(maximumIntersection)) { - // we only want to save the blueprint of a nested array as a blueprint - maximumIntersection = [[], maximumIntersection[1]]; + if (QCConfigurationAdapter.isPrimitive(currentItemRestrictions)) { continue; } // skip primitives + + if (QCConfigurationAdapter.isObjectOnly(currentItemRestrictions)) { + innerObjectBlueprint = innerObjectBlueprint === null ? + currentItemRestrictions : + QCConfigurationAdapter.getRestrictionsIntersection( + innerObjectBlueprint, + currentItemRestrictions + ); + + continue; + } + + // if the current restrictions describe an array, we intersect object restrictions for them + // with current object restrictions (for previously calculated values) + innerArrayObjectBlueprint = + innerArrayObjectBlueprint === null + ? currentItemRestrictions + : QCConfigurationAdapter.getRestrictionsIntersection( + innerArrayObjectBlueprint, + currentItemRestrictions[1] + ); + + // we also intersect array restrictions for them + innerArrayArrayBlueprint = + innerArrayArrayBlueprint === null + ? currentItemRestrictions + : QCConfigurationAdapter.getRestrictionsIntersection( + innerArrayArrayBlueprint, + currentItemRestrictions[2] + ); } - return [itemsRestrictions, maximumIntersection]; + return [ + itemsRestrictions, + innerObjectBlueprint, + [innerArrayObjectBlueprint, innerArrayArrayBlueprint] + ]; }; /** @@ -117,28 +155,57 @@ class QCConfigurationAdapter { * @param {Restrictions} first * @param {Restrictions} second */ - static getRestrictionIntersection = (first, second) => { - if (!QCConfigurationAdapter.bothAreObjects(first, second)) { return null; } + static getRestrictionsIntersection = (first, second) => { + if (QCConfigurationAdapter.bothArePrimitive(first, second)) { + // the intersection returns the value type, or null if types are different + return first === second ? first : null; + } - const restrictions = {}; - Object.entries(first).forEach(([key, val]) => { - if (!(key in second)) { return; } - const maximumIntersection = QCConfigurationAdapter.getRestrictionIntersection(val, second[key]); - if (maximumIntersection === null) { return; } - restrictions[key] = maximumIntersection; - }); - return restrictions; + if (QCConfigurationAdapter.bothAreArrays(first, second)) { + // intersection of two ArrayRestrictions objects + return QCConfigurationAdapter.getArrayRestrictionsIntersection(first, second); + } + + if (QCConfigurationAdapter.bothAreObjects(first, second)) { + const restrictions = {}; + Object.entries(first).forEach(([key, val]) => { + if (!(key in second)) { return; } + const maximumIntersection = QCConfigurationAdapter.getRestrictionsIntersection(val, second[key]); + if (maximumIntersection === null) { return; } + restrictions[key] = maximumIntersection; + }); + return restrictions; + } + + return null; }; + static getArrayRestrictionsIntersection = (first, second) => { + return [ + [], + QCConfigurationAdapter.getRestrictionsIntersection(first[1], second[1]), + QCConfigurationAdapter.getRestrictionsIntersection(first[2], second[2]), + ]; + }; + + static isPrimitive = (value) => typeof value === 'string'; + + static isObjectOnly = (value) => ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); + + static bothArePrimitive = (first, second) => { + return QCConfigurationAdapter.isPrimitive(first) && QCConfigurationAdapter.isPrimitive(second); + } + + static bothAreArrays = (first, second) => { + return Array.isArray(first) && Array.isArray(second); + } + static bothAreObjects = (first, second) => { - return ( - typeof first === 'object' && - typeof second === 'object' && - first !== null && - second !== null && - !Array.isArray(first) && - !Array.isArray(second) - ); + return QCConfigurationAdapter.isObjectOnly(first) && QCConfigurationAdapter.isObjectOnly(second); }; } diff --git a/Control/lib/typedefs/Restrictions.js b/Control/lib/typedefs/Restrictions.js index 1cc10cda2..117bc1993 100644 --- a/Control/lib/typedefs/Restrictions.js +++ b/Control/lib/typedefs/Restrictions.js @@ -15,7 +15,7 @@ * @typedef {RestrictionsEntry | Object.} Restrictions * Object which is a map of types. * Keys are taken from existing configuration. - * Values are describing what is expected type of value held under that key. + * Values are describing what is the expected type of value held under that key. * * For example for the given configuration: * ``` @@ -79,7 +79,8 @@ * name: 'string', * path: 'string', * active: 'boolean' - * } + * }, + * null * ] * } * ``` @@ -95,10 +96,24 @@ /** * ArrayRestrictions is a data structure which holds the info about objects held in an array. - * It always is of length two: + * It always is of length three: * - at index 0 there is a nested array which describes Restrictions of each object held in input array - * - at index 1 there is a 'blueprint' Restrictions in case user decides to create a new object or array - * If user creates an object on the frontend, it is pre-populated according to the blueprint - * If user creates an array on the frontend, its blueprint is populated with the current blueprint - * @typedef { [Array, Restrictions] } ArrayRestrictions + * - at index 1 there is a 'blueprint' Restrictions in case user decides to create a new object, + * or null if source array contains no objects + * - at index 2 there is a 'blueprint' of length 2 which is passed to new arrays created + * this inner blueprint at index 0 has a blueprint for new objects in the newly created array + * and at index 1 it has a blueprint for new arrays created inside of the newly created array + * if the source array does not contain an array, this value is null + * If user creates an object on the frontend, it is pre-populated according to the blueprint at index 1 + * If user creates an array on the frontend, its blueprint is populated with the two blueprints at index 2 + * + * Example ArrayRestrictions object: + * [ + * [ + * Restrictions for item #1, Restrictions for item #2 + * ], + * blueprint for newly created object, + * [object blueprint for new array created in this one, array blueprint for new array created in this one] + * ] + * @typedef { [Array, Restrictions, Restrictions] } ArrayRestrictions */ diff --git a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js index 3457eda4e..dc96f014b 100644 --- a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js +++ b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js @@ -16,8 +16,12 @@ const assert = require('assert'); const sinon = require('sinon'); const QCConfigurationAdapter = require('../../../lib/adapters/QCConfigurationAdapter.js'); -const { computeRestrictions, computeArrayRestrictions, deriveValueType } = - QCConfigurationAdapter; +const { + computeRestrictions, + computeArrayRestrictions, + deriveValueType, + getRestrictionsIntersection, +} = QCConfigurationAdapter; describe(`'QCConfigurationAdapter' test suite`, () => { describe('test computeRestrictions function', () => { @@ -28,6 +32,22 @@ describe(`'QCConfigurationAdapter' test suite`, () => { assert.deepStrictEqual(computeRestrictions(configuration), restrictions); }); + it('should handle inconsistent types for the same key across objects', () => { + const configuration = { + key1: 10, + key2: { value: "text" }, + key3: { value: true }, + }; + + const expected = { + key1: 'number', + key2: { value: 'string' }, + key3: { value: 'boolean' }, + }; + + assert.deepStrictEqual(computeRestrictions(configuration), expected); + }); + it('should return restrictions for a big configuration', () => { const configuration = { key1: 'value1', @@ -36,10 +56,11 @@ describe(`'QCConfigurationAdapter' test suite`, () => { key4: 'false', key5: { key1: 'nested', key2: 'false' }, }; + const restrictions = { key1: 'string', key2: 'number', - key3: [[{ key1: 'string' }, { key1: 'boolean' }], {}], + key3: [[{ key1: 'string' }, { key1: 'boolean' }], {}, null], key4: 'boolean', key5: { key1: 'string', key2: 'boolean' }, }; @@ -87,19 +108,25 @@ describe(`'QCConfigurationAdapter' test suite`, () => { assert.equal(deriveValueType('15'), 'number'); assert.equal(deriveValueType('1e-3'), 'number'); assert.equal(deriveValueType('0.3'), 'number'); + }); - it('should handle object arguments properly', () => { - const computeRestrictionsSpy = sinon.spy( - QCConfigurationAdapter, - 'computeRestrictions' - ); - // for object arguments, the computeRestrictions function should be called - deriveValueType(0); - assert.equal(computeRestrictionsSpy.calledOnce, false); - deriveValueType({}); - assert.equal(computeRestrictionsSpy.calledOnce, true); - QCConfigurationAdapter.computeRestrictions.restore(); - }); + it('should not treat malformed numbers as numeric', () => { + assert.equal(deriveValueType("1.2.3"), "string"); + assert.equal(deriveValueType("ten"), "string"); + assert.equal(deriveValueType("true-ish"), "string"); + }); + + it('should handle object arguments properly', () => { + const computeRestrictionsSpy = sinon.spy( + QCConfigurationAdapter, + 'computeRestrictions' + ); + // for object arguments, the computeRestrictions function should be called + deriveValueType(0); + assert.equal(computeRestrictionsSpy.notCalled, true); + deriveValueType({}); + assert.equal(computeRestrictionsSpy.calledOnce, true); + QCConfigurationAdapter.computeRestrictions.restore(); }); it('should handle array arguments properly', () => { @@ -109,15 +136,41 @@ describe(`'QCConfigurationAdapter' test suite`, () => { ); // for array arguments, the computeArrayRestrictions function should be called deriveValueType(0); - assert.equal(computeArrayRestrictionsSpy.calledOnce, false); + assert.equal(computeArrayRestrictionsSpy.notCalled, true); deriveValueType([]); assert.equal(computeArrayRestrictionsSpy.calledOnce, true); QCConfigurationAdapter.computeArrayRestrictions.restore(); }); }); + describe('test computeArrayRestrictions function', () => { it('should return base case for empty array', () => { - assert.deepStrictEqual(computeArrayRestrictions([]), [[], {}]); + assert.deepStrictEqual(computeArrayRestrictions([]), [[], null, null]); + }); + + it('should generate identical restrictions for arrays with same content in different order', () => { + const item1 = { name: 'flp' }; + const item2 = { name: 'ctp' }; + + assert.deepStrictEqual( + computeArrayRestrictions([item1, item2]), + computeArrayRestrictions([item2, item1]), + ); + }); + + it('should drop keys from blueprint intersection when missing in any object', () => { + const arr = [{ name: "flp", type: "flp-Mx-03" }, { name: "ctp" }]; + + const expected = [ + [ + { name: "string", type: "string" }, + { name: "string" }, + ], + { name: "string" }, + null + ]; + + assert.deepStrictEqual(computeArrayRestrictions(arr), expected); }); it('should return type of every object in an array and a proper intersection', () => { @@ -131,21 +184,47 @@ describe(`'QCConfigurationAdapter' test suite`, () => { [ { type: 'string', active: 'boolean', id: 'number' }, { type: 'string', active: 'string', id: 'number' }, - { type: 'string', id: 'number', list: [['string', 'number', 'boolean'], {}] } + { type: 'string', id: 'number', list: [['string', 'number', 'boolean'], null, null] } ], { type: 'string', id: 'number' }, + null ]; assert.deepStrictEqual(computeArrayRestrictions(inputArray), expectedRestrictions); }); it('should ignore primitives when finding an intersection of types included in the array', () => { - const expectedRestrictions = [[{ type: 'string'}, 'string'], { type: 'string'}]; - assert.deepStrictEqual(computeArrayRestrictions([0, false, 'text']), [['number', 'boolean', 'string'], {}]); - assert.deepStrictEqual(computeArrayRestrictions([{ type: 'flp'}, 'text']), expectedRestrictions); + const expectedRestrictions1 = [['number', 'boolean', 'string'], null, null]; + const expectedRestrictions2 = [[{ type: 'string'}, 'string'], { type: 'string'}, null]; + assert.deepStrictEqual(computeArrayRestrictions(['0', false, 'text']), expectedRestrictions1); + assert.deepStrictEqual(computeArrayRestrictions([{ type: 'flp'}, 'text']), expectedRestrictions2); }); - it('should intersect blueprints of directly nested arrays', () => { + it('should properly intersect the blueprint value for nested arrays', () => { + const inputArray = [ + { type: 'flp', active: false }, + 'ignored-for-type-intersection', + { type: 'ctp' , active: 'no' }, + [{ id: 0, active: true }, 'also-ignored'], + [{ id: 0, active: 'yes' }] + ]; + + const expectedRestrictions = [ + [ + { type: 'string', active: 'boolean'}, + 'string', + { type: 'string', active: 'string'}, + [[{ id: 'number', active: 'boolean' }, 'string'], { id: 'number', active: 'boolean' }, {}], + [[{ id: 'number', active: 'string' }], { id: 'number', active: 'string' }, {}], + ], + { type: 'string' }, + { id: 'number' }, + ]; + + assert.deepStrictEqual(computeArrayRestrictions(inputArray), expectedRestrictions); + }); + + it('should properly intersect blueprints of directly nested arrays', () => { const firstArray = [{ name: 'flp', active: 'yes' }]; const firstArrayRestrictions = [[{ name: 'string', active: 'string' }], { name: 'string', active: 'string'}]; const secondArray = [{ name: 'also-flp', active: true }]; @@ -153,5 +232,120 @@ describe(`'QCConfigurationAdapter' test suite`, () => { const expectedRestrictions = [[firstArrayRestrictions, secondArrayRestrictions], { name: 'string' }]; assert.deepStrictEqual(computeArrayRestrictions([firstArray, secondArray]), expectedRestrictions); }); + + it('should properly propagate and intersect blueprints of nested arrays', () => { + const innerArray1 = [{ one: 1, two: 2, three: 3 }]; + const innerArray1Restrictions = [ + [{ one: 'number', two: 'number', three: 'number' }], // content Restrictions + { one: 'number', two: 'number', three: 'number' }, // blueprint for a new object created + null // blueprint for a new array created, null because array does not contain other arrays + ]; + const innerArray2 = [{ one: 1, three: 3 }]; + const innerArray2Restrictions = [ + [{ one: 'number', three: 'number' }], + { one: 'number', three: 'number' }, + null // null because array does not contain other arrays which are directly nested + ]; + const innerArray3 = [{ one: 1, two: 3 }]; + const innerArray3Restrictions = [ + [{ one: 'number', two: 'number' }], + { one: 'number', two: 'number' }, + null // null because array does not contain other arrays which are directly nested + ]; + + const firstArray = [innerArray1, innerArray2]; + const firstArrayRestrictions = [ + [innerArray1Restrictions, innerArray2Restrictions], + null, // null because array does not contain any objects + [{ one: 'number', three: 'number' }, null] + ] + const secondArray = ['text', { key: true }, innerArray3]; + const secondArrayRestrictions = [ + ['string', { key: 'boolean' }, innerArray3Restrictions], + { key: 'boolean' }, + [innerArray3Restrictions[1], innerArray3Restrictions[2]] + ]; + + const bigArray = [firstArray, secondArray]; + const bigArrayRestrictions = [ + [firstArrayRestrictions, secondArrayRestrictions], + null, // null because array does not contain any objects + [{ key: 'boolean' }, [{ one: 'number' }, null]] // this inner array blueprint contains + // recursive definition for inner arrays because there is a two-level-deep nested array + ] + + assert.deepStrictEqual(computeArrayRestrictions(innerArray1), innerArray1Restrictions); + assert.deepStrictEqual(computeArrayRestrictions(innerArray2), innerArray2Restrictions); + assert.deepStrictEqual(computeArrayRestrictions(innerArray3), innerArray3Restrictions); + assert.deepStrictEqual(computeArrayRestrictions(firstArray), firstArrayRestrictions); + assert.deepStrictEqual(computeArrayRestrictions(secondArray), secondArrayRestrictions); + assert.deepStrictEqual(computeArrayRestrictions(bigArray), bigArrayRestrictions); + }); + }); + + describe('test getRestrictionsIntersection', () => { + it('does not fail for bad input', () => { + assert.equal(getRestrictionsIntersection('text', 'another'), null); + assert.equal(getRestrictionsIntersection('text', 3), null); + assert.equal(getRestrictionsIntersection(false, 'another'), null); + assert.equal(getRestrictionsIntersection(2, true), null); + assert.equal(getRestrictionsIntersection(['text'], 'another'), null); + assert.equal(getRestrictionsIntersection('text', []), null); + assert.equal(getRestrictionsIntersection({}, 'another'), null); + assert.equal(getRestrictionsIntersection('text', { shouldFail: true }), null); + assert.equal(getRestrictionsIntersection(['text', 'another'], { shouldFail: true }), null); + }); + + it('does find the proper intersection', () => { + const first = { test: 'string', id: 'number', active: 'boolean' }; + const second = { test: 'string', id: 'number', list: [[{ key: 'string' }], { key: 'string' }, null] }; + const third = { active: 'boolean', list: [[{ key: 'string' }], { key: 'string' }, null] } + assert.deepStrictEqual(getRestrictionsIntersection(first, second), { test: 'string', id: 'number' }); + assert.deepStrictEqual(getRestrictionsIntersection(first, third), { active: 'boolean' }); + assert.deepStrictEqual( + getRestrictionsIntersection(second, third), + { list: [[{ key: 'string' }], { key: 'string' }, null] }, + ); + assert.deepStrictEqual( + getRestrictionsIntersection(getRestrictionsIntersection(first, second), third), + {} + ); + }); + + it('should intersect nested arrays correctly', () => { + const first = { + list: [ + [{ id: "number" }], + { id: "number" }, + null + ] + }; + + const second = { + list: [ + [{ id: "number", active: "boolean" }], + { id: "number" }, + null + ] + }; + + const expected = { + list: [ + [{ id: "number" }], + { id: "number" }, + null + ] + }; + + assert.deepStrictEqual(getRestrictionsIntersection(first, second), expected); + }); + + it('should return empty object when no keys intersect', () => { + const first = { nested: { name: "string" } }; + const second = { nested: { id: "number" } }; + + assert.deepStrictEqual(getRestrictionsIntersection(first, second), { nested: {} }); + }); + }); }); From 5438ad99568378fe52a5b47a04893891c309b614 Mon Sep 17 00:00:00 2001 From: Deaponn Date: Sat, 6 Dec 2025 15:50:12 +0100 Subject: [PATCH 06/11] feat: implement calculation of ArrayRestrictions add documentation update tests --- .../lib/adapters/QCConfigurationAdapter.js | 156 ++++++++++-------- ...mocha-consul-configuration.adapter.test.js | 101 ++++++------ 2 files changed, 137 insertions(+), 120 deletions(-) diff --git a/Control/lib/adapters/QCConfigurationAdapter.js b/Control/lib/adapters/QCConfigurationAdapter.js index 63b0381f0..149f1f1b7 100644 --- a/Control/lib/adapters/QCConfigurationAdapter.js +++ b/Control/lib/adapters/QCConfigurationAdapter.js @@ -49,20 +49,6 @@ class QCConfigurationAdapter { return [[], null, null]; } - /** - * Derive type of value for every key-val pair of given configuration object - * @param {Object} configuration object we want to get restrictions of - * @returns {Restrictions} derived restrictions for a given configuration - */ - static computeRestrictions = (value) => { - const restrictions = {}; - if (typeof value !== 'object' || Array.isArray(value) || value === null) { - return restrictions; - } - Object.entries(value).forEach(([key, val]) => (restrictions[key] = QCConfigurationAdapter.deriveValueType(val))); - return restrictions; - }; - /** * Derive the type of a value and return it as a string * possible types are Restrictions or ArrayRestrictions @@ -72,7 +58,7 @@ class QCConfigurationAdapter { */ static deriveValueType = (value) => { if (Array.isArray(value)) { return QCConfigurationAdapter.computeArrayRestrictions(value); } - if (typeof value === 'object' && value !== null) { return QCConfigurationAdapter.computeRestrictions(value); } + if (typeof value === 'object' && value !== null) { return QCConfigurationAdapter.computeObjectRestrictions(value); } if (typeof value === 'boolean' || typeof value === 'string' && (value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false')) { return 'boolean'; @@ -85,11 +71,23 @@ class QCConfigurationAdapter { }; /** - * Derive the type of values in an array - * When deriving restrictions for an array, we gather three pieces of data: + * Derive type of value for every key-val pair of given configuration object + * @param {Object} configuration object we want to get restrictions of + * @returns {Restrictions} derived restrictions for a given configuration + */ + static computeObjectRestrictions = (value) => { + const restrictions = {}; + if (!QCConfigurationAdapter.isObject(value)) { return restrictions; } + Object.entries(value).forEach(([key, val]) => (restrictions[key] = QCConfigurationAdapter.deriveValueType(val))); + return restrictions; + }; + + /** + * Compute the type of values in an array + * When computing restrictions for an array, we gather three pieces of data: * - itemsRestrictions: a list of types for every object held inside - * - newObjectRestrictions: a blueprint for a new item in the array - * - newArrayRestrictions: a blueprints to pass into directly nested newly created array + * - innerObjectRestrictions: a blueprint for a new item in the array + * - innerArrayRestrictions: a blueprint of directly nested, newly created array * @param {Array} array for which we calculate the Restrictions * @returns {ArrayRestrictions} value describing the nature of objects held in an array */ @@ -98,11 +96,10 @@ class QCConfigurationAdapter { return QCConfigurationAdapter.emptyArrayRestrictions; } - const firstItemRestrictions = QCConfigurationAdapter.deriveValueType(array[0]); - let innerObjectBlueprint = QCConfigurationAdapter.isObjectOnly(array[0]) ? - firstItemRestrictions : null; - let [innerArrayObjectBlueprint, innerArrayArrayBlueprint] = Array.isArray(array[0]) ? - [firstItemRestrictions[1], firstItemRestrictions[2]] : null; + const firstItem = array[0]; + const firstItemRestrictions = QCConfigurationAdapter.deriveValueType(firstItem); + let innerObjectBlueprint = QCConfigurationAdapter.isObject(firstItem) ? firstItemRestrictions : null; + let innerArrayBlueprint = Array.isArray(firstItem) ? firstItemRestrictions : null; const itemsRestrictions = [firstItemRestrictions]; for (let i = 1; i < array.length; i++) { @@ -111,42 +108,22 @@ class QCConfigurationAdapter { if (QCConfigurationAdapter.isPrimitive(currentItemRestrictions)) { continue; } // skip primitives - if (QCConfigurationAdapter.isObjectOnly(currentItemRestrictions)) { - innerObjectBlueprint = innerObjectBlueprint === null ? - currentItemRestrictions : - QCConfigurationAdapter.getRestrictionsIntersection( - innerObjectBlueprint, - currentItemRestrictions - ); - - continue; + if (QCConfigurationAdapter.isObject(currentItemRestrictions)) { + innerObjectBlueprint = QCConfigurationAdapter.getRestrictionsIntersection( + innerObjectBlueprint, + currentItemRestrictions + ); } - // if the current restrictions describe an array, we intersect object restrictions for them - // with current object restrictions (for previously calculated values) - innerArrayObjectBlueprint = - innerArrayObjectBlueprint === null - ? currentItemRestrictions - : QCConfigurationAdapter.getRestrictionsIntersection( - innerArrayObjectBlueprint, - currentItemRestrictions[1] - ); - - // we also intersect array restrictions for them - innerArrayArrayBlueprint = - innerArrayArrayBlueprint === null - ? currentItemRestrictions - : QCConfigurationAdapter.getRestrictionsIntersection( - innerArrayArrayBlueprint, - currentItemRestrictions[2] - ); + if (Array.isArray(currentItemRestrictions)) { + innerArrayBlueprint = QCConfigurationAdapter.getRestrictionsIntersection( + innerArrayBlueprint, + currentItemRestrictions + ); + } } - return [ - itemsRestrictions, - innerObjectBlueprint, - [innerArrayObjectBlueprint, innerArrayArrayBlueprint] - ]; + return [itemsRestrictions, innerObjectBlueprint, innerArrayBlueprint]; }; /** @@ -161,26 +138,38 @@ class QCConfigurationAdapter { return first === second ? first : null; } - if (QCConfigurationAdapter.bothAreArrays(first, second)) { + if (QCConfigurationAdapter.arrayIntersectionCondition(first, second)) { // intersection of two ArrayRestrictions objects return QCConfigurationAdapter.getArrayRestrictionsIntersection(first, second); } - if (QCConfigurationAdapter.bothAreObjects(first, second)) { - const restrictions = {}; - Object.entries(first).forEach(([key, val]) => { - if (!(key in second)) { return; } - const maximumIntersection = QCConfigurationAdapter.getRestrictionsIntersection(val, second[key]); - if (maximumIntersection === null) { return; } - restrictions[key] = maximumIntersection; - }); - return restrictions; + if (QCConfigurationAdapter.objectIntersectionCondition(first, second)) { + return QCConfigurationAdapter.getObjectRestrictionsIntersection(first, second); } return null; }; + static getObjectRestrictionsIntersection = (first, second) => { + if (first === null) { return second; } + if (second === null) { return first; } + + const restrictions = {}; + Object.entries(first).forEach(([key, val]) => { + if (!(key in second)) { return; } + const maximumIntersection = QCConfigurationAdapter.getRestrictionsIntersection(val, second[key]); + // we skip empty intersection or empty keys which are used for documentation + if (maximumIntersection === null || key.trim() === '') { return; } + restrictions[key] = maximumIntersection; + }); + return restrictions; + } + static getArrayRestrictionsIntersection = (first, second) => { + // in both cases we drop excessive data because the newly created arrays will be empty anyway + if (first === null) { return [[], second[1], second[2]]; } + if (second === null) { return [[], first[1], first[2]]; } + return [ [], QCConfigurationAdapter.getRestrictionsIntersection(first[1], second[1]), @@ -190,7 +179,7 @@ class QCConfigurationAdapter { static isPrimitive = (value) => typeof value === 'string'; - static isObjectOnly = (value) => ( + static isObject = (value) => ( typeof value === 'object' && value !== null && !Array.isArray(value) @@ -200,12 +189,37 @@ class QCConfigurationAdapter { return QCConfigurationAdapter.isPrimitive(first) && QCConfigurationAdapter.isPrimitive(second); } - static bothAreArrays = (first, second) => { - return Array.isArray(first) && Array.isArray(second); + /** + * Function to determine if we can perform ArrayRestrictions intersection + * This intersection is possible only if: + * both first and second are arrays + * at least one of them in an array and the other one is null + * @param {string | Object | Array | null} first + * @param {string | Object | Array | null} second + * @returns {boolean} info if we should intersect the ArrayRestrictions + */ + static arrayIntersectionCondition = (first, second) => { + if (first === null && second === null) { return false; } + if ((Array.isArray(first) || first === null) && (Array.isArray(second) || second === null)) { return true; } + return false; } - static bothAreObjects = (first, second) => { - return QCConfigurationAdapter.isObjectOnly(first) && QCConfigurationAdapter.isObjectOnly(second); + /** + * Function to determine if we can perform Restrictions intersection + * This intersection is possible only if: + * both first and second are objects + * at least one of them in an object and the other one is null + * @param {string | Object | Array | null} first + * @param {string | Object | Array | null} second + * @returns {boolean} info if we should intersect the Restrictions + */ + static objectIntersectionCondition = (first, second) => { + if (first === null && second === null) { return false; } + if ( + (QCConfigurationAdapter.isObject(first) || first === null) && + (QCConfigurationAdapter.isObject(second) || second === null) + ) { return true; } + return false; }; } diff --git a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js index dc96f014b..8fdd97f1f 100644 --- a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js +++ b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js @@ -17,19 +17,19 @@ const sinon = require('sinon'); const QCConfigurationAdapter = require('../../../lib/adapters/QCConfigurationAdapter.js'); const { - computeRestrictions, + computeObjectRestrictions, computeArrayRestrictions, deriveValueType, getRestrictionsIntersection, } = QCConfigurationAdapter; describe(`'QCConfigurationAdapter' test suite`, () => { - describe('test computeRestrictions function', () => { + describe('test computeObjectRestrictions function', () => { it('should work for minimal input', () => { const configuration = {}; const restrictions = {}; - assert.deepStrictEqual(computeRestrictions(configuration), restrictions); + assert.deepStrictEqual(computeObjectRestrictions(configuration), restrictions); }); it('should handle inconsistent types for the same key across objects', () => { @@ -45,7 +45,7 @@ describe(`'QCConfigurationAdapter' test suite`, () => { key3: { value: 'boolean' }, }; - assert.deepStrictEqual(computeRestrictions(configuration), expected); + assert.deepStrictEqual(computeObjectRestrictions(configuration), expected); }); it('should return restrictions for a big configuration', () => { @@ -56,7 +56,7 @@ describe(`'QCConfigurationAdapter' test suite`, () => { key4: 'false', key5: { key1: 'nested', key2: 'false' }, }; - + const restrictions = { key1: 'string', key2: 'number', @@ -65,17 +65,17 @@ describe(`'QCConfigurationAdapter' test suite`, () => { key5: { key1: 'string', key2: 'boolean' }, }; - assert.deepStrictEqual(computeRestrictions(configuration), restrictions); + assert.deepStrictEqual(computeObjectRestrictions(configuration), restrictions); }); it('should not throw for bad input', () => { - assert.deepStrictEqual(computeRestrictions(), {}); - assert.deepStrictEqual(computeRestrictions(undefined), {}); - assert.deepStrictEqual(computeRestrictions(null), {}); - assert.deepStrictEqual(computeRestrictions(0), {}); - assert.deepStrictEqual(computeRestrictions(''), {}); + assert.deepStrictEqual(computeObjectRestrictions(), {}); + assert.deepStrictEqual(computeObjectRestrictions(undefined), {}); + assert.deepStrictEqual(computeObjectRestrictions(null), {}); + assert.deepStrictEqual(computeObjectRestrictions(0), {}); + assert.deepStrictEqual(computeObjectRestrictions(''), {}); assert.deepStrictEqual( - computeRestrictions([{ wrong: 'array input' }]), + computeObjectRestrictions([{ wrong: 'array input' }]), {} ); }); @@ -117,16 +117,16 @@ describe(`'QCConfigurationAdapter' test suite`, () => { }); it('should handle object arguments properly', () => { - const computeRestrictionsSpy = sinon.spy( + const computeObjectRestrictionsSpy = sinon.spy( QCConfigurationAdapter, - 'computeRestrictions' + 'computeObjectRestrictions' ); - // for object arguments, the computeRestrictions function should be called + // for object arguments, the computeObjectRestrictions function should be called deriveValueType(0); - assert.equal(computeRestrictionsSpy.notCalled, true); + assert.equal(computeObjectRestrictionsSpy.notCalled, true); deriveValueType({}); - assert.equal(computeRestrictionsSpy.calledOnce, true); - QCConfigurationAdapter.computeRestrictions.restore(); + assert.equal(computeObjectRestrictionsSpy.calledOnce, true); + QCConfigurationAdapter.computeObjectRestrictions.restore(); }); it('should handle array arguments properly', () => { @@ -214,11 +214,11 @@ describe(`'QCConfigurationAdapter' test suite`, () => { { type: 'string', active: 'boolean'}, 'string', { type: 'string', active: 'string'}, - [[{ id: 'number', active: 'boolean' }, 'string'], { id: 'number', active: 'boolean' }, {}], - [[{ id: 'number', active: 'string' }], { id: 'number', active: 'string' }, {}], + [[{ id: 'number', active: 'boolean' }, 'string'], { id: 'number', active: 'boolean' }, null], + [[{ id: 'number', active: 'string' }], { id: 'number', active: 'string' }, null], ], { type: 'string' }, - { id: 'number' }, + [[], { id: 'number' }, null], ]; assert.deepStrictEqual(computeArrayRestrictions(inputArray), expectedRestrictions); @@ -226,19 +226,32 @@ describe(`'QCConfigurationAdapter' test suite`, () => { it('should properly intersect blueprints of directly nested arrays', () => { const firstArray = [{ name: 'flp', active: 'yes' }]; - const firstArrayRestrictions = [[{ name: 'string', active: 'string' }], { name: 'string', active: 'string'}]; + const firstArrayRestrictions = [ + [{ name: 'string', active: 'string' }], + { name: 'string', active: 'string'}, + null, // null because firstArray does not contain directly nested arrays + ]; const secondArray = [{ name: 'also-flp', active: true }]; - const secondArrayRestrictions = [[{ name: 'string', active: 'boolean' }], { name: 'string', active: 'boolean'}]; - const expectedRestrictions = [[firstArrayRestrictions, secondArrayRestrictions], { name: 'string' }]; - assert.deepStrictEqual(computeArrayRestrictions([firstArray, secondArray]), expectedRestrictions); + const secondArrayRestrictions = [ + [{ name: 'string', active: 'boolean' }], + { name: 'string', active: 'boolean'}, + null, // null because secondArray does not contain directly nested arrays + ]; + const bigArray = [firstArray, secondArray]; + const bigArrayRestrictions = [ + [firstArrayRestrictions, secondArrayRestrictions], + null, // null because bigArray does not contain any objects directly + [[], { name: 'string' }, null], + ]; + assert.deepStrictEqual(computeArrayRestrictions(bigArray), bigArrayRestrictions); }); - it('should properly propagate and intersect blueprints of nested arrays', () => { + it('should properly intersect blueprints and drop excessive data', () => { const innerArray1 = [{ one: 1, two: 2, three: 3 }]; const innerArray1Restrictions = [ [{ one: 'number', two: 'number', three: 'number' }], // content Restrictions { one: 'number', two: 'number', three: 'number' }, // blueprint for a new object created - null // blueprint for a new array created, null because array does not contain other arrays + null // would be a blueprint for a new array created, null because array does not contain other arrays ]; const innerArray2 = [{ one: 1, three: 3 }]; const innerArray2Restrictions = [ @@ -256,23 +269,25 @@ describe(`'QCConfigurationAdapter' test suite`, () => { const firstArray = [innerArray1, innerArray2]; const firstArrayRestrictions = [ [innerArray1Restrictions, innerArray2Restrictions], - null, // null because array does not contain any objects - [{ one: 'number', three: 'number' }, null] + null, // null because firstArray does not contain any objects + [[], { one: 'number', three: 'number' }, null] ] const secondArray = ['text', { key: true }, innerArray3]; const secondArrayRestrictions = [ ['string', { key: 'boolean' }, innerArray3Restrictions], { key: 'boolean' }, - [innerArray3Restrictions[1], innerArray3Restrictions[2]] + [[], innerArray3Restrictions[1], innerArray3Restrictions[2]] + // we drop the list of object restrictions above because the new array is not filled with objects yet + // this is excessive data we do not want in the result ]; const bigArray = [firstArray, secondArray]; const bigArrayRestrictions = [ [firstArrayRestrictions, secondArrayRestrictions], null, // null because array does not contain any objects - [{ key: 'boolean' }, [{ one: 'number' }, null]] // this inner array blueprint contains + [[], { key: 'boolean' }, [[], { one: 'number' }, innerArray3Restrictions[2]]] // this inner array blueprint contains // recursive definition for inner arrays because there is a two-level-deep nested array - ] + ]; assert.deepStrictEqual(computeArrayRestrictions(innerArray1), innerArray1Restrictions); assert.deepStrictEqual(computeArrayRestrictions(innerArray2), innerArray2Restrictions); @@ -302,14 +317,8 @@ describe(`'QCConfigurationAdapter' test suite`, () => { const third = { active: 'boolean', list: [[{ key: 'string' }], { key: 'string' }, null] } assert.deepStrictEqual(getRestrictionsIntersection(first, second), { test: 'string', id: 'number' }); assert.deepStrictEqual(getRestrictionsIntersection(first, third), { active: 'boolean' }); - assert.deepStrictEqual( - getRestrictionsIntersection(second, third), - { list: [[{ key: 'string' }], { key: 'string' }, null] }, - ); - assert.deepStrictEqual( - getRestrictionsIntersection(getRestrictionsIntersection(first, second), third), - {} - ); + assert.deepStrictEqual(getRestrictionsIntersection(second, third), { list: [[], { key: 'string' }, null] }); + assert.deepStrictEqual(getRestrictionsIntersection(getRestrictionsIntersection(first, second), third), {}); }); it('should intersect nested arrays correctly', () => { @@ -324,19 +333,13 @@ describe(`'QCConfigurationAdapter' test suite`, () => { const second = { list: [ [{ id: "number", active: "boolean" }], - { id: "number" }, - null - ] - }; - - const expected = { - list: [ - [{ id: "number" }], - { id: "number" }, + { id: "number", active: "boolean" }, null ] }; + const expected = { list: [[], { id: "number" }, null] }; + assert.deepStrictEqual(getRestrictionsIntersection(first, second), expected); }); From 4edb1d48a1490d9811f272843ed8dc8b85963006 Mon Sep 17 00:00:00 2001 From: Deaponn Date: Sun, 7 Dec 2025 14:52:05 +0100 Subject: [PATCH 07/11] test: add more tests improve documentation fix code which caused tests to fail --- .../lib/adapters/QCConfigurationAdapter.js | 97 +++++-- .../lib/services/QCConfiguration.service.js | 2 +- Control/lib/typedefs/Restrictions.js | 18 +- ...mocha-consul-configuration.adapter.test.js | 255 +++++++++++++++--- 4 files changed, 314 insertions(+), 58 deletions(-) diff --git a/Control/lib/adapters/QCConfigurationAdapter.js b/Control/lib/adapters/QCConfigurationAdapter.js index 149f1f1b7..ac2cf57de 100644 --- a/Control/lib/adapters/QCConfigurationAdapter.js +++ b/Control/lib/adapters/QCConfigurationAdapter.js @@ -58,15 +58,24 @@ class QCConfigurationAdapter { */ static deriveValueType = (value) => { if (Array.isArray(value)) { return QCConfigurationAdapter.computeArrayRestrictions(value); } + if (typeof value === 'object' && value !== null) { return QCConfigurationAdapter.computeObjectRestrictions(value); } + if (typeof value === 'boolean' || typeof value === 'string' && - (value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false')) { - return 'boolean'; - } - if (typeof value === 'number' || (!isNaN(Number(value)) && value.trim() !== '')) { return 'number'; } + (value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false') + ) { return 'boolean'; } + + if (typeof value === 'number' || typeof value === 'string' && + (!isNaN(Number(value)) && value.trim() !== '') + ) { return 'number'; } + if (typeof value === 'string') { return 'string'; } + const logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'cog'}/qc-conf-adapter`); - logger.warnMessage(`Unknown value encountered while calculating restrictions from a configuration: ${value}`); + logger.warnMessage( + `Unknown value encountered while calculating restrictions from a configuration: ${value}` + ); + return 'unknown'; }; @@ -78,7 +87,9 @@ class QCConfigurationAdapter { static computeObjectRestrictions = (value) => { const restrictions = {}; if (!QCConfigurationAdapter.isObject(value)) { return restrictions; } - Object.entries(value).forEach(([key, val]) => (restrictions[key] = QCConfigurationAdapter.deriveValueType(val))); + Object.entries(value).forEach( + ([key, val]) => (restrictions[key] = QCConfigurationAdapter.deriveValueType(val)) + ); return restrictions; }; @@ -150,6 +161,45 @@ class QCConfigurationAdapter { return null; }; + /** + * Function used to calculate the intersection of blueprints of two arrays. + * + * The value at index 0 of both arrays is excessive, since it describes the values + * currently held in there which is irrelevant when user chooses to create new, + * empty array (which will use the ArrayRestricitons we are calculating right now) + * + * The values at index 1 (describing objects directly in the array) and + * the values at index 2 (describing arrays directly in the array) + * are calculated as usual + * + * If one of the arguments is an array and the other one is null, + * the proper array is considered the valid blueprint + * @param {Array | null} first + * @param {Array | null} second + * @returns {ArrayRestrictions} + */ + static getArrayRestrictionsIntersection = (first, second) => { + if (first === null && second === null) { return null; } + + // in both cases we drop excessive data because the newly created arrays will be empty anyway + if (first === null) { return [[], second[1], second[2]]; } + if (second === null) { return [[], first[1], first[2]]; } + + return [ + [], + QCConfigurationAdapter.getRestrictionsIntersection(first[1], second[1]), + QCConfigurationAdapter.getRestrictionsIntersection(first[2], second[2]), + ]; + }; + + /** + * Function used to calculate the intersection of blueprints of two objects. + * If one of the arguments is an object and the other one is null, + * the proper object is considered the valid blueprint + * @param {Object | null} first + * @param {Object | null} second + * @returns {Restrictions} + */ static getObjectRestrictionsIntersection = (first, second) => { if (first === null) { return second; } if (second === null) { return first; } @@ -165,26 +215,32 @@ class QCConfigurationAdapter { return restrictions; } - static getArrayRestrictionsIntersection = (first, second) => { - // in both cases we drop excessive data because the newly created arrays will be empty anyway - if (first === null) { return [[], second[1], second[2]]; } - if (second === null) { return [[], first[1], first[2]]; } - - return [ - [], - QCConfigurationAdapter.getRestrictionsIntersection(first[1], second[1]), - QCConfigurationAdapter.getRestrictionsIntersection(first[2], second[2]), - ]; - }; - + /** + * A primitive value in this context is a description of value held in Configuration. + * This means that when we encounter a primitive value, we describe it (using Restrictions) with a string. + * Otherwise we define that this Restriction do not describe a primitive value, but rather an Array or an Object + * @param {any} value + * @returns {boolean} true if the values provided describes a primitive value held in Configuration + */ static isPrimitive = (value) => typeof value === 'string'; + /** + * Function designed to check if value passed is an Object specifically, excluding null and arrays + * @param {any} value + * @returns + */ static isObject = (value) => ( typeof value === 'object' && value !== null && !Array.isArray(value) ); + /** + * For the definition of primitives in context of the Restrictions see the JSDoc of `isPrimitive` function above + * @param {any} first + * @param {any} second + * @returns {boolean} true if both values describe primitives + */ static bothArePrimitive = (first, second) => { return QCConfigurationAdapter.isPrimitive(first) && QCConfigurationAdapter.isPrimitive(second); } @@ -200,7 +256,10 @@ class QCConfigurationAdapter { */ static arrayIntersectionCondition = (first, second) => { if (first === null && second === null) { return false; } - if ((Array.isArray(first) || first === null) && (Array.isArray(second) || second === null)) { return true; } + if ( + (Array.isArray(first) || first === null) && + (Array.isArray(second) || second === null) + ) { return true; } return false; } diff --git a/Control/lib/services/QCConfiguration.service.js b/Control/lib/services/QCConfiguration.service.js index 09988d2b4..e7fc3b732 100644 --- a/Control/lib/services/QCConfiguration.service.js +++ b/Control/lib/services/QCConfiguration.service.js @@ -90,7 +90,7 @@ class QCConfigurationService { */ async getConfigurationRestrictionsByKey(key) { const configuration = await this._consulService.getOnlyRawValueByKey(key); - return QCConfigurationAdapter.computeRestrictions(configuration); + return QCConfigurationAdapter.computeObjectRestrictions(configuration); } /** diff --git a/Control/lib/typedefs/Restrictions.js b/Control/lib/typedefs/Restrictions.js index 117bc1993..9098f14c3 100644 --- a/Control/lib/typedefs/Restrictions.js +++ b/Control/lib/typedefs/Restrictions.js @@ -100,20 +100,22 @@ * - at index 0 there is a nested array which describes Restrictions of each object held in input array * - at index 1 there is a 'blueprint' Restrictions in case user decides to create a new object, * or null if source array contains no objects - * - at index 2 there is a 'blueprint' of length 2 which is passed to new arrays created - * this inner blueprint at index 0 has a blueprint for new objects in the newly created array - * and at index 1 it has a blueprint for new arrays created inside of the newly created array - * if the source array does not contain an array, this value is null + * - at index 2 there is a 'blueprint' ArrayRestrictions in case user decides to create a directly nested array + * or null if source array contains no nested arrays * If user creates an object on the frontend, it is pre-populated according to the blueprint at index 1 - * If user creates an array on the frontend, its blueprint is populated with the two blueprints at index 2 + * If user creates an array on the frontend, its blueprint is populated with the value at index 2 * * Example ArrayRestrictions object: * [ * [ - * Restrictions for item #1, Restrictions for item #2 + * { name: 'string', id: 'number', active: 'boolean' }, // object Restrictions + * { name: 'string', id: 'string', active: 'string' }, // another object Restrictions + * 'string', // primitive values held in the array + * 'number', + * [['boolean', { title: 'string' }], { title: 'string' }, null] // nested array * ], - * blueprint for newly created object, - * [object blueprint for new array created in this one, array blueprint for new array created in this one] + * { name: 'string' }, // intersection of the objects + * [[], { title: 'string' }, null] // blueprint for a new array * ] * @typedef { [Array, Restrictions, Restrictions] } ArrayRestrictions */ diff --git a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js index 8fdd97f1f..0641096f9 100644 --- a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js +++ b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js @@ -16,11 +16,19 @@ const assert = require('assert'); const sinon = require('sinon'); const QCConfigurationAdapter = require('../../../lib/adapters/QCConfigurationAdapter.js'); +const { LogManager } = require('@aliceo2/web-ui'); const { computeObjectRestrictions, computeArrayRestrictions, deriveValueType, getRestrictionsIntersection, + getArrayRestrictionsIntersection, + getObjectRestrictionsIntersection, + isPrimitive, + isObject, + bothArePrimitive, + arrayIntersectionCondition, + objectIntersectionCondition, } = QCConfigurationAdapter; describe(`'QCConfigurationAdapter' test suite`, () => { @@ -82,6 +90,16 @@ describe(`'QCConfigurationAdapter' test suite`, () => { }); describe('test deriveValueType function', () => { + it('should not throw for bad input', () => { + const logCreator = sinon.spy(LogManager, 'getLogger'); + + assert.equal(deriveValueType(null), 'unknown'); + assert.equal(deriveValueType(undefined), 'unknown'); + assert.equal(logCreator.calledTwice, true); + + logCreator.restore(); + }); + it('should handle string arguments properly', () => { assert.equal(deriveValueType('test'), 'string'); assert.equal(deriveValueType(''), 'string'); @@ -126,7 +144,7 @@ describe(`'QCConfigurationAdapter' test suite`, () => { assert.equal(computeObjectRestrictionsSpy.notCalled, true); deriveValueType({}); assert.equal(computeObjectRestrictionsSpy.calledOnce, true); - QCConfigurationAdapter.computeObjectRestrictions.restore(); + computeObjectRestrictions.restore(); }); it('should handle array arguments properly', () => { @@ -139,7 +157,7 @@ describe(`'QCConfigurationAdapter' test suite`, () => { assert.equal(computeArrayRestrictionsSpy.notCalled, true); deriveValueType([]); assert.equal(computeArrayRestrictionsSpy.calledOnce, true); - QCConfigurationAdapter.computeArrayRestrictions.restore(); + computeArrayRestrictions.restore(); }); }); @@ -148,6 +166,17 @@ describe(`'QCConfigurationAdapter' test suite`, () => { assert.deepStrictEqual(computeArrayRestrictions([]), [[], null, null]); }); + it('should ignore primitives when finding an intersection of types included in the array', () => { + const inputArray1 = ['0', false, 'text']; + const expectedRestrictions1 = [['number', 'boolean', 'string'], null, null]; + + const inputArray2 = [{ type: 'flp'}, 'text']; + const expectedRestrictions2 = [[{ type: 'string'}, 'string'], { type: 'string'}, null]; + + assert.deepStrictEqual(computeArrayRestrictions(inputArray1), expectedRestrictions1); + assert.deepStrictEqual(computeArrayRestrictions(inputArray2), expectedRestrictions2); + }); + it('should generate identical restrictions for arrays with same content in different order', () => { const item1 = { name: 'flp' }; const item2 = { name: 'ctp' }; @@ -162,10 +191,7 @@ describe(`'QCConfigurationAdapter' test suite`, () => { const arr = [{ name: "flp", type: "flp-Mx-03" }, { name: "ctp" }]; const expected = [ - [ - { name: "string", type: "string" }, - { name: "string" }, - ], + [{ name: "string", type: "string" }, { name: "string" }], { name: "string" }, null ]; @@ -193,17 +219,10 @@ describe(`'QCConfigurationAdapter' test suite`, () => { assert.deepStrictEqual(computeArrayRestrictions(inputArray), expectedRestrictions); }); - it('should ignore primitives when finding an intersection of types included in the array', () => { - const expectedRestrictions1 = [['number', 'boolean', 'string'], null, null]; - const expectedRestrictions2 = [[{ type: 'string'}, 'string'], { type: 'string'}, null]; - assert.deepStrictEqual(computeArrayRestrictions(['0', false, 'text']), expectedRestrictions1); - assert.deepStrictEqual(computeArrayRestrictions([{ type: 'flp'}, 'text']), expectedRestrictions2); - }); - it('should properly intersect the blueprint value for nested arrays', () => { const inputArray = [ { type: 'flp', active: false }, - 'ignored-for-type-intersection', + 'ignored-for-type-and-array-intersection', { type: 'ctp' , active: 'no' }, [{ id: 0, active: true }, 'also-ignored'], [{ id: 0, active: 'yes' }] @@ -231,12 +250,14 @@ describe(`'QCConfigurationAdapter' test suite`, () => { { name: 'string', active: 'string'}, null, // null because firstArray does not contain directly nested arrays ]; + const secondArray = [{ name: 'also-flp', active: true }]; const secondArrayRestrictions = [ [{ name: 'string', active: 'boolean' }], { name: 'string', active: 'boolean'}, null, // null because secondArray does not contain directly nested arrays ]; + const bigArray = [firstArray, secondArray]; const bigArrayRestrictions = [ [firstArrayRestrictions, secondArrayRestrictions], @@ -253,12 +274,14 @@ describe(`'QCConfigurationAdapter' test suite`, () => { { one: 'number', two: 'number', three: 'number' }, // blueprint for a new object created null // would be a blueprint for a new array created, null because array does not contain other arrays ]; + const innerArray2 = [{ one: 1, three: 3 }]; const innerArray2Restrictions = [ [{ one: 'number', three: 'number' }], { one: 'number', three: 'number' }, null // null because array does not contain other arrays which are directly nested ]; + const innerArray3 = [{ one: 1, two: 3 }]; const innerArray3Restrictions = [ [{ one: 'number', two: 'number' }], @@ -271,7 +294,8 @@ describe(`'QCConfigurationAdapter' test suite`, () => { [innerArray1Restrictions, innerArray2Restrictions], null, // null because firstArray does not contain any objects [[], { one: 'number', three: 'number' }, null] - ] + ]; + const secondArray = ['text', { key: true }, innerArray3]; const secondArrayRestrictions = [ ['string', { key: 'boolean' }, innerArray3Restrictions], @@ -299,7 +323,7 @@ describe(`'QCConfigurationAdapter' test suite`, () => { }); describe('test getRestrictionsIntersection', () => { - it('does not fail for bad input', () => { + it('should not fail for bad input', () => { assert.equal(getRestrictionsIntersection('text', 'another'), null); assert.equal(getRestrictionsIntersection('text', 3), null); assert.equal(getRestrictionsIntersection(false, 'another'), null); @@ -311,10 +335,11 @@ describe(`'QCConfigurationAdapter' test suite`, () => { assert.equal(getRestrictionsIntersection(['text', 'another'], { shouldFail: true }), null); }); - it('does find the proper intersection', () => { + it('should find the proper intersection', () => { const first = { test: 'string', id: 'number', active: 'boolean' }; const second = { test: 'string', id: 'number', list: [[{ key: 'string' }], { key: 'string' }, null] }; - const third = { active: 'boolean', list: [[{ key: 'string' }], { key: 'string' }, null] } + const third = { active: 'boolean', list: [[{ key: 'string' }], { key: 'string' }, null] }; + assert.deepStrictEqual(getRestrictionsIntersection(first, second), { test: 'string', id: 'number' }); assert.deepStrictEqual(getRestrictionsIntersection(first, third), { active: 'boolean' }); assert.deepStrictEqual(getRestrictionsIntersection(second, third), { list: [[], { key: 'string' }, null] }); @@ -322,20 +347,10 @@ describe(`'QCConfigurationAdapter' test suite`, () => { }); it('should intersect nested arrays correctly', () => { - const first = { - list: [ - [{ id: "number" }], - { id: "number" }, - null - ] - }; + const first = { list: [[{ id: "number" }], { id: "number" }, null] }; const second = { - list: [ - [{ id: "number", active: "boolean" }], - { id: "number", active: "boolean" }, - null - ] + list: [[{ id: "number", active: "boolean" }], { id: "number", active: "boolean" }, null] }; const expected = { list: [[], { id: "number" }, null] }; @@ -351,4 +366,184 @@ describe(`'QCConfigurationAdapter' test suite`, () => { }); }); + + describe('test getArrayRestrictionsIntersection', () => { + it('should not fail for bad input', () => { + assert.equal(getArrayRestrictionsIntersection(null, null), null); + }); + + it('should respect any ArrayRestrictions if the second one is null', () => { + const verySpecificArrayRestrictions = [ + [ + { rareKey: 'string', specificName: 'boolean', nestedArray: [['boolean'], null, null] }, + [['boolean', { key: 'value' }, []], { key: 'value' }, [[], null, null]], + ], + { rareKey: 'string', specificName: 'boolean', nestedArray: [['boolean'], null, null] }, + [[], { key: 'value' }, ['boolean', { key: 'value' }, [[], null, null]]], + ]; + + assert.deepStrictEqual( + getArrayRestrictionsIntersection(verySpecificArrayRestrictions, null), + [[], verySpecificArrayRestrictions[1], verySpecificArrayRestrictions[2]], + ); + + assert.deepStrictEqual( + getArrayRestrictionsIntersection(null, verySpecificArrayRestrictions), + [[], verySpecificArrayRestrictions[1], verySpecificArrayRestrictions[2]], + ); + }); + }); + + describe('test getObjectRestrictionsIntersection', () => { + it('should not fail for bad input', () => { + assert.equal(getObjectRestrictionsIntersection(null, null), null); + }); + + it('should respect any ArrayRestrictions if the second one is null', () => { + const verySpecificObjectRestrictions = { + name: 'string', + id: 'number', + active: 'boolean', + dbCredentials: { url: 'string', username: 'string', port: 'number' }, + list: [ + [ + { rareKey: 'string', specificName: 'boolean', nestedArray: [['boolean'], null, null] }, + [['boolean', { key: 'value' }, []], { key: 'value' }, [[], null, null]], + ], + { rareKey: 'string', specificName: 'boolean', nestedArray: [['boolean'], null, null] }, + [[], { key: 'value' }, ['boolean', { key: 'value' }, [[], null, null]]], + ], + }; + + assert.deepStrictEqual( + getObjectRestrictionsIntersection(verySpecificObjectRestrictions, null), + verySpecificObjectRestrictions, + ); + + assert.deepStrictEqual( + getObjectRestrictionsIntersection(null, verySpecificObjectRestrictions), + verySpecificObjectRestrictions, + ); + }); + }); + + describe('test isPrimitive function', () => { + it('should return true for strings', () => { + assert.strictEqual(isPrimitive('string'), true); + assert.strictEqual(isPrimitive('number'), true); + assert.strictEqual(isPrimitive('boolean'), true); + assert.strictEqual(isPrimitive(''), true); + }); + + it('should return false for non-strings', () => { + assert.strictEqual(isPrimitive(123), false); + assert.strictEqual(isPrimitive(true), false); + assert.strictEqual(isPrimitive(null), false); + assert.strictEqual(isPrimitive(undefined), false); + assert.strictEqual(isPrimitive({}), false); + assert.strictEqual(isPrimitive([]), false); + assert.strictEqual(isPrimitive(() => {}), false); + }); + }); + + describe('test isObject function', () => { + it('should return true for plain objects', () => { + assert.strictEqual(isObject({}), true); + assert.strictEqual(isObject({ a: 1, b: 'two' }), true); + }); + + it('should return false for null', () => { + assert.strictEqual(isObject(null), false); + }); + + it('should return false for arrays', () => { + assert.strictEqual(isObject([]), false); + assert.strictEqual(isObject([1, 2, 3]), false); + }); + + it('should return false for primitives and undefined', () => { + assert.strictEqual(isObject('string'), false); + assert.strictEqual(isObject(123), false); + assert.strictEqual(isObject(true), false); + assert.strictEqual(isObject(undefined), false); + }); + }); + + describe('test bothArePrimitive function', () => { + it('should return true when both are strings', () => { + assert.strictEqual(bothArePrimitive('string', 'number'), true); + assert.strictEqual(bothArePrimitive('boolean', ''), true); + }); + + it('should return false when the first is not a string', () => { + assert.strictEqual(bothArePrimitive(123, 'string'), false); + assert.strictEqual(bothArePrimitive(null, 'string'), false); + }); + + it('should return false when the second is not a string', () => { + assert.strictEqual(bothArePrimitive('string', 123), false); + assert.strictEqual(bothArePrimitive('string', {}), false); + }); + + it('should return false when neither are strings', () => { + assert.strictEqual(bothArePrimitive(123, true), false); + assert.strictEqual(bothArePrimitive({}, []), false); + }); + }); + + describe('test arrayIntersectionCondition function', () => { + it('should return true for two arrays', () => { + assert.strictEqual(arrayIntersectionCondition([], []), true); + }); + + it('should return true for an array and null (in either order)', () => { + assert.strictEqual(arrayIntersectionCondition([], null), true); + assert.strictEqual(arrayIntersectionCondition(null, []), true); + }); + + it('should return false for two nulls', () => { + assert.strictEqual(arrayIntersectionCondition(null, null), false); + }); + + it('should return false if one or both are objects', () => { + assert.strictEqual(arrayIntersectionCondition([], {}), false); + assert.strictEqual(arrayIntersectionCondition({}, []), false); + assert.strictEqual(arrayIntersectionCondition({}, null), false); + }); + + it('should return false if one or both are primitives', () => { + assert.strictEqual(arrayIntersectionCondition([], 'str'), false); + assert.strictEqual(arrayIntersectionCondition('str', []), false); + assert.strictEqual(arrayIntersectionCondition('str', 'str'), false); + assert.strictEqual(arrayIntersectionCondition('str', null), false); + }); + }); + + describe('test objectIntersectionCondition function', () => { + it('should return true for two objects', () => { + assert.strictEqual(objectIntersectionCondition({}, {}), true); + }); + + it('should return true for an object and null (in either order)', () => { + assert.strictEqual(objectIntersectionCondition({}, null), true); + assert.strictEqual(objectIntersectionCondition(null, {}), true); + }); + + it('should return false for two nulls', () => { + assert.strictEqual(objectIntersectionCondition(null, null), false); + }); + + it('should return false if one or both are arrays', () => { + assert.strictEqual(objectIntersectionCondition({}, []), false); + assert.strictEqual(objectIntersectionCondition([], {}), false); + assert.strictEqual(objectIntersectionCondition([], null), false); + }); + + it('should return false if one or both are primitives', () => { + assert.strictEqual(objectIntersectionCondition({}, 'str'), false); + assert.strictEqual(objectIntersectionCondition('str', {}), false); + assert.strictEqual(objectIntersectionCondition('str', 'str'), false); + assert.strictEqual(objectIntersectionCondition('str', null), false); + }); + }); }); From 23c9ceae12c5bfb80b7b58b036cb41266fa5aada Mon Sep 17 00:00:00 2001 From: Deaponn Date: Sun, 7 Dec 2025 15:29:56 +0100 Subject: [PATCH 08/11] test: fix failing test spec --- .../lib/adapters/mocha-consul-configuration.adapter.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js index 0641096f9..d0a8ace3b 100644 --- a/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js +++ b/Control/test/lib/adapters/mocha-consul-configuration.adapter.test.js @@ -144,7 +144,7 @@ describe(`'QCConfigurationAdapter' test suite`, () => { assert.equal(computeObjectRestrictionsSpy.notCalled, true); deriveValueType({}); assert.equal(computeObjectRestrictionsSpy.calledOnce, true); - computeObjectRestrictions.restore(); + computeObjectRestrictionsSpy.restore(); }); it('should handle array arguments properly', () => { @@ -157,7 +157,7 @@ describe(`'QCConfigurationAdapter' test suite`, () => { assert.equal(computeArrayRestrictionsSpy.notCalled, true); deriveValueType([]); assert.equal(computeArrayRestrictionsSpy.calledOnce, true); - computeArrayRestrictions.restore(); + computeArrayRestrictionsSpy.restore(); }); }); From 1cd140c64ec4c373e894082f567b17f3b2319140 Mon Sep 17 00:00:00 2001 From: Deaponn Date: Sun, 7 Dec 2025 22:40:10 +0100 Subject: [PATCH 09/11] feat: implement array rendering on the frontend refactor frontend code --- .../app/api/query/useConfigurationQuery.ts | 4 +- .../useConfigurationRestrictionsQuery.ts | 4 +- .../app/components/form/ArrayWidget.tsx | 60 +++++++++++++ .../webapp/app/components/form/Form.tsx | 64 ++++++-------- .../webapp/app/components/form/FormItem.tsx | 86 +++++++++++++++++++ .../webapp/app/components/form/Widget.tsx | 63 ++------------ .../webapp/app/routes/configuration.tsx | 8 +- 7 files changed, 184 insertions(+), 105 deletions(-) create mode 100644 Configuration/webapp/app/components/form/ArrayWidget.tsx create mode 100644 Configuration/webapp/app/components/form/FormItem.tsx diff --git a/Configuration/webapp/app/api/query/useConfigurationQuery.ts b/Configuration/webapp/app/api/query/useConfigurationQuery.ts index 6c7e77b23..fb3d5f9fa 100644 --- a/Configuration/webapp/app/api/query/useConfigurationQuery.ts +++ b/Configuration/webapp/app/api/query/useConfigurationQuery.ts @@ -15,7 +15,7 @@ import { useQuery } from '@tanstack/react-query'; import axiosInstance from '../axiosInstance'; import { BASE_CONFIGURATION_PATH } from '~/config'; -import type { FormItem } from '~/components/form/Form'; +import type { FormValue } from '~/components/form/Form'; export const CONFIGURATION_QUERY_KEY = 'configuration'; @@ -24,6 +24,6 @@ export const useConfigurationQuery = (configuration: string) => queryKey: [CONFIGURATION_QUERY_KEY, configuration], queryFn: async () => axiosInstance - .get(`configurations/${BASE_CONFIGURATION_PATH}/${configuration}`) + .get(`configurations/${BASE_CONFIGURATION_PATH}/${configuration}`) .then((response) => response.data), }); diff --git a/Configuration/webapp/app/api/query/useConfigurationRestrictionsQuery.ts b/Configuration/webapp/app/api/query/useConfigurationRestrictionsQuery.ts index 8fdf483fe..92a845ec7 100644 --- a/Configuration/webapp/app/api/query/useConfigurationRestrictionsQuery.ts +++ b/Configuration/webapp/app/api/query/useConfigurationRestrictionsQuery.ts @@ -15,7 +15,7 @@ import { BASE_CONFIGURATION_PATH } from '~/config'; import { useQuery } from '@tanstack/react-query'; import axiosInstance from '../axiosInstance'; -import type { FormRestrictions } from '~/components/form/Form'; +import type { ObjectRestrictions } from '~/components/form/Form'; export const CONFIGURATION_RESTRICTIONS_QUERY_KEY = 'configuration-restrictions'; @@ -24,7 +24,7 @@ export const useConfigurationRestrictionsQuery = (configuration: string) => queryKey: [CONFIGURATION_RESTRICTIONS_QUERY_KEY, configuration], queryFn: async () => axiosInstance - .get( + .get( `configurations/restrictions/${BASE_CONFIGURATION_PATH}/${configuration}`, ) .then((response) => response.data), diff --git a/Configuration/webapp/app/components/form/ArrayWidget.tsx b/Configuration/webapp/app/components/form/ArrayWidget.tsx new file mode 100644 index 000000000..f9629404d --- /dev/null +++ b/Configuration/webapp/app/components/form/ArrayWidget.tsx @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * 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 { useState, type ReactElement } from 'react'; +import { type ArrayRestrictions, type FormArrayValue } from './Form'; +import { Accordion, AccordionDetails, Stack, Typography } from '@mui/material'; +import { AccordionHeader } from './AccordionHeader'; +import { FormItem } from './FormItem'; + +interface ArrayWidgetProps { + sectionTitle: string; + items: FormArrayValue; + itemsRestrictions: ArrayRestrictions; +} + +export const ArrayWidget = ({ + sectionTitle, + items, + itemsRestrictions, +}: ArrayWidgetProps): ReactElement => { + const [viewForm, setViewForm] = useState(true); + const [arrayRestrictions] = itemsRestrictions; // [arrayRestrictions, objectBlueprint, arrayBlueprint] + + return ( + + setViewForm((v) => !v)} + /> + + {viewForm ? ( + + {items.map((item, idx) => ( + + ))} + + ) : ( + {JSON.stringify(items, null, 2)} + )} + + + ); +}; diff --git a/Configuration/webapp/app/components/form/Form.tsx b/Configuration/webapp/app/components/form/Form.tsx index 27347ddc9..ae5118b75 100644 --- a/Configuration/webapp/app/components/form/Form.tsx +++ b/Configuration/webapp/app/components/form/Form.tsx @@ -12,68 +12,45 @@ * or submit itself to any jurisdiction. */ -import { useCallback, useState, type FC, type PropsWithChildren } from 'react'; +import { useState, type FC, type PropsWithChildren } from 'react'; import Accordion from '@mui/material/Accordion'; import AccordionDetails from '@mui/material/AccordionDetails'; import Stack from '@mui/material/Stack'; -import { Widget } from './Widget'; import { AccordionHeader } from './AccordionHeader'; import { Typography } from '@mui/material'; +import { FormItem } from './FormItem'; -export type FormItem = { [key: string]: string | object | FormItem }; +export type FormValue = FormPrimitiveValue | FormArrayValue | FormObjectValue; -type PrimitiveRestrictions = 'string' | 'number' | 'boolean'; +export type FormPrimitiveValue = string | number | boolean; + +export type FormArrayValue = Array; + +export type FormObjectValue = { [key: string]: FormValue }; + +export type PrimitiveRestrictions = 'string' | 'number' | 'boolean'; export type ArrayRestrictions = [ Array, - Restrictions, + ObjectRestrictions | null, // Restrictions for an object directly in the array + ArrayRestrictions | null, // ArrayRestrictions for a directly nested array ]; -export type WidgetRestrictions = PrimitiveRestrictions | ArrayRestrictions; - -export type FormRestrictions = { +export type ObjectRestrictions = { [key: string]: Restrictions; }; -export type Restrictions = PrimitiveRestrictions | ArrayRestrictions | FormRestrictions; +export type Restrictions = PrimitiveRestrictions | ArrayRestrictions | ObjectRestrictions; interface FormProps extends PropsWithChildren { sectionTitle: string; - items: FormItem; - itemsRestrictions: FormRestrictions; -} - -/** - * Function which returns false if the given object - * which describes restrictions is the leaf (string, number, bool, array) - * or returns true if the given object describes restrictions recursively - * @param {Restrictions} obj - * the object which describes restrictions - * @returns {boolean} value which indicates if the restrictions are recursive - * or if this is the leaf of the FormRestrictions tree - */ -export function isFormRestrictions(obj: FormRestrictions[string]): obj is FormRestrictions { - return obj instanceof Object && obj !== null && !(Array.isArray(obj)); + items: FormObjectValue; + itemsRestrictions: ObjectRestrictions; } export const Form: FC = ({ sectionTitle, items, itemsRestrictions }) => { const [viewForm, setViewForm] = useState(true); - const renderItem = useCallback( - (key: string, value: FormRestrictions[string]) => - isFormRestrictions(value) ? ( - - ) : ( - - ), - [items, itemsRestrictions], - ); - return ( = ({ sectionTitle, items, itemsRestrictions }) {viewForm ? ( - {Object.entries(itemsRestrictions).map(([key, value]) => renderItem(key, value))} + {Object.entries(items).map(([key, value]) => ( + + ))} ) : ( {JSON.stringify(items, null, 2)} diff --git a/Configuration/webapp/app/components/form/FormItem.tsx b/Configuration/webapp/app/components/form/FormItem.tsx new file mode 100644 index 000000000..7e2648a70 --- /dev/null +++ b/Configuration/webapp/app/components/form/FormItem.tsx @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * 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 type { FC } from 'react'; +import { + type ArrayRestrictions, + type ObjectRestrictions, + type Restrictions, + type FormValue, + Form, + type FormObjectValue, + type FormArrayValue, + type FormPrimitiveValue, +} from './Form'; +import { Widget } from './Widget'; +import { ArrayWidget } from './ArrayWidget'; + +interface FormItemProps { + sectionTitle: string; + value: FormValue; + restrictions: Restrictions | ObjectRestrictions | ArrayRestrictions; +} + +/** + * Function which returns true only if the given argument is an ObjectRestrictions object itself + * @param {Restrictions} value the object which describes the Restrictions + * @returns {boolean} true if value given is an ObjectRestrictions object + */ +export function isObjectRestrictions( + value: ObjectRestrictions[string], +): value is ObjectRestrictions { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Function which returns true only if the given argument is an ArrayRestrictions array itself + * @param {Restrictions} value the object which describes the Restrictions + * @returns {boolean} true if value given is an ArrayRestrictions object + */ +export function isArrayRestrictions(value: ObjectRestrictions[string]): value is ArrayRestrictions { + return Array.isArray(value); +} + +export const FormItem: FC = ({ sectionTitle, value, restrictions }) => { + if (isObjectRestrictions(restrictions)) { + return ( + + ); + } + + if (isArrayRestrictions(restrictions)) { + return ( + } + itemsRestrictions={restrictions} + /> + ); + } + + return ( + + ); +}; diff --git a/Configuration/webapp/app/components/form/Widget.tsx b/Configuration/webapp/app/components/form/Widget.tsx index bea689ab0..873113059 100644 --- a/Configuration/webapp/app/components/form/Widget.tsx +++ b/Configuration/webapp/app/components/form/Widget.tsx @@ -12,69 +12,18 @@ * or submit itself to any jurisdiction. */ -import { useState, type FC, type PropsWithChildren, type ReactElement } from 'react'; +import { type FC, type PropsWithChildren, type ReactElement } from 'react'; import FormControlLabel from '@mui/material/FormControlLabel'; import TextField from '@mui/material/TextField'; import Switch from '@mui/material/Switch'; -import { - Form, - isFormRestrictions, - type ArrayRestrictions, - type FormItem, - type WidgetRestrictions, -} from './Form'; -import { Accordion, AccordionDetails, Stack, Typography } from '@mui/material'; -import { AccordionHeader } from './AccordionHeader'; +import { type FormPrimitiveValue, type PrimitiveRestrictions } from './Form'; -interface WidgetProps extends PropsWithChildren { +export interface WidgetProps extends PropsWithChildren { title: string; - type: WidgetRestrictions; - value: unknown; + value: FormPrimitiveValue; + type: PrimitiveRestrictions; } -type ArrayWidgetProps = Omit & { type: ArrayRestrictions }; - -const ArrayWidget = ({ title, type, value }: ArrayWidgetProps): ReactElement => { - const [viewForm, setViewForm] = useState(true); - const items = value as Array; - const [itemsRestrictions] = type; - - return ( - - setViewForm((v) => !v)} - /> - - {viewForm ? ( - - {items.map((item, idx) => - isFormRestrictions(itemsRestrictions[idx]) ? ( - - ) : ( - - ), - )} - - ) : ( - {JSON.stringify(items, null, 2)} - )} - - - ); -}; - export const Widget: FC = ({ title, type, value }): ReactElement => { switch (type) { case 'string': @@ -86,6 +35,6 @@ export const Widget: FC = ({ title, type, value }): ReactElement => } label={title} /> ); default: - return ; + return <>unknown widget type: {type}; } }; diff --git a/Configuration/webapp/app/routes/configuration.tsx b/Configuration/webapp/app/routes/configuration.tsx index 0337b302a..7321e6f4b 100644 --- a/Configuration/webapp/app/routes/configuration.tsx +++ b/Configuration/webapp/app/routes/configuration.tsx @@ -15,7 +15,7 @@ import { useLocation } from 'react-router'; import { useConfigurationQuery } from '~/api/query/useConfigurationQuery'; import { useConfigurationRestrictionsQuery } from '~/api/query/useConfigurationRestrictionsQuery'; -import { Form } from '~/components/form/Form'; +import { FormItem } from '~/components/form/FormItem'; import { Spinner } from '~/ui/spinner'; const ConfigurationPage = () => { @@ -37,10 +37,10 @@ const ConfigurationPage = () => { } return ( - ); }; From e83269fe37610b994f5d5b4f4ce6b18b64b3d755 Mon Sep 17 00:00:00 2001 From: Deaponn Date: Wed, 10 Dec 2025 14:03:32 +0100 Subject: [PATCH 10/11] fix: form not hydrating properly --- Configuration/webapp/app/components/form/Form.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Configuration/webapp/app/components/form/Form.tsx b/Configuration/webapp/app/components/form/Form.tsx index 081ce7d0a..b1dbdfe11 100644 --- a/Configuration/webapp/app/components/form/Form.tsx +++ b/Configuration/webapp/app/components/form/Form.tsx @@ -28,7 +28,6 @@ import { isArrayRestrictions, isObjectRestrictions } from './types/helpers'; import { ObjectWidget } from './components/widgets/ObjectWidget'; import type { Control } from 'react-hook-form'; import type { InputsType } from '~/routes/configuration'; -import { KEY_SEPARATOR } from './constants'; interface FormProps { sectionTitle: string; @@ -50,7 +49,7 @@ export const Form: FC = ({ = ({ } itemsRestrictions={restrictions} control={control} @@ -75,7 +74,7 @@ export const Form: FC = ({ Date: Wed, 10 Dec 2025 15:17:00 +0100 Subject: [PATCH 11/11] fix: refactor how getDefaultValues works with arrays --- .../app/components/form/types/helpers.ts | 22 +++++++++++++- .../utils/getDefaultValuesFromConfigObject.ts | 29 +++++++------------ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/Configuration/webapp/app/components/form/types/helpers.ts b/Configuration/webapp/app/components/form/types/helpers.ts index 643ec3516..1fdc1b2f1 100644 --- a/Configuration/webapp/app/components/form/types/helpers.ts +++ b/Configuration/webapp/app/components/form/types/helpers.ts @@ -12,7 +12,27 @@ * or submit itself to any jurisdiction. */ -import type { ArrayRestrictions, ObjectRestrictions, Restrictions } from '.'; +import type { + ArrayRestrictions, + FormPrimitiveValue, + FormValue, + ObjectRestrictions, + Restrictions, +} from '.'; + +/** + * Function which returns true only if the given argument is a FormPrimitiveValue + * ie. it is not an array and it is not an object + * the leaves of a configuration tree are always strings, numbers or booleans + * @param {FormValue} value the FormValue we want to check for being primitive + * @returns {boolean} true if value given is a FormPrimitiveValue + */ +export function isPrimitiveValue(value: FormValue): value is FormPrimitiveValue { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return true; + } + return false; +} /** * Function which returns true only if the given argument is an ObjectRestrictions object itself diff --git a/Configuration/webapp/app/components/form/utils/getDefaultValuesFromConfigObject.ts b/Configuration/webapp/app/components/form/utils/getDefaultValuesFromConfigObject.ts index 3a2acbb64..ba4c60884 100644 --- a/Configuration/webapp/app/components/form/utils/getDefaultValuesFromConfigObject.ts +++ b/Configuration/webapp/app/components/form/utils/getDefaultValuesFromConfigObject.ts @@ -14,39 +14,32 @@ import { DEFAULT_PREFIX, KEY_SEPARATOR } from '../constants'; import type { FormValue } from '../types'; +import { isPrimitiveValue } from '../types/helpers'; /** * Get the default values from the configuration object. - * @param {FormValue | undefined} obj - The configuration object. + * @param {FormValue} val - The configuration value. * @param {string} prefix - The prefix of the configuration object. * @returns {Record} The default values. */ export const getDefaultValuesFromConfigObject = ( - obj: FormValue | undefined, + val: FormValue | undefined, prefix: string = DEFAULT_PREFIX, ) => { - if (!obj) { + if (val === undefined) { return {}; } - // omit arrays for now - if (Array.isArray(obj)) { - return {}; + + if (isPrimitiveValue(val)) { + return { [prefix]: val }; } + let result: Record = {}; - const entries = Object.entries(obj); + const entries = Object.entries(val); for (const [key, value] of entries) { const newPrefix = `${prefix}${KEY_SEPARATOR}${key}`; - if (typeof value === 'object') { - result = { ...result, ...getDefaultValuesFromConfigObject(value as FormValue, newPrefix) }; - } else { - if (value === 'true') { - result[newPrefix] = true; - } else if (value === 'false') { - result[newPrefix] = false; - } else { - result[newPrefix] = value; - } - } + result = { ...result, ...getDefaultValuesFromConfigObject(value, newPrefix) }; } + return result; };