From 998aea6d0bb5c73cb117c986d4535d0679cad5df Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:28:47 -0700 Subject: [PATCH 1/5] Add rulemapping for nested rules. --- src/api/ruleMapping/ruleMapping.controller.ts | 4 +- src/api/ruleMapping/ruleMapping.service.ts | 82 +++++++++++++------ src/api/scenarioData/scenarioData.service.ts | 2 +- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/src/api/ruleMapping/ruleMapping.controller.ts b/src/api/ruleMapping/ruleMapping.controller.ts index 04fae18..8442d37 100644 --- a/src/api/ruleMapping/ruleMapping.controller.ts +++ b/src/api/ruleMapping/ruleMapping.controller.ts @@ -14,7 +14,7 @@ export class RuleMappingController { @Body('ruleContent') ruleContent: EvaluateRuleMappingDto, @Res() res: Response, ) { - const rulemap = this.ruleMappingService.ruleSchema(ruleContent); + const rulemap = await this.ruleMappingService.ruleSchema(ruleContent); try { res.setHeader('Content-Type', 'application/json'); @@ -33,7 +33,7 @@ export class RuleMappingController { @Post('/evaluate') async evaluateRuleMap(@Body() ruleContent: EvaluateRuleMappingDto) { try { - const result = this.ruleMappingService.ruleSchema(ruleContent); + const result = await this.ruleMappingService.ruleSchema(ruleContent); return { result }; } catch (error) { if (error instanceof InvalidRuleContent) { diff --git a/src/api/ruleMapping/ruleMapping.service.ts b/src/api/ruleMapping/ruleMapping.service.ts index 4256ff7..e9688c4 100644 --- a/src/api/ruleMapping/ruleMapping.service.ts +++ b/src/api/ruleMapping/ruleMapping.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Node, Edge, TraceObject, Field, RuleContent } from './ruleMapping.interface'; +import { DocumentsService } from '../documents/documents.service'; +import { ConfigService } from '@nestjs/config'; import { RuleSchema } from '../scenarioData/scenarioData.interface'; export class InvalidRuleContent extends Error { @@ -11,16 +13,31 @@ export class InvalidRuleContent extends Error { @Injectable() export class RuleMappingService { - constructor() {} + rulesDirectory: string; + constructor( + private documentsService: DocumentsService, + private configService: ConfigService, + ) { + this.rulesDirectory = this.configService.get('RULES_DIRECTORY'); + } // Extract all fields from a list of nodes - extractFields(nodes: Node[], fieldKey: 'inputs' | 'outputs'): { [key: string]: any[] } { - const fields: any[] = nodes.flatMap((node: any) => { + async extractFields(nodes: Node[], fieldKey: 'inputs' | 'outputs'): Promise<{ [key: string]: any[] }> { + const promises = nodes.map(async (node: any) => { + if (node.type === 'decisionNode' && node.content?.key) { + const generateNestedSchema = await this.ruleSchemaFile(node.content.key); + const { inputs, resultOutputs } = generateNestedSchema; + return fieldKey === 'inputs' ? inputs : resultOutputs; + } if (node.type === 'expressionNode' && node.content?.expressions) { - return node.content.expressions.map((expr: { key: any; value: any }) => ({ - key: fieldKey === 'inputs' ? expr.key : expr.value, - property: fieldKey === 'inputs' ? expr.value : expr.key, - })); + const simpleExpressionRegex = /^[a-zA-Z]+$/; + return node.content.expressions.map((expr: { key: any; value: any }) => { + const isSimpleValue = simpleExpressionRegex.test(expr.value); + return { + key: isSimpleValue ? (fieldKey === 'inputs' ? expr.key : expr.value) : expr.key, + property: isSimpleValue ? (fieldKey === 'inputs' ? expr.value : expr.key) : expr.key, + }; + }); } else if (node.type === 'functionNode' && node?.content) { return (node.content.split('\n') || []).reduce((acc: any, line: string) => { const match = line.match(fieldKey === 'inputs' ? /\s*\*\s*@param\s+/ : /\s*\*\s*@returns\s+/); @@ -42,16 +59,22 @@ export class RuleMappingService { })); } }); - return { [fieldKey]: fields }; + + const results = await Promise.all(promises); + const fields = results.flat(); + + const uniqueFieldsMap = new Map(); + + fields.forEach((field) => { + uniqueFieldsMap.set(field.property, field); + }); + + const uniqueFields = Array.from(uniqueFieldsMap.values()); + return { [fieldKey]: uniqueFields }; } // Get the final outputs of a rule from mapping the target output nodes and the edges - extractResultOutputs( - nodes: Node[], - edges: Edge[], - ): { - resultOutputs: any[]; - } { + async extractResultOutputs(nodes: Node[], edges: Edge[]): Promise<{ resultOutputs: any[] }> { // Find the output node const outputNode = nodes.find((obj) => obj.type === 'outputNode'); @@ -64,17 +87,15 @@ export class RuleMappingService { // Find the edges that connect the output node to other nodes const targetEdges = edges.filter((edge) => edge.targetId === outputNodeID); const targetOutputNodes = targetEdges.map((edge) => nodes.find((node) => node.id === edge.sourceId)); - const resultOutputs: any[] = this.extractFields(targetOutputNodes, 'outputs').outputs; + const resultOutputs: any[] = (await this.extractFields(targetOutputNodes, 'outputs')).outputs; return { resultOutputs }; } - extractInputsAndOutputs(nodes: Node[]): { - inputs: any[]; - outputs: any[]; - } { - const inputs: any[] = this.extractFields(nodes, 'inputs').inputs; - const outputs: any[] = this.extractFields(nodes, 'outputs').outputs; + async extractInputsAndOutputs(nodes: Node[]): Promise<{ inputs: any[]; outputs: any[] }> { + const inputs: any[] = (await this.extractFields(nodes, 'inputs')).inputs; + const outputs: any[] = (await this.extractFields(nodes, 'outputs')).outputs; + return { inputs, outputs }; } @@ -92,8 +113,8 @@ export class RuleMappingService { // extract only the unique inputs from a list of nodes // excludes inputs found in the outputs of other nodes - extractUniqueInputs(nodes: Node[]) { - const { inputs, outputs } = this.extractInputsAndOutputs(nodes); + async extractUniqueInputs(nodes: Node[]): Promise<{ uniqueInputs: any[] }> { + const { inputs, outputs } = await this.extractInputsAndOutputs(nodes); const outputFields = new Set(outputs.map((outputField) => outputField.property)); const uniqueInputFields = this.findUniqueFields(inputs, outputFields); @@ -103,7 +124,7 @@ export class RuleMappingService { } // generate a rule schema from a list of nodes that represent the origin inputs and all outputs of a rule - ruleSchema(ruleContent: RuleContent): RuleSchema { + async ruleSchema(ruleContent: RuleContent): Promise { if (!ruleContent) { throw new InvalidRuleContent('No content'); } @@ -111,9 +132,9 @@ export class RuleMappingService { if (!nodes || !Array.isArray(nodes)) { throw new InvalidRuleContent('Rule has no nodes'); } - const inputs: any[] = this.extractUniqueInputs(nodes).uniqueInputs; - const generalOutputs: any[] = this.extractFields(nodes, 'outputs').outputs; - const resultOutputs: any[] = this.extractResultOutputs(nodes, edges).resultOutputs; + const inputs: any[] = (await this.extractUniqueInputs(nodes)).uniqueInputs; + const generalOutputs: any[] = (await this.extractFields(nodes, 'outputs')).outputs; + const resultOutputs: any[] = (await this.extractResultOutputs(nodes, edges)).resultOutputs; //get unique outputs excluding final outputs const outputs: any[] = generalOutputs.filter( @@ -128,6 +149,13 @@ export class RuleMappingService { return { inputs, outputs, resultOutputs }; } + // generate a rule schema from a given local file + async ruleSchemaFile(ruleFileName: string): Promise { + const fileContent = await this.documentsService.getFileContent(ruleFileName); + const ruleContent = await JSON.parse(fileContent.toString()); + return this.ruleSchema(ruleContent); + } + // generate a schema for the inputs and outputs of a rule given the trace data of a rule run evaluateRuleSchema(trace: TraceObject): { input: any; diff --git a/src/api/scenarioData/scenarioData.service.ts b/src/api/scenarioData/scenarioData.service.ts index a18952b..bee024c 100644 --- a/src/api/scenarioData/scenarioData.service.ts +++ b/src/api/scenarioData/scenarioData.service.ts @@ -109,7 +109,7 @@ export class ScenarioDataService { const fileContent = await this.documentsService.getFileContent(goRulesJSONFilename); ruleContent = await JSON.parse(fileContent.toString()); } - const ruleSchema: RuleSchema = this.ruleMappingService.ruleSchema(ruleContent); + const ruleSchema: RuleSchema = await this.ruleMappingService.ruleSchema(ruleContent); const results: { [scenarioId: string]: any } = {}; for (const scenario of scenarios as ScenarioDataDocument[]) { From 0ad3379a41de743d1d6a029c498797fc8c9eef0a Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Tue, 23 Jul 2024 12:17:29 -0700 Subject: [PATCH 2/5] Update testing for linked rules rulemapping. --- .../ruleMapping.controller.spec.ts | 4 +- .../ruleMapping/ruleMapping.service.spec.ts | 127 +++++++++++++----- src/api/ruleMapping/ruleMapping.service.ts | 17 ++- .../scenarioData/scenarioData.service.spec.ts | 6 +- 4 files changed, 116 insertions(+), 38 deletions(-) diff --git a/src/api/ruleMapping/ruleMapping.controller.spec.ts b/src/api/ruleMapping/ruleMapping.controller.spec.ts index 62e22cc..3302c9c 100644 --- a/src/api/ruleMapping/ruleMapping.controller.spec.ts +++ b/src/api/ruleMapping/ruleMapping.controller.spec.ts @@ -41,7 +41,7 @@ describe('RuleMappingController', () => { const ruleFileName = 'test-rule.json'; const ruleContent = { nodes: [], edges: [] }; const rulemap = { inputs: [], outputs: [], resultOutputs: [] }; - jest.spyOn(service, 'ruleSchema').mockReturnValue(rulemap); + jest.spyOn(service, 'ruleSchema').mockResolvedValue(rulemap); const mockResponse = { setHeader: jest.fn(), @@ -65,7 +65,7 @@ describe('RuleMappingController', () => { const nodes = [{ id: '1', type: 'someType', content: { inputs: [], outputs: [] } }]; const edges = [{ id: '2', type: 'someType', targetId: '1', sourceId: '1' }]; const result = { inputs: [], outputs: [], resultOutputs: [] }; - jest.spyOn(service, 'ruleSchema').mockReturnValue(result); + jest.spyOn(service, 'ruleSchema').mockResolvedValue(result); const dto: EvaluateRuleMappingDto = { nodes, edges }; const response = await controller.evaluateRuleMap(dto); diff --git a/src/api/ruleMapping/ruleMapping.service.spec.ts b/src/api/ruleMapping/ruleMapping.service.spec.ts index 6b6c589..7c5a49c 100644 --- a/src/api/ruleMapping/ruleMapping.service.spec.ts +++ b/src/api/ruleMapping/ruleMapping.service.spec.ts @@ -29,7 +29,7 @@ describe('RuleMappingService', () => { }); describe('extractFields', () => { - it('should extract inputs correctly', () => { + it('should extract inputs correctly', async () => { const nodes: Node[] = [ { type: 'someType', @@ -51,17 +51,17 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractFields(nodes, 'inputs'); + const result = await service.extractFields(nodes, 'inputs'); expect(result).toEqual({ inputs: [ { id: '1', name: 'Input1', type: 'string', property: 'field1' }, { id: '2', name: 'Input2', type: 'number', property: 'field2' }, - { key: 'expr1', property: 'field3' }, + { key: 'expr1', property: 'field3', exception: null }, ], }); }); - it('should extract outputs correctly', () => { + it('should extract outputs correctly', async () => { const nodes: Node[] = [ { type: 'someType', @@ -75,7 +75,7 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractFields(nodes, 'outputs'); + const result = await service.extractFields(nodes, 'outputs'); expect(result).toEqual({ outputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field1' }, @@ -84,15 +84,15 @@ describe('RuleMappingService', () => { }); }); - it('should handle empty nodes array', () => { + it('should handle empty nodes array', async () => { const nodes: Node[] = []; - const result = service.extractFields(nodes, 'outputs'); + const result = await service.extractFields(nodes, 'outputs'); expect(result).toEqual({ outputs: [] }); }); }); describe('extractResultOutputs', () => { - it('should extract result outputs correctly when there is one output node and corresponding edges', () => { + it('should extract result outputs correctly when there is one output node and corresponding edges', async () => { const nodes: Node[] = [ { id: '1', @@ -122,14 +122,14 @@ describe('RuleMappingService', () => { { id: '2', type: 'someType', sourceId: '3', targetId: '2' }, ]; - jest.spyOn(service, 'extractFields').mockReturnValue({ + jest.spyOn(service, 'extractFields').mockResolvedValue({ outputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, { id: '2', name: 'Output2', type: 'number', property: 'field3' }, ], }); - const result = service.extractResultOutputs(nodes, edges); + const result = await service.extractResultOutputs(nodes, edges); expect(result).toEqual({ resultOutputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, @@ -138,7 +138,7 @@ describe('RuleMappingService', () => { }); }); - it('should throw an error if no outputNode is found', () => { + it('should throw an error if no outputNode is found', async () => { const nodes: Node[] = [ { id: '1', @@ -151,10 +151,15 @@ describe('RuleMappingService', () => { const edges: Edge[] = [{ id: '1', type: 'someType', sourceId: '1', targetId: '2' }]; - expect(() => service.extractResultOutputs(nodes, edges)).toThrow('No outputNode found in the nodes array'); + try { + await service.extractResultOutputs(nodes, edges); + fail('Expected extractResultOutputs to throw an error'); + } catch (error) { + expect(error.message).toBe('No outputNode found in the nodes array'); + } }); - it('should return an empty array if no target edges are found for the output node', () => { + it('should return an empty array if no target edges are found for the output node', async () => { const nodes: Node[] = [ { id: '1', @@ -174,17 +179,17 @@ describe('RuleMappingService', () => { const edges: Edge[] = []; - jest.spyOn(service, 'extractFields').mockReturnValue({ + jest.spyOn(service, 'extractFields').mockResolvedValue({ outputs: [], }); - const result = service.extractResultOutputs(nodes, edges); + const result = await service.extractResultOutputs(nodes, edges); expect(result).toEqual({ resultOutputs: [], }); }); - it('should handle cases where there are multiple output nodes', () => { + it('should handle cases where there are multiple output nodes', async () => { const nodes: Node[] = [ { id: '1', @@ -214,14 +219,14 @@ describe('RuleMappingService', () => { { id: '2', type: 'someType', sourceId: '1', targetId: '3' }, ]; - jest.spyOn(service, 'extractFields').mockReturnValue({ + jest.spyOn(service, 'extractFields').mockResolvedValue({ outputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, { id: '2', name: 'Output2', type: 'number', property: 'field3' }, ], }); - const result = service.extractResultOutputs(nodes, edges); + const result = await service.extractResultOutputs(nodes, edges); expect(result).toEqual({ resultOutputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, @@ -232,7 +237,7 @@ describe('RuleMappingService', () => { }); describe('extractInputsAndOutputs', () => { - it('should extract inputs and outputs correctly', () => { + it('should extract inputs and outputs correctly', async () => { const nodes: Node[] = [ { type: 'someType', @@ -257,24 +262,24 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractInputsAndOutputs(nodes); + const result = await service.extractInputsAndOutputs(nodes); expect(result).toEqual({ inputs: [ { id: '1', name: 'Input1', type: 'string', property: 'field1' }, { id: '2', name: 'Input2', type: 'number', property: 'field2' }, - { key: 'expr1', property: 'field5' }, + { key: 'expr1', property: 'field5', exception: null }, ], outputs: [ { id: '3', name: 'Output1', type: 'string', property: 'field3' }, { id: '4', name: 'Output2', type: 'number', property: 'field4' }, - { key: 'field5', property: 'expr1' }, + { key: 'field5', property: 'expr1', exception: null }, ], }); }); - it('should handle empty nodes array', () => { + it('should handle empty nodes array', async () => { const nodes: Node[] = []; - const result = service.extractInputsAndOutputs(nodes); + const result = await service.extractInputsAndOutputs(nodes); expect(result).toEqual({ inputs: [], outputs: [] }); }); }); @@ -294,7 +299,7 @@ describe('RuleMappingService', () => { }); describe('extractUniqueInputs', () => { - it('should extract unique inputs correctly', () => { + it('should extract unique inputs correctly', async () => { const nodes: Node[] = [ { type: 'someType', @@ -309,13 +314,13 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractUniqueInputs(nodes); + const result = await service.extractUniqueInputs(nodes); expect(result).toEqual({ uniqueInputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], }); }); - it('should handle nodes without inputs', () => { + it('should handle nodes without inputs', async () => { const nodes: Node[] = [ { type: 'someType', @@ -326,7 +331,7 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractUniqueInputs(nodes); + const result = await service.extractUniqueInputs(nodes); expect(result).toEqual({ uniqueInputs: [], }); @@ -334,7 +339,7 @@ describe('RuleMappingService', () => { }); describe('ruleSchema', () => { - it('should generate a rule schema correctly', () => { + it('should generate a rule schema correctly', async () => { const nodes: Node[] = [ { id: '1', @@ -360,7 +365,7 @@ describe('RuleMappingService', () => { const edges = [{ id: '1', type: 'someType', targetId: '2', sourceId: '1' }]; // Edge connects input node to output node - const result = service.ruleSchema({ nodes, edges }); + const result = await service.ruleSchema({ nodes, edges }); expect(result).toEqual({ inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], outputs: [ @@ -373,7 +378,14 @@ describe('RuleMappingService', () => { it('should handle invalid request data', async () => { const ruleContent = { nodes: 'invalid' } as unknown as RuleContent; - expect(() => service.ruleSchema(ruleContent)).toThrow(new InvalidRuleContent('Rule has no nodes')); + + try { + await service.ruleSchema(ruleContent); + fail('Expected ruleSchema to throw an error'); + } catch (error) { + expect(error).toBeInstanceOf(InvalidRuleContent); + expect(error.message).toBe('Rule has no nodes'); + } }); }); @@ -485,4 +497,57 @@ describe('RuleMappingService', () => { }); }); }); + + describe('ruleSchemaFile', () => { + it('should generate a rule schema from a file', async () => { + const mockFileContent = JSON.stringify({ + nodes: [ + { + id: '1', + type: 'inputNode', // Assuming this node is an input node + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + }, + }, + { + id: '2', + type: 'outputNode', // This node is the output node + content: { + outputs: [ + { id: '3', name: 'Output1', type: 'string', field: 'field2' }, + { id: '4', name: 'Output2', type: 'number', field: 'field3' }, + ], + }, + }, + ], + edges: [ + { + id: '1', + type: 'someType', + targetId: '2', + sourceId: '1', + }, + ], + }); + + const mockGetFileContent = jest.fn().mockResolvedValue(mockFileContent); + DocumentsService.prototype.getFileContent = mockGetFileContent; + + const filePath = 'path/to/mock/file.json'; + const result = await service.ruleSchemaFile(filePath); + + expect(mockGetFileContent).toHaveBeenCalledWith(filePath); + expect(result).toEqual({ + resultOutputs: [], + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + outputs: [ + { id: '3', name: 'Output1', type: 'string', property: 'field2' }, + { id: '4', name: 'Output2', type: 'number', property: 'field3' }, + ], + }); + }); + }); }); diff --git a/src/api/ruleMapping/ruleMapping.service.ts b/src/api/ruleMapping/ruleMapping.service.ts index e9688c4..4580027 100644 --- a/src/api/ruleMapping/ruleMapping.service.ts +++ b/src/api/ruleMapping/ruleMapping.service.ts @@ -30,12 +30,13 @@ export class RuleMappingService { return fieldKey === 'inputs' ? inputs : resultOutputs; } if (node.type === 'expressionNode' && node.content?.expressions) { - const simpleExpressionRegex = /^[a-zA-Z]+$/; + const simpleExpressionRegex = /^[a-zA-Z0-9]+$/; return node.content.expressions.map((expr: { key: any; value: any }) => { const isSimpleValue = simpleExpressionRegex.test(expr.value); return { key: isSimpleValue ? (fieldKey === 'inputs' ? expr.key : expr.value) : expr.key, property: isSimpleValue ? (fieldKey === 'inputs' ? expr.value : expr.key) : expr.key, + exception: isSimpleValue ? null : expr.value, }; }); } else if (node.type === 'functionNode' && node?.content) { @@ -113,9 +114,21 @@ export class RuleMappingService { // extract only the unique inputs from a list of nodes // excludes inputs found in the outputs of other nodes + // inputs that are only transformed are still included as unique as marked as exception async extractUniqueInputs(nodes: Node[]): Promise<{ uniqueInputs: any[] }> { const { inputs, outputs } = await this.extractInputsAndOutputs(nodes); - const outputFields = new Set(outputs.map((outputField) => outputField.property)); + const outputFields = new Set( + outputs + // check for exceptions where input is transformed and exclude from output fields + .filter((outputField) => + outputField.exception + ? outputField.exception.includes(outputField.key) + ? outputField.exception === outputField.key + : true + : true, + ) + .map((outputField) => outputField.property), + ); const uniqueInputFields = this.findUniqueFields(inputs, outputFields); return { diff --git a/src/api/scenarioData/scenarioData.service.spec.ts b/src/api/scenarioData/scenarioData.service.spec.ts index 8fe0d2c..d0bbc96 100644 --- a/src/api/scenarioData/scenarioData.service.spec.ts +++ b/src/api/scenarioData/scenarioData.service.spec.ts @@ -346,7 +346,7 @@ describe('ScenarioDataService', () => { }; jest.spyOn(service, 'getScenariosByFilename').mockResolvedValue(scenarios); - jest.spyOn(ruleMappingService, 'ruleSchema').mockReturnValue(ruleSchemaOutput); + jest.spyOn(ruleMappingService, 'ruleSchema').mockResolvedValue(ruleSchemaOutput); jest.spyOn(decisionsService, 'runDecisionByContent').mockResolvedValue(decisionResult); const results = await service.runDecisionsForScenarios(goRulesJSONFilename, ruleContent); @@ -392,7 +392,7 @@ describe('ScenarioDataService', () => { }; jest.spyOn(service, 'getScenariosByFilename').mockResolvedValue(scenarios); - jest.spyOn(ruleMappingService, 'ruleSchema').mockReturnValue(ruleSchema); + jest.spyOn(ruleMappingService, 'ruleSchema').mockResolvedValue(ruleSchema); jest.spyOn(decisionsService, 'runDecisionByContent').mockRejectedValue(new Error('Decision execution error')); const results = await service.runDecisionsForScenarios(goRulesJSONFilename, ruleContent); @@ -433,7 +433,7 @@ describe('ScenarioDataService', () => { }; jest.spyOn(service, 'getScenariosByFilename').mockResolvedValue(scenarios); - jest.spyOn(ruleMappingService, 'ruleSchema').mockReturnValue(ruleSchema); + jest.spyOn(ruleMappingService, 'ruleSchema').mockResolvedValue(ruleSchema); jest.spyOn(decisionsService, 'runDecisionByContent').mockResolvedValue(decisionResult); const results = await service.runDecisionsForScenarios(goRulesJSONFilename, ruleContent); From 2decfe6ace911fcd3e207b872a1dc40aa2ec8902 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Tue, 23 Jul 2024 14:32:36 -0700 Subject: [PATCH 3/5] Add additional tests to handle broader functionality. --- src/api/ruleMapping/ruleMapping.interface.ts | 7 +- .../ruleMapping/ruleMapping.service.spec.ts | 424 ++++++++++++++++++ 2 files changed, 429 insertions(+), 2 deletions(-) diff --git a/src/api/ruleMapping/ruleMapping.interface.ts b/src/api/ruleMapping/ruleMapping.interface.ts index 5caa6fd..a3c576b 100644 --- a/src/api/ruleMapping/ruleMapping.interface.ts +++ b/src/api/ruleMapping/ruleMapping.interface.ts @@ -9,7 +9,9 @@ export interface Field { export interface InputField extends Field {} -export interface OutputField extends Field {} +export interface OutputField extends Field { + exception?: string; +} export interface Expression { key: string; @@ -20,12 +22,13 @@ export interface NodeContent { inputs?: InputField[]; outputs?: OutputField[]; expressions?: Expression[]; + key?: string; } export interface Node { id: any; type: string; - content: NodeContent; + content: NodeContent | string; } export interface Edge { diff --git a/src/api/ruleMapping/ruleMapping.service.spec.ts b/src/api/ruleMapping/ruleMapping.service.spec.ts index 7c5a49c..d622b00 100644 --- a/src/api/ruleMapping/ruleMapping.service.spec.ts +++ b/src/api/ruleMapping/ruleMapping.service.spec.ts @@ -89,6 +89,162 @@ describe('RuleMappingService', () => { const result = await service.extractFields(nodes, 'outputs'); expect(result).toEqual({ outputs: [] }); }); + it('should handle decisionNode correctly', async () => { + const nodes: Node[] = [ + { + type: 'decisionNode', + content: { + key: 'someKey', + }, + id: 'testNode', + }, + ]; + + // Mock the ruleSchemaFile method to return a sample schema + jest.spyOn(service, 'ruleSchemaFile').mockResolvedValue({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + resultOutputs: [{ id: '2', name: 'Output1', type: 'number', property: 'field2' }], + }); + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + }); + + const resultOutputs = await service.extractFields(nodes, 'outputs'); + expect(resultOutputs).toEqual({ + outputs: [{ id: '2', name: 'Output1', type: 'number', property: 'field2' }], + }); + }); + + it('should handle expressionNode with simple expressions correctly', async () => { + const nodes: Node[] = [ + { + type: 'expressionNode', + content: { + expressions: [ + { key: 'expr1', value: 'field3' }, + { key: 'expr2', value: '123' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [ + { key: 'expr1', property: 'field3', exception: null }, + { key: 'expr2', property: '123', exception: null }, + ], + }); + }); + + it('should handle expressionNode with complex expressions correctly', async () => { + const nodes: Node[] = [ + { + type: 'expressionNode', + content: { + expressions: [ + { key: 'expr1', value: 'field3' }, + { key: 'expr2', value: 'complexExpr + 2' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [ + { key: 'expr1', property: 'field3', exception: null }, + { key: 'expr2', property: 'expr2', exception: 'complexExpr + 2' }, + ], + }); + }); + + it('should handle functionNode correctly', async () => { + const nodes: Node[] = [ + { + type: 'functionNode', + content: ` + /** + * @param input1 + * @param input2 + * @returns output1 + */ + `, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [ + { key: 'input1', property: 'input1' }, + { key: 'input2', property: 'input2' }, + ], + }); + + const resultOutputs = await service.extractFields(nodes, 'outputs'); + expect(resultOutputs).toEqual({ + outputs: [{ key: 'output1', property: 'output1' }], + }); + }); + + it('should handle nodes with unknown type correctly', async () => { + const nodes: Node[] = [ + { + type: 'unknownType', + content: { + inputs: [{ id: '1', name: 'Input1', type: 'string', field: 'field1' }], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + }); + }); + + it('should handle nodes without the specified fieldKey correctly', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [{ id: '1', name: 'Input1', type: 'string', field: 'field1' }], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'outputs'); + expect(result).toEqual({ + outputs: [], + }); + }); + + it('should handle nodes with duplicate properties correctly', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field1' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [{ id: '2', name: 'Input2', type: 'number', property: 'field1' }], + }); + }); }); describe('extractResultOutputs', () => { @@ -336,6 +492,153 @@ describe('RuleMappingService', () => { uniqueInputs: [], }); }); + + it('should handle nodes with inputs and no outputs', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [ + { id: '1', name: 'Input1', type: 'string', property: 'field1' }, + { id: '2', name: 'Input2', type: 'number', property: 'field2' }, + ], + }); + }); + + it('should handle nodes with inputs and outputs that match', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + outputs: [ + { id: '3', name: 'Output1', type: 'string', field: 'field1' }, + { id: '4', name: 'Output2', type: 'number', field: 'field2' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [], + }); + }); + + it('should handle nodes with transformed inputs as exceptions', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + outputs: [ + { + id: '3', + name: 'Output1', + type: 'string', + field: 'field3', + exception: 'field2', + }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [ + { id: '1', name: 'Input1', type: 'string', property: 'field1' }, + { id: '2', name: 'Input2', type: 'number', property: 'field2' }, + ], + }); + }); + + it('should handle multiple nodes with overlapping inputs and outputs', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [{ id: '1', name: 'Input1', type: 'string', field: 'field1' }], + outputs: [{ id: '2', name: 'Output1', type: 'string', field: 'field2' }], + }, + id: 'testNode1', + }, + { + type: 'someType', + content: { + inputs: [ + { id: '3', name: 'Input3', type: 'boolean', field: 'field3' }, + { id: '4', name: 'Input4', type: 'number', field: 'field4' }, + ], + outputs: [{ id: '5', name: 'Output2', type: 'boolean', field: 'field3' }], + }, + id: 'testNode2', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [ + { id: '1', name: 'Input1', type: 'string', property: 'field1' }, + { id: '4', name: 'Input4', type: 'number', property: 'field4' }, + ], + }); + }); + + it('should handle nodes with exceptions correctly', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + outputs: [ + { + id: '3', + name: 'Output1', + type: 'string', + field: 'field1', + exception: 'field1', + }, + { + id: '4', + name: 'Output2', + type: 'number', + field: 'field3', + exception: 'field2', + }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [{ id: '2', name: 'Input2', type: 'number', property: 'field2' }], + }); + }); }); describe('ruleSchema', () => { @@ -387,6 +690,127 @@ describe('RuleMappingService', () => { expect(error.message).toBe('Rule has no nodes'); } }); + it('should handle nodes with inputs but no edges', async () => { + const nodes: Node[] = [ + { + id: '1', + type: 'inputNode', + content: { + inputs: [{ id: '1', name: 'Input1', type: 'string', field: 'field1' }], + }, + }, + { + id: '1', + type: 'outputNode', + content: { + outputs: [{ id: '1', name: 'Output1', type: 'string', field: 'field2' }], + }, + }, + ]; + const edges: Edge[] = []; + + const result = await service.ruleSchema({ nodes, edges }); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + outputs: [{ id: '1', name: 'Output1', type: 'string', property: 'field2' }], + resultOutputs: [], + }); + }); + + it('should handle nodes with outputs but no edges', async () => { + const nodes: Node[] = [ + { + id: '1', + type: 'outputNode', + content: { + outputs: [{ id: '1', name: 'Output1', type: 'string', field: 'field2' }], + }, + }, + ]; + const edges: Edge[] = []; + + const result = await service.ruleSchema({ nodes, edges }); + expect(result).toEqual({ + inputs: [], + outputs: [{ id: '1', name: 'Output1', type: 'string', property: 'field2' }], + resultOutputs: [], + }); + }); + + it('should handle nodes with both inputs and outputs correctly', async () => { + const nodes: Node[] = [ + { + id: '1', + type: 'inputNode', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + }, + }, + { + id: '2', + type: 'outputNode', + content: { + outputs: [ + { id: '1', name: 'Output1', type: 'string', field: 'field2' }, + { id: '2', name: 'Output2', type: 'number', field: 'field3' }, + ], + }, + }, + ]; + const edges: Edge[] = []; + + const result = await service.ruleSchema({ nodes, edges }); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + outputs: [ + { id: '1', name: 'Output1', type: 'string', property: 'field2' }, + { id: '2', name: 'Output2', type: 'number', property: 'field3' }, + ], + resultOutputs: [], + }); + }); + + it('should handle nodes with decisionNode', async () => { + const nodes: Node[] = [ + { + id: '1', + type: 'decisionNode', + content: { + key: 'someKey', + }, + }, + { + id: '2', + type: 'outputNode', + content: { + outputs: [ + { id: '1', name: 'Output1', type: 'string', field: 'field2' }, + { id: '2', name: 'Output2', type: 'number', field: 'field3' }, + ], + }, + }, + ]; + const edges: Edge[] = []; + + // Mock the ruleSchemaFile method to return a sample schema + jest.spyOn(service, 'ruleSchemaFile').mockResolvedValue({ + inputs: [{ id: '1', name: 'DecisionInput1', type: 'string', property: 'field1' }], + resultOutputs: [{ id: '2', name: 'DecisionOutput1', type: 'number', property: 'field2' }], + }); + + const result = await service.ruleSchema({ nodes, edges }); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'DecisionInput1', type: 'string', property: 'field1' }], + outputs: [ + { id: '1', name: 'Output1', type: 'string', property: 'field2' }, + { id: '2', name: 'Output2', type: 'number', property: 'field3' }, + ], + resultOutputs: [], + }); + }); }); describe('evaluateRuleSchema', () => { From a14d288e3f3b415525834bbfdf24ac9eaa760547 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:29:54 -0700 Subject: [PATCH 4/5] Refactor csv generation to handle user entered commas. --- .../scenarioData/scenarioData.service.spec.ts | 22 ++++++++++ src/api/scenarioData/scenarioData.service.ts | 44 ++++++++++++------- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/api/scenarioData/scenarioData.service.spec.ts b/src/api/scenarioData/scenarioData.service.spec.ts index d0bbc96..bd685b4 100644 --- a/src/api/scenarioData/scenarioData.service.spec.ts +++ b/src/api/scenarioData/scenarioData.service.spec.ts @@ -461,11 +461,13 @@ describe('ScenarioDataService', () => { inputs: { familyComposition: 'single', numberOfChildren: 2 }, outputs: { isEligible: true, baseAmount: 100 }, expectedResults: {}, + resultMatch: false, }, 'Scenario 2': { inputs: { familyComposition: 'couple', numberOfChildren: 3 }, outputs: { isEligible: false, baseAmount: 200 }, expectedResults: {}, + resultMatch: false, }, }; @@ -555,6 +557,26 @@ describe('ScenarioDataService', () => { expect(csvContent.trim()).toBe(expectedCsvContent.trim()); }); + + it('should escape inputs and outputs containing commas or quotes', async () => { + const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; + const ruleRunResults = { + 'Scenario 1': { + inputs: { input1: 'value, with, commas', input2: 'value "with" quotes' }, + outputs: { output1: 'result, with, commas', output2: 'result "with" quotes' }, + expectedResults: {}, + }, + }; + + jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); + + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename, ruleContent); + + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: input1,Input: input2\nScenario 1,Fail,"value, with, commas",value "with" quotes`; + + expect(csvContent.trim()).toBe(expectedCsvContent.trim()); + }); }); describe('processProvidedScenarios', () => { diff --git a/src/api/scenarioData/scenarioData.service.ts b/src/api/scenarioData/scenarioData.service.ts index bee024c..af12f24 100644 --- a/src/api/scenarioData/scenarioData.service.ts +++ b/src/api/scenarioData/scenarioData.service.ts @@ -164,29 +164,43 @@ export class ScenarioDataService { newScenarios, ); - const inputKeys = extractUniqueKeys(ruleRunResults, 'inputs'); - const outputKeys = extractUniqueKeys(ruleRunResults, 'result'); - const expectedResultsKeys = extractUniqueKeys(ruleRunResults, 'expectedResults'); + const keys = { + inputs: extractUniqueKeys(ruleRunResults, 'inputs'), + expectedResults: extractUniqueKeys(ruleRunResults, 'expectedResults'), + result: extractUniqueKeys(ruleRunResults, 'result'), + }; const headers = [ 'Scenario', 'Results Match Expected (Pass/Fail)', - ...inputKeys.map((key) => `Input: ${key}`), - ...expectedResultsKeys.map((key) => `Expected Result: ${key}`), - ...outputKeys.map((key) => `Result: ${key}`), + ...this.prefixKeys(keys.inputs, 'Input'), + ...this.prefixKeys(keys.expectedResults, 'Expected Result'), + ...this.prefixKeys(keys.result, 'Result'), ]; - const rows = Object.entries(ruleRunResults).map(([scenarioName, scenarioData]) => { - const resultsMatch = scenarioData.resultMatch ? 'Pass' : 'Fail'; - const inputs = inputKeys.map((key) => scenarioData.inputs[key] ?? ''); - const outputs = outputKeys.map((key) => scenarioData.result[key] ?? ''); - const expectedResults = expectedResultsKeys.map((key) => scenarioData.expectedResults[key] ?? ''); + const rows = Object.entries(ruleRunResults).map(([scenarioName, data]) => [ + this.escapeCSVField(scenarioName), + data.resultMatch ? 'Pass' : 'Fail', + ...this.mapFields(data.inputs, keys.inputs), + ...this.mapFields(data.expectedResults, keys.expectedResults), + ...this.mapFields(data.result, keys.result), + ]); - return [scenarioName, resultsMatch, ...inputs, ...expectedResults, ...outputs]; - }); + return [headers, ...rows].map((row) => row.join(',')).join('\n'); + } + + private prefixKeys(keys: string[], prefix: string): string[] { + return keys.map((key) => `${prefix}: ${key}`); + } + + private mapFields(data: Record, keys: string[]): string[] { + return keys.map((key) => this.escapeCSVField(data[key])); + } - const csvContent = [headers, ...rows].map((row) => row.join(',')).join('\n'); - return csvContent; + private escapeCSVField(field: any): string { + if (field == null) return ''; + const stringField = typeof field === 'string' ? field : String(field); + return stringField.includes(',') ? `"${stringField.replace(/"/g, '""')}"` : stringField; } /** From dfd905779211962a22d2db7a6777b4392ad4e574 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:39:15 -0700 Subject: [PATCH 5/5] Update per recommended changes to simplify mapping and clarify testing case. --- src/api/ruleMapping/ruleMapping.service.spec.ts | 4 ++-- src/api/ruleMapping/ruleMapping.service.ts | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/api/ruleMapping/ruleMapping.service.spec.ts b/src/api/ruleMapping/ruleMapping.service.spec.ts index d622b00..7a3b4cf 100644 --- a/src/api/ruleMapping/ruleMapping.service.spec.ts +++ b/src/api/ruleMapping/ruleMapping.service.spec.ts @@ -146,7 +146,7 @@ describe('RuleMappingService', () => { type: 'expressionNode', content: { expressions: [ - { key: 'expr1', value: 'field3' }, + { key: 'expr1', value: 'field3 > 5' }, { key: 'expr2', value: 'complexExpr + 2' }, ], }, @@ -157,7 +157,7 @@ describe('RuleMappingService', () => { const result = await service.extractFields(nodes, 'inputs'); expect(result).toEqual({ inputs: [ - { key: 'expr1', property: 'field3', exception: null }, + { key: 'expr1', property: 'expr1', exception: 'field3 > 5' }, { key: 'expr2', property: 'expr2', exception: 'complexExpr + 2' }, ], }); diff --git a/src/api/ruleMapping/ruleMapping.service.ts b/src/api/ruleMapping/ruleMapping.service.ts index 4580027..15b9e16 100644 --- a/src/api/ruleMapping/ruleMapping.service.ts +++ b/src/api/ruleMapping/ruleMapping.service.ts @@ -64,11 +64,7 @@ export class RuleMappingService { const results = await Promise.all(promises); const fields = results.flat(); - const uniqueFieldsMap = new Map(); - - fields.forEach((field) => { - uniqueFieldsMap.set(field.property, field); - }); + const uniqueFieldsMap = new Map(fields.map((field) => [field.property, field])); const uniqueFields = Array.from(uniqueFieldsMap.values()); return { [fieldKey]: uniqueFields };