sokko-g/tests/toolbox-modules.test.mjs
Shinuwa b5901b31e3
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
Allow toolbox tools to collapse into their headers
2026-09-24 14:05:17 +02:00

1570 lines
74 KiB
JavaScript

// 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 { createBalancedTeams, drawWeightedEntries, filterExclusionsForPlayers, getDiceShape, rollDice } from "../website/src/features/toolboxes/modules/randomizerUtils.js";
import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngRealDrawsSummary, calculateRngSummary, getAveragePositionStatus, getDurationPresetsForAverageHours, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, 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,
normalizeMemosData,
normalizeChecklistData,
normalizeLinksData,
normalizeCountersData,
normalizeCombosData,
normalizeCalculatorData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,
normalizeGoalTreeData,
normalizeEquipmentPlannerData,
normalizeImageAnnotationData,
normalizeRandomizerData,
normalizeRngSimulatorData,
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: '<h3>Route</h3><p><strong style="color: #f6c453">Boss</strong></p>',
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, /<h3>Route<\/h3>/);
assert.match(notepadImport.data.html, /<strong style="color: #f6c453">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({
categoryOrder: ["Builds", "Guides"],
links: [{ id: "l1", title: "Build", url: "https://example.com/build", category: "Builds" }]
});
const linksImport = importModuleText("links", exportModuleText("links", links), context);
assert.equal(linksImport.ok, true);
assert.equal(linksImport.data.links[0].url, "https://example.com/build");
assert.equal(linksImport.data.links[0].category, "Builds");
assert.deepEqual(linksImport.data.categoryOrder, ["Builds", "Guides"]);
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]]);
const memos = normalizeMemosData({ items: [{ id: "m1", text: "Route", color: "#f6c453" }, { id: "m2", text: "Potion", color: "#22d3ee" }] });
const memosImport = importModuleText("memos", exportModuleText("memos", memos), context);
assert.equal(memosImport.ok, true);
assert.deepEqual(memosImport.data.items.map((memo) => [memo.text, memo.color]), [["Route", "#f6c453"], ["Potion", "#22d3ee"]]);
});
test("randomizer helpers roll dice, draw weighted values and balance teams", () => {
assert.equal(getDiceShape(2), "circle");
assert.equal(getDiceShape(3), "triangle");
assert.equal(getDiceShape(6), "square");
assert.equal(getDiceShape(12), "hexagon");
assert.deepEqual(rollDice(6, 3, () => 0), [1, 1, 1]);
assert.deepEqual(rollDice(6, 2, () => 0.99), [6, 6]);
const wheelDraws = drawWeightedEntries([
{ id: "a", label: "A", weight: 1 },
{ id: "b", label: "B", weight: 3 }
], 2, () => 0.9);
assert.deepEqual(wheelDraws.map((entry) => entry.label), ["B", "B"]);
const result = createBalancedTeams([
{ id: "p1", name: "Alice", score: 10 },
{ id: "p2", name: "Bob", score: 8 },
{ id: "p3", name: "Chloé", score: 7 },
{ id: "p4", name: "Dorian", score: 5 }
], 2, [{ id: "e1", playerAId: "p1", playerBId: "p2" }], () => 0.4);
assert.equal(result.ok, true);
assert.equal(result.teams.length, 2);
assert.ok(result.teams.every((team) => team.players.length === 2));
assert.ok(!result.teams.some((team) => team.players.some((player) => player.id === "p1") && team.players.some((player) => player.id === "p2")));
const singleTeam = createBalancedTeams([
{ id: "p1", name: "Bulbizarre", score: 0 },
{ id: "p2", name: "Salamèche", score: 0 },
{ id: "p3", name: "Carapuce", score: 0 }
], 1, [], () => 0.4);
assert.equal(singleTeam.ok, true);
assert.equal(singleTeam.teams.length, 1);
assert.equal(singleTeam.teams[0].players.length, 3);
});
test("randomizer storage compacts values and detects impossible teams", () => {
const normalized = normalizeRandomizerData({
activeTab: "teams",
dice: { faces: 2000, rolls: 200, history: [{ id: "old", at: "2026-08-30T12:00:00.000Z", results: [1, 2, 6] }] },
wheel: { entries: [{ id: "w1", label: "Rare", weight: 1000 }, { label: "", weight: 1 }] },
teams: {
teamCount: 2,
playerSelectionMode: "count",
playerSelectionCount: 3,
players: [
{ id: "p1", name: "Alice", score: 10 },
{ id: "p2", name: "Bob", score: 8 },
{ id: "p3", name: "Chloé", score: 7 }
],
exclusions: [
{ id: "e1", playerAId: "p1", playerBId: "p2", mode: "group" },
{ id: "e2", playerAId: "p2", playerBId: "p1" },
{ id: "e3", playerAId: "p1", playerBId: "missing" }
]
}
});
assert.equal(normalized.dice.faces, 999);
assert.equal(normalized.dice.rolls, 100);
assert.deepEqual(normalized.dice.history[0].results, [1, 2, 6]);
assert.equal(normalized.wheel.entries[0].weight, 999);
assert.equal(normalized.teams.exclusions.length, 1);
assert.deepEqual(normalized.teams.exclusions[0].playerBIds, ["p2"]);
assert.equal(normalized.teams.exclusions[0].mode, "group");
assert.equal(normalized.teams.playerSelectionMode, "count");
assert.equal(normalized.teams.playerSelectionCount, 3);
assert.equal(compactModuleDataForStorage("randomizer", normalizeRandomizerData({})), null);
assert.equal(compactModuleDataForStorage("randomizer", normalized).activeTab, "teams");
const impossible = createBalancedTeams([
{ id: "p1", name: "Alice", score: 10 },
{ id: "p2", name: "Bob", score: 8 },
{ id: "p3", name: "Chloé", score: 7 }
], 2, [
{ id: "e1", playerAId: "p1", playerBId: "p2" },
{ id: "e2", playerAId: "p1", playerBId: "p3" },
{ id: "e3", playerAId: "p2", playerBId: "p3" }
], () => 0.2);
assert.equal(impossible.ok, false);
assert.equal(impossible.error, "constraints_impossible");
const impossibleGroup = createBalancedTeams([
{ id: "p1", name: "Dracolosse", score: 10 },
{ id: "p2", name: "Drattak", score: 9 },
{ id: "p3", name: "Carchacrok", score: 8 },
{ id: "p4", name: "Lanssorien", score: 7 }
], 3, [
{ id: "e1", playerAId: "p1", playerBIds: ["p2", "p3", "p4"], mode: "group" }
], () => 0.2);
assert.equal(impossibleGroup.ok, false);
assert.equal(impossibleGroup.error, "constraints_impossible");
const scopedGroupExclusions = filterExclusionsForPlayers([
{ id: "e1", playerAId: "p1", playerBIds: ["p2", "p3", "p4"], mode: "group" }
], [
{ id: "p3", name: "Carchacrok", score: 8 },
{ id: "p4", name: "Lanssorien", score: 7 },
{ id: "p5", name: "Corvaillus", score: 6 }
]);
assert.equal(scopedGroupExclusions.length, 1);
assert.equal(scopedGroupExclusions[0].playerAId, "p3");
assert.deepEqual(scopedGroupExclusions[0].playerBIds, ["p4"]);
const scopedGroupResult = createBalancedTeams([
{ id: "p3", name: "Carchacrok", score: 8 },
{ id: "p4", name: "Lanssorien", score: 7 },
{ id: "p5", name: "Corvaillus", score: 6 }
], 2, scopedGroupExclusions, () => 0.2);
assert.equal(scopedGroupResult.ok, true);
assert.ok(!scopedGroupResult.teams.some((team) => team.players.some((player) => player.id === "p3") && team.players.some((player) => player.id === "p4")));
});
test("randomizer text exchange round trips full configuration", () => {
const context = createTextImportContext();
const randomizer = normalizeRandomizerData({
activeTab: "wheel",
dice: { faces: 6, rolls: 3 },
wheel: {
draws: 2,
entries: [
{ id: "w1", label: "Route farm", weight: 3 },
{ id: "w2", label: "Boss rare", weight: 1 }
]
},
teams: {
teamCount: 2,
playerSelectionMode: "count",
playerSelectionCount: 3,
players: [
{ id: "p1", name: "Alice", score: 10 },
{ id: "p2", name: "Bob", score: 8 },
{ id: "p3", name: "Chloé", score: 7 }
],
exclusions: [{ id: "e1", playerAId: "p1", playerBIds: ["p2", "p3"], mode: "group" }]
}
});
const imported = importModuleText("randomizer", exportModuleText("randomizer", randomizer), context);
assert.equal(imported.ok, true);
assert.equal(imported.data.activeTab, "wheel");
assert.deepEqual(imported.data.wheel.entries.map((entry) => [entry.label, entry.weight]), [["Route farm", 3], ["Boss rare", 1]]);
assert.equal(imported.data.teams.playerSelectionMode, "count");
assert.equal(imported.data.teams.playerSelectionCount, 3);
assert.deepEqual(imported.data.teams.players.map((player) => [player.name, player.score]), [["Alice", 10], ["Bob", 8], ["Chloé", 7]]);
assert.equal(imported.data.teams.exclusions.length, 1);
assert.equal(imported.data.teams.exclusions[0].mode, "group");
assert.deepEqual(
imported.data.teams.exclusions[0].playerBIds.map((playerId) => imported.data.teams.players.find((player) => player.id === playerId)?.name),
["Bob", "Chloé"]
);
});
test("rng simulator preserves rate precision and compacts storage", () => {
assert.equal(normalizeRateString("2.500"), "2.500");
assert.equal(normalizeRateString("0.001"), "0.001");
assert.equal(normalizeRateString("0.000004%"), "0.000004");
assert.equal(normalizeRateString("100.500"), "100");
const normalized = normalizeRngSimulatorData({
activeRateId: "rate2",
rates: [
{ id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "", multiplier: 25, occurrenceValue: 10, occurrenceUnit: "perHour", realDraws: 0 },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "12,500", multiplier: 50, occurrenceValue: 3600, occurrenceUnit: "perMonth", realDraws: "100 000" },
{ id: "bad", label: "", rate: "4" }
],
settings: { simulationDraws: 2000 },
history: [
{ id: "h1", at: "2026-08-30T12:00:00.000Z", label: "Drop rare", rate: "2.500", multiplier: 50, draws: 100, successes: 4, fails: 400, longestFailStreak: 40 }
]
});
assert.equal(normalized.activeRateId, "rate2");
assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
assert.deepEqual(normalized.rates.map((rate) => rate.costPerDraw), ["", "12.500"]);
assert.deepEqual(normalized.rates.map((rate) => [rate.multiplier, rate.occurrenceValue, rate.occurrenceUnit, rate.realDraws]), [[25, 10, "perHour", 0], [50, 120, "perDay", 100000]]);
assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Bad real", rate: "5", realDraws: -10 }] }).rates[0].realDraws, 0);
assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Decimal real", rate: "5", realDraws: "42.9" }] }).rates[0].realDraws, 42);
assert.equal(normalizeRngSimulatorData({ rates: [{ label: "Huge real", rate: "5", realDraws: 2000000000 }] }).rates[0].realDraws, 1000000000);
assert.equal(normalized.history, undefined);
assert.equal(normalizeCostString("0"), "");
assert.equal(normalizeCostString("0012,500"), "12.500");
assert.equal(normalizeCostString("100 000 000"), "100000000");
assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[0].costPerDraw, undefined);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].costPerDraw, "12.500");
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].multiplier, 50);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceValue, 120);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].occurrenceUnit, "perDay");
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[0].realDraws, undefined);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).rates[1].realDraws, 100000);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.simulationDraws, 2000);
assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined);
});
test("rng simulator calculates probability milestones and deterministic simulations", () => {
assert.equal(calculateAtLeastOneProbability("100", 50), 1);
assert.equal(calculateDrawsForChance("50", 0.5), 1);
assert.equal(calculateDrawsForChance("1", 0.5), 69);
assert.equal(getEffectiveRate("10", 50), 15);
assert.equal(getEffectiveRate("10", -50), 5);
assert.equal(getEffectiveRate("75", 100), 100);
const hourlySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perHour" });
assert.equal(hourlySummary.timeByFrequency.hour, 2);
assert.equal(hourlySummary.timeByFrequency.day, 2 / 24);
assert.deepEqual(hourlySummary.durationChances.map((preset) => preset.id), ["1h", "5h", "10h", "1j"]);
assert.equal(hourlySummary.durationChances.find((preset) => preset.id === "1h").draws, 2);
assert.equal(hourlySummary.durationChances.find((preset) => preset.id === "1h").chance, 0.4375);
const dailySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 2, occurrenceUnit: "perDay" });
assert.equal(dailySummary.timeByFrequency.hour, 48);
assert.equal(dailySummary.timeByFrequency.day, 2);
assert.deepEqual(dailySummary.durationChances.map((preset) => preset.id), ["1j", "3j", "7j", "30j"]);
const legacyMonthSummary = calculateRngSummary({ rate: "25", simulationDraws: 8, occurrenceValue: 60, occurrenceUnit: "perMonth" });
assert.equal(legacyMonthSummary.timeByFrequency.day, 2);
assert.deepEqual(legacyMonthSummary.durationChances.map((preset) => preset.id), ["1j", "3j", "7j", "30j"]);
const fractionalDurationSummary = calculateRngSummary({ rate: "24", simulationDraws: 8, occurrenceValue: 1, occurrenceUnit: "perDay" });
assert.ok(fractionalDurationSummary.durationChances.find((preset) => preset.id === "1j").chance > 0);
const rareDurationSummary = calculateRngSummary({ rate: "0.000004", simulationDraws: 8, occurrenceValue: 60, occurrenceUnit: "perHour" });
assert.deepEqual(rareDurationSummary.durationChances.map((preset) => preset.id), ["1a", "5a", "10a", "25a"]);
assert.deepEqual(getDurationPresetsForAverageHours(24 * 30).map((preset) => preset.id), ["1j", "3j", "7j", "30j"]);
assert.deepEqual(getDurationPresetsForAverageHours(24 * 365 * 2).map((preset) => preset.id), ["30j", "90j", "1a", "5a"]);
assert.deepEqual(getDurationPresetsForAverageHours(0, "perDay").map((preset) => preset.id), ["1j", "3j", "7j", "15j"]);
const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, occurrenceValue: 0, occurrenceUnit: "perHour" });
assert.equal(multipliedSummary.effectiveRate, 20);
assert.equal(multipliedSummary.averageDraws, 5);
const costSummary = calculateRngCostSummary({ costPerDraw: "12,5", averageDraws: 5, drawsForChance: { 50: 4, 90: 12 }, simulationDraws: 8, simulationSuccesses: 2 });
assert.equal(costSummary.costPerDraw, 12.5);
assert.equal(costSummary.averageSuccessCost, 62.5);
assert.equal(costSummary.targetCosts[50], 50);
assert.equal(costSummary.targetCosts[90], 150);
assert.equal(costSummary.simulationCostPerSuccess, 50);
assert.equal(calculateRngCostSummary({ costPerDraw: "12,5", averageDraws: 5, drawsForChance: {}, simulationDraws: 8, simulationSuccesses: 2, firstSuccessDraw: 3 }).simulationFirstSuccessCost, 37.5);
assert.equal(calculateRngCostSummary({ costPerDraw: "0", averageDraws: 5, drawsForChance: {}, simulationDraws: 8, simulationSuccesses: 2 }), null);
const realSummary = calculateRngRealDrawsSummary({ rate: "25", realDraws: 2, averageDraws: 4, drawsForChance: { 50: 3, 90: 9 }, costPerDraw: "10" });
assert.equal(realSummary.hasCost, true);
assert.equal(realSummary.cumulativeChance, 0.4375);
assert.equal(realSummary.noSuccessChance, 0.5625);
assert.equal(realSummary.averageRatio, 0.5);
assert.deepEqual(realSummary.averagePosition, { id: "below", tone: "positive" });
assert.equal(realSummary.drawsUntilAverage, 2);
assert.equal(realSummary.spentCost, 20);
assert.equal(realSummary.averageSuccessCost, 40);
assert.equal(realSummary.averageCostDelta, -20);
assert.equal(realSummary.costUntilAverage, 20);
assert.equal(realSummary.targets[50].remainingDraws, 1);
assert.deepEqual(getAveragePositionStatus(0.1), { id: "start", tone: "positive" });
assert.deepEqual(getAveragePositionStatus(0.3), { id: "farBelow", tone: "positive" });
assert.deepEqual(getAveragePositionStatus(0.75), { id: "below", tone: "positive" });
assert.deepEqual(getAveragePositionStatus(1), { id: "average", tone: "" });
assert.deepEqual(getAveragePositionStatus(1.25), { id: "slightlyAbove", tone: "negative" });
assert.deepEqual(getAveragePositionStatus(2), { id: "high", tone: "negative" });
assert.deepEqual(getAveragePositionStatus(3), { id: "catastrophic", tone: "negative" });
const result = simulateRngDraws("25", 8, (() => {
const values = [0.1, 0.9, 0.2, 0.8, 0.7, 0.1, 0.6, 0.5];
let index = 0;
return () => values[index++];
})());
assert.deepEqual(result, {
draws: 8,
successes: 3,
fails: 5,
observedRate: 37.5,
longestFailStreak: 2,
longestSuccessStreak: 1,
firstSuccessDraw: 1
});
const multipliedResult = simulateRngDraws("10", 4, (() => {
const values = [0.15, 0.25, 0.05, 0.9];
let index = 0;
return () => values[index++];
})(), 100);
assert.equal(multipliedResult.successes, 2);
assert.equal(multipliedResult.longestSuccessStreak, 1);
assert.equal(multipliedResult.firstSuccessDraw, 1);
});
test("rng simulator text exchange round trips rates and settings", () => {
const context = createTextImportContext();
const rngSimulator = normalizeRngSimulatorData({
activeRateId: "rate2",
rates: [
{ id: "rate1", label: "Drop rare", rate: "2.500", costPerDraw: "150", multiplier: 25, occurrenceValue: 20, occurrenceUnit: "perHour", realDraws: 60 },
{ id: "rate2", label: "Ultra rare", rate: "0.000004", costPerDraw: "", multiplier: 50, occurrenceValue: 120, occurrenceUnit: "perDay", realDraws: 0 }
],
settings: { simulationDraws: 5000 }
});
const exported = exportModuleText("rngSimulator", rngSimulator);
assert.doesNotMatch(exported, /\[history\]/);
const imported = importModuleText("rngSimulator", exported, context);
assert.equal(imported.ok, true);
assert.deepEqual(imported.data.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.costPerDraw, "150");
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.costPerDraw, "");
assert.equal(imported.data.rates.find((rate) => rate.id === imported.data.activeRateId)?.label, "Ultra rare");
assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.multiplier, 25);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.multiplier, 50);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceValue, 120);
assert.equal(imported.data.rates.find((rate) => rate.label === "Ultra rare")?.occurrenceUnit, "perDay");
assert.equal(imported.data.rates.find((rate) => rate.label === "Drop rare")?.realDraws, 60);
const legacyImported = importModuleText("rngSimulator", "[rates]\n- Legacy | rate=1 | occurrenceValue=300 | occurrenceUnit=perMonth", context);
assert.equal(legacyImported.ok, true);
assert.equal(legacyImported.data.rates[0].occurrenceValue, 10);
assert.equal(legacyImported.data.rates[0].occurrenceUnit, "perDay");
assert.equal(imported.data.settings.simulationDraws, 5000);
assert.equal(imported.data.history, undefined);
});
test("notepad helpers keep empty html and invalid command states stable", () => {
assert.equal(isHtmlEmpty("<p><br></p>", ""), true);
assert.equal(isHtmlEmpty("<p>&nbsp;</p>", ""), true);
assert.equal(isHtmlEmpty("<p>Hello</p>", "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<p>Note</p>", 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 modules preserve only active shell display options", () => {
const toolbox = normalizeToolbox({
modules: [
{ id: "collapsed", type: "notepad", collapsed: true, scrollable: true },
{ id: "expanded", type: "links", collapsed: false }
]
});
assert.equal(toolbox.modules[0].collapsed, true);
assert.equal(toolbox.modules[0].scrollable, true);
assert.equal("collapsed" in toolbox.modules[1], false);
});
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("toolbox export remaps randomizer multi-target exclusions", () => {
const payload = createToolboxExportPayload(
{
id: "toolbox1",
name: "Randomizer",
modules: [{ id: "module1", type: "randomizer" }],
moduleOrder: {
one: ["module1"],
two: [["module1"], []]
}
},
{
[moduleStorageKey("toolbox1", "module1")]: normalizeRandomizerData({
teams: {
players: [
{ id: "p1", name: "Dracolosse" },
{ id: "p2", name: "Drattak" },
{ id: "p3", name: "Carchacrok" },
{ id: "p4", name: "Lanssorien" }
],
exclusions: [{ id: "e1", playerAId: "p1", playerBIds: ["p2", "p3", "p4"], mode: "group" }]
}
})
}
);
const moduleData = Object.values(payload.modules)[0];
assert.equal(moduleData.teams.exclusions.length, 1);
assert.equal(moduleData.teams.exclusions[0].mode, "group");
assert.equal(moduleData.teams.exclusions[0].playerBIds.length, 3);
assert.ok(!moduleData.teams.exclusions[0].playerBIds.includes(undefined));
assert.ok(moduleData.teams.exclusions[0].playerBIds.every((playerId) => playerId !== "p2" && playerId !== "p3" && playerId !== "p4"));
});
test("grouped reorder helpers group flat items and complete group order", () => {
assert.deepEqual(getGroupedEntries([], { getItemGroup: (item) => item.category }), []);
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 original = structuredClone(data);
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"]);
assert.deepEqual(data, original);
assert.deepEqual(movedIn.items.map((item) => `${item.id}:${item.category || ""}`), ["b:Boss", "c:Boss", "a:Farm", "d:Farm"]);
assert.equal(applyGroupedReorderOperation(data, { ...config, operation: { type: "unknown" } }), data);
});
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, /<p>Boss<\/p><p>Phase 2<\/p>/);
const normalized = normalizeNotepadData({
html: '<h3 onclick="bad()">Titre</h3><script>alert(1)</script><span style="color: rgb(246, 196, 83); position: fixed">Important</span><img src=x>',
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, /<h3>Titre<\/h3>/);
assert.match(normalized.html, /<span style="color: #f6c453">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: "<p>&quot;&amp;amp;quot; &amp; test</p>" });
assert.equal(escaped.text, "\"\" & test");
assert.equal(escaped.html, "<p>&quot;&quot; &amp; test</p>");
const compactTemporary = compactModuleDataForStorage("notepad", {
html: "<p>Note</p>",
text: "Note",
drawingMode: "temporary",
drawings: { strokes: normalized.drawings.strokes }
});
assert.equal(compactTemporary.pages[0].drawings, undefined);
assert.equal(compactTemporary.pages[0].drawingMode, undefined);
const compactPermanent = compactModuleDataForStorage("notepad", {
html: "<p>Note</p>",
text: "Note",
updatedAt: "not a date",
drawingMode: "permanent",
drawings: normalized.drawings
});
assert.equal(compactPermanent.pages[0].updatedAt, undefined);
assert.equal(compactPermanent.pages[0].drawingMode, "permanent");
assert.equal(compactPermanent.pages[0].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");
});