task(CAD-12a+b): autosave service with visible status indicator + global keyboard shortcuts (ESC, Delete, Strg+A/D, F7/F8/F3) + BOM dialog wiring fix
This commit is contained in:
@@ -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