From 0734fa1033a35907d27c986cc7494180825d96d6 Mon Sep 17 00:00:00 2001 From: chrismclarke Date: Tue, 11 Feb 2025 10:39:24 -0800 Subject: [PATCH] refactor: template-calc service --- .../template-calc.service.mock.spec.ts | 22 +++-- .../services/template-calc.service.spec.ts | 89 +++++++++++++++++++ .../services/template-calc.service.ts | 46 ++++++++-- 3 files changed, 143 insertions(+), 14 deletions(-) diff --git a/src/app/shared/components/template/services/template-calc.service.mock.spec.ts b/src/app/shared/components/template/services/template-calc.service.mock.spec.ts index 692a1e4221..fb6f66e8f7 100644 --- a/src/app/shared/components/template/services/template-calc.service.mock.spec.ts +++ b/src/app/shared/components/template/services/template-calc.service.mock.spec.ts @@ -3,15 +3,21 @@ import { ICalcContext, TemplateCalcService } from "./template-calc.service"; import { MockDataEvaluationService } from "src/app/shared/services/data/data-evaluation.service.mock.spec"; export class MockTemplateCalcService extends TemplateCalcService { - constructor() { + constructor(mockCalcContext: Partial = {}) { super(new MockDataEvaluationService(), new MockLocalStorageService()); - } - - public getCalcContext(): ICalcContext { - return { - thisCtxt: {}, - globalConstants: {}, - globalFunctions: {}, + // merge any mock calc context with defaults + super["calcContext"] = { + thisCtxt: { + // ensure default calc included as allows `@calc(...)` to be triggered via `this.calc(...)` + calc: (v: any) => v, + ...mockCalcContext.thisCtxt, + }, + globalConstants: { + ...mockCalcContext.globalConstants, + }, + globalFunctions: { + ...mockCalcContext.globalFunctions, + }, }; } } diff --git a/src/app/shared/components/template/services/template-calc.service.spec.ts b/src/app/shared/components/template/services/template-calc.service.spec.ts index 344a5affad..9df9645cb8 100644 --- a/src/app/shared/components/template/services/template-calc.service.spec.ts +++ b/src/app/shared/components/template/services/template-calc.service.spec.ts @@ -1,3 +1,92 @@ +import { TestBed } from "@angular/core/testing"; +import { TemplateCalcService } from "./template-calc.service"; +import { MockDataEvaluationService } from "src/app/shared/services/data/data-evaluation.service.mock.spec"; +import { MockLocalStorageService } from "src/app/shared/services/local-storage/local-storage.service.mock.spec"; +import { DataEvaluationService } from "src/app/shared/services/data/data-evaluation.service"; +import { LocalStorageService } from "src/app/shared/services/local-storage/local-storage.service"; +import { CORE_CALC_FUNCTIONS } from "./template-calc-functions/core-calc-functions"; +import { PLH_CALC_FUNCTIONS } from "./template-calc-functions/plh-calc-functions"; + +/** + * Call standalone tests via: + * yarn ng test --include src/app/shared/components/template/services/template-calc.service.spec.ts + */ +describe("TemplateCalcService", () => { + let service: TemplateCalcService; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + { + provide: DataEvaluationService, + useValue: new MockDataEvaluationService(), + }, + { + provide: LocalStorageService, + useValue: new MockLocalStorageService(), + }, + ], + }); + service = TestBed.inject(TemplateCalcService); + await service.ready(); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("[Deprecated] populates window.calc with calc functions", () => { + const { calc } = service["windowWithCalc"]; + expect(Object.keys(calc)).toEqual([ + ...Object.keys(CORE_CALC_FUNCTIONS), + ...Object.keys(PLH_CALC_FUNCTIONS), + ]); + }); + + it("populates window.date_fns with date_fns lib", () => { + const { date_fns } = service["windowWithCalc"]; + expect(date_fns.hoursToMilliseconds(1)).toEqual(3600000); + }); + + it("Gets calc context", () => { + const calcContext = service.getCalcContext(); + const { globalConstants, globalFunctions, thisCtxt } = calcContext; + // global constants and functions are passed by service + expect(globalConstants).toEqual({}); + // global functions should be same as window but without 3rd party + expect(Object.keys(globalFunctions)).toEqual([ + ...Object.keys(CORE_CALC_FUNCTIONS), + ...Object.keys(PLH_CALC_FUNCTIONS), + ]); + // By default a handful of specific app data context fields always populated + expect(Object.keys(thisCtxt)).toEqual([ + "calc", + "app_day", + "app_first_launch", + "app_user_id", + "device_info", + ]); + }); + + it("evaluates @calc statements", async () => { + const res = await service.evaluate("@calc(2*2)"); + expect(res).toEqual(4); + }); + + it("evaluates @calc statements with window functions", async () => { + const res = await service.evaluate("@calc(window.date_fns.hoursToMilliseconds(1))", {}); + expect(res).toEqual(3600000); + }); + + it("evaluates @calc statements with local context", async () => { + // NOTE - `@local` expression should be converted to `this.local` in template-parser + const res = await service.evaluate("@calc(this.local.test_string)", { + local: { test_string: "hello" }, + }); + expect(res).toEqual("hello"); + }); +}); + /** * TODO - Add testing data and methods * diff --git a/src/app/shared/components/template/services/template-calc.service.ts b/src/app/shared/components/template/services/template-calc.service.ts index 65d8225a9b..0e8af9d874 100644 --- a/src/app/shared/components/template/services/template-calc.service.ts +++ b/src/app/shared/components/template/services/template-calc.service.ts @@ -1,4 +1,4 @@ -import { IFunctionHashmap, IConstantHashmap } from "src/app/shared/utils"; +import { IFunctionHashmap, IConstantHashmap, evaluateJSExpression } from "src/app/shared/utils"; import { Injectable } from "@angular/core"; import { Device, DeviceInfo } from "@capacitor/device"; import * as date_fns from "date-fns"; @@ -7,6 +7,22 @@ import { AsyncServiceBase } from "src/app/shared/services/asyncService.base"; import { PLH_CALC_FUNCTIONS } from "./template-calc-functions/plh-calc-functions"; import { CORE_CALC_FUNCTIONS } from "./template-calc-functions/core-calc-functions"; import { LocalStorageService } from "src/app/shared/services/local-storage/local-storage.service"; +import type { FlowTypes } from "packages/data-models"; + +/** Window context with additional calc service properties attached */ +type IWindowWithCalc = Window & { + /** + * @deprecated 0.18.0 Prefer to call directly instead of via window + * + * ✔️ `@calc(pick_random([1,2,3]))` + * ❌ `@calc(window.calc.pick_random([1,2,3]))` + * + * All user-defined calc available globally + * */ + calc: IFunctionHashmap; + /** Specific data_fns lib available globally */ + date_fns: typeof date_fns; +}; @Injectable({ providedIn: "root" }) export class TemplateCalcService extends AsyncServiceBase { @@ -27,6 +43,11 @@ export class TemplateCalcService extends AsyncServiceBase { super("TemplateCalc"); this.registerInitFunction(this.initialise); } + + private get windowWithCalc() { + return window as any as IWindowWithCalc; + } + private async initialise() { this.ensureSyncServicesReady([this.localStorageService]); await this.ensureAsyncServicesReady([this.dataEvaluationService]); @@ -41,10 +62,23 @@ export class TemplateCalcService extends AsyncServiceBase { this.calcContext = this.generateCalcContext(); } // Assign all calc functions also to window object to allow calling between functions - (window as any).calc = this.calcFunctions; + this.windowWithCalc.calc = this.calcFunctions; return this.calcContext; } + /** + * Evaluate inner expression provided by `@calc(...)` expression + * The expression is evaluated as JS, with additional access to global constants, function and + * evaluation context variables + * */ + public evaluate(expression: string, evalContext: FlowTypes.TemplateRowEvalContext = {}) { + const calcContext = this.getCalcContext(); + const calcExpression = expression.replace(/@/gi, "this."); + const { thisCtxt, globalFunctions, globalConstants } = calcContext; + const mergedContext = { ...thisCtxt, ...evalContext }; + return evaluateJSExpression(calcExpression, mergedContext, globalFunctions, globalConstants); + } + /** * Main export for use in evaluation statements. Includes all functions listed below * alongside additional a base for variables found at `this.` @@ -91,9 +125,7 @@ export class TemplateCalcService extends AsyncServiceBase { * ``` */ private generateGlobalConstants() { - const globalConstants: IConstantHashmap = { - test_var: "hello", - }; + const globalConstants: IConstantHashmap = {}; return globalConstants; } @@ -105,7 +137,7 @@ export class TemplateCalcService extends AsyncServiceBase { * ``` */ private addWindowCalcFunctions() { - (window as any).date_fns = date_fns; + this.windowWithCalc.date_fns = date_fns; } } @@ -116,6 +148,8 @@ export class TemplateCalcService extends AsyncServiceBase { */ export interface ICalcContext { thisCtxt: { + /** assign `this.calc` variable to handle replaced `this.calc(...)` expressions */ + calc: (v) => any; [name: string]: any; }; globalFunctions: IFunctionHashmap;