diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 71c8154..d4f374b 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -146,6 +146,9 @@ test("toolbox module registry and modules expose expected behavior", async () => const combosBundle = `${combosModule}\n${combosEditor}\n${combosParts}\n${combosList}`; 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 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 tableGrid = await readFile("website/src/features/toolboxes/modules/TableGrid.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, /canMoveGroup:/); assert.match(calculatorModule, /export function CalculatorModule/); - assert.match(calculatorModule, /calculateExpression/); + assert.match(calculatorUtils, /export function calculateExpression/); assert.match(calculatorModule, /activeParentId/); assert.match(calculatorModule, /parentId/); assert.match(calculatorModule, /MixTagsInput/); assert.match(calculatorModule, /useGroupedReorder/); - assert.match(calculatorModule, /calculator-drag-handle/); + assert.match(calculatorEntries, /calculator-drag-handle/); assert.match(calculatorModule, /scrollResults/); assert.match(calculatorModule, /tool-split-scroll-toggle/); 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, /editingEntryId/); assert.match(calculatorModule, /updateButton/); - assert.match(calculatorModule, /onAddChild/); - assert.match(calculatorModule, /entry\.label \|\| formatResult\(entry\.value\)/); + assert.match(calculatorEntries, /onAddChild/); + assert.match(calculatorEntries, /entry\.label \|\| formatResult\(entry\.value\)/); assert.match(calculatorModule, /ResizeObserver/); assert.match(calculatorModule, /--calculator-scroll-height/); - assert.match(calculatorModule, /CalculatorEntries/); + assert.match(calculatorBundle, /CalculatorEntries/); assert.match(tableModule, /export function TableModule/); assert.match(tableBundle, /Icon name="add-row"/); assert.match(tableBundle, /Icon name="add-column"/); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 3d02de2..0706068 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -6,6 +6,7 @@ import { exportModuleText, importModuleText, parseColonImportLines } from "../we import { buildInsertedMixTagValue, normalizeMixTagSpacing, normalizeMixTagText, removeMixTagAtOffset, tokenizeMixTagValue } from "../website/src/utils/mixTagsInputUtils.js"; import { getEffectiveGoalTreeProgressMode, getVisibleGoalTreeEdges } from "../website/src/features/toolboxes/modules/goalTreeDisplay.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 { 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"; @@ -154,6 +155,21 @@ test("text exchange imports table TSV and calculator hierarchy", () => { 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", () => { const base = new Date(2026, 0, 5, 8, 0, 0, 0).getTime(); const occurrences = [ diff --git a/website/src/features/toolboxes/modules/CalculatorEntries.jsx b/website/src/features/toolboxes/modules/CalculatorEntries.jsx new file mode 100644 index 0000000..0c139ea --- /dev/null +++ b/website/src/features/toolboxes/modules/CalculatorEntries.jsx @@ -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 {part.value}; + } + if (part.type === "group") return {part.value}; + return {part.value}; + }); +} + +export function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) { + const children = getChildren(entries, parentId); + if (!children.length) return null; + + return ( + + ); +} diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx index f910c16..3efa3a1 100644 --- a/website/src/features/toolboxes/modules/CalculatorModule.jsx +++ b/website/src/features/toolboxes/modules/CalculatorModule.jsx @@ -3,118 +3,20 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { MixTagsInput } from "../../../components/MixTagsInput.jsx"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; +import { CalculatorEntries, FormattedCalculatorNumber } from "./CalculatorEntries.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; - -const DISPLAY_NUMBER_FORMATTER = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 6 }); -const BASE_TOKEN = "base"; - -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; - } -} - -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 {part.value}; - } - if (part.type === "group") return {part.value}; - return {part.value}; - }); -} +import { + BASE_TOKEN, + calculateExpression, + createStoredFormula, + evaluateCalculatorEntries, + formatDisplayResult, + formatResult, + getDescendantIds, + getEditableFormula, + getEntriesInTreeOrder, + getResultLengthClass +} from "./calculatorUtils.js"; function focusExpressionInputAtEnd(element) { if (!element) return; @@ -393,57 +295,3 @@ export function CalculatorModule({ toolboxId, moduleId, context, textExchangeOpe ); } - -function CalculatorEntries({ entries, parentId, getScopedParentId, activeEntryId, textContent, reorder, onEdit, onAddChild, onDelete }) { - const children = getChildren(entries, parentId); - if (!children.length) return null; - - return ( - - ); -} diff --git a/website/src/features/toolboxes/modules/calculatorUtils.js b/website/src/features/toolboxes/modules/calculatorUtils.js new file mode 100644 index 0000000..a610dee --- /dev/null +++ b/website/src/features/toolboxes/modules/calculatorUtils.js @@ -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, "\\$&"); +}