Refactor calculator module helpers
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 23:08:26 +02:00
parent e512eef232
commit 61176ba9b7
5 changed files with 211 additions and 170 deletions

View file

@ -146,6 +146,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const combosBundle = `${combosModule}\n${combosEditor}\n${combosParts}\n${combosList}`; const combosBundle = `${combosModule}\n${combosEditor}\n${combosParts}\n${combosList}`;
const combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8"); const combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8");
const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8");
const calculatorEntries = await readFile("website/src/features/toolboxes/modules/CalculatorEntries.jsx", "utf8");
const calculatorUtils = await readFile("website/src/features/toolboxes/modules/calculatorUtils.js", "utf8");
const calculatorBundle = `${calculatorModule}\n${calculatorEntries}\n${calculatorUtils}`;
const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8"); const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8");
const tableGrid = await readFile("website/src/features/toolboxes/modules/TableGrid.jsx", "utf8"); const tableGrid = await readFile("website/src/features/toolboxes/modules/TableGrid.jsx", "utf8");
const tableGridParts = await readFile("website/src/features/toolboxes/modules/TableGridParts.jsx", "utf8"); const tableGridParts = await readFile("website/src/features/toolboxes/modules/TableGridParts.jsx", "utf8");
@ -274,12 +277,12 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.doesNotMatch(combosModule, /canMoveItem:/); assert.doesNotMatch(combosModule, /canMoveItem:/);
assert.doesNotMatch(combosModule, /canMoveGroup:/); assert.doesNotMatch(combosModule, /canMoveGroup:/);
assert.match(calculatorModule, /export function CalculatorModule/); assert.match(calculatorModule, /export function CalculatorModule/);
assert.match(calculatorModule, /calculateExpression/); assert.match(calculatorUtils, /export function calculateExpression/);
assert.match(calculatorModule, /activeParentId/); assert.match(calculatorModule, /activeParentId/);
assert.match(calculatorModule, /parentId/); assert.match(calculatorModule, /parentId/);
assert.match(calculatorModule, /MixTagsInput/); assert.match(calculatorModule, /MixTagsInput/);
assert.match(calculatorModule, /useGroupedReorder/); assert.match(calculatorModule, /useGroupedReorder/);
assert.match(calculatorModule, /calculator-drag-handle/); assert.match(calculatorEntries, /calculator-drag-handle/);
assert.match(calculatorModule, /scrollResults/); assert.match(calculatorModule, /scrollResults/);
assert.match(calculatorModule, /tool-split-scroll-toggle/); assert.match(calculatorModule, /tool-split-scroll-toggle/);
assert.match(calculatorModule, /copyChecklistImport/); assert.match(calculatorModule, /copyChecklistImport/);
@ -293,11 +296,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(calculatorModule, /setExpression\(BASE_TOKEN\)/); assert.match(calculatorModule, /setExpression\(BASE_TOKEN\)/);
assert.match(calculatorModule, /editingEntryId/); assert.match(calculatorModule, /editingEntryId/);
assert.match(calculatorModule, /updateButton/); assert.match(calculatorModule, /updateButton/);
assert.match(calculatorModule, /onAddChild/); assert.match(calculatorEntries, /onAddChild/);
assert.match(calculatorModule, /entry\.label \|\| formatResult\(entry\.value\)/); assert.match(calculatorEntries, /entry\.label \|\| formatResult\(entry\.value\)/);
assert.match(calculatorModule, /ResizeObserver/); assert.match(calculatorModule, /ResizeObserver/);
assert.match(calculatorModule, /--calculator-scroll-height/); assert.match(calculatorModule, /--calculator-scroll-height/);
assert.match(calculatorModule, /CalculatorEntries/); assert.match(calculatorBundle, /CalculatorEntries/);
assert.match(tableModule, /export function TableModule/); assert.match(tableModule, /export function TableModule/);
assert.match(tableBundle, /Icon name="add-row"/); assert.match(tableBundle, /Icon name="add-row"/);
assert.match(tableBundle, /Icon name="add-column"/); assert.match(tableBundle, /Icon name="add-column"/);

View file

