Add equipment planner toolbox module
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-09 10:03:55 +02:00
parent ac33a55c8b
commit d53d4160af
33 changed files with 3522 additions and 20 deletions

View file

@ -332,6 +332,73 @@ export function validateSiteContent(site) {
"toolboxes.modules.taskPlanner.prerequisiteCheckboxLabel",
"toolboxes.modules.taskPlanner.removeRelationTitle",
"toolboxes.modules.taskPlanner.emptyTasks",
"toolboxes.modules.equipmentPlanner.typePlaceholder",
"toolboxes.modules.equipmentPlanner.equipmentIconLabel",
"toolboxes.modules.equipmentPlanner.equipmentPlaceholder",
"toolboxes.modules.equipmentPlanner.addEquipmentButton",
"toolboxes.modules.equipmentPlanner.summaryTitle",
"toolboxes.modules.equipmentPlanner.activeEquipmentsSingular",
"toolboxes.modules.equipmentPlanner.activeEquipmentsPlural",
"toolboxes.modules.equipmentPlanner.numericTotalsTitle",
"toolboxes.modules.equipmentPlanner.equipmentTotalsTitle",
"toolboxes.modules.equipmentPlanner.socketTotalsTitle",
"toolboxes.modules.equipmentPlanner.mixedTotalsTitle",
"toolboxes.modules.equipmentPlanner.summaryTooltipTitle",
"toolboxes.modules.equipmentPlanner.textBonusesTitle",
"toolboxes.modules.equipmentPlanner.emptyTotals",
"toolboxes.modules.equipmentPlanner.emptyTextBonuses",
"toolboxes.modules.equipmentPlanner.emptyEquipments",
"toolboxes.modules.equipmentPlanner.activeLabel",
"toolboxes.modules.equipmentPlanner.includeInSummaryLabel",
"toolboxes.modules.equipmentPlanner.reorderTypeTitle",
"toolboxes.modules.equipmentPlanner.renameTypeTitle",
"toolboxes.modules.equipmentPlanner.showTypeTitle",
"toolboxes.modules.equipmentPlanner.hideTypeTitle",
"toolboxes.modules.equipmentPlanner.reorderEquipmentTitle",
"toolboxes.modules.equipmentPlanner.renameEquipmentTitle",
"toolboxes.modules.equipmentPlanner.showDetailsTitle",
"toolboxes.modules.equipmentPlanner.hideDetailsTitle",
"toolboxes.modules.equipmentPlanner.deleteEquipmentTitle",
"toolboxes.modules.equipmentPlanner.tabsLabel",
"toolboxes.modules.equipmentPlanner.statsTab",
"toolboxes.modules.equipmentPlanner.socketsTab",
"toolboxes.modules.equipmentPlanner.craftTab",
"toolboxes.modules.equipmentPlanner.displayStatsTitle",
"toolboxes.modules.equipmentPlanner.displaySocketTitle",
"toolboxes.modules.equipmentPlanner.editModeTitle",
"toolboxes.modules.equipmentPlanner.viewModeTitle",
"toolboxes.modules.equipmentPlanner.editModeButton",
"toolboxes.modules.equipmentPlanner.viewModeButton",
"toolboxes.modules.equipmentPlanner.obtainLabel",
"toolboxes.modules.equipmentPlanner.obtainPlaceholder",
"toolboxes.modules.equipmentPlanner.emptyObtain",
"toolboxes.modules.equipmentPlanner.characteristicsTitle",
"toolboxes.modules.equipmentPlanner.traitIconLabel",
"toolboxes.modules.equipmentPlanner.categoryPlaceholder",
"toolboxes.modules.equipmentPlanner.traitNamePlaceholder",
"toolboxes.modules.equipmentPlanner.traitValuePlaceholder",
"toolboxes.modules.equipmentPlanner.addTraitButton",
"toolboxes.modules.equipmentPlanner.reorderTraitTitle",
"toolboxes.modules.equipmentPlanner.reorderCategoryTitle",
"toolboxes.modules.equipmentPlanner.deleteTraitTitle",
"toolboxes.modules.equipmentPlanner.emptyCharacteristics",
"toolboxes.modules.equipmentPlanner.materialsTitle",
"toolboxes.modules.equipmentPlanner.materialNamePlaceholder",
"toolboxes.modules.equipmentPlanner.materialQtyLabel",
"toolboxes.modules.equipmentPlanner.addMaterialButton",
"toolboxes.modules.equipmentPlanner.deleteMaterialTitle",
"toolboxes.modules.equipmentPlanner.socketItemsTitle",
"toolboxes.modules.equipmentPlanner.emptySocketItems",
"toolboxes.modules.equipmentPlanner.socketNamePlaceholder",
"toolboxes.modules.equipmentPlanner.socketShapeLabel",
"toolboxes.modules.equipmentPlanner.socketColorLabel",
"toolboxes.modules.equipmentPlanner.addSocketButton",
"toolboxes.modules.equipmentPlanner.bonusNamePlaceholder",
"toolboxes.modules.equipmentPlanner.addBonusButton",
"toolboxes.modules.equipmentPlanner.deleteSocketTitle",
"toolboxes.modules.equipmentPlanner.deleteBonusTitle",
"toolboxes.modules.equipmentPlanner.addSocketLinkLabel",
"toolboxes.modules.equipmentPlanner.deleteSocketLinkTitle",
"toolboxes.modules.imageAnnotation.addImage",
"toolboxes.modules.imageAnnotation.replaceImage",
"toolboxes.modules.imageAnnotation.pastePlaceholder",
@ -432,7 +499,7 @@ export function validateLibraryData(payload) {
assert.equal(typeof payload.modules[module.id], "object", `library.modules.${module.id} must be an object`);
});
["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "taskPlanner", "imageAnnotation"].forEach((type) => {
["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "taskPlanner", "equipmentPlanner", "imageAnnotation"].forEach((type) => {
assert.ok(payload.toolbox.modules.some((module) => module.type === type), `library must include a ${type} example`);
});
@ -451,6 +518,12 @@ export function validateLibraryData(payload) {
assert.ok(Array.isArray(taskData.tasks), "library task planner example must contain tasks");
assert.ok(Array.isArray(taskData.relations), "library task planner example must contain relations");
assert.ok(taskData.relations.length >= 1, "library task planner example must include a parent/child relation");
const equipmentModule = payload.toolbox.modules.find((module) => module.type === "equipmentPlanner");
const equipmentData = payload.modules[equipmentModule.id];
assert.ok(Array.isArray(equipmentData.types), "library equipment planner example must contain types");
assert.ok(Array.isArray(equipmentData.equipments), "library equipment planner example must contain equipments");
assert.ok(equipmentData.equipments.some((equipment) => Array.isArray(equipment.socketItems) && equipment.socketItems.length), "library equipment planner example must include socket items");
}
function assertSnakeCase(value, path) {

View file

@ -66,6 +66,9 @@ test("vite entrypoint and app shell are wired", async () => {
assert.match(libraryPage, /export function LibraryPage/);
assert.match(libraryPage, /LIBRARY_TOOLBOX_ID/);
assert.match(libraryPage, /TaskPlannerModule/);
assert.match(libraryPage, /EquipmentPlannerModule/);
assert.match(libraryPage, /normalizeEquipmentPlannerData/);
assert.match(libraryPage, /summarizeEquipmentPlannerData/);
assert.match(bodyScrollLock, /modalLocks/);
assert.match(bodyScrollLock, /is-modal-open/);
});

View file

@ -78,6 +78,7 @@ test("style entrypoint and theme layers are wired", async () => {
assert.match(styleIcons, /ui-icon-grab/);
assert.match(styleIcons, /ui-icon-shield/);
assert.match(styleIcons, /ui-icon-gun/);
assert.match(styleToolboxes, /module-icon-chest-armor/);
assert.match(styleToolboxes, /\.combo-hold-controls/);
assert.match(styleToolboxes, /\.combo-input-direction/);
assert.match(styleToolboxes, /\.combo-device-n64\.combo-value-stick-up/);

View file

@ -43,6 +43,8 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(toolboxStorage, /export function normalizeTableData/);
assert.match(toolboxStorage, /export function normalizeTimerData/);
assert.match(toolboxStorage, /export function normalizeTaskPlannerData/);
assert.match(toolboxStorage, /export function normalizeEquipmentPlannerData/);
assert.match(toolboxStorage, /export function summarizeEquipmentPlannerData/);
assert.match(toolboxStorage, /export function normalizeImageAnnotationData/);
assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/);
assert.match(toolboxStorage, /scrollResults/);
@ -108,6 +110,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8");
const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8");
const imagesModule = await readFile("website/src/features/toolboxes/modules/ImagesModule.jsx", "utf8");
const imageAnnotationModule = await readFile("website/src/features/toolboxes/modules/ImageAnnotationModule.jsx", "utf8");
@ -133,6 +136,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/);
assert.match(moduleRegistry, /timer:/);
assert.match(moduleRegistry, /taskPlanner:/);
assert.match(moduleRegistry, /equipmentPlanner:/);
assert.match(moduleRegistry, /Planificateur d'équipements/);
assert.match(moduleRegistry, /imageAnnotation:/);
assert.match(moduleRegistry, /Annotation d'images/);
assert.match(moduleRegistry, /editable: false/);
@ -245,6 +250,13 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(taskPlannerModule, /reorderFeatures/);
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/);
assert.match(equipmentPlannerModule, /normalizeEquipmentPlannerData/);
assert.match(equipmentPlannerModule, /summarizeEquipmentPlannerData/);
assert.match(equipmentPlannerModule, /socketItems/);
assert.match(equipmentPlannerModule, /socketLinks/);
assert.match(equipmentPlannerModule, /useGroupedReorder/);
assert.match(equipmentPlannerModule, /reorderFeatures/);
assert.match(checklistModule, /export function ChecklistModule/);
assert.match(checklistModule, /TextImportModal/);
assert.match(checklistModule, /Icon name="import"/);

View file

@ -4,7 +4,7 @@ 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 { compactModuleDataForStorage, createToolboxExportPayload, normalizeChecklistData, normalizeCombosData, normalizeEquipmentPlannerData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, 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";
test("colon text import keeps urls intact after the first separator", () => {
@ -96,6 +96,228 @@ test("table storage clamps dimensions and compacts non-empty cells", () => {
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("grouped reorder helpers group flat items and complete group order", () => {
const items = [
{ id: "a", category: "" },