diff --git a/apps/showcase/src/app/rules-engine/rules-engine.component.ts b/apps/showcase/src/app/rules-engine/rules-engine.component.ts index 0376bd50f6..e32b93f228 100644 --- a/apps/showcase/src/app/rules-engine/rules-engine.component.ts +++ b/apps/showcase/src/app/rules-engine/rules-engine.component.ts @@ -8,12 +8,24 @@ import { ConfigOverrideStoreModule, ConfigurationBaseServiceModule, Configuratio import { O3rComponent } from '@o3r/core'; import { AssetPathOverrideStoreModule, DynamicContentService } from '@o3r/dynamic-content'; import { LocalizationOverrideStoreModule } from '@o3r/localization'; -import { Rule, RulesEngineDevtoolsMessageService, RulesEngineDevtoolsModule, RulesEngineModule, RulesEngineService, RulesetsStore, setRulesetsEntities, UnaryOperator } from '@o3r/rules-engine'; +import { + dateInNextMinutes, + Operator, + Rule, + RulesEngineDevtoolsMessageService, + RulesEngineDevtoolsModule, + RulesEngineModule, + RulesEngineService, + RulesetsStore, + setRulesetsEntities, + UnaryOperator +} from '@o3r/rules-engine'; import { firstValueFrom } from 'rxjs'; import { CopyTextPresComponent, IN_PAGE_NAV_PRES_DIRECTIVES, InPageNavLink, InPageNavLinkDirective, InPageNavPresService, RulesEnginePresComponent } from '../../components/index'; import { environment } from '../../environments/environment.development'; import { TripFactsService } from '../../facts/index'; import { duringSummer } from '../../operators/index'; +// import { CurrentTimeFactsService } from '../../services/current-time-facts.service'; @O3rComponent({ componentType: 'Page' }) @Component({ @@ -44,6 +56,7 @@ export class RulesEngineComponent implements OnInit, AfterViewInit { public newYorkAvailableRule = ''; public helloNewYorkRule = ''; public summerOtterRule = ''; + public lateOtterRule = ''; @ViewChildren(InPageNavLinkDirective) private inPageNavLinkDirectives!: QueryList; @@ -55,6 +68,7 @@ export class RulesEngineComponent implements OnInit, AfterViewInit { private inPageNavPresService: InPageNavPresService, private dynamicContentService: DynamicContentService, private tripFactsService: TripFactsService, + // private currentTimeFactsService: CurrentTimeFactsService, private store: Store, configurationDevtoolsMessageService: ConfigurationDevtoolsMessageService, rulesEngineDevtoolsMessageService: RulesEngineDevtoolsMessageService, @@ -62,9 +76,8 @@ export class RulesEngineComponent implements OnInit, AfterViewInit { ) { configurationDevtoolsMessageService.activate(); rulesEngineDevtoolsMessageService.activate(); - rulesEngineService.engine.upsertOperators([ - duringSummer - ] as UnaryOperator[]); + rulesEngineService.engine.upsertOperators([duringSummer] as UnaryOperator[]); + rulesEngineService.engine.upsertOperators([dateInNextMinutes] as Operator[]); } private formatRule(rule: Rule) { @@ -84,16 +97,20 @@ export class RulesEngineComponent implements OnInit, AfterViewInit { const resultCall = await fetch(path); const result = await resultCall.json(); - this.store.dispatch(setRulesetsEntities({entities: result.rulesets})); + this.store.dispatch(setRulesetsEntities({ entities: result.rulesets })); this.tripFactsService.register(); + // uncomment to test currentTimeFactsService override + // this.currentTimeFactsService.register(); const [ newYorkAvailableRule, helloNewYorkRule, - summerOtterRule + summerOtterRule, + lateOtterRule ] = result.rulesets[0].rules as Rule[]; this.newYorkAvailableRule = JSON.stringify(this.formatRule(newYorkAvailableRule), null, 2); this.helloNewYorkRule = JSON.stringify(this.formatRule(helloNewYorkRule), null, 2); this.summerOtterRule = JSON.stringify(this.formatRule(summerOtterRule), null, 2); + this.lateOtterRule = JSON.stringify(this.formatRule(lateOtterRule), null, 2); } public ngAfterViewInit() { diff --git a/apps/showcase/src/app/rules-engine/rules-engine.template.html b/apps/showcase/src/app/rules-engine/rules-engine.template.html index ad54b603fc..df152577bb 100644 --- a/apps/showcase/src/app/rules-engine/rules-engine.template.html +++ b/apps/showcase/src/app/rules-engine/rules-engine.template.html @@ -37,6 +37,9 @@

Example

+
@@ -64,6 +67,14 @@

Example

+
+

+ When selecting a departure date in less than 2 days, the otter picture changes. We have created the following rule to change the targeted asset. +
+ It is using the `inNextMinutes` operator which is based on the current time. +

+ +

How to install

diff --git a/apps/showcase/src/assets/rules/rulesets.json b/apps/showcase/src/assets/rules/rulesets.json index 81593c665a..131d63f0da 100644 --- a/apps/showcase/src/assets/rules/rulesets.json +++ b/apps/showcase/src/assets/rules/rulesets.json @@ -7,11 +7,10 @@ "disabled": false, "rules": [ { - "id": "5467e501-b9ff-414f-8026-56885d0d7a4c", + "id": "5467e501-b9ff-414f-8026-56885d0d7a4b", "name": "New-York availability", "disabled": false, "outputRuntimeFacts": [], - "inputFacts": ["outboundDate"], "inputRuntimeFacts": [], "rootElement": { "elementType": "RULE_BLOCK", @@ -50,7 +49,6 @@ "name": "Destination selected", "disabled": false, "outputRuntimeFacts": [], - "inputFacts": ["destination"], "inputRuntimeFacts": [], "rootElement": { "elementType": "RULE_BLOCK", @@ -78,11 +76,10 @@ } }, { - "id": "5467e501-b9ff-414f-8026-56885d0d7a4c", - "name": "The otter is in vacations", + "id": "5467e501-b9ff-414f-8026-56885d0d7a4d", + "name":"The otter is on vacation", "disabled": false, "outputRuntimeFacts": [], - "inputFacts": ["outboundDate"], "inputRuntimeFacts": [], "rootElement": { "elementType": "RULE_BLOCK", @@ -108,6 +105,41 @@ ], "failureElements": [] } + }, + { + "id": "5467e501-b9ff-414f-8026-56885d0d7a4c", + "name": "The otter is late", + "disabled": false, + "outputRuntimeFacts": [], + "inputRuntimeFacts": [], + "rootElement": { + "elementType": "RULE_BLOCK", + "blockType": "IF_ELSE", + "condition": { + "all": [ + { + "lhs": { + "type": "FACT", + "value": "outboundDate" + }, + "rhs": { + "type": "LITERAL", + "value": "2880" + }, + "operator": "dateInNextMinutes" + } + ] + }, + "successElements": [ + { + "elementType": "ACTION", + "actionType": "UPDATE_ASSET", + "asset": "otter.svg", + "value": "otter-summer.svg" + } + ], + "failureElements": [] + } } ] } diff --git a/apps/showcase/src/services/current-time-facts.service.ts b/apps/showcase/src/services/current-time-facts.service.ts new file mode 100644 index 0000000000..e25902648f --- /dev/null +++ b/apps/showcase/src/services/current-time-facts.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { CurrentTimeFacts, FactsService, RulesEngineService } from '@o3r/rules-engine'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class CurrentTimeFactsService extends FactsService { + + private currentTimeSubject$ = new BehaviorSubject(new Date('2023-11-2').getTime()); + /** @inheritDoc */ + public facts = { + o3rCurrentTime: this.currentTimeSubject$.asObservable() + }; + + constructor(rulesEngine: RulesEngineService) { + super(rulesEngine); + } + + /** Compute the current time */ + public tick() { + this.currentTimeSubject$.next(Date.now()); + } +} diff --git a/docs/rules-engine/operators.md b/docs/rules-engine/operators.md index 819419253f..33c4fdf41c 100644 --- a/docs/rules-engine/operators.md +++ b/docs/rules-engine/operators.md @@ -7,7 +7,7 @@ A condition has 3 different parts : Each operator has its unique name, functions to validate its operands and evaluate function that will output the result. -Example of usage : +Example of usage : ```json { "lhs": { @@ -26,48 +26,50 @@ Example of usage : `[]` means the variable is an array of primitives -| Type | Operator | Display | Description | -| ----------- | ------------------------- | ---------------- | ----------- | -| All | equals | is equal to | Check if a variable is equal to a specific value | -| All | notEquals | is not equal to | Check if a variable is different than a specific value | -| [All] | allEqual | all equal to | Check if every values of the variable equals a specific value | -| [All] | oneEquals | one equal to | Check if at least one of the values of the variable equals a specific value | -| [All] | oneIn | one in | Check if at least one of the values of the variable is equal to one in a specified list | -| [All] | allIn | all in | Check if every values of the variable are in a specific list | -| [All] | allNotIn | none in | Check if every values of the variable are not in a specific list | -| [All] | arrayContains | contains | Check if any of the variable's value is equal to a specific value | -| [All] | notArrayContains | does not contain | Check if every values of the variable are different than a specific value | -| All | inArray | is in | Check if the variable's value is included in a specified list | -| All | notInArray | is not in | Check if the variable's value is not included in the value list | -| All | isDefined | is defined | Check if the variable and its value is defined | -| All | isUndefined | is not defined | Check if the variable and its value is undefined | -| Date | inRangeDate | is between | Check if a date variable is in a specified date range | -| Date | dateBefore | is before | Check if a date variable is prior than a specified date | -| Date | dateAfter | is after | Check if a date variable is posterior than a specified date | -| Date | dateEquals | is equal to | Check if a date variable is the same as a specified date | -| Date | dateNotEquals | is not equal to | Check if a date variable is different from a specified date | -| Number | lessOrEqual | ≤ | Check if the number variable is lower or equal to a specific value | -| Number | lessThan | < | Check if the number variable is lower than a specific value | -| Number | greaterThanOrEqual | ≥ | Check if the number variable is greater or equal to a specific value | -| Number | greaterThan | > | Check if the number variable is greater than a specific value | -| [Number] | allLower | all < | Check if every numerical values of the variable are lower than a specific value | -| [Number] | allGreater | all > | Check if every numerical values of the variable are greater than a specific value | -| [Number] | oneLower | one < | Check if one of the values of the variable is lower than a specific value | -| [Number] | oneGreater | one > | Check if one of the values of the variable is greater than a specific value | -| [Number] | allRangeNumber | all between | Check if every values of the variable are included in a specified range | -| [Number] | oneRangeNumber | one between | Check if one of the values of the variable is included in a specified range | -| String | inString | within | Check if the text variable is part of the specified value | -| String | notInString | not within | Check if the text variable is not part in the specified value | -| String | stringContains | contains | Check if the specified text value is included in the text variable | -| String | notStringContains | does not contain | Check if the specified text value is not included in the text variable | -| [String] | allMatch | all match | Check if every string values of the variable match a specific pattern | -| [String] | oneMatches | one matches | Check if one of the values of the variable matches a specific pattern | -| [All] | lengthEquals | number of = | Check if the number of values of the variable is equal to a specific value | -| [All] | lengthNotEquals | number of ≠ | Check if the number of values of the variable is different from a specific value | -| [All] | lengthLessThanOrEquals | number of ≤ | Check if the number of values of the variable is lower or equal to a specific value | -| [All] | lengthLessThan | number of < | Check if the number of values of the variable is lower than a specific value | -| [All] | lengthGreaterThanOrEquals | number of ≥ | Check if the number of values of the variable is greater or equal to a specific value | -| [All] | lengthGreaterThan | number of > | Check if the number of values of the variable is greater than a specific value | +| Type | Operator | Display | Description | +| ----------- | ------------------------- | ---------------------- | ----------- | +| All | equals | is equal to | Check if a variable is equal to a specific value | +| All | notEquals | is not equal to | Check if a variable is different than a specific value | +| [All] | allEqual | all equal to | Check if every values of the variable equals a specific value | +| [All] | oneEquals | one equal to | Check if at least one of the values of the variable equals a specific value | +| [All] | oneIn | one in | Check if at least one of the values of the variable is equal to one in a specified list | +| [All] | allIn | all in | Check if every values of the variable are in a specific list | +| [All] | allNotIn | none in | Check if every values of the variable are not in a specific list | +| [All] | arrayContains | contains | Check if any of the variable's value is equal to a specific value | +| [All] | notArrayContains | does not contain | Check if every values of the variable are different than a specific value | +| All | inArray | is in | Check if the variable's value is included in a specified list | +| All | notInArray | is not in | Check if the variable's value is not included in the value list | +| All | isDefined | is defined | Check if the variable and its value is defined | +| All | isUndefined | is not defined | Check if the variable and its value is undefined | +| Date | inRangeDate | is between | Check if a date variable is in a specified date range | +| Date | dateInNextMinutes | is in next minutes | Check if a date is in the next specified minutes | +| Date | dateNotInNextMinutes | is not in next minutes | Check if a date is not in the next specified minutes | +| Date | dateBefore | is before | Check if a date variable is prior than a specified date | +| Date | dateAfter | is after | Check if a date variable is posterior than a specified date | +| Date | dateEquals | is equal to | Check if a date variable is the same as a specified date | +| Date | dateNotEquals | is not equal to | Check if a date variable is different from a specified date | +| Number | lessOrEqual | ≤ | Check if the number variable is lower or equal to a specific value | +| Number | lessThan | < | Check if the number variable is lower than a specific value | +| Number | greaterThanOrEqual | ≥ | Check if the number variable is greater or equal to a specific value | +| Number | greaterThan | > | Check if the number variable is greater than a specific value | +| [Number] | allLower | all < | Check if every numerical values of the variable are lower than a specific value | +| [Number] | allGreater | all > | Check if every numerical values of the variable are greater than a specific value | +| [Number] | oneLower | one < | Check if one of the values of the variable is lower than a specific value | +| [Number] | oneGreater | one > | Check if one of the values of the variable is greater than a specific value | +| [Number] | allRangeNumber | all between | Check if every values of the variable are included in a specified range | +| [Number] | oneRangeNumber | one between | Check if one of the values of the variable is included in a specified range | +| String | inString | within | Check if the text variable is part of the specified value | +| String | notInString | not within | Check if the text variable is not part in the specified value | +| String | stringContains | contains | Check if the specified text value is included in the text variable | +| String | notStringContains | does not contain | Check if the specified text value is not included in the text variable | +| [String] | allMatch | all match | Check if every string values of the variable match a specific pattern | +| [String] | oneMatches | one matches | Check if one of the values of the variable matches a specific pattern | +| [All] | lengthEquals | number of = | Check if the number of values of the variable is equal to a specific value | +| [All] | lengthNotEquals | number of ≠ | Check if the number of values of the variable is different from a specific value | +| [All] | lengthLessThanOrEquals | number of ≤ | Check if the number of values of the variable is lower or equal to a specific value | +| [All] | lengthLessThan | number of < | Check if the number of values of the variable is lower than a specific value | +| [All] | lengthGreaterThanOrEquals | number of ≥ | Check if the number of values of the variable is greater or equal to a specific value | +| [All] | lengthGreaterThan | number of > | Check if the number of values of the variable is greater than a specific value | You can create your own operator in your application and add it to the engine. @@ -87,4 +89,56 @@ export const customOperator: Operator = { }; ``` +## Operators with dependencies + +### dateInNextMinutes and dateNotInNextMinutes + +`dateInNextMinutes` and `dateNotInNextMinutes` need the current time in order to execute. The `o3rCurrentTime` fact is registerd in the Rules Engine service but, if needed, one could override the default behavior in the app as follows: + + +Create a custom fact service that is overriding the value of `o3rCurrentTime` + +```typescript +import { Injectable } from '@angular/core'; +import { CurrentTimeFacts, FactsService, RulesEngineService } from '@o3r/rules-engine'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class CurrentTimeFactsService extends FactsService { + + private currentTimeSubject$ = new BehaviorSubject(new Date('2023-11-2').getTime()); // define a date in the past instead of the current time + /** @inheritDoc */ + public facts = { + o3rCurrentTime: this.currentTimeSubject$.asObservable() + }; + + constructor(rulesEngine: RulesEngineService) { + super(rulesEngine); + } + + /** Compute the current time */ + public tick() { + this.currentTimeSubject$.next(Date.now()); + } +} + +``` + +The application has to register the custom service. + +```typescript +constructor( + ... + private currentTimeFactsService: CurrentTimeFactsService +) + +ngOnInit() { + ... + this.currentTimeFactsService.register(); +} +``` + +Here, `CurrentTimeFactsService` also provides a `tick()` method that, when called, it recomputes the current time. It is up to the application to decide how ofter the current time should be recomputed (at a given time interval, on page navigation, etc). diff --git a/docs/rules-engine/rule.md b/docs/rules-engine/rule.md index 97093f5be3..566a52f507 100644 --- a/docs/rules-engine/rule.md +++ b/docs/rules-engine/rule.md @@ -1,11 +1,11 @@ # Rule -A rule is a group of conditions that will output a list of actions after processing. A unique id identifies each rule. -The default action types and their object structure definitions can be found in [structure definition file](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/rules-engine/src/engine/structure.ts). -To see more about conditions, have a look at [nested conditions example](./examples/nested-conditions.md). +A rule is a group of conditions that will output a list of actions after processing. A unique id identifies each rule. +The default action types and their object structure definitions can be found in [structure definition file](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/rules-engine/src/engine/structure.ts). +To see more about conditions, have a look at [nested conditions example](./examples/nested-conditions.md). -It contains at root level the information needed to optimize the reevaluation of this rule : +It contains at root level the information needed to optimize the reevaluation of this rule: * inputRuntimeFacts : the input runtime facts the rule is based on * inputFacts : the facts that are used by the rule * outputRuntimeFacts : the runtime facts that are updated/set by the rule diff --git a/packages/@o3r/core/package.json b/packages/@o3r/core/package.json index 111443b731..e81ccc66a7 100644 --- a/packages/@o3r/core/package.json +++ b/packages/@o3r/core/package.json @@ -146,9 +146,9 @@ "cpy-cli": "^4.2.0", "eslint": "^8.42.0", "@nx/eslint-plugin": "~16.10.0", - "jsonc-eslint-parser": "~2.3.0", + "jsonc-eslint-parser": "~2.4.0", "eslint-import-resolver-node": "^0.3.4", - "eslint-plugin-jest": "~27.4.0", + "eslint-plugin-jest": "~27.6.0", "eslint-plugin-jsdoc": "~46.8.0", "eslint-plugin-prefer-arrow": "~1.2.3", "eslint-plugin-unicorn": "^47.0.0", diff --git a/packages/@o3r/rules-engine/src/engine/operator/index.ts b/packages/@o3r/rules-engine/src/engine/operator/index.ts index 6273ed8dda..32e6728ff0 100644 --- a/packages/@o3r/rules-engine/src/engine/operator/index.ts +++ b/packages/@o3r/rules-engine/src/engine/operator/index.ts @@ -1,4 +1,3 @@ export * from './operator.helpers'; export * from './operator.interface'; export * from './operators/index'; - diff --git a/packages/@o3r/rules-engine/src/engine/operator/operator.helpers.spec.ts b/packages/@o3r/rules-engine/src/engine/operator/operator.helpers.spec.ts index 52e18856fb..3b7678c6a9 100644 --- a/packages/@o3r/rules-engine/src/engine/operator/operator.helpers.spec.ts +++ b/packages/@o3r/rules-engine/src/engine/operator/operator.helpers.spec.ts @@ -18,7 +18,7 @@ describe('Operator helpers', () => { expect(operator.validateRhs).toHaveBeenCalledTimes(1); expect(operator.validateLhs).toHaveBeenCalledWith('testLhs'); expect(operator.validateRhs).toHaveBeenCalledWith('testRhs'); - expect(operator.evaluator).toHaveBeenCalledWith('testLhs', 'testRhs'); + expect(operator.evaluator).toHaveBeenCalledWith('testLhs', 'testRhs', undefined); }); test('should not evaluate the condition if the checks are not passed', () => { @@ -77,7 +77,7 @@ describe('Operator helpers', () => { }; expect(executeOperator('testLhs', 'testRhs', operator)).toBeTruthy(); - expect(operator.evaluator).toHaveBeenCalledWith('testLhs', 'testRhs'); + expect(operator.evaluator).toHaveBeenCalledWith('testLhs', 'testRhs', undefined); }); test('with promise value', () => { @@ -87,7 +87,7 @@ describe('Operator helpers', () => { }; expect(executeOperator('testLhs', 'testRhs', operator)).toBeTruthy(); - expect(operator.evaluator).toHaveBeenCalledWith('testLhs', 'testRhs'); + expect(operator.evaluator).toHaveBeenCalledWith('testLhs', 'testRhs', undefined); }); test('with observable value', () => { @@ -97,7 +97,7 @@ describe('Operator helpers', () => { }; expect(executeOperator('testLhs', 'testRhs', operator)).toBeTruthy(); - expect(operator.evaluator).toHaveBeenCalledWith('testLhs', 'testRhs'); + expect(operator.evaluator).toHaveBeenCalledWith('testLhs', 'testRhs', undefined); }); }); }); diff --git a/packages/@o3r/rules-engine/src/engine/operator/operator.helpers.ts b/packages/@o3r/rules-engine/src/engine/operator/operator.helpers.ts index 83fc5c1596..881e9df55f 100644 --- a/packages/@o3r/rules-engine/src/engine/operator/operator.helpers.ts +++ b/packages/@o3r/rules-engine/src/engine/operator/operator.helpers.ts @@ -1,22 +1,34 @@ import {DateInput, Operator, SupportedSimpleTypes} from './operator.interface'; +import type {Facts} from '../engine.interface'; /** * Execute Operator - * * @param lhs Left hand side * @param rhs Right hand side * @param operator Operator to compare values + * @param operatorFacts Operator facts that operator can depend on */ -export function executeOperator(lhs: L, rhs: R, operator: Operator) { +export function executeOperator(lhs: L, rhs: R, operator: Operator, operatorFacts?: Record) { const validLhs = (!operator.validateLhs || operator.validateLhs(lhs)); const validRhs = (!operator.validateRhs || operator.validateRhs(rhs)); + let operatorFactValues: Record | undefined; + if (operatorFacts && operator.factImplicitDependencies) { + operatorFactValues = operator.factImplicitDependencies.reduce((acc, dep) => { + if (operatorFacts[dep]) { + acc[dep] = operatorFacts[dep]!; + } else { + throw new Error(`No value found for ${dep}.`); + } + return acc; + }, {} as Record); + } if (!validLhs) { - throw new Error(`Invalid left operand : ${JSON.stringify(lhs)}`); + throw new Error(`Invalid left operand: ${JSON.stringify(lhs)}`); } if (!validRhs) { - throw new Error(`Invalid right operand : ${JSON.stringify(rhs)}`); + throw new Error(`Invalid right operand: ${JSON.stringify(rhs)}`); } - const obs = operator.evaluator(lhs, rhs); + const obs = operator.evaluator(lhs, rhs, operatorFactValues); return obs; } diff --git a/packages/@o3r/rules-engine/src/engine/operator/operator.interface.ts b/packages/@o3r/rules-engine/src/engine/operator/operator.interface.ts index 1b72a1dada..9ffb38e7ff 100644 --- a/packages/@o3r/rules-engine/src/engine/operator/operator.interface.ts +++ b/packages/@o3r/rules-engine/src/engine/operator/operator.interface.ts @@ -1,3 +1,5 @@ +import type { Facts } from '../engine.interface'; + /** * Rule Engine operator */ @@ -11,7 +13,9 @@ export interface Operator boolean : (operand: unknown) => operand is RightSupported; /** Evaluate the values */ - evaluator: (lhs: LeftSupported, rhs: RightSupported) => boolean; + evaluator: (lhs: LeftSupported, rhs: RightSupported, operatorFactValues?: Record) => boolean; + /** List of facts names used inside the operator */ + factImplicitDependencies?: string[]; } /** diff --git a/packages/@o3r/rules-engine/src/engine/operator/operators/date-based.operators.spec.ts b/packages/@o3r/rules-engine/src/engine/operator/operators/date-based.operators.spec.ts index 181f1131ed..fe71a15ab3 100644 --- a/packages/@o3r/rules-engine/src/engine/operator/operators/date-based.operators.spec.ts +++ b/packages/@o3r/rules-engine/src/engine/operator/operators/date-based.operators.spec.ts @@ -3,7 +3,9 @@ import { dateAfter, dateBefore, dateEquals, + dateInNextMinutes, dateNotEquals, + dateNotInNextMinutes, inRangeDate } from './date-based.operators'; @@ -13,16 +15,16 @@ describe('Operators', () => { const now = new Date(); const tomorrow = new Date(new Date(now).setHours(0, 0, 0, 0) + millisecondsInADay); const yesterday = new Date(new Date(now).setHours(0, 0, 0, 0) - millisecondsInADay); + const inTwoDays = new Date(new Date(now).setHours(0, 0, 0, 0) + 2 * millisecondsInADay); describe('inRangeDate', () => { it('should have a valid name', () => { expect(inRangeDate.name).toBe('inRangeDate'); }); - it('should invalid when dates is invalid', () => { + it('should invalid when dates are invalid', () => { expect(() => executeOperator('invalid date' as any, [] as any, inRangeDate)).toThrow(); expect(() => executeOperator(null as any, [] as any, inRangeDate)).toThrow(); - expect(() => executeOperator(null as any, [] as any, inRangeDate)).toThrow(); expect(() => executeOperator(now, ['invalid date'] as any, inRangeDate)).toThrow(); expect(() => executeOperator(now, ['invalid date', 'invalid date'], inRangeDate)).toThrow(); }); @@ -38,6 +40,112 @@ describe('Operators', () => { }); }); + describe('dateInNextMinutes', () => { + it('should have a valid name', () => { + expect(dateInNextMinutes.name).toBe('dateInNextMinutes'); + }); + + it('should invalid when dates are invalid', () => { + expect(() => executeOperator('invalid date' as any, [] as any, dateInNextMinutes)).toThrow(); + expect(() => executeOperator(null as any, [] as any, dateInNextMinutes)).toThrow(); + expect(() => executeOperator(yesterday, 'invalid date', dateInNextMinutes)).toThrow(); + expect(() => executeOperator(yesterday, -1, dateInNextMinutes)).toThrow(); + + }); + + it('should correctly check date in next minutes', () => { + expect(executeOperator(tomorrow, (24 * 60 + 1), dateInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeTruthy(); + // for past events, the operator should return false + expect(executeOperator(yesterday, 0, dateInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeFalsy(); + // range is from now to +24h but event is in two days. The operator returns false + expect(executeOperator(inTwoDays, (24 * 60 + 1), dateInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeFalsy(); + + }); + + it('should throw error when no operatorFactValues is provided', () => { + expect(() => executeOperator(now, 5, dateInNextMinutes)).toThrow('No operatorFactValues. Unable to retrieve the current time.'); + }); + + it('should correctly check the edge case of current time', () => { + // Considering the exact current time as the leftDate, it should still be true if the range is 0 minutes + expect(executeOperator(now, 0, dateInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeTruthy(); + + // Considering the exact current time but with 1 minute in the future, it should be true + expect(executeOperator(now, 1, dateInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeTruthy(); + }); + + it('should correctly check the edge case of exact target date time', () => { + const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour later from now + expect(executeOperator(oneHourLater, 60, dateInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeTruthy(); + + const oneMinuteLater = new Date(now.getTime() + 60 * 1000); // 1 minute later from now + expect(executeOperator(oneMinuteLater, 1, dateInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeTruthy(); + }); + + it('should return false for dates just beyond the specified range', () => { + const oneMinutePastTarget = new Date(new Date(now).setMinutes(now.getMinutes() + 6)); // 6 minutes from now + expect(executeOperator(oneMinutePastTarget, 5, dateInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeFalsy(); + }); + + it('should validate rhs as numbers', () => { + expect(() => executeOperator(now, 'invalid' as any, dateInNextMinutes, {o3rCurrentTime: now.getTime()})).toThrow(); + }); + }); + + describe('dateNotInNextMinutes', () => { + it('should have a valid name', () => { + expect(dateNotInNextMinutes.name).toBe('dateNotInNextMinutes'); + }); + + it('should invalid when dates are invalid', () => { + expect(() => executeOperator('invalid date' as any, 1, dateNotInNextMinutes)).toThrow(); + expect(() => executeOperator(null as any, 0, dateNotInNextMinutes)).toThrow(); + expect(() => executeOperator(now, 'invalid minutes' as any, dateNotInNextMinutes)).toThrow(); + expect(() => executeOperator(now, 7, dateNotInNextMinutes)).toThrow(); + }); + + it('should correctly check date not in next minutes', () => { + // event occuring tomorrow is not in 0 minutes from now + expect(executeOperator(tomorrow, 0, dateNotInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeTruthy(); + // event occuring in two days is not in 24h from now + expect(executeOperator(inTwoDays, 24 * 60 + 1, dateNotInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeTruthy(); + // event that occured yesterday is not in 10 minutes from now + expect(executeOperator(yesterday, 10, dateNotInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeFalsy(); + }); + + it('should throw error when no operatorFactValues is provided', () => { + expect(() => executeOperator(now, 5, dateNotInNextMinutes)).toThrow('No operatorFactValues. Unable to retrieve the current time.'); + }); + + it('should correctly check for events exactly at the edge of the range', () => { + // Event occurring exactly 1 minute from now should return false, as it is within the range + const oneMinuteFromNow = new Date(now.getTime() + 60 * 1000); + expect(executeOperator(oneMinuteFromNow, 1, dateNotInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeFalsy(); + + // Event occurring just after 1 minute from now should return true, as it is outside the range + const slightlyAfterOneMinuteFromNow = new Date(oneMinuteFromNow.getTime() + 1); + expect(executeOperator(slightlyAfterOneMinuteFromNow, 1, dateNotInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeTruthy(); + }); + + it('should return false for events within the specified range', () => { + const halfHourFromNow = new Date(now.getTime() + 30 * 60 * 1000); + expect(executeOperator(halfHourFromNow, 60, dateNotInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeFalsy(); + }); + + it('should validate rhs as numbers', () => { + expect(() => executeOperator(now, 'invalid' as any, dateNotInNextMinutes, {o3rCurrentTime: now.getTime()})).toThrow(); + }); + it('should return true for events happening way into the future', () => { + const nextYear = new Date(new Date().setFullYear(now.getFullYear() + 1)); + expect(executeOperator(nextYear, 60, dateNotInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeTruthy(); + }); + + it('should return false for dates way in the past', () => { + const lastYear = new Date(new Date().setFullYear(now.getFullYear() - 1)); + expect(executeOperator(lastYear, 60, dateNotInNextMinutes, {o3rCurrentTime: now.getTime()})).toBeFalsy(); + }); + }); + describe('dateBefore', () => { it('should have a valid name', () => { expect(dateBefore.name).toBe('dateBefore'); diff --git a/packages/@o3r/rules-engine/src/engine/operator/operators/date-based.operators.ts b/packages/@o3r/rules-engine/src/engine/operator/operators/date-based.operators.ts index 7b5a156747..374226e8ba 100644 --- a/packages/@o3r/rules-engine/src/engine/operator/operators/date-based.operators.ts +++ b/packages/@o3r/rules-engine/src/engine/operator/operators/date-based.operators.ts @@ -1,4 +1,4 @@ -import {isValidDateInput, isValidDateRange} from '../operator.helpers'; +import {isValidDateInput, isValidDateRange, numberValidator} from '../operator.helpers'; import {DateInput, Operator} from '../operator.interface'; /** @@ -16,6 +16,52 @@ export const inRangeDate: Operator = { validateRhs: isValidDateRange }; + +/** + * Check if the value of the variable is in the next x minutes + * + * @title is in next minutes + * + * @returns false for dates before `now` and for dates after `now` + `nextMinutes`, true for dates between `now` and `now` + `nextMinutes` + */ +export const dateInNextMinutes: Operator = { + name: 'dateInNextMinutes', + evaluator: (leftDateInput, minutes, operatorFactValues) => { + if (!operatorFactValues) { + throw new Error('No operatorFactValues. Unable to retrieve the current time.'); + } + const currentTimeValue = operatorFactValues.o3rCurrentTime as number; + return inRangeDate.evaluator(leftDateInput, [currentTimeValue, currentTimeValue + +minutes * 60000]); + }, + factImplicitDependencies: ['o3rCurrentTime'], + validateLhs: isValidDateInput, + validateRhs: numberValidator +}; + +/** + * Check if the value of the variable is not in the next x minutes + * + * @title is not in next minutes + * + * @returns false for dates before `now` and for dates between `now` and `now` + `nextMinutes`, true for dates after `now` + `nextMinutes` + */ +export const dateNotInNextMinutes: Operator = { + name: 'dateNotInNextMinutes', + evaluator: (leftDateInput, minutes, operatorFactValues) => { + if (!operatorFactValues) { + throw new Error('No operatorFactValues. Unable to retrieve the current time.'); + } + const currentTimeValue = operatorFactValues.o3rCurrentTime as number; + const now = new Date(currentTimeValue); + const leftDate = new Date(leftDateInput); + const targetDate = new Date(new Date(currentTimeValue).setMinutes(now.getMinutes() + +minutes)); + return leftDate >= now && leftDate > targetDate; + }, + factImplicitDependencies: ['o3rCurrentTime'], + validateLhs: isValidDateInput, + validateRhs: numberValidator +}; + /** * Check if a date variable is prior than a specified date * @@ -81,5 +127,5 @@ export const dateNotEquals: Operator = { }; export const dateBasedOperators = [ - inRangeDate, dateAfter, dateBefore, dateEquals, dateNotEquals + inRangeDate, dateInNextMinutes, dateNotInNextMinutes, dateAfter, dateBefore, dateEquals, dateNotEquals ]; diff --git a/packages/@o3r/rules-engine/src/engine/ruleset-executor.ts b/packages/@o3r/rules-engine/src/engine/ruleset-executor.ts index 9bcbd7a433..f6d05bbbb7 100644 --- a/packages/@o3r/rules-engine/src/engine/ruleset-executor.ts +++ b/packages/@o3r/rules-engine/src/engine/ruleset-executor.ts @@ -182,7 +182,7 @@ export class RulesetExecutor { throw new Error(`Unknown operator : ${nestedCondition.operator}, skipping the rule execution...`); } return executeOperator(this.getOperandValue(nestedCondition.lhs, factsValue, runtimeFactValues), - this.getOperandValue('rhs' in nestedCondition ? nestedCondition.rhs : undefined, factsValue, runtimeFactValues), operator); + this.getOperandValue('rhs' in nestedCondition ? nestedCondition.rhs : undefined, factsValue, runtimeFactValues), operator, factsValue); } if (isNotCondition(nestedCondition)) { @@ -195,38 +195,58 @@ export class RulesetExecutor { throw new Error(`Unknown condition block met : ${JSON.stringify(nestedCondition)}`); } + // TODO: make private and move to top of the class in a separate PR to make the diff more meaningful in this one /** * Plug ruleset to fact streams and trigger a first evaluation - * - * @param ruleset */ public prepareRuleset() { - const rulesWithContext: Rule[] = []; - const rulesWithoutContext: Rule[] = []; + const inputFactsForRule: Record = {}; + const exploreObject = (currentObj: any, ruleInputFacts: Set) => { + if (currentObj && currentObj.type === 'FACT') { + ruleInputFacts.add(currentObj.value); + } else if (Array.isArray(currentObj)) { + currentObj.forEach((elem: any) => exploreObject(elem, ruleInputFacts)); + } else { + for (const key in currentObj) { + if (key === 'operator') { + const op = this.operators[currentObj[key]]; + if (op && op.factImplicitDependencies) { + op.factImplicitDependencies.forEach(dep => ruleInputFacts.add(dep)); + } + } else if (typeof currentObj[key] === 'object' && currentObj[key] !== null) { + exploreObject(currentObj[key], ruleInputFacts); + } + } + } + }; + const findRuleInputFacts = (obj: AllBlock): string[] => { + const ruleInputFacts = new Set(); + exploreObject(obj, ruleInputFacts); + return Array.from(ruleInputFacts); + }; + this.ruleset.rules.forEach((rule) => inputFactsForRule[rule.id] = findRuleInputFacts(rule.rootElement)); const factsThatRerunEverything: string[] = []; this.ruleset.rules.forEach((rule) => { if (rule.outputRuntimeFacts.length > 0 || rule.inputRuntimeFacts.length > 0) { - rulesWithContext.push(rule); - factsThatRerunEverything.push(...rule.inputFacts); + factsThatRerunEverything.push(...inputFactsForRule[rule.id]); } else { - rulesWithoutContext.push(rule); } }); - const triggerFull: Observable = factsThatRerunEverything.length === 0 ? of([]) : + const triggerFull$: Observable = factsThatRerunEverything.length === 0 ? of([]) : combineLatest(factsThatRerunEverything.map((fact) => this.rulesEngine.retrieveOrCreateFactStream(fact))); - const result$ = triggerFull.pipe(switchMap(() => { + const result$ = triggerFull$.pipe(switchMap(() => { const runtimeFactValues: Record = {}; let rulesetInputFacts: string[]; if (this.rulesEngine.debugMode) { rulesetInputFacts = Array.from(this.ruleset.rules.reduce((acc, rule) => { - rule.inputFacts.forEach((factName) => acc.add(factName)); + inputFactsForRule[rule.id].forEach((factName) => acc.add(factName)); return acc; }, new Set())); } return combineLatest(this.ruleset.rules.map((rule) => { - const inputFacts = Array.from(new Set(rule.inputFacts)); + const inputFacts = inputFactsForRule[rule.id]; const values$ = inputFacts.map((fact) => this.rulesEngine.retrieveOrCreateFactStream(fact)); return (values$.length ? combineLatest(values$) : of([[]] as (Facts | undefined)[])) .pipe( @@ -247,7 +267,7 @@ export class RulesetExecutor { } if (this.rulesEngine.debugMode) { - output.evaluation = handleRuleEvaluationDebug(rule, this.ruleset.name, output.actions, output.error, runtimeFactValues, factValues, oldFactValues); + output.evaluation = handleRuleEvaluationDebug({ ...rule, inputFacts }, this.ruleset.name, output.actions, output.error, runtimeFactValues, factValues, oldFactValues); } else if (output.error) { this.rulesEngine.logger?.error(output.error); this.rulesEngine.logger?.warn(`Skipping rule ${rule.name}, and the associated ruleset`); @@ -302,5 +322,4 @@ export class RulesetExecutor { rulesResultsSubject$: result$ } as EngineRuleset; } - } diff --git a/packages/@o3r/rules-engine/src/facts/current-time/current-time-fact-factories.ts b/packages/@o3r/rules-engine/src/facts/current-time/current-time-fact-factories.ts new file mode 100644 index 0000000000..71c5ea53f3 --- /dev/null +++ b/packages/@o3r/rules-engine/src/facts/current-time/current-time-fact-factories.ts @@ -0,0 +1,6 @@ +import { BehaviorSubject } from 'rxjs'; + +/** + * Behaviour subject with the current time as the initial value + */ +export const currentTimeSubject$ = new BehaviorSubject(Date.now()); diff --git a/packages/@o3r/rules-engine/src/facts/current-time/current-time.facts.ts b/packages/@o3r/rules-engine/src/facts/current-time/current-time.facts.ts new file mode 100644 index 0000000000..6977227b68 --- /dev/null +++ b/packages/@o3r/rules-engine/src/facts/current-time/current-time.facts.ts @@ -0,0 +1,12 @@ +import type { FactDefinitions } from '../../engine'; + + +/** + * Operator facts that provide the current time + */ +export interface CurrentTimeFacts extends FactDefinitions { + /** + * The current time as a timestamp + */ + o3rCurrentTime: number; +} diff --git a/packages/@o3r/rules-engine/src/facts/current-time/index.ts b/packages/@o3r/rules-engine/src/facts/current-time/index.ts new file mode 100644 index 0000000000..77e793f187 --- /dev/null +++ b/packages/@o3r/rules-engine/src/facts/current-time/index.ts @@ -0,0 +1 @@ +export * from './current-time.facts'; diff --git a/packages/@o3r/rules-engine/src/facts/index.ts b/packages/@o3r/rules-engine/src/facts/index.ts new file mode 100644 index 0000000000..1a98ea8285 --- /dev/null +++ b/packages/@o3r/rules-engine/src/facts/index.ts @@ -0,0 +1 @@ +export * from './current-time/index'; diff --git a/packages/@o3r/rules-engine/src/public_api.ts b/packages/@o3r/rules-engine/src/public_api.ts index 76a755f4a0..3f1a0a2c77 100644 --- a/packages/@o3r/rules-engine/src/public_api.ts +++ b/packages/@o3r/rules-engine/src/public_api.ts @@ -2,6 +2,7 @@ export * from './components/index'; export * from './devkit/index'; export * from './engine/index'; export * from './fact/index'; +export * from './facts/index'; export * from './interfaces/index'; export * from './services/index'; export * from './stores/index'; diff --git a/packages/@o3r/rules-engine/src/services/rules-engine.service.it.spec.ts b/packages/@o3r/rules-engine/src/services/rules-engine.service.it.spec.ts index 59fae380d6..90be399f50 100644 --- a/packages/@o3r/rules-engine/src/services/rules-engine.service.it.spec.ts +++ b/packages/@o3r/rules-engine/src/services/rules-engine.service.it.spec.ts @@ -91,13 +91,11 @@ describe('Rules engine service', () => { jest.spyOn(console, 'warn').mockImplementation(); }); - it('Should support Block with no condition', (done) => { + it('Should support Block with no condition', async () => { store.dispatch(setRulesetsEntities({entities: jsonOneRulesetOneRuleNoCond.ruleSets})); - service.events$.pipe(take(1)).subscribe((actions) => { - expect(actions.length).toBe(1); - expect(actions[0].actionType).toBe('UPDATE_LOCALISATION'); - done(); - }); + const actions = await firstValueFrom(service.events$); + expect(actions.length).toBe(1); + expect(actions[0].actionType).toBe('UPDATE_LOCALISATION'); }); it('should handle linked components and validity range properly', (done) => { @@ -110,7 +108,7 @@ describe('Rules engine service', () => { }); - it('should have configuration updated in the store', (done) => { + it('should have configuration updated in the store', async () => { service.engine.upsertFacts([{ id: 'isMobileDevice', value$: isMobileDevice$ @@ -122,26 +120,17 @@ describe('Rules engine service', () => { ]); store.dispatch(setRulesetsEntities({entities: jsonTwoRulesetTwoRules.ruleSets})); - service.events$.pipe(take(1)).subscribe((actions) => { - // RunTime fact should be properly propagated to the next rule depending on it, with the proper value - // Actions should be returned in the correct order - expect(actions.length).toBe(4); - expect(actions[0].actionType).toBe('UPDATE_CONFIG'); - expect(actions[1].actionType).toBe('UPDATE_ASSET'); - expect(actions[2].value).toBe('my.custom.ssci.loc.key2'); - expect(actions[3].value).toBe('my.custom.ssci.loc.key3'); - - store.pipe( - select(selectConfigOverride), - filter((configs) => Object.keys(configs).length > 0), - take(1) - ).subscribe((configs) => { - expect(configs['@otter/library#TheConfig']).toBeDefined(); - expect(configs['@otter/library#TheConfig']?.theproperty).toEqual(['raviole', 'truelle']); - done(); - }); - }); - + const actions = await firstValueFrom(service.events$); + // RunTime fact should be properly propagated to the next rule depending on it, with the proper value + // Actions should be returned in the correct order + expect(actions.length).toBe(4); + expect(actions[0].actionType).toBe('UPDATE_CONFIG'); + expect(actions[1].actionType).toBe('UPDATE_ASSET'); + expect(actions[2].value).toBe('my.custom.ssci.loc.key2'); + expect(actions[3].value).toBe('my.custom.ssci.loc.key3'); + const configs = await firstValueFrom(store.pipe(select(selectConfigOverride), filter((confs) => Object.keys(confs).length > 0))); + expect(configs['@otter/library#TheConfig']).toBeDefined(); + expect(configs['@otter/library#TheConfig']?.theproperty).toEqual(['raviole', 'truelle']); }); it('re-evaluation should work properly', async () => { @@ -198,33 +187,29 @@ describe('Rules engine service', () => { expect(actions[2].value).toBe('my.loc.value3.success'); }); - it('should ignore all actions from a ruleset if one rules throws', (done) => { + it('should ignore all actions from a ruleset if one rules throws', async () => { service.engine.upsertFacts([{ id: 'factWithUndefinedValue', value$: of(undefined) }]); store.dispatch(setRulesetsEntities({entities: jsonOneRulesetThreeRulesOneThrows.ruleSets})); - service.events$.pipe(take(1)).subscribe((actions) => { - expect(actions.length).toBe(0); - done(); - }); + const actions = await firstValueFrom(service.events$); + expect(actions.length).toBe(0); }); - it('should supported nested rules', (done) => { + it('should supported nested rules', async () => { service.engine.upsertFacts([{ id: 'isMobileDevice', value$: isMobileDevice$ }, {id: 'cart', value$: cartFact$} ]); store.dispatch(setRulesetsEntities({entities: jsonOneRulesetTwoNestedRules.ruleSets})); - service.events$.pipe(take(1)).subscribe((actions) => { - expect(actions.length).toBe(4); - expect(actions[0].value).toBe('my.loc.value2.success'); - expect(actions[1].value).toBe('my.loc.value3.success'); - expect(actions[2].value).toBe('my.loc.value4.success'); - expect(actions[3].value).toBe('my.loc.value6.success'); - done(); - }); + const actions = await firstValueFrom(service.events$); + expect(actions.length).toBe(4); + expect(actions[0].value).toBe('my.loc.value2.success'); + expect(actions[1].value).toBe('my.loc.value3.success'); + expect(actions[2].value).toBe('my.loc.value4.success'); + expect(actions[3].value).toBe('my.loc.value6.success'); }); it('should work properly if the fact is added after the first initialization, should trigger changes', async () => { @@ -249,54 +234,47 @@ describe('Rules engine service', () => { expect(nextActions[1].value).toBe('my.loc.value2.success'); }); - it('should enable/disable an on demand ruleset', (done) => { + it('should enable/disable an on demand ruleset', async () => { service.engine.upsertFacts([{ id: 'isMobileDevice', value$: of(true) }]); store.dispatch(setRulesetsEntities({entities: jsonTwoRulesetsOneOnDemand.rulesets})); - service.events$.pipe(take(1)).subscribe((actions) => { - // should execute the actions from active rulesets at bootstrap - expect(actions.length).toBe(4); - service.enableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); - service.events$.pipe(take(1)).subscribe((nextActions) => { - // should execute the extra action added by the ruleset on demand (linked to the component) - expect(nextActions.length).toBe(5); - service.disableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); - service.events$.pipe(take(1)).subscribe((nextNextActions) => { - // should execute the actions from active rulesets after deactivating the ruleset on demand - expect(nextNextActions.length).toBe(4); - done(); - }); - }); - }); + const actions = await firstValueFrom(service.events$); + // should execute the actions from active rulesets at bootstrap + expect(actions.length).toBe(4); + service.enableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); + const nextActions = await firstValueFrom(service.events$); + + // should execute the extra action added by the ruleset on demand (linked to the component) + expect(nextActions.length).toBe(5); + service.disableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); + const nextNextActions = await firstValueFrom(service.events$); + // should execute the actions from active rulesets after deactivating the ruleset on demand + expect(nextNextActions.length).toBe(4); }); - it('should keep enabled for multiple instances of components', (done) => { + it('should keep enabled for multiple instances of components', async () => { service.engine.upsertFacts([{ id: 'isMobileDevice', value$: of(true) }]); store.dispatch(setRulesetsEntities({entities: jsonTwoRulesetsOneOnDemand.rulesets})); - service.events$.pipe(take(1)).subscribe((actions) => { - // should execute the actions from active rulesets at bootstrap - expect(actions.length).toBe(4); - service.enableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); - service.enableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); - service.events$.pipe(take(1)).subscribe((nextActions) => { - // should execute once the extra action added by the ruleset on demand (linked to the component multiple instances) - expect(nextActions.length).toBe(5); - service.disableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); - service.events$.pipe(take(1)).subscribe((nextNextActions) => { - // should keep the ruleset active after deactivating the ruleset for one component instance - expect(nextNextActions.length).toBe(5); - done(); - }); - }); - }); + const actions = await firstValueFrom(service.events$); + // should execute the actions from active rulesets at bootstrap + expect(actions.length).toBe(4); + service.enableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); + service.enableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); + const nextActions = await firstValueFrom(service.events$); + // should execute once the extra action added by the ruleset on demand (linked to the component multiple instances) + expect(nextActions.length).toBe(5); + service.disableRuleSetFor(computeConfigurationName('o3r-calendar-per-bound-cont', '@otter/demo-app-components')); + const nextNextActions = await firstValueFrom(service.events$); + // should keep the ruleset active after deactivating the ruleset for one component instance + expect(nextNextActions.length).toBe(5); }); - it('should skip the entire ruleset if undefined fact encountered', (done) => { + it('should skip the entire ruleset if undefined fact encountered', async () => { const aNumberSubj = new BehaviorSubject(undefined); // const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); service.engine.upsertFacts([{ @@ -306,25 +284,21 @@ describe('Rules engine service', () => { store.dispatch(setRulesetsEntities({entities: jsonOneRulesetTwoRules.ruleSets})); // eslint-disable-next-line no-console expect(consoleSpy).toHaveBeenCalled(); - service.events$.pipe(take(1)).subscribe((actions) => { - expect(actions.length).toBe(1); - // Fake emit of same value from sNumber fact, should not do anything - aNumberSubj.next(undefined); - // eslint-disable-next-line no-console - expect(consoleSpy).toHaveBeenCalledTimes(1); - // Fake emit of new value from sNumber fact, should trigger error, but not the events$ - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - aNumberSubj.next(null as any); - // eslint-disable-next-line no-console - expect(consoleSpy).toHaveBeenCalledTimes(2); - // Fake emit of new value from sNumber fact, should not trigger error, and trigger events$ - aNumberSubj.next(4); - service.events$.pipe(take(1)).subscribe((newActions) => { - expect(newActions.length).toBe(3); - done(); - }); - - }); + const actions = await firstValueFrom(service.events$); + expect(actions.length).toBe(1); + // Fake emit of same value from sNumber fact, should not do anything + aNumberSubj.next(undefined); + // eslint-disable-next-line no-console + expect(consoleSpy).toHaveBeenCalledTimes(1); + // Fake emit of new value from sNumber fact, should trigger error, but not the events$ + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + aNumberSubj.next(null as any); + // eslint-disable-next-line no-console + expect(consoleSpy).toHaveBeenCalledTimes(2); + // Fake emit of new value from sNumber fact, should not trigger error, and trigger events$ + aNumberSubj.next(4); + const newActions = await firstValueFrom(service.events$); + expect(newActions.length).toBe(3); }); diff --git a/packages/@o3r/rules-engine/src/services/rules-engine.service.ts b/packages/@o3r/rules-engine/src/services/rules-engine.service.ts index 6fa583b2d7..39b6b1e22b 100644 --- a/packages/@o3r/rules-engine/src/services/rules-engine.service.ts +++ b/packages/@o3r/rules-engine/src/services/rules-engine.service.ts @@ -24,6 +24,7 @@ import type {RulesetsStore} from '../stores'; import {selectActiveRuleSets, selectAllRulesets, selectRuleSetLinkComponents} from '../stores'; import {RULES_ENGINE_OPTIONS, RulesEngineServiceOptions} from './rules-engine.token'; import {LoggerService} from '@o3r/logger'; +import { currentTimeSubject$ } from '../facts/current-time/current-time-fact-factories'; @Injectable() export class RulesEngineService implements OnDestroy { @@ -60,6 +61,9 @@ export class RulesEngineService implements OnDestroy { debugger: engineConfig?.debug ? new EngineDebugger({eventsStackLimit: engineConfig?.debugEventsStackLimit}) : undefined, logger: this.logger }); + + this.registerOperatorFacts(); + this.ruleSets$ = combineLatest([ this.store.pipe(select(selectActiveRuleSets)), this.linkedComponents$.pipe( @@ -196,6 +200,10 @@ export class RulesEngineService implements OnDestroy { })); } + private registerOperatorFacts() { + this.upsertFacts([{ id: 'o3rCurrentTime', value$: currentTimeSubject$.asObservable() }]); + } + /** * Execute the list of actions *