Serialize toolbox writes and preserve concurrent edits
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
This commit is contained in:
parent
7521d50626
commit
a4cf633ab4
12 changed files with 381 additions and 320 deletions
155
tests/persistence-queue.test.mjs
Normal file
155
tests/persistence-queue.test.mjs
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue