Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 933dfcbac2 | |||
| 51d9c2f208 |
+3
-2
@@ -42,8 +42,8 @@
|
||||
**Offene Aufgaben:**
|
||||
|
||||
### MEILENSTEIN 1: Produktive Basis (CAD-12 — NÄCHSTER TASK)
|
||||
- [ ] CAD-12a: Autosave (CRDT-Sync alle 30s)
|
||||
- [ ] CAD-12b: Keyboard-Shortcuts (ESC, Delete, Strg+A/D, F7/F8/F3)
|
||||
- [x] CAD-12a: Autosave (CRDT-Sync alle 30s) *(2026-09-16; Log CAD-12)*
|
||||
- [x] CAD-12b: Keyboard-Shortcuts (ESC, Delete, Strg+A/D, F7/F8/F3) *(2026-09-16; Log CAD-12)*
|
||||
- [ ] CAD-12c: Visueller UI-Audit (Playwright-Screenshots, Vision-Modell)
|
||||
|
||||
### MEILENSTEIN 2: Alltagstauglichkeit (CAD-13)
|
||||
@@ -718,6 +718,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
|
||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-16 | CAD-12a+b | Autosave + Keyboard-Shortcuts: (1) autosaveService NEU — createAutosaveController mit 30s Interval, Status-Anzeige (Autom. gespeichert/Speichert/Offline) im Topbar via setSavedStatus, performFullSave pusht alle Elemente in SQLite-Tabellen (Redundanz zum CRDT-LevelDB das bereits automatisch speichert), markChanges fuer dirty-tracking, destroy fuer Cleanup; (2) keyboardShortcuts NEU — createKeyboardShortcuts Handler: ESC=Tool-Abbrechen, Delete/Backspace=Auswahl loeschen, Strg+A=Alles auswaehlen, Strg+D=Auswahl duplizieren (CRDT-setElement mit +20 Offset), F7=Grid, F8=Ortho, F3=Snap; Input-Felder werden ignoriert (kein Shortcut in Textfeldern); (3) Beide in App.tsx useEffect verdrahtet (nur wenn drawingId+token vorhanden), destroy bei Cleanup; (4) BOMDialog-Render-Verdrahtung die in CAD-9 fehlte ergaenzt (Action + Render) | Frontend: 884/884 Tests (+18), tsc 0, Build 0 | git | CAD-12a+b COMPLETE; naechster: CAD-12c (visueller UI-Audit mit Vision) |
|
||||
| 2026-09-13 | CAD-11 | Traversen Professional-Upgrade (Nutzer-Anforderungen): (1) chordCount NEU — Anzahl Gurtrohre 1-4 einstellbar (Option number 1-4, Default 2), Rendering gleichmaessig ueber die Breite verteilt (1=Mittellinie, 2=aeussere, 3/4=dichter), BOM gruppiert nach chordCount (2-rohr vs 4-rohr getrennt) mit Detail im Label; (2) FREIE LAENGENEINGABE — length von Select auf number geaendert (10-1000cm), Presets als Voreinstellungen ueber Optionsfeld, nicht mehr auf 4 Werte begrenzt; (3) VARIABLE ECK-WINKEL — angle-Option 15-165 Grad (Default 90), boxcornerEndpoints nutzt properties.angle fuer rot+angle statt fix rot+90, Rendering mit rot+angle Geometrie (8 Punkte), magnetisches Andocken arm-spezifisch; (4) KREIS-TRAVERSE NEU — circleTrussTool (Durchmesser 50-1500, Breite, Gurtrohre 1-4), Klick-Platzierer, circletruss-ElementType mit Aussen-/Innenkreis + Gurtrohr-Kreisen; (5) BOM-UPDATE — Kreis-Traversen mit Pi-d-Umfang als Gesamtlaenge, Eck-Winkel im Label wenn abweichend von 90, chordCount-Label | Frontend: 866 Tests (+21 trussPro, 2 trussUpgrade auf neues Verhalten angepasst), tsc 0, Build 0 | 875dbd6 | CAD-11 COMPLETE |
|
||||
| 2026-09-02 | CAD-10 | Ribbon-Kuratisierung (Nutzer-Kritik: verstreute Menues): VOLLSTANDIGER REWRITE von RibbonBar.tsx (369 auf 242 Zeilen): (1) 4 Tabs statt 7 — Start (Datei/Bearbeiten), Zeichnen (ALLE Werkzeuge), Ansicht, KI; (2) Zeichnen-Tab zeigt alle 48 V2-Werkzeuge in 3 logischen Gruppen: Zeichnen & Aendern (29 canvas-Tools), Bemassung & Text (9 format-Tools), Bestuhlung & Traversen (9 insert-Tools + Stueckliste-Button direkt neben den Traversen-Werkzeugen); (3) Ansicht-Tab konsolidiert: Zoom-Fit/100%/Raster/Layer + Hintergrund/Bild + Farben/Lineal + Historie/Gaeste; (4) Duplikate entfernt (Raster war in 2 Tabs); (5) RibbonTab-Typ auf start/draw/view/ki reduziert, uiMode-Test + Components-Tab-Test angepasst; (6) durchgaengig SVG-Icons; Zwischenfaelle: 2-mal Quoting-/Heredoc-Fehler beim Test-Patch (per sed behoben) + Session-Hang (reset) | Frontend: 845/845 Tests, tsc 0, Build EXIT 0 | 47c029b | CAD-10 COMPLETE |
|
||||
| 2026-08-31 | CAD-9 | Stueckliste (BOM) fuer Traversen: (1) bomService NEU — buildBOM: gruppiert alle truss-Elemente nach (Laenge x trussWidth) und boxcorner nach (armLength x trussWidth) in EINE Position je Mass mit Stueckzahl; Gesamt-Traverse-Meter mit Eck-Konvention BEIDE Arme zaehlen (50er-Ecke = 1.00m — eigener Test verifiziert die Konvention); Sortierung: Traversen vor Eck-Traversen, je Typ Laenge absteigend; Nicht-Traversen (chair etc.) still ignoriert; leerer Bestand → leere Liste ohne Crash; (2) exportBOMCsv: Semikolon-Separatoren (Excel-DE oeffnet direkt mehrspaltig), Spalten Pos/Typ/Laenge_cm/Breite_cm/Anzahl/Gesamt_cm/Bezeichnung, SUMME-Zeile nur wenn Positionen vorhanden (leere BOM = nur Header); (3) BOMDialog-Komponente: Modal-Tabelle mit allen Spalten + Summenzeile hervorgehoben, CSV-Download als Datei (UTF-8-BOM fuer Excel-Umlaute, Datum im Dateinamen), Empty-State mit Hinweis auf Traversen-Tools; (4) Ribbon-Button „Stueckliste“ in Extras-Gruppe neben Gäste, App-Verdrahtung ueber documentService.getActiveDocument().getAllElements(); Test-Zwischenfaelle selbst korrigiert: Gesamtlaenge-Erwartung 7.0m war falsch gerechnet (Ecken zaehlen beide Arme → 8.0m korrekt, konsistent mit Both-Arms-Test) + SUMME-Zeile bei leerer BOM unterdrueckt | Frontend: 845/845 Tests (+13 bomExport.test.ts), tsc 0, Build EXIT 0 | 4398c67 | CAD-9 COMPLETE |
|
||||
|
||||
@@ -18,6 +18,10 @@ import StatusBar from './components/StatusBar';
|
||||
import LayoutBar from './components/LayoutBar';
|
||||
import MobileDrawers from './components/MobileDrawers';
|
||||
import BackgroundImport from './components/BackgroundImport';
|
||||
import BOMDialog from './components/BOMDialog';
|
||||
import { createAutosaveController } from './services/autosaveService';
|
||||
import { createKeyboardShortcuts } from './services/keyboardShortcuts';
|
||||
import { getActiveDocument } from './kernel/document/documentService';
|
||||
import HistoryPanel from './components/HistoryPanel';
|
||||
import V2HistoryPanel from './components/V2HistoryPanel';
|
||||
import GuestPanel from './components/GuestPanel';
|
||||
@@ -178,8 +182,78 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
|
||||
// History panel
|
||||
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
|
||||
const [bomDialogOpen, setBomDialogOpen] = useState(false);
|
||||
const [guestPanelOpen, setGuestPanelOpen] = useState(false);
|
||||
|
||||
// ── Task CAD-9: BOM-Action ──
|
||||
useEffect(() => {
|
||||
if (!drawingId || !token) return;
|
||||
// noop: BOM-Action wird über handleRibbonAction verdrahtet (unten)
|
||||
}, [drawingId, token]);
|
||||
|
||||
// ── Task CAD-12a/12b: Autosave + Keyboard-Shortcuts ──
|
||||
useEffect(() => {
|
||||
if (!drawingId || !token) return;
|
||||
|
||||
// Autosave: Status im Topbar sichtbar machen (CAD-12a)
|
||||
const autosave = createAutosaveController({
|
||||
isConnected: () => collab.status === 'connected',
|
||||
hasUnsavedChanges: () => elements.length > 0,
|
||||
performFullSave: async () => {
|
||||
await Promise.all(
|
||||
elements.map((el) =>
|
||||
updateElement(token, el.id, el).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
},
|
||||
onStatusChange: (s) => {
|
||||
if (s.state === 'saving') setSavedStatus('Speichert…');
|
||||
else if (s.state === 'saved') setSavedStatus('Autom. gespeichert');
|
||||
else if (s.state === 'offline') setSavedStatus('Offline');
|
||||
},
|
||||
});
|
||||
|
||||
// Keyboard-Shortcuts (CAD-12b)
|
||||
const ks = createKeyboardShortcuts({
|
||||
escape: () => setActiveTool('select'),
|
||||
delete: () => {
|
||||
const ids = selectedElementIdsRef.current;
|
||||
if (ids.length > 0) handleElementsDeleted(ids);
|
||||
},
|
||||
selectAll: () => {
|
||||
setSelectedElementIds(elements.map((el) => el.id));
|
||||
},
|
||||
duplicate: () => {
|
||||
const ids = selectedElementIdsRef.current;
|
||||
if (ids.length > 0) {
|
||||
elements
|
||||
.filter((el) => ids.includes(el.id))
|
||||
.forEach((el) => {
|
||||
const copy = {
|
||||
...el,
|
||||
id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
||||
x: el.x + 20,
|
||||
y: el.y + 20,
|
||||
} as typeof el;
|
||||
collab.setElement(copy);
|
||||
});
|
||||
}
|
||||
},
|
||||
toggleGrid: () => setGridEnabled((g) => !g),
|
||||
toggleOrtho: () => setOrthoEnabled((o) => !o),
|
||||
toggleSnap: () => setSnapEnabled((s) => !s),
|
||||
});
|
||||
|
||||
const keyHandler = (e: KeyboardEvent) => ks.handler(e);
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
|
||||
return () => {
|
||||
autosave.destroy();
|
||||
document.removeEventListener('keydown', keyHandler);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [drawingId, token, collab.status]);
|
||||
|
||||
// Export format selector (cross-browser replacement for nwsave)
|
||||
const [exportFormatOpen, setExportFormatOpen] = useState(false);
|
||||
|
||||
@@ -1164,6 +1238,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
|
||||
// ─── Background ───
|
||||
if (action === 'background-import') { setBgImportOpen(true); return; }
|
||||
if (action === 'bom') { setBomDialogOpen(true); return; }
|
||||
|
||||
// ─── KI actions ───
|
||||
if (action === 'ki') { setActiveRightPanel('ki'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'KI Copilot geöffnet', type: 'info' }]); return; }
|
||||
@@ -1749,6 +1824,12 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
||||
onApply={handleBgApply}
|
||||
backgroundService={bgServiceRef.current}
|
||||
/>
|
||||
{bomDialogOpen && getActiveDocument() && (
|
||||
<BOMDialog
|
||||
elements={getActiveDocument()!.getAllElements()}
|
||||
onClose={() => setBomDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{historyPanelOpen && (V2_TOOLS_ENABLED ? (
|
||||
<V2HistoryPanel
|
||||
onClose={() => setHistoryPanelOpen(false)}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Task CAD-12a – Autosave-Service.
|
||||
*
|
||||
* Der Yjs-CRDT speichert bereits automatisch über WebSocket → LevelDB.
|
||||
* Dieser Service ergänzt: (1) periodischen Full-Save in die SQLite-Tabellen
|
||||
* (Redundanz), (2) sichtbaren Status (Gespeichert/Speichert.../Offline),
|
||||
* (3) Reconnect-Handling (CRDT synced nach).
|
||||
*/
|
||||
|
||||
export const AUTOSAVE_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface AutosaveStatus {
|
||||
state: 'idle' | 'saved' | 'saving' | 'offline';
|
||||
lastSaved: Date | null;
|
||||
}
|
||||
|
||||
export interface AutosaveDeps {
|
||||
/** Ist der CRDT-WebSocket verbunden? */
|
||||
isConnected(): boolean;
|
||||
/** Gibt es ungespeicherte Änderungen (seit letztem Full-Save)? */
|
||||
hasUnsavedChanges(): boolean;
|
||||
/** Speichert den Zustand (Promise). */
|
||||
performFullSave(): Promise<void>;
|
||||
/** Status-Callback (UI-Update). */
|
||||
onStatusChange(status: AutosaveStatus): void;
|
||||
}
|
||||
|
||||
export interface AutosaveController {
|
||||
getStatus(): AutosaveStatus;
|
||||
/** Merkt eine Änderung (nächstes Interval speichert). */
|
||||
markChanges(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createAutosaveController(deps: AutosaveDeps): AutosaveController {
|
||||
let state: AutosaveStatus['state'] = 'idle';
|
||||
let lastSaved: Date | null = null;
|
||||
let dirty = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
const setStatus = (s: AutosaveStatus['state']): void => {
|
||||
state = s;
|
||||
deps.onStatusChange({ state, lastSaved });
|
||||
};
|
||||
|
||||
const tick = (): void => {
|
||||
if (destroyed) return;
|
||||
if (!deps.isConnected()) { setStatus('offline'); return; }
|
||||
if (!dirty && !deps.hasUnsavedChanges()) { setStatus('saved'); return; }
|
||||
setStatus('saving');
|
||||
deps.performFullSave()
|
||||
.then(() => {
|
||||
lastSaved = new Date();
|
||||
dirty = false;
|
||||
setStatus('saved');
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus('offline');
|
||||
});
|
||||
};
|
||||
|
||||
// Init: Status sofort melden
|
||||
if (!deps.isConnected()) setStatus('offline');
|
||||
else setStatus('saved');
|
||||
|
||||
timer = setInterval(tick, AUTOSAVE_INTERVAL_MS);
|
||||
|
||||
return {
|
||||
getStatus: () => ({ state, lastSaved }),
|
||||
markChanges: () => { dirty = true; },
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Task CAD-12b – Globale Keyboard-Shortcuts fuer den Editor.
|
||||
*
|
||||
* ESC = aktives Tool abbrechen
|
||||
* Delete/Backspace = Auswahl loeschen
|
||||
* Strg+A = alles auswaehlen
|
||||
* Strg+D = Auswahl duplizieren
|
||||
* F7 = Grid toggle, F8 = Ortho toggle, F3 = Snap toggle
|
||||
*
|
||||
* Input-Felder (INPUT, TEXTAREA, contentEditable) werden ignoriert.
|
||||
*/
|
||||
|
||||
export interface ShortcutAction {
|
||||
escape: () => void;
|
||||
delete: () => void;
|
||||
selectAll: () => void;
|
||||
duplicate: () => void;
|
||||
toggleGrid: () => void;
|
||||
toggleOrtho: () => void;
|
||||
toggleSnap: () => void;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutsController {
|
||||
handler: (e: KeyboardEvent) => void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createKeyboardShortcuts(actions: ShortcutAction): KeyboardShortcutsController {
|
||||
const isInputElement = (target: EventTarget | null): boolean => {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName?.toUpperCase() ?? '';
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true;
|
||||
};
|
||||
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if (isInputElement(e.target)) return;
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
actions.escape();
|
||||
break;
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
e.preventDefault();
|
||||
actions.delete();
|
||||
break;
|
||||
case 'a':
|
||||
case 'A':
|
||||
if (ctrl) { e.preventDefault(); actions.selectAll(); }
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
if (ctrl) { e.preventDefault(); actions.duplicate(); }
|
||||
break;
|
||||
case 'F7':
|
||||
e.preventDefault();
|
||||
actions.toggleGrid();
|
||||
break;
|
||||
case 'F8':
|
||||
e.preventDefault();
|
||||
actions.toggleOrtho();
|
||||
break;
|
||||
case 'F3':
|
||||
e.preventDefault();
|
||||
actions.toggleSnap();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handler,
|
||||
destroy: () => {
|
||||
// cleanup: caller removes listener
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Task CAD-12a - Autosave-Service.
|
||||
*
|
||||
* Der Yjs-CRDT speichert bereits automatisch ueber WebSocket.
|
||||
* Dieser Service macht das SICHTBAR und ergaenzt:
|
||||
* - Save-Status-Text (Automatisch gespeichert / Offline / Speichert...)
|
||||
* - Periodischer Full-Save in SQLite (Redundanz)
|
||||
* - Reconnect-Handling (CRDT synced automatisch nach)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createAutosaveController,
|
||||
type AutosaveStatus,
|
||||
AUTOSAVE_INTERVAL_MS,
|
||||
} from '../src/services/autosaveService';
|
||||
|
||||
describe('CAD-12a: autosaveService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('AUTOSAVE_INTERVAL_MS ist 30 Sekunden', () => {
|
||||
expect(AUTOSAVE_INTERVAL_MS).toBe(30000);
|
||||
});
|
||||
|
||||
it('Controller: Status ist "idle" wenn kein CRDT verbunden', () => {
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => false,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: vi.fn(),
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
const status: AutosaveStatus = ctrl.getStatus();
|
||||
expect(status.state).toBe('offline');
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: Status ist "saved" wenn CRDT verbunden und keine ungespeicherten Aenderungen', () => {
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: vi.fn(),
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
expect(ctrl.getStatus().state).toBe('saved');
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: Status wechselt zu "saving" wenn Aenderungen vorhanden und Timer feuert', () => {
|
||||
const fullSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onStatus = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => true,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: onStatus,
|
||||
});
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).toHaveBeenCalledTimes(1);
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: fuehrt KEINEN Full-Save aus wenn keine Aenderungen', () => {
|
||||
const fullSave = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).not.toHaveBeenCalled();
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: fuehrt KEINEN Full-Save aus wenn CRDT offline', () => {
|
||||
const fullSave = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => false,
|
||||
hasUnsavedChanges: () => true,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).not.toHaveBeenCalled();
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: Status-Meldung an onStatusChange bei Aenderung', () => {
|
||||
const onStatus = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: vi.fn(),
|
||||
onStatusChange: onStatus,
|
||||
});
|
||||
expect(onStatus).toHaveBeenCalledWith({ state: 'saved', lastSaved: null });
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: markChanges triggert saving beim naechsten Timer-Tick', () => {
|
||||
const fullSave = vi.fn().mockResolvedValue(undefined);
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => false,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
ctrl.markChanges();
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(fullSave).toHaveBeenCalled();
|
||||
ctrl.destroy();
|
||||
});
|
||||
|
||||
it('Controller: destroy stoppt den Timer', () => {
|
||||
const fullSave = vi.fn();
|
||||
const ctrl = createAutosaveController({
|
||||
isConnected: () => true,
|
||||
hasUnsavedChanges: () => true,
|
||||
performFullSave: fullSave,
|
||||
onStatusChange: vi.fn(),
|
||||
});
|
||||
ctrl.destroy();
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(fullSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Task CAD-12b - Globale Keyboard-Shortcuts fuer den Editor.
|
||||
*
|
||||
* ESC = aktives Tool abbrechen, Delete/Entf = Auswahl loeschen,
|
||||
* Strg+A = alles auswaehlen, Strg+D = Auswahl duplizieren,
|
||||
* F7 = Grid toggle, F8 = Ortho toggle, F3 = Snap toggle.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createKeyboardShortcuts, type ShortcutAction } from '../src/services/keyboardShortcuts';
|
||||
describe('CAD-12b: keyboardShortcuts', () => {
|
||||
let actions: Record<string, ReturnType<typeof vi.fn>>;
|
||||
let handler: (e: KeyboardEvent) => void;
|
||||
let cleanup: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
actions = {
|
||||
escape: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
toggleOrtho: vi.fn(),
|
||||
toggleSnap: vi.fn(),
|
||||
};
|
||||
const ctrl = createKeyboardShortcuts(actions as unknown as ShortcutAction);
|
||||
handler = ctrl.handler;
|
||||
cleanup = ctrl.destroy;
|
||||
});
|
||||
|
||||
afterEach(() => { cleanup(); });
|
||||
|
||||
const fire = (key: string, opts: { ctrl?: boolean; shift?: boolean } = {}) => {
|
||||
handler({ key, ctrlKey: opts.ctrl ?? false, shiftKey: opts.shift ?? false, preventDefault: vi.fn() } as unknown as KeyboardEvent);
|
||||
};
|
||||
|
||||
it('ESC ruft escape auf (Tool abbrechen)', () => {
|
||||
fire('Escape');
|
||||
expect(actions.escape).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Delete und Entf rufen delete auf (Auswahl loeschen)', () => {
|
||||
fire('Delete');
|
||||
expect(actions.delete).toHaveBeenCalledTimes(1);
|
||||
fire('Backspace');
|
||||
expect(actions.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Strg+A ruft selectAll auf', () => {
|
||||
fire('a', { ctrl: true });
|
||||
expect(actions.selectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Strg+D ruft duplicate auf', () => {
|
||||
fire('d', { ctrl: true });
|
||||
expect(actions.duplicate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('F7 ruft toggleGrid auf', () => {
|
||||
fire('F7');
|
||||
expect(actions.toggleGrid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('F8 ruft toggleOrtho auf', () => {
|
||||
fire('F8');
|
||||
expect(actions.toggleOrtho).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('F3 ruft toggleSnap auf', () => {
|
||||
fire('F3');
|
||||
expect(actions.toggleSnap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Buchstaben ohne Strg loesen KEINE Aktion aus', () => {
|
||||
fire('a');
|
||||
fire('d');
|
||||
fire('x');
|
||||
expect(actions.selectAll).not.toHaveBeenCalled();
|
||||
expect(actions.duplicate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Input-Feld-Fokus wird ignoriert (kein Shortcut wenn in Input/Textarea)', () => {
|
||||
// Simuliere: Event-Target ist ein Input-Element
|
||||
const inputEvent = {
|
||||
key: 'a',
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
target: { tagName: 'INPUT', isContentEditable: false },
|
||||
} as unknown as KeyboardEvent & { target: { tagName: string } };
|
||||
handler(inputEvent);
|
||||
expect(actions.selectAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user