From 50cbb1600eac339a3ca6166e724d174d01b413cb Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Fri, 26 Apr 2024 12:32:37 +0100 Subject: [PATCH 1/8] spike: make local context available in data-items loop --- .../services/template-variables.service.ts | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/src/app/shared/components/template/services/template-variables.service.ts b/src/app/shared/components/template/services/template-variables.service.ts index 75984760e8..16585e799d 100644 --- a/src/app/shared/components/template/services/template-variables.service.ts +++ b/src/app/shared/components/template/services/template-variables.service.ts @@ -318,36 +318,48 @@ export class TemplateVariablesService extends AsyncServiceBase { // TODO - assumed 'value' field will be returned but this could be provided instead as an arg const returnField: keyof FlowTypes.TemplateRow = "value"; - // find any rows where nested path corresponds to match path - let matchedRows: { row: FlowTypes.TemplateRow; nestedName: string }[] = []; - Object.entries(templateRowMap).forEach(([nestedName, row]) => { - if (nestedName === fieldName || nestedName.endsWith(`.${fieldName}`)) { - matchedRows.push({ row, nestedName }); - } - }); - // no match found. If condition assume this is fine, otherwise authoring error - if (matchedRows.length === 0) { - if (field === "condition") { - parsedValue = false; - } else { + // If there is itemContext, then we're in an items loop and the templateRowMap is only of the items rows. + // In this case, we can look at the calcContext to see if the local variable value has already been parsed, and return this value + if (context.itemContext) { + parsedValue = context.calcContext.thisCtxt?.local?.[evaluator.fieldName]; + if (!parsedValue && parsedValue !== 0) { parseSuccess = false; console.error(`@local.${fieldName} not found`, { evaluator, rowMap: templateRowMap, }); } - } - // match found - return least nested (in case of duplicates) - else { - matchedRows = matchedRows.sort( - (a, b) => a.nestedName.split(".").length - b.nestedName.split(".").length - ); - if (matchedRows.length > 1) { - console.warn(`@local.${fieldName} found multiple`, { matchedRows }); + } else { + // find any rows where nested path corresponds to match path + let matchedRows: { row: FlowTypes.TemplateRow; nestedName: string }[] = []; + Object.entries(templateRowMap).forEach(([nestedName, row]) => { + if (nestedName === fieldName || nestedName.endsWith(`.${fieldName}`)) { + matchedRows.push({ row, nestedName }); + } + }); + // no match found. If condition assume this is fine, otherwise authoring error + if (matchedRows.length === 0) { + if (field === "condition") { + parsedValue = false; + } else { + parseSuccess = false; + console.error(`@local.${fieldName} not found`, { + evaluator, + rowMap: templateRowMap, + }); + } + } + // match found - return least nested (in case of duplicates) + else { + matchedRows = matchedRows.sort( + (a, b) => a.nestedName.split(".").length - b.nestedName.split(".").length + ); + if (matchedRows.length > 1) { + console.warn(`@local.${fieldName} found multiple`, { matchedRows }); + } + parsedValue = matchedRows[0].row[returnField]; } - parsedValue = matchedRows[0].row[returnField]; } - break; case "field": // console.warn("To keep consistency with rapidpro, @fields should be used instead of @field"); From 4b195d9965ea40a7743e74a6dffa2dee49b8faf9 Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Mon, 29 Apr 2024 12:25:29 +0100 Subject: [PATCH 2/8] chore: clarify comment --- .../components/template/services/template-variables.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/components/template/services/template-variables.service.ts b/src/app/shared/components/template/services/template-variables.service.ts index 16585e799d..e2288c0f76 100644 --- a/src/app/shared/components/template/services/template-variables.service.ts +++ b/src/app/shared/components/template/services/template-variables.service.ts @@ -318,7 +318,7 @@ export class TemplateVariablesService extends AsyncServiceBase { // TODO - assumed 'value' field will be returned but this could be provided instead as an arg const returnField: keyof FlowTypes.TemplateRow = "value"; - // If there is itemContext, then we're in an items loop and the templateRowMap is only of the items rows. + // In a data-items loop, the templateRowMap is only of the items rows. // In this case, we can look at the calcContext to see if the local variable value has already been parsed, and return this value if (context.itemContext) { parsedValue = context.calcContext.thisCtxt?.local?.[evaluator.fieldName]; From b545f75f61163244cd4701f8c755f820c2d9a1d5 Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Mon, 29 Apr 2024 12:38:53 +0100 Subject: [PATCH 3/8] chore: added spec files from feat/set-item-at-index --- .../services/template-calc.service.spec.ts | 114 +++++++ .../template-variables.service.spec.ts | 300 ++++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 src/app/shared/components/template/services/template-calc.service.spec.ts create mode 100644 src/app/shared/components/template/services/template-variables.service.spec.ts 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 new file mode 100644 index 0000000000..2752062482 --- /dev/null +++ b/src/app/shared/components/template/services/template-calc.service.spec.ts @@ -0,0 +1,114 @@ +import { ICalcContext, TemplateCalcService } from "./template-calc.service"; + +export class MockTemplateCalcService implements Partial { + public async ready(): Promise { + return true; + } + + public getCalcContext(): ICalcContext { + return { + thisCtxt: {}, + globalConstants: {}, + globalFunctions: {}, + }; + } +} + +/** + * TODO - Add testing data and methods + * + * Temp methods below used when testing locally in quokka + * + + const functions = [ + function pick_random(items: any[] = []) { + console.log("picking random from arr"); + try { + const randomItem = items[Math.floor(Math.random() * items.length)]; + return randomItem; + } catch (error) { + console.error("[pick_random] error", { items, error }); + return items; + } + }, + function lookup_text(items: { name: string; text: string }[], name: string) { + console.log("looking up text", items, name); + try { + const foundItem = items.find((el) => el.name === name); + return foundItem ? foundItem.text : name; + } catch (error) { + console.error("[lookup_text] error", { items, name, error }); + return name; + } + }, +]; + +const tests = [ + // "Math.random()", + // "calc_1: 3\ncalc_2: 31\ncalc_3: 2", + // "pick_random()", + // "Math.max(this.local.example_calc_1,this.local.example_calc_2,this.local.example_calc_3)", + "this.local.number_1", // TODO - test this.local.this.local.some_field + // "this.local.relative_1", + // "hello: this.local.number_1", + // "hello: this.local.number_1 + this.local.number_2", // will not work + // "this.local.number_1 * this.local.number_2 > 5", + // TODO - "this.fields.non_existant_field" + // TODO - include more values that fail to check fails gracefully (e.g. no accidental infinite loops or thrown errors) +]; +const thisCtxt = getCtxt(); +const results = {}; + +tests.forEach((str, i) => { + const evaluated = evaluate(str); + results[i] = { evaluated, type: typeof evaluated }; +}); +console.table(results); + +function evaluate(str: string) { + // line break characters can mess up so handle separately + // make sure to not map a single line string as this will make the return type always string + const lines = str.split("\n"); + return lines.length > 1 + ? lines.map((s) => evaluateJSExpression(s, thisCtxt, functions)).join("") + : evaluateJSExpression(str, thisCtxt, functions); +} + +const t1 = new Function(`"use strict"; return (${tests[0]})`).apply({}); +console.log("t1", t1, typeof t1); + +console.log(evaluateJSExpression(tests[0])); + +function evaluateJSExpression( + expression: string, + thisCtxt = {}, + globalFunctions: ((...args: any) => any)[] = [] +): any { + const globalString = globalFunctions.map((fn) => fn.toString()).join(";"); + const funcString = `"use strict"; ${globalString}; return (${expression});`; + const func = new Function(funcString); + + return func.apply(thisCtxt); +} + +function getCtxt() { + return { + calc: (v) => v, + local: { + example_calc_1: 1, + example_calc_2: 2, + example_calc_3: false, + number_1: 1, + number_2: 2, + arr_1: ["hello", "world"], + obj_1: { hello: "world" }, + str_1: "hello", + str_2: "world", + bool_1: true, + bool_2: false, + relative_1: "this.local.number_1", + }, + }; +} + + */ diff --git a/src/app/shared/components/template/services/template-variables.service.spec.ts b/src/app/shared/components/template/services/template-variables.service.spec.ts new file mode 100644 index 0000000000..32ebb0cd26 --- /dev/null +++ b/src/app/shared/components/template/services/template-variables.service.spec.ts @@ -0,0 +1,300 @@ +import { TestBed } from "@angular/core/testing"; +import { IVariableContext, TemplateVariablesService } from "./template-variables.service"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { TemplateFieldService } from "./template-field.service"; +import { MockTemplateFieldService } from "./template-field.service.spec"; +import { AppDataService } from "src/app/shared/services/data/app-data.service"; +import { CampaignService } from "src/app/feature/campaign/campaign.service"; +import { MockAppDataService } from "src/app/shared/services/data/app-data.service.spec"; +import { TemplateCalcService } from "./template-calc.service"; +import { MockTemplateCalcService } from "./template-calc.service.spec"; + +const MOCK_APP_DATA = {}; + +const MOCK_ITEM_STRING = "@item._index + 1"; + +// Context as logged from this debug template: +// https://docs.google.com/spreadsheets/d/1tL6CPHEIW-GPMYjdhVKQToy_hZ1H5qNIBkkh9XnA5QM/edit#gid=114708400 +const MOCK_CONTEXT_WITH_ITEM_CTXT: IVariableContext = { + itemContext: { + id: "id1", + number: 1, + string: "hello", + boolean: true, + _index: 0, + _id: "id1", + _first: true, + _last: false, + }, + templateRowMap: { + "data_items_2.text_1": { + type: "text", + value: 2, + _translations: { + value: {}, + }, + name: "text_1", + _nested_name: "data_items_2.text_1", + _dynamicFields: { + value: [ + { + fullExpression: "@item._index + 1", + matchedExpression: "@item._index", + type: "item", + fieldName: "_index", + }, + ], + }, + _dynamicDependencies: { + "@item._index": ["value"], + }, + _evalContext: { + itemContext: { + id: "id2", + number: 2, + string: "goodbye", + boolean: false, + _index: 1, + _id: "id2", + _first: false, + _last: true, + }, + }, + }, + }, + row: { + type: "text", + value: "@item._index + 1", + _translations: { + value: {}, + }, + name: "text_1", + _nested_name: "data_items_2.text_1", + _dynamicFields: { + value: [ + { + fullExpression: "@item._index + 1", + matchedExpression: "@item._index", + type: "item", + fieldName: "_index", + }, + ], + }, + _dynamicDependencies: { + "@item._index": ["value"], + }, + _evalContext: { + itemContext: { + id: "id1", + number: 1, + string: "hello", + boolean: true, + _index: 0, + _id: "id1", + _first: true, + _last: false, + }, + }, + }, + field: "value", + calcContext: { + globalConstants: { + test_var: "hello", + }, + globalFunctions: {}, + thisCtxt: { + app_day: 8, + app_first_launch: "2024-04-05T17:49:29", + fields: { + _app_language: "gb_en", + _app_skin: "default", + }, + local: { + button_list: [ + { + image: "images/icons/house_white.svg", + target_template: "home_screen", + }, + { + image: "images/icons/star_white.svg", + target_template: "comp_button", + }, + { + image: "images/icons/book_white.svg", + target_template: "comp_button", + }, + ], + }, + item: { + _index: 1, + }, + }, + }, +}; + +const MOCK_CONTEXT_WITHOUT_ITEM_CTXT: IVariableContext = { + templateRowMap: { + "data_items_2.text_1": { + type: "text", + value: "@item._index + 1", + _translations: { + value: {}, + }, + name: "text_1", + _nested_name: "data_items_2.text_1", + _dynamicFields: { + value: [ + { + fullExpression: "@item._index + 1", + matchedExpression: "@item._index", + type: "item", + fieldName: "_index", + }, + ], + }, + _dynamicDependencies: { + "@item._index": ["value"], + }, + }, + data_items_2: { + type: "data_items", + value: "debug_item_data", + rows: [ + { + type: "text", + value: "@item._index + 1", + _translations: { + value: {}, + }, + name: "text_1", + _nested_name: "data_items_2.text_1", + _dynamicFields: { + value: [ + { + fullExpression: "@item._index + 1", + matchedExpression: "@item._index", + type: "item", + fieldName: "_index", + }, + ], + }, + _dynamicDependencies: { + "@item._index": ["value"], + }, + }, + ], + name: "data_items_2", + _nested_name: "data_items_2", + }, + }, + row: { + type: "text", + value: "@item._index + 1", + _translations: { + value: {}, + }, + name: "text_1", + _nested_name: "data_items_2.text_1", + _dynamicFields: { + value: [ + { + fullExpression: "@item._index + 1", + matchedExpression: "@item._index", + type: "item", + fieldName: "_index", + }, + ], + }, + _dynamicDependencies: { + "@item._index": ["value"], + }, + }, + field: "value", + calcContext: { + globalConstants: { + test_var: "hello", + }, + globalFunctions: {}, + thisCtxt: { + app_day: 8, + app_first_launch: "2024-04-05T17:49:29", + fields: { + _app_language: "gb_en", + _app_skin: "default", + }, + local: { + button_list: [ + { + image: "images/icons/house_white.svg", + target_template: "home_screen", + }, + { + image: "images/icons/star_white.svg", + target_template: "comp_button", + }, + { + image: "images/icons/book_white.svg", + target_template: "comp_button", + }, + ], + }, + item: { + _index: 1, + }, + }, + }, +}; + +/** + * Call standalone tests via: + * yarn ng test --include src/app/shared/components/template/services/template-variables.service.spec.ts + */ +describe("TemplateVariablesService", () => { + let service: TemplateVariablesService; + let getNextCampaignRowsSpy: jasmine.Spy; + + beforeEach(() => { + getNextCampaignRowsSpy = jasmine.createSpy(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { + provide: TemplateFieldService, + useValue: new MockTemplateFieldService(), + }, + { + provide: AppDataService, + useValue: new MockAppDataService(MOCK_APP_DATA), + }, + { + provide: TemplateCalcService, + useValue: new MockTemplateCalcService(), + }, + // Mock single method from campaign service called + { + provide: CampaignService, + useValue: { + ready: async () => { + return true; + }, + getNextCampaignRows: getNextCampaignRowsSpy, + }, + }, + ], + }); + service = TestBed.inject(TemplateVariablesService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("evaluates string containing item variable", async () => { + const value = await service.evaluatePLHData(MOCK_ITEM_STRING, MOCK_CONTEXT_WITH_ITEM_CTXT); + expect(value).toBe(1); + }); + it("does not evaluate item string without appropriate context", async () => { + const value = await service.evaluatePLHData(MOCK_ITEM_STRING, MOCK_CONTEXT_WITHOUT_ITEM_CTXT); + expect(value).toBe(MOCK_ITEM_STRING); + }); +}); From ca7092bdd4bc13597f5fcab21ff5301d61e899d2 Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Mon, 29 Apr 2024 15:46:41 +0100 Subject: [PATCH 4/8] fix: exclude condition field from item-specific local context handling --- .../template/services/template-variables.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/shared/components/template/services/template-variables.service.ts b/src/app/shared/components/template/services/template-variables.service.ts index e2288c0f76..4da184b1c3 100644 --- a/src/app/shared/components/template/services/template-variables.service.ts +++ b/src/app/shared/components/template/services/template-variables.service.ts @@ -320,8 +320,8 @@ export class TemplateVariablesService extends AsyncServiceBase { // In a data-items loop, the templateRowMap is only of the items rows. // In this case, we can look at the calcContext to see if the local variable value has already been parsed, and return this value - if (context.itemContext) { - parsedValue = context.calcContext.thisCtxt?.local?.[evaluator.fieldName]; + if (context.itemContext && field !== "condition") { + parsedValue = context.calcContext.thisCtxt?.local?.[fieldName]; if (!parsedValue && parsedValue !== 0) { parseSuccess = false; console.error(`@local.${fieldName} not found`, { From e8f7267cff515e7773328f68c8480877de79c7e4 Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Mon, 29 Apr 2024 17:47:40 +0100 Subject: [PATCH 5/8] wip: template variables service spec tests --- .../services/template-calc.service.spec.ts | 17 +- .../template-variables.service.spec.ts | 232 ++++++++++++++++-- 2 files changed, 218 insertions(+), 31 deletions(-) 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 2752062482..2f5e80c3e7 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,16 +1,23 @@ import { ICalcContext, TemplateCalcService } from "./template-calc.service"; +const EMPTY_CALC_CONTEXT: ICalcContext = { + thisCtxt: {}, + globalConstants: {}, + globalFunctions: {}, +}; + export class MockTemplateCalcService implements Partial { + private calcContext: ICalcContext; + constructor(mockCalcContext?: Partial) { + this.calcContext = { ...EMPTY_CALC_CONTEXT, ...mockCalcContext }; + } + public async ready(): Promise { return true; } public getCalcContext(): ICalcContext { - return { - thisCtxt: {}, - globalConstants: {}, - globalFunctions: {}, - }; + return this.calcContext; } } diff --git a/src/app/shared/components/template/services/template-variables.service.spec.ts b/src/app/shared/components/template/services/template-variables.service.spec.ts index 32ebb0cd26..0fa94b76fb 100644 --- a/src/app/shared/components/template/services/template-variables.service.spec.ts +++ b/src/app/shared/components/template/services/template-variables.service.spec.ts @@ -6,16 +6,30 @@ import { MockTemplateFieldService } from "./template-field.service.spec"; import { AppDataService } from "src/app/shared/services/data/app-data.service"; import { CampaignService } from "src/app/feature/campaign/campaign.service"; import { MockAppDataService } from "src/app/shared/services/data/app-data.service.spec"; -import { TemplateCalcService } from "./template-calc.service"; +import { ICalcContext, TemplateCalcService } from "./template-calc.service"; import { MockTemplateCalcService } from "./template-calc.service.spec"; const MOCK_APP_DATA = {}; +const test_local_1 = "test value 1"; + +const MOCK_CALC_CONTEXT: ICalcContext = { + thisCtxt: { + local: { + test_local_1, + }, + }, + globalConstants: {}, + globalFunctions: {}, +}; + const MOCK_ITEM_STRING = "@item._index + 1"; +const MOCK_LOCAL_STRING = "Local variable value: @local.test_local_1"; + // Context as logged from this debug template: // https://docs.google.com/spreadsheets/d/1tL6CPHEIW-GPMYjdhVKQToy_hZ1H5qNIBkkh9XnA5QM/edit#gid=114708400 -const MOCK_CONTEXT_WITH_ITEM_CTXT: IVariableContext = { +const MOCK_CONTEXT_ITEM_VAR_WITH_ITEM_CTXT: IVariableContext = { itemContext: { id: "id1", number: 1, @@ -110,20 +124,7 @@ const MOCK_CONTEXT_WITH_ITEM_CTXT: IVariableContext = { _app_skin: "default", }, local: { - button_list: [ - { - image: "images/icons/house_white.svg", - target_template: "home_screen", - }, - { - image: "images/icons/star_white.svg", - target_template: "comp_button", - }, - { - image: "images/icons/book_white.svg", - target_template: "comp_button", - }, - ], + test_local_1: "test value 1", }, item: { _index: 1, @@ -131,8 +132,7 @@ const MOCK_CONTEXT_WITH_ITEM_CTXT: IVariableContext = { }, }, }; - -const MOCK_CONTEXT_WITHOUT_ITEM_CTXT: IVariableContext = { +const MOCK_CONTEXT_ITEM_VAR_WITHOUT_ITEM_CTXT: IVariableContext = { templateRowMap: { "data_items_2.text_1": { type: "text", @@ -222,6 +222,85 @@ const MOCK_CONTEXT_WITHOUT_ITEM_CTXT: IVariableContext = { _app_language: "gb_en", _app_skin: "default", }, + local: { + test_local_1: "test value 1", + }, + item: { + _index: 1, + }, + }, + }, +}; + +const MOCK_CONTEXT_LOCAL_VAR: IVariableContext = { + templateRowMap: { + mock_variable_1: { + name: "mock_variable_1", + value: "Mock value 1", + _translations: { + value: {}, + }, + type: "set_variable", + _nested_name: "mock_variable_1", + }, + mock_text_1: { + type: "text", + name: "mock_text_1", + value: "Text that includes Mock value 1", + _translations: { + value: {}, + }, + _nested_name: "mock_text_1", + _dynamicFields: { + value: [ + { + fullExpression: "Text that includes @local.mock_variable_1", + matchedExpression: "@local.mock_variable_1", + type: "local", + fieldName: "mock_variable_1", + }, + ], + }, + _dynamicDependencies: { + "@local.mock_variable_1": ["value"], + }, + }, + }, + row: { + type: "text", + name: "mock_text_1", + value: "Text that includes @local.mock_variable_1", + _translations: { + value: {}, + }, + _nested_name: "mock_text_1", + _dynamicFields: { + value: [ + { + fullExpression: "Text that includes @local.mock_variable_1", + matchedExpression: "@local.mock_variable_1", + type: "local", + fieldName: "mock_variable_1", + }, + ], + }, + _dynamicDependencies: { + "@local.mock_variable_1": ["value"], + }, + }, + field: "value", + calcContext: { + globalConstants: { + test_var: "hello", + }, + globalFunctions: {}, + thisCtxt: { + app_day: 11, + app_first_launch: "2024-04-05T17:49:29", + fields: { + _app_language: "gb_en", + _app_skin: "default", + }, local: { button_list: [ { @@ -237,6 +316,108 @@ const MOCK_CONTEXT_WITHOUT_ITEM_CTXT: IVariableContext = { target_template: "comp_button", }, ], + mock_variable_1: "Mock value 1", + }, + }, + }, +}; + +const MOCK_CONTEXT_LOCAL_VAR_WITH_ITEM_CTXT: IVariableContext = { + itemContext: { + id: "id1", + number: 1, + string: "hello", + boolean: true, + _index: 0, + _id: "id1", + _first: true, + _last: false, + }, + templateRowMap: { + "data_items_2.text_1": { + type: "text", + value: 2, + _translations: { + value: {}, + }, + name: "text_1", + _nested_name: "data_items_2.text_1", + _dynamicFields: { + value: [ + { + fullExpression: MOCK_LOCAL_STRING, + matchedExpression: "@local.test_local_1", + type: "local", + fieldName: "test_local_1", + }, + ], + }, + _dynamicDependencies: { + "@local.test_local_1": ["value"], + }, + _evalContext: { + itemContext: { + id: "id2", + number: 2, + string: "goodbye", + boolean: false, + _index: 1, + _id: "id2", + _first: false, + _last: true, + }, + }, + }, + }, + row: { + type: "text", + value: "@local.test_local_1", + _translations: { + value: {}, + }, + name: "text_1", + _nested_name: "data_items_2.text_1", + _dynamicFields: { + value: [ + { + fullExpression: MOCK_LOCAL_STRING, + matchedExpression: "@local.test_local_1", + type: "local", + fieldName: "test_local_1", + }, + ], + }, + _dynamicDependencies: { + "@local.test_local_1": ["value"], + }, + _evalContext: { + itemContext: { + id: "id1", + number: 1, + string: "hello", + boolean: true, + _index: 0, + _id: "id1", + _first: true, + _last: false, + }, + }, + }, + field: "value", + calcContext: { + globalConstants: { + test_var: "hello", + }, + globalFunctions: {}, + thisCtxt: { + app_day: 8, + app_first_launch: "2024-04-05T17:49:29", + fields: { + _app_language: "gb_en", + _app_skin: "default", + }, + local: { + test_local_1: "test value 1", }, item: { _index: 1, @@ -268,7 +449,7 @@ describe("TemplateVariablesService", () => { }, { provide: TemplateCalcService, - useValue: new MockTemplateCalcService(), + useValue: new MockTemplateCalcService(MOCK_CALC_CONTEXT), }, // Mock single method from campaign service called { @@ -289,12 +470,11 @@ describe("TemplateVariablesService", () => { expect(service).toBeTruthy(); }); - it("evaluates string containing item variable", async () => { - const value = await service.evaluatePLHData(MOCK_ITEM_STRING, MOCK_CONTEXT_WITH_ITEM_CTXT); - expect(value).toBe(1); - }); - it("does not evaluate item string without appropriate context", async () => { - const value = await service.evaluatePLHData(MOCK_ITEM_STRING, MOCK_CONTEXT_WITHOUT_ITEM_CTXT); - expect(value).toBe(MOCK_ITEM_STRING); + it("applies local variable values from thisCtxt when in an item loop", async () => { + const value = await service.evaluatePLHData( + MOCK_LOCAL_STRING, + MOCK_CONTEXT_LOCAL_VAR_WITH_ITEM_CTXT + ); + expect(value).toBe(MOCK_LOCAL_STRING.replace("@local.test_local_1", test_local_1)); }); }); From cb3bb2a6c07b8dcc3756f1cb5282c5a2fd21f601 Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Tue, 30 Apr 2024 10:54:15 +0100 Subject: [PATCH 6/8] chore: replace template-variables.service.spec.ts with version from master --- .../template-variables.service.spec.ts | 467 ++++-------------- 1 file changed, 84 insertions(+), 383 deletions(-) diff --git a/src/app/shared/components/template/services/template-variables.service.spec.ts b/src/app/shared/components/template/services/template-variables.service.spec.ts index 0fa94b76fb..259296d750 100644 --- a/src/app/shared/components/template/services/template-variables.service.spec.ts +++ b/src/app/shared/components/template/services/template-variables.service.spec.ts @@ -6,323 +6,76 @@ import { MockTemplateFieldService } from "./template-field.service.spec"; import { AppDataService } from "src/app/shared/services/data/app-data.service"; import { CampaignService } from "src/app/feature/campaign/campaign.service"; import { MockAppDataService } from "src/app/shared/services/data/app-data.service.spec"; -import { ICalcContext, TemplateCalcService } from "./template-calc.service"; +import { TemplateCalcService } from "./template-calc.service"; import { MockTemplateCalcService } from "./template-calc.service.spec"; const MOCK_APP_DATA = {}; -const test_local_1 = "test value 1"; - -const MOCK_CALC_CONTEXT: ICalcContext = { - thisCtxt: { - local: { - test_local_1, - }, - }, - globalConstants: {}, - globalFunctions: {}, +// Fields populated to mock field service +const MOCK_FIELDS = { + _app_language: "gb_en", + _app_skin: "default", + string_field: "test_string_value", + number_field: 2, }; -const MOCK_ITEM_STRING = "@item._index + 1"; - -const MOCK_LOCAL_STRING = "Local variable value: @local.test_local_1"; - -// Context as logged from this debug template: -// https://docs.google.com/spreadsheets/d/1tL6CPHEIW-GPMYjdhVKQToy_hZ1H5qNIBkkh9XnA5QM/edit#gid=114708400 -const MOCK_CONTEXT_ITEM_VAR_WITH_ITEM_CTXT: IVariableContext = { - itemContext: { - id: "id1", - number: 1, - string: "hello", - boolean: true, - _index: 0, - _id: "id1", - _first: true, - _last: false, - }, - templateRowMap: { - "data_items_2.text_1": { - type: "text", - value: 2, - _translations: { - value: {}, - }, - name: "text_1", - _nested_name: "data_items_2.text_1", - _dynamicFields: { - value: [ - { - fullExpression: "@item._index + 1", - matchedExpression: "@item._index", - type: "item", - fieldName: "_index", - }, - ], - }, - _dynamicDependencies: { - "@item._index": ["value"], - }, - _evalContext: { - itemContext: { - id: "id2", - number: 2, - string: "goodbye", - boolean: false, - _index: 1, - _id: "id2", - _first: false, - _last: true, - }, - }, - }, - }, +const MOCK_CONTEXT_BASE: IVariableContext = { + // Assume the row will have a dynamic 'field' entry + field: "value", row: { type: "text", - value: "@item._index + 1", - _translations: { - value: {}, - }, - name: "text_1", - _nested_name: "data_items_2.text_1", - _dynamicFields: { - value: [ - { - fullExpression: "@item._index + 1", - matchedExpression: "@item._index", - type: "item", - fieldName: "_index", - }, - ], - }, - _dynamicDependencies: { - "@item._index": ["value"], - }, - _evalContext: { - itemContext: { - id: "id1", - number: 1, - string: "hello", - boolean: true, - _index: 0, - _id: "id1", - _first: true, - _last: false, - }, - }, + value: "", + name: "test_row", + _nested_name: "test_row", }, - field: "value", + templateRowMap: {}, calcContext: { - globalConstants: { - test_var: "hello", - }, + globalConstants: {}, globalFunctions: {}, thisCtxt: { - app_day: 8, - app_first_launch: "2024-04-05T17:49:29", - fields: { - _app_language: "gb_en", - _app_skin: "default", - }, - local: { - test_local_1: "test value 1", - }, - item: { - _index: 1, - }, + fields: MOCK_FIELDS, + local: {}, }, }, }; -const MOCK_CONTEXT_ITEM_VAR_WITHOUT_ITEM_CTXT: IVariableContext = { - templateRowMap: { - "data_items_2.text_1": { - type: "text", - value: "@item._index + 1", - _translations: { - value: {}, - }, - name: "text_1", - _nested_name: "data_items_2.text_1", - _dynamicFields: { - value: [ - { - fullExpression: "@item._index + 1", - matchedExpression: "@item._index", - type: "item", - fieldName: "_index", - }, - ], - }, - _dynamicDependencies: { - "@item._index": ["value"], - }, - }, - data_items_2: { - type: "data_items", - value: "debug_item_data", - rows: [ - { - type: "text", - value: "@item._index + 1", - _translations: { - value: {}, - }, - name: "text_1", - _nested_name: "data_items_2.text_1", - _dynamicFields: { - value: [ - { - fullExpression: "@item._index + 1", - matchedExpression: "@item._index", - type: "item", - fieldName: "_index", - }, - ], - }, - _dynamicDependencies: { - "@item._index": ["value"], - }, - }, - ], - name: "data_items_2", - _nested_name: "data_items_2", - }, - }, + +const TEST_FIELD_CONTEXT: IVariableContext = { + ...MOCK_CONTEXT_BASE, row: { - type: "text", - value: "@item._index + 1", - _translations: { - value: {}, - }, - name: "text_1", - _nested_name: "data_items_2.text_1", + ...MOCK_CONTEXT_BASE.row, + value: "Hello @fields.string_field", _dynamicFields: { value: [ { - fullExpression: "@item._index + 1", - matchedExpression: "@item._index", - type: "item", - fieldName: "_index", + fullExpression: "Hello @fields.string_field", + matchedExpression: "@fields.string_field", + type: "fields", + fieldName: "string_field", }, ], }, - _dynamicDependencies: { - "@item._index": ["value"], - }, - }, - field: "value", - calcContext: { - globalConstants: { - test_var: "hello", - }, - globalFunctions: {}, - thisCtxt: { - app_day: 8, - app_first_launch: "2024-04-05T17:49:29", - fields: { - _app_language: "gb_en", - _app_skin: "default", - }, - local: { - test_local_1: "test value 1", - }, - item: { - _index: 1, - }, - }, }, }; -const MOCK_CONTEXT_LOCAL_VAR: IVariableContext = { - templateRowMap: { - mock_variable_1: { - name: "mock_variable_1", - value: "Mock value 1", - _translations: { - value: {}, - }, - type: "set_variable", - _nested_name: "mock_variable_1", - }, - mock_text_1: { - type: "text", - name: "mock_text_1", - value: "Text that includes Mock value 1", - _translations: { - value: {}, - }, - _nested_name: "mock_text_1", - _dynamicFields: { - value: [ - { - fullExpression: "Text that includes @local.mock_variable_1", - matchedExpression: "@local.mock_variable_1", - type: "local", - fieldName: "mock_variable_1", - }, - ], - }, - _dynamicDependencies: { - "@local.mock_variable_1": ["value"], - }, - }, - }, +// Context adapted from this debug template: +// https://docs.google.com/spreadsheets/d/1tL6CPHEIW-GPMYjdhVKQToy_hZ1H5qNIBkkh9XnA5QM/edit#gid=114708400 +const TEST_ITEM_CONTEXT: IVariableContext = { + ...MOCK_CONTEXT_BASE, row: { - type: "text", - name: "mock_text_1", - value: "Text that includes @local.mock_variable_1", - _translations: { - value: {}, - }, - _nested_name: "mock_text_1", + ...MOCK_CONTEXT_BASE.row, + value: "@item._index + 1", + // NOTE - any evaluated fields should appea _dynamicFields: { value: [ { - fullExpression: "Text that includes @local.mock_variable_1", - matchedExpression: "@local.mock_variable_1", - type: "local", - fieldName: "mock_variable_1", + fullExpression: "@item._index + 1", + matchedExpression: "@item._index", + type: "item", + fieldName: "_index", }, ], }, - _dynamicDependencies: { - "@local.mock_variable_1": ["value"], - }, - }, - field: "value", - calcContext: { - globalConstants: { - test_var: "hello", - }, - globalFunctions: {}, - thisCtxt: { - app_day: 11, - app_first_launch: "2024-04-05T17:49:29", - fields: { - _app_language: "gb_en", - _app_skin: "default", - }, - local: { - button_list: [ - { - image: "images/icons/house_white.svg", - target_template: "home_screen", - }, - { - image: "images/icons/star_white.svg", - target_template: "comp_button", - }, - { - image: "images/icons/book_white.svg", - target_template: "comp_button", - }, - ], - mock_variable_1: "Mock value 1", - }, - }, }, -}; - -const MOCK_CONTEXT_LOCAL_VAR_WITH_ITEM_CTXT: IVariableContext = { itemContext: { id: "id1", number: 1, @@ -333,97 +86,6 @@ const MOCK_CONTEXT_LOCAL_VAR_WITH_ITEM_CTXT: IVariableContext = { _first: true, _last: false, }, - templateRowMap: { - "data_items_2.text_1": { - type: "text", - value: 2, - _translations: { - value: {}, - }, - name: "text_1", - _nested_name: "data_items_2.text_1", - _dynamicFields: { - value: [ - { - fullExpression: MOCK_LOCAL_STRING, - matchedExpression: "@local.test_local_1", - type: "local", - fieldName: "test_local_1", - }, - ], - }, - _dynamicDependencies: { - "@local.test_local_1": ["value"], - }, - _evalContext: { - itemContext: { - id: "id2", - number: 2, - string: "goodbye", - boolean: false, - _index: 1, - _id: "id2", - _first: false, - _last: true, - }, - }, - }, - }, - row: { - type: "text", - value: "@local.test_local_1", - _translations: { - value: {}, - }, - name: "text_1", - _nested_name: "data_items_2.text_1", - _dynamicFields: { - value: [ - { - fullExpression: MOCK_LOCAL_STRING, - matchedExpression: "@local.test_local_1", - type: "local", - fieldName: "test_local_1", - }, - ], - }, - _dynamicDependencies: { - "@local.test_local_1": ["value"], - }, - _evalContext: { - itemContext: { - id: "id1", - number: 1, - string: "hello", - boolean: true, - _index: 0, - _id: "id1", - _first: true, - _last: false, - }, - }, - }, - field: "value", - calcContext: { - globalConstants: { - test_var: "hello", - }, - globalFunctions: {}, - thisCtxt: { - app_day: 8, - app_first_launch: "2024-04-05T17:49:29", - fields: { - _app_language: "gb_en", - _app_skin: "default", - }, - local: { - test_local_1: "test value 1", - }, - item: { - _index: 1, - }, - }, - }, }; /** @@ -434,14 +96,14 @@ describe("TemplateVariablesService", () => { let service: TemplateVariablesService; let getNextCampaignRowsSpy: jasmine.Spy; - beforeEach(() => { + beforeEach(async () => { getNextCampaignRowsSpy = jasmine.createSpy(); TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ { provide: TemplateFieldService, - useValue: new MockTemplateFieldService(), + useValue: new MockTemplateFieldService(MOCK_FIELDS), }, { provide: AppDataService, @@ -449,7 +111,7 @@ describe("TemplateVariablesService", () => { }, { provide: TemplateCalcService, - useValue: new MockTemplateCalcService(MOCK_CALC_CONTEXT), + useValue: new MockTemplateCalcService(), }, // Mock single method from campaign service called { @@ -464,17 +126,56 @@ describe("TemplateVariablesService", () => { ], }); service = TestBed.inject(TemplateVariablesService); + await service.ready(); }); it("should be created", () => { expect(service).toBeTruthy(); }); - it("applies local variable values from thisCtxt when in an item loop", async () => { - const value = await service.evaluatePLHData( - MOCK_LOCAL_STRING, - MOCK_CONTEXT_LOCAL_VAR_WITH_ITEM_CTXT + it("Evaluates PLH Data String", async () => { + console.log({ TEST_FIELD_CONTEXT }); + const res = await service.evaluatePLHData("Hello @fields.string_field", TEST_FIELD_CONTEXT); + expect(res).toEqual("Hello test_string_value"); + // Data will only be evaluated if it has been pre-parsed, extracting dynamic references + // If not returns raw value + delete TEST_FIELD_CONTEXT.row._dynamicFields; + const resWithoutDynamicContext = await service.evaluatePLHData( + "@fields.string_field", + TEST_FIELD_CONTEXT + ); + expect(resWithoutDynamicContext).toEqual("@fields.string_field"); + /** + * TODO - include all edge cases, e.g. raw, item, calc, deep-nested, object, array etc. + const res = await service.evaluatePLHData(["@fields.string_field"], MOCK_CONTEXT); + expect(res).toEqual({ 1: "test_string_value" }); + const res = await service.evaluatePLHData( + { + nested: "@fields.string_field", + }, + MOCK_CONTEXT + ); + expect(res).toEqual({ nested: "test_string_value" }); + */ + }); + it("Evaluates condition strings", async () => { + // Condition strings are evaluated without any previous pre-parsed dynamic fields + const res = await service.evaluateConditionString("@fields.number_field > 3"); + expect(res).toEqual(false); + }); + + it("evaluates string containing item variable", async () => { + const MOCK_ITEM_STRING = "@item._index + 1"; + // Parse expression when item context included + const resWithItemContext = await service.evaluatePLHData(MOCK_ITEM_STRING, TEST_ITEM_CONTEXT); + expect(resWithItemContext).toEqual(1); + // Retain raw expression if evaluating outside of item context + // https://github.com/IDEMSInternational/parenting-app-ui/pull/2215#discussion_r1514757364 + delete TEST_ITEM_CONTEXT.itemContext; + const resWithoutItemContext = await service.evaluatePLHData( + MOCK_ITEM_STRING, + TEST_ITEM_CONTEXT ); - expect(value).toBe(MOCK_LOCAL_STRING.replace("@local.test_local_1", test_local_1)); + expect(resWithoutItemContext).toEqual(MOCK_ITEM_STRING); }); }); From e900a1a56d489d0e0cc02865fb337ccc27a90a3d Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Tue, 30 Apr 2024 13:02:08 +0100 Subject: [PATCH 7/8] wip: template-variables service spec tests --- .../template-variables.service.spec.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/app/shared/components/template/services/template-variables.service.spec.ts b/src/app/shared/components/template/services/template-variables.service.spec.ts index 259296d750..017f6f5ef2 100644 --- a/src/app/shared/components/template/services/template-variables.service.spec.ts +++ b/src/app/shared/components/template/services/template-variables.service.spec.ts @@ -19,6 +19,10 @@ const MOCK_FIELDS = { number_field: 2, }; +const MOCK_LOCALS = { + string_local: "test_local_string_value", +}; + const MOCK_CONTEXT_BASE: IVariableContext = { // Assume the row will have a dynamic 'field' entry field: "value", @@ -34,7 +38,7 @@ const MOCK_CONTEXT_BASE: IVariableContext = { globalFunctions: {}, thisCtxt: { fields: MOCK_FIELDS, - local: {}, + local: MOCK_LOCALS, }, }, }; @@ -88,6 +92,34 @@ const TEST_ITEM_CONTEXT: IVariableContext = { }, }; +const TEST_LOCAL_CONTEXT: IVariableContext = { + ...MOCK_CONTEXT_BASE, + row: { + ...MOCK_CONTEXT_BASE.row, + value: "Hello @local.string_local", + _dynamicFields: { + value: [ + { + fullExpression: "Hello @local.string_local", + matchedExpression: "@local.string_local", + type: "local", + fieldName: "string_local", + }, + ], + }, + }, + itemContext: { + id: "id1", + number: 1, + string: "hello", + boolean: true, + _index: 0, + _id: "id1", + _first: true, + _last: false, + }, +}; + /** * Call standalone tests via: * yarn ng test --include src/app/shared/components/template/services/template-variables.service.spec.ts @@ -178,4 +210,19 @@ describe("TemplateVariablesService", () => { ); expect(resWithoutItemContext).toEqual(MOCK_ITEM_STRING); }); + + it("evaluates string containing local variable", async () => { + const MOCK_LOCAL_STRING = "Hello @local.string_local"; + // Parse expression when item context included + const resWithItemContext = await service.evaluatePLHData(MOCK_LOCAL_STRING, TEST_LOCAL_CONTEXT); + expect(resWithItemContext).toEqual("Hello test_local_string_value"); + // Retain raw expression if evaluating outside of item context + // https://github.com/IDEMSInternational/parenting-app-ui/pull/2215#discussion_r1514757364 + // delete TEST_ITEM_CONTEXT.itemContext; + // const resWithoutItemContext = await service.evaluatePLHData( + // MOCK_ITEM_STRING, + // TEST_ITEM_CONTEXT + // ); + // expect(resWithoutItemContext).toEqual(MOCK_ITEM_STRING); + }); }); From 1567f9feb70bc8721c1b5a4b59218b478855bafe Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Wed, 1 May 2024 18:40:01 +0100 Subject: [PATCH 8/8] test: template-variables service test coverage --- .../template-variables.service.spec.ts | 93 ++++++++++--------- 1 file changed, 51 insertions(+), 42 deletions(-) diff --git a/src/app/shared/components/template/services/template-variables.service.spec.ts b/src/app/shared/components/template/services/template-variables.service.spec.ts index 017f6f5ef2..3341064070 100644 --- a/src/app/shared/components/template/services/template-variables.service.spec.ts +++ b/src/app/shared/components/template/services/template-variables.service.spec.ts @@ -6,8 +6,9 @@ import { MockTemplateFieldService } from "./template-field.service.spec"; import { AppDataService } from "src/app/shared/services/data/app-data.service"; import { CampaignService } from "src/app/feature/campaign/campaign.service"; import { MockAppDataService } from "src/app/shared/services/data/app-data.service.spec"; -import { TemplateCalcService } from "./template-calc.service"; +import { ICalcContext, TemplateCalcService } from "./template-calc.service"; import { MockTemplateCalcService } from "./template-calc.service.spec"; +import clone from "clone"; const MOCK_APP_DATA = {}; @@ -19,10 +20,6 @@ const MOCK_FIELDS = { number_field: 2, }; -const MOCK_LOCALS = { - string_local: "test_local_string_value", -}; - const MOCK_CONTEXT_BASE: IVariableContext = { // Assume the row will have a dynamic 'field' entry field: "value", @@ -36,10 +33,7 @@ const MOCK_CONTEXT_BASE: IVariableContext = { calcContext: { globalConstants: {}, globalFunctions: {}, - thisCtxt: { - fields: MOCK_FIELDS, - local: MOCK_LOCALS, - }, + thisCtxt: {}, }, }; @@ -61,6 +55,17 @@ const TEST_FIELD_CONTEXT: IVariableContext = { }, }; +const MOCK_ITEM_CONTEXT: IVariableContext["itemContext"] = { + id: "id1", + number: 1, + string: "hello", + boolean: true, + _index: 0, + _id: "id1", + _first: true, + _last: false, +}; + // Context adapted from this debug template: // https://docs.google.com/spreadsheets/d/1tL6CPHEIW-GPMYjdhVKQToy_hZ1H5qNIBkkh9XnA5QM/edit#gid=114708400 const TEST_ITEM_CONTEXT: IVariableContext = { @@ -80,20 +85,19 @@ const TEST_ITEM_CONTEXT: IVariableContext = { ], }, }, - itemContext: { - id: "id1", - number: 1, - string: "hello", - boolean: true, - _index: 0, - _id: "id1", - _first: true, - _last: false, - }, + itemContext: MOCK_ITEM_CONTEXT, }; const TEST_LOCAL_CONTEXT: IVariableContext = { ...MOCK_CONTEXT_BASE, + templateRowMap: { + string_local: { + name: "string_local", + value: "Jasper", + type: "set_variable", + _nested_name: "string_local", + }, + }, row: { ...MOCK_CONTEXT_BASE.row, value: "Hello @local.string_local", @@ -108,16 +112,15 @@ const TEST_LOCAL_CONTEXT: IVariableContext = { ], }, }, - itemContext: { - id: "id1", - number: 1, - string: "hello", - boolean: true, - _index: 0, - _id: "id1", - _first: true, - _last: false, - }, +}; + +const TEST_LOCAL_CONTEXT_WITH_ITEM_CONTEXT = { + ...TEST_LOCAL_CONTEXT, + itemContext: MOCK_ITEM_CONTEXT, +}; + +const MOCK_CALC_CONTEXT: Partial = { + thisCtxt: { local: { string_local: "Jasper2" } }, }; /** @@ -143,7 +146,9 @@ describe("TemplateVariablesService", () => { }, { provide: TemplateCalcService, - useValue: new MockTemplateCalcService(), + // HACK: hardcoded calcContext from mock context is overridden by calcContext returned from MockTemplateCalcService, + // so insert values here for testing evaluation of local variable inside item loop + useValue: new MockTemplateCalcService(clone(MOCK_CALC_CONTEXT)), }, // Mock single method from campaign service called { @@ -211,18 +216,22 @@ describe("TemplateVariablesService", () => { expect(resWithoutItemContext).toEqual(MOCK_ITEM_STRING); }); - it("evaluates string containing local variable", async () => { + it("Evaluates string containing local variable", async () => { const MOCK_LOCAL_STRING = "Hello @local.string_local"; - // Parse expression when item context included - const resWithItemContext = await service.evaluatePLHData(MOCK_LOCAL_STRING, TEST_LOCAL_CONTEXT); - expect(resWithItemContext).toEqual("Hello test_local_string_value"); - // Retain raw expression if evaluating outside of item context - // https://github.com/IDEMSInternational/parenting-app-ui/pull/2215#discussion_r1514757364 - // delete TEST_ITEM_CONTEXT.itemContext; - // const resWithoutItemContext = await service.evaluatePLHData( - // MOCK_ITEM_STRING, - // TEST_ITEM_CONTEXT - // ); - // expect(resWithoutItemContext).toEqual(MOCK_ITEM_STRING); + const resWithLocalContext = await service.evaluatePLHData( + MOCK_LOCAL_STRING, + TEST_LOCAL_CONTEXT + ); + expect(resWithLocalContext).toEqual("Hello Jasper"); + }); + + it("Evaluates string containing local variable, inside item loop", async () => { + const MOCK_LOCAL_STRING = "Hello @local.string_local"; + // When itemContext is included (i.e. in an item loop), look to thisCtxt for the parsed local var + const resWithLocalContext = await service.evaluatePLHData( + MOCK_LOCAL_STRING, + TEST_LOCAL_CONTEXT_WITH_ITEM_CONTEXT + ); + expect(resWithLocalContext).toEqual("Hello Jasper2"); }); });