From 3e340268a53bee5e64b8964d8ac775c9ce334460 Mon Sep 17 00:00:00 2001 From: Josh Howenstine Date: Tue, 7 Nov 2023 10:31:43 -0800 Subject: [PATCH] fix: withThemeStyles order refactor (#388) * fix: Update rules for withThemeStyles merge order * fix: add deprecation notice for styleConfig. Add comments * fix: small code linting issue * test: add unit tests * test: write unit tests for withThemeStyles utils * test: add more unit tests to withThemeStyles utils * test: fix withThemeStyles util tests * test: fix unit tests for withThemeStyles utils * test: update unit tests for withThemeStyles utils * fix: bug in withThemeStyles utils * fix: update linting errors * feat: add ability for themes to interact with component props * fix: update deep merge to accomidate styles for componentConfig sub properties * fix: updates ability to set props from componentConfig and adds unit tests * fix: reference to componentLevelStyle * fix: bug in generateStyle * fix: add support for custom modes and tones * fix: add custom mode support * fix: remove commented code * fix: update formatting * fix: update snapshots --- .../__snapshots__/Keyboard.test.js.snap | 4 +- .../mixins/withThemeStyles/StyleManager.js | 25 +- .../src/mixins/withThemeStyles/index.js | 26 +- .../src/mixins/withThemeStyles/utils.js | 778 +++++--- .../src/mixins/withThemeStyles/utils.test.js | 1700 +++++++++++++++-- .../withThemeStyles/withThemeStyles.test.js | 42 +- .../src/mixins/withUpdates/index.js | 14 +- 7 files changed, 2176 insertions(+), 413 deletions(-) diff --git a/packages/@lightningjs/ui-components/src/components/Keyboard/__snapshots__/Keyboard.test.js.snap b/packages/@lightningjs/ui-components/src/components/Keyboard/__snapshots__/Keyboard.test.js.snap index 75798460d..2b9298ca0 100644 --- a/packages/@lightningjs/ui-components/src/components/Keyboard/__snapshots__/Keyboard.test.js.snap +++ b/packages/@lightningjs/ui-components/src/components/Keyboard/__snapshots__/Keyboard.test.js.snap @@ -23681,7 +23681,7 @@ exports[`KeyboardInput renders 1`] = ` "visible": true, "w": 1920, "x": 0, - "y": 112, + "y": 202, "zIndex": 0, }, }, @@ -23690,7 +23690,7 @@ exports[`KeyboardInput renders 1`] = ` "enabled": true, "flex": false, "flexItem": false, - "h": 112, + "h": 202, "isComponent": undefined, "mount": 0, "mountX": 0, diff --git a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/StyleManager.js b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/StyleManager.js index 9e91081c4..7786265aa 100644 --- a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/StyleManager.js +++ b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/StyleManager.js @@ -18,6 +18,7 @@ import { generateComponentStyleSource, + getStyleChainMemoized, generateStyle, getHash } from './utils.js'; @@ -42,6 +43,7 @@ export default class StyleManager extends lng.EventEmitter { this.component = component; this.setupListeners(); this._style = {}; // This will be the source of truth for the style manager + this._props = {}; // props that are set in componentConfig will be stored here // Initial update is not debounced this.update(); } @@ -199,7 +201,17 @@ export default class StyleManager extends lng.EventEmitter { if (!styleSource) { // Style source does not exist so it will need to be generated. We attempt to run this function only when necessary for optimal performance - styleSource = generateComponentStyleSource(this.component); + styleSource = generateComponentStyleSource({ + alias: this.component.constructor.aliasStyles, + componentConfig: this.component._componentConfig, + inlineStyle: this.component._componentLevelStyle, + name: + this.component.constructor.__componentName || + this.component.constructor.name, + styleChain: getStyleChainMemoized(this.component), + theme: this.component.theme + }); + this._addCache('styleSource', styleSource); } @@ -209,9 +221,10 @@ export default class StyleManager extends lng.EventEmitter { if (!style) { // Style does not exist so will also need to be generated style = generateStyle(this.component, styleSource); - this._addCache(`style_${mode}_${tone}`, style); } + this._props = style.props; + delete style.props; this._style = style; this.emit('styleUpdate', this.style); } catch (error) { @@ -230,6 +243,14 @@ export default class StyleManager extends lng.EventEmitter { return this._style; } + set props(v) { + context.warn('styleManager: Cannot mutate props directly'); + } + + get props() { + return this._props; + } + /** * Simple check to see if this component can leverage caching. Components using .style cannot use the cache at this time */ diff --git a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/index.js b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/index.js index 31e06b47e..1ce922bce 100644 --- a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/index.js +++ b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/index.js @@ -21,7 +21,7 @@ import { updateManager } from '../../globals'; import { context } from '../../globals'; import { getComponentConfig, getSubTheme } from './utils'; import { capitalizeFirstLetter } from '../../utils'; - +import utils from '../../utils'; /** * A higher-order function that returns a class with theme styles. * @param {function} Base - The base class to extend. @@ -43,8 +43,11 @@ export default function withThemeStyles(Base, mixinStyle = {}) { this._styleManager = new StyleManager({ component: this }); this._style = this._styleManager.style; // Set the style for the first time. After this is will be updated by events + + this._updatePropDefaults(); this._styleManager.on('styleUpdate', () => { this._style = this._styleManager.style; + this._updatePropDefaults(); this.queueThemeUpdate(); }); this._withThemeStylesSetupComplete = true; @@ -67,6 +70,27 @@ export default function withThemeStyles(Base, mixinStyle = {}) { } } + _updatePropDefaults() { + // Add support for properties passed through the theme + const componentConfigProps = this._styleManager.props || {}; + if ( + Object.keys(componentConfigProps).length && + this.constructor.properties && + this.constructor.properties.length + ) { + Object.keys(componentConfigProps).forEach(key => { + if (this.constructor.properties.includes(key)) { + this[`_${key}`] = + typeof this[`_${key}`] === 'object' && + this[`_${key}`] !== null && + !Array.isArray(this[`_${key}`]) + ? utils.clone(this[`_${key}`] || {}, componentConfigProps[key]) + : componentConfigProps[key]; + } + }); + } + } + /** * On component attach, ensures the StyleManager has been reinitialized if it was previously destroyed in detach. * @private diff --git a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/utils.js b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/utils.js index c24e07e2c..309b0236b 100644 --- a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/utils.js +++ b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/utils.js @@ -17,8 +17,9 @@ */ import { clone, getValFromObjPath, getHexColor } from '../../utils'; -import context from '../../globals/context'; +import log from '../../globals/context/logger'; +const CORE_STYLE_PROPS = ['base', 'tone', 'mode', 'style', 'styleConfig']; /** Given a character, return its ASCII value multiplied by its position. * @@ -30,6 +31,31 @@ export const getCharacterValue = (char, index) => { return char.charCodeAt(0) * (index + 1); }; +/** + * Recursively sorts an object by its keys. If an object has nested objects as values, + * it will sort those nested objects as well. + * + * @param {Object} obj - The object to be sorted. + * @returns {Object} A new object that is a sorted version of the input object. + */ +export const sortObject = obj => { + const sortedObj = {}; + Object.keys(obj) + .sort() + .forEach(key => { + if ( + typeof obj[key] === 'object' && + obj[key] !== null && + !Array.isArray(obj[key]) + ) { + sortedObj[key] = sortObject(obj[key]); // Recursive call for nested objects + } else { + sortedObj[key] = obj[key]; + } + }); + return sortedObj; +}; + /** Given an object, return a sum of the ASCII values of all characters in its JSON stringified representation, each multiplied by its position. @@ -38,7 +64,8 @@ JSON stringified representation, each multiplied by its position. @returns {number} - The sum of ASCII values, each multiplied by its position. */ export const getCharacterSum = obj => { - const str = JSON.stringify(obj); + const sortedObj = sortObject(obj); + const str = JSON.stringify(sortedObj).replace(/[{}:",\s]/g, ''); // Remove brackets, colons, and whitespace let sum = 0; for (let i = 0; i < str.length; i++) { sum += getCharacterValue(str[i], i); @@ -59,32 +86,50 @@ export const getHash = obj => { return str.length + '-' + getCharacterSum(obj); }; -export function executeWithContext(objOrFunction, theme) { +/** + * Recursively executes functions within an object or array structure, passing them a given context. + * @param {Function|Object|Array} objOrFunction - The object, array, or function to process. + * @param {*} theme - The context to pass to any encountered functions. + * @returns {*} The processed structure with functions executed. + */ +export function executeWithContextRecursive(objOrFunction, theme) { if (typeof objOrFunction === 'function') { - // If the input is a function, execute it with the context.theme as a parameter - return objOrFunction(theme); - } else if (typeof objOrFunction === 'object') { - // If the input is an object, you can perform other operations here if needed. - // For now, let's just return the input object. - return objOrFunction; + // If the input is a function, execute it with the theme as a parameter + const result = objOrFunction(theme); + return executeWithContextRecursive(result, theme); + } else if (Array.isArray(objOrFunction)) { + // If the input is an array, iterate through its elements and apply the function recursively. + return objOrFunction.map(item => executeWithContextRecursive(item, theme)); + } else if (typeof objOrFunction === 'object' && objOrFunction !== null) { + // If the input is an object (and not null), iterate through its properties and apply the function recursively. + const result = {}; + for (const key in objOrFunction) { + if (objOrFunction.hasOwnProperty(key)) { + result[key] = executeWithContextRecursive(objOrFunction[key], theme); + } + } + return result; } else { - return {}; + // Return the value as is if it's neither a function, an object, nor an array. + return objOrFunction; } } + /** * Checks if a value is a plain object. * * @param {*} value - The value to check. * @returns {boolean} - True if the value is a plain object, false otherwise. */ -function isPlainObject(value) { +export function isPlainObject(value) { return ( typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof RegExp) && - !(value instanceof Function) + !(value instanceof Function) && + !(value instanceof Error) ); } @@ -95,12 +140,10 @@ function isPlainObject(value) { * @returns {(String | Undefined)} - The value of the subTheme property, or undefined if none exists. */ export const getSubTheme = obj => { - if (obj.subTheme) return obj.subTheme; - let parent = obj.p; - while (parent && !parent.subTheme) { - parent = parent.parent; + while (obj && (!obj.subTheme || typeof obj.subTheme !== 'string')) { + obj = obj.p; } - return parent && parent.subTheme; + return obj ? obj.subTheme : undefined; }; /** @@ -113,7 +156,8 @@ export const getComponentConfig = obj => { if (!isPlainObject(obj)) return {}; const prototypeChain = getPrototypeChain(obj); - if (!prototypeChain) { + + if (!prototypeChain.length) { return obj?.theme?.componentConfig?.[obj.constructor.__componentName] || {}; } @@ -134,12 +178,15 @@ export const getPrototypeChain = obj => { const prototypeChain = new Set(); let proto = obj; + if (obj.constructor && obj.constructor.__componentName) { + prototypeChain.add(obj.constructor.__componentName); + } + /** * Traverse the prototype chain and add component names to the set */ do { proto = Object.getPrototypeOf(proto); - if (proto !== null && typeof proto === 'object') { // Add only components that support theming if (proto.constructor.__componentName) { @@ -153,147 +200,287 @@ export const getPrototypeChain = obj => { }; /** - * Recursively removes empty objects from the provided object. + * Recursively removes properties from an object that are themselves empty objects. + * Does not remove arrays, non-plain objects, or non-empty objects. * - * @param {object} obj - The object from which to remove empty objects. - * @returns {object} - The object with empty objects removed. + * @param {Object} obj - The object to clean of empty objects. + * @returns {Object} The cleaned object. */ -function removeEmptyObjects(obj) { +export function removeEmptyObjects(obj) { for (const key in obj) { - if ( - obj.hasOwnProperty(key) && - typeof obj[key] === 'object' && - obj[key] !== null - ) { - removeEmptyObjects(obj[key]); + if (obj.hasOwnProperty(key) && isPlainObject(obj[key])) { + removeEmptyObjects(obj[key]); // Recurse into the object + // After recursion, if the object is empty, delete it from the parent if (Object.keys(obj[key]).length === 0) { delete obj[key]; } } } - if (Object.keys(obj).length === 0) { - return; // Exit if the current object is empty - } - return obj; + return obj; // Always return obj, even if it's empty } -export function styleFormatter(obj, target, search) { - // Check if obj is an object and not null - if (obj === null || typeof obj !== 'object') { - return []; - } +// This map will store hashes of objects to detect duplicates. - // Check if target is a string - if (typeof target !== 'string') { - return []; - } +export function createSharedReferences(obj = {}) { + const seenObjects = new Map(); - // Check if search is a string - if (typeof search !== 'string') { - return []; + // Generates a hash for an object. + // Sorting keys ensures consistent hash regardless of property order. + function hash(object) { + return JSON.stringify(object, Object.keys(object).sort()); } - // Attempt to find the property of 'target' in obj - if (obj.hasOwnProperty(target)) { - const targetObj = obj[target]; - - // Check if targetObj is an object, not null, and has keys - if ( - targetObj !== null && - typeof targetObj === 'object' && - Object.keys(targetObj).length > 0 - ) { - // Check each value in targetObj - for (const key in targetObj) { - if (targetObj.hasOwnProperty(key)) { - const value = targetObj[key]; - - // Check if the value is an object that has a key of search - if ( - typeof value === 'object' && - value !== null && - value.hasOwnProperty(search) - ) { - const nestedObj = value[search]; - - // Check if the nestedObj is an object that also has keys - if ( - typeof nestedObj === 'object' && - Object.keys(nestedObj).length > 0 - ) { - return [nestedObj, `${target}.${key}.${search}`]; - } + function process(currentObj) { + for (const key in currentObj) { + if (currentObj.hasOwnProperty(key)) { + const value = currentObj[key]; + if (typeof value === 'object' && value !== null) { + // Ensure it's an object + const valueHash = hash(value); + if (seenObjects.has(valueHash)) { + // If we've seen this object before, replace the current reference + // with the original reference. + currentObj[key] = seenObjects.get(valueHash); + } else { + seenObjects.set(valueHash, value); + process(value); // Recursively process this object } } } } } - return []; + + process(obj); + + return obj; } +// TODO: Need to add defaultStyle functionality + /** - * Finds unique property names nested under a specified sub-property within an object. - * @param {object} obj - The object to search. - * @param {string} subPropertyName - The sub-property name to search for. - * @returns {string[]} - An array of unique property names found. + * Combines the provided properties and returns a list of unique properties. + * + * @param {string[]} defaultProps - Default property names. + * @param {Object} additionalProps - Object whose keys are additional property names. + * @param {string[]} subProps - Sub property names. + * @returns {string[]} - Array of unique property names. + */ +export function getUniqueProperties(defaultProps = []) { + if (!Array.isArray(defaultProps)) { + throw new TypeError('Expected defaultProps to be an array of strings.'); + } + + return [...new Set(defaultProps)]; +} + +/** + * Generate the payload by cloning and merging multiple objects. + * + * @param {Object} base - The base object to start with. + * @param {Object} defaultStyle - Default styles provided by the user. + * @param {string} toneItem - The current tone being processed. + * @param {string} modeItem - The current mode being processed. + * @param {Object} tone - Tone configurations. + * @param {Object} mode - Mode configurations. + * @returns {Object} - The merged payload. */ -const findPropertiesBySubProperty = (obj, subPropertyName) => { - // Initialize a Set to store unique property names - const result = new Set(); +export function generatePayload( + base, + defaultStyle, + toneItem, + modeItem, + tone, + mode +) { + let payload = clone(defaultStyle, base); + payload = clone(payload, tone?.[toneItem]); + payload = clone(payload, mode?.[modeItem]); + payload = clone(payload, tone?.[toneItem]?.mode?.[modeItem] || {}); + payload = clone(payload, mode?.[modeItem]?.tone?.[toneItem] || {}); + return payload; +} + +/** + * Recursively searches for and returns all the property keys nested within the specified key in the object. + * + * @param {Object} obj - The object to search through. + * @param {string} keyToFind - The key whose nested keys are to be found. + * @returns {string[]} An array containing all nested property keys under the specified key. + */ +function findNestedKeys(obj, keyToFind) { + const nestedKeys = []; /** - * Recursively traverses the object and extracts property names under the specified sub-property. - * @param {object} obj - The object to traverse. + * Inner function to recursively search for nested keys. + * + * @param {Object} obj - The nested object to search through. */ - function traverse(obj) { - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - if (typeof obj[key] === 'object') { - // If the current key matches the specified sub-property - if (key === subPropertyName) { - // Loop through the sub-object's keys and add them to the result set - for (const subKey in obj[key]) { - if (obj[key].hasOwnProperty(subKey)) { - result.add(subKey); - } - } + function searchNestedKeys(obj) { + if (typeof obj === 'object' && obj !== null) { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + nestedKeys.push(key); // Add the nested key to the array + } + } + } + } + + /** + * Outer function to initiate search when the specified key is found. + * + * @param {Object} obj - The object to search through. + */ + function searchForKey(obj) { + if (typeof obj === 'object' && obj !== null) { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + if (key === keyToFind) { + searchNestedKeys(obj[key]); // Start searching for nested keys + break; // Once the specified key is found, we don't need to look further at this level } - // Continue recursive traversal - traverse(obj[key]); + searchForKey(obj[key]); // Continue searching for the specified key } } } } - // Start traversing the object - traverse(obj); + searchForKey(obj); // Initialize the search with the object + return nestedKeys; // Return the array of nested keys +} - // Convert the Set to an array and return - return Array.from(result); +/** + * Generates a solution based on the provided configurations. + * + * @param {Object} options - The configuration options for generating the solution. + * @param {Object} [options.base={}] - Base object. + * @param {Object} [options.tone={}] - Tone configurations. + * @param {Object} [options.mode={}] - Mode configurations. + * @param {Object} [options.defaultStyle={}] - Default styles provided by the user. + * @returns {Object} - The generated solution with shared references and merged identical properties. + */ +export const generateSolution = ( + { base = {}, tone = {}, mode = {}, defaultStyle = {} }, + modeKeys = [], + toneKeys = [] +) => { + const solution = {}; + + const uniqueModes = getUniqueProperties([ + 'unfocused', + 'focused', + 'disabled', + ...modeKeys + ]); + + const uniqueTones = getUniqueProperties([ + 'neutral', + 'inverse', + 'brand', + ...toneKeys + ]); + + for (const modeItem of uniqueModes) { + for (const toneItem of uniqueTones) { + const payload = generatePayload( + base, + defaultStyle, + toneItem, + modeItem, + tone, + mode + ); + solution[`${modeItem}_${toneItem}`] = payload; + } + } + + return solution; }; +const DEFAULT_KEYS = [ + // NOTE: ORDER MATTERS + 'unfocused_neutral', + 'unfocused_inverse', + 'unfocused_brand', + 'focused_neutral', + 'focused_inverse', + 'focused_brand', + 'disabled_neutral', + 'disabled_inverse', + 'disabled_brand' +]; + +/** + * Enforce a contract on an input object by ensuring that it contains a set of specified keys + * and, if not, substituting them with values from fallback keys in a predefined order. + * + * @param {Object} inputObj - The input object to enforce the contract on. + * @returns {Object} - An object that adheres to the contract, with missing keys replaced by fallback values. + */ +export function enforceContract(inputObj) { + const result = {}; + for (const key of [...DEFAULT_KEYS, ...Object.keys(inputObj)]) { + if (!inputObj.hasOwnProperty(key)) { + // Find the first fallback property that exists in inputObj + const fallbackKey = DEFAULT_KEYS.find(fallback => + inputObj.hasOwnProperty(fallback) + ); + if (fallbackKey) { + const fallback = inputObj[fallbackKey]; + result[key] = typeof fallback !== 'object' ? {} : fallback; + } else { + result[key] = {}; + } + } else { + if (typeof inputObj[key] !== 'object') { + // If the value is not an object, replace it with an empty object + result[key] = {}; + } else { + result[key] = inputObj[key]; + } + } + } + return result; +} + /** * Generates the source style object for a given component by merging base, mode, and tone styles from the component's style chain * @param {object} component - The component for which to generate the style source * @returns {object} - The source style object for the component */ -export const generateComponentStyleSource = component => { - // Initialize the finalStyle object to an empty object - let finalStyle = {}; - const theme = component.theme; - // Check if the provided component is a plain object - if (!isPlainObject(component)) { - return {}; +export const generateComponentStyleSource = ({ + theme = {}, + componentConfig = {}, + styleChain = [], + inlineStyle = {}, + alias = [] +} = {}) => { + if (typeof theme !== 'object') { + throw new Error('Expected theme to be an object'); } - // Get the styleChain for the component - const styleChain = getStyleChainMemoized(component); + if (typeof componentConfig !== 'object') { + throw new Error('Expected componentConfig to be an object'); + } + + if (!Array.isArray(styleChain)) { + throw new Error('Expected styleChain to be an array'); + } + + if (typeof inlineStyle !== 'object') { + throw new Error('Expected inlineStyle to be an object'); + } + + if (!Array.isArray(alias)) { + throw new Error('Expected alias to be an array'); + } + + /** + * Component default styles + */ - // Process all styles in styleChain - styleChain.forEach(({ style }) => { - // Check if the style object does not have specific keys (base, mode, tone, default) + const componentDefault = styleChain.map(({ style }) => { if ( typeof style === 'object' && !style.base && @@ -301,146 +488,153 @@ export const generateComponentStyleSource = component => { !style.tone && !style.default ) { - // Merge the style as a base style - finalStyle = clone(finalStyle, { base: style }); + return { base: style }; } else { - const { base, mode, tone } = style; - // Apply the style at different levels (Base Level: Component Style File) - finalStyle = clone(finalStyle, { base: executeWithContext(base, theme) }); - finalStyle = clone(finalStyle, { tone: executeWithContext(tone, theme) }); - finalStyle = clone(finalStyle, { mode: executeWithContext(mode, theme) }); + const { base = {}, mode = {}, tone = {} } = style; + return { + base, + mode, + tone + }; } }); - // Apply Theme Level styles from ComponentConfig - if (component._componentConfig) { - if (component._componentConfig.styleConfig) { - context.info( - 'style config is deprecated. Please use style = { base: {}, tone: {}, mode: {} }' - ); - finalStyle = clone(finalStyle, component._componentConfig.styleConfig); - } + /** + * ComponentConfig settings + * StyleConfig is deprecated but we will still support it for now + */ + const componentConfigOrigin = + componentConfig?.style || + (componentConfig?.styleConfig && + clone(componentConfig?.style || {}, componentConfig.styleConfig || {})); + + if (!(componentConfig || {}).hasOwnProperty('styleConfig')) { + log.warn( + '[Deprecation Warning]: "styleConfig" will soon be deprecated. Refer to the theming section of the latest documentation for guidance on updates and alternatives.' + ); + } - const componentConfigStyle = component._componentConfig.style; + /** + * DefaultStyle will apply to the next level in the hierarchy + */ + let componentConfigDefaultStyle; + if (componentConfigOrigin) { + const defaultStyle = JSON.parse(JSON.stringify(componentConfigOrigin)); + delete defaultStyle.base; + delete defaultStyle.tone; + delete defaultStyle.mode; + componentConfigDefaultStyle = defaultStyle; // Anything in the root level of style + } - if (componentConfigStyle?.base) { - finalStyle = clone(finalStyle, { - base: componentConfigStyle.base - }); - } + const componentConfigSanitized = { + defaultStyle: componentConfigDefaultStyle || {}, + base: componentConfigOrigin?.base || {}, + mode: componentConfigOrigin?.mode || {}, + tone: componentConfigOrigin?.tone || {} + }; - if (componentConfigStyle) { - const overwrite = JSON.parse(JSON.stringify(componentConfigStyle)); - delete overwrite.base; - delete overwrite.tone; - delete overwrite.mode; - finalStyle = clone(finalStyle, { overwrite }); // Anything in the root level of style - } + /** + * Filters the componentConfig object to retain only those properties that are not core style properties. + * + * @param {Object} componentConfig - The configuration object for components. + * @returns {Object} An object containing only properties to be applied to the component. + */ - if (componentConfigStyle?.tone) { - finalStyle = clone(finalStyle, { - tone: componentConfigStyle.tone - }); - } + const props = Object.entries(componentConfig || {}).reduce( + (acc, [key, value]) => { + if (!CORE_STYLE_PROPS.includes(key)) { + acc[key] = value; + } + return acc; + }, + {} + ); - if (componentConfigStyle?.mode) { - finalStyle = clone(finalStyle, { - mode: componentConfigStyle.mode - }); - } + /** + * Local / Instance level styles + * DefaultStyle will apply to the next level in the hierarchy + */ + let localDefaultStyle; + if (inlineStyle) { + const defaultStyle = JSON.parse(JSON.stringify(inlineStyle)); + delete defaultStyle.base; + delete defaultStyle.tone; + delete defaultStyle.mode; + localDefaultStyle = defaultStyle; // Anything in the root level of style } - // Apply Component Level styles - if (component._componentLevelStyle) { - if (component._componentLevelStyle.styleConfig) { - finalStyle = clone( - finalStyle, - component._componentLevelStyle.styleConfig - ); - } + const local = { + defaultStyle: localDefaultStyle || {}, + base: inlineStyle?.base || {}, + mode: inlineStyle?.mode || {}, + tone: inlineStyle?.tone || {} + }; - const componentStyle = component._componentLevelStyle; + // Merge all the styles together into one array to loop + const merged = [...componentDefault, componentConfigSanitized, local]; - if (componentStyle) { - const overwrite = JSON.parse(JSON.stringify(componentStyle)); - delete overwrite.base; - delete overwrite.tone; - delete overwrite.mode; - finalStyle = clone(finalStyle, { - overwrite - }); - } + // Find all the keys that are nested under mode and tone this will help generate the final solution + const modeKeys = findNestedKeys(merged, 'mode'); + const toneKeys = findNestedKeys(merged, 'tone'); - if (componentStyle?.base) { - finalStyle = clone(finalStyle, { - base: componentStyle.base - }); - } + const solution = merged.reduce((acc, style) => { + const parsed = executeWithContextRecursive(style, theme); + return clone(acc, generateSolution(parsed, modeKeys, toneKeys)); + }, {}); - if (componentStyle?.tone) { - finalStyle = clone(finalStyle, { - tone: componentStyle.tone - }); - } + const final = formatStyleObj( + removeEmptyObjects(colorParser({ theme }, solution)) || {}, + alias + ); - if (componentStyle?.mode) { - finalStyle = clone(finalStyle, { - mode: componentStyle.mode + // Pass properties to final object + if (Object.keys(props).length) { + if (Object.keys(final).length) { + Object.keys(final).forEach(key => { + final[key].props = props; }); + } else { + final['unfocused_neutral'] = { + props + }; } } - // Destructure the finalStyle object - const { base = {}, mode = {}, tone = {}, overwrite = {} } = finalStyle; - - // Create the solution object to store the processed styles - const solution = {}; - const toneProperties = findPropertiesBySubProperty(mode, 'tone'); - const modeProperties = findPropertiesBySubProperty(tone, 'mode'); - - // Iterate through modes and tones to generate styles - for (const modeItem of [ - ...new Set(['unfocused', ...Object.keys(mode), ...modeProperties]) - ]) { - for (const toneItem of [ - ...new Set(['neutral', ...Object.keys(tone), ...toneProperties]) - ]) { - let payload = clone(base, overwrite); - payload = clone(payload, tone[toneItem]); - payload = clone(payload, mode[modeItem]); - payload = clone(payload, tone[toneItem]?.mode?.[modeItem] || {}); - payload = clone(payload, mode[modeItem]?.tone?.[toneItem] || {}); - solution[modeItem + '_' + toneItem] = payload; - } - } - - // Return the final processed style object + const cleanObj = createSharedReferences(final); - return formatStyleObj( - removeEmptyObjects(colorParser(component, solution)) || {}, - component.constructor.aliasStyles - ); + return enforceContract(cleanObj); }; /** * Parse and process a style object to replace theme strings and process color arrays. - * @param {string} component - Lightning Component + * @param {object} targetObject - In most cases, this will be a theme object. * @param {object} styleObj - The input style object to be processed. * @returns {object} The processed style object with theme strings replaced and color arrays processed. */ -export const colorParser = (component, styleObj) => { +export const colorParser = (targetObject, styleObj) => { + // Check if targetObject is an object + if (typeof targetObject !== 'object' || targetObject === null) { + throw new TypeError('targetObject must be an object.'); + } + + // Check if styleObj is an object + if (typeof styleObj !== 'object' || styleObj === null) { + throw new TypeError('styleObj must be an object.'); + } + // Process style object and remove unnecessary properties const processedStyle = JSON.stringify(styleObj, (_, value) => { if (-1 < ['tone', 'mode'].indexOf(_)) return undefined; // Remove any tone/mode or mode/tone properties as they have already been processed if ('string' === typeof value && value.startsWith('theme.')) { // Support theme strings example: theme.radius.md - return getValFromObjPath(component, value); // If no theme value exists, the property will be removed from the object + return getValFromObjPath(targetObject, value); // If no theme value exists, the property will be removed from the object } else if (Array.isArray(value) && value.length === 2) { // Process value as a color ['#663399', 1] return getHexColor(value[0], value[1]); } return value; }); + return JSON.parse(processedStyle || {}); }; @@ -453,18 +647,11 @@ export const colorParser = (component, styleObj) => { export const generateStyle = (component, componentStyleSource = {}) => { if (!isPlainObject(component)) return {}; const { mode = 'unfocused', tone = 'neutral' } = component; - - const style = + return ( componentStyleSource[`${mode}_${tone}`] || - componentStyleSource[`unfocused_${tone}`] || componentStyleSource['unfocused_neutral'] || - {}; - - const componentStyle = component._componentLevelStyle; - if (componentStyle) { - return clone(style, colorParser(component, componentStyle)); - } - return formatStyleObj(style, component.constructor.aliasStyles); + {} + ); }; /** @@ -472,20 +659,18 @@ export const generateStyle = (component, componentStyleSource = {}) => { * @param {object} obj - The object for which to generate the name. * @returns {string} - The generated name. */ -function generateNameFromPrototypeChain(obj) { - // Base case: If the object has no prototype or its prototype is null, return its own constructor name (if available). - if (!Object.getPrototypeOf(obj)) { - return obj.constructor?.name || ''; - } - - // Recursive step: Get the constructor name of the current object and concatenate it with the name generated from the prototype. - const currentName = obj.constructor?.name || ''; - const parentName = generateNameFromPrototypeChain(Object.getPrototypeOf(obj)); - - // Concatenate the names in reverse order (from the top of the prototype chain to the bottom). - return parentName ? `${parentName}.${currentName}` : currentName; +export function generateNameFromPrototypeChain(obj, name = '') { + if (!obj) return name; + const proto = Object.getPrototypeOf(obj); + if (!proto || !proto.constructor) return name; + const componentName = `${name ? name + '.' : ''}${ + proto?.constructor?.__componentName || '' + }` + .replace(/\.*$/, '') + .trim(); + const result = generateNameFromPrototypeChain(proto, componentName); + return result; } - /** * Creates a cache object to store the results of getStyleChainMemoized function calls. * @type {object} @@ -504,6 +689,7 @@ export const getStyleChainMemoized = componentObj => { */ const cacheKey = generateNameFromPrototypeChain(componentObj); + // Check if the result is already in the cache if (styleChainCache[cacheKey]) { return styleChainCache[cacheKey]; @@ -522,57 +708,92 @@ export const getStyleChainMemoized = componentObj => { return styleChain; }; +/** + * Removes duplicate objects from an array based on their content. + * @param {Array} arr - The array of objects to be deduplicated. + * @returns {Array} An array of objects without duplicates. + * @throws {Error} Throws an error if the input is not an array. + */ +export function removeDuplicateObjects(arr) { + if (!Array.isArray(arr)) { + throw new Error('Input should be an array'); + } + + const deepEquals = (a, b) => { + const typeA = typeof a; + const typeB = typeof b; + + if (typeA !== typeB) return false; + + if (typeA !== 'object' || a === null || b === null) { + if (typeA === 'function') { + return a.toString() === b.toString(); + } + return a === b; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) { + if ( + !Object.prototype.hasOwnProperty.call(b, key) || + !deepEquals(a[key], b[key]) + ) + return false; + } + + return true; + }; + + return arr.filter((item, index, self) => { + return index === self.findIndex(t => deepEquals(item, t)); + }); +} + /** * Traverse up the prototype chain to create an array of all the styles that are present in the Components ancestors * @param {object} componentObj - The component object to get the style chain from. * @returns {{ style: (object | function) }[]} - An array of style objects containing either an object of styles or a function to return an object of styles. */ export const getStyleChain = componentObj => { - const styleSet = new Set(); + const styleMap = new Map(); // Use a Map to store styles as JSON strings let proto = componentObj; - + let firstRun = true; do { - proto = Object.getPrototypeOf(proto); + const parent = firstRun ? proto : Object.getPrototypeOf(proto); // The first time the loop runs it should get the style from the current component + firstRun = false; + proto = parent !== Object.prototype ? parent : null; if (proto && proto.constructor) { - // Check if style was passed in as param in .map(style => {to mixin withThemeStyles(MyComponent, {foo: 'bar'}) + // Access the __themeStyle property from the current prototype's constructor + const themeStyle = proto.constructor.__themeStyle; - if ( - proto.constructor.__mixinStyle && - !styleSet.has(proto.constructor.__mixinStyle) - ) { - if ( - typeof proto.constructor.__mixinStyle === 'object' && - Object.keys(proto.constructor.__mixinStyle).length - ) { - styleSet.add(proto.constructor.__mixinStyle); - } else if (typeof proto.constructor.__mixinStyle === 'function') { - styleSet.add(proto.constructor.__mixinStyle); + if (themeStyle) { + if (!styleMap.has(themeStyle)) { + styleMap.set(themeStyle, { style: themeStyle }); } } - // Check if has __themeStyle set - if ( - proto.constructor.__themeStyle && - !styleSet.has(proto.constructor.__themeStyle) - ) { - if ( - typeof proto.constructor.__themeStyle === 'object' && - Object.keys(proto.constructor.__themeStyle).length - ) { - styleSet.add(proto.constructor.__themeStyle); - } else if (typeof proto.constructor.__themeStyle === 'function') { - styleSet.add(proto.constructor.__themeStyle); + // Access the __mixinStyle property from the current prototype's constructor + const mixinStyle = proto.constructor.__mixinStyle; + + if (mixinStyle) { + if (!styleMap.has(mixinStyle)) { + styleMap.set(mixinStyle, { style: mixinStyle }); } } } } while (proto); - // Return an array of style objects - return Array.from(styleSet) - .map(style => ({ - style - })) + // Convert the values of the Map (unique styles) back to an array + const uniqueStyles = Array.from(styleMap.values()); + + // Return an array of unique style objects with a "style" property + return removeDuplicateObjects(uniqueStyles) + .map(style => style) .reverse(); }; @@ -584,6 +805,10 @@ export const getStyleChain = componentObj => { * @returns {object} The formatted style object after applying all formatter functions. */ export const formatStyleObj = (originalObj, aliasStyles = []) => { + if (typeof originalObj !== 'object' || originalObj === null) { + throw new Error('The originalObj parameter must be an object.'); + } + const formatters = new Set(); // Adding a key-value pair to the 'formatters' Set. @@ -610,12 +835,21 @@ export const formatStyleObj = (originalObj, aliasStyles = []) => { * @returns {object} The style object with alias values replaced. */ export const replaceAliasValues = (value, aliasStyles = []) => { + if (typeof value !== 'object' || value === null) { + throw new Error('Value must be an object'); + } + + if (!Array.isArray(aliasStyles)) { + throw new Error('Alias styles must be an array'); + } + let str = JSON.stringify(value); const aliasProps = [ { prev: 'height', curr: 'h', skipWarn: true }, { prev: 'width', curr: 'w', skipWarn: true }, ...(aliasStyles || []) ]; + aliasProps.forEach(alias => { if ( alias && @@ -624,7 +858,7 @@ export const replaceAliasValues = (value, aliasStyles = []) => { ) { !alias.skipWarn && str.search(`"${alias.prev}":`) >= 0 && - console.warn( + log.warn( `The style property "${alias.prev}" is deprecated and will be removed in a future release. Please use "${alias.curr}" instead.` ); str = str.replace( diff --git a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/utils.test.js b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/utils.test.js index 228a46cb6..fec8af730 100644 --- a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/utils.test.js +++ b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/utils.test.js @@ -1,194 +1,1628 @@ -// Mock context.theme -import { expect } from '@jest/globals'; -import context from '../../globals/context'; -// Import the functions to be tested import { + createSharedReferences, + generatePayload, + getCharacterValue, + getCharacterSum, + getHash, + executeWithContextRecursive, + isPlainObject, getSubTheme, getComponentConfig, getPrototypeChain, + getUniqueProperties, + removeEmptyObjects, + removeDuplicateObjects, + // generateSolution, // TODO: Need a test for this + enforceContract, generateComponentStyleSource, generateStyle, + generateNameFromPrototypeChain, + getStyleChainMemoized, getStyleChain, - replaceAliasValues -} from './utils'; // Replace with the correct path + formatStyleObj, + replaceAliasValues, + colorParser +} from './utils'; +import log from '../../globals/context/logger'; -// Sample test data -class GrandParent { - static get __componentName() { - return 'GrandParent'; - } - static get __themeStyle() { - return { - base: { - spacing: 1 +import { jest } from '@jest/globals'; + +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + executeWithContextRecursive: jest.fn(), + clone: jest.fn(), + generateSolution: jest.fn(), + formatStyleObj: jest.fn(), + getValFromObjPath: jest.fn(), + removeEmptyObjects: jest.fn(), + getHexColor: jest.fn(), + colorParser: jest.fn() +})); + +jest.mock('../../globals/context/logger', () => ({ + warn: jest.fn() +})); + +describe('getCharacterValue', () => { + it('should calculate the correct character value for a lowercase letter', () => { + const charValue = getCharacterValue('a', 0); + // ASCII value of 'a' is 97, and index is 0 + // Expected result: 97 * 1 = 97 + expect(charValue).toEqual(97); + }); + + it('should calculate the correct character value for an uppercase letter', () => { + const charValue = getCharacterValue('Z', 2); + // ASCII value of 'Z' is 90, and index is 2 + // Expected result: 90 * 3 = 270 + expect(charValue).toEqual(270); + }); + + it('should calculate the correct character value for a special character', () => { + const charValue = getCharacterValue('@', 5); + // ASCII value of '@' is 64, and index is 5 + // Expected result: 64 * 6 = 384 + expect(charValue).toEqual(384); + }); + + it('should calculate the correct character value for a space character', () => { + const charValue = getCharacterValue(' ', 1); + // ASCII value of space ' ' is 32, and index is 1 + // Expected result: 32 * 2 = 64 + expect(charValue).toEqual(64); + }); + + it('should calculate the correct character value for a digit character', () => { + const charValue = getCharacterValue('7', 3); + // ASCII value of '7' is 55, and index is 3 + // Expected result: 55 * 4 = 220 + expect(charValue).toEqual(220); + }); +}); + +describe('getCharacterSum', () => { + it('should return the expected sum of characters for a given object', () => { + const obj = { + a: 1, + b: 'test' + }; + // 'a1btest' => 97*1 + 49*2 + 98*3 + 116*4 + 101*5 + 115*6 + 116*7 + const expectedSum = + 97 * 1 + 49 * 2 + 98 * 3 + 116 * 4 + 101 * 5 + 115 * 6 + 116 * 7; + expect(getCharacterSum(obj)).toBe(expectedSum); + }); + + it('should handle objects with keys in different orders consistently', () => { + const obj1 = { + a: 1, + b: 'test' + }; + const obj2 = { + b: 'test', + a: 1 + }; + expect(getCharacterSum(obj1)).toBe(getCharacterSum(obj2)); + }); + + it('should work with more complex objects', () => { + const obj = { + foo: 'bar', + baz: 123, + qux: { + quux: 'corge' } }; - } -} -class Parent extends GrandParent { - static get __componentName() { - return 'Parent'; - } - static get __themeStyle() { - return { - base: { - spacing: 2 + // After sorting the object and its nested objects and then removing unwanted characters, + // the expected string would be 'baz123foobarquxquuxcorge' + const expectedSum = + 98 * 1 + + 97 * 2 + + 122 * 3 + + 49 * 4 + + 50 * 5 + + 51 * 6 + + 102 * 7 + + 111 * 8 + + 111 * 9 + + 98 * 10 + + 97 * 11 + + 114 * 12 + + 113 * 13 + + 117 * 14 + + 120 * 15 + + 113 * 16 + + 117 * 17 + + 117 * 18 + + 120 * 19 + + 99 * 20 + + 111 * 21 + + 114 * 22 + + 103 * 23 + + 101 * 24; + + expect(getCharacterSum(obj)).toBe(expectedSum); + }); +}); + +describe('getHash', () => { + it('should return a string', () => { + const result = getHash('test'); + expect(typeof result).toBe('string'); + }); + + it('should return the same hash for the same input', () => { + const input = 'test'; + const result1 = getHash(input); + const result2 = getHash(input); + expect(result1).toBe(result2); + }); + + it('should return different hashes for different inputs', () => { + const input1 = 'test1'; + const input2 = 'test2'; + const result1 = getHash(input1); + const result2 = getHash(input2); + expect(result1).not.toBe(result2); + }); +}); + +describe('executeWithContextRecursive', () => { + it('should execute the callback function with the provided context', () => { + const context = { foo: 'bar' }; + const callback = jest.fn(); + executeWithContextRecursive(callback, context); + expect(callback).toHaveBeenCalledWith(context); + }); + + it('should execute the callback function with the provided context and child contexts', () => { + const context = { foo: 'bar', children: [{ baz: 'qux' }] }; + const callback2 = jest.fn(theme => ({ base2: theme.foo })); + const callback = jest.fn(() => ({ base: callback2 })); + const result = executeWithContextRecursive(callback, context); + expect(callback).toHaveBeenCalledWith(context); + expect(callback2).toHaveBeenCalledWith(context); + expect(callback2).toHaveReturnedWith({ base2: 'bar' }); + expect(result).toEqual({ base: { base2: 'bar' } }); + }); + + it('should execute the callback function with the provided context and nested child contexts', () => { + const context = { + foo: 'bar', + children: [{ baz: 'qux', children: [{ fizz: 'buzz' }] }] + }; + const callback = jest.fn(); + executeWithContextRecursive(callback, context); + expect(callback).toHaveBeenCalledWith(context); + }); +}); + +describe('isPlainObject', () => { + it('should return true for plain objects', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ foo: 'bar' })).toBe(true); + expect(isPlainObject(Object.create(null))).toBe(true); + }); + + it('should return false for non-plain objects', () => { + expect(isPlainObject([])).toBe(false); + expect(isPlainObject(new Date())).toBe(false); + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject(undefined)).toBe(false); + expect(isPlainObject('')).toBe(false); + expect(isPlainObject(42)).toBe(false); + expect(isPlainObject(true)).toBe(false); + expect(isPlainObject(() => {})).toBe(false); + expect(isPlainObject(/foo/)).toBe(false); + expect(isPlainObject(new Error())).toBe(false); + }); +}); + +// Tests for the updated getSubTheme function +describe('getSubTheme', () => { + it('should return the correct sub-theme when it exists and is a string', () => { + const theme = { + subTheme: 'primary', + p: { + subTheme: 'secondary' } }; - } -} -class Child extends Parent { - static get __componentName() { - return 'Child'; - } + expect(getSubTheme(theme)).toBe('primary'); + }); - static get __themeStyle() { - return { - base: { - spacing: 3 + it('should return the parent sub-theme when the main object’s subTheme is not a string', () => { + const theme = { + subTheme: 123, // Not a string + p: { + subTheme: 'secondary', + p: { + subTheme: 'tertiary' + } } }; - } + expect(getSubTheme(theme)).toBe('secondary'); + }); - get theme() { - return context.theme; - } -} + it('should return undefined if neither the object nor its parents have a subTheme of type string', () => { + const theme = { + subTheme: 123, // Not a string + p: { + subTheme: { name: 'not-a-string' }, // Not a string + p: {} + } + }; + expect(getSubTheme(theme)).toBeUndefined(); + }); +}); + +describe('getComponentConfig', () => { + it('should return the component config for a given component name', () => { + const componentName = 'MyComponent'; + const expectedConfig = { + prop1: 'value1', + prop2: 'value2' + }; + + function MyClass() { + const instance = Object.create(MyClass.prototype); + instance.theme = { + componentConfig: { + [componentName]: expectedConfig + } + }; + return instance; + } + + MyClass.__componentName = componentName; + const config = getComponentConfig(new MyClass()); + expect(config).toEqual(expectedConfig); + }); + + it('should return an empty object if the component config is not found', () => { + const componentName = 'MyComponent'; + const expectedConfig = { + prop1: 'value1', + prop2: 'value2' + }; -afterEach(async () => { - await context.setTheme({}); // Replace with your actual asynchronous cleanup tasks + function MyClass() { + const instance = Object.create(MyClass.prototype); + instance.theme = { + componentConfig: { + Foo: expectedConfig + } + }; + return instance; + } + + MyClass.__componentName = componentName; + const config = getComponentConfig(new MyClass()); + expect(config).toEqual({}); + }); }); -const childComponentInstance = new Child(); +describe('getPrototypeChain', () => { + it('should return an empty array if prototypes do not contain __componentName', () => { + const obj = {}; + const chain = getPrototypeChain(obj); + expect(chain).toEqual([]); + }); + + it('should return an array of prototype chain for a given object with custom prototype', () => { + function MyClass() { + const instance = Object.create(MyClass.prototype); + instance.constructor = { + __componentName: 'MyClass' + }; + return instance; + } + + function MyClass2() { + this.prototype = new MyClass(this); + const instance = Object.create(new MyClass(this)); + instance.constructor = { + __componentName: 'MyClass2' + }; + return instance; + } + + const chain = getPrototypeChain(new MyClass2()); + + expect(chain).toEqual(['MyClass2', 'MyClass']); + }); + + it('should return an empty array for a null object', () => { + const obj = null; + const chain = getPrototypeChain(obj); + expect(chain).toEqual([]); + }); -describe('Test Suite for Custom Utils', () => { - describe('getSubTheme', () => { - // Test cases for getSubTheme function - it('should return the subTheme property value of the first parent object with subTheme', () => { - const childObj = { p: { parent: { subTheme: 'mySubTheme' } } }; - expect(getSubTheme(childObj)).toBe('mySubTheme'); + it('should return an empty array for an undefined object', () => { + const obj = undefined; + const chain = getPrototypeChain(obj); + expect(chain).toEqual([]); + }); +}); + +describe('removeEmptyObjects', () => { + it('should remove empty objects from a given object', () => { + const obj = { + a: { + b: {}, + c: 'hello', + d: { + e: {}, + f: 'world' + } + }, + g: {}, + h: 'foo', + i: { + j: {}, + k: 'bar' + } + }; + const expected = { + a: { + c: 'hello', + d: { + f: 'world' + } + }, + h: 'foo', + i: { + k: 'bar' + } + }; + const result = removeEmptyObjects(obj); + expect(result).toEqual(expected); + }); + + it('should return an empty object if given an empty object', () => { + const obj = {}; + const expected = {}; + const result = removeEmptyObjects(obj); + expect(result).toEqual(expected); + }); + + it('should return the same object if no empty objects are present', () => { + const obj = { + a: { + b: { + c: 'hello' + } + }, + d: 'world' + }; + const expected = { + a: { + b: { + c: 'hello' + } + }, + d: 'world' + }; + const result = removeEmptyObjects(obj); + expect(result).toEqual(expected); + }); +}); + +describe('createSharedReferences', () => { + it('should return an empty object if no arguments are passed', () => { + const result = createSharedReferences(); + expect(result).toEqual({}); + }); + + it('should return an object with the same keys as the input object', () => { + const input = { + a: 1, + b: 2, + c: 3 + }; + const result = createSharedReferences(input); + expect(Object.keys(result)).toEqual(Object.keys(input)); + }); + + it('should return an object with the same values as the input object', () => { + const input = { + a: 1, + b: 2, + c: 3 + }; + const result = createSharedReferences(input); + expect(Object.values(result)).toEqual(Object.values(input)); + }); + + it('should return an object with shared references for equal values', () => { + const input = { + a: 1, + b: 2, + c: 1 + }; + const result = createSharedReferences(input); + expect(result.a).toBe(result.c); + }); + + it('should return an object with no shared references for unequal values', () => { + const input = { + a: { x: 1 }, + b: { x: 2 }, + c: { x: 1 } + }; + const result = createSharedReferences(input); + expect(result.a).not.toBe(result.b); + }); +}); + +describe('getUniqueProperties', () => { + it('should return unique properties', () => { + const defaultProps = ['color', 'size', 'color']; + const result = getUniqueProperties(defaultProps); + expect(result).toEqual(['color', 'size']); + }); + + it('should throw TypeError when defaultProps is not an array', () => { + expect(() => getUniqueProperties('notArray')).toThrow(TypeError); + expect(() => getUniqueProperties('notArray')).toThrow( + 'Expected defaultProps to be an array of strings.' + ); + }); + + it('should handle default values', () => { + const result = getUniqueProperties(); + expect(result).toEqual([]); + }); +}); + +describe('generatePayload', () => { + // Define base objects for testing + const baseObject = { baseProp: 'baseValue' }; + const defaultStyleObject = { defaultProp: 'defaultValue' }; + const toneObject = { + toneItem: { + toneProp: 'toneValue' + } + }; + const modeObject = { + modeItem: { + modeProp: 'modeValue' + } + }; + + it('should merge base, defaultStyle, tone, and mode objects when all inputs are provided', () => { + const result = generatePayload( + baseObject, + defaultStyleObject, + 'toneItem', + 'modeItem', + toneObject, + modeObject + ); + + expect(result).toEqual({ + baseProp: 'baseValue', + defaultProp: 'defaultValue', + toneProp: 'toneValue', + modeProp: 'modeValue' }); + }); + + it('should merge base and defaultStyle objects when tone and mode are not provided', () => { + const result = generatePayload( + baseObject, + defaultStyleObject, + null, + null, + null, + null + ); - it('should return undefined if no parent object has subTheme', () => { - const childObj = { p: { parent: { parent: {} } } }; - expect(getSubTheme(childObj)).toBeUndefined(); + expect(result).toEqual({ + baseProp: 'baseValue', + defaultProp: 'defaultValue' }); }); - describe('getComponentConfig', () => { - // Test cases for getComponentConfig function - it('should return the component configuration object for the given object', async () => { - await context.setTheme({ - componentConfig: { - Child: { - tone: 'inverse', - mode: 'focused', - style: { - backgroundColor: ['#ffffff', 1] + it('should handle missing toneItem and modeItem gracefully', () => { + const result = generatePayload( + baseObject, + defaultStyleObject, + 'nonExistentTone', + 'nonExistentMode', + toneObject, + modeObject + ); + + expect(result).toEqual({ + baseProp: 'baseValue', + defaultProp: 'defaultValue' + }); + }); +}); + +describe('enforceContract', () => { + it('should enforce the contract with all default keys in the specified order', () => { + const inputObj = { + focused_inverse: {}, + disabled_neutral: {} + }; + + const result = enforceContract(inputObj); + + // Ensure that all default keys are present and in the specified order + const expectedOutput = { + ...inputObj, + unfocused_neutral: {}, + unfocused_inverse: {}, + unfocused_brand: {}, + focused_neutral: {}, + focused_brand: {}, + disabled_neutral: {}, + disabled_inverse: {}, + disabled_brand: {} + }; + + expect(result).toEqual(expectedOutput); + }); + + it('should prioritize values in the order of FALLBACK_ORDER', () => { + const inputObj = { + unfocused_brand: {}, + focused_neutral: 'value1', // This value is not an object + disabled_inverse: {} + }; + + const expectedOutput = { + unfocused_neutral: {}, + unfocused_inverse: {}, + unfocused_brand: {}, + focused_neutral: {}, // This value is not an object + focused_inverse: {}, + focused_brand: {}, + disabled_neutral: {}, + disabled_inverse: {}, + disabled_brand: {} + }; + + const result = enforceContract(inputObj); + + expect(result).toEqual(expectedOutput); + }); + + it('should handle an empty input object', () => { + const inputObj = {}; + + const expectedOutput = { + unfocused_neutral: {}, + unfocused_inverse: {}, + unfocused_brand: {}, + focused_neutral: {}, + focused_inverse: {}, + focused_brand: {}, + disabled_neutral: {}, + disabled_inverse: {}, + disabled_brand: {} + }; + + const result = enforceContract(inputObj); + + expect(result).toEqual(expectedOutput); + }); +}); + +describe('generateComponentStyleSource', () => { + // Resetting all mocks after each test + afterEach(() => { + jest.clearAllMocks(); + }); + + it('throws an error if theme is not an object', () => { + expect(() => { + generateComponentStyleSource({ theme: 'string' }); + }).toThrow('Expected theme to be an object'); + }); + + it('throws an error if componentConfig is not an object', () => { + expect(() => { + generateComponentStyleSource({ componentConfig: 'string' }); + }).toThrow('Expected componentConfig to be an object'); + }); + + it('throws an error if styleChain is not an array', () => { + expect(() => { + generateComponentStyleSource({ styleChain: 'string' }); + }).toThrow('Expected styleChain to be an array'); + }); + + it('throws an error if inlineStyle is not an object', () => { + expect(() => { + generateComponentStyleSource({ inlineStyle: 'string' }); + }).toThrow('Expected inlineStyle to be an object'); + }); + + it('throws an error if alias is not an array', () => { + expect(() => { + generateComponentStyleSource({ alias: 'string' }); + }).toThrow('Expected alias to be an array'); + }); + + it('will provide correct source for a component with componentDefaults for base', () => { + const source = generateComponentStyleSource({ + styleChain: [ + { + style: { + base: { + color: 'primary' } } } - }); + ] + }); + + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } + }); + }); + + it('should have all objects in source be pointers to the same object in memory', () => { + const source = generateComponentStyleSource({ + styleChain: [ + { + style: { + base: { + color: 'primary' + } + } + } + ] + }); + + const objects = Object.values(source); + const firstObject = objects[0]; - const componentConfig = getComponentConfig(childComponentInstance); - expect(componentConfig).toEqual({ - mode: 'focused', + for (let i = 1; i < objects.length; i++) { + expect(objects[i]).toBe(firstObject); + } + }); + + it('will provide correct source for a component with componentDefaults for tone', () => { + const source = generateComponentStyleSource({ + styleChain: [ + { + style: { + tone: { + neutral: { + color: 'primary' + } + } + } + } + ] + }); + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } + }); + }); + + it('will provide correct source for a component with componentDefaults for mode', () => { + const source = generateComponentStyleSource({ + styleChain: [ + { + style: { + mode: { + unfocused: { + color: 'primary' + } + } + } + } + ] + }); + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } + }); + }); + + it('will provide correct source for a component with componentConfig', () => { + const source = generateComponentStyleSource({ + componentConfig: { style: { - backgroundColor: 4294967295 - }, - tone: 'inverse' - }); + base: { + color: 'primary' + } + } + } }); - it('should return an empty object if the object is not a plain object', () => { - const nonObject = 123; // Not an object - expect(getComponentConfig(nonObject)).toEqual({}); + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } }); }); - describe('getPrototypeChain', () => { - // Test cases for getPrototypeChain function - it('should return an array of component names in the prototype chain of the given object', () => { - const prototypeChain = getPrototypeChain(childComponentInstance); - expect(prototypeChain).toEqual(['Child', 'Parent', 'GrandParent']); + it('will provide correct source for a component with componentConfig with tone', () => { + const source = generateComponentStyleSource({ + componentConfig: { + style: { + base: { + color: 'primary' + }, + tone: { + neutral: { + color: 'secondary' + } + } + } + } }); - it('should return an empty array if the object is not a plain object', () => { - const nonObject = 123; // Not an object - expect(getPrototypeChain(nonObject)).toEqual([]); + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'secondary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'secondary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'secondary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } }); }); - describe('generateComponentStyleSource', () => { - // Test cases for generateComponentStyleSource function - it('should generate the source style object for a given component', () => { - const styleSource = generateComponentStyleSource(childComponentInstance); - expect(styleSource).toEqual({ - unfocused_neutral: { spacing: 3 } - }); + it('will provide correct source for a component with componentConfig with mode', () => { + const source = generateComponentStyleSource({ + componentConfig: { + style: { + base: { + color: 'primary' + }, + mode: { + unfocused: { + color: 'secondary' + } + } + } + } }); - it('should return an empty object if the object is not a plain object', () => { - const nonObject = 123; // Not an object - expect(generateComponentStyleSource(nonObject)).toEqual({}); + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'secondary' }, + unfocused_inverse: { color: 'secondary' }, + unfocused_brand: { color: 'secondary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } }); }); - describe('generateStyle', () => { - // Test cases for generateStyle function - it('should generate the final style object for a component', () => { - const componentStyleSource = { - unfocused_neutral: { fontSize: 14, backgroundColor: 'green' } - }; - const style = generateStyle(childComponentInstance, componentStyleSource); - expect(style).toEqual({ - fontSize: 14, - backgroundColor: 'green' - }); + it('will provide correct source for a component with componentConfig with mode', () => { + const source = generateComponentStyleSource({ + componentConfig: { + style: { + base: { + color: 'primary' + }, + mode: { + unfocused: { + color: 'secondary' + } + } + } + } + }); + + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'secondary' }, + unfocused_inverse: { color: 'secondary' }, + unfocused_brand: { color: 'secondary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } + }); + }); + + it('will provide correct source for a component with componentConfig with tone/mode', () => { + const source = generateComponentStyleSource({ + componentConfig: { + style: { + base: { + color: 'primary' + }, + tone: { + neutral: { + mode: { + unfocused: { + color: 'secondary' + } + } + } + } + } + } + }); + + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'secondary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } + }); + }); + + it('will provide correct source for a component with componentConfig with mode/tone', () => { + const source = generateComponentStyleSource({ + componentConfig: { + style: { + base: { + color: 'primary' + }, + mode: { + unfocused: { + tone: { + neutral: { + color: 'secondary' + } + } + } + } + } + } + }); + + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'secondary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } + }); + }); + + it('will provide correct source for a component with inlineStyle', () => { + const source = generateComponentStyleSource({ + inlineStyle: { + color: 'primary' + } }); - it('should return an empty object if the object is not a plain object', () => { - const nonObject = 123; // Not an object - const style = generateStyle(nonObject); - expect(style).toEqual({}); + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } }); }); - describe('getStyleChain', () => { - // Test cases for getStyleChain function - it('should return an array of style objects from the prototype chain of the given component object', async () => { - const styleChain = getStyleChain(childComponentInstance); - expect(styleChain).toEqual([ - { style: { base: { spacing: 1 } } }, - { style: { base: { spacing: 2 } } }, - { style: { base: { spacing: 3 } } } - ]); + it('will provide correct source for a component with inlineStyle base', () => { + const source = generateComponentStyleSource({ + inlineStyle: { + base: { + color: 'primary' + } + } + }); + + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } + }); + }); + + it('will provide correct source for a component with inlineStyle tone', () => { + const source = generateComponentStyleSource({ + inlineStyle: { + tone: { + neutral: { + color: 'primary' + } + } + } + }); + + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } + }); + }); + + it('will provide correct source for a component with inlineStyle mode', () => { + const source = generateComponentStyleSource({ + inlineStyle: { + mode: { + unfocused: { + color: 'primary' + } + } + } + }); + + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } + }); + }); + + it('will provide correct source for a component with inlineStyle tone/mode', () => { + const source = generateComponentStyleSource({ + inlineStyle: { + tone: { + neutral: { + mode: { + unfocused: { + color: 'primary' + } + } + } + } + } }); - it('should return an empty array if no styles are found in the prototype chain', () => { - const nonStyledComponent = {}; - const styleChain = getStyleChain(nonStyledComponent); - expect(styleChain).toEqual([]); + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } }); }); - describe('replaceAliasValues', () => { - // Test cases for replaceAliasValues function - it('should replace alias values in the style object with their corresponding aliases', () => { - const styleObj = { height: '100%', width: '50%' }; - const aliasStyles = [ - { prev: 'height', curr: 'h' }, - { prev: 'width', curr: 'w' } - ]; - const processedStyle = replaceAliasValues(styleObj, aliasStyles); - expect(processedStyle).toEqual({ h: '100%', w: '50%' }); + it('will provide correct source for a component with inlineStyle mode/tone', () => { + const source = generateComponentStyleSource({ + inlineStyle: { + mode: { + unfocused: { + tone: { + neutral: { + color: 'primary' + } + } + } + } + } }); - it('should not modify the object if alias values are not present', () => { - const styleObj = { color: 'red', fontSize: '16px' }; - const processedStyle = replaceAliasValues(styleObj); - expect(processedStyle).toEqual(styleObj); + expect(source).toStrictEqual({ + unfocused_neutral: { color: 'primary' }, + unfocused_inverse: { color: 'primary' }, + unfocused_brand: { color: 'primary' }, + focused_neutral: { color: 'primary' }, + focused_inverse: { color: 'primary' }, + focused_brand: { color: 'primary' }, + disabled_neutral: { color: 'primary' }, + disabled_inverse: { color: 'primary' }, + disabled_brand: { color: 'primary' } }); }); + + it('will provide props that are available in the componentConfig', () => { + const source = generateComponentStyleSource({ + componentConfig: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + }, + style: { + base: { + color: 'primary' + } + } + } + }); + + expect(source).toStrictEqual({ + unfocused_neutral: { + color: 'primary', + props: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + } + } + }, + unfocused_inverse: { + color: 'primary', + props: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + } + } + }, + unfocused_brand: { + color: 'primary', + props: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + } + } + }, + focused_neutral: { + color: 'primary', + props: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + } + } + }, + focused_inverse: { + color: 'primary', + props: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + } + } + }, + focused_brand: { + color: 'primary', + props: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + } + } + }, + disabled_neutral: { + color: 'primary', + props: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + } + } + }, + disabled_inverse: { + color: 'primary', + props: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + } + } + }, + disabled_brand: { + color: 'primary', + props: { + prop1: 'string1', + prop2: 'string2', + prop3: { + prop4: 'string4' + } + } + } + }); + }); +}); + +describe('colorParser', () => { + it('should parse style object with theme strings and color arrays', () => { + const targetObject = { + theme: { + radius: { + md: '10' + } + } + }; + + const styleObj = { + backgroundColor: 'theme.radius.md', + borderColor: ['#663399', 1] + }; + + const result = colorParser(targetObject, styleObj); + + const expectedOutput = { + backgroundColor: '10', + borderColor: 4284887961 + }; + + expect(result).toEqual(expectedOutput); + }); + + it('should handle empty style object and return an empty object', () => { + const targetObject = { + theme: {} + }; + + const styleObj = {}; + + const result = colorParser(targetObject, styleObj); + + expect(result).toEqual({}); + }); + + it('should throw a TypeError if targetObject is not an object', () => { + const targetObject = 'not an object'; // Passing a string instead of an object + + const styleObj = { + backgroundColor: 'theme.radius.md' + }; + + expect(() => colorParser(targetObject, styleObj)).toThrow(TypeError); + }); + + it('should throw a TypeError if styleObj is not an object', () => { + const targetObject = { + theme: {} + }; + + const styleObj = 'not an object'; // Passing a string instead of an object + + expect(() => colorParser(targetObject, styleObj)).toThrow(TypeError); + }); +}); + +describe('generateStyle', () => { + it('should generate style based on component properties', () => { + const component = { + mode: 'focused', + tone: 'brand' + }; + + const componentStyleSource = { + focused_brand: { + fontSize: '20', + color: 'blue' + } + }; + + const generatedStyle = generateStyle(component, componentStyleSource); + + const expectedStyle = { + fontSize: '20', + color: 'blue' + }; + + expect(generatedStyle).toEqual(expectedStyle); + }); + + it('should use default mode and tone if not provided in component', () => { + const component = {}; + + const componentStyleSource = { + unfocused_neutral: { + fontSize: '16', + color: 'black' + } + }; + + const generatedStyle = generateStyle(component, componentStyleSource); + + const expectedStyle = { + fontSize: '16', + color: 'black' + }; + + expect(generatedStyle).toEqual(expectedStyle); + }); + + it('should return an empty object for non-object component', () => { + const component = 'not an object'; + + const componentStyleSource = { + focused_brand: { + fontSize: '20' + } + }; + + const generatedStyle = generateStyle(component, componentStyleSource); + + expect(generatedStyle).toEqual({}); + }); + + it('should return an empty object for missing styles', () => { + const component = { + mode: 'focused', + tone: 'brand' + }; + + const componentStyleSource = {}; + + const generatedStyle = generateStyle(component, componentStyleSource); + + expect(generatedStyle).toEqual({}); + }); +}); +class Parent { + static get __componentName() { + return 'ParentComponent'; + } +} + +class Child extends Parent { + static get __componentName() { + return 'ChildComponent'; + } +} + +describe('generateNameFromPrototypeChain', () => { + it('should generate the name for an object with a prototype chain', () => { + const obj = new Child(); + const result = generateNameFromPrototypeChain(obj); + + expect(result).toBe('ChildComponent.ParentComponent'); + }); + + it('should generate the name for an object without a prototype chain', () => { + const obj = {}; + const result = generateNameFromPrototypeChain(obj); + + expect(result).toBe(''); + }); + + it('should handle an object with missing __componentName', () => { + class ComponentWithoutName {} + + const obj = new ComponentWithoutName(); + const result = generateNameFromPrototypeChain(obj); + + expect(result).toBe(''); + }); + + it('should handle an object with repeated __componentName', () => { + class RepeatedComponent { + static get __componentName() { + return 'RepeatedComponent'; + } + } + + const obj = new RepeatedComponent(); + obj.__proto__.constructor = RepeatedComponent; // Set a repeated __componentName + + const result = generateNameFromPrototypeChain(obj); + + expect(result).toBe('RepeatedComponent'); + }); +}); + +describe('getStyleChainMemoized', () => { + it('should memoize and return the same style chain for the same component', () => { + const component = { + constructor: { + __themeStyle: { + fontSize: '16', + color: 'blue' + } + } + }; + + const styleChain = getStyleChainMemoized(component); + const cachedStyleChain = getStyleChainMemoized(component); + + expect(styleChain).toBe(cachedStyleChain); + }); + + it('should return a different style chain for different components', () => { + class Component1 { + static get __themeStyle() { + return { + fontSize: '16', + color: 'blue' + }; + } + + static get __componentName() { + return 'Component1'; + } + } + + class Component2 { + static get __themeStyle() { + return { + fontSize: '20', + color: 'red' + }; + } + + static get __componentName() { + return 'Component2'; + } + } + + const component1 = new Component1(); + const component2 = new Component2(); + + const styleChain1 = getStyleChainMemoized(component1); + const styleChain2 = getStyleChainMemoized(component2); + + expect(styleChain1).not.toBe(styleChain2); + }); +}); + +describe('removeDuplicateObjects', () => { + test('should remove duplicates from array', () => { + const input = [ + { style: { color: 'red' } }, + { style: { fontSize: 16 } }, + { style: { color: 'red' } } + ]; + + const expected = [{ style: { color: 'red' } }, { style: { fontSize: 16 } }]; + + const result = removeDuplicateObjects(input); + expect(result).toEqual(expected); + }); + + test('should throw an error if input is not an array', () => { + expect(() => { + removeDuplicateObjects('not an array'); + }).toThrow('Input should be an array'); + }); + + test('should return an empty array if input is empty', () => { + expect(removeDuplicateObjects([])).toEqual([]); + }); +}); + +class ComponentA { + static get __themeStyle() { + return { color: 'red' }; + } +} + +class ComponentB extends ComponentA { + static get __mixinStyle() { + return { fontSize: 16 }; + } +} + +class ComponentC extends ComponentB {} + +describe('getStyleChain', () => { + it('should return an array of style objects from the prototype chain', () => { + const componentC = new ComponentC(); + const styleChain = getStyleChain(componentC); + expect(styleChain).toHaveLength(2); // Two styles in the chain + expect(styleChain[0]).toEqual({ style: { fontSize: 16 } }); + expect(styleChain[1]).toEqual({ style: { color: 'red' } }); + }); + + it('should handle components with no styles in the chain', () => { + const componentWithoutStyles = {}; + + const styleChain = getStyleChain(componentWithoutStyles); + + expect(styleChain).toHaveLength(0); // No styles in the chain + }); + + it('should handle styles defined as functions', () => { + const style = () => ({ fontWeight: 'bold' }); + class FunctionStyleComponent { + static get __themeStyle() { + return style; + } + } + const componentWithFunctionStyle = new FunctionStyleComponent(); + + const styleChain = getStyleChain(componentWithFunctionStyle); + + expect(styleChain).toHaveLength(1); + expect(styleChain[0]).toEqual({ style }); + }); +}); + +describe('formatStyleObj', () => { + it('should format a valid style object', () => { + const inputStyle = { + fontSize: '16', + color: 'blue' + }; + + const formattedStyle = formatStyleObj(inputStyle); + + expect(formattedStyle).toEqual(inputStyle); // Should return the same style object + }); + + it('should format a style object with alias styles', () => { + const inputStyle = { + fontSize: '1rem', + width: '50%' + }; + + const aliasStyles = [ + { prev: 'fontSize', curr: 'fs' }, + { prev: 'width', curr: 'w' } + ]; + + const expectedFormattedStyle = { + fs: '1rem', + w: '50%' + }; + + const formattedStyle = formatStyleObj(inputStyle, aliasStyles); + + expect(formattedStyle).toEqual(expectedFormattedStyle); + }); + + it('should throw an error for non-object input', () => { + const invalidInput = 'not an object'; + + // Use an arrow function to invoke formatStyleObj with the invalid input + const testFunction = () => { + formatStyleObj(invalidInput); + }; + + expect(testFunction).toThrowError( + 'The originalObj parameter must be an object.' + ); + }); +}); + +describe('replaceAliasValues', () => { + it('should replace alias values in the provided style object', () => { + const styleObject = { + width: 100, + height: 50 + }; + + const aliasStyles = [ + { prev: 'width', curr: 'w' }, + { prev: 'height', curr: 'h' } + ]; + + const result = replaceAliasValues(styleObject, aliasStyles); + + expect(result).toEqual({ w: 100, h: 50 }); + }); + + it('should replace alias values with custom alias styles', () => { + const styleObject = { + maxW: 20, + maxH: 10 + }; + + const aliasStyles = [ + { prev: 'maxW', curr: 'maxWidth' }, + { prev: 'maxH', curr: 'maxHeight' } + ]; + + const result = replaceAliasValues(styleObject, aliasStyles); + + expect(result).toEqual({ maxWidth: 20, maxHeight: 10 }); + }); + + it('should throw an error if the value is not an object', () => { + const nonObjectValue = 'not an object'; + + expect(() => replaceAliasValues(nonObjectValue)).toThrowError( + 'Value must be an object' + ); + }); + + it('should throw an error if aliasStyles is not an array', () => { + const styleObject = { + width: 100 + }; + + const invalidAliasStyles = 'not an array'; + + expect(() => + replaceAliasValues(styleObject, invalidAliasStyles) + ).toThrowError('Alias styles must be an array'); + }); + + it('should replace alias values with a warning when skipWarn is false', () => { + const styleObject = { + testW: 100 + }; + + const aliasStyles = [{ prev: 'testW', curr: 'testWidth', skipWarn: false }]; + + const consoleWarnSpy = jest.spyOn(log, 'warn').mockImplementation(() => {}); + + const result = replaceAliasValues(styleObject, aliasStyles); + + expect(result).toEqual({ testWidth: 100 }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'The style property "testW" is deprecated and will be removed in a future release. Please use "testWidth" instead.' + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should replace alias values without a warning when skipWarn is true', () => { + const styleObject = { + width: 100 + }; + + const aliasStyles = [{ prev: 'width', curr: 'w', skipWarn: true }]; + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const result = replaceAliasValues(styleObject, aliasStyles); + + expect(result).toEqual({ w: 100 }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); }); diff --git a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/withThemeStyles.test.js b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/withThemeStyles.test.js index 93dfc0f82..09aad9225 100644 --- a/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/withThemeStyles.test.js +++ b/packages/@lightningjs/ui-components/src/mixins/withThemeStyles/withThemeStyles.test.js @@ -1,6 +1,44 @@ +import Lightning from '@lightningjs/core'; +import withThemeStyles from '.'; +import { makeCreateComponent } from '@lightningjs/ui-components-test-utils'; + describe('withThemeStyles', () => { beforeEach(() => {}); - it.skip('should construct with default behavior', () => { - // TODO: Increase unit test coverage for withThemeStyles + it('should apply componentConfig props to the component', () => { + class Test extends Lightning.Component { + static get __componentName() { + return 'Test'; + } + + static get properties() { + return ['prop1']; + } + + get prop1() { + return this._prop1; + } + + set prop1(value) { + this._prop1 = value; + } + } + + const createComponent = makeCreateComponent( + class extends withThemeStyles(Test) { + get theme() { + return { + componentConfig: { + Test: { + prop1: 'foo' + } + } + }; + } + } + ); + const [testComponent] = createComponent(); + + expect(testComponent.prop1).toBe('foo'); + expect(testComponent.style).toMatchObject({}); }); }); diff --git a/packages/@lightningjs/ui-components/src/mixins/withUpdates/index.js b/packages/@lightningjs/ui-components/src/mixins/withUpdates/index.js index 143e4f552..bf0a68bf7 100644 --- a/packages/@lightningjs/ui-components/src/mixins/withUpdates/index.js +++ b/packages/@lightningjs/ui-components/src/mixins/withUpdates/index.js @@ -40,7 +40,19 @@ function getPropertyDescriptor(name, key) { if (changeHandler && typeof changeHandler === 'function') { value = changeHandler.call(this, value); } - this[key] = key === 'style' ? clone(this[key], value) : value; + const newValue = key === 'style' ? clone(this[key], value) : value; + + if ( + typeof this[key] === 'object' && + this[key] !== null && + this[key].style + ) { + // If the property is for a nested component, recursively combine it with the component's existing styles, ensuring that any styles defined in componentConfig are also applied. + const style = clone(this[key].style, value.style || {}); + newValue.style = style; + } + + this[key] = newValue; this.queueRequestUpdate(); } },