diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f5802e7..366ce02 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,6 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 16 cache: yarn - run: yarn install --immutable diff --git a/package.json b/package.json index b7014f2..8eb917b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "format": "prettier --write .", "format:check": "prettier --check ." }, + "engines": { + "node": ">=17" + }, "exports": { ".": { "import": "./dist/index.js", @@ -47,16 +50,15 @@ "license": "MIT", "dependencies": { "@types/node": "^18.11.18", - "publicodes": "1.0.0-beta.77" + "publicodes": "^1.0.1" }, "devDependencies": { "@types/jest": "^29.2.5", "docdash": "^2.0.1", "jest": "^29.4.1", - "mitata": "^0.1.6", "prettier": "^3.0.0", "ts-jest": "^29.0.4", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "tsup": "^6.5.0", "typedoc": "^0.24.8", "typedoc-plugin-export-functions": "^1.0.0", diff --git a/source/commons.ts b/source/commons.ts index 33b4157..238e00f 100644 --- a/source/commons.ts +++ b/source/commons.ts @@ -1,13 +1,5 @@ import { basename } from 'path' -import { - Rule, - ParsedRules, - Logger, - ExprAST, - RuleNode, - reduceAST, - ASTNode, -} from 'publicodes' +import { Rule, Logger, ExprAST, reduceAST, ASTNode } from 'publicodes' import yaml from 'yaml' /** @@ -67,39 +59,13 @@ export type ImportMacro = { /** * Represents a non-parsed NGC rule. */ -export type RawRule = Omit & ImportMacro +export type RawRule = Omit | ImportMacro /** * Represents a non-parsed NGC model. */ export type RawRules = Record -/** - * Returns the raw nodes of a parsed rules object. - * - * @param parsedRules - The parsed rules object. - * - * @returns The raw nodes of the parsed rules object. - */ -export function getRawNodes(parsedRules: ParsedRules): RawRules { - return Object.fromEntries( - Object.values(parsedRules).reduce((acc, rule) => { - const { nom, ...rawNode } = rule.rawNode - // We don't want to keep the `avec` attribute in the raw node - // as they are already resolved in the [parsedRules] object. - delete rawNode['avec'] - - acc.push([ - rule.dottedName, - // If the rule only contained the 'nom' attribute, we don't want to - // keep an empty object in the raw node. - Object.keys(rawNode).length === 0 ? null : rawNode, - ]) - return acc - }, []), - ) as RawRules -} - function consumeMsg(_: string): void {} export const disabledLogger: Logger = { @@ -115,7 +81,7 @@ export const disabledLogger: Logger = { * * @returns The references. */ -export function getAllRefsInNode(node: RuleNode): RuleName[] { +export function getAllRefsInNode(node: ASTNode): RuleName[] { return reduceAST( (refs: RuleName[], node: ASTNode) => { if (node === undefined) { diff --git a/source/compilation/resolveImports.ts b/source/compilation/resolveImports.ts index 3e0da44..b51aa50 100644 --- a/source/compilation/resolveImports.ts +++ b/source/compilation/resolveImports.ts @@ -162,21 +162,6 @@ ${importedRule.description}` } } -/** - * @throws {Error} If the `nom` attribute is different from the `ruleNameToCheck`. - */ -function removeRawNodeNom( - rawNode: Rule, - ruleNameToCheck: string, -): Omit { - const { nom, ...rest } = rawNode - if (nom !== ruleNameToCheck) - throw Error( - `Imported rule's publicode raw node "nom" attribute is different from the resolveImport script ruleName. Please investigate`, - ) - return rest -} - function appearsMoreThanOnce( rulesToImport: RuleToImport[], ruleName: RuleName, @@ -218,7 +203,7 @@ export function resolveImports( let neededNamespaces = new Set() const resolvedRules = Object.entries(rules).reduce((acc, [name, value]) => { if (name === IMPORT_KEYWORD) { - const importMacro: ImportMacro = value + const importMacro = value as ImportMacro const engine = getEngine(filePath, importMacro, verbose) const rulesToImport: RuleToImport[] = importMacro['les règles']?.map(getRuleToImportInfos) @@ -252,10 +237,7 @@ export function resolveImports( utils .ruleParents(ruleName) .forEach((rule) => neededNamespaces.add(`${namespace} . ${rule}`)) - return [ - `${namespace} . ${ruleName}`, - removeRawNodeNom(ruleWithUpdatedDescription, ruleName), - ] + return [`${namespace} . ${ruleName}`, ruleWithUpdatedDescription] } const ruleWithOverridenAttributes = { ...rule.rawNode, ...attrs } diff --git a/source/index.ts b/source/index.ts index 479c713..8d1a41d 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1 +1,2 @@ export * from './commons' +export * from './serializeParsedRules' diff --git a/source/optims/constantFolding.ts b/source/optims/constantFolding.ts index adb036f..8e90243 100644 --- a/source/optims/constantFolding.ts +++ b/source/optims/constantFolding.ts @@ -1,16 +1,16 @@ -import Engine, { reduceAST, ParsedRules, parseExpression } from 'publicodes' -import type { RuleNode, ASTNode, Unit } from 'publicodes' -import { - RuleName, - serializeParsedExprAST, - substituteInParsedExpr, -} from '../commons' - -type RefMap = Map< - RuleName, - // NOTE: It's an array but it's built from a Set, so no duplication - RuleName[] | undefined -> +import Engine, { + reduceAST, + ParsedRules, + transformAST, + traverseASTNode, + Unit, + EvaluatedNode, + utils, +} from 'publicodes' +import type { RuleNode, ASTNode } from 'publicodes' +import { RuleName } from '../commons' + +type RefMap = Map | undefined> type RefMaps = { parents: RefMap @@ -31,35 +31,33 @@ type FoldingCtx = { toKeep?: PredicateOnRule params: FoldingParams /** - * The rules that are evaluated with a modified situation (in a [recalcul] mechanism) - * and we don't want to be folded. + * The rules that are evaluated with a modified situation (in a [contexte] + * mechanism or with a [remplacement]) and we don't want to be folded. * * @example * ``` * rule: - * recalcul: - * règle: rule2 - * avec: - * rule3: 10 - * rule4: 20 + * valeur: rule2 + * contexte: + * rule3: 10 + * rule4: 20 * ... * ``` - * In this case, [rule2] should not be folded. + * In this case, we don't want to fold [rule2] because it's evaluated with a + * modified situation (unless it's a constant). We also don't want to fold + * [rule3] and [rule4] because they are used in the contexte of [rule]. */ - recalculRules: Set + unfoldableRules: Set } -function addMapEntry(map: RefMap, key: RuleName, values: RuleName[]) { - let vals = map.get(key) - if (vals) { - vals = vals.concat(values) - } - map.set(key, vals || values) +function addMapEntry(map: RefMap, key: RuleName, values: Set) { + const vals = map.get(key) ?? new Set() + values.forEach((val) => vals.add(val)) + map.set(key, vals) } function initFoldingCtx( engine: Engine, - parsedRules: ParsedRules, toKeep?: PredicateOnRule, foldingParams?: FoldingParams, ): FoldingCtx { @@ -67,22 +65,59 @@ function initFoldingCtx( parents: new Map(), childs: new Map(), } - const recalculRules = new Set() + const unfoldableRules = new Set() + const parsedRules = copyFullParsedRules(engine) + + for (const ruleName in parsedRules) { + const ruleNode = parsedRules[ruleName] + + if (ruleNode.replacements.length > 0) { + unfoldableRules.add(ruleName) + ruleNode.replacements.forEach((replacement) => { + // TODO: we could use white-listed and black-listed rules to be more + // precise about which rules we want to fold. But for now, we + // consider that all rules that are replaced are not foldable in + // all cases. + unfoldableRules.add(replacement.replacedReference.name) + }) + } + + if (ruleNode.explanation.valeur.nodeKind === 'contexte') { + engine.cache.traversedVariablesStack = [] + const evaluation = engine.evaluate(ruleName) + + // We don't want to fold a rule which can be nullable with a different situation + // For example, if its namespace is conditionnaly applicable. + // + // TODO(@EmileRolley): for now, all ref nodes inside a contexte are considered + // as not foldable. We could be more precise by associating a ref node with + // the rules that are used in its contexte therefore we could fold the ref node + // in all other cases. + if ( + Object.keys(evaluation.missingVariables).length !== 0 || + isNullable(evaluation) + ) { + unfoldableRules.add(ruleName) + ruleNode.explanation.valeur.explanation.contexte.forEach(([ref, _]) => { + unfoldableRules.add(ref.dottedName) + }) + } + } - Object.entries(parsedRules).forEach(([ruleName, ruleNode]) => { - const reducedAST = + const traversedRefs: Set = + // We need to traverse the AST to find all the references used inside a rule. + // + // NOTE: We can't use the [referencesMap] from the engine's context because it + // contains references to rules that are beyond the scope of the current + // rule and we only want to consider the references that are used inside the + // current rule. reduceAST( (acc: Set, node: ASTNode) => { - if ( - node.nodeKind === 'recalcul' && - 'dottedName' in node.explanation.recalculNode - ) { - recalculRules.add(node.explanation.recalculNode.dottedName) - } if ( node.nodeKind === 'reference' && 'dottedName' in node && - node.dottedName !== ruleName + node.dottedName !== ruleName && + !node.dottedName.endsWith('$SITUATION') ) { return acc.add(node.dottedName) } @@ -90,331 +125,295 @@ function initFoldingCtx( new Set(), ruleNode.explanation.valeur, ) ?? new Set() - const traversedVariables: RuleName[] = Array.from(reducedAST).filter( - (name) => !name.endsWith(' . $SITUATION'), - ) - if (traversedVariables.length > 0) { - addMapEntry(refs.childs, ruleName, traversedVariables) - traversedVariables.forEach((traversedVar) => { - addMapEntry(refs.parents, traversedVar, [ruleName]) + if (traversedRefs.size > 0) { + addMapEntry(refs.childs, ruleName, traversedRefs) + traversedRefs.forEach((traversedVar) => { + addMapEntry(refs.parents, traversedVar, new Set([ruleName])) }) } - }) + } return { engine, parsedRules, refs, toKeep, - recalculRules, - params: { isFoldedAttr: foldingParams?.isFoldedAttr ?? 'optimized' }, - } -} - -function isFoldable( - rule: RuleNode | undefined, - recalculRules: Set, -): boolean { - if (!rule) { - return false + unfoldableRules, + params: { + isFoldedAttr: foldingParams?.isFoldedAttr ?? 'optimized', + }, } - - const rawNode = rule.rawNode - return !( - recalculRules.has(rule.dottedName) || - 'question' in rawNode || - // NOTE(@EmileRolley): I assume that a rule can have a [par défaut] attribute without a [question] one. - // The behavior could be specified. - 'par défaut' in rawNode || - 'applicable si' in rawNode || - 'non applicable si' in rawNode - ) } -function isEmptyRule(rule: RuleNode): boolean { - // There is always a 'nom' attribute. - return Object.keys(rule.rawNode).length <= 1 -} - -function formatPublicodesUnit(unit?: Unit): string { - if ( - unit !== undefined && - unit.numerators.length === 1 && - unit.numerators[0] === '%' - ) { - return '%' - } - return '' -} +const unfoldableAttr = ['par défaut', 'question'] -// Replaces boolean values by their string representation in French. -function formatToPulicodesValue(value: any, unit?: Unit) { - if (typeof value === 'boolean') { - return value ? 'oui' : 'non' - } +function isFoldable(ctx: FoldingCtx, rule: RuleNode): boolean { + let childInContext = false + const childs = ctx.refs.childs.get(rule.dottedName) - return value + formatPublicodesUnit(unit) -} + childs?.forEach((child) => { + if (ctx.unfoldableRules.has(child)) { + childInContext = true + return + } + }) -function replaceAllRefs( - str: string, - refName: string, - constantValue: any, - currentRuleName: string, -): string { - const parsedExpression = parseExpression(str, currentRuleName) - const newParsedExpression = substituteInParsedExpr( - parsedExpression, - refName, - constantValue, + return ( + rule !== undefined && + !unfoldableAttr.find((attr) => attr in rule.rawNode) && + !ctx.unfoldableRules.has(rule.dottedName) && + !childInContext ) - return serializeParsedExprAST(newParsedExpression) } -function lexicalSubstitutionOfRefValue( - parent: RuleNode, - constant: RuleNode, -): RuleNode | undefined { - // Retrieves the name form used in the rule. For exemple, the rule 'root . a - // . b' could have the name 'b', 'a . b' or 'root . a . b'. - const refName = reduceAST( - (_, node: ASTNode) => { - if ( - node.nodeKind === 'reference' && - node.dottedName === constant.dottedName - ) { - return node.name - } - }, - '', - parent, - ) - - const constValue = formatToPulicodesValue(constant.rawNode.valeur) - - if ('formule' in parent.rawNode) { - if (typeof parent.rawNode.formule === 'string') { - const newFormule = replaceAllRefs( - parent.rawNode.formule, - refName, - constValue, - constant.dottedName, - ) - parent.rawNode.formule = newFormule - return parent - } else if ('somme' in parent.rawNode.formule) { - // TODO: needs to be abstracted - parent.rawNode.formule.somme = ( - parent.rawNode.formule.somme as (string | number)[] - ).map((expr: string | number) => { - return typeof expr === 'string' - ? replaceAllRefs(expr, refName, constValue, constant.dottedName) - : expr - }) - return parent - } - } - // When a rule defined as an unique string: 'var * var2', it's parsed as a [valeur] attribute not a [formule]. - if (typeof parent.rawNode.valeur === 'string') { - parent.rawNode.formule = replaceAllRefs( - parent.rawNode.valeur, - refName, - constValue, - constant.dottedName, - ) - delete parent.rawNode.valeur - return parent - } +function isEmptyRule(rule: RuleNode): boolean { + return Object.keys(rule.rawNode).length === 0 } -/** Replaces all references in parent refs of [ruleName] by its [rule.valeur] */ +/** + * Replaces all references in parent refs of [ruleName] by its [constantNode] + */ function searchAndReplaceConstantValueInParentRefs( ctx: FoldingCtx, ruleName: RuleName, - rule: RuleNode, -): void { + constantNode: ASTNode, +) { const refs = ctx.refs.parents.get(ruleName) if (refs) { for (const parentName of refs) { - let parentRule = ctx.parsedRules[parentName] - - if (isFoldable(parentRule, ctx.recalculRules)) { - const newRule = lexicalSubstitutionOfRefValue(parentRule, rule) - if (newRule !== undefined) { - parentRule = newRule - parentRule.rawNode[ctx.params.isFoldedAttr] = true - removeInMap(ctx.refs.parents, ruleName, parentName) - } + const parentRule = ctx.parsedRules[parentName] + const newRule = traverseASTNode( + transformAST((node, _) => { + if (node.nodeKind === 'reference' && node.dottedName === ruleName) { + return constantNode + } + }), + parentRule, + ) as RuleNode + + if (newRule !== undefined) { + ctx.parsedRules[parentName] = newRule + ctx.parsedRules[parentName].rawNode[ctx.params.isFoldedAttr] = + 'partially' + removeInMap(ctx.refs.parents, ruleName, parentName) } } } } function isAlreadyFolded(params: FoldingParams, rule: RuleNode): boolean { - return 'rawNode' in rule && params.isFoldedAttr in rule.rawNode + return ( + 'rawNode' in rule && + params.isFoldedAttr in rule.rawNode && + rule.rawNode[params.isFoldedAttr] === 'fully' + ) } -/** - * Subsitutes [parentRuleNode.formule] ref constant from [refs]. - * - * @note It folds child rules in [refs] if possible. - */ -function replaceAllPossibleChildRefs(ctx: FoldingCtx, refs: RuleName[]): void { - if (refs) { - for (const childName of refs) { - const childNode = ctx.parsedRules[childName] - - if ( - childNode && - isFoldable(childNode, ctx.recalculRules) && - !isAlreadyFolded(ctx.params, childNode) - ) { - tryToFoldRule(ctx, childName, childNode) - } - } +function removeInMap(map: Map>, key: K, val: V) { + if (map.has(key)) { + map.get(key).delete(val) } } -export function removeInMap( - map: Map, - key: K, - val: V, -): Map { - return map.set( - key, - (map.get(key) ?? []).filter((v) => v !== val), - ) -} - function removeRuleFromRefs(ref: RefMap, ruleName: RuleName) { - ref.forEach((_, rule) => { + for (const rule of ref.keys()) { removeInMap(ref, rule, ruleName) - }) + } } -function deleteRule(ctx: FoldingCtx, dottedName: RuleName): void { +function tryToDeleteRule(ctx: FoldingCtx, dottedName: RuleName): boolean { const ruleNode = ctx.parsedRules[dottedName] + if ( (ctx.toKeep === undefined || !ctx.toKeep([dottedName, ruleNode])) && - isFoldable(ruleNode, ctx.recalculRules) + isFoldable(ctx, ruleNode) ) { removeRuleFromRefs(ctx.refs.parents, dottedName) removeRuleFromRefs(ctx.refs.childs, dottedName) delete ctx.parsedRules[dottedName] ctx.refs.parents.delete(dottedName) ctx.refs.childs.delete(dottedName) + + return true } + + return false } /** Removes the [parentRuleName] as a parent dependency of each [childRuleNamesToUpdate]. */ function updateRefCounting( ctx: FoldingCtx, parentRuleName: RuleName, - ruleNamesToUpdate: RuleName[], -): void { - ruleNamesToUpdate.forEach((ruleNameToUpdate) => { + ruleNamesToUpdate: Set, +) { + for (const ruleNameToUpdate of ruleNamesToUpdate) { removeInMap(ctx.refs.parents, ruleNameToUpdate, parentRuleName) - if (ctx.refs.parents.get(ruleNameToUpdate)?.length === 0) { - deleteRule(ctx, ruleNameToUpdate) + if (ctx.refs.parents.get(ruleNameToUpdate)?.size === 0) { + tryToDeleteRule(ctx, ruleNameToUpdate) } - }) + } } -function tryToFoldRule( - ctx: FoldingCtx, - ruleName: RuleName, +function replaceRuleWithEvaluatedNodeValue( rule: RuleNode, -): void { + nodeValue: number | boolean | string | Record, + unit: Unit | undefined, +): ASTNode { + const constantNode: ASTNode = { + nodeValue, + type: + typeof nodeValue === 'number' + ? 'number' + : typeof nodeValue === 'boolean' + ? 'boolean' + : typeof nodeValue === 'string' + ? 'string' + : undefined, + + nodeKind: 'constant', + missingVariables: {}, + rawNode: { + valeur: nodeValue, + }, + isNullable: false, + } + const explanationThen: ASTNode = + unit !== undefined + ? { + nodeKind: 'unité', + unit, + explanation: constantNode, + } + : constantNode + + if (rule.explanation.valeur.nodeKind === 'contexte') { + // We remove the contexte as it's now considered as a constant. + rule.explanation.valeur = rule.explanation.valeur.explanation.node + } + + rule.explanation.valeur = traverseASTNode( + transformAST((node, _) => { + if (node.nodeKind === 'condition') { + /* we found the first condition, which wrapped the rule in the form of: + * + * - si: + * est non défini: . $SITUATION + * - alors: + * - sinon: . $SITUATION + */ + node.explanation.alors = explanationThen + return node + } + }), + rule, + ) + + return explanationThen +} + +function isNullable(node: ASTNode): boolean { + // @ts-ignore + if (node?.explanation?.nullableParent !== undefined) { + return true + } + + return reduceAST( + // @ts-ignore + (_, node) => { + if (!node) { + return false + } + + //@ts-ignore + if (node?.explanation?.nullableParent !== undefined) { + return true + } + }, + false, + // We expect a reference node here + // @ts-ignore + node?.explanation?.valeur, + ) +} + +function fold(ctx: FoldingCtx, ruleName: RuleName, rule: RuleNode): void { if ( rule !== undefined && - (!isFoldable(rule, ctx.recalculRules) || + (!isFoldable(ctx, rule) || + !utils.isAccessible(ctx.parsedRules, '', rule.dottedName) || isAlreadyFolded(ctx.params, rule) || !(ruleName in ctx.parsedRules)) ) { - // Already managed rule return } const ruleParents = ctx.refs.parents.get(ruleName) if ( isEmptyRule(rule) && - (ruleParents === undefined || ruleParents?.length === 0) + (ruleParents === undefined || ruleParents?.size === 0) ) { // Empty rule with no parent - deleteRule(ctx, ruleName) + tryToDeleteRule(ctx, ruleName) return } - const { nodeValue, missingVariables, traversedVariables, unit } = - ctx.engine.evaluateNode(rule) - - const traversedVariablesWithoutSelf = traversedVariables.filter( - (dottedName) => dottedName !== ruleName, + const evaluation: ASTNode & EvaluatedNode = ctx.engine.evaluate( + rule.dottedName, ) - - // NOTE(@EmileRolley): we need to evaluate due to possible standalone rule [formule] - // parsed as a [valeur]. - if ('valeur' in rule.rawNode && traversedVariablesWithoutSelf?.length > 0) { - rule.rawNode.formule = rule.rawNode.valeur - delete rule.rawNode.valeur - } - + const { missingVariables, nodeValue, unit } = evaluation const missingVariablesNames = Object.keys(missingVariables) - // Constant leaf -> search and replace the constant in all its parents. if ( - 'valeur' in rule.rawNode || - ('formule' in rule.rawNode && missingVariablesNames.length === 0) + missingVariablesNames.length === 0 && + // We don't want to fold a rule which can be nullable with a different situation. + // For example, if its namespace is conditionnaly applicable. + !isNullable(evaluation) ) { - if ('formule' in rule.rawNode) { - ctx.parsedRules[ruleName].rawNode.valeur = formatToPulicodesValue( - nodeValue, - unit, - ) - } + const constantNode = replaceRuleWithEvaluatedNodeValue( + rule, + nodeValue, + unit, + ) + searchAndReplaceConstantValueInParentRefs(ctx, ruleName, constantNode) - searchAndReplaceConstantValueInParentRefs(ctx, ruleName, rule) - if (ctx.parsedRules[ruleName] === undefined) { - return - } + const childs = ctx.refs.childs.get(ruleName) ?? new Set() - if ('formule' in rule.rawNode) { - // The rule do not depends on any other rule anymore, so we need to remove - // it from the [refs]. - const childs = ctx.refs.childs.get(ruleName) ?? [] - - updateRefCounting( - ctx, - ruleName, - // NOTE(@EmileRolley): for some reason, the [traversedVariables] are not always - // depencies of the rule. Consequently, we need to keep only the ones that are - // in the [childs] list in order to avoid removing rules that are not dependencies. - traversedVariablesWithoutSelf?.filter((v: RuleName) => - childs.includes(v), - ) ?? [], - ) - delete ctx.parsedRules[ruleName].rawNode.formule - } + updateRefCounting(ctx, ruleName, childs) + delete ctx.parsedRules[ruleName].rawNode.formule - if (ctx.refs.parents.get(ruleName)?.length === 0) { - // NOTE(@EmileRolley): temporary work around until all mechanisms are supported. - // Indeed, when replacing a leaf ref by its value in all its parents, - // it should always be removed. - deleteRule(ctx, ruleName) - } else { - ctx.parsedRules[ruleName].rawNode[ctx.params.isFoldedAttr] = true + const parents = ctx.refs.parents.get(ruleName) + // NOTE(@EmileRolley): if the rule has no parent ([parents === undefined]) + // we assume it's a root rule and we don't want to delete it. + if (parents !== undefined && parents.size === 0) { + if (tryToDeleteRule(ctx, ruleName)) { + return + } } + ctx.parsedRules[ruleName].rawNode[ctx.params.isFoldedAttr] = 'fully' + return - } else if ('formule' in rule.rawNode) { - // Try to replace internal refs if possible. - const childs = ctx.refs.childs.get(ruleName) - if (childs?.length > 0) { - replaceAllPossibleChildRefs(ctx, childs) + } +} + +/** + * Deep copies the private [parsedRules] field of [engine] (without the '$SITUATION' + * rules). + */ +function copyFullParsedRules(engine: Engine): ParsedRules { + const parsedRules: ParsedRules = {} + + for (const ruleName in engine.baseContext.parsedRules) { + if (!ruleName.endsWith('$SITUATION')) { + parsedRules[ruleName] = structuredClone( + engine.baseContext.parsedRules[ruleName], + ) } } + + return parsedRules } /** @@ -432,32 +431,36 @@ export function constantFolding( toKeep?: PredicateOnRule, params?: FoldingParams, ): ParsedRules { - const parsedRules: ParsedRules = - // PERF: could it be avoided? - JSON.parse(JSON.stringify(engine.getParsedRules())) - - let ctx: FoldingCtx = initFoldingCtx(engine, parsedRules, toKeep, params) - - Object.entries(ctx.parsedRules).forEach(([ruleName, ruleNode]) => { - if ( - isFoldable(ruleNode, ctx.recalculRules) && - !isAlreadyFolded(ctx.params, ruleNode) - ) { - tryToFoldRule(ctx, ruleName, ruleNode) + let ctx = initFoldingCtx(engine, toKeep, params) + + let nbRules = Object.keys(ctx.parsedRules).length + let nbRulesBefore = undefined + + while (nbRules !== nbRulesBefore) { + for (const ruleName in ctx.parsedRules) { + const ruleNode = ctx.parsedRules[ruleName] + + if (isFoldable(ctx, ruleNode) && !isAlreadyFolded(ctx.params, ruleNode)) { + fold(ctx, ruleName, ruleNode) + } } - }) + nbRulesBefore = nbRules + nbRules = Object.keys(ctx.parsedRules).length + } if (toKeep) { - ctx.parsedRules = Object.fromEntries( - Object.entries(ctx.parsedRules).filter(([ruleName, ruleNode]) => { - const parents = ctx.refs.parents.get(ruleName) - return ( - !isFoldable(ruleNode, ctx.recalculRules) || - toKeep([ruleName, ruleNode]) || - parents?.length > 0 - ) - }), - ) + for (const ruleName in ctx.parsedRules) { + const ruleNode = ctx.parsedRules[ruleName] + const parents = ctx.refs.parents.get(ruleName) + + if ( + isFoldable(ctx, ruleNode) && + !toKeep([ruleName, ruleNode]) && + (!parents || parents?.size === 0) + ) { + delete ctx.parsedRules[ruleName] + } + } } return ctx.parsedRules diff --git a/source/serializeParsedRules.ts b/source/serializeParsedRules.ts new file mode 100644 index 0000000..ccbdb88 --- /dev/null +++ b/source/serializeParsedRules.ts @@ -0,0 +1,458 @@ +import { ASTNode, ParsedRules, reduceAST, serializeUnit } from 'publicodes' +import { RawRule, RuleName } from './commons' + +type SerializedRule = RawRule | number | string | null + +function serializedRuleToRawRule(serializedRule: SerializedRule): RawRule { + if (typeof serializedRule === 'object') { + return serializedRule + } + return { + valeur: serializedRule, + } +} + +function serializeValue(node: ASTNode, needParens = false): SerializedRule { + switch (node.nodeKind) { + case 'reference': { + return node.name + } + + case 'constant': { + switch (node.type) { + case 'boolean': + return node.nodeValue ? 'oui' : 'non' + case 'string': + return `'${node.nodeValue}'` + case 'number': + return Number(node.nodeValue) + default: { + if (node.nodeValue === null) { + return null + } + // TODO: case 'date': + return node.nodeValue?.toLocaleString('fr-FR') + } + } + } + + case 'operation': { + switch (node?.sourceMap?.mecanismName) { + /* + * All these mecanisms are inlined with simplier ones. Therefore, + * we need to serialize the sourceMap in order to retrieve the + * original mecanism. + * + * Example: + * [une de ces conditions] is inlined with a composition of disjunctions ('ou') + * [toutes ces conditions] is inlined with a composition of conjunctions ('et') + * [somme] is inlined with a sum of values ('+') + * etc... + */ + case 'somme': + case 'moyenne': + case 'une de ces conditions': + case 'toutes ces conditions': + /* + * The engine parse the mecanism + * 'est défini: ' + * as + * 'est non défini: = non' + */ + case 'est défini': + case 'est applicable': { + return serializeSourceMap(node) + } + + default: { + return ( + (needParens ? '(' : '') + + `${serializeValue(node.explanation[0], true)} ${ + node.operationKind + } ${serializeValue(node.explanation[1], true)}` + + (needParens ? ')' : '') + ) + } + } + } + + case 'unité': { + const serializedUnit = serializeUnit(node.unit) + const serializedExplanation = serializeASTNode(node.explanation) + + if (serializedExplanation === null) { + return null + } + + // Inlined unit (e.g. '10 €/mois') + if (node?.explanation?.nodeKind === 'constant') { + return ( + serializedExplanation + (serializedUnit ? ' ' + serializedUnit : '') + ) + } + + // Explicit [unité] mecanism + return { + unité: serializedUnit, + ...serializedRuleToRawRule(serializedExplanation), + } + } + + case 'variations': { + return serializeASTNode(node) + } + + default: { + throw new Error(`[SERIALIZE_VALUE]: '${node.nodeKind}' not implemented`) + } + } +} + +// TODO: this function might be refactored +function serializeSourceMap(node: ASTNode): SerializedRule { + const sourceMap = node.sourceMap + + const rawRule = {} + for (const key in sourceMap.args) { + const value = sourceMap.args[key] + const isArray = Array.isArray(value) + + rawRule[sourceMap.mecanismName] = isArray + ? value.map((v) => serializeASTNode(v)).filter((v) => v !== null) + : serializeASTNode(value) + } + return rawRule +} + +function serializeASTNode(node: ASTNode): SerializedRule { + return reduceAST( + (_, node: ASTNode) => { + switch (node?.nodeKind) { + case 'rule': { + const serializedValeur = serializeASTNode(node.explanation.valeur) + + if (node.replacements.length > 0) { + let serializedRemplace = { + 'références à': serializeValue( + node.replacements[0].replacedReference, + ), + } + + if (node.replacements[0].whiteListedNames.length > 0) { + serializedRemplace['dans'] = + node.replacements[0].whiteListedNames.map(({ name }) => name) + } + if (node.replacements[0].blackListedNames.length > 0) { + serializedRemplace['sauf dans'] = + node.replacements[0].blackListedNames.map(({ name }) => name) + } + if (node.replacements[0].priority) { + serializedRemplace['priorité'] = node.replacements[0].priority + } + return { + remplace: serializedRemplace, + ...serializedRuleToRawRule(serializedValeur), + } + } + + return serializedValeur + } + + case 'reference': + case 'constant': + case 'unité': + case 'operation': { + return serializeValue(node) + } + + case 'est non défini': + case 'est non applicable': { + return { + [node.nodeKind]: serializeASTNode(node.explanation), + } + } + + // [produit] is parsed as a one big multiplication, so we need to + // gets the sourceMap to get the real mecanismName + case 'simplifier unité': { + return serializeSourceMap(node) + } + + case 'variations': { + // If the node is a replacement rule, we need to serialize the original ref + if (node?.sourceMap?.mecanismName === 'replacement') { + // @ts-ignore + return node.sourceMap.args.originalNode.dottedName + } + return { + variations: node.explanation.map(({ condition, consequence }) => { + if ( + 'type' in condition && + condition.type === 'boolean' && + condition.nodeValue + ) { + return { sinon: serializeASTNode(consequence) } + } + return { + si: serializeASTNode(condition), + alors: serializeASTNode(consequence), + } + }), + } + } + + case 'arrondi': { + const serializedValeur = serializedRuleToRawRule( + serializeASTNode(node.explanation.valeur), + ) + return { + ...serializedValeur, + arrondi: serializeASTNode(node.explanation.arrondi), + } + } + + case 'durée': { + return { + durée: { + depuis: serializeASTNode(node.explanation.depuis), + "jusqu'à": serializeASTNode(node.explanation["jusqu'à"]), + }, + } + } + + case 'barème': + case 'grille': + case 'taux progressif': { + const serializedNode = { + assiette: serializeASTNode(node.explanation.assiette), + tranches: node.explanation.tranches.map((tranche) => { + const res = {} + + for (const key in tranche) { + const val = tranche[key] + if (key !== 'plafond' || val.nodeValue !== Infinity) { + res[key] = serializeASTNode(tranche[key]) + } + } + + return res + }), + } + + const serializedMultiplicateur = serializeASTNode( + node.explanation.multiplicateur, + ) + + if (serializedMultiplicateur !== 1) { + serializedNode['multiplicateur'] = serializedMultiplicateur + } + + return { [node.nodeKind]: serializedNode } + } + + case 'contexte': { + const contexte = node.explanation.contexte.reduce( + (currCtx, [ref, node]) => { + currCtx[ref.name] = serializeASTNode(node) + return currCtx + }, + {}, + ) + const serializedExplanationNode = serializedRuleToRawRule( + serializeASTNode(node.explanation.node), + ) + return { + ...serializedExplanationNode, + contexte, + } + } + + case 'condition': { + const sourceMap = node?.sourceMap + const mecanismName = sourceMap?.mecanismName + switch (mecanismName) { + case 'dans la situation': { + /* + * The engine parse all rules into a root condition: + * + * - si: + * est non défini: . $SITUATION + * - alors: + * - sinon: . $SITUATION + */ + if ( + sourceMap.args['dans la situation']['title'] === '$SITUATION' + ) { + return serializeASTNode(node.explanation.alors) + } + } + + case 'applicable si': + case 'non applicable si': { + const serializedExplanationNode = serializedRuleToRawRule( + serializeASTNode(node.explanation.alors), + ) + return { + ...serializedExplanationNode, + [mecanismName]: serializeASTNode( + sourceMap.args[mecanismName] as ASTNode, + ), + } + } + + // Needs to the serialize the source map in order to retrieve the + // original mecanism. + case 'le maximum de': + case 'le minimum de': { + return serializeSourceMap(node) + } + + case 'abattement': + case 'plancher': + case 'plafond': + case 'par défaut': { + const serializedExplanationNode = serializedRuleToRawRule( + serializeASTNode(node.sourceMap.args.valeur as ASTNode), + ) + + return { + ...serializedExplanationNode, + [mecanismName]: serializeASTNode( + node.sourceMap.args[mecanismName] as ASTNode, + ), + } + } + + default: { + throw new Error( + `[SERIALIZE_AST_NODE]: mecanism '${mecanismName}' found in a '${ + node.nodeKind + }Node:\n${JSON.stringify(node, null, 2)}`, + ) + } + } + } + + case 'variable manquante': { + // Simple need to unwrap the explanation node + return serializeASTNode(node.explanation) + } + + case 'texte': { + const serializedText = node.explanation.reduce( + (currText: string, node: ASTNode | string) => { + if (typeof node === 'string') { + return currText + node + } + + const serializedNode = serializeASTNode(node) + if (typeof serializedNode !== 'string') { + throw new Error(`[SERIALIZE_AST_NODE > 'texte']: all childs of 'texte' expect to be serializable as string. + Got '${serializedNode}'`) + } + return currText + '{{ ' + serializedNode + ' }}' + }, + '', + ) + return { texte: serializedText } + } + + case 'une possibilité': { + return { + 'une possibilité': { + 'choix obligatoire': node['choix obligatoire'], + possibilités: node.explanation.map(serializeASTNode), + }, + } + } + case 'inversion': { + return { + 'inversion numérique': + node.explanation.inversionCandidates.map(serializeASTNode), + } + } + case 'résoudre référence circulaire': { + return { + 'résoudre la référence circulaire': 'oui', + ...serializedRuleToRawRule( + serializeASTNode(node.explanation.valeur), + ), + } + } + + case 'replacementRule': { + throw new Error( + `[SERIALIZE_AST_NODE]: 'replacementRule' should have been handled before`, + ) + } + } + }, + {} as RawRule, + node, + ) +} + +export function serializeParsedRules( + parsedRules: ParsedRules, +): Record { + /** + * This mecanisms are syntaxic sugars that are inlined with simplier ones. + * Consequently, we need to remove them from the rawNode in order to avoid + * duplicate mecanisms. + * + * + * NOTE: for now, the [avec] mecanism is unfolded as full rules. Therefore, + * we need to remove the [avec] mecanism from the rawNode in order to + * avoid duplicate rule definitions. + * + * TODO: a way to keep the [avec] mecanism in the rawNode could be investigated but + * for now it's not a priority. + */ + const syntaxicSugars = [ + 'avec', + 'formule', + 'valeur', + 'contexte', + 'somme', + 'moyenne', + 'produit', + 'une de ces conditions', + 'toutes ces conditions', + 'est défini', + 'est applicable', + ] + const rawRules = {} + + for (const [rule, node] of Object.entries(parsedRules)) { + if (rule.endsWith('$SITUATION') || rule.includes('$INTERNAL')) { + delete rawRules[rule] + continue + } + + if (Object.keys(node.rawNode).length === 0) { + // Empty rule should be null not {} + rawRules[rule] = null + continue + } + + const serializedNode = serializedRuleToRawRule(serializeASTNode(node)) + + rawRules[rule] = { ...node.rawNode } + syntaxicSugars.forEach((attr) => { + if (attr in rawRules[rule]) { + delete rawRules[rule][attr] + } + }) + + rawRules[rule] = { + ...rawRules[rule], + ...serializedNode, + } + + if (node.private) { + rawRules[rule]['privé'] = 'oui' + } + } + + return rawRules +} diff --git a/test/getRawRules.test.ts b/test/getRawRules.test.ts deleted file mode 100644 index 2f6996f..0000000 --- a/test/getRawRules.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { RawRules } from '../source/commons' -import { getRawNodes } from '../source/commons' - -import { callWithParsedRules } from './utils.test' - -function getRawNodesWith(rawRules: any): RawRules { - return callWithParsedRules(getRawNodes, rawRules) -} - -describe('getRawRules', () => { - it('∅ -> ∅', () => { - expect(getRawNodesWith({})).toStrictEqual({}) - }) - it('Single null rule', () => { - expect(getRawNodesWith({ test1: null })).toStrictEqual({ - test1: null, - }) - }) - it('Simple single rule', () => { - const rawRules = { - test2: { - titre: 'Test 2', - formule: '10 * 3', - }, - } - expect(getRawNodesWith(rawRules)).toStrictEqual(rawRules) - }) - it('Number constant', () => { - expect(getRawNodesWith({ test3: 10 })).toStrictEqual({ - test3: { valeur: '10' }, - }) // will be reparsed by the website client, so not a problem? - }) - it('Referenced rules', () => { - const rawRules = { - ruleA: { - titre: 'Rule A', - formule: 'B . C * 3', - }, - 'ruleA . B . C': { - valeur: '10', - }, - } - expect(getRawNodesWith(rawRules)).toStrictEqual(rawRules) - }) - it('Mechansim [avec] should not create empty objects', () => { - const rawRules = { - ruleA: { - avec: { - bus: null, - }, - titre: 'Rule A', - formule: 'B . C * 3', - }, - 'ruleA . B . C': { - valeur: '10', - }, - } - expect(getRawNodesWith(rawRules)).toStrictEqual({ - ruleA: { - titre: 'Rule A', - formule: 'B . C * 3', - }, - 'ruleA . B . C': { - valeur: '10', - }, - 'ruleA . bus': null, - }) - }) -}) diff --git a/test/optims/constantFolding.test.ts b/test/optims/constantFolding.test.ts index d2a2acb..d6fc4a4 100644 --- a/test/optims/constantFolding.test.ts +++ b/test/optims/constantFolding.test.ts @@ -1,10 +1,6 @@ import Engine from 'publicodes' -import { - getRawNodes, - RuleName, - RawRules, - disabledLogger, -} from '../../source/commons' +import { serializeParsedRules } from '../../source' +import { RuleName, RawRules, disabledLogger } from '../../source/commons' import { constantFolding } from '../../source/optims/' import { callWithEngine } from '../utils.test' @@ -17,15 +13,17 @@ function constantFoldingWith(rawRules: any, targets?: RuleName[]): RawRules { ), rawRules, ) - return getRawNodes(res) + return serializeParsedRules(res) } +// NOTE(@EmileRolley): I modified `toStrictEqual` to `toEqual` when using `structuredClone` +// instead of `JSON.parse(JSON.stringify())`. describe('Constant folding [meta]', () => { it('should not modify the original rules', () => { const rawRules = { ruleA: { titre: 'Rule A', - formule: 'B . C * D', + valeur: 'B . C * D', }, 'ruleA . B . C': { valeur: '10', @@ -34,59 +32,71 @@ describe('Constant folding [meta]', () => { valeur: '3', }, } - const engine = new Engine(rawRules, { logger: disabledLogger }) - const untouchedParsedRules = getRawNodes(engine.getParsedRules()) + const engine = new Engine(rawRules, { + logger: disabledLogger, + allowOrphanRules: true, + }) + const baseParsedRules = engine.getParsedRules() + const serializedBaseParsedRules = serializeParsedRules(baseParsedRules) constantFolding(engine, ([ruleName, _]) => ruleName === 'ruleA') - expect(getRawNodes(engine.getParsedRules())).toStrictEqual( - untouchedParsedRules, + const shouldNotBeModifiedRules = engine.getParsedRules() + const serializedShouldNotBeModifiedRules = serializeParsedRules( + shouldNotBeModifiedRules, + ) + + expect(baseParsedRules).toEqual(shouldNotBeModifiedRules) + expect(serializedBaseParsedRules).toEqual( + serializedShouldNotBeModifiedRules, ) }) }) describe('Constant folding [base]', () => { it('∅ -> ∅', () => { - expect(constantFoldingWith({})).toStrictEqual({}) + expect(constantFoldingWith({})).toEqual({}) }) + it('should remove empty nodes', () => { expect( constantFoldingWith({ - ruleA: null, ruleB: { - formule: '10 * 10', + valeur: '10 * 10', }, }), - ).toStrictEqual({ + ).toEqual({ ruleB: { - valeur: '100', - optimized: true, + valeur: 100, + optimized: 'fully', }, }) }) - it('should replace a [formule] with 1 dependency with the corresponding constant value', () => { + + it('one deps', () => { const rawRules = { ruleA: { titre: 'Rule A', - formule: 'B . C * 3', + valeur: 'B . C * 3', }, 'ruleA . B . C': { valeur: '10', }, } - expect(constantFoldingWith(rawRules, ['ruleA'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['ruleA'])).toEqual({ ruleA: { titre: 'Rule A', - valeur: '30', - optimized: true, + valeur: 30, + optimized: 'fully', }, }) }) - it('should replace a [formule] with 2 dependencies with the corresponding constant value', () => { + + it('should replace a [valeur] with 2 dependencies with the corresponding constant value', () => { const rawRules = { ruleA: { titre: 'Rule A', - formule: 'B . C * D', + valeur: 'B . C * D', }, 'ruleA . B . C': { valeur: '10', @@ -95,19 +105,20 @@ describe('Constant folding [base]', () => { valeur: '3', }, } - expect(constantFoldingWith(rawRules, ['ruleA'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['ruleA'])).toEqual({ ruleA: { titre: 'Rule A', - valeur: '30', - optimized: true, + valeur: 30, + optimized: 'fully', }, }) }) + it('should replace the constant reference without being able to fold entirely the rule', () => { const rawRules = { ruleA: { titre: 'Rule A', - formule: 'B . C * D', + valeur: 'B . C * D', }, 'ruleA . D': { question: "What's the value of D", @@ -116,25 +127,26 @@ describe('Constant folding [base]', () => { valeur: '10', }, } - expect(constantFoldingWith(rawRules, ['ruleA'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['ruleA'])).toEqual({ ruleA: { titre: 'Rule A', - formule: '10 * D', - optimized: true, + valeur: '10 * D', + optimized: 'partially', }, 'ruleA . D': { question: "What's the value of D", }, }) }) + it('should partially fold rule with constant with multiple parents dependencies', () => { const rawRules = { ruleA: { titre: 'Rule A', - formule: 'B . C * D', + valeur: 'B . C * D', }, ruleB: { - formule: 'ruleA . B . C * 3', + valeur: 'ruleA . B . C * 3', }, 'ruleA . D': { question: "What's the value of D?", @@ -143,25 +155,26 @@ describe('Constant folding [base]', () => { valeur: '10', }, } - expect(constantFoldingWith(rawRules, ['ruleA'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['ruleA'])).toEqual({ ruleA: { titre: 'Rule A', - formule: '10 * D', - optimized: true, + valeur: '10 * D', + optimized: 'partially', }, 'ruleA . D': { question: "What's the value of D?", }, }) }) + it('should partially fold rule with constant with multiple parents dependencies add keep the only targeted rule: [ruleA]', () => { const rawRules = { ruleA: { titre: 'Rule A', - formule: 'B . C * D', + valeur: 'B . C * D', }, ruleB: { - formule: 'ruleA . B . C * 3', + valeur: 'ruleA . B . C * 3', }, 'ruleA . D': { question: "What's the value of D?", @@ -170,93 +183,97 @@ describe('Constant folding [base]', () => { valeur: '10', }, } - expect(constantFoldingWith(rawRules, ['ruleA'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['ruleA'])).toEqual({ ruleA: { titre: 'Rule A', - formule: '10 * D', - optimized: true, + valeur: '10 * D', + optimized: 'partially', }, 'ruleA . D': { question: "What's the value of D?", }, }) }) + it('should fold a constant within _two degrees_', () => { const rawRules = { A: { - formule: 'B', + valeur: 'B', }, 'A . B': { - formule: 'C * 10', + valeur: 'C * 10', }, 'A . B . C': { valeur: 7, }, } - expect(constantFoldingWith(rawRules, ['A'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['A'])).toEqual({ A: { - valeur: '70', - optimized: true, + valeur: 70, + optimized: 'fully', }, }) }) + it('should fold constant within two degrees with B, a partially foldable rule', () => { const rawRules = { A: { - formule: 'B', + valeur: 'B', }, B: { - formule: 'A . B * D', + valeur: 'A . B * D', }, 'B . D': { question: "What's the value of B . D?", }, 'A . B': { - formule: 'C * 10', + valeur: 'C * 10', }, 'A . B . C': { valeur: 7, }, } - expect(constantFoldingWith(rawRules, ['B'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['B'])).toEqual({ B: { - formule: '70 * D', - optimized: true, + valeur: '70 * D', + optimized: 'partially', }, 'B . D': { question: "What's the value of B . D?", }, }) }) + it('should completely fold a [somme] mechanism', () => { const rawRules = { ruleA: { - formule: 'ruleB', + valeur: 'ruleB', }, ruleB: { somme: ['A . B * 2', 10, 12 * 2], }, 'A . B': { - formule: 'C * 10', + valeur: 'C * 10', }, 'A . B . C': { valeur: 7, }, } - expect(constantFoldingWith(rawRules, ['ruleA'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['ruleA'])).toEqual({ ruleA: { - valeur: '174', - optimized: true, + valeur: 174, + optimized: 'fully', }, }) }) - it('should partially fold [formule > somme] mechanism', () => { + + it('should partially fold [valeur > somme] mechanism', () => { const rawRules = { ruleA: { - formule: 'ruleB', + valeur: 'ruleB', }, ruleB: { - formule: { + valeur: { somme: ['A . B * D', 10, 12 * 2], }, }, @@ -264,96 +281,96 @@ describe('Constant folding [base]', () => { question: "What's the value of ruleB . D?", }, 'A . B': { - formule: 'C * 10', + valeur: 'C * 10', }, 'A . B . C': { valeur: 7, }, } - expect(constantFoldingWith(rawRules, ['ruleA'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['ruleA'])).toEqual({ ruleA: { - formule: 'ruleB', + valeur: 'ruleB', }, ruleB: { - formule: { - somme: ['70 * D', 10, 24], - }, - optimized: true, + somme: ['70 * D', 10, 24], + optimized: 'partially', }, 'ruleB . D': { question: "What's the value of ruleB . D?", }, }) }) + it('should fold a mutiple [somme] deep dependencies', () => { const rawRules = { omr: { - formule: { + valeur: { somme: ['omr . putrescibles', 'omr . papier carton'], }, }, 'omr . putrescibles': { - formule: { + valeur: { somme: ['stockage', 'incinération'], }, }, 'omr . putrescibles . stockage': { - formule: 'stockage . pourcentage * stockage . impact', + valeur: 'stockage . pourcentage * stockage . impact', unité: 'kgCO2e', }, 'omr . putrescibles . stockage . pourcentage': { - formule: '24%', + valeur: '24%', }, 'omr . putrescibles . stockage . impact': { - formule: 0.692, + valeur: 0.692, unité: 'kgCO2e/kg', }, 'omr . putrescibles . incinération': { - formule: 'incinération . pourcentage * incinération . impact', + valeur: 'incinération . pourcentage * incinération . impact', unité: 'kgCO2e', }, 'omr . putrescibles . incinération . pourcentage': { - formule: '68%', + valeur: '68%', }, 'omr . putrescibles . incinération . impact': { - formule: 0.045, + valeur: 0.045, unité: 'kgCO2e/kg', }, 'omr . papier carton': { - formule: { + valeur: { somme: ['stockage', 'incinération'], }, }, 'omr . papier carton . stockage': { - formule: 'stockage . pourcentage * stockage . impact', + valeur: 'stockage . pourcentage * stockage . impact', }, 'omr . papier carton . stockage . pourcentage': { - formule: '26%', + valeur: '26%', }, 'omr . papier carton . stockage . impact': { - formule: 0.95, + valeur: 0.95, }, 'omr . papier carton . incinération': { - formule: 'incinération . pourcentage * incinération . impact', + valeur: 'incinération . pourcentage * incinération . impact', }, 'omr . papier carton . incinération . pourcentage': { - formule: '26%', + valeur: '26%', }, 'omr . papier carton . incinération . impact': { - formule: 0.95, + valeur: 0.95, }, } - expect(constantFoldingWith(rawRules, ['omr'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['omr'])).toEqual({ omr: { - valeur: '0.69068', - optimized: true, + valeur: '0.69068 kgCO2e', + optimized: 'fully', }, }) }) + it('should replace properly child rule references when one is a substring of the other: (Ambiguity with rule name)', () => { const rawRules = { biogaz: { - formule: + valeur: "biogaz . facteur d'émission * gaz . facteur d'émission + not foldable", }, "biogaz . facteur d'émission": { @@ -366,20 +383,21 @@ describe('Constant folding [base]', () => { question: 'The user needs to provide a value.', }, } - expect(constantFoldingWith(rawRules, ['biogaz'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['biogaz'])).toEqual({ biogaz: { - formule: '(20 * 10) + not foldable', - optimized: true, + valeur: '(20 * 10) + not foldable', + optimized: 'partially', }, 'not foldable': { question: 'The user needs to provide a value.', }, }) }) + it('replaceAllRefs bug #1', () => { const rawRules = { biogaz: { - formule: + valeur: "gaz . facteur d'émission * biogaz . facteur d'émission + not foldable", }, "biogaz . facteur d'émission": { @@ -392,20 +410,21 @@ describe('Constant folding [base]', () => { question: 'The user needs to provide a value.', }, } - expect(constantFoldingWith(rawRules, ['biogaz'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['biogaz'])).toEqual({ biogaz: { - formule: '(10 * 20) + not foldable', - optimized: true, + valeur: '(10 * 20) + not foldable', + optimized: 'partially', }, 'not foldable': { question: 'The user needs to provide a value.', }, }) }) + it('replaceAllRefs bug #2', () => { const rawRules = { boisson: { - formule: 'tasse de café * nombre', + valeur: 'tasse de café * nombre', }, 'boisson . tasse de café': { valeur: 20, @@ -414,17 +433,18 @@ describe('Constant folding [base]', () => { 'par défaut': 10, }, } - expect(constantFoldingWith(rawRules, ['boisson'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['boisson'])).toEqual({ boisson: { - formule: '20 * nombre', - optimized: true, + valeur: '20 * nombre', + optimized: 'partially', }, 'boisson . nombre': { 'par défaut': 10, }, }) }) - it('should fold standalone [formule] rule', () => { + + it('should fold standalone [valeur] rule', () => { const rawRules = { boisson: 'tasse de café * nombre', 'boisson . tasse de café': { @@ -434,40 +454,42 @@ describe('Constant folding [base]', () => { 'par défaut': 10, }, } - expect(constantFoldingWith(rawRules, ['boisson'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['boisson'])).toEqual({ boisson: { - formule: '20 * nombre', - optimized: true, + valeur: '20 * nombre', + optimized: 'partially', }, 'boisson . nombre': { 'par défaut': 10, }, }) }) + it('should keeps % when folding', () => { const rawRules = { boisson: 'pct * nombre', 'boisson . pct': { - formule: '2%', + valeur: '2%', }, 'boisson . nombre': { 'par défaut': 10, }, } - expect(constantFoldingWith(rawRules, ['boisson'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['boisson'])).toEqual({ boisson: { - formule: '2% * nombre', - optimized: true, + valeur: '2 % * nombre', + optimized: 'partially', }, 'boisson . nombre': { 'par défaut': 10, }, }) }) + it('par défaut = 0', () => { const rawRules = { 'chocolat chaud': { - formule: 'tasse de chocolat chaud * nombre', + valeur: 'tasse de chocolat chaud * nombre', }, 'tasse de chocolat chaud': { valeur: 20.3, @@ -477,10 +499,10 @@ describe('Constant folding [base]', () => { 'par défaut': 0, }, } - expect(constantFoldingWith(rawRules, ['chocolat chaud'])).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['chocolat chaud'])).toEqual({ 'chocolat chaud': { - formule: '20.3 * nombre', - optimized: true, + valeur: '20.3 * nombre', + optimized: 'partially', }, 'chocolat chaud . nombre': { question: 'Nombre de chocolats chauds par semaine', @@ -488,32 +510,32 @@ describe('Constant folding [base]', () => { }, }) }) + it('should replace constant ref, even if it starts with diacritic', () => { const rawRules = { piscine: { icônes: '🏠🏊', }, 'piscine . empreinte': { - formule: { somme: ['équipés * nombre * équipés * équipés'] }, + valeur: { somme: ['équipés * nombre * équipés * équipés'] }, }, 'piscine . nombre': { question: 'Combien ?', 'par défaut': 2 }, - 'piscine . équipés': { formule: 45 }, + 'piscine . équipés': { valeur: 45 }, } - expect( - constantFoldingWith(rawRules, ['piscine . empreinte']), - ).toStrictEqual({ + expect(constantFoldingWith(rawRules, ['piscine . empreinte'])).toEqual({ 'piscine . empreinte': { - formule: { somme: ['((45 * nombre) * 45) * 45'] }, - optimized: true, + somme: ['((45 * nombre) * 45) * 45'], + optimized: 'partially', }, 'piscine . nombre': { question: 'Combien ?', 'par défaut': 2 }, }) }) - it('should work with parentheses inside [formule]', () => { + + it('should work with parentheses inside [valeur]', () => { const rawRules = { 'divers . ameublement . meubles . armoire . empreinte amortie': { titre: 'Empreinte armoire amortie', - formule: 'armoire . empreinte / (durée * coefficient préservation)', + valeur: 'armoire . empreinte / (durée * coefficient préservation)', unité: 'kgCO2e', }, 'divers . ameublement . meubles . armoire . coefficient préservation': 45, @@ -526,12 +548,12 @@ describe('Constant folding [base]', () => { constantFoldingWith(rawRules, [ 'divers . ameublement . meubles . armoire . empreinte amortie', ]), - ).toStrictEqual({ + ).toEqual({ 'divers . ameublement . meubles . armoire . empreinte amortie': { titre: 'Empreinte armoire amortie', - formule: 'armoire . empreinte / (10 * 45)', + valeur: 'armoire . empreinte / (10 * 45)', unité: 'kgCO2e', - optimized: true, + optimized: 'partially', }, 'divers . ameublement . meubles . armoire . empreinte': { question: 'Empreinte?', @@ -539,89 +561,255 @@ describe('Constant folding [base]', () => { }) }) - it('should not fold rules used in a [recalcul]', () => { + it('should not fold rules impacted by a [contexte] with a question in dependency', () => { const rawRules = { root: { - recalcul: { - règle: 'rule to recompute', - avec: { - constant: 20, - }, + valeur: 'rule to recompute', + contexte: { + constant: 20, }, }, 'rule to recompute': { - formule: 'constant * 2', + valeur: '(constant * 2) * question', + }, + question: { + question: 'Question ?', }, constant: { valeur: 10, }, } - expect(constantFoldingWith(rawRules)).toStrictEqual({ + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + }) + + it('should fold constant rules used in a [contexte]', () => { + const rawRules = { root: { - recalcul: { - règle: 'rule to recompute', - avec: { - constant: 20, - }, + valeur: 'rule to recompute', + contexte: { + 'rule to replace': 'constant', }, }, 'rule to recompute': { - formule: 'constant * 2', + valeur: '(rule to replace * 2) * question', + }, + 'rule to replace': { + valeur: 0, + }, + question: { + question: 'Question ?', }, constant: { valeur: 10, - optimized: true, + }, + } + expect(constantFoldingWith(rawRules)).toEqual({ + root: { + valeur: 'rule to recompute', + contexte: { + 'rule to replace': 10, + }, + optimized: 'partially', + }, + 'rule to recompute': { + valeur: '(rule to replace * 2) * question', + }, + 'rule to replace': { + valeur: 0, + }, + question: { + question: 'Question ?', }, }) }) - it('should not fold rules used in a [recalcul] but still fold used constant in other rules', () => { + it('should fold rules impacted by a [] with multiple contexte rules', () => { const rawRules = { root: { - recalcul: { - règle: 'rule to recompute', - avec: { - constant: 20, - }, + valeur: 'rule to recompute', + contexte: { + constant: 50, + 'constant 2': 100, }, }, 'rule to recompute': { - formule: 'constant * 2', + valeur: 'constant * 2 + constant 2', }, - 'rule to fold': { - formule: 'constant * 4', + constant: { + valeur: 10, + }, + 'constant 2': { + valeur: 15, + }, + } + expect( + constantFoldingWith(rawRules, ['root', 'rule to recompute']), + ).toEqual({ + root: { + valeur: 200, + optimized: 'fully', + }, + 'rule to recompute': { + valeur: 35, + optimized: 'fully', + }, + }) + }) + + it('should not fold nested rules (2 deep) impacted by a [contexte]', () => { + const rawRules = { + root: { + valeur: 'rule to recompute', + contexte: { + constant: 20, + }, + }, + 'rule to recompute': { + valeur: 'nested 1 * 2', + }, + 'rule to recompute . nested 1': { + valeur: 'nested 2 * 4', + }, + 'rule to recompute . nested 2': { + valeur: 'nested 3 * 4', + }, + 'rule to recompute . nested 3': { + valeur: '(constant * 4) * question', + }, + question: { + question: 'Question ?', }, constant: { valeur: 10, }, } - expect(constantFoldingWith(rawRules)).toStrictEqual({ + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + }) + + it('should not fold rules impacted by a [contexte] with nested mechanisms in the formula', () => { + const rawRules = { root: { - recalcul: { - règle: 'rule to recompute', - avec: { - constant: 20, - }, + somme: ['rule to recompute', 'question', 10], + contexte: { + constant: 20, + }, + }, + 'rule to recompute': { + valeur: 'constant * 2', + }, + question: { + question: 'Question ?', + }, + constant: { + valeur: 10, + }, + } + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + }) + + it('should fold rules impacted by a [contexte] with nested mechanisms in the formula', () => { + const rawRules = { + root: { + somme: ['rule to recompute', 'question', 10], + contexte: { + constant: 20, + }, + }, + 'rule to recompute': { + valeur: 'constant * 2 * foldable', + }, + question: { + question: 'Question ?', + }, + constant: { + valeur: 10, + }, + foldable: { + valeur: 15, + }, + } + expect(constantFoldingWith(rawRules)).toEqual({ + root: { + somme: ['rule to recompute', 'question', 10], + contexte: { + constant: 20, }, }, 'rule to recompute': { - formule: 'constant * 2', + valeur: '(constant * 2) * 15', + optimized: 'partially', + }, + question: { + question: 'Question ?', + }, + constant: { + valeur: 10, + }, + }) + }) + + it('should fold a constant rule even with [contexte]', () => { + const rawRules = { + root: { + valeur: 'rule to recompute', + contexte: { + constant: 15, + }, + }, + 'rule to recompute': { + valeur: 'constant * 2', }, 'rule to fold': { - valeur: '40', - optimized: true, + valeur: 'constant * 4', }, constant: { valeur: 10, - optimized: true, + }, + } + expect(constantFoldingWith(rawRules)).toEqual({ + root: { + valeur: 30, + optimized: 'fully', + }, + 'rule to fold': { + valeur: 40, + optimized: 'fully', }, }) }) + it('should not fold a nullable constant [contexte] rule', () => { + const rawRules = { + root: { + 'applicable si': 'présent', + }, + 'root . présent': { + question: 'Is present?', + 'par défaut': 'non', + }, + 'root . a': { + valeur: 'rule to recompute', + contexte: { + constant: 15, + }, + }, + 'rule to recompute': { + valeur: 'constant * 2', + }, + 'rule to fold': { + valeur: 'constant * 4', + }, + constant: { + valeur: 10, + }, + } + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + }) + it('replaceAllRefs bug #3', () => { const rawRules = { boisson: { - formule: 'tasse de café * café', + valeur: 'tasse de café * café', }, 'boisson . café': { valeur: 20, @@ -630,10 +818,10 @@ describe('Constant folding [base]', () => { question: '?', }, } - expect(constantFoldingWith(rawRules)).toStrictEqual({ + expect(constantFoldingWith(rawRules)).toEqual({ boisson: { - formule: 'tasse de café * 20', - optimized: true, + valeur: 'tasse de café * 20', + optimized: 'partially', }, 'boisson . tasse de café': { question: '?', @@ -641,131 +829,271 @@ describe('Constant folding [base]', () => { }) }) - // - // - // TODO: not supported yet - // - // - // it('should fold a constant within two degrees with an [applicable si] (set to false) mechanism', () => { - // const rawRules = { - // A: { - // formule: 'B', - // }, - // 'A . B': { - // 'applicable si': 'présent', - // formule: 'C * 10', - // }, - // 'A . B . présent': { - // question: 'Is present?', - // 'par défaut': 'non', - // }, - // 'A . B . C': { - // valeur: 7, - // }, - // } - // expect(constantFoldingWith(rawRules)).toStrictEqual({ - // A: { - // formule: 'B', - // }, - // 'A . B': { - // 'applicable si': 'présent', - // formule: '7 * 10', - // 'est compressée': true, - // }, - // 'A . B . présent': { - // question: 'Is present?', - // 'par défaut': 'non', - // }, - // }) - // }) - // it('should fold a constant within two degrees with an [applicable si] (set to true) mechanism', () => { - // const rawRules = { - // A: { - // formule: 'B', - // }, - // 'A . B': { - // 'applicable si': 'présent', - // formule: 'C * 10', - // }, - // 'A . B . présent': { - // question: 'Is present?', - // 'par défaut': 'oui', - // }, - // 'A . B . C': { - // valeur: 7, - // }, - // } - // expect(constantFoldingWith(rawRules)).toStrictEqual({ - // A: { - // formule: 'B', - // }, - // 'A . B': { - // 'applicable si': 'présent', - // formule: '7 * 10', - // 'est compressée': true, - // }, - // 'A . B . présent': { - // question: 'Is present?', - // 'par défaut': 'oui', - // }, - // }) - // }) - // - // it('should not delete leaf used in [applicable si > toutes ces conditions (evaluated to ⊤)]', () => { - // const rawRules = { - // root: { - // 'applicable si': { - // 'toutes ces conditions': ['unfoldable < foldable'], - // }, - // formule: 'foldable * pas foldable', - // }, - // 'root . foldable': { - // valeur: 20, - // }, - // 'root . unfoldable': { - // 'par défaut': 10, - // }, - // } - // expect(constantFoldingWith(rawRules)).toStrictEqual({ - // root: { - // 'applicable si': { - // // TODO: should be replaced by 'unfoldable < 20' - // 'toutes ces conditions': ['unfoldable < foldable'], - // }, - // formule: '20 * unfoldable', - // 'est compressée': true, - // }, - // 'root . unfoldable': { - // 'par défaut': 10, - // }, - // }) - // }) - // it('should not delete leaf used in [applicable si > toutes ces conditions (evaluated to ⊥)] ', () => { - // const rawRules = { - // root: { - // 'applicable si': { - // 'toutes ces conditions': ['unfoldable > foldable'], - // }, - // formule: 'foldable * unfoldable', - // }, - // 'root . foldable': { - // valeur: 20, - // }, - // 'root . unfoldable': { - // 'par défaut': 10, - // }, - // } - // expect(constantFoldingWith(rawRules)).toStrictEqual({ - // root: { - // 'applicable si': { - // 'toutes ces conditions': ['unfoldable > 20'], - // }, - // formule: '20 * unfoldable', - // 'est compressée': true, - // }, - // 'root . unfoldable': { - // 'par défaut': 10, - // }, - // }) - // }) + it('should fold a unit rule with a constant [unité]', () => { + const rawRules = { + root: { + formule: '14 repas/semaine * plats . végétalien . empreinte', + unité: 'kgCO2e/semaine', + }, + 'plats . végétalien . empreinte': { + titre: "Empreinte d'un repas végétalien", + formule: 0.785, + unité: 'kgCO2e/repas', + }, + } + expect(constantFoldingWith(rawRules)).toEqual({ + root: { + valeur: '10.99 kgCO2e/semaine', + unité: 'kgCO2e/semaine', + optimized: 'fully', + }, + }) + }) + + it('should fold a constant within two degrees with an [applicable si] (set to false) mechanism', () => { + const rawRules = { + A: { + valeur: 'B', + }, + 'A . B': { + 'applicable si': 'présent', + valeur: 'C * 10', + }, + 'A . B . présent': { + question: 'Is present?', + 'par défaut': 'non', + }, + 'A . B . C': { + valeur: 7, + }, + } + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + }) + + it('should fold a constant within two degrees with an [applicable si] (set to true) mechanism', () => { + const rawRules = { + A: { + valeur: 'B', + }, + 'A . B': { + 'applicable si': 'présent', + valeur: 'C * 10', + }, + 'A . B . présent': { + question: 'Is present?', + 'par défaut': 'oui', + }, + 'A . B . C': { + valeur: 7, + }, + } + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + }) + + it('should not delete leaf used in [applicable si > toutes ces conditions (evaluated to ⊤)]', () => { + const rawRules = { + root: { + 'applicable si': { + 'toutes ces conditions': ['unfoldable < foldable'], + }, + valeur: 'foldable * unfoldable', + }, + 'root . foldable': { + valeur: 20, + }, + 'root . unfoldable': { + 'par défaut': 10, + }, + } + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + }) + + it('should not delete leaf used in [applicable si > toutes ces conditions (evaluated to ⊥)] ', () => { + const rawRules = { + root: { + 'applicable si': { + 'toutes ces conditions': ['unfoldable > foldable'], + }, + valeur: 'foldable * unfoldable', + }, + 'root . foldable': { + valeur: 20, + }, + 'root . unfoldable': { + 'par défaut': 10, + }, + } + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + }) + + it('should not fold nullable rules evaluated to null in the default situation', () => { + const rawRules = { + A: { + valeur: 'B . C', + }, + 'A . B': { + 'applicable si': 'présent', + valeur: 'C * 10', + }, + 'A . B . présent': { + question: 'Is present?', + 'par défaut': 'non', + }, + // nullable rule + 'A . B . C': { + valeur: 7, + }, + } + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + }) + + it('should not fold nullable rules evaluated not to null in the default situation', () => { + const rawRules = { + A: { + valeur: 'B . C', + }, + 'A . B': { + 'applicable si': 'présent', + valeur: 'C * 10', + }, + 'A . B . présent': { + question: 'Is present?', + 'par défaut': 'oui', + }, + // nullable rule + 'A . B . C': { + valeur: 7, + }, + } + expect(constantFoldingWith(rawRules)).toEqual(rawRules) + + // TODO: fine tune the conditional applicability fold + // { + // A: { + // 'applicable si': 'A . B . présent', + // valeur: 7, + // optimized: 'partially', + // }, + // 'A . B': { + // 'applicable si': 'présent', + // valeur: '7 * 10', + // optimized: 'partially', + // }, + // 'A . B . présent': { + // question: 'Is present?', + // 'par défaut': 'oui', + // }, + // })) + }) + + it('should not fold rules used in a [remplacement]', () => { + const rawRules = { + 'frais de repas': { + valeur: '5 €/repas', + }, + 'cafés-restaurants': { + valeur: 'oui', + }, + 'cafés-restaurants . frais de repas': { + remplace: { + 'références à': 'frais de repas', + }, + valeur: '6 €/repas', + }, + 'montant repas mensuels': { + valeur: '20 repas * frais de repas', + }, + } + expect(constantFoldingWith(rawRules)).toEqual({ + ...rawRules, + 'cafés-restaurants': { + valeur: 'oui', + optimized: 'fully', + }, + }) + }) + + it('should not fold rules used in a [remplacement] with a specified context', () => { + const rawRules = { + foo: { + valeur: 0, + }, + 'foo remplacé dans résultat 1': { + remplace: { + 'références à': 'foo', + priorité: 2, + dans: ['résultat 1'], + }, + valeur: 2, + }, + 'foo remplacé dans résultat 2': { + 'applicable si': 'non', + remplace: { + 'références à': 'foo', + 'sauf dans': ['résultat 1'], + }, + valeur: 3, + }, + 'résultat 1': { valeur: 'foo' }, + 'résultat 2': { valeur: 'foo' }, + } + expect(constantFoldingWith(rawRules)).toEqual({ ...rawRules }) + }) + + it('should fully fold a rule with [syntaxic sugar]', () => { + const rawRules = { + foo: { + somme: ['bar', 'baz'], + }, + 'foo 2': { + produit: ['bar', 'baz'], + }, + bar: { + valeur: 10, + }, + baz: { + valeur: 20, + }, + } + expect(constantFoldingWith(rawRules)).toEqual({ + foo: { + valeur: 30, + optimized: 'fully', + }, + 'foo 2': { + valeur: 200, + optimized: 'fully', + }, + }) + }) + + it('should fold [private rule]', () => { + const rawRules = { + assiette: { + valeur: '2100 €', + }, + cotisation: { + produit: ['assiette', 'taux'], + avec: { + '[privé] taux': { + valeur: '2.8 %', + }, + }, + }, + foo: { + privé: 'oui', + 'par défaut': 10, + }, + } + expect(constantFoldingWith(rawRules)).toEqual({ + cotisation: { + optimized: 'fully', + valeur: '58.8 €', + }, + foo: { + privé: 'oui', + 'par défaut': 10, + }, + }) + }) }) diff --git a/test/serializeParsedRules.test.ts b/test/serializeParsedRules.test.ts new file mode 100644 index 0000000..b5c7abf --- /dev/null +++ b/test/serializeParsedRules.test.ts @@ -0,0 +1,941 @@ +import Engine from 'publicodes' +import { serializeParsedRules } from '../source/index' + +describe('API > mecanisms list', () => { + it('should serialize empty rules', () => { + expect(serializeParsedRules({})).toStrictEqual({}) + }) + + it('should serialize rule with constant [valeur]', () => { + const rules = { + rule: { + titre: 'My rule', + valeur: 10, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with ref [valeur]', () => { + const rules = { + rule: { + titre: 'My rule', + valeur: 10, + }, + rule2: { + titre: 'Rule with a ref', + valeur: 'rule', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with constant [formule]', () => { + const rules = { + rule: { + titre: 'My rule', + formule: '10 * rule2', + }, + rule2: { + valeur: 2, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual({ + rule: { + titre: 'My rule', + valeur: '10 * rule2', + }, + rule2: { + valeur: 2, + }, + }) + }) + + it('should serialize rule with ref [applicable si]', () => { + const rules = { + rule: { + 'applicable si': 'rule2', + valeur: 10, + }, + rule2: { + valeur: 20, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with condition [applicable si]', () => { + const rules = { + rule: { + 'applicable si': 'rule2 < 5', + valeur: 10, + }, + rule2: { + valeur: 20, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with condition [non applicable si]', () => { + const rules = { + rule: { + 'non applicable si': 'rule2 < 5', + valeur: 10, + }, + rule2: { + valeur: 20, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [est non défini]', () => { + const rules = { + rule: { + 'est non défini': 'rule2', + }, + rule2: { + valeur: 20, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [est défini]', () => { + const rules = { + rule: { + 'est défini': 'rule2', + }, + rule2: { + valeur: 20, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [est non applicable]', () => { + const rules = { + rule: { + 'est non applicable': 'rule2', + }, + rule2: { + valeur: 20, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [est applicable]', () => { + const rules = { + rule: { + 'est applicable': 'rule2', + }, + rule2: { + valeur: 20, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [une de ces conditions]', () => { + const rules = { + rule: { + 'une de ces conditions': ['rule2', 'rule2 < 5'], + }, + rule2: { + valeur: 20, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [toutes ces conditions]', () => { + const rules = { + rule: { + 'toutes ces conditions': ['rule2', 'rule2 < 5'], + }, + rule2: { + valeur: 20, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [produit]', () => { + const rules = { + volume: { + produit: ['2.5 m', '3 m', '4 m'], + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [produit] (without unit)', () => { + const rules = { + volume: { + produit: [2.5, 3, 4], + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [variations]', () => { + const rules = { + 'taux réduit': { + valeur: 'oui', + }, + 'taux majoré': { + valeur: 'non', + }, + 'taux allocation familiales': { + variations: [ + { + si: 'taux réduit', + alors: '3.45 %', + }, + { + si: 'taux majoré', + alors: '10 %', + }, + { sinon: '5.25 %' }, + ], + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [somme]', () => { + const rules = { + exemple: { + somme: ['15.89 €', '12 % * 14 €', '-20 €'], + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [moyenne]', () => { + const rules = { + exemple: { + moyenne: ['15.89 €', '12 % * 14 €', '-20 €'], + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [le maximum de]', () => { + const rules = { + max: { + 'le maximum de': ['15.89 €', '12 % * 14 €', '-20 €'], + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [le minimum de]', () => { + const rules = { + max: { + 'le minimum de': ['15.89 €', '12 % * 14 €', '-20 €'], + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [arrondi]', () => { + const rules = { + arrondi: { + arrondi: 'oui', + valeur: 10.5, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [arrondi] (example 2)', () => { + const rules = { + arrondi: { + arrondi: '2 décimales', + valeur: '2 / 3', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [contexte]', () => { + const rules = { + brut: { + valeur: '2000 €', + }, + cotisation: { + valeur: 'brut * 20 %', + }, + 'cotisation pour un SMIC': { + valeur: 'cotisation', + contexte: { + brut: '1500 €', + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [contexte] applied to a [somme]', () => { + const rules = { + brut: { + valeur: '2000 €', + }, + cotisation: { + valeur: 'brut * 20 %', + }, + 'cotisation pour un SMIC': { + somme: ['cotisation'], + contexte: { + brut: '1500 €', + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [barème]', () => { + const rules = { + 'revenu imposable': { + valeur: '54126 €', + }, + 'impôt sur le revenu': { + barème: { + assiette: 'revenu imposable', + tranches: [ + { taux: '0 %', plafond: '9807 €' }, + { taux: '14 %', plafond: '27086 €' }, + { taux: '30 %', plafond: '72617 €' }, + { taux: '41 %', plafond: '153783 €' }, + { taux: '45 %' }, + ], + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [grille]', () => { + const rules = { + 'SMIC horaire': { + valeur: '10 €/heures', + }, + 'revenu cotisé': { + valeur: '1900 €/an', + }, + 'trimestres validés': { + unité: 'trimestres validés/an', + grille: { + assiette: 'revenu cotisé', + multiplicateur: 'SMIC horaire', + tranches: [ + { montant: 0, plafond: '150 heures/an' }, + { montant: 1, plafond: '300 heures/an' }, + { montant: 2, plafond: '450 heures/an' }, + { montant: 3, plafond: '600 heures/an' }, + { montant: 4 }, + ], + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it("should serialize rule with [barème] and a custom 'multiplicateur'", () => { + const rules = { + 'revenu imposable': { + valeur: '54126 €', + }, + 'plafond sécurité sociale': { + valeur: '41136 €', + }, + 'impôt sur le revenu': { + barème: { + assiette: 'revenu imposable', + multiplicateur: 'plafond sécurité sociale', + tranches: [ + { taux: '0 %', plafond: '9807 €' }, + { taux: '14 %', plafond: '27086 €' }, + { taux: '30 %', plafond: '72617 €' }, + { taux: '41 %', plafond: '153783 €' }, + { taux: '45 %' }, + ], + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [taux progressif]', () => { + const rules = { + "chiffre d'affaires": { + valeur: '30000 €/an', + }, + plafond: { + valeur: '3000 €/mois', + }, + 'taux de réduction de cotisation': { + 'taux progressif': { + assiette: "chiffre d'affaires", + multiplicateur: 'plafond', + tranches: [ + { taux: '100 %', plafond: '75 %' }, + { taux: '0 %', plafond: '100 %' }, + ], + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [abattement]', () => { + const rules = { + 'revenu imposable simple': { + valeur: '30000 €', + abattement: '2000 €', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [plancher]', () => { + const rules = { + 'temperature mesurée': { + valeur: '-500 °C', + plancher: '-273.15 °C', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [plafond]', () => { + const rules = { + 'déduction fiscale': { + valeur: '1300 €/mois', + plafond: '200 €/mois', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [durée]', () => { + const rules = { + "date d'embauche": { + valeur: '14/04/2008', + }, + "ancienneté en fin d'année": { + durée: { + depuis: "date d'embauche", + "jusqu'à": '31/12/2020', + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [unité]', () => { + const rules = { + test1: { + valeur: 35, + unité: '€/mois', + }, + test2: { + valeur: '35 €/mois', + }, + test3: { + variations: [ + { si: 'test1 > 0', alors: 'test1' }, + { + sinon: { + somme: ['test1', 'test2'], + unité: '€/mois', + }, + }, + ], + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [par défaut]', () => { + const rules = { + 'prix HT': { + valeur: '50 €', + }, + 'prix TTC': { + valeur: 'prix HT * (100 % + TVA)', + }, + TVA: { + valeur: '50 %', + 'par défaut': '20 %', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [avec]', () => { + const rules = { + 'prix final': { + valeur: 'prix de base * (100% - réduction)', + avec: { + 'prix de base': '157 €', + réduction: '20 %', + }, + }, + test2: { + valeur: 'a + b', + avec: { + a: null, + b: null, + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual({ + 'prix final': { + valeur: 'prix de base * (100 % - réduction)', + }, + 'prix final . prix de base': { + valeur: '157 €', + }, + 'prix final . réduction': { + valeur: '20 %', + }, + test2: { + valeur: 'a + b', + }, + 'test2 . a': null, + 'test2 . b': null, + }) + }) + + it('should serialize rule with [texte]', () => { + const rules = { + 'aide vélo': { + texte: `La région subventionne l’achat d’un vélo à hauteur de + {{ prise en charge }} et jusqu’à un plafond de {{ 500 € }}. + Les éventuelles aides locales déjà perçues sont déduites de + ce montant. + + Par exemple, pour un vélo de {{ exemple }}, la région {{ 'Nouvelle Aquitaine' }} + vous versera {{ exemple * prise en charge }}`, + }, + 'aide vélo . prise en charge': { + valeur: '50 %', + }, + 'aide vélo . exemple': { + valeur: '250 €', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with multiple [mécanismes chaînés]', () => { + const rules = { + 'nombre de repas': { + valeur: '12 repas', + }, + 'remboursement repas': { + valeur: 'nombre de repas * 4.21 €/repas', + plafond: '500 €', + abattement: '25 €', + arrondi: 'oui', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with multiple [une possibilité]', () => { + const rules = { + 'chauffage collectif': { + avec: { + collectif: null, + individuel: null, + }, + question: 'Votre chauffage est-il collectif ou individuel ?', + 'par défaut': "'collectif'", + formule: { + 'une possibilité': { + 'choix obligatoire': 'oui', + possibilités: ['collectif', 'individuel'], + }, + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual({ + 'chauffage collectif': { + question: 'Votre chauffage est-il collectif ou individuel ?', + 'par défaut': "'collectif'", + 'une possibilité': { + 'choix obligatoire': 'oui', + possibilités: ['collectif', 'individuel'], + }, + }, + 'chauffage collectif . collectif': null, + 'chauffage collectif . individuel': null, + }) + }) + + it('should serialize rule with [remplacement]', () => { + const rules = { + 'frais de repas': { + valeur: '5 €/repas', + }, + 'cafés-restaurants': { + valeur: 'oui', + }, + 'cafés-restaurants . frais de repas': { + remplace: { + 'références à': 'frais de repas', + }, + valeur: '6 €/repas', + }, + 'montant repas mensuels': { + valeur: '20 repas * frais de repas', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [remplacement] with a shorten rule ref', () => { + const rules = { + 'cafés-restaurants': { + valeur: 'oui', + }, + 'cafés-restaurants . frais de repas': { + remplace: { + 'références à': 'montant repas mensuels . frais de repas', + }, + valeur: '6 €/repas', + }, + 'montant repas mensuels': { + valeur: '20 repas * frais de repas', + }, + 'montant repas mensuels . frais de repas': { + valeur: '5 €/repas', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual({ + ...rules, + 'montant repas mensuels': { + valeur: '20 repas * montant repas mensuels . frais de repas', + }, + }) + }) + + it('should serialize rule with [remplacement] in a specified context', () => { + const rules = { + foo: { + valeur: 0, + }, + 'foo remplacé dans résultat 1': { + remplace: { + 'références à': 'foo', + priorité: 2, + dans: 'résultat 1', + }, + valeur: 2, + }, + 'foo remplacé dans résultat 2': { + remplace: { + 'références à': 'foo', + 'sauf dans': 'résultat 1', + }, + valeur: 3, + }, + 'résultat 1': { valeur: 'foo' }, + 'résultat 2': { valeur: 'foo' }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual({ + ...rules, + 'foo remplacé dans résultat 1': { + remplace: { + 'références à': 'foo', + priorité: 2, + dans: ['résultat 1'], + }, + valeur: 2, + }, + 'foo remplacé dans résultat 2': { + remplace: { + 'références à': 'foo', + 'sauf dans': ['résultat 1'], + }, + valeur: 3, + }, + }) + }) + + it('should serialize rule with [inversion]', () => { + const rules = { + bruts: { + unité: '€/an', + 'par défaut': '0 €/an', + 'inversion numérique': ["nets d'impôt"], + }, + "nets d'impôt": { + valeur: '0 €/an', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [résoudre la référence circulaire]', () => { + const rules = { + x: { + valeur: '(4 * x) - 5', + 'résoudre la référence circulaire': 'oui', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) + + it('should serialize rule with [private rule]', () => { + const rules = { + assiette: { + valeur: '2100 €', + }, + cotisation: { + produit: ['assiette', 'taux'], + }, + '[privé] taux': { + valeur: '2.8 %', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).baseContext.parsedRules, + ) + expect(serializedRules).toStrictEqual({ + assiette: { + valeur: '2100 €', + }, + cotisation: { + produit: ['assiette', 'taux'], + }, + taux: { + privé: 'oui', + valeur: '2.8 %', + }, + }) + }) + + it('should serialize rule with [private rule] inside [avec]', () => { + const rules = { + assiette: { + valeur: '2100 €', + }, + cotisation: { + produit: ['assiette', 'taux'], + avec: { + '[privé] taux': { + valeur: '2.8 %', + }, + }, + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).baseContext.parsedRules, + ) + expect(serializedRules).toStrictEqual({ + assiette: { + valeur: '2100 €', + }, + cotisation: { + produit: ['assiette', 'taux'], + }, + 'cotisation . taux': { + privé: 'oui', + valeur: '2.8 %', + }, + }) + }) +}) + +describe('More complexe cases', () => { + it('should serialize the same rules multiple times', () => { + const rules = { + ruleA: { + titre: 'Rule A', + valeur: 'B . C * D', + }, + 'ruleA . B . C': { + valeur: '10', + }, + 'ruleA . D': { + valeur: '3', + }, + } + const parsedRules = new Engine(rules, { + allowOrphanRules: true, + }).getParsedRules() + + expect(serializeParsedRules(parsedRules)).toStrictEqual( + serializeParsedRules(parsedRules), + ) + }) + + it('should correctly serialize [valeur] composed with other mecanisms', () => { + const rules = { + ex1: { + valeur: { + somme: ['15.89 €', '12 % * 14 €', '-20 €'], + }, + }, + ex2: { + valeur: '2 * 15.89 €', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual({ + ex1: { + somme: ['15.89 €', '12 % * 14 €', '-20 €'], + }, + ex2: { + valeur: '2 * 15.89 €', + }, + }) + }) + + it('should correclty serialize complexe [unité]', () => { + const rules = { + ex1: { + valeur: 10, + unité: '€/part.an', + }, + } + const serializedRules = serializeParsedRules( + new Engine(rules).getParsedRules(), + ) + expect(serializedRules).toStrictEqual(rules) + }) +}) diff --git a/test/utils.test.ts b/test/utils.test.ts index c1d104a..58fa921 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -3,7 +3,10 @@ import Engine from 'publicodes' import type { ParsedRules } from 'publicodes' export function callWithEngine(fn: (engine: Engine) => R, rawRules: any): R { - const engine = new Engine(rawRules, { logger: disabledLogger }) + const engine = new Engine(rawRules, { + logger: disabledLogger, + allowOrphanRules: true, + }) return fn(engine) } diff --git a/yarn.lock b/yarn.lock index 8c63f98..7768604 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2221,11 +2221,6 @@ minimatch@^9.0.0, minimatch@^9.0.1: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== -mitata@^0.1.6: - version "0.1.8" - resolved "https://registry.yarnpkg.com/mitata/-/mitata-0.1.8.tgz#bc000a5945b7f977567089ce55d4a29cf6e31f42" - integrity sha512-q+tTmAMdyT69SfwvOkMMCCzLhqoPY9OmbZ2nTDwFopTYMKl9HLRA+XfDXydGaRzVEYehkyfP1pFYoXzE6jVRKw== - ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -2407,10 +2402,10 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -publicodes@1.0.0-beta.77: - version "1.0.0-beta.77" - resolved "https://registry.yarnpkg.com/publicodes/-/publicodes-1.0.0-beta.77.tgz#45e3c4d2a46bfadcc932e1405ea037e659c28134" - integrity sha512-F8U3WGUWMo3/rxhWYS1gWIiG20g1Yy/+PpXdHM99d6ZHKWnnyh/4txVEuyVE75glgDs+mTjwZPnmoKWsTMXluA== +publicodes@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/publicodes/-/publicodes-1.0.1.tgz#07c1e5d94b3609a2fe09b531490969c9f4960086" + integrity sha512-vrgnLCKeu3orxmBhyjll8/rxnL3k2EYtIYa7D7GiJVYWmOE08J5S6c/1j15LnC3cQkYqYgOJR5JnX5gxooQGLA== punycode@^2.1.0: version "2.3.1" @@ -2740,7 +2735,7 @@ ts-jest@^29.0.4: semver "^7.5.3" yargs-parser "^21.0.1" -ts-node@^10.9.1: +ts-node@^10.9.2: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==