@ -6,6 +6,7 @@ import { exportModuleText, importModuleText, parseColonImportLines } from "../we
import { buildInsertedMixTagValue, normalizeMixTagSpacing, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../website/src/utils/mixTagsInputUtils.js"; import { buildInsertedMixTagValue, normalizeMixTagSpacing, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../website/src/utils/mixTagsInputUtils.js";
import { getEffectiveGoalTreeProgressMode, getVisibleGoalTreeEdges } from "../website/src/features/toolboxes/modules/goalTreeDisplay.js"; import { getEffectiveGoalTreeProgressMode, getVisibleGoalTreeEdges } from "../website/src/features/toolboxes/modules/goalTreeDisplay.js";
import { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js"; import { calculateGoalTreeFormula, evaluateGoalTreeNodes } from "../website/src/features/toolboxes/modules/goalTreeUtils.js";
import { calculateExpression, createStoredFormula, evaluateCalculatorEntries, getEntriesInTreeOrder } from "../website/src/features/toolboxes/modules/calculatorUtils.js";
import { createDefaultDraft, getDateSpanDays, getDraftSelection, normalizeDraftAlertOffsets, normalizeDraftMonthlyRepeatBy } from "../website/src/features/toolboxes/modules/calendarDraft.js"; import { createDefaultDraft, getDateSpanDays, getDraftSelection, normalizeDraftAlertOffsets, normalizeDraftMonthlyRepeatBy } from "../website/src/features/toolboxes/modules/calendarDraft.js";
import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.js"; import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, layoutConcurrentOccurrences } from "../website/src/features/toolboxes/modules/calendarLayout.js";
import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js"; import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js";
@ -154,6 +155,21 @@ test("text exchange imports table TSV and calculator hierarchy", () => {
assert.equal(calculatorImport.data.entries[1].formula, "base*5"); assert.equal(calculatorImport.data.entries[1].formula, "base*5");
}); });
test("calculator helpers evaluate formulas and preserve tree order", () => {
const entries = evaluateCalculatorEntries([
{ id: "root", label: "Root", value: 10, formula: "10" },
{ id: "child", parentId: "root", label: "Child", value: 0, formula: "base*3" },
{ id: "sibling", label: "Sibling", value: 2, formula: "1+1" }
]);
assert.equal(calculateExpression("2 + 3 * 4"), 14);
assert.equal(calculateExpression("base / 2", 10), 5);
assert.equal(calculateExpression("window.alert(1)"), null);
assert.equal(createStoredFormula("+5", 10), "base+5");
assert.equal(createStoredFormula("10*2", 10), "base*2");
assert.deepEqual(entries.map((entry) => [entry.id, entry.value]), [["root", 10], ["child", 30], ["sibling", 2]]);
assert.deepEqual(getEntriesInTreeOrder(entries).map((entry) => entry.id), ["root", "child", "sibling"]);
});
test("calendar layout keeps week compaction and overlapping lanes deterministic", () => { test("calendar layout keeps week compaction and overlapping lanes deterministic", () => {
const base = new Date(2026, 0, 5, 8, 0, 0, 0).getTime(); const base = new Date(2026, 0, 5, 8, 0, 0, 0).getTime();
const occurrences = [ const occurrences = [

View file

@ -0,0 +1,78 @@
// Rôle : rend l'arbre des résultats enregistrés du calculateur.
import { Icon } from "../../../components/Icon.jsx";
import {
DISPLAY_NUMBER_FORMATTER,
formatDisplayResult,
formatResult,
getChildren,
getEntryResultLengthClass
} from "./calculatorUtils.js";
export function FormattedCalculatorNumber({ value }) {
if (!Number.isFinite(value)) return "-";
let integerGroupIndex = 0;
const normalized = Number.parseFloat(value.toFixed(6));
return DISPLAY_NUMBER_FORMATTER.formatToParts(normalized).map((part, index) => {
if (part.type === "integer") {
const className = `calculator-number-group ${integerGroupIndex % 2 ? "is-muted" : "is-strong"}`;
integerGroupIndex += 1;
return <span className={className} key={`${part.type}-${index}`}>{part.value}</span>;
}
if (part.type === "group") return <span className="calculator-number-separator" key={`${part.type}-${index}`}>{part.value}</span>;
return <span key={`${part.type}-${index}`}>{part.value}</span>;
});
}
export function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) {
const children = getChildren(entries, parentId);
if (!children.length) return null;
return (
<ul className="tool-split-entry-list calculator-entry-list">
{children.map((entry) => {
const className = [
entry.id === activeEntryId ? "active" : "",
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li className={className} key={entry.id} {...reorder.getItemProps({ itemId: entry.id, parentId: getScopedParentId(parentId) })}>
<div className="tool-split-entry calculator-entry has-drag-handle">
<button
className="calculator-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, entry.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${entry.label || formatResult(entry.value)}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
<button className="tool-split-entry-summary" type="button" onClick={() => onEdit(entry)} title={textContent.editTitle || "Modifier"}>
<span>
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
<FormattedCalculatorNumber value={entry.value} />
</strong>
{entry.label && <em>{entry.label}</em>}
</span>
</button>
<button
type="button"
onClick={() => onAddChild(entry)}
aria-label={`${textContent.addChildTitle || "Ajouter un calcul enfant"} ${entry.label || formatResult(entry.value)}`}
title={textContent.addChildTitle || "Ajouter un calcul enfant"}
>
<Icon name="add" />
</button>
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeEntryId={activeEntryId} textContent={textContent} reorder={reorder} onEdit={onEdit} onAddChild={onAddChild} onDelete={onDelete} />
</li>
);
})}
</ul>
);
}

View file

@ -3,118 +3,20 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { MixTagsInput } from "../../../components/MixTagsInput.jsx"; import { MixTagsInput } from "../../../components/MixTagsInput.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { CalculatorEntries, FormattedCalculatorNumber } from "./CalculatorEntries.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 }); BASE_TOKEN,
const BASE_TOKEN = "base"; calculateExpression,
createStoredFormula,
function calculateExpression(expression, baseValue = null) { evaluateCalculatorEntries,
const normalized = String(expression || "").replaceAll(",", ".").trim(); formatDisplayResult,
if (!normalized) return null; formatResult,
const withBase = normalized.replace(/\bbase\b/gi, () => Number.isFinite(baseValue) ? `(${formatResult(baseValue)})` : "NaN"); getDescendantIds,
if (!/^[\d\s+\-*/().]+$/.test(withBase)) return null; getEditableFormula,
getEntriesInTreeOrder,
try { getResultLengthClass
const value = Function(`"use strict"; return (${withBase})`)(); } from "./calculatorUtils.js";
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
function formatResult(value) {
if (!Number.isFinite(value)) return "";
return Number.parseFloat(value.toFixed(6)).toString();
}
function formatDisplayResult(value) {
if (!Number.isFinite(value)) return "";
return DISPLAY_NUMBER_FORMATTER.format(Number.parseFloat(value.toFixed(6)));
}
function getResultLengthClass(value) {
const length = formatResult(value).replace(/[^\d]/g, "").length;
if (length >= 13) return "is-compact";
if (length >= 9) return "is-long";
return "";
}
function getEntryResultLengthClass(value) {
const length = formatResult(value).replace(/[^\d]/g, "").length;
if (length >= 21) return "is-compact";
if (length >= 18) return "is-long";
return "";
}
function getChildren(entries, parentId) {
return entries.filter((entry) => (entry.parentId || "") === parentId);
}
function getDescendantIds(entries, parentId) {
const children = getChildren(entries, parentId);
return children.flatMap((child) => [child.id, ...getDescendantIds(entries, child.id)]);
}
function getEntriesInTreeOrder(entries, parentId = "") {
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
}
function getEntryFormula(entry) {
return String(entry?.formula || "").trim();
}
function getEditableFormula(entry) {
return getEntryFormula(entry) || formatResult(entry.value);
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function createStoredFormula(expression, parentValue) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
if (!Number.isFinite(parentValue)) return normalized;
const parentText = formatResult(parentValue);
if (/\bbase\b/i.test(normalized)) return normalized;
const withBase = normalized.replace(new RegExp(`(^|[^\\d.])${escapeRegExp(parentText)}(?=$|[^\\d.])`, "g"), `$1${BASE_TOKEN}`);
if (withBase !== normalized) return withBase;
if (/^[+\-*/]/.test(normalized)) return `${BASE_TOKEN}${normalized}`;
return normalized;
}
function evaluateCalculatorEntries(entries) {
const byParent = new Map();
entries.forEach((entry) => {
const parentId = entry.parentId || "";
byParent.set(parentId, [...(byParent.get(parentId) || []), entry]);
});
function visit(parentId = "", parentValue = null) {
return (byParent.get(parentId) || []).flatMap((entry) => {
const formula = getEntryFormula(entry);
const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value;
const nextEntry = { ...entry, value: calculatedValue ?? entry.value };
return [nextEntry, ...visit(entry.id, nextEntry.value)];
});
}
return visit();
}
function FormattedCalculatorNumber({ value }) {
if (!Number.isFinite(value)) return "-";
let integerGroupIndex = 0;
const normalized = Number.parseFloat(value.toFixed(6));
return DISPLAY_NUMBER_FORMATTER.formatToParts(normalized).map((part, index) => {
if (part.type === "integer") {
const className = `calculator-number-group ${integerGroupIndex % 2 ? "is-muted" : "is-strong"}`;
integerGroupIndex += 1;
return <span className={className} key={`${part.type}-${index}`}>{part.value}</span>;
}
if (part.type === "group") return <span className="calculator-number-separator" key={`${part.type}-${index}`}>{part.value}</span>;
return <span key={`${part.type}-${index}`}>{part.value}</span>;
});
}
function focusExpressionInputAtEnd(element) { function focusExpressionInputAtEnd(element) {
if (!element) return; if (!element) return;
@ -393,57 +295,3 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe
</div> </div>
); );
} }
function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) {
const children = getChildren(entries, parentId);
if (!children.length) return null;
return (
<ul className="tool-split-entry-list calculator-entry-list">
{children.map((entry) => {
const className = [
entry.id === activeEntryId ? "active" : "",
reorder.isItemDragging(entry.id) ? "is-dragging" : "",
reorder.isItemDropTarget(entry.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", entry.id, parentId) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li className={className} key={entry.id} {...reorder.getItemProps({ itemId: entry.id, parentId: getScopedParentId(parentId) })}>
<div className="tool-split-entry calculator-entry has-drag-handle">
<button
className="calculator-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, entry.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${entry.label || formatResult(entry.value)}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
<button className="tool-split-entry-summary" type="button" onClick={() => onEdit(entry)} title={textContent.editTitle || "Modifier"}>
<span>
<strong className={getEntryResultLengthClass(entry.value)} aria-label={formatDisplayResult(entry.value)}>
<FormattedCalculatorNumber value={entry.value} />
</strong>
{entry.label && <em>{entry.label}</em>}
</span>
</button>
<button
type="button"
onClick={() => onAddChild(entry)}
aria-label={`${textContent.addChildTitle || "Ajouter un calcul enfant"} ${entry.label || formatResult(entry.value)}`}
title={textContent.addChildTitle || "Ajouter un calcul enfant"}
>
<Icon name="add" />
</button>
<button type="button" className="danger" onClick={() => onDelete(entry.id)} aria-label={`${textContent.deleteTitle || "Supprimer"} ${entry.label}`} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<CalculatorEntries entries={entries} parentId={entry.id} getScopedParentId={getScopedParentId} activeEntryId={activeEntryId} textContent={textContent} reorder={reorder} onEdit={onEdit} onAddChild={onAddChild} onDelete={onDelete} />
</li>
);
})}
</ul>
);
}

View file

@ -0,0 +1,96 @@
// Rôle : regroupe les helpers purs du calculateur et de son arbre de résultats.
export const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 });
export const BASE_TOKEN = "base";
export function calculateExpression(expression, baseValue = null) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
if (!normalized) return null;
const withBase = normalized.replace(/\bbase\b/gi, () => Number.isFinite(baseValue) ? `(${formatResult(baseValue)})` : "NaN");
if (!/^[\d\s+\-*/().]+$/.test(withBase)) return null;
try {
const value = Function(`"use strict"; return (${withBase})`)();
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
export function formatResult(value) {
if (!Number.isFinite(value)) return "";
return Number.parseFloat(value.toFixed(6)).toString();
}
export function formatDisplayResult(value) {
if (!Number.isFinite(value)) return "";
return DISPLAY_NUMBER_FORMATTER.format(Number.parseFloat(value.toFixed(6)));
}
export function getResultLengthClass(value) {
const length = formatResult(value).replace(/[^\d]/g, "").length;
if (length >= 13) return "is-compact";
if (length >= 9) return "is-long";
return "";
}
export function getEntryResultLengthClass(value) {
const length = formatResult(value).replace(/[^\d]/g, "").length;
if (length >= 21) return "is-compact";
if (length >= 18) return "is-long";
return "";
}
export function getChildren(entries, parentId) {
return entries.filter((entry) => (entry.parentId || "") === parentId);
}
export function getDescendantIds(entries, parentId) {
const children = getChildren(entries, parentId);
return children.flatMap((child) => [child.id, ...getDescendantIds(entries, child.id)]);
}
export function getEntriesInTreeOrder(entries, parentId = "") {
return getChildren(entries, parentId).flatMap((entry) => [entry, ...getEntriesInTreeOrder(entries, entry.id)]);
}
export function getEntryFormula(entry) {
return String(entry?.formula || "").trim();
}
export function getEditableFormula(entry) {
return getEntryFormula(entry) || formatResult(entry.value);
}
export function createStoredFormula(expression, parentValue) {
const normalized = String(expression || "").replaceAll(",", ".").trim();
if (!Number.isFinite(parentValue)) return normalized;
const parentText = formatResult(parentValue);
if (/\bbase\b/i.test(normalized)) return normalized;
const withBase = normalized.replace(new RegExp(`(^|[^\\d.])${escapeRegExp(parentText)}(?=$|[^\\d.])`, "g"), `$1${BASE_TOKEN}`);
if (withBase !== normalized) return withBase;
if (/^[+\-*/]/.test(normalized)) return `${BASE_TOKEN}${normalized}`;
return normalized;
}
export function evaluateCalculatorEntries(entries) {
const byParent = new Map();
entries.forEach((entry) => {
const parentId = entry.parentId || "";
byParent.set(parentId, [...(byParent.get(parentId) || []), entry]);
});
function visit(parentId = "", parentValue = null) {
return (byParent.get(parentId) || []).flatMap((entry) => {
const formula = getEntryFormula(entry);
const calculatedValue = formula ? calculateExpression(formula, parentValue) : entry.value;
const nextEntry = { ...entry, value: calculatedValue ?? entry.value };
return [nextEntry, ...visit(entry.id, nextEntry.value)];
});
}
return visit();
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}