Serialize toolbox writes and preserve concurrent edits
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-10 19:09:48 +02:00
parent 7521d50626
commit a4cf633ab4
12 changed files with 381 additions and 320 deletions

View file

@ -1,80 +0,0 @@
// Rôle : teste les rollbacks optimistes par clé des données de modules IndexedDB.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
applyModuleDataValue,
commitModuleDataCheckpoints,
createModuleDataCheckpoint,
createModuleDataCheckpoints,
removeModuleDataKeys,
rollbackModuleDataCheckpoints
} from "../website/src/features/toolboxes/storage/optimisticModuleData.js";
test("module data rollback restores a failed simple write", () => {
const initialData = { "tbx:mod-a": { text: "before" } };
const initialVersions = {};
const checkpoint = createModuleDataCheckpoint(initialData, initialVersions, "tbx:mod-a");
const versions = commitModuleDataCheckpoints(initialVersions, [checkpoint]);
const optimisticData = applyModuleDataValue(initialData, "tbx:mod-a", { text: "after" });
assert.deepEqual(rollbackModuleDataCheckpoints(optimisticData, versions, [checkpoint]), initialData);
});
test("module data rollback ignores a stale failure for the same module", () => {
const initialData = { "tbx:mod-a": { text: "before" } };
const firstCheckpoint = createModuleDataCheckpoint(initialData, {}, "tbx:mod-a");
const firstVersions = commitModuleDataCheckpoints({}, [firstCheckpoint]);
const firstOptimisticData = applyModuleDataValue(initialData, "tbx:mod-a", { text: "after" });
const secondCheckpoint = createModuleDataCheckpoint(firstOptimisticData, firstVersions, "tbx:mod-a");
const secondVersions = commitModuleDataCheckpoints(firstVersions, [secondCheckpoint]);
const secondOptimisticData = applyModuleDataValue(firstOptimisticData, "tbx:mod-a", { text: "latest" });
assert.strictEqual(rollbackModuleDataCheckpoints(secondOptimisticData, secondVersions, [firstCheckpoint]), secondOptimisticData);
});
test("module data rollback restores one module even when another module changed later", () => {
const initialData = {
"tbx:mod-a": { text: "before-a" },
"tbx:mod-b": { text: "before-b" }
};
const firstCheckpoint = createModuleDataCheckpoint(initialData, {}, "tbx:mod-a");
const firstVersions = commitModuleDataCheckpoints({}, [firstCheckpoint]);
const firstOptimisticData = applyModuleDataValue(initialData, "tbx:mod-a", { text: "after-a" });
const secondCheckpoint = createModuleDataCheckpoint(firstOptimisticData, firstVersions, "tbx:mod-b");
const secondVersions = commitModuleDataCheckpoints(firstVersions, [secondCheckpoint]);
const secondOptimisticData = applyModuleDataValue(firstOptimisticData, "tbx:mod-b", { text: "after-b" });
assert.deepEqual(rollbackModuleDataCheckpoints(secondOptimisticData, secondVersions, [firstCheckpoint]), {
"tbx:mod-a": { text: "before-a" },
"tbx:mod-b": { text: "after-b" }
});
});
test("module data rollback for batch removal restores only untouched keys", () => {
const initialData = {
"tbx:mod-a": { text: "before-a" },
"tbx:mod-b": { text: "before-b" },
"tbx:mod-c": { text: "before-c" }
};
const batchCheckpoints = createModuleDataCheckpoints(initialData, {}, ["tbx:mod-a", "tbx:mod-b"]);
const batchVersions = commitModuleDataCheckpoints({}, batchCheckpoints);
const removedData = removeModuleDataKeys(initialData, ["tbx:mod-a", "tbx:mod-b"]);
const laterCheckpoint = createModuleDataCheckpoint(removedData, batchVersions, "tbx:mod-b");
const laterVersions = commitModuleDataCheckpoints(batchVersions, [laterCheckpoint]);
const laterData = applyModuleDataValue(removedData, "tbx:mod-b", { text: "latest-b" });
assert.deepEqual(rollbackModuleDataCheckpoints(laterData, laterVersions, batchCheckpoints), {
"tbx:mod-a": { text: "before-a" },
"tbx:mod-b": { text: "latest-b" },
"tbx:mod-c": { text: "before-c" }
});
});
test("module data rollback keeps removal of an initially missing key stable", () => {
const initialData = { "tbx:mod-a": { text: "before-a" } };
const checkpoint = createModuleDataCheckpoint(initialData, {}, "tbx:missing");
const versions = commitModuleDataCheckpoints({}, [checkpoint]);
const optimisticData = removeModuleDataKeys(initialData, ["tbx:missing"]);
assert.deepEqual(rollbackModuleDataCheckpoints(optimisticData, versions, [checkpoint]), initialData);
});

