// 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 { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; import { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; 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("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("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("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" }]] }, { id: "combo1", device: "n64", category: `${longText} `, name: `${longText} `, inputs: [ [ { kind: "direction", value: "down" }, { kind: "button", value: "cross" }, { 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[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" } ], [{ 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[1].device, "n64"); assert.equal(compact.combos[1].id, "combo1"); assert.equal(compact.combos[1].category.length, 80); 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 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" }, { id: "task2", title: "Weekly", type: "weekly", weeklyResetDay: 4, category: "Raid" }, { id: "task3", title: "Invalid type", type: "later", category: "Farm" } ], 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[1].weeklyResetDay, 4); assert.equal(normalized.tasks[1].category, "Raid"); assert.equal(normalized.tasks[2].type, "unique"); 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 }, { 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" }); 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); });