Undo/Redo für Projekt-Änderungen

setProject wird jetzt von einer History gewrappt (undoStack/redoStack,
Limit 100). Echte Aenderungen (Referenzvergleich) landen auf dem
Undo-Stack, eine neue Aktion leert den Redo-Stack. Hochfrequente
Drag-Mutatoren (Griffe/Body-Move) bekommen einen coalesceKey, damit
ein Drag nicht hunderte Einzelschritte erzeugt. Tastatur-Bindung
(Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y) folgt im TopBar-Commit (App.tsx).
This commit is contained in:
2026-07-04 05:01:15 +02:00
parent e0d71691e1
commit 44942e6976
4 changed files with 398 additions and 62 deletions
+136
View File
@@ -0,0 +1,136 @@
/**
* Undo/Redo für das Projekt-Modell (`src/state/historySlice.ts` +
* `setProject`-Wrapper in `src/state/projectSlice.ts`).
*
* • Ein diskreter Store-Aufruf (z. B. `toggleLevelVisible`) muss GENAU einen
* Undo-Schritt erzeugen.
* • `undo`/`redo` müssen den jeweils anderen Stack korrekt befüllen und
* dürfen bei leerem Stack nicht crashen (No-Op).
* • Eine neue Aktion nach `undo()` muss den Redo-Stack verwerfen.
* • Hochfrequente Drag-Aktionen (z. B. `moveGripOf`, bei JEDEM Maus-Move-
* Schritt aufgerufen statt über ein Draft/Commit-Muster) müssen zu EINEM
* Undo-Schritt koalesziert werden, nicht zu einem pro Aufruf.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { getState, setState } from "./appStore";
import { sampleProject } from "../model/sampleProject";
import { HISTORY_LIMIT } from "./historySlice";
// Frischen Ausgangszustand vor jedem Test herstellen: eigenes Projekt +
// leere Stacks, damit die Tests unabhängig voneinander laufen (derselbe
// Store ist ein Singleton über die ganze Datei hinweg).
beforeEach(() => {
setState({
project: sampleProject,
undoStack: [],
redoStack: [],
canUndo: false,
canRedo: false,
});
});
describe("Undo/Redo", () => {
it("undo macht eine Projekt-Änderung rückgängig", () => {
const before = getState().project;
getState().toggleLevelVisible("eg");
const afterToggle = getState().project;
expect(afterToggle).not.toBe(before);
expect(afterToggle.drawingLevels.find((l) => l.id === "eg")?.visible).toBe(false);
expect(getState().canUndo).toBe(true);
getState().undo();
expect(getState().project.drawingLevels.find((l) => l.id === "eg")?.visible).toBe(true);
expect(getState().canUndo).toBe(false);
expect(getState().canRedo).toBe(true);
});
it("redo stellt die rückgängig gemachte Änderung wieder her", () => {
getState().toggleLevelVisible("eg");
getState().undo();
getState().redo();
expect(getState().project.drawingLevels.find((l) => l.id === "eg")?.visible).toBe(false);
expect(getState().canRedo).toBe(false);
expect(getState().canUndo).toBe(true);
});
it("undo bei leerem Stack ist ein No-Op (kein Crash)", () => {
expect(getState().undoStack.length).toBe(0);
const before = getState().project;
expect(() => getState().undo()).not.toThrow();
expect(getState().project).toBe(before);
expect(getState().canUndo).toBe(false);
});
it("redo bei leerem Stack ist ein No-Op (kein Crash)", () => {
expect(getState().redoStack.length).toBe(0);
const before = getState().project;
expect(() => getState().redo()).not.toThrow();
expect(getState().project).toBe(before);
expect(getState().canRedo).toBe(false);
});
it("eine neue Aktion nach undo() verwirft den Redo-Stack", () => {
getState().toggleLevelVisible("eg");
getState().undo();
expect(getState().redoStack.length).toBe(1);
getState().toggleLevelVisible("og");
expect(getState().redoStack.length).toBe(0);
expect(getState().canRedo).toBe(false);
// Die neue Aktion selbst bleibt natürlich undo-fähig.
expect(getState().canUndo).toBe(true);
});
it("die allererste Änderung bleibt exakt EIN Undo-Schritt (kein Start-Eintrag)", () => {
expect(getState().undoStack.length).toBe(0);
getState().toggleLevelVisible("eg");
expect(getState().undoStack.length).toBe(1);
});
it("history-Stack verwirft die ältesten Einträge über HISTORY_LIMIT hinaus", () => {
for (let i = 0; i < HISTORY_LIMIT + 10; i++) {
getState().toggleLevelVisible("eg");
}
expect(getState().undoStack.length).toBe(HISTORY_LIMIT);
});
it("ein Grip-Drag (viele setProject-Aufrufe in Serie) koalesziert zu EINEM Undo-Schritt", () => {
const w1Before = getState().project.walls.find((w) => w.id === "W1");
expect(w1Before?.start).toEqual({ x: 0, y: 0 });
// Simuliert einen Drag: viele schnelle moveGripOf-Aufrufe auf DASSELBE
// Element/denselben Griff, wie sie bei jedem Maus-Move-Event entstehen
// (siehe gripHandlers.onGripMove in App.tsx → moveGripOf).
for (let i = 1; i <= 20; i++) {
getState().moveGripOf(null, "W1", 0, { x: i * 0.1, y: 0 });
}
const w1After = getState().project.walls.find((w) => w.id === "W1");
expect(w1After?.start).toEqual({ x: 2, y: 0 });
// Trotz 20 Aufrufen: genau EIN Undo-Schritt.
expect(getState().undoStack.length).toBe(1);
getState().undo();
const w1Restored = getState().project.walls.find((w) => w.id === "W1");
expect(w1Restored?.start).toEqual({ x: 0, y: 0 });
expect(getState().undoStack.length).toBe(0);
});
it("zwei Drags auf UNTERSCHIEDLICHE Griffe erzeugen ZWEI Undo-Schritte", () => {
getState().moveGripOf(null, "W1", 0, { x: 1, y: 0 });
getState().moveGripOf(null, "W1", 1, { x: 6, y: 0 });
expect(getState().undoStack.length).toBe(2);
});
});