// Rôle : teste les parseurs d'import texte utilisés par les outils toolbox. import { test } from "node:test"; import assert from "node:assert/strict"; import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.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 { 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"; import { formatUpdatedAt, isHtmlEmpty, queryBlockFormat, queryCommandState } from "../website/src/features/toolboxes/modules/notepadUtils.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeModuleOrder, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; function createTextImportContext() { let index = 0; return { uid: (prefix) => `${prefix}-${index += 1}`, normalizeNotepadData, normalizeChecklistData, normalizeLinksData, normalizeCountersData, normalizeCombosData, normalizeCalculatorData, normalizeTableData, normalizeTimerData, normalizeTaskPlannerData, normalizeGoalTreeData, normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeUrl }; } test("colon text import keeps urls intact after the first separator", () => { assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [ { label: "Build", value: "https://example.com/build?class=rogue" }, { label: "Potion", value: "10" } ]); }); test("colon text import accepts labels without value", () => { assert.deepEqual(parseColonImportLines("Simple item"), [ { label: "Simple item", value: "" } ]); }); test("mix tags input parses isolated token values only", () => { const tokens = [{ value: "base", label: "Valeur du parent" }, { value: "loot", label: "Butin" }]; assert.deepEqual(tokenizeMixTagValue("base * 3", tokens).map((segment) => segment.type), ["token", "text"]); assert.deepEqual(tokenizeMixTagValue("X - Base", tokens).map((segment) => segment.type), ["text", "token"]); assert.deepEqual(tokenizeMixTagValue("database", tokens).map((segment) => ({ type: segment.type, text: segment.text })), [{ type: "text", text: "database" }]); assert.deepEqual(tokenizeMixTagValue("base + loot", tokens).map((segment) => segment.type), ["token", "text", "token"]); assert.deepEqual(tokenizeMixTagValue("", tokens), []); assert.deepEqual(tokenizeMixTagValue("base * 2,5", tokens).map((segment) => segment.type), ["token", "text"]); assert.equal(normalizeMixTagText("X - Base", tokens), "X - base"); assert.equal(normalizeMixTagText("database", tokens), "database"); assert.equal(normalizeMixTagSpacing(" 1 base + 2 "), "1 base + 2"); }); test("mix tags input insertion preserves formula spacing when requested", () => { assert.deepEqual(buildInsertedMixTagValue("* 3", 0, 0, "base", { formulaSpacing: true }), { value: "base* 3", cursor: 4 }); assert.deepEqual(buildInsertedMixTagValue("2", 1, 1, "base", { formulaSpacing: true }), { value: "2 base", cursor: 6 }); assert.deepEqual(buildInsertedMixTagValue("2+3", 2, 2, "base", { formulaSpacing: true }), { value: "2+base 3", cursor: 7 }); assert.deepEqual(buildInsertedMixTagValue("2+old*3", 2, 5, "base", { formulaSpacing: true }), { value: "2+base*3", cursor: 6 }); assert.deepEqual(buildInsertedMixTagValue("route", 5, 5, "@item"), { value: "route@item", cursor: 10 }); }); test("mix tags input deletion removes the whole token", () => { const tokens = [{ value: "base", label: "Valeur du parent" }]; assert.deepEqual(removeMixTagAtOffset("base * 3", 4, 4, tokens, "backward"), { value: " * 3", cursor: 0 }); assert.deepEqual(removeMixTagAtOffset("2 + base", 4, 4, tokens, "forward"), { value: "2 + ", cursor: 4 }); assert.deepEqual(removeMixTagAtOffset("2 + base * 3", 6, 8, tokens, "backward"), { value: "2 + * 3", cursor: 4 }); assert.equal(removeMixTagAtOffset("2 + 3", 5, 5, tokens, "backward"), null); }); test("text exchange round trips simple toolbox modules and resets checklist progress", () => { const context = createTextImportContext(); const notepad = normalizeNotepadData({ html: '

Route

Boss

', drawingMode: "permanent", drawings: { strokes: [ { id: "old-stroke", color: "#22d3ee", width: 4, points: [{ x: 2, y: 3 }, { x: 8, y: 13 }] } ] } }); const notepadImport = importModuleText("notepad", exportModuleText("notepad", notepad), context); assert.equal(notepadImport.ok, true); assert.match(notepadImport.data.html, /

Route<\/h3>/); assert.match(notepadImport.data.html, /Boss<\/strong>/); assert.equal(notepadImport.data.drawingMode, "permanent"); assert.equal(notepadImport.data.drawings.strokes.length, 1); assert.notEqual(notepadImport.data.drawings.strokes[0].id, "old-stroke"); const checklist = normalizeChecklistData({ sections: [ { id: "s1", title: "Potions", items: [ { id: "i1", label: "Potion", qtyTarget: 10, qtyCurrent: 7 }, { id: "i2", label: "Mega potion", qtyTarget: 5, qtyCurrent: 5 } ] } ] }); const checklistImport = importModuleText("checklist", exportModuleText("checklist", checklist), context); assert.equal(checklistImport.ok, true); assert.deepEqual(checklistImport.data.sections[0].items.map((item) => [item.label, item.qtyTarget, item.qtyCurrent]), [ ["Potion", 10, 0], ["Mega potion", 5, 0] ]); const links = normalizeLinksData({ links: [{ id: "l1", title: "Build", url: "https://example.com/build" }] }); const linksImport = importModuleText("links", exportModuleText("links", links), context); assert.equal(linksImport.ok, true); assert.equal(linksImport.data.links[0].url, "https://example.com/build"); const counters = normalizeCountersData({ counters: [{ id: "c1", label: "Win", value: 12 }, { id: "c2", label: "Loss", value: -2 }] }); const countersImport = importModuleText("counters", exportModuleText("counters", counters), context); assert.equal(countersImport.ok, true); assert.deepEqual(countersImport.data.counters.map((counter) => [counter.label, counter.value]), [["Win", 12], ["Loss", -2]]); }); test("notepad helpers keep empty html and invalid command states stable", () => { assert.equal(isHtmlEmpty("


", ""), true); assert.equal(isHtmlEmpty("

 

", ""), true); assert.equal(isHtmlEmpty("

Hello

", "Hello"), false); assert.equal(formatUpdatedAt("not-a-date"), ""); assert.match(formatUpdatedAt("2026-08-14T10:30:00.000Z"), /\d/); assert.equal(queryCommandState("bold"), false); assert.equal(queryBlockFormat(), ""); }); test("text exchange imports table TSV and calculator hierarchy", () => { const context = createTextImportContext(); const tableImport = importModuleText("table", "@size: 2x2\n@columns: Item\tTotal\n@rows: One\tTwo\n@tsv\nPotion\t=A1\nMega\t5", context); assert.equal(tableImport.ok, true); assert.deepEqual(normalizeTableData(tableImport.data), { rows: 2, columns: 2, cells: { A1: "Potion", B1: "=A1", A2: "Mega", B2: "5" }, rowLabels: { 0: "One", 1: "Two" }, columnLabels: { 0: "Item", 1: "Total" } }); const pureTsvImport = importModuleText("table", "A\tB\n1\t2", context); assert.equal(pureTsvImport.ok, true); assert.equal(pureTsvImport.data.rows, 2); assert.equal(pureTsvImport.data.columns, 2); const calculator = normalizeCalculatorData({ scrollResults: true, entries: [ { id: "a", label: "Ore", value: 100, formula: "100" }, { id: "b", parentId: "a", label: "Shard", value: 500, formula: "base*5" } ] }); const calculatorImport = importModuleText("calculator", exportModuleText("calculator", calculator), context); assert.equal(calculatorImport.ok, true); assert.equal(calculatorImport.data.scrollResults, true); assert.equal(calculatorImport.data.entries[1].parentId, calculatorImport.data.entries[0].id); 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 = [ { id: "a", startMs: base, endMs: base + 60 * 60000, event: { startMinutes: 8 * 60, endMinutes: 9 * 60 } }, { id: "b", startMs: base + 30 * 60000, endMs: base + 90 * 60000, event: { startMinutes: 8 * 60 + 30, endMinutes: 9 * 60 + 30 } } ]; const layout = getWeekGridLayout(occurrences, true); assert.ok(layout.rows.some((row) => row.type === "hidden")); assert.equal(getWeekMinutePosition(layout, 8 * 60), layout.hourTopPx[8]); assert.equal(getWeekMinutePosition(layout, 24 * 60), layout.totalHeightPx); assert.deepEqual(layoutConcurrentOccurrences(occurrences).map((occurrence) => [occurrence.id, occurrence.lane, occurrence.laneCount]), [ ["a", 0, 2], ["b", 1, 2] ]); }); test("calendar draft helpers normalize selections and options", () => { const draft = createDefaultDraft(new Date(2026, 7, 14, 8, 17)); assert.equal(draft.dayIndex, 4); assert.equal(draft.startDate, "2026-08-14"); assert.equal(draft.startTime, "08:30"); assert.equal(draft.endTime, "09:00"); assert.deepEqual(getDraftSelection({ ...draft, startTime: "08:30", endTime: "10:00" }), { dayIndex: 4, startSlot: 17, endSlot: 19 }); assert.equal(getDraftSelection({ ...draft, dayIndex: 8 }), null); assert.deepEqual(normalizeDraftAlertOffsets([10, 5, 10, "60", 999]), [10, 5]); assert.equal(normalizeDraftMonthlyRepeatBy("weekday"), "weekday"); assert.equal(normalizeDraftMonthlyRepeatBy("invalid"), "monthDay"); assert.equal(getDateSpanDays("2026-08-14", "2026-08-17"), 3); assert.equal(getDateSpanDays("2026-08-17", "2026-08-14"), 0); }); test("calendar month layout splits bars on week boundaries and shares lanes", () => { const days = Array.from({ length: 14 }, (_, index) => ({ date: new Date(2026, 0, 5 + index), inMonth: true })); const firstStart = days[4].date.getTime(); const secondStart = days[5].date.getTime(); const segments = getMonthOccurrenceSegments(days, [ { id: "a", startMs: firstStart, endMs: days[9].date.getTime(), event: {} }, { id: "b", startMs: secondStart, endMs: days[7].date.getTime(), event: {} } ]); assert.deepEqual(segments.map((segment) => [segment.occurrence.id, segment.lane, segment.startIndex, segment.endIndex]), [ ["a", 0, 4, 6], ["a", 0, 7, 8], ["b", 1, 5, 6] ]); assert.equal(segments[0].laneCount, 2); }); test("goal tree text exchange imports formulas and hierarchy", () => { const context = createTextImportContext(); const imported = importModuleText("goalTree", "- Breuvage de rage x10\n - Élixir de destruction = base * 3\n - Poudre de flamme x5\n - Sève de cèdre x7\n - Catalyseur magique x10", context); assert.equal(imported.ok, true); assert.equal(imported.data.nodes.length, 5); assert.equal(imported.data.nodes[0].kind, "step"); assert.equal(imported.data.nodes[1].parentId, imported.data.nodes[0].id); assert.equal(imported.data.nodes[1].formula, "base * 3"); assert.equal(imported.data.nodes[2].kind, "step"); assert.equal(imported.data.nodes[2].qtyCurrent, 0); const exported = exportModuleText("goalTree", imported.data); assert.match(exported, /Breuvage de rage x10/); assert.match(exported, /Élixir de destruction = base \* 3/); }); test("text exchange round trips combos, tasks, equipment, images and timers", () => { const context = createTextImportContext(); const combos = normalizeCombosData({ device: "xbox", combos: [{ id: "o1", name: "Anti air", category: "Neutral", device: "xbox", inputs: [[{ kind: "direction", value: "down" }], [{ kind: "button", value: "a", holdMs: 2000 }]] }] }); const combosImport = importModuleText("combos", exportModuleText("combos", combos), context); assert.equal(combosImport.ok, true); assert.equal(combosImport.data.combos[0].inputs[1][0].holdMs, 2000); const tasks = normalizeTaskPlannerData({ resetTime: "06:00", tasks: [ { id: "a", title: "Root", type: "daily", category: "Raid", checked: true, checkedAt: 99 }, { id: "b", title: "Child", type: "unique", checked: true, checkedAt: 99 } ], relations: [{ id: "r", fromTaskId: "b", toTaskId: "a", prerequisite: true }] }); const tasksImport = importModuleText("taskPlanner", exportModuleText("taskPlanner", tasks), context); assert.equal(tasksImport.ok, true); assert.deepEqual(tasksImport.data.tasks.map((task) => [task.title, task.checked, task.checkedAt]), [["Root", false, 0], ["Child", false, 0]]); assert.equal(tasksImport.data.relations[0].prerequisite, true); const equipment = normalizeEquipmentPlannerData({ types: [{ id: "t", title: "Weapons", icon: "sword" }], equipments: [{ id: "e", typeId: "t", name: "Blade", icon: "sword", characteristics: [{ id: "v", category: "Stats", icon: "sword", name: "Attack", value: 12 }], socketItems: [ { id: "j", name: "Jewel", shape: "jewel", color: "yellow", bonuses: [{ id: "b", category: "Skill", name: "Focus", value: 1 }] }, { id: "k", name: "Materia", shape: "ball", color: "blue", bonuses: [] } ], socketLinks: [{ id: "u", fromSocketItemId: "j", toSocketItemId: "k" }], materials: [{ id: "m", name: "Ore", qty: 4 }] }] }); const equipmentImport = importModuleText("equipmentPlanner", exportModuleText("equipmentPlanner", equipment), context); assert.equal(equipmentImport.ok, true); assert.equal(equipmentImport.data.equipments[0].socketItems[0].bonuses[0].name, "Focus"); assert.equal(equipmentImport.data.equipments[0].socketLinks.length, 1); const imageDataUrl = "data:image/png;base64,QUJD"; const imagesImport = importModuleText("images", `# Map\n${imageDataUrl}`, context); assert.equal(imagesImport.ok, true); assert.equal(imagesImport.data.images[0].dataUrl, imageDataUrl); const annotationImport = importModuleText("imageAnnotation", `${imageDataUrl}\n@marker: 42.5,68,Entry\n@drawings: {"strokes":[]}`, context); assert.equal(annotationImport.ok, true); assert.equal(annotationImport.data.markers[0].label, "Entry"); const timer = normalizeTimerData({ countdowns: [{ id: "z", label: "Boss", type: "duration", durationMs: 300000, targetAt: Date.now() + 300000 }] }); const timerImport = importModuleText("timer", exportModuleText("timer", timer), context); assert.equal(timerImport.ok, true); assert.equal(timerImport.data.countdowns[0].durationMs, 300000); assert.ok(timerImport.data.countdowns[0].targetAt > Date.now()); }); test("text exchange rejects invalid imports without data", () => { const context = createTextImportContext(); const invalidLink = importModuleText("links", "Not a url", context); const invalidImage = importModuleText("images", "data:text/plain;base64,QUJD", context); const invalidMarker = importModuleText("imageAnnotation", "data:image/png;base64,QUJD\n@marker: 120,0,Bad", context); assert.equal(invalidLink.ok, false); assert.match(invalidLink.error, /^Ligne 1:/); assert.equal(invalidImage.ok, false); assert.match(invalidImage.error, /^Ligne 1:/); assert.equal(invalidMarker.ok, false); assert.match(invalidMarker.error, /^Ligne 2:/); assert.equal(importModuleText("notepad", "@notepad\n@html\n

Note

", context).ok, false); assert.equal(importModuleText("notepad", "@notepad\n@drawings: {bad}", context).ok, false); assert.equal(importModuleText("table", "\n", context).ok, false); }); test("time pattern recurrence uses configured frequency instead of next remaining delay", () => { const now = new Date(2026, 0, 1, 13, 23, 45, 0).getTime(); const target = getTimePatternTargetMs("X:24:X", now); assert.equal(new Date(target).getHours(), 13); assert.equal(new Date(target).getMinutes(), 24); assert.equal(new Date(target).getSeconds(), 0); assert.equal(getTimePatternRecurrenceMs("X:24:X"), 60 * 60 * 1000); assert.equal(getTimePatternRecurrenceMs("X:X:30"), 60 * 1000); }); test("table formulas evaluate arithmetic, parentheses and cell references", () => { const cells = { A1: "100", A2: "20", B1: "=A1+A2*2", B2: "=(A1+A2)/2", C1: "=B1-B2/3" }; const dimensions = { rows: 10, columns: 6 }; assert.equal(evaluateTableCell("B1", cells, dimensions).display, "140"); assert.equal(evaluateTableCell("B2", cells, dimensions).display, "60"); assert.equal(evaluateTableCell("C1", cells, dimensions).display, "120"); }); test("table formulas report invalid references, division by zero and circular references", () => { const dimensions = { rows: 10, columns: 6 }; assert.equal(evaluateTableCell("A1", { A1: "=Z99+1" }, dimensions).error, "reference"); assert.equal(evaluateTableCell("A1", { A1: "=10/0" }, dimensions).error, "division"); assert.equal(evaluateTableCell("A1", { A1: "=B1", B1: "=A1" }, dimensions).error, "cycle"); assert.deepEqual(evaluateTableCell("A1", { A1: "texte", B1: "=A1+1" }, dimensions), { value: null, display: "texte", error: "" }); assert.equal(evaluateTableCell("B1", { A1: "texte", B1: "=A1+1" }, dimensions).error, "reference"); }); test("table storage clamps dimensions and compacts non-empty cells", () => { const normalized = normalizeTableData({ rows: 200, columns: 40, cells: { A1: "Boss ", T50: "=A1+1", U51: "Ignored", B1: " " }, rowLabels: { 0: "Boss final ", 1: "2", 50: "Ignored" }, columnLabels: { 0: "Item final ", 1: "B", 20: "Ignored" } }); assert.equal(normalized.rows, 50); assert.equal(normalized.columns, 20); assert.deepEqual(normalized.cells, { A1: "Boss ", T50: "=A1+1" }); assert.deepEqual(normalized.rowLabels, { 0: "Boss final " }); assert.deepEqual(normalized.columnLabels, { 0: "Item final " }); const compact = compactModuleDataForStorage("table", normalized); assert.equal(compact.rows, 50); assert.equal(compact.columns, 20); assert.deepEqual(compact.cells, { A1: "Boss ", T50: "=A1+1" }); assert.deepEqual(compact.rowLabels, { 0: "Boss final " }); assert.deepEqual(compact.columnLabels, { 0: "Item final " }); assert.deepEqual(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {}, columnLabels: { 0: "Item " } }), { cells: {}, columnLabels: { 0: "Item " } }); assert.equal(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {} }), null); }); test("equipment planner storage normalizes sockets, materials and links", () => { const normalized = normalizeEquipmentPlannerData({ types: [ { id: "type1", title: "Armes" }, { id: "empty", title: "Vide" } ], equipments: [ { id: "eq1", typeId: "type1", name: "Épée", active: false, characteristics: [ { id: "trait1", category: "Stats", name: "Force", value: "12" }, { id: "ignored", category: "Stats", name: "", value: 4 } ], materials: [ { id: "mat1", name: "Minerai", qty: 0 } ], socketItems: [ { id: "socket1", name: "Materia feu", shape: "triangle", color: "invalid", bonuses: [ { id: "bonus1", category: "Stats", name: "Force", value: "3" }, { id: "bonus2", category: "Compétences", name: "", value: 1 } ] }, { id: "socket2", name: "Materia soin", shape: "ball", color: "cyan", bonuses: [ { id: "bonus3", category: "Sorts", name: "Soin", value: "" } ] } ], socketLinks: [ { id: "link1", fromSocketItemId: "socket1", toSocketItemId: "socket2" }, { id: "duplicate", fromSocketItemId: "socket2", toSocketItemId: "socket1" }, { id: "invalid", fromSocketItemId: "socket1", toSocketItemId: "missing" } ], categoryOrder: ["Compétences", "Stats", "Missing"], collapsedCategories: ["Stats", "Missing"] } ], typeOrder: ["empty", "type1"] }); assert.deepEqual(normalized.types.map((type) => type.id), ["type1"]); assert.equal(normalized.equipments[0].active, false); assert.equal(normalized.equipments[0].materials[0].qty, 1); assert.equal(normalized.equipments[0].characteristics.length, 1); assert.equal(normalized.equipments[0].socketItems[0].shape, "jewel"); assert.equal(normalized.equipments[0].socketItems[0].color, "yellow"); assert.equal(normalized.equipments[0].socketItems[0].bonuses.length, 1); assert.equal(normalized.equipments[0].socketLinks.length, 1); assert.deepEqual(normalized.equipments[0].categoryOrder, ["Stats", "Sorts"]); assert.deepEqual(normalized.equipments[0].collapsedCategories, ["Stats"]); const compact = compactModuleDataForStorage("equipmentPlanner", normalized); assert.equal(compact.equipments[0].active, false); assert.equal(compact.equipments[0].socketItems[0].shape, "jewel"); assert.equal(compact.equipments[0].socketLinks.length, 1); assert.equal(compactModuleDataForStorage("equipmentPlanner", { types: [{ id: "empty", title: "Vide" }], equipments: [] }), null); }); test("equipment planner summary only uses active equipments", () => { const summary = summarizeEquipmentPlannerData({ types: [{ id: "type1", title: "Build" }], equipments: [ { id: "eq1", typeId: "type1", name: "Casque", active: true, characteristics: [{ id: "trait1", category: "Stats", name: "Force", value: 10 }], socketItems: [{ id: "socket1", name: "Joyau attaque", shape: "ball", color: "yellow", bonuses: [ { id: "bonus1", category: "Stats", name: "Force", value: 2 }, { id: "bonus2", category: "Affixes", name: "Brutalité", value: "" } ] }] }, { id: "eq2", typeId: "type1", name: "Armure", active: false, characteristics: [{ id: "trait2", category: "Stats", name: "Force", value: 99 }] } ] }); assert.equal(summary.activeEquipmentCount, 1); assert.deepEqual(summary.totals, [{ name: "Force", icon: "sword", sourceKind: "mixed", contributions: [ { id: "trait1", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Casque", sourceName: "Casque", value: 10 }, { id: "bonus1", sourceKind: "socket", equipmentIcon: "shield", equipmentName: "Casque", sourceName: "Casque · Joyau attaque", value: 2 } ], value: 12 }]); assert.deepEqual(summary.equipmentTotals, []); assert.deepEqual(summary.socketTotals, []); assert.deepEqual(summary.mixedTotals, summary.totals); assert.equal(summary.textBonuses.length, 1); assert.equal(summary.textBonuses[0].name, "Brutalité"); }); test("equipment planner summary prioritizes direct stat icons over socket icons", () => { const summary = summarizeEquipmentPlannerData({ types: [{ id: "type1", title: "Build" }], equipments: [ { id: "eq1", typeId: "type1", name: "Épée", characteristics: [{ id: "trait1", category: "Stats", icon: "shield", name: "Attaque", value: 100 }], socketItems: [{ id: "socket1", name: "Joyau rouge", shape: "jewel", color: "red", bonuses: [ { id: "bonus1", category: "Stats", name: "Attaque", value: 10 }, { id: "bonus2", category: "Compétences", name: "Attaque de feu", value: 1 } ] }] }, { id: "eq2", typeId: "type1", name: "Fusil", characteristics: [ { id: "trait2", category: "Stats", icon: "sword", name: "Défense", value: 10 }, { id: "trait3", category: "Stats", icon: "sword", name: "Défense", value: 20 }, { id: "trait4", category: "Stats", icon: "shield", name: "Défense", value: 30 } ] } ] }); assert.deepEqual(summary.totals.find((total) => total.name === "Attaque"), { name: "Attaque", icon: "shield", sourceKind: "mixed", contributions: [ { id: "trait1", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Épée", sourceName: "Épée", value: 100 }, { id: "bonus1", sourceKind: "socket", equipmentIcon: "shield", equipmentName: "Épée", sourceName: "Épée · Joyau rouge", value: 10 } ], value: 110 }); assert.deepEqual(summary.totals.find((total) => total.name === "Attaque de feu"), { name: "Attaque de feu", icon: "jewel", color: "red", sourceKind: "socket", contributions: [{ id: "bonus2", sourceKind: "socket", equipmentIcon: "shield", equipmentName: "Épée", sourceName: "Épée · Joyau rouge", value: 1 }], value: 1 }); assert.deepEqual(summary.totals.find((total) => total.name === "Défense"), { name: "Défense", icon: "sword", sourceKind: "direct", contributions: [ { id: "trait2", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Fusil", sourceName: "Fusil", value: 10 }, { id: "trait3", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Fusil", sourceName: "Fusil", value: 20 }, { id: "trait4", sourceKind: "direct", equipmentIcon: "shield", equipmentName: "Fusil", sourceName: "Fusil", value: 30 } ], value: 60 }); assert.deepEqual(summary.equipmentTotals.map((total) => total.name), ["Défense"]); assert.deepEqual(summary.socketTotals.map((total) => total.name), ["Attaque de feu"]); assert.deepEqual(summary.mixedTotals.map((total) => total.name), ["Attaque"]); }); test("equipment planner export remaps nested ids and socket links", () => { const payload = createToolboxExportPayload( { id: "toolbox1", name: "Build", modules: [{ id: "module1", type: "equipmentPlanner" }] }, { "toolbox1:module1": { types: [{ id: "type1", title: "Build" }], equipments: [{ id: "eq1", typeId: "type1", name: "Casque", characteristics: [{ id: "trait1", name: "Force", value: 1 }], materials: [{ id: "mat1", name: "Minerai", qty: 2 }], socketItems: [ { id: "socket1", name: "Joyau attaque", shape: "ball", color: "yellow", bonuses: [{ id: "bonus1", name: "Attaque", value: 1 }] }, { id: "socket2", name: "Joyau expert", shape: "jewel", color: "cyan", bonuses: [] } ], socketLinks: [{ id: "link1", fromSocketItemId: "socket1", toSocketItemId: "socket2" }] }], typeOrder: ["type1"] } } ); const moduleId = payload.toolbox.modules[0].id; const exported = payload.modules[moduleId]; assert.notEqual(exported.types[0].id, "type1"); assert.equal(exported.equipments[0].typeId, exported.types[0].id); assert.notEqual(exported.equipments[0].socketItems[0].id, "socket1"); assert.equal(exported.equipments[0].socketLinks[0].fromSocketItemId, exported.equipments[0].socketItems[0].id); assert.equal(exported.equipments[0].socketLinks[0].toSocketItemId, exported.equipments[0].socketItems[1].id); }); test("toolbox module order normalizes one and two column layouts", () => { const modules = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; assert.deepEqual(normalizeModuleOrder(null, modules), { one: ["a", "b", "c", "d"], two: [["a", "b"], ["c", "d"]] }); assert.deepEqual(normalizeModuleOrder({ one: ["c", "x", "c"], two: [["d", "d", "x"], ["b"]] }, modules), { one: ["c", "a", "b", "d"], two: [["d", "a"], ["b", "c"]] }); assert.deepEqual(normalizeModuleOrder({ one: ["a", "b", "c", "d"], two: [["a", "b", "c", "d"], []] }, modules).two, [["a", "b", "c", "d"], []]); }); test("toolbox export remaps module order ids", () => { const payload = createToolboxExportPayload( { id: "toolbox1", name: "Order", modules: [{ id: "module1", type: "notepad" }, { id: "module2", type: "links" }, { id: "module3", type: "counters" }], moduleOrder: { one: ["module3", "module1", "module2"], two: [["module2"], ["module3", "module1"]] } }, {} ); assert.deepEqual(payload.toolbox.modules.map((module) => module.id), ["m1", "m2", "m3"]); assert.deepEqual(payload.toolbox.moduleOrder, { one: ["m3", "m1", "m2"], two: [["m2"], ["m3", "m1"]] }); assert.deepEqual(normalizeToolbox(payload.toolbox).moduleOrder, payload.toolbox.moduleOrder); }); test("grouped reorder helpers group flat items and complete group order", () => { const items = [ { id: "a", category: "" }, { id: "b", category: "Boss" }, { id: "c", category: "Farm" }, { id: "d", category: "Boss" } ]; assert.deepEqual(completeGroupOrder(["Farm"], ["Boss", "Farm"]), ["Farm", "Boss"]); assert.deepEqual(getGroupedEntries(items, { groupOrder: ["Farm"], getItemGroup: (item) => item.category, groupIdKey: "category", groupItemKey: "items" }), [ { type: "item", item: items[0] }, { type: "group", group: { category: "Boss", items: [items[1], items[3]] } }, { type: "group", group: { category: "Farm", items: [items[2]] } } ]); }); test("grouped reorder helpers move items and grouped item blocks", () => { const items = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; assert.deepEqual(moveItem(items, "a", "c", "after").map((item) => item.id), ["b", "c", "a", "d"]); assert.deepEqual(moveItemGroup(items, ["b", "c"], "a", "before").map((item) => item.id), ["b", "c", "a", "d"]); assert.equal(getBoundaryItemId(items, ["b", "c"], "before"), "b"); assert.equal(getBoundaryItemId(items, ["b", "c"], "after"), "c"); }); test("grouped reorder helpers reorder categories at targets and edges", () => { const groups = ["Neutral", "Advanced", "Punish"]; assert.deepEqual(moveGroupOrder(["Neutral", "Advanced", "Punish"], groups, "Punish", "Neutral", "before"), ["Punish", "Neutral", "Advanced"]); assert.deepEqual(moveGroupOrderToStart(["Neutral", "Advanced", "Punish"], groups, "Punish"), ["Punish", "Neutral", "Advanced"]); assert.deepEqual(moveGroupOrderToEnd(["Neutral", "Advanced", "Punish"], groups, "Neutral"), ["Advanced", "Punish", "Neutral"]); }); test("grouped reorder operation moves items in and out of categories", () => { const data = { categoryOrder: ["Boss"], collapsedCategories: ["Farm"], items: [ { id: "a" }, { id: "b", category: "Boss" }, { id: "c", category: "Boss" }, { id: "d", category: "Farm" } ] }; const config = { itemsKey: "items", groupOrderKey: "categoryOrder", collapsedGroupsKey: "collapsedCategories", getItemGroup: (item) => item.category || "", setItemGroup: (item, category) => { const nextItem = { ...item }; if (category) nextItem.category = category; else delete nextItem.category; return nextItem; } }; const movedIn = applyGroupedReorderOperation(data, { ...config, operation: { type: "item", sourceId: "a", targetId: "Farm", targetType: "group", placement: "before", sourceGroup: "", targetGroup: "Farm", sourceParentId: "", targetParentId: "" } }); assert.deepEqual(movedIn.items.map((item) => `${item.id}:${item.category || ""}`), ["b:Boss", "c:Boss", "a:Farm", "d:Farm"]); assert.deepEqual(movedIn.collapsedCategories, []); const movedOut = applyGroupedReorderOperation(movedIn, { ...config, operation: { type: "item", sourceId: "a", targetId: "Boss", targetType: "boundary", placement: "after", sourceGroup: "Farm", targetGroup: "Boss", sourceParentId: "", targetParentId: "" } }); assert.deepEqual(movedOut.items.map((item) => `${item.id}:${item.category || ""}`), ["b:Boss", "c:Boss", "a:", "d:Farm"]); }); test("grouped reorder operation moves whole categories", () => { const data = { categoryOrder: ["Boss", "Farm"], items: [ { id: "a", category: "Boss" }, { id: "b", category: "Boss" }, { id: "c", category: "Farm" }, { id: "d", category: "Farm" }, { id: "e" } ] }; const moved = applyGroupedReorderOperation(data, { operation: { type: "group", sourceId: "Boss", targetId: "Farm", targetType: "group", placement: "after", sourceGroup: "Boss", targetGroup: "Farm", sourceParentId: "", targetParentId: "" }, groupOrderKey: "categoryOrder", getItemGroup: (item) => item.category || "", setItemGroup: (item) => item }); assert.deepEqual(moved.items.map((item) => item.id), ["c", "d", "a", "b", "e"]); assert.deepEqual(moved.categoryOrder, ["Farm", "Boss"]); }); test("checklist data drops empty titled sections", () => { const normalized = normalizeChecklistData({ sections: [ { id: "empty", title: "Empty", items: [] }, { id: "filled", title: "Filled", items: [{ id: "item1", label: "Potion" }] } ] }); assert.deepEqual(normalized.sections.map((section) => section.id), ["filled"]); const compact = compactModuleDataForStorage("checklist", { sections: [ { id: "empty", title: "Empty", items: [] }, { id: "filled", title: "Filled", items: [{ id: "item1", label: "Potion" }] } ] }); assert.deepEqual(compact.sections.map((section) => section.id), ["filled"]); }); test("checklist helpers merge categories and move items deterministically", () => { const context = { uid: (prefix) => `${prefix}-new` }; const sections = [ { id: "s1", title: "Alpha", items: [{ id: "i1", label: "One" }, { id: "i2", label: "Two" }] }, { id: "s2", title: "Beta", items: [{ id: "i3", label: "Three" }] } ]; assert.deepEqual(appendChecklistItems(sections, "alpha", [{ id: "i4", label: "Four" }], context)[0].items.map((item) => item.id), ["i1", "i2", "i4"]); assert.deepEqual(renameChecklistSection(sections, "s1", "Beta").map((section) => [section.title, section.items.map((item) => item.id)]), [ ["Beta", ["i3", "i1", "i2"]] ]); assert.deepEqual(moveChecklistItemToCategory(sections, "i2", "Gamma", context).map((section) => [section.title, section.items.map((item) => item.id)]), [ ["Alpha", ["i1"]], ["Beta", ["i3"]], ["Gamma", ["i2"]] ]); assert.deepEqual(moveChecklistItem(sections, { sourceId: "i1", targetType: "item", targetId: "i3", placement: "after" }, context).map((section) => [section.title, section.items.map((item) => item.id)]), [ ["Alpha", ["i2"]], ["Beta", ["i3", "i1"]] ]); }); test("combos storage normalizes devices, steps and compact export ids", () => { const longText = "x".repeat(120); const normalized = normalizeCombosData({ device: "invalid", categoryOrder: [`${longText} `, "Missing", `${longText} `], collapsedCategories: [`${longText} `, "", `${longText} `], combos: [ { id: "rootCombo", name: "Root", device: "switch", inputs: [[{ kind: "button", value: "triangle", hold: true }]] }, { id: "combo1", device: "n64", category: `${longText} `, name: `${longText} `, inputs: [ [ { kind: "direction", value: "down" }, { kind: "button", value: "cross", hold: false, holdMs: 2000.4 }, { kind: "button", value: "circle", holdMs: 100 }, { kind: "bad", value: "ignored" } ], [], [{ kind: "key", value: "space" }] ] }, { id: "empty", name: " ", inputs: [] } ] }); assert.equal(normalized.device, "playstation"); assert.equal(normalized.combos.length, 2); assert.equal(normalized.combos[0].device, "switch"); assert.equal(normalized.combos[0].inputs[0][0].hold, true); assert.equal(normalized.combos[1].category.length, 80); assert.equal(normalized.combos[1].name.length, 80); assert.equal("note" in normalized.combos[1], false); assert.deepEqual(normalized.categoryOrder, [longText.slice(0, 80)]); assert.deepEqual(normalized.collapsedCategories, [longText.slice(0, 80)]); assert.deepEqual(normalized.combos[1].inputs, [ [ { kind: "direction", value: "down" }, { kind: "button", value: "cross", hold: true, holdMs: 2000 }, { kind: "button", value: "circle" } ], [{ kind: "key", value: "space" }] ]); const compact = compactModuleDataForStorage("combos", { ...normalized, device: "xbox" }); assert.equal(compact.device, "xbox"); assert.deepEqual(compact.categoryOrder, [longText.slice(0, 80)]); assert.deepEqual(compact.collapsedCategories, [longText.slice(0, 80)]); assert.equal(compact.combos[0].id, "rootCombo"); assert.equal(compact.combos[0].device, "switch"); assert.equal(compact.combos[0].inputs[0][0].hold, true); assert.equal(compact.combos[1].device, "n64"); assert.equal(compact.combos[1].id, "combo1"); assert.equal(compact.combos[1].category.length, 80); assert.equal(compact.combos[1].inputs[0][1].holdMs, 2000); assert.equal(compact.combos[1].inputs[0][1].hold, true); const exported = createToolboxExportPayload( { id: "toolbox1", name: "Combos", modules: [{ id: "module1", type: "combos" }], updatedAt: "2026-01-01T00:00:00.000Z" }, { "toolbox1:module1": compact } ); assert.equal(exported.modules.m1.combos[0].id, "o1"); assert.equal(exported.modules.m1.combos[0].device, "switch"); assert.deepEqual(exported.modules.m1.categoryOrder, [longText.slice(0, 80)]); assert.deepEqual(exported.modules.m1.collapsedCategories, [longText.slice(0, 80)]); assert.equal(exported.modules.m1.combos[1].id, "o2"); assert.equal(exported.modules.m1.combos[1].device, "n64"); }); test("notepad data migrates text, sanitizes html and compacts drawings", () => { const migrated = normalizeNotepadData({ text: "Boss\nPhase 2" }); assert.equal(migrated.text, "Boss\nPhase 2"); assert.match(migrated.html, /

Boss<\/p>

Phase 2<\/p>/); const normalized = normalizeNotepadData({ html: '

Titre

Important', updatedAt: "2026-07-29T12:00:00.000Z", drawingMode: "permanent", drawings: { strokes: [ { id: "stroke1", color: "#f6c453", width: 40, points: [{ x: 1.123 }, { x: 4, y: 8 }, { x: 12.345, y: 16.789 }] } ] } }); assert.match(normalized.html, /

Titre<\/h3>/); assert.match(normalized.html, /Important<\/span>/); assert.doesNotMatch(normalized.html, /script|onclick|position|img/); assert.equal(normalized.updatedAt, "2026-07-29T12:00:00.000Z"); assert.equal(normalized.drawings.strokes[0].width, 24); assert.deepEqual(normalized.drawings.strokes[0].points, [{ x: 4, y: 8 }, { x: 12.35, y: 16.79 }]); const escaped = normalizeNotepadData({ html: "

"&amp;quot; & test

" }); assert.equal(escaped.text, "\"\" & test"); assert.equal(escaped.html, "

"" & test

"); const compactTemporary = compactModuleDataForStorage("notepad", { html: "

Note

", text: "Note", drawingMode: "temporary", drawings: { strokes: normalized.drawings.strokes } }); assert.equal(compactTemporary.drawings, undefined); assert.equal(compactTemporary.drawingMode, undefined); const compactPermanent = compactModuleDataForStorage("notepad", { html: "

Note

", text: "Note", updatedAt: "not a date", drawingMode: "permanent", drawings: normalized.drawings }); assert.equal(compactPermanent.updatedAt, undefined); assert.equal(compactPermanent.drawingMode, "permanent"); assert.equal(compactPermanent.drawings.strokes.length, 1); }); test("image annotation storage keeps drawing strokes as percent coordinates", () => { const compact = compactModuleDataForStorage("imageAnnotation", { image: "data:image/png;base64,aaa", markers: [{ id: "marker1", x: 120, y: 50, label: "Boss" }], drawings: { strokes: [ { id: "stroke1", color: "#22d3ee", width: 4, points: [{ x: 10.123, y: 20.456 }, { x: 140, y: -4 }] } ] } }); assert.equal(compact.markers[0].x, 100); assert.deepEqual(compact.drawings.strokes[0].points, [{ x: 10.12, y: 20.46 }, { x: 100, y: 0 }]); const exported = createToolboxExportPayload( { id: "toolbox1", name: "Map", modules: [{ id: "module1", type: "imageAnnotation" }], updatedAt: "2026-01-01T00:00:00.000Z" }, { "toolbox1:module1": compact } ); assert.equal(exported.modules.m1.markers[0].id, "k1"); assert.equal(exported.modules.m1.drawings.strokes[0].id, "d1"); }); test("task planner data normalizes invalid settings and relations", () => { const normalized = normalizeTaskPlannerData({ weeklyResetDay: 9, resetTime: "27:80", hideCompleted: true, categoryOrder: ["Raid", "Missing", "Farm", "Raid"], collapsedCategories: ["Raid", "", "Raid"], tasks: [ { id: "task1", title: "Daily", type: "daily", checked: true, checkedAt: 1000, category: "Farm", dailyResetTime: "06:30" }, { id: "task2", title: "Weekly", type: "weekly", weeklyResetDay: 4, category: "Raid" }, { id: "task3", title: "Invalid type", type: "later", category: "Farm", dailyResetTime: "25:00" } ], relations: [ { id: "link1", fromTaskId: "task1", toTaskId: "task2" }, { id: "dep1", fromTaskId: "task3", toTaskId: "task2", prerequisite: true }, { id: "bad", fromTaskId: "task1", toTaskId: "missing" }, { id: "self", fromTaskId: "task2", toTaskId: "task2" } ] }); assert.equal(normalized.weeklyResetDay, 1); assert.equal(normalized.resetTime, "00:00"); assert.equal(normalized.hideCompleted, true); assert.deepEqual(normalized.categoryOrder, ["Raid"]); assert.deepEqual(normalized.collapsedCategories, ["Raid"]); assert.equal(normalized.tasks[0].category, undefined); assert.equal(normalized.tasks[0].dailyResetTime, "06:30"); assert.equal(normalized.tasks[1].weeklyResetDay, 4); assert.equal(normalized.tasks[1].category, "Raid"); assert.equal(normalized.tasks[2].type, "unique"); assert.equal(normalized.tasks[2].dailyResetTime, undefined); assert.deepEqual(normalized.relations.map((relation) => relation.id), ["link1", "dep1"]); assert.equal(normalized.relations[0].prerequisite, false); assert.equal(normalized.relations[1].prerequisite, true); }); test("task planner storage compacts defaults and export remaps task relations", () => { const compact = compactModuleDataForStorage("taskPlanner", { weeklyResetDay: 1, resetTime: "00:00", hideCompleted: true, collapsedCategories: ["Raid"], tasks: [ { id: "task1", title: "Daily", description: "", type: "daily", checked: false, dailyResetTime: "06:30" }, { id: "task2", title: "Weekly", description: "Run", type: "weekly", checked: true, checkedAt: 2000, weeklyResetDay: 5, category: "Raid" } ], categoryOrder: ["Raid"], relations: [ { id: "dep1", fromTaskId: "task1", toTaskId: "task2", prerequisite: true } ] }); assert.equal(compact.weeklyResetDay, undefined); assert.equal(compact.resetTime, undefined); assert.equal(compact.hideCompleted, true); assert.deepEqual(compact.collapsedCategories, ["Raid"]); assert.deepEqual(compact.categoryOrder, ["Raid"]); assert.deepEqual(compact.tasks[0], { id: "task1", title: "Daily", type: "daily", dailyResetTime: "06:30" }); assert.equal(compact.tasks[1].category, "Raid"); assert.equal(compact.tasks[1].weeklyResetDay, 5); const exported = createToolboxExportPayload( { id: "toolbox1", name: "Planner", modules: [{ id: "module1", type: "taskPlanner" }], updatedAt: "2026-01-01T00:00:00.000Z" }, { "toolbox1:module1": compact } ); const exportedPlanner = exported.modules.m1; assert.deepEqual(exportedPlanner.tasks.map((task) => task.id), ["a1", "a2"]); assert.equal(exportedPlanner.relations[0].fromTaskId, "a1"); assert.equal(exportedPlanner.relations[0].toTaskId, "a2"); assert.equal(exportedPlanner.relations[0].prerequisite, true); }); test("goal tree data normalizes, compacts, calculates and remaps nodes", () => { assert.equal(calculateGoalTreeFormula("base * 3", 10), 30); assert.equal(calculateGoalTreeFormula("base / 2", 10), 5); assert.equal(calculateGoalTreeFormula("base - 12", 10, { clamp: false }), -2); assert.equal(calculateGoalTreeFormula("base,5", 10), null); assert.equal(calculateGoalTreeFormula("alert(1)", 10), null); const normalized = normalizeGoalTreeData({ orientation: "vertical", nodes: [ { id: "root", title: "Root", kind: "step", icon: "fire", color: "cyan", qtyTarget: 10, qtyCurrent: 1 }, { id: "child", parentId: "root", title: "Child", formula: "base*3", qtyCurrent: 15, resetType: "daily", resetTime: "09:30", checkedAt: 99 }, { id: "bad", parentId: "missing", title: "Bad", kind: "weird", resetType: "later" }, { id: "list", title: "List", kind: "checklist", items: [{ id: "i1", label: "Done", qtyCurrent: 1 }, { id: "i2", label: "Todo", formula: "5", qtyCurrent: 2 }, { id: "i3", label: "" }] }, { id: "formulaList", parentId: "root", parentIds: ["root", "list"], title: "Formula list", kind: "checklist", items: [{ id: "i4", label: "Triple", formula: "base * 3", qtyCurrent: 30 }, { id: "i5", label: "Delta", formula: "base - 12" }] } ] }); assert.equal(normalized.orientation, "vertical"); assert.equal(normalized.nodes[2].parentId, ""); assert.equal(normalized.nodes[2].kind, "step"); assert.equal(normalized.nodes[2].resetType, "none"); assert.equal(normalized.nodes[1].resetTime, "09:30"); assert.deepEqual(normalized.nodes[4].parentIds, ["root", "list"]); const evaluated = evaluateGoalTreeNodes(normalized.nodes); assert.equal(evaluated[1].computedQtyTarget, 30); assert.equal(evaluated[1].progress.ratio, 0.5); assert.equal(evaluated[0].progress.current, 45); assert.equal(evaluated[0].progress.target, 61); assert.equal(evaluated[3].progress.current, 3); assert.equal(evaluated[3].progress.target, 7); assert.equal(evaluated[4].items[0].qtyTarget, 30); assert.equal(evaluated[4].items[1].qtyTarget, 1); assert.equal(evaluated[4].progress.current, 30); const compact = compactModuleDataForStorage("goalTree", normalized); assert.equal(compact.settings, undefined); assert.equal(compact.orientation, "vertical"); assert.equal(compact.nodes[0].kind, undefined); assert.equal(compact.nodes[0].icon, "fire"); assert.equal(compact.nodes[0].color, "cyan"); assert.equal(compact.nodes[0].qtyCurrent, 1); assert.equal(compact.nodes[1].formula, "base*3"); assert.equal(compact.nodes[1].resetType, "daily"); assert.equal(compact.nodes[1].resetTime, "09:30"); assert.deepEqual(compact.nodes[3].items, [{ id: "i1", label: "Done", qtyCurrent: 1 }, { id: "i2", label: "Todo", formula: "5", qtyCurrent: 2 }, { id: "i3", label: "" }]); assert.deepEqual(compact.nodes[4].parentIds, ["root", "list"]); assert.deepEqual(compact.nodes[4].items, [{ id: "i4", label: "Triple", formula: "base * 3", qtyCurrent: 30 }, { id: "i5", label: "Delta", formula: "base - 12" }]); const exported = createToolboxExportPayload( { id: "toolbox1", name: "Goals", modules: [{ id: "module1", type: "goalTree" }], updatedAt: "2026-01-01T00:00:00.000Z" }, { "toolbox1:module1": compact } ); const exportedTree = exported.modules.m1; assert.deepEqual(exportedTree.nodes.map((node) => node.id), ["g1", "g2", "g3", "g4", "g5"]); assert.equal(exportedTree.nodes[1].parentId, "g1"); assert.deepEqual(exportedTree.nodes[4].parentIds, ["g1", "g4"]); assert.deepEqual(exportedTree.nodes[3].items.map((item) => item.id), ["g6", "g7", "g8"]); assert.deepEqual(exportedTree.nodes[4].items.map((item) => item.id), ["g9", "ga"]); }); test("goal tree parent progress uses base-scaled child bottlenecks", () => { const normalized = normalizeGoalTreeData({ nodes: [ { id: "root", title: "Potion de vie", kind: "step", qtyTarget: 10 }, { id: "ingredients", parentId: "root", title: "Ingrédients", kind: "checklist", items: [ { id: "herb", label: "Herbe magique", formula: "base", qtyCurrent: 7 }, { id: "water", label: "Eau rajeunissante", formula: "base", qtyCurrent: 5 } ] }, { id: "manual", title: "Objectif manuel", kind: "step", qtyTarget: 10, qtyCurrent: 3, progressMode: "manual" }, { id: "manualChild", parentId: "manual", title: "Enfant ignoré", kind: "step", formula: "base", qtyCurrent: 10 }, { id: "defaultManual", title: "Validation manuelle par défaut", kind: "step", qtyTarget: 1, qtyCurrent: 1 }, { id: "defaultManualChild", parentId: "defaultManual", title: "Validation enfant", kind: "step", qtyTarget: 1, qtyCurrent: 0 }, { id: "forcedAuto", title: "Validation auto forcée", kind: "step", qtyTarget: 1, qtyCurrent: 1, progressMode: "auto" }, { id: "forcedAutoChild", parentId: "forcedAuto", title: "Validation enfant auto", kind: "step", qtyTarget: 1, qtyCurrent: 0 } ] }); const evaluated = evaluateGoalTreeNodes(normalized.nodes); assert.equal(evaluated[0].progress.current, 5); assert.equal(evaluated[0].progress.target, 10); assert.equal(evaluated[0].progress.ratio, 0.5); assert.equal(evaluated[1].progress.current, 12); assert.equal(evaluated[1].progress.target, 20); assert.equal(evaluated[2].progress.current, 3); assert.equal(evaluated[2].progress.target, 10); assert.equal(evaluated[4].progress.current, 1); assert.equal(evaluated[4].progress.target, 1); assert.equal(evaluated[6].progress.current, 0); assert.equal(evaluated[6].progress.target, 1); const compact = compactModuleDataForStorage("goalTree", normalized); assert.equal(compact.nodes[0].progressMode, undefined); assert.equal(compact.nodes[2].progressMode, "manual"); assert.equal(compact.nodes[4].progressMode, undefined); assert.equal(compact.nodes[6].progressMode, "auto"); }); test("goal tree display helpers keep collapsed edges and progress modes stable", () => { const nodes = evaluateGoalTreeNodes(normalizeGoalTreeData({ nodes: [ { id: "root", title: "Root", kind: "step", qtyTarget: 1, collapsed: true }, { id: "child", parentId: "root", title: "Child", kind: "step", qtyTarget: 1 }, { id: "linked", parentId: "", parentIds: ["root"], title: "Linked", kind: "checklist", items: [{ id: "i1", label: "Line", qtyTarget: 1 }] }, { id: "manualParent", title: "Manual Parent", kind: "step", qtyTarget: 1 }, { id: "manualChild", parentId: "manualParent", title: "Manual Child", kind: "step", qtyTarget: 1 }, { id: "autoParent", title: "Auto Parent", kind: "step", qtyTarget: 10 }, { id: "autoChild", parentId: "autoParent", title: "Auto Child", kind: "step", qtyTarget: 5 } ] }).nodes); const edges = getVisibleGoalTreeEdges(nodes); assert.deepEqual(edges, [ { from: "manualParent", to: "manualChild" }, { from: "autoParent", to: "autoChild" } ]); assert.equal(edges.some((edge) => edge.from === "root" || edge.to === "child" || edge.to === "linked"), false); assert.equal(getEffectiveGoalTreeProgressMode(nodes.find((node) => node.id === "manualParent"), [nodes.find((node) => node.id === "manualChild")]), "manual"); assert.equal(getEffectiveGoalTreeProgressMode(nodes.find((node) => node.id === "autoParent"), [nodes.find((node) => node.id === "autoChild")]), "auto"); });