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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user