Skip to content

Commit

Permalink
test: add AidesVeloEngine tests
Browse files Browse the repository at this point in the history
  • Loading branch information
EmileRolley committed Oct 25, 2024
1 parent c850b57 commit 7e86540
Show file tree
Hide file tree
Showing 4 changed files with 425 additions and 294 deletions.
30 changes: 20 additions & 10 deletions src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,29 @@ import rawMiniatures from "./miniatures.json" assert { type: "json" };
* A commune in France with its population, region, departement, EPCI, and
* postal codes.
*
* @param code The INSEE code
* @param nom The name
* @param departement The departement code (e.g. "75" for Paris)
* @param region The region code (e.g. "11" for Île-de-France)
* @param population The size of the population
* @param zfe Whether the commune is in a low-emission zone
* @param epci The EPCI name (e.g. "Métropole du Grand Paris")
* @param codesPostaux The postal codes (e.g. ["75001", "75002"])
* @param slug The slugified name (e.g. "paris")
*
* @note This is filtered and transformed data from the
* `@etalab/decoupage-administratif` package so please refer to
* https://unpkg.com/@etalab/decoupage-administratif@4.0.0/data/communes.json
* if you have a doubt about the format of the data.
*/
export type Commune = {
/** The INSEE code */
code: string;
/** The name (as defined in `@etalab/decoupage-administratif`) */
nom: string;
/** The departement code (e.g. "75" for Paris) */
departement: string;
/** The region code (e.g. "11" for Île-de-France) */
region: string;
/** The size of the population */
population: number;
/** Whether the commune is in a low-emission zone */
zfe: boolean;
/** The EPCI name (e.g. "Métropole du Grand Paris") */
epci: string | null;
/** The postal codes (e.g. ["75001", "75002"]) */
codesPostaux: string[];
/** The slugified name (e.g. "le-chatelet-sur-sormonne") */
slug: string;
};

Expand All @@ -40,15 +39,19 @@ export type Commune = {
* situation before evaluating the rules.
*/
export type Localisation = {
/** The collectivity scale and value */
collectivity: {
kind: "pays" | "région" | "département" | "epci" | "code insee";
value: string;
code?: string;
};
country: "france" | "monaco" | "luxembourg";
codeInsee?: string;
/** The departement code (e.g. "75" for Paris) */
departement?: string;
/** The slugified name (e.g. "le-chatelet-sur-sormonne") */
slug: string;
/** The size of the population */
population: number;
};

Expand Down Expand Up @@ -77,4 +80,11 @@ export const aidesAvecLocalisation = collectivities as Record<
Localisation
>;

/**
* The list of all miniatures file name corresponding to the collectivities
* providing the aids.
*
* TODO: host the images on a CDN and provide the full URL instead of the
* filename.
*/
export const miniatures = rawMiniatures as Record<AideRuleNames, string>;
279 changes: 4 additions & 275 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,9 @@
* wrapper functions, and the types.
*/

import Engine, {
formatValue,
Situation as PublicodesSituation,
} from "publicodes";
import { AideRuleNames, Commune, Localisation } from "./data";

// Export the compiled rules and the types.

import compiledRules, { Questions, RuleName, Situation } from "../build";
import compiledRules from "../build";
export type { Questions, RuleName } from "../build";
/**
* Publicodes rules compiled in a single JSON object.
Expand All @@ -23,275 +17,10 @@ export const rules = compiledRules;

// Export generated data

import * as data from "./data";
export * as data from "./data";
export type { AideRuleNames, Commune, Localisation } from "./data";

// Export the main functions

/**
* Represents an aid with its metadata.
*
* @param id The rule name of the aid (see {@link AideRuleNames}).
* @param title The title of the aid (as defined in the Publicodes rules).
* @param description The description of the aid (with resolved placeholders).
* @param url The URL of the aid (as defined in the Publicodes rules).
* @param collectivity The collectivity that provides the aid.
* @param amount The amount of the aid in euros.
* @param logo The miniature URL of the collectivity providing the aid.
*/
export type Aide = {
id: AideRuleNames;
title: string;
description: string | undefined;
url: string;
collectivity: {
kind: "pays" | "région" | "département" | "epci" | "code insee";
value: string;
code?: string;
};
amount: number;
logo?: string;
};

const aidesAvecLocalisationEntries = Object.entries(
data.aidesAvecLocalisation,
) as readonly [AideRuleNames, Localisation][];

/**
* A wrapper around the {@link Engine} class to compute the available aids for the
* given inputs (which are a subset of the Publicodes situation corresponding to
* the rules that are questions).
*
* @note This class is stateful and should be used to compute the aids for a
* single situation. If you want to compute the aids for multiple situations,
* you should create a new instance of this class for each situation. You
* can use {@link shallowCopy} to create a new instance with the same rules and
* inputs.
*/
export class AidesVeloEngine {
private inputs: Questions = {};
private engine: Engine<RuleName>;

/**
* Create a new instance of the engine with a initialized set of rules.
*
* @param empty If `true`, the engine will be created with an empty set of
* rules. Otherwise, it will be created with the default set of rules.
*/
constructor(empty = false) {
this.inputs = {};
this.engine = empty ? new Engine<RuleName>() : new Engine<RuleName>(rules);
}

/**
* Set the inputs of the engine. This will update the Publicodes situation
* with the given inputs.
*
* @param inputs The inputs to set (corresponding to the rules that are
* questions).
*
* @note The format of the inputs are in the JS format, not the Publicodes
* format. For example, boolean values are represented as `true` or `false`
* instead of `oui` or `non` and the values are not wrapped in single quotes.
*/
public setInputs(inputs: Questions): this {
this.inputs = inputs;
this.engine.setSituation(
formatInputs(inputs) as PublicodesSituation<RuleName>,
);
return this;
}

/**
* Filter the available aids by the given country.
*
* @param country The country to filter the aids by. The default is `france`.
* @returns The list of available aids with their metadata (see {@link
* Aide}).
*
* @note No computation is done here. This is just a filter on the available
* aids.
*/
public getAllAidesIn(
country: Localisation["country"] = "france",
): Omit<Aide, "amount">[] {
return aidesAvecLocalisationEntries
.filter(([, { country: aideCountry }]) => aideCountry === country)
.map(([ruleName]) => {
const rule = this.engine.getRule(ruleName);
const collectivity = data.aidesAvecLocalisation[ruleName].collectivity;

return {
id: ruleName,
title: rule.title,
description: rule.rawNode.description,
url: (rule.rawNode as any).lien,
collectivity,
logo: data.miniatures[ruleName],
};
});
}

/**
* Compute all the available aids for the current inputs (see {@link setInputs}).
*
* @returns The list of available aids with their metadata (see {@link Aide}).
*/
public computeAides(): Aide[] {
return this.getAllAidesIn(
(this.inputs["localisation . pays"]?.toLowerCase() ??
"france") as Localisation["country"],
).flatMap((metadata) => {
const ruleName = metadata.id;
const { nodeValue } = this.engine.evaluate({
valeur: metadata.id,
unité: "€",
});

if (typeof nodeValue === "number" && nodeValue > 0) {
return [
{
...metadata,
description: this.formatDescription({
ruleName,
veloCat: this.engine.evaluate("vélo . type")
.nodeValue as Questions["vélo . type"],
ville: "votre ville",
}),
amount: nodeValue,
},
];
} else {
return [];
}
});
}

/**
* Create a shallow copy of the engine with the same rules and inputs. This
* is useful to compute the aids for multiple situations.
*
* @returns A new instance of the engine with the same rules and inputs.
*/
public shallowCopy() {
const newEngine = new AidesVeloEngine(true);
newEngine.inputs = { ...this.inputs };
newEngine.engine = this.engine.shallowCopy();
return newEngine;
}

/**
* Return a shallow copy of the wrapped Publicodes engine. This is useful to
* get the current state of the engine (rules and inputs) without modifying
* it.
*/
getEngine(): Engine {
return this.engine.shallowCopy();
}

/**
* Format the description of an aid by replacing the placeholders with the
* evaluated values.
*
* @param ruleName The name of the rule to format.
* @param engine The Publicodes engine used to evaluate the rule.
* @param veloCat The category of the bike.
* @param ville The name of the city.
*
* @returns The formatted description.
*/
public formatDescription({
ruleName,
veloCat,
ville,
}: {
ruleName: RuleName;
veloCat: Questions["vélo . type"];
ville: string;
}) {
const { rawNode } = this.engine.getRule(ruleName);
const description = rawNode?.description ?? "";
const plafondRuleName = `${ruleName} . $plafond`;
const plafondIsDefined = Object.keys(this.engine.getParsedRules()).includes(
plafondRuleName,
);
const plafond = plafondIsDefined && this.engine.evaluate(plafondRuleName);
return (
description
.replace(
/\$vélo/g,
veloCat === "motorisation"
? "kit de motorisation"
: `vélo ${veloCat}`,
)
.replace(
/\$plafond/,
// TODO: improve Publicodes typing
// @ts-ignore
formatValue(plafond?.nodeValue, { displayedUnit: "€" }),
)
// NOTE: doesn't seem to be used
.replace(/\$ville/, ville)
);
}

/**
* Get the commune by its name.
*
* @param name The name of the commune (e.g. "Paris").
* @returns The commune if found, `undefined` otherwise.
*/
static getCommuneByName(name: string): Commune | undefined {
return data.communes.find(
({ nom }) => nom.toLowerCase() === name.toLowerCase(),
);
}

/**
* Get the commune by its INSEE code.
*
* @param inseeCode The INSEE code of the commune (e.g. "75056").
* @returns The commune if found, `undefined` otherwise.
*
* @note The INSEE code is not the same as the postal code. It's a unique
* identifier for each commune in France in contrast to the postal code which
* can be shared by multiple communes.
*/
static getCommuneByInseeCode(inseeCode: string): Commune | undefined {
return data.communes.find(({ code }) => code === inseeCode);
}
}

function formatInputs(inputs: Questions): Partial<Situation> {
const entries = Object.entries(inputs);

const transformedEntries = entries.map(([key, val]) => {
let transformedVal: string | number | boolean | null;

if (typeof val === "boolean") {
transformedVal = val ? "oui" : "non";
} else if (key === "localisation . epci") {
transformedVal = val ? `'${epciSirenToName[val] || val}'` : null;
} else if (typeof val === "string") {
transformedVal = `'${val}'`;
} else {
transformedVal = val;
}

return [key, transformedVal];
});

const transformedInput = Object.fromEntries(transformedEntries);

return transformedInput;
}
// Export the AidesVeloEngine class and the Aide type.

const epciSirenToName = Object.fromEntries(
aidesAvecLocalisationEntries.flatMap(([, { collectivity }]) => {
if (collectivity.kind !== "epci") {
return [];
}
return [[(collectivity as any).code, collectivity.value]];
}),
);
export { AidesVeloEngine } from "./lib/AidesVeloEngine";
export type { Aide } from "./lib/AidesVeloEngine";
Loading

0 comments on commit 7e86540

Please sign in to comment.