View file

@ -0,0 +1,155 @@
// Rôle : teste les écritures confirmées, échecs successifs et imports concurrents.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createPersistenceQueue } from "../website/src/features/toolboxes/storage/persistenceQueue.js";
import { useToolboxActions } from "../website/src/features/toolboxes/useToolboxActions.js";
import { applyModuleDataValue, removeModuleDataKeys } from "../website/src/features/toolboxes/storage/optimisticModuleData.js";
function deferred() {
let resolve, reject;
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
return { promise, resolve, reject };
}
for (const field of ["toolboxes", "links", "moduleData", "toastPosition"]) {
for (const outcomes of [[true, true], [true, false], [false, true], [false, false]]) {
test(`${field}: confirms only successful writes ${outcomes}`, async () => {
let disk = { [field]: "A" };
const errors = [];
const queue = createPersistenceQueue(disk, () => {});
const gates = [deferred(), deferred()];
let active = 0;
const write = (index) => queue.enqueue(
(current) => ({ ...current, [field]: index ? "C" : "B" }),
async (next) => {
assert.equal(++active, 1);
try { await gates[index].promise; disk = next; }
finally { active--; }
},
(error) => errors.push(error.message)
);
const first = write(0);
const second = write(1);
assert.equal(queue.getCurrent()[field], "C");
outcomes[0] ? gates[0].resolve() : gates[0].reject(new Error("first"));
assert.equal(await first, outcomes[0]);
assert.equal(queue.getCurrent()[field], "C");
outcomes[1] ? gates[1].resolve() : gates[1].reject(new Error("second"));
assert.equal(await second, outcomes[1]);
assert.deepEqual(queue.getCurrent(), disk);
assert.equal(disk[field], outcomes[1] ? "C" : outcomes[0] ? "B" : "A");
assert.equal(errors.length, outcomes.filter((ok) => !ok).length);
});
}
}
test("failed snapshot cannot undo a later edit or reintroduce a failed earlier write", async () => {
const initial = { toolboxes: ["existing"], links: {}, moduleData: { note: "A" } };
let disk = initial;
const queue = createPersistenceQueue(initial, () => {});
const gate = deferred();
const earlier = queue.enqueue((s) => ({ ...s, moduleData: { note: "B" } }), () => gate.promise);
const snapshot = queue.enqueue(
(s) => ({ ...s, toolboxes: ["imported", ...s.toolboxes] }),
async () => { throw new Error("abort"); }
);
const edit = queue.enqueue((s) => ({ ...s, moduleData: { note: "C" } }), async (next) => { disk = next; });
gate.reject(new Error("quota"));
assert.deepEqual(await Promise.all([earlier, snapshot, edit]), [false, false, true]);
assert.deepEqual(queue.getCurrent(), { ...initial, moduleData: { note: "C" } });
assert.deepEqual(disk, queue.getCurrent());
});
test("imports merge after file reading and preserve edits queued during the transaction", async () => {
const initial = { toolboxes: [{ id: "existing", modules: [] }], links: {}, moduleData: { note: "A" } };
let disk = initial;
const queue = createPersistenceQueue(initial, () => {});
const transaction = deferred();
const fileRead = deferred();
const notices = [];
const actions = useToolboxActions({
store: {
...initial,
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => {
await transaction.promise;
disk = next;
})
},
notify: (value) => notices.push(value),
setConfirmModal: (value) => assert.fail(value.message)
});
const imported = actions.importToolbox({ text: () => fileRead.promise }, "game");
await queue.enqueue((s) => ({ ...s, moduleData: { note: "B" } }), async (next) => { disk = next; });
fileRead.resolve(JSON.stringify({ toolbox: { id: "source", name: "Import", modules: [] }, modules: {} }));
await Promise.resolve();
assert.equal(notices.length, 0);
const edit = queue.enqueue((s) => ({ ...s, moduleData: { ...s.moduleData, note: "C" } }), async (next) => { disk = next; });
transaction.resolve();
const toolbox = await imported;
await edit;
assert.equal(disk.moduleData.note, "C");
assert.deepEqual(disk.toolboxes.map((t) => t.id), [toolbox.id, "existing"]);
assert.equal(disk.links.game, toolbox.id);
assert.deepEqual(queue.getCurrent(), disk);
assert.equal(notices.length, 1);
});
test("failed module removal restores the confirmed value after an earlier failed edit", async () => {
const initial = { moduleData: { note: { text: "A" }, other: { text: "Other" } } };
const queue = createPersistenceQueue(initial, () => {});
const first = queue.enqueue(
(s) => ({ ...s, moduleData: applyModuleDataValue(s.moduleData, "note", { text: "B" }) }),
async () => { throw new Error("quota"); }
);
const second = queue.enqueue(
(s) => ({ ...s, moduleData: removeModuleDataKeys(s.moduleData, ["note"]) }),
async () => { throw new Error("abort"); }
);
assert.deepEqual(await Promise.all([first, second]), [false, false]);
assert.deepEqual(queue.getCurrent(), initial);
});
test("global imports started from the same render both survive", async () => {
const initial = { toolboxes: [], links: {}, moduleData: {} };
let disk = initial;
const queue = createPersistenceQueue(initial, () => {});
const actions = useToolboxActions({
store: {
...initial,
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => { disk = next; })
},
notify() {},
setConfirmModal: (value) => assert.fail(value.message)
});
const file = (id) => ({ text: async () => JSON.stringify({
toolboxes: [{ id, name: id, modules: [{ id: "note", type: "notepad" }] }],
links: { [id]: id },
modules: { [`${id}:note`]: { text: id } }
}) });
assert.deepEqual(await Promise.all([
actions.importAllToolboxes(file("one")), actions.importAllToolboxes(file("two"))
]), [true, true]);
assert.equal(disk.toolboxes.length, 2);
assert.equal(Object.keys(disk.moduleData).length, 2);
assert.deepEqual(Object.keys(disk.links).sort(), ["one", "two"]);
assert.deepEqual(queue.getCurrent(), disk);
});
test("failed toolbox deletion preserves registry links and modules together", async () => {
const initial = {
toolboxes: [{ id: "existing", modules: [{ id: "note", type: "notepad" }] }],
links: { game: "existing" },
moduleData: { "existing:note": { text: "Keep" } }
};
const queue = createPersistenceQueue(initial, () => {});
const actions = useToolboxActions({
store: {
...initial,
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async () => { throw new Error("abort"); })
}
});
const deleted = actions.deleteToolbox("existing");
assert.deepEqual(queue.getCurrent(), { toolboxes: [], links: {}, moduleData: {} });
assert.equal(await deleted, false);
assert.deepEqual(queue.getCurrent(), initial);
});

View file

@ -29,7 +29,6 @@ test("toolbox storage, cards and pages are wired", async () => {
const resetUtils = await readFile("website/src/features/toolboxes/modules/resetUtils.js", "utf8");
const equipmentStorage = await readFile("website/src/features/toolboxes/storage/modules/equipmentPlanner.js", "utf8");
const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8");
const optimisticModuleData = await readFile("website/src/features/toolboxes/storage/optimisticModuleData.js", "utf8");
const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8");
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8");
@ -43,10 +42,6 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(source, /features\/toolboxes\/useToolboxActions\.js/);
assert.match(indexedToolboxesHook, /export function useIndexedToolboxes/);
assert.match(indexedToolboxesHook, /getStorageEstimate/);
assert.match(indexedToolboxesHook, /moduleDataByKey/);
assert.match(indexedToolboxesHook, /rollbackModuleDataCheckpoints/);
assert.match(optimisticModuleData, /export function createModuleDataCheckpoint/);
assert.match(optimisticModuleData, /export function rollbackModuleDataCheckpoints/);
assert.match(toolboxActions, /export function useToolboxActions/);
assert.match(toolboxActions, /importAllToolboxesPayload/);
assert.match(toolboxActions, /async function addImageFiles/);