diff --git a/Configuration/webapp/app/api/query/useConfigurationQuery.ts b/Configuration/webapp/app/api/query/useConfigurationQuery.ts index 1863f8e13..ec1ae9d52 100644 --- a/Configuration/webapp/app/api/query/useConfigurationQuery.ts +++ b/Configuration/webapp/app/api/query/useConfigurationQuery.ts @@ -14,7 +14,7 @@ import { useQuery } from '@tanstack/react-query'; import axiosInstance from '../axiosInstance'; -import type { FormItem } from '~/components/form/Form'; +import type { FormValue } from '~/components/form/types'; export const CONFIGURATION_QUERY_KEY = 'configuration'; @@ -23,6 +23,6 @@ export const useConfigurationQuery = (configuration: string) => queryKey: [CONFIGURATION_QUERY_KEY, configuration], queryFn: async () => axiosInstance - .get(`configurations/${configuration}`) + .get(`configurations/${configuration}`) .then((response) => response.data), }); diff --git a/Configuration/webapp/app/api/query/useConfigurationRestrictionsQuery.ts b/Configuration/webapp/app/api/query/useConfigurationRestrictionsQuery.ts index 8f117d1fd..6bc7bd8c3 100644 --- a/Configuration/webapp/app/api/query/useConfigurationRestrictionsQuery.ts +++ b/Configuration/webapp/app/api/query/useConfigurationRestrictionsQuery.ts @@ -14,7 +14,7 @@ import { useQuery } from '@tanstack/react-query'; import axiosInstance from '../axiosInstance'; -import type { FormRestrictions } from '~/components/form/Form'; +import type { ObjectRestrictions } from '~/components/form/types'; export const CONFIGURATION_RESTRICTIONS_QUERY_KEY = 'configuration-restrictions'; @@ -23,6 +23,6 @@ export const useConfigurationRestrictionsQuery = (configuration: string) => queryKey: [CONFIGURATION_RESTRICTIONS_QUERY_KEY, configuration], queryFn: async () => axiosInstance - .get(`configurations/restrictions/${configuration}`) + .get(`configurations/restrictions/${configuration}`) .then((response) => response.data), }); diff --git a/Configuration/webapp/app/components/form/Form.tsx b/Configuration/webapp/app/components/form/Form.tsx index c10a3302a..b1dbdfe11 100644 --- a/Configuration/webapp/app/components/form/Form.tsx +++ b/Configuration/webapp/app/components/form/Form.tsx @@ -12,102 +12,72 @@ * or submit itself to any jurisdiction. */ -import { useCallback, 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 type { FC } from 'react'; import { Widget } from './components/Widget'; -import { AccordionHeader } from './components/AccordionHeader'; -import { RawViewModal } from './raw-view/RawViewModal'; +import { ArrayWidget } from './components/widgets/ArrayWidget'; +import type { + ArrayRestrictions, + FormArrayValue, + FormObjectValue, + FormPrimitiveValue, + FormValue, + ObjectRestrictions, + Restrictions, +} from './types'; +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'; - -export type FormItem = { [key: string]: string | object | FormItem }; - -export type FormRestrictions = { - [key: string]: 'string' | 'number' | 'boolean' | 'array' | FormRestrictions; -}; +import type { InputsType } from '~/routes/configuration'; -interface FormProps extends PropsWithChildren { +interface FormProps { sectionTitle: string; sectionPrefix: string; - items: FormItem; - itemsRestrictions: FormRestrictions; + value: FormValue; + restrictions: Restrictions | ObjectRestrictions | ArrayRestrictions; control: Control; } -/** - * 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 - * 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); -} - -/** - * Form component. - * @param {FormProps} props - The props of the form. - * @param {string} props.sectionTitle - The title of the section. - * @param {string} props.sectionPrefix - The prefix of the section. - * @param {FormItem} props.items - The items of the form. - * @param {FormRestrictions} props.itemsRestrictions - The restrictions of the items. - * @param {Control} props.control - The control of the form. - * @returns {ReactElement} The form component. - */ export const Form: FC = ({ sectionTitle, sectionPrefix, - items, - itemsRestrictions, + value, + restrictions, control, }) => { - const [isRawModalOpen, setIsRawModalOpen] = useState(false); + if (isObjectRestrictions(restrictions)) { + return ( + + ); + } - const renderItem = useCallback( - (key: string, value: FormRestrictions[string]) => - isFormRestrictions(value) ? ( -
- ) : ( - - ), - [items, itemsRestrictions], - ); + if (isArrayRestrictions(restrictions)) { + return ( + } + itemsRestrictions={restrictions} + control={control} + /> + ); + } return ( - <> - - setIsRawModalOpen(true)} /> - - - {Object.entries(itemsRestrictions).map(([key, value]) => renderItem(key, value))} - - - - - {isRawModalOpen && ( - setIsRawModalOpen(false)} title={sectionTitle} data={items} /> - )} - + ); }; diff --git a/Configuration/webapp/app/components/form/components/Widget.tsx b/Configuration/webapp/app/components/form/components/Widget.tsx index 17854d1c2..d96b13ce7 100644 --- a/Configuration/webapp/app/components/form/components/Widget.tsx +++ b/Configuration/webapp/app/components/form/components/Widget.tsx @@ -17,12 +17,13 @@ import { useFormState, type Control } from 'react-hook-form'; import type { InputsType } from '~/routes/configuration'; import { FormTextInput } from './widgets/FormTextInput'; import { FormToggleInput } from './widgets/FormToggleInput'; +import type { FormPrimitiveValue, PrimitiveRestrictions } from '../types'; export interface WidgetProps extends PropsWithChildren { sectionPrefix: string; label: string; - type: 'string' | 'number' | 'boolean' | 'array'; - value: unknown; + type: PrimitiveRestrictions; + value: FormPrimitiveValue; control: Control; } @@ -45,7 +46,7 @@ export const Widget: FC = ({ type, ...rest }): ReactElement => { return ; case 'boolean': return ; - case 'array': - return <>array not implemented; // TODO OGUI-1803: add implementation after the decision is made + default: + return <>unknown widget type: {JSON.stringify(type)}; } }; diff --git a/Configuration/webapp/app/components/form/components/widgets/ArrayWidget.tsx b/Configuration/webapp/app/components/form/components/widgets/ArrayWidget.tsx new file mode 100644 index 000000000..bbb37c9be --- /dev/null +++ b/Configuration/webapp/app/components/form/components/widgets/ArrayWidget.tsx @@ -0,0 +1,68 @@ +/** + * @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 { Accordion, AccordionDetails, Stack } from '@mui/material'; +import { AccordionHeader } from '../AccordionHeader'; +import type { ArrayRestrictions, FormArrayValue } from '../../types'; +import { RawViewModal } from '../../raw-view/RawViewModal'; +import { Form } from '../../Form'; +import type { Control } from 'react-hook-form'; +import type { InputsType } from '~/routes/configuration'; +import { KEY_SEPARATOR } from '../../constants'; + +interface ArrayWidgetProps { + sectionTitle: string; + sectionPrefix: string; + items: FormArrayValue; + itemsRestrictions: ArrayRestrictions; + control: Control; +} + +export const ArrayWidget = ({ + sectionTitle, + sectionPrefix, + items, + itemsRestrictions, + control, +}: ArrayWidgetProps): ReactElement => { + const [isRawModalOpen, setIsRawModalOpen] = useState(false); + const [arrayRestrictions] = itemsRestrictions; // [arrayRestrictions, objectBlueprint, arrayBlueprint] + + return ( + <> + + setIsRawModalOpen(true)} /> + + + {items.map((item, idx) => ( + + ))} + + + + + {isRawModalOpen && ( + setIsRawModalOpen(false)} title={sectionTitle} data={items} /> + )} + + ); +}; diff --git a/Configuration/webapp/app/components/form/components/widgets/ObjectWidget.tsx b/Configuration/webapp/app/components/form/components/widgets/ObjectWidget.tsx new file mode 100644 index 000000000..926570e44 --- /dev/null +++ b/Configuration/webapp/app/components/form/components/widgets/ObjectWidget.tsx @@ -0,0 +1,79 @@ +/** + * @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 FC, type PropsWithChildren } from 'react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import Stack from '@mui/material/Stack'; +import { AccordionHeader } from '../AccordionHeader'; +import { RawViewModal } from '../../raw-view/RawViewModal'; +import type { Control } from 'react-hook-form'; +import { type InputsType } from '~/routes/configuration'; +import type { FormObjectValue, ObjectRestrictions } from '../../types'; +import { KEY_SEPARATOR } from '../../constants'; +import { Form } from '../../Form'; + +interface ObjectWidgetProps extends PropsWithChildren { + sectionTitle: string; + sectionPrefix: string; + items: FormObjectValue; + itemsRestrictions: ObjectRestrictions; + control: Control; +} + +/** + * Form component. + * @param {ObjectWidgetProps} props - The props of the form. + * @param {string} props.sectionTitle - The title of the section. + * @param {string} props.sectionPrefix - The prefix of the section. + * @param {FormItem} props.items - The items of the form. + * @param {FormRestrictions} props.itemsRestrictions - The restrictions of the items. + * @param {Control} props.control - The control of the form. + * @returns {ReactElement} The form component. + */ +export const ObjectWidget: FC = ({ + sectionTitle, + sectionPrefix, + items, + itemsRestrictions, + control, +}) => { + const [isRawModalOpen, setIsRawModalOpen] = useState(false); + + return ( + <> + + setIsRawModalOpen(true)} /> + + + {Object.entries(items).map(([key, value]) => ( + + ))} + + + + + {isRawModalOpen && ( + setIsRawModalOpen(false)} title={sectionTitle} data={items} /> + )} + + ); +}; diff --git a/Configuration/webapp/app/components/form/types/helpers.ts b/Configuration/webapp/app/components/form/types/helpers.ts new file mode 100644 index 000000000..1fdc1b2f1 --- /dev/null +++ b/Configuration/webapp/app/components/form/types/helpers.ts @@ -0,0 +1,53 @@ +/** + * @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 { + 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 + * @param {Restrictions} value the object which describes the Restrictions + * @returns {boolean} true if value given is an ObjectRestrictions object + */ +export function isObjectRestrictions(value: Restrictions): 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: Restrictions): value is ArrayRestrictions { + return Array.isArray(value); +} diff --git a/Configuration/webapp/app/components/form/types/index.d.ts b/Configuration/webapp/app/components/form/types/index.d.ts new file mode 100644 index 000000000..536e3cdb1 --- /dev/null +++ b/Configuration/webapp/app/components/form/types/index.d.ts @@ -0,0 +1,35 @@ +/** + * @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. + */ + +export type FormValue = FormPrimitiveValue | FormArrayValue | FormObjectValue; + +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, + ObjectRestrictions | null, // Restrictions for an object directly in the array + ArrayRestrictions | null, // ArrayRestrictions for a directly nested array +]; + +export type ObjectRestrictions = { + [key: string]: Restrictions; +}; + +export type Restrictions = PrimitiveRestrictions | ArrayRestrictions | ObjectRestrictions; diff --git a/Configuration/webapp/app/components/form/utils/getDefaultValuesFromConfigObject.ts b/Configuration/webapp/app/components/form/utils/getDefaultValuesFromConfigObject.ts index ac9a731ab..ba4c60884 100644 --- a/Configuration/webapp/app/components/form/utils/getDefaultValuesFromConfigObject.ts +++ b/Configuration/webapp/app/components/form/utils/getDefaultValuesFromConfigObject.ts @@ -13,40 +13,33 @@ */ import { DEFAULT_PREFIX, KEY_SEPARATOR } from '../constants'; -import type { FormItem } from '../Form'; +import type { FormValue } from '../types'; +import { isPrimitiveValue } from '../types/helpers'; /** * Get the default values from the configuration object. - * @param {FormItem | 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: FormItem | 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 FormItem, 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; }; diff --git a/Configuration/webapp/app/components/form/widgets/FormNumberInput.tsx b/Configuration/webapp/app/components/form/widgets/FormNumberInput.tsx deleted file mode 100644 index 97cea7fe3..000000000 --- a/Configuration/webapp/app/components/form/widgets/FormNumberInput.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @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. - */ - -interface FormNumberInputProps extends Omit { - isDirty: boolean; -} - -import { Controller } from 'react-hook-form'; -import type { WidgetProps } from '../components/Widget'; -import { TextField } from '@mui/material'; -import type { ReactElement } from 'react'; - -/** - * Number input widget for the form. - * @param {FormNumberInputProps} props - The props of the widget. - * @param {string} props.sectionPrefix - The prefix of the section. - * @param {string} props.label - The label of the widget. - * @param {Control} props.control - The control of the widget. - * @param {boolean} props.isDirty - Whether the widget is dirty. - * @returns {ReactElement} The number input widget. - */ -export const FormNumberInput = ({ - sectionPrefix, - label, - control, - isDirty, -}: FormNumberInputProps): ReactElement => ( - ( - - )} - /> -); diff --git a/Configuration/webapp/app/components/form/widgets/FormTextInput.tsx b/Configuration/webapp/app/components/form/widgets/FormTextInput.tsx deleted file mode 100644 index 0e3e2acb0..000000000 --- a/Configuration/webapp/app/components/form/widgets/FormTextInput.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @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 { TextField } from '@mui/material'; -import { type ReactElement } from 'react'; -import { Controller } from 'react-hook-form'; -import type { WidgetProps } from '../components/Widget'; - -interface FormTextInputProps extends Omit { - isDirty: boolean; -} - -/** - * Text input widget for the form. - * @param {FormTextInputProps} props - The props of the widget. - * @param {string} props.sectionTitle - The section title of the widget. - * @param {string} props.label - The title of the widget. - * @param {Control} props.control - The control of the widget. - * @returns {ReactElement} The text input widget. - */ -export const FormTextInput = ({ - sectionPrefix, - label, - control, - isDirty, -}: FormTextInputProps): ReactElement => ( - ( - - )} - /> -); diff --git a/Configuration/webapp/app/components/form/widgets/FormToggleInput.tsx b/Configuration/webapp/app/components/form/widgets/FormToggleInput.tsx deleted file mode 100644 index 3bc3f62ea..000000000 --- a/Configuration/webapp/app/components/form/widgets/FormToggleInput.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @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 { FormControlLabel, styled, Switch, switchClasses } from '@mui/material'; -import { Controller } from 'react-hook-form'; -import type { ReactElement } from 'react'; -import type { WidgetProps } from '../components/Widget'; - -interface FormToggleInputProps extends Omit { - isDirty: boolean; -} - -const StyledSwitch = styled(Switch)<{ isDirty: boolean }>( - ({ isDirty, theme }) => ` - & .${switchClasses.switchBase} { - ${isDirty ? `color: ${theme.palette.secondary.main}` : ''} - } - & .${switchClasses.track} { - ${isDirty ? `background-color: ${theme.palette.secondary.main}` : ''} - } -`, -); - -export const FormToggleInput = ({ - sectionPrefix, - label, - control, - isDirty, -}: FormToggleInputProps): ReactElement => ( - ( - - } - label={label} - /> - )} - /> -); diff --git a/Configuration/webapp/app/components/layout/content/Content.tsx b/Configuration/webapp/app/components/layout/content/Content.tsx index 7d0e3ebd7..037e1e4ad 100644 --- a/Configuration/webapp/app/components/layout/content/Content.tsx +++ b/Configuration/webapp/app/components/layout/content/Content.tsx @@ -33,6 +33,8 @@ export const Content: FC = ({ children }) => { = ({ children }) => { className="content-section" > - - {children} - + {children} ); }; diff --git a/Configuration/webapp/app/routes/configuration.tsx b/Configuration/webapp/app/routes/configuration.tsx index 844a1d382..8c91cd2ae 100644 --- a/Configuration/webapp/app/routes/configuration.tsx +++ b/Configuration/webapp/app/routes/configuration.tsx @@ -55,6 +55,8 @@ const ConfigurationPage = () => { console.log(data); // eslint-disable-next-line no-console console.log(getValues()); + // eslint-disable-next-line no-console + console.log({ defaultValues }); }; useEffect(() => () => reset(defaultValues), [defaultValues]); @@ -74,8 +76,8 @@ const ConfigurationPage = () => { control={control} sectionTitle={DEFAULT_PREFIX} sectionPrefix={DEFAULT_PREFIX} - items={configuration} - itemsRestrictions={configurationRestrictions} + value={configuration} + restrictions={configurationRestrictions} /> void handleSubmit(onSubmit)()} disabled={!isDirty} /> diff --git a/Control/lib/adapters/QCConfigurationAdapter.js b/Control/lib/adapters/QCConfigurationAdapter.js index a43fcec85..ac2cf57de 100644 --- a/Control/lib/adapters/QCConfigurationAdapter.js +++ b/Control/lib/adapters/QCConfigurationAdapter.js @@ -19,39 +19,266 @@ const { LogManager } = require('@aliceo2/web-ui'); */ class QCConfigurationAdapter { /** - * Derive type of value for every key-val pair - * of given configuration object + * 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 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' }, + * ] + * + * The ArrayRestrictions will be: + * [ + * [ + * { name: 'string', strength: 'number', isActive: 'boolean' }, + * { name: 'string', strength: 'number', isActive: 'string' } + * ], + * { 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 [[], null, null]; + } + + /** + * 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) => { + 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' || 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}` + ); + + return 'unknown'; + }; + + /** + * 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) => { + static computeObjectRestrictions = (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))); + if (!QCConfigurationAdapter.isObject(value)) { return restrictions; } + Object.entries(value).forEach( + ([key, val]) => (restrictions[key] = QCConfigurationAdapter.deriveValueType(val)) + ); return restrictions; }; /** - * 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 + * 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 + * - 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 */ - 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 (value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false') { - return 'boolean'; + static computeArrayRestrictions = (array) => { + if (array.length === 0) { + return QCConfigurationAdapter.emptyArrayRestrictions; } - if (!Number.isNaN(Number(value))) { 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}`); - return 'unknown'; + + 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++) { + const currentItemRestrictions = QCConfigurationAdapter.deriveValueType(array[i]); + itemsRestrictions.push(currentItemRestrictions); + + if (QCConfigurationAdapter.isPrimitive(currentItemRestrictions)) { continue; } // skip primitives + + if (QCConfigurationAdapter.isObject(currentItemRestrictions)) { + innerObjectBlueprint = QCConfigurationAdapter.getRestrictionsIntersection( + innerObjectBlueprint, + currentItemRestrictions + ); + } + + if (Array.isArray(currentItemRestrictions)) { + innerArrayBlueprint = QCConfigurationAdapter.getRestrictionsIntersection( + innerArrayBlueprint, + currentItemRestrictions + ); + } + } + + return [itemsRestrictions, innerObjectBlueprint, innerArrayBlueprint]; + }; + + /** + * Function which finds maximum intersection for two different Restrictions + * only if they describe objects and it returns null otherwise + * @param {Restrictions} first + * @param {Restrictions} second + */ + 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; + } + + if (QCConfigurationAdapter.arrayIntersectionCondition(first, second)) { + // intersection of two ArrayRestrictions objects + return QCConfigurationAdapter.getArrayRestrictionsIntersection(first, second); + } + + if (QCConfigurationAdapter.objectIntersectionCondition(first, second)) { + return QCConfigurationAdapter.getObjectRestrictionsIntersection(first, second); + } + + 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; } + + 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; + } + + /** + * 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); + } + + /** + * 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; + } + + /** + * 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/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 272733c04..9098f14c3 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 the 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,11 +62,25 @@ * } * }, * dataSources: [ + * [ + * { + * name: 'string', + * path: 'string', + * active: 'boolean', + * count: 'number' + * }, + * { + * name: 'string', + * path: 'string', + * active: 'boolean' + * } + * ], * { * name: 'string', * path: 'string', * active: 'boolean' - * } + * }, + * null * ] * } * ``` @@ -74,8 +88,34 @@ /** * 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 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 null if source array contains no objects + * - 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 value at index 2 + * + * Example ArrayRestrictions object: + * [ + * [ + * { 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 + * ], + * { 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 3cfa1cba8..d0a8ace3b 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,537 @@ */ const assert = require('assert'); +const sinon = require('sinon'); +const QCConfigurationAdapter = require('../../../lib/adapters/QCConfigurationAdapter.js'); +const { LogManager } = require('@aliceo2/web-ui'); const { - computeRestrictions, -} = require('../../../lib/adapters/QCConfigurationAdapter.js'); + computeObjectRestrictions, + computeArrayRestrictions, + deriveValueType, + getRestrictionsIntersection, + getArrayRestrictionsIntersection, + getObjectRestrictionsIntersection, + isPrimitive, + isObject, + bothArePrimitive, + arrayIntersectionCondition, + objectIntersectionCondition, +} = QCConfigurationAdapter; describe(`'QCConfigurationAdapter' test suite`, () => { - 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: 'array', - 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(''), {}); - }) + describe('test computeObjectRestrictions function', () => { + it('should work for minimal input', () => { + const configuration = {}; + const restrictions = {}; + + assert.deepStrictEqual(computeObjectRestrictions(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(computeObjectRestrictions(configuration), expected); + }); + + 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' }], {}, null], + key4: 'boolean', + key5: { key1: 'string', key2: 'boolean' }, + }; + + assert.deepStrictEqual(computeObjectRestrictions(configuration), restrictions); + }); + + it('should not throw for bad input', () => { + assert.deepStrictEqual(computeObjectRestrictions(), {}); + assert.deepStrictEqual(computeObjectRestrictions(undefined), {}); + assert.deepStrictEqual(computeObjectRestrictions(null), {}); + assert.deepStrictEqual(computeObjectRestrictions(0), {}); + assert.deepStrictEqual(computeObjectRestrictions(''), {}); + assert.deepStrictEqual( + computeObjectRestrictions([{ wrong: 'array input' }]), + {} + ); + }); + }); + + 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'); + 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 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 computeObjectRestrictionsSpy = sinon.spy( + QCConfigurationAdapter, + 'computeObjectRestrictions' + ); + // for object arguments, the computeObjectRestrictions function should be called + deriveValueType(0); + assert.equal(computeObjectRestrictionsSpy.notCalled, true); + deriveValueType({}); + assert.equal(computeObjectRestrictionsSpy.calledOnce, true); + computeObjectRestrictionsSpy.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.notCalled, true); + deriveValueType([]); + assert.equal(computeArrayRestrictionsSpy.calledOnce, true); + computeArrayRestrictionsSpy.restore(); + }); + }); + + describe('test computeArrayRestrictions function', () => { + it('should return base case for empty array', () => { + 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' }; + + 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', () => { + 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'], null, null] } + ], + { type: 'string', id: 'number' }, + null + ]; + + assert.deepStrictEqual(computeArrayRestrictions(inputArray), expectedRestrictions); + }); + + it('should properly intersect the blueprint value for nested arrays', () => { + const inputArray = [ + { type: 'flp', active: false }, + 'ignored-for-type-and-array-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' }, null], + [[{ id: 'number', active: 'string' }], { id: 'number', active: 'string' }, null], + ], + { type: 'string' }, + [[], { id: 'number' }, null], + ]; + + 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'}, + 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], + null, // null because bigArray does not contain any objects directly + [[], { name: 'string' }, null], + ]; + assert.deepStrictEqual(computeArrayRestrictions(bigArray), bigArrayRestrictions); + }); + + 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 // 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' }], + { 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 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]] + // 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' }, 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); + assert.deepStrictEqual(computeArrayRestrictions(innerArray3), innerArray3Restrictions); + assert.deepStrictEqual(computeArrayRestrictions(firstArray), firstArrayRestrictions); + assert.deepStrictEqual(computeArrayRestrictions(secondArray), secondArrayRestrictions); + assert.deepStrictEqual(computeArrayRestrictions(bigArray), bigArrayRestrictions); + }); + }); + + describe('test getRestrictionsIntersection', () => { + 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); + 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('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] }; + + 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] }); + 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", active: "boolean" }, null] + }; + + const expected = { list: [[], { 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: {} }); + }); + + }); + + 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); + }); + }); });