From caa54b99eb4ca98bbf5c1156bdae3c39c25ca685 Mon Sep 17 00:00:00 2001 From: Jeff Walden Date: Fri, 5 Jun 2026 01:21:26 -0700 Subject: [PATCH] refactor: Define the contextual variables exposed by a variables parser statically. --- companion/lib/Controls/ControlStore.ts | 10 +- .../lib/Controls/ControlTypes/Button/Base.ts | 6 +- .../Controls/ControlTypes/Button/Layered.ts | 11 +- .../Controls/ControlTypes/Button/Preset.ts | 12 +- .../lib/Controls/ControlTypes/PageButton.ts | 2 +- companion/lib/Controls/Controller.ts | 2 +- .../Controls/Entities/EntityListPoolBase.ts | 10 +- companion/lib/Controls/IControlStore.ts | 3 +- companion/lib/ImportExport/Backups.ts | 2 +- companion/lib/ImportExport/Controller.ts | 2 +- companion/lib/ImportExport/Export.ts | 2 +- .../Instance/Connection/ChildHandlerLegacy.ts | 4 +- .../Instance/Connection/ChildHandlerNew.ts | 4 +- .../lib/Instance/Connection/EntityManager.ts | 2 +- companion/lib/Internal/Controller.ts | 9 +- companion/lib/Surface/Controller.ts | 26 +- companion/lib/Variables/Values.ts | 90 ++--- .../Variables/VariablesAndExpressionParser.ts | 170 ++++++++- companion/test/Instance/EntityManager.test.ts | 2 +- companion/test/Variables/Values.test.ts | 65 +--- .../VariablesAndExpressionParser.test.ts | 334 ++++++++++++++---- package.json | 1 + yarn.lock | 1 + 23 files changed, 497 insertions(+), 273 deletions(-) diff --git a/companion/lib/Controls/ControlStore.ts b/companion/lib/Controls/ControlStore.ts index 28b11b8d81..76b07f7241 100644 --- a/companion/lib/Controls/ControlStore.ts +++ b/companion/lib/Controls/ControlStore.ts @@ -1,5 +1,4 @@ import type { SomeControlModel } from '@companion-app/shared/Model/Controls.js' -import type { VariableValues } from '@companion-app/shared/Model/Variables.js' import type { DataDatabase } from '../Data/Database.js' import type { DataStoreTableView } from '../Data/StoreBase.js' import type { VariablesValues } from '../Variables/Values.js' @@ -166,15 +165,16 @@ export class ControlStore implements IControlStore { createVariablesAndExpressionParser( controlId: string | null | undefined, - overrideVariableValues: VariableValues | null + surfaceId: string | undefined ): VariablesAndExpressionParser { const control = controlId && this.getControl(controlId) // If the control exists and supports entities, use its parser for local variables - if (control && control.supportsEntities) - return control.entities.createVariablesAndExpressionParser(overrideVariableValues) + if (control && control.supportsEntities) { + return control.entities.createVariablesAndExpressionParser(surfaceId) + } // Otherwise create a generic one - return this.#variablesValues.createVariablesAndExpressionParser(null, null, overrideVariableValues) + return this.#variablesValues.createStandaloneParser(surfaceId, null) } } diff --git a/companion/lib/Controls/ControlTypes/Button/Base.ts b/companion/lib/Controls/ControlTypes/Button/Base.ts index 69db96bc47..e21e00dab8 100644 --- a/companion/lib/Controls/ControlTypes/Button/Base.ts +++ b/companion/lib/Controls/ControlTypes/Button/Base.ts @@ -85,10 +85,10 @@ export abstract class ButtonControlBase deps.variableValues - .createVariablesAndExpressionParser( + .createParserForControl( deps.pageStore.getLocationOfControlId(this.controlId), - this.entities.getLocalVariableEntities(), - null + undefined, + this.entities.getLocalVariableEntities() ) .executeExpression(expression, requiredType), isLayered diff --git a/companion/lib/Controls/ControlTypes/Button/Layered.ts b/companion/lib/Controls/ControlTypes/Button/Layered.ts index 92ded04839..e822d000bc 100644 --- a/companion/lib/Controls/ControlTypes/Button/Layered.ts +++ b/companion/lib/Controls/ControlTypes/Button/Layered.ts @@ -24,7 +24,6 @@ import { type ButtonStyleProperties, type DrawStyleLayeredButtonModel, } from '@companion-app/shared/Model/StyleModel.js' -import type { VariableValues } from '@companion-app/shared/Model/Variables.js' import { ConvertSomeButtonGraphicsElementForDrawing } from '../../../Graphics/ConvertGraphicsElements.js' import { ElementConversionCache } from '../../../Graphics/ElementConversionCache.js' import type { ImageResult } from '../../../Graphics/ImageResult.js' @@ -257,18 +256,18 @@ export class ControlButtonLayered */ async getDrawStyle(): Promise { // Block out the button text - const injectedVariableValues: VariableValues = {} const location = this.deps.pageStore.getLocationOfControlId(this.controlId) if (location) { // Ensure we don't enter into an infinite loop // TODO - legacy location variables? - // injectedVariableValues[`internal:b_text_${location.pageNumber}_${location.row}_${location.column}`] = '$RE' + // would mean creating a createParserForLayeredButton that adds them as + // another contextual variables set possibility } - const parser = this.deps.variableValues.createVariablesAndExpressionParser( + const parser = this.deps.variableValues.createParserForControl( location, - this.entities.getLocalVariableEntities(), - injectedVariableValues + undefined, + this.entities.getLocalVariableEntities() ) const locationStr = location ? formatLocation(location) : null diff --git a/companion/lib/Controls/ControlTypes/Button/Preset.ts b/companion/lib/Controls/ControlTypes/Button/Preset.ts index 205c697d6a..f506419b05 100644 --- a/companion/lib/Controls/ControlTypes/Button/Preset.ts +++ b/companion/lib/Controls/ControlTypes/Button/Preset.ts @@ -133,10 +133,10 @@ export class ControlButtonPreset this.sendRuntimePropsChange.bind(this), (expression, requiredType) => deps.variableValues - .createVariablesAndExpressionParser( + .createParserForControl( deps.pageStore.getLocationOfControlId(this.controlId), - null, // This doesn't support local variables - null + undefined, + null // This doesn't support local variables ) .executeExpression(expression, requiredType), false @@ -237,11 +237,7 @@ export class ControlButtonPreset * @returns the processed style of the button */ async getDrawStyle(): Promise { - const parser = this.deps.variableValues.createVariablesAndExpressionParser( - null, - this.entities.getLocalVariableEntities(), - null - ) + const parser = this.deps.variableValues.createStandaloneParser(undefined, this.entities.getLocalVariableEntities()) const feedbackOverrides = this.entities.getFeedbackStyleOverrides() diff --git a/companion/lib/Controls/ControlTypes/PageButton.ts b/companion/lib/Controls/ControlTypes/PageButton.ts index 4c71d27edf..d893e18a00 100644 --- a/companion/lib/Controls/ControlTypes/PageButton.ts +++ b/companion/lib/Controls/ControlTypes/PageButton.ts @@ -92,7 +92,7 @@ export abstract class ControlButtonPage */ async getDrawStyle(): Promise { const location = this.deps.pageStore.getLocationOfControlId(this.controlId) - const parser = this.deps.variableValues.createVariablesAndExpressionParser(location, null, null) + const parser = this.deps.variableValues.createParserForControl(location, undefined, null) const { drawType, elements: rawElements } = this.getDrawElements() diff --git a/companion/lib/Controls/Controller.ts b/companion/lib/Controls/Controller.ts index 223cc6943d..b1dc17bc85 100644 --- a/companion/lib/Controls/Controller.ts +++ b/companion/lib/Controls/Controller.ts @@ -680,6 +680,6 @@ export class ControlsController { } createVariablesAndExpressionParser(controlId: string | null | undefined): VariablesAndExpressionParser { - return this.#store.createVariablesAndExpressionParser(controlId, null) + return this.#store.createVariablesAndExpressionParser(controlId, undefined) } } diff --git a/companion/lib/Controls/Entities/EntityListPoolBase.ts b/companion/lib/Controls/Entities/EntityListPoolBase.ts index 6bb10fa175..998299bb2b 100644 --- a/companion/lib/Controls/Entities/EntityListPoolBase.ts +++ b/companion/lib/Controls/Entities/EntityListPoolBase.ts @@ -82,7 +82,7 @@ export abstract class ControlEntityListPoolBase { this.#specialExpressionManager = new EntityPoolSpecialExpressionManager( props.controlId, - this.createVariablesAndExpressionParser.bind(this), + this.createVariablesAndExpressionParser.bind(this, undefined), { isInverted: this.updateIsInvertedValues.bind(this), storeResult: this.updateStoreResultValues.bind(this), @@ -174,15 +174,11 @@ export abstract class ControlEntityListPoolBase { }) } - createVariablesAndExpressionParser(overrideVariableValues: VariableValues | null): VariablesAndExpressionParser { + createVariablesAndExpressionParser(surfaceId: string | undefined): VariablesAndExpressionParser { const controlLocation = this.#pageStore.getLocationOfControlId(this.controlId) const variableEntities = this.getLocalVariableEntities() - return this.#variableValues.createVariablesAndExpressionParser( - controlLocation, - variableEntities, - overrideVariableValues - ) + return this.#variableValues.createParserForControl(controlLocation, surfaceId, variableEntities) } /** diff --git a/companion/lib/Controls/IControlStore.ts b/companion/lib/Controls/IControlStore.ts index 269d65bab4..e96c0e4b62 100644 --- a/companion/lib/Controls/IControlStore.ts +++ b/companion/lib/Controls/IControlStore.ts @@ -1,4 +1,3 @@ -import type { VariableValues } from '@companion-app/shared/Model/Variables.js' import type { VariablesAndExpressionParser } from '../Variables/VariablesAndExpressionParser.js' import type { NewFeedbackValue } from './Entities/Types.js' import type { SomeControl } from './IControlFragments.js' @@ -47,7 +46,7 @@ export interface IControlStore { createVariablesAndExpressionParser( controlId: string | null | undefined, - overrideVariableValues: VariableValues | null + surfaceId: string | undefined ): VariablesAndExpressionParser /** diff --git a/companion/lib/ImportExport/Backups.ts b/companion/lib/ImportExport/Backups.ts index 0dc7cf696c..40bc5e03e0 100644 --- a/companion/lib/ImportExport/Backups.ts +++ b/companion/lib/ImportExport/Backups.ts @@ -383,7 +383,7 @@ export class BackupController { await fs.mkdir(backupDir, { recursive: true }) // Generate backup filename - const parser = this.#variableValuesController.createVariablesAndExpressionParser(null, null, null) + const parser = this.#variableValuesController.createStandaloneParser(undefined, null) const backupName = parser.parseVariables(rule.backupNamePattern).text if (!backupName) { logger.info('No backup name generated, skipping backup') diff --git a/companion/lib/ImportExport/Controller.ts b/companion/lib/ImportExport/Controller.ts index fae4970bf6..762d8f6a52 100644 --- a/companion/lib/ImportExport/Controller.ts +++ b/companion/lib/ImportExport/Controller.ts @@ -386,7 +386,7 @@ export class ImportExportController { return null } - const parser = this.#variablesController.values.createVariablesAndExpressionParser(null, null, null) + const parser = this.#variablesController.values.createStandaloneParser(undefined, null) // Compute the new drawing const { elements } = await ConvertSomeButtonGraphicsElementForDrawing( diff --git a/companion/lib/ImportExport/Export.ts b/companion/lib/ImportExport/Export.ts index 8c4019d3f4..a3a0f23324 100644 --- a/companion/lib/ImportExport/Export.ts +++ b/companion/lib/ImportExport/Export.ts @@ -327,7 +327,7 @@ export class ExportController { #generateFilename(filename: string, exportType: string, fileExt: string): string { //If the user isn't using their default file name, don't append any extra info in file name since it was a manual choice const useDefault = filename == this.#userConfigController.getKey('default_export_filename') - const parser = this.#variablesController.values.createVariablesAndExpressionParser(null, null, null) + const parser = this.#variablesController.values.createStandaloneParser(undefined, null) const parsedName = parser.parseVariables(filename).text return parsedName && parsedName !== 'undefined' diff --git a/companion/lib/Instance/Connection/ChildHandlerLegacy.ts b/companion/lib/Instance/Connection/ChildHandlerLegacy.ts index 7af0dc3448..62f6dffdc7 100644 --- a/companion/lib/Instance/Connection/ChildHandlerLegacy.ts +++ b/companion/lib/Instance/Connection/ChildHandlerLegacy.ts @@ -580,7 +580,7 @@ export class ConnectionChildHandlerLegacy implements ChildProcessHandlerBase, Co if (!actionDefinition) throw new Error(`Failed to find action definition for ${action.definitionId}`) // Note: for actions, this doesn't need to be reactive - const parser = this.#deps.controls.createVariablesAndExpressionParser(extras.controlId, null) + const parser = this.#deps.controls.createVariablesAndExpressionParser(extras.controlId, undefined) const parseRes = parser.parseEntityOptions(actionDefinition, action.options) if (!parseRes.ok) { this.logger.warn( @@ -938,7 +938,7 @@ export class ConnectionChildHandlerLegacy implements ChildProcessHandlerBase, Co msg: ParseVariablesInStringMessage ): Promise { try { - const parser = this.#deps.controls.createVariablesAndExpressionParser(msg.controlId, null) + const parser = this.#deps.controls.createVariablesAndExpressionParser(msg.controlId, undefined) const result = parser.parseVariables(msg.text) return { diff --git a/companion/lib/Instance/Connection/ChildHandlerNew.ts b/companion/lib/Instance/Connection/ChildHandlerNew.ts index 43507ddaa1..7f57e0f653 100644 --- a/companion/lib/Instance/Connection/ChildHandlerNew.ts +++ b/companion/lib/Instance/Connection/ChildHandlerNew.ts @@ -284,7 +284,7 @@ export class ConnectionChildHandlerNew implements ChildProcessHandlerBase, Conne } const learnTimeout = entityDefinition.learnTimeout - const parser = this.#deps.controls.createVariablesAndExpressionParser(controlId, null) + const parser = this.#deps.controls.createVariablesAndExpressionParser(controlId, undefined) const parseRes = parser.parseEntityOptions(entityDefinition, entity.options) if (!parseRes.ok) { this.logger.warn( @@ -376,7 +376,7 @@ export class ConnectionChildHandlerNew implements ChildProcessHandlerBase, Conne if (!actionDefinition) throw new Error(`Failed to find action definition for ${action.definitionId}`) // Note: for actions, this doesn't need to be reactive - const parser = this.#deps.controls.createVariablesAndExpressionParser(extras.controlId, null) + const parser = this.#deps.controls.createVariablesAndExpressionParser(extras.controlId, undefined) const parseRes = parser.parseEntityOptions(actionDefinition, action.options) if (!parseRes.ok) { let location = 'Unknown' diff --git a/companion/lib/Instance/Connection/EntityManager.ts b/companion/lib/Instance/Connection/EntityManager.ts index 55715d15dc..a3265023d7 100644 --- a/companion/lib/Instance/Connection/EntityManager.ts +++ b/companion/lib/Instance/Connection/EntityManager.ts @@ -173,7 +173,7 @@ export class ConnectionEntityManager { let updateOptions: CompanionOptionValues | undefined try { // Parse the options and track the variables referenced - const parser = this.controlsStore.createVariablesAndExpressionParser(wrapper.controlId, null) + const parser = this.controlsStore.createVariablesAndExpressionParser(wrapper.controlId, undefined) const parseRes = parser.parseEntityOptions(entityDefinition, entityModel.options) if (!parseRes.ok) { this.#logger.warn( diff --git a/companion/lib/Internal/Controller.ts b/companion/lib/Internal/Controller.ts index d412726999..af4764c567 100644 --- a/companion/lib/Internal/Controller.ts +++ b/companion/lib/Internal/Controller.ts @@ -20,7 +20,7 @@ import { type SomeEntityModel, } from '@companion-app/shared/Model/EntityModel.js' import { convertExpressionOptionsWithoutParsing } from '@companion-app/shared/Model/Options.js' -import type { VariableValue, VariableValues } from '@companion-app/shared/Model/Variables.js' +import type { VariableValue } from '@companion-app/shared/Model/Variables.js' import { stringifyError } from '@companion-app/shared/Stringify.js' import { assertNever } from '@companion-app/shared/Util.js' import type { CompanionOptionValues, Complete } from '@companion-module/base' @@ -315,7 +315,7 @@ export class InternalController { return undefined } - const parser = this.#controlsStore.createVariablesAndExpressionParser(feedbackState.controlId, null) + const parser = this.#controlsStore.createVariablesAndExpressionParser(feedbackState.controlId, undefined) // Parse the options if enabled let parsedOptions: CompanionOptionValues @@ -462,10 +462,7 @@ export class InternalController { ) if (!entityDefinition) return - const overrideVariableValues: VariableValues = { - 'this:surface_id': extras.surfaceId, - } - const parser = this.#controlsStore.createVariablesAndExpressionParser(extras.controlId, overrideVariableValues) + const parser = this.#controlsStore.createVariablesAndExpressionParser(extras.controlId, extras.surfaceId) let parsedOptions: CompanionOptionValues if (entityDefinition.optionsSupportExpressions) { diff --git a/companion/lib/Surface/Controller.ts b/companion/lib/Surface/Controller.ts index a7ddab59ac..472433ac45 100644 --- a/companion/lib/Surface/Controller.ts +++ b/companion/lib/Surface/Controller.ts @@ -31,9 +31,7 @@ import type { SurfacePanelConfig, SurfacesUpdate, } from '@companion-app/shared/Model/Surfaces.js' -import type { VariableValues } from '@companion-app/shared/Model/Variables.js' import { stringifyError } from '@companion-app/shared/Stringify.js' -import { VARIABLE_UNKNOWN_VALUE } from '@companion-app/shared/Variables.js' import type { Complete } from '@companion-module/base' import type { HIDDevice } from '@companion-surface/host' import type { DataDatabase } from '../Data/Database.js' @@ -1415,32 +1413,12 @@ export class SurfaceController extends EventEmitter { } surfaceExecuteExpression(str: string, surfaceId: string): ExecuteExpressionResult { - const parser = this.#handlerDependencies.variables.values.createVariablesAndExpressionParser( - null, - null, - this.#getInjectedVariablesForSurfaceId(surfaceId) - ) + const surfacePageNumber = this.devicePageGet(surfaceId) + const parser = this.#handlerDependencies.variables.values.createParserForSurface(surfaceId, surfacePageNumber) return parser.executeExpression(str, undefined) } - /** - * Variables to inject based on location - */ - #getInjectedVariablesForSurfaceId(surfaceId: string): VariableValues { - const pageNumber = this.devicePageGet(surfaceId) - - return { - 'this:surface_id': surfaceId, - - // Reactivity is triggered manually - 'this:page': pageNumber, - - // Reactivity happens for these because of references to the inner variables - 'this:page_name': pageNumber ? `$(internal:page_number_${pageNumber}_name)` : VARIABLE_UNKNOWN_VALUE, - } - } - exportAll(): Record { return this.#dbTableSurfaces.all() } diff --git a/companion/lib/Variables/Values.ts b/companion/lib/Variables/Values.ts index a970803eaf..e79e7c168d 100644 --- a/companion/lib/Variables/Values.ts +++ b/companion/lib/Variables/Values.ts @@ -11,21 +11,14 @@ import EventEmitter from 'node:events' import z from 'zod' -import { formatLocation } from '@companion-app/shared/ControlId.js' -import type { ThisLocationVariable } from '@companion-app/shared/ControlLocation.js' import { BANNED_PROPS } from '@companion-app/shared/Expression/ExpressionResolve.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' -import { - stringifyVariableValue, - type VariableValue, - type VariableValues, -} from '@companion-app/shared/Model/Variables.js' -import { VARIABLE_UNKNOWN_VALUE } from '@companion-app/shared/Variables.js' +import { stringifyVariableValue, type VariableValue } from '@companion-app/shared/Model/Variables.js' import type { ControlEntityInstance } from '../Controls/Entities/EntityInstance.js' import LogController from '../Log/Controller.js' import { publicProcedure, router } from '../UI/TRPC.js' -import type { VariablesCache, VariableValueData } from './Util.js' -import { VariablesAndExpressionParser } from './VariablesAndExpressionParser.js' +import type { VariableValueData } from './Util.js' +import { ThisLocationVariablesSet, VariablesAndExpressionParser } from './VariablesAndExpressionParser.js' import { VariablesBlinker } from './VariablesBlinker.js' export interface VariablesValuesEvents { @@ -33,52 +26,6 @@ export interface VariablesValuesEvents { local_variables_changed: [changed: ReadonlySet, fromControlId: string] } -const ThisLocationVariables: Record< - ThisLocationVariable, - (location: ControlLocation | null | undefined) => VariableValue -> = { - 'this:page': (location) => location?.pageNumber, - 'this:column': (location) => location?.column, - 'this:row': (location) => location?.row, - 'this:location': (location) => (location ? formatLocation(location) : undefined), - - // The remaining variables simply delegate to internally-defined variables. - 'this:page_name': (location) => - location ? `$(internal:page_number_${location.pageNumber}_name)` : VARIABLE_UNKNOWN_VALUE, - 'this:active': (location) => - location - ? `$(internal:b_active_${location.pageNumber}_${location.row}_${location.column})` - : VARIABLE_UNKNOWN_VALUE, - - 'this:step': (location) => - location ? `$(internal:b_step_${location.pageNumber}_${location.row}_${location.column})` : VARIABLE_UNKNOWN_VALUE, - 'this:step_count': (location) => - location - ? `$(internal:b_step_count_${location.pageNumber}_${location.row}_${location.column})` - : VARIABLE_UNKNOWN_VALUE, - - 'this:actions_running': (location) => - location - ? `$(internal:b_actions_running_${location.pageNumber}_${location.row}_${location.column})` - : VARIABLE_UNKNOWN_VALUE, - - 'this:button_status': (location) => - location - ? `$(internal:b_status_${location.pageNumber}_${location.row}_${location.column})` - : VARIABLE_UNKNOWN_VALUE, -} - -const ThisLocationVariablesSet: ReadonlySet = new Set(Object.keys(ThisLocationVariables)) - -export function InjectedVariablesForLocation(controlLocation: ControlLocation | null | undefined): VariablesCache { - return new Map( - Object.entries(ThisLocationVariables).map(([variableId, computeVariable]) => [ - variableId, - computeVariable(controlLocation), - ]) - ) -} - export class VariablesValues extends EventEmitter { readonly #logger = LogController.createLogger('Variables/Values') @@ -106,19 +53,34 @@ export class VariablesValues extends EventEmitter { return this.getVariableValue('custom', name) } - createVariablesAndExpressionParser( + createStandaloneParser( + surfaceId: string | undefined, + localValues: ControlEntityInstance[] | null + ): VariablesAndExpressionParser { + return VariablesAndExpressionParser.forControl(this.#blinker, this.#variableValues, null, surfaceId, localValues) + } + + createParserForControl( controlLocation: ControlLocation | null | undefined, - localValues: ControlEntityInstance[] | null, - overrideVariableValues: VariableValues | null + surfaceId: string | undefined, + localValues: ControlEntityInstance[] | null ): VariablesAndExpressionParser { - const thisValues = InjectedVariablesForLocation(controlLocation) + return VariablesAndExpressionParser.forControl( + this.#blinker, + this.#variableValues, + controlLocation, + surfaceId, + localValues + ) + } - return new VariablesAndExpressionParser( + createParserForSurface(surfaceId: string, surfacePageNumber: string | undefined): VariablesAndExpressionParser { + return VariablesAndExpressionParser.forSurface( this.#blinker, this.#variableValues, - thisValues, - localValues, - overrideVariableValues + surfaceId, + surfacePageNumber, + null ) } diff --git a/companion/lib/Variables/VariablesAndExpressionParser.ts b/companion/lib/Variables/VariablesAndExpressionParser.ts index 1b88ad5e2b..8c9bc427a7 100644 --- a/companion/lib/Variables/VariablesAndExpressionParser.ts +++ b/companion/lib/Variables/VariablesAndExpressionParser.ts @@ -1,5 +1,8 @@ import type { JsonValue, ReadonlyDeep } from 'type-fest' +import { formatLocation } from '@companion-app/shared/ControlId.js' +import type { ThisLocationVariable } from '@companion-app/shared/ControlLocation.js' import type { ExecuteExpressionResult } from '@companion-app/shared/Expression/ExpressionResult.js' +import type { ControlLocation } from '@companion-app/shared/Model/Common.js' import type { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' import type { ExpressionableOptionsObject, ExpressionOrValue } from '@companion-app/shared/Model/Options.js' import { @@ -23,8 +26,83 @@ import { } from './Util.js' import type { VariablesBlinker } from './VariablesBlinker.js' +type VariablesRecord = Record< + Variables, + (contextData: ReadonlyDeep) => VariableValue +> + +type ThisLocationVariablesData = { + location: ControlLocation | null | undefined +} + +const ThisLocationVariables: VariablesRecord = { + 'this:page': ({ location }) => location?.pageNumber, + 'this:column': ({ location }) => location?.column, + 'this:row': ({ location }) => location?.row, + 'this:location': ({ location }) => (location ? formatLocation(location) : undefined), + + // The remaining variables simply delegate to internally-defined variables. + 'this:page_name': ({ location }) => + location ? `$(internal:page_number_${location.pageNumber}_name)` : VARIABLE_UNKNOWN_VALUE, + 'this:active': ({ location }) => + location + ? `$(internal:b_active_${location.pageNumber}_${location.row}_${location.column})` + : VARIABLE_UNKNOWN_VALUE, + + 'this:step': ({ location }) => + location ? `$(internal:b_step_${location.pageNumber}_${location.row}_${location.column})` : VARIABLE_UNKNOWN_VALUE, + 'this:step_count': ({ location }) => + location + ? `$(internal:b_step_count_${location.pageNumber}_${location.row}_${location.column})` + : VARIABLE_UNKNOWN_VALUE, + + 'this:actions_running': ({ location }) => + location + ? `$(internal:b_actions_running_${location.pageNumber}_${location.row}_${location.column})` + : VARIABLE_UNKNOWN_VALUE, + + 'this:button_status': ({ location }) => + location + ? `$(internal:b_status_${location.pageNumber}_${location.row}_${location.column})` + : VARIABLE_UNKNOWN_VALUE, +} + +export const ThisLocationVariablesSet: ReadonlySet = new Set(Object.keys(ThisLocationVariables)) + +type SurfaceVariablesData = { + surfaceId: string | undefined + pageNumber: string | undefined +} + +export type SurfaceVariable = 'this:surface_id' | 'this:page' | 'this:page_name' + +const SurfaceVariables: VariablesRecord = { + 'this:surface_id': ({ surfaceId }) => surfaceId, + + // Reactivity is triggered manually + 'this:page': ({ pageNumber }) => pageNumber, + + // Reactivity happens for these because of references to the inner variables + 'this:page_name': ({ pageNumber }) => + pageNumber ? `$(internal:page_number_${pageNumber}_name)` : VARIABLE_UNKNOWN_VALUE, +} + +type ThisLocationThroughSurfaceVariablesData = ThisLocationVariablesData & { + surfaceId: string | undefined +} + +export type ThisLocationThroughSurfaceVariable = ThisLocationVariable | 'this:surface_id' + +const ThisLocationThroughSurfaceVariables: VariablesRecord< + ThisLocationThroughSurfaceVariable, + ThisLocationThroughSurfaceVariablesData +> = { + ...ThisLocationVariables, + 'this:surface_id': ({ surfaceId }) => surfaceId, +} + /** - * A class to parse and execute expressions with variables + * A class to parse strings and execute expressions with variables. * This allows for preparing any injected/lazy variables before executing multiple expressions */ export class VariablesAndExpressionParser { @@ -33,16 +111,17 @@ export class VariablesAndExpressionParser { readonly #blinker: VariablesBlinker readonly #rawVariableValues: ReadonlyDeep - readonly #thisValues: VariablesCache + readonly #contextVariables: Record VariableValue)> + readonly #contextData: unknown readonly #localValues: VariablesCache = new Map() readonly #overrideVariableValues: VariableValues - readonly #valueCacheAccessor: VariableValueCache = { + private readonly valueCacheAccessor: VariableValueCache = { has: (id: string): boolean => { - return this.#thisValues.has(id) || this.#localValues.has(id) || this.#overrideVariableValues[id] !== undefined + return !!this.#contextVariables[id] || this.#localValues.has(id) || this.#overrideVariableValues[id] !== undefined }, get: (id: string): VariableValue | undefined => { - if (this.#thisValues.has(id)) return this.#thisValues.get(id) + if (this.#contextVariables[id]) return this.#contextVariables[id](this.#contextData) if (this.#localValues.has(id)) return this.#localValues.get(id) return this.#overrideVariableValues[id] }, @@ -51,26 +130,91 @@ export class VariablesAndExpressionParser { }, } - constructor( + private constructor( blinker: VariablesBlinker, rawVariableValues: ReadonlyDeep, - thisValues: VariablesCache, + contextVariables: Record) => VariableValue)>, + contextData: any, localValues: ControlEntityInstance[] | null, - overrideVariableValues: VariableValues | null + overrideVariableValues: VariableValues ) { this.#blinker = blinker this.#rawVariableValues = rawVariableValues - this.#thisValues = thisValues - this.#overrideVariableValues = overrideVariableValues || {} + this.#contextVariables = contextVariables + this.#contextData = contextData + this.#overrideVariableValues = overrideVariableValues if (localValues) this.#bindLocalVariables(localValues) } + // Template arguments aren't allowed on constructors, so use a helper function + // to add them. + private static new_( + blinker: VariablesBlinker, + rawVariableValues: ReadonlyDeep, + contextVariables: Record>) => VariableValue)>, + contextData: ReadonlyDeep, + localValues: ControlEntityInstance[] | null + ): VariablesAndExpressionParser { + return new VariablesAndExpressionParser(blinker, rawVariableValues, contextVariables, contextData, localValues, {}) + } + + static forControl( + blinker: VariablesBlinker, + rawVariableValues: ReadonlyDeep, + controlLocation: ControlLocation | null | undefined, + surfaceId: string | undefined, + localValues: ControlEntityInstance[] | null + ): VariablesAndExpressionParser { + if (surfaceId) { + return VariablesAndExpressionParser.new_( + blinker, + rawVariableValues, + ThisLocationThroughSurfaceVariables, + { + location: controlLocation, + surfaceId, + }, + localValues + ) + } + + return VariablesAndExpressionParser.new_( + blinker, + rawVariableValues, + ThisLocationVariables, + { + location: controlLocation, + }, + localValues + ) + } + + static forSurface( + blinker: VariablesBlinker, + rawVariableValues: ReadonlyDeep, + surfaceId: string, + surfacePageNumber: string | undefined, + localValues: ControlEntityInstance[] | null + ): VariablesAndExpressionParser { + return VariablesAndExpressionParser.new_( + blinker, + rawVariableValues, + SurfaceVariables, + { + surfaceId, + pageNumber: surfacePageNumber, + }, + localValues + ) + } + createChildParser(overrideVariableValues: VariableValues): VariablesAndExpressionParser { const childParser = new VariablesAndExpressionParser( this.#blinker, this.#rawVariableValues, - this.#thisValues, + this.#contextVariables, + this.#contextData, null, { ...this.#overrideVariableValues, @@ -106,7 +250,7 @@ export class VariablesAndExpressionParser { * @returns result of the expression */ executeExpression(str: string, requiredType: string | undefined): ExecuteExpressionResult { - return executeExpression(this.#blinker, str, this.#rawVariableValues, requiredType, this.#valueCacheAccessor) + return executeExpression(this.#blinker, str, this.#rawVariableValues, requiredType, this.valueCacheAccessor) } /** @@ -115,7 +259,7 @@ export class VariablesAndExpressionParser { * @returns with variables replaced with values */ parseVariables(str: string): ParseVariablesResult { - return parseVariablesInString(str, this.#rawVariableValues, this.#valueCacheAccessor, VARIABLE_UNKNOWN_VALUE) + return parseVariablesInString(str, this.#rawVariableValues, this.valueCacheAccessor, VARIABLE_UNKNOWN_VALUE) } /** diff --git a/companion/test/Instance/EntityManager.test.ts b/companion/test/Instance/EntityManager.test.ts index 5ddb4d232d..3931a5d1a4 100644 --- a/companion/test/Instance/EntityManager.test.ts +++ b/companion/test/Instance/EntityManager.test.ts @@ -1320,7 +1320,7 @@ describe('InstanceEntityManager', () => { vi.runAllTimers() // Should have passed the location to parse variables - expect(mockControlsController.createVariablesAndExpressionParser).toHaveBeenCalledWith('control-1', null) + expect(mockControlsController.createVariablesAndExpressionParser).toHaveBeenCalledWith('control-1', undefined) expect(mockVariablesParser.parseEntityOptions).toHaveBeenCalled() }) }) diff --git a/companion/test/Variables/Values.test.ts b/companion/test/Variables/Values.test.ts index 619f4f3059..1be8548a2c 100644 --- a/companion/test/Variables/Values.test.ts +++ b/companion/test/Variables/Values.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { BANNED_PROPS } from '@companion-app/shared/Expression/ExpressionResolve.js' import { VARIABLE_UNKNOWN_VALUE } from '@companion-app/shared/Variables.js' import type { VariablesCache } from '../../lib/Variables/Util.js' -import { InjectedVariablesForLocation, VariablesValues, type VariableValueEntry } from '../../lib/Variables/Values.js' +import { VariablesValues, type VariableValueEntry } from '../../lib/Variables/Values.js' describe('VariablesValues', () => { let values: VariablesValues @@ -319,69 +319,6 @@ describe('VariablesValues', () => { }) }) - describe('InjectedVariablesForLocation', () => { - test('sets all ten $(this:*) keys for a valid location', () => { - const cache: VariablesCache = InjectedVariablesForLocation({ pageNumber: 2, row: 3, column: 4 }) - const expectedKeys = [ - 'this:page', - 'this:column', - 'this:row', - 'this:location', - 'this:page_name', - 'this:active', - 'this:step', - 'this:step_count', - 'this:actions_running', - 'this:button_status', - ] - for (const key of expectedKeys) { - expect(cache.has(key), `expected cache to have key "${key}"`).toBe(true) - } - }) - - test('sets page, row, and column to the correct numeric values', () => { - const cache: VariablesCache = InjectedVariablesForLocation({ pageNumber: 5, row: 2, column: 7 }) - expect(cache.get('this:page')).toBe(5) - expect(cache.get('this:row')).toBe(2) - expect(cache.get('this:column')).toBe(7) - }) - - test('sets $(this:page_name) to the correct $(internal:page_number_N_name) reference', () => { - const cache: VariablesCache = InjectedVariablesForLocation({ pageNumber: 3, row: 1, column: 2 }) - expect(cache.get('this:page_name')).toBe('$(internal:page_number_3_name)') - }) - - test('sets button dynamic variables to the correct $(internal:b_*) reference strings', () => { - const cache: VariablesCache = InjectedVariablesForLocation({ pageNumber: 2, row: 4, column: 6 }) - expect(cache.get('this:active')).toBe('$(internal:b_active_2_4_6)') - expect(cache.get('this:step')).toBe('$(internal:b_step_2_4_6)') - expect(cache.get('this:step_count')).toBe('$(internal:b_step_count_2_4_6)') - expect(cache.get('this:actions_running')).toBe('$(internal:b_actions_running_2_4_6)') - expect(cache.get('this:button_status')).toBe('$(internal:b_status_2_4_6)') - }) - - test.each([[null], [undefined]])('$0 location: primitive this:* variables are undefined', (controlLocation) => { - const cache: VariablesCache = InjectedVariablesForLocation(controlLocation) - expect(cache.get('this:page')).toBeUndefined() - expect(cache.get('this:row')).toBeUndefined() - expect(cache.get('this:column')).toBeUndefined() - expect(cache.get('this:location')).toBeUndefined() - }) - - test.each([[null], [undefined]])( - '$0 location: complex this:* variables resolve to VARIABLE_UNKNOWN_VALUE', - (controlLocation) => { - const cache: VariablesCache = InjectedVariablesForLocation(controlLocation) - expect(cache.get('this:page_name')).toBe(VARIABLE_UNKNOWN_VALUE) - expect(cache.get('this:active')).toBe(VARIABLE_UNKNOWN_VALUE) - expect(cache.get('this:step')).toBe(VARIABLE_UNKNOWN_VALUE) - expect(cache.get('this:step_count')).toBe(VARIABLE_UNKNOWN_VALUE) - expect(cache.get('this:actions_running')).toBe(VARIABLE_UNKNOWN_VALUE) - expect(cache.get('this:button_status')).toBe(VARIABLE_UNKNOWN_VALUE) - } - ) - }) - describe('triggerLocationVariablesChange', () => { test('emits the local_variables_changed event', () => { const listener = vi.fn() diff --git a/companion/test/Variables/VariablesAndExpressionParser.test.ts b/companion/test/Variables/VariablesAndExpressionParser.test.ts index 46cc4eac46..af722e131f 100644 --- a/companion/test/Variables/VariablesAndExpressionParser.test.ts +++ b/companion/test/Variables/VariablesAndExpressionParser.test.ts @@ -1,4 +1,7 @@ +import type { Equal, Expect } from 'type-testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ThisLocationVariable } from '@companion-app/shared/ControlLocation.js' +import { ControlLocation } from '@companion-app/shared/Model/Common.js' import type { ClientEntityDefinition } from '@companion-app/shared/Model/EntityDefinitionModel.js' import { EntityModelType } from '@companion-app/shared/Model/EntityModel.js' import { @@ -7,9 +10,14 @@ import { exprVal, type ExpressionableOptionsObject, } from '@companion-app/shared/Model/Options.js' +import { VARIABLE_UNKNOWN_VALUE } from '@companion-app/shared/Variables.js' import type { ControlEntityInstance } from '../../lib/Controls/Entities/EntityInstance.js' import type { VariablesCache, VariableValueData, VisitEntityOptionValueOptions } from '../../lib/Variables/Util.js' -import { VariablesAndExpressionParser } from '../../lib/Variables/VariablesAndExpressionParser.js' +import { + SurfaceVariable, + ThisLocationThroughSurfaceVariable, + VariablesAndExpressionParser, +} from '../../lib/Variables/VariablesAndExpressionParser.js' const useVariablesMinimal = CompanionFieldVariablesSupport.Basic @@ -37,6 +45,23 @@ function createDefinition( } } +const ThisControlPrimitiveVariables = ['this:page', 'this:column', 'this:row', 'this:location'] as const + +const ThisControlComplexVariables = [ + 'this:page_name', + 'this:active', + 'this:step', + 'this:step_count', + 'this:actions_running', + 'this:button_status', +] as const + +const ThisControlVariables = [...ThisControlPrimitiveVariables, ...ThisControlComplexVariables] as const + +type _AllThisVariablesIncluded = Expect> + +const SurfaceVariables = ['this:surface_id', 'this:page', 'this:page_name'] as const + describe('VariablesAndExpressionParser', () => { // Sample variable data for testing const defaultVariables: VariableValueData = { @@ -52,13 +77,228 @@ describe('VariablesAndExpressionParser', () => { const createParser = ( variables: VariableValueData = defaultVariables, - thisValues: VariablesCache = new Map(), - localValues: null = null, - overrideValues: null = null + controlLocation: ControlLocation | null | undefined = undefined, + localValues: null = null ): VariablesAndExpressionParser => { - return new VariablesAndExpressionParser(null as any, variables, thisValues, localValues, overrideValues) + return VariablesAndExpressionParser.forControl(null as any, variables, controlLocation, undefined, localValues) } + describe('for control, with this-location variables', () => { + it('has all ten $(this:*) keys for a valid location', () => { + const parser = VariablesAndExpressionParser.forControl( + null as any, + {}, + { pageNumber: 2, row: 3, column: 4 }, + undefined, + null + ) + + const cache = parser['valueCacheAccessor'] + for (const key of ThisControlVariables) { + expect(cache.has(key), `expected cache to have key "${key}"`).toBe(true) + } + }) + + it('does not have this:surface_id, does have this:page and this:page_number', () => { + const parser = createParser({}, { pageNumber: 2, row: 3, column: 4 }) + + for (const key of SurfaceVariables) { + expect(parser['valueCacheAccessor'].has(key)).toBe(key !== 'this:surface_id') + } + }) + + it('has $(this:*) keys with expected values, not through surface', () => { + const parser = VariablesAndExpressionParser.forControl( + null as any, + {}, + { pageNumber: 2, row: 3, column: 4 }, + undefined, + null + ) + + const keyAndExpected = [ + ['this:page', 2], + ['this:column', 4], + ['this:row', 3], + ['this:location', '2/3/4'], + ['this:page_name', '$(internal:page_number_2_name)'], + ['this:active', '$(internal:b_active_2_3_4)'], + ['this:step', '$(internal:b_step_2_3_4)'], + ['this:step_count', '$(internal:b_step_count_2_3_4)'], + ['this:actions_running', '$(internal:b_actions_running_2_3_4)'], + ['this:button_status', '$(internal:b_status_2_3_4)'], + ] as const + + type _VerifyThisVariablesComplete = Expect> + + const cache = parser['valueCacheAccessor'] + for (const [key, expectedValue] of keyAndExpected) { + expect(cache.has(key), `expected cache to have key "${key}"`).toBe(true) + expect(cache.get(key), `cache value for "${key}"`).toBe(expectedValue) + } + + expect(cache.has('this:surface_id'), 'cache should omit this:surface_id').toBe(false) + }) + + it('has $(this:*) keys with expected values, through surface', () => { + const parser = VariablesAndExpressionParser.forControl( + null as any, + {}, + { pageNumber: 2, row: 3, column: 4 }, + 'specific-surface-id', + null + ) + + const keyAndExpected = [ + ['this:page', 2], + ['this:column', 4], + ['this:row', 3], + ['this:location', '2/3/4'], + ['this:page_name', '$(internal:page_number_2_name)'], + ['this:active', '$(internal:b_active_2_3_4)'], + ['this:step', '$(internal:b_step_2_3_4)'], + ['this:step_count', '$(internal:b_step_count_2_3_4)'], + ['this:actions_running', '$(internal:b_actions_running_2_3_4)'], + ['this:button_status', '$(internal:b_status_2_3_4)'], + ['this:surface_id', 'specific-surface-id'], + ] as const + + type _VerifyThisVariablesComplete = Expect< + Equal<(typeof keyAndExpected)[number][0], ThisLocationThroughSurfaceVariable> + > + + const cache = parser['valueCacheAccessor'] + for (const [key, expectedValue] of keyAndExpected) { + expect(cache.has(key), `expected cache to have key "${key}"`).toBe(true) + expect(cache.get(key), `cache value for "${key}"`).toBe(expectedValue) + } + }) + + it.each([null, undefined])( + '$0 location: primitive this:* variables are undefined, location without surface', + (noControlLocation) => { + const parser = VariablesAndExpressionParser.forControl(null as any, {}, noControlLocation, undefined, null) + + const cache = parser['valueCacheAccessor'] + for (const key of ThisControlPrimitiveVariables) { + expect(cache.has(key), `expected cache to have key "${key}"`).toBe(true) + expect(cache.get(key), `cache value for "${key}"`).toBeUndefined() + } + } + ) + + it.each([null, undefined])( + '$0 location: complex this:* variables are VARIABLE_UNKNOWN_VALUE, location without surface', + (noControlLocation) => { + const parser = VariablesAndExpressionParser.forControl(null as any, {}, noControlLocation, undefined, null) + + const cache = parser['valueCacheAccessor'] + for (const key of ThisControlComplexVariables) { + expect(cache.has(key), `expected cache to have key "${key}"`).toBe(true) + expect(cache.get(key), `cache value for "${key}"`).toBe(VARIABLE_UNKNOWN_VALUE) + } + } + ) + + it.each([null, undefined])( + '$0 location: primitive this:* variables are undefined, location with surface', + (noControlLocation) => { + const parser = VariablesAndExpressionParser.forControl( + null as any, + {}, + noControlLocation, + 'surfs up dude', + null + ) + + const cache = parser['valueCacheAccessor'] + for (const key of ThisControlPrimitiveVariables) { + expect(cache.has(key), `expected cache to have key "${key}"`).toBe(true) + expect(cache.get(key), `cache value for "${key}"`).toBeUndefined() + } + } + ) + + it.each([null, undefined])( + '$0 location: complex this:* variables are VARIABLE_UNKNOWN_VALUE, location with surface', + (noControlLocation) => { + const parser = VariablesAndExpressionParser.forControl( + null as any, + {}, + noControlLocation, + 'surfs up dude', + null + ) + + const cache = parser['valueCacheAccessor'] + for (const key of ThisControlComplexVariables) { + expect(cache.has(key), `expected cache to have key "${key}"`).toBe(true) + expect(cache.get(key), `cache value for "${key}"`).toBe(VARIABLE_UNKNOWN_VALUE) + } + } + ) + + it.each([null, undefined])( + '$0 location: this:surface_id is as specified, location with surface', + (noControlLocation) => { + const parser = VariablesAndExpressionParser.forControl( + null as any, + {}, + noControlLocation, + 'surfs up dude', + null + ) + + const cache = parser['valueCacheAccessor'] + expect(cache.has('this:surface_id'), 'cache this:surface_id key').toBe(true) + expect(cache.get('this:surface_id'), 'cache this:surface_id value').toBe('surfs up dude') + } + ) + }) + + describe('for surface expression, with only surface-relevant variables', () => { + it('has all three $(this:*) variables exposed in a surface expression', () => { + const parser = VariablesAndExpressionParser.forSurface( + null as any, + {}, + 'this surface', + 'surface page number', + null + ) + + const keyAndExpected = [ + ['this:surface_id', 'this surface'], + ['this:page', 'surface page number'], + ['this:page_name', '$(internal:page_number_surface page number_name)'], + ] as const + + type _VerifySurfaceVariablesComplete = Expect> + + const cache = parser['valueCacheAccessor'] + for (const [key, expectedValue] of keyAndExpected) { + expect(cache.has(key), `expected cache to have key "${key}"`).toBe(true) + expect(cache.get(key), `expected value of cache entry for "${key}"`).toBe(expectedValue) + } + }) + + it('does not contain location-related variables', () => { + const parser = VariablesAndExpressionParser.forSurface( + null as any, + {}, + 'this surface', + 'surface page number', + null + ) + + const cache = parser['valueCacheAccessor'] + for (const key of ThisControlVariables) { + expect(cache.has(key), `expected cache to have key "${key}"`).toBe( + key === 'this:page' || key === 'this:page_name' + ) + } + }) + }) + describe('parseVariables', () => { it('should return unchanged string when no variables present', () => { const parser = createParser() @@ -1054,24 +1294,22 @@ describe('VariablesAndExpressionParser', () => { }) }) - describe('thisValues and overrideValues', () => { - it('should use thisValues when available', () => { - const thisValues: VariablesCache = new Map([['custom:val', 'from-this']]) - - const parser = createParser({}, thisValues) - const result = parser.parseVariables('$(custom:val)') + describe('contextual variables and overrideValues', () => { + it('should use contextual variable values when available', () => { + const parser = createParser({}, { pageNumber: 2, row: 3, column: 5 }) + const result = parser.parseVariables('$(this:location)') - expect(result.text).toBe('from-this') - expect(result.variableIds).toContain('custom:val') + expect(result.text).toBe('2/3/5') + expect(result.variableIds).toContain('this:location') }) - it('should prefer thisValues over rawVariables', () => { - const thisValues: VariablesCache = new Map([['test:var1', 'overridden']]) - - const parser = createParser(defaultVariables, thisValues) - const result = parser.parseVariables('$(test:var1)') + it('should prefer contextual variables over rawVariables', () => { + const parser = createParser(defaultVariables, { pageNumber: 6, row: 2, column: 4 }).createChildParser({ + 'this:row': 42, + }) + const result = parser.parseVariables('$(this:row)') - expect(result.text).toBe('overridden') + expect(result.text).toBe('2') }) }) @@ -1179,7 +1417,7 @@ describe('VariablesAndExpressionParser', () => { describe('createChildParser', () => { it('child inherits raw variable values from parent', () => { - const parser = new VariablesAndExpressionParser(null as any, defaultVariables, new Map(), null, null) + const parser = VariablesAndExpressionParser.forControl(null as any, defaultVariables, null, undefined, null) const child = parser.createChildParser({}) const result = child.parseVariables('$(test:var1)') @@ -1187,47 +1425,23 @@ describe('VariablesAndExpressionParser', () => { expect(result.variableIds).toContain('test:var1') }) - it('child inherits thisValues from parent', () => { + it('child inherits desired contextual variable values from parent', () => { const thisValues: VariablesCache = new Map([['custom:val', 'from-this']]) - const parser = new VariablesAndExpressionParser(null as any, {}, thisValues, null, null) - const child = parser.createChildParser({}) - - const result = child.parseVariables('$(custom:val)') - expect(result.text).toBe('from-this') - }) - - it('child inherits parent override values', () => { - const parser = new VariablesAndExpressionParser(null as any, {}, new Map(), null, { - 'override:val': 'parent-override', - }) + const parser = VariablesAndExpressionParser.forControl( + null as any, + {}, + { pageNumber: 3, row: 1, column: 3 }, + undefined, + null + ) const child = parser.createChildParser({}) - const result = child.parseVariables('$(override:val)') - expect(result.text).toBe('parent-override') - }) - - it('child new overrides take precedence over parent overrides', () => { - const parser = new VariablesAndExpressionParser(null as any, {}, new Map(), null, { - 'override:val': 'parent-override', - }) - const child = parser.createChildParser({ 'override:val': 'child-override' }) - - const result = child.parseVariables('$(override:val)') - expect(result.text).toBe('child-override') - }) - - it('non-overlapping parent overrides remain accessible in child', () => { - const parser = new VariablesAndExpressionParser(null as any, {}, new Map(), null, { - 'override:parent-only': 'parent-value', - }) - const child = parser.createChildParser({ 'override:child-only': 'child-value' }) - - expect(child.parseVariables('$(override:parent-only)').text).toBe('parent-value') - expect(child.parseVariables('$(override:child-only)').text).toBe('child-value') + const result = child.parseVariables('$(this:location)') + expect(result.text).toBe('3/1/3') }) it('child overrides do not affect parent', () => { - const parser = new VariablesAndExpressionParser(null as any, {}, new Map(), null, null) + const parser = VariablesAndExpressionParser.forControl(null as any, {}, null, undefined, null) const child = parser.createChildParser({ 'override:new': 'child-value' }) expect(parser.parseVariables('$(override:new)').text).toBe('$NA') @@ -1242,7 +1456,7 @@ describe('VariablesAndExpressionParser', () => { connectionId: 'non-internal', definitionId: 'some-def', } as unknown as ControlEntityInstance - const parser = new VariablesAndExpressionParser(null as any, {}, new Map(), [mockEntity], null) + const parser = VariablesAndExpressionParser.forControl(null as any, {}, null, undefined, [mockEntity]) const child = parser.createChildParser({}) const result = child.parseVariables('$(local:myvar)') @@ -1258,7 +1472,7 @@ describe('VariablesAndExpressionParser', () => { connectionId: 'non-internal', definitionId: 'some-def', } as unknown as ControlEntityInstance - const parser = new VariablesAndExpressionParser(null as any, {}, new Map(), [mockEntity], null) + const parser = VariablesAndExpressionParser.forControl(null as any, {}, null, undefined, [mockEntity]) const child = parser.createChildParser({ 'local:myvar': 'override-value' }) // localValues (inherited) take priority over overrideVariableValues @@ -1267,7 +1481,7 @@ describe('VariablesAndExpressionParser', () => { }) it('child executeExpression works with inherited raw variables', () => { - const parser = new VariablesAndExpressionParser(null as any, defaultVariables, new Map(), null, null) + const parser = VariablesAndExpressionParser.forControl(null as any, defaultVariables, null, undefined, null) const child = parser.createChildParser({}) const result = child.executeExpression('$(test:num) + 1', undefined) @@ -1276,7 +1490,7 @@ describe('VariablesAndExpressionParser', () => { }) it('child executeExpression uses child override values', () => { - const parser = new VariablesAndExpressionParser(null as any, {}, new Map(), null, null) + const parser = VariablesAndExpressionParser.forControl(null as any, {}, null, undefined, null) const child = parser.createChildParser({ 'custom:num': 100 }) const result = child.executeExpression('$(custom:num) * 2', undefined) @@ -1285,7 +1499,7 @@ describe('VariablesAndExpressionParser', () => { }) it('child override shadows parent raw variable', () => { - const parser = new VariablesAndExpressionParser(null as any, defaultVariables, new Map(), null, null) + const parser = VariablesAndExpressionParser.forControl(null as any, defaultVariables, null, undefined, null) const child = parser.createChildParser({ 'test:var1': 'shadowed' }) expect(child.parseVariables('$(test:var1)').text).toBe('shadowed') diff --git a/package.json b/package.json index abbacd2fda..d8e77006d9 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "prettier": "^3.8.3", "socket": "^1.1.102", "tsx": "^4.22.3", + "type-testing": "^0.2.0", "typescript": "~6.0.3", "typescript-eslint": "^8.59.4", "vitest": "^4.1.7", diff --git a/yarn.lock b/yarn.lock index 7d47bfd89b..d78469b631 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1877,6 +1877,7 @@ __metadata: prettier: "npm:^3.8.3" socket: "npm:^1.1.102" tsx: "npm:^4.22.3" + type-testing: "npm:^0.2.0" typescript: "npm:~6.0.3" typescript-eslint: "npm:^8.59.4" vitest: "npm:^4.1.7"