Add text import export for toolbox modules

This commit is contained in:
Shinuwa 2026-08-09 15:26:10 +02:00
parent a9b5ca423a
commit 117b7b79e1
27 changed files with 1430 additions and 181 deletions

View file

@ -2,11 +2,29 @@
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 { exportModuleText, importModuleText, 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, normalizeEquipmentPlannerData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeLinksData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, 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}`,
normalizeChecklistData,
normalizeLinksData,
normalizeCountersData,
normalizeCombosData,
normalizeCalculatorData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,
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" },
@ -20,6 +38,140 @@ test("colon text import accepts labels without value", () => {
]);
});
test("text exchange round trips simple toolbox modules and resets checklist progress", () => {
const context = createTextImportContext();
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("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 },
{ id: "b", parentId: "a", label: "Shard", value: 500 }
]
});
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);
});
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("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);