Compare commits

..

2 Commits

3 changed files with 81 additions and 11 deletions
+3 -2
View File
@@ -717,6 +717,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
| 2026-08-28 | F2-backend | Block-Favoriten: schema.sql is_favorite INTEGER DEFAULT 0 + SqliteAdapter-Migration (PRAGMA table_info-Muster) für Bestands-DBs, mapGlobalBlockRow 0/1→boolean, createGlobalBlock/updateGlobalBlock erweitert, DBGlobalBlock.is_favorite: boolean, PATCH /api/global-blocks/:id validiert is_favorite (nicht-boolean→400); ROT-Beweis: 5 Tests zuerst ROT (is_favorite fehlte komplett), dann GRUEN | Backend: 263/263 Tests (+5), tsc 0 | 83a94e4 | F2-BACKEND COMPLETE | | 2026-08-28 | F2-backend | Block-Favoriten: schema.sql is_favorite INTEGER DEFAULT 0 + SqliteAdapter-Migration (PRAGMA table_info-Muster) für Bestands-DBs, mapGlobalBlockRow 0/1→boolean, createGlobalBlock/updateGlobalBlock erweitert, DBGlobalBlock.is_favorite: boolean, PATCH /api/global-blocks/:id validiert is_favorite (nicht-boolean→400); ROT-Beweis: 5 Tests zuerst ROT (is_favorite fehlte komplett), dann GRUEN | Backend: 263/263 Tests (+5), tsc 0 | 83a94e4 | F2-BACKEND COMPLETE |
| 2026-08-28 | F2-frontend | BlockLibraryTree: Suchfeld (global-lib-toolbar, filtert root+folder+favorites), ★/☆-Favorite-Toggle je Block (setGlobalBlockFavorite-API, optimistisches UI-Update), Favoriten-Sektion mit goldenem Titel, SVG-Thumbnail-Vorschau (tree-thumb, dangerouslySetInnerHTML, Fallback 📦), Grid-Ansicht (lib-grid, Thumbnail-Zellen mit Drag-Drop + Kontextmenü + Favorit); GlobalBlock.is_favorite: boolean in api.ts | Frontend: 491/491 Tests, tsc 0, build OK | 8b50c0a | **F2 COMPLETE — LibraryPanel-Kernfeatures**: Baum+Suche+Favoriten+Thumbnails+Grid+DragDrop→Canvas (Drop-Handler existierte bereits) | | 2026-08-28 | F2-frontend | BlockLibraryTree: Suchfeld (global-lib-toolbar, filtert root+folder+favorites), ★/☆-Favorite-Toggle je Block (setGlobalBlockFavorite-API, optimistisches UI-Update), Favoriten-Sektion mit goldenem Titel, SVG-Thumbnail-Vorschau (tree-thumb, dangerouslySetInnerHTML, Fallback 📦), Grid-Ansicht (lib-grid, Thumbnail-Zellen mit Drag-Drop + Kontextmenü + Favorit); GlobalBlock.is_favorite: boolean in api.ts | Frontend: 491/491 Tests, tsc 0, build OK | 8b50c0a | **F2 COMPLETE — LibraryPanel-Kernfeatures**: Baum+Suche+Favoriten+Thumbnails+Grid+DragDrop→Canvas (Drop-Handler existierte bereits) |
| 2026-08-28 | F3 | blockThumbnail.ts NEU (generateBlockSvg/primitivesToSvg: Elemente→elementToPrimitives→eigenständiges SVG mit PAD-BBox, alle 6 Primitive-Kinds, XML-Escape, Arc→path mit Konvention 0°=3Uhr/CCW); Save-Button 💾 im Bibliotheks-Header (liest __v2GetSelection + documentService.getActiveDocument, Name via prompt, block_data=Element-Array → Drop-Handler akzeptiert Arrays, svg_data=Thumbnail, createGlobalBlock+loadData-Refresh); Dokumentierte Abweichung: SVG statt canvas.toDataURL (konsistent, testbar); ROT-Beweis: 4 Tests zuerst fehlgeschlagen (Modul fehlte), 1 Erwartung nach Primitive-BBox-Analyse korrigiert (-50 -50 560 360); Fix: Duplikat-Import + Handler vor loadData-Deklaration (tsc 2300/2448) | Frontend: 495/495 Tests (+4), tsc 0, build OK | 5d8253a | **F3 COMPLETE — Auswahl als Block speichern**: Canvas-Selektion → wiederverwendbarer Block mit Thumbnail, sofort per DragDrop einsetzbar | | 2026-08-28 | F3 | blockThumbnail.ts NEU (generateBlockSvg/primitivesToSvg: Elemente→elementToPrimitives→eigenständiges SVG mit PAD-BBox, alle 6 Primitive-Kinds, XML-Escape, Arc→path mit Konvention 0°=3Uhr/CCW); Save-Button 💾 im Bibliotheks-Header (liest __v2GetSelection + documentService.getActiveDocument, Name via prompt, block_data=Element-Array → Drop-Handler akzeptiert Arrays, svg_data=Thumbnail, createGlobalBlock+loadData-Refresh); Dokumentierte Abweichung: SVG statt canvas.toDataURL (konsistent, testbar); ROT-Beweis: 4 Tests zuerst fehlgeschlagen (Modul fehlte), 1 Erwartung nach Primitive-BBox-Analyse korrigiert (-50 -50 560 360); Fix: Duplikat-Import + Handler vor loadData-Deklaration (tsc 2300/2448) | Frontend: 495/495 Tests (+4), tsc 0, build OK | 5d8253a | **F3 COMPLETE — Auswahl als Block speichern**: Canvas-Selektion → wiederverwendbarer Block mit Thumbnail, sofort per DragDrop einsetzbar |
| 2026-08-28 | ids | Nebenproblem „Date.now()-IDs“ erledigt: Alle 9 verbleibenden Auto-ID-Generierungen im SqliteAdapter (user/proj/pfolder/draw/layer/elem/block/notif/share) auf etabliertes randomUUID().slice(0,8)-Muster umgestellt (gleiche BUG-1-Kollisionsklasse wie gblock-Fix in F1); Deterministischer ROT-Beweis: 4 neue Tests mit vi.useFakeTimers+EINGEFRORENER Systemzeit (zwei Creates im selben Tick → UNIQUE-PK-Exception mit Date.now, eindeutig mit UUID); perl-Escape-Fehler (\${p} literal) selbst erkannt und zeilenweise korrigiert | Backend: 267/267 Tests (+4), tsc 0 | c78d8d8 | **BUG-1-KLASSE GESCHLOSSEN** — keine Date.now()-Auto-IDs mehr im Backend |
## Gefundene Nebenprobleme (noch nicht zugewiesen) ## Gefundene Nebenprobleme (noch nicht zugewiesen)
- `webhook-receiver.py` im Root: Zweck unklar, ggf. entfernen/dokumentieren (Frage an Nutzer) - `webhook-receiver.py` im Root: Zweck unklar, ggf. entfernen/dokumentieren (Frage an Nutzer)
@@ -724,5 +725,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2) - `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
--- ---
*Nächster offener Schritt: **F4** (LibraryProviderExtension anbinden: builtin-catalog JSONs, Startersets) — Details in Teil 5. Alternativ: F5/F6 (DXF Parser/Writer erweitern), Nebenproblem „Date.now()-IDs“ (user/proj/pfolder/draw/layer/block → randomUUID), Legacy-Entsorgung nach visueller Verifikation, oder Phase G (Layouts/PDF).* *Nächster offener Schritt: **F4** (LibraryProviderExtension anbinden: builtin-catalog JSONs, Startersets) — Details in Teil 5. Alternativ: F5/F6 (DXF Parser/Writer erweitern), Legacy-Entsorgung nach visueller Verifikation, oder Phase G (Layouts/PDF).*
*Status 2026-08-28: Phasen B+A+C+D+E+F1+F2+F3 komplett. FE 495/495 Tests, BE 263/263, tsc/Build grün. Alle Commits auf Forgejo main.* *Status 2026-08-28: Phasen B+A+C+D+E+F1+F2+F3 komplett, BUG-1-Kollisionsklasse geschlossen. FE 495/495 Tests, BE 267/267, tsc/Build grün. Alle Commits auf Forgejo main.*
+9 -9
View File
@@ -56,7 +56,7 @@ export class SqliteAdapter implements DatabaseInterface {
} }
createUser(data: Partial<DBUser>): DBUser { createUser(data: Partial<DBUser>): DBUser {
const id = data.id ?? `user-${Date.now()}`; const id = data.id ?? `user-${randomUUID().slice(0, 8)}`;
this.db.prepare( this.db.prepare(
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer'); ).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer');
@@ -110,7 +110,7 @@ export class SqliteAdapter implements DatabaseInterface {
} }
createProject(data: Partial<DBProject>): DBProject { createProject(data: Partial<DBProject>): DBProject {
const id = data.id ?? `proj-${Date.now()}`; const id = data.id ?? `proj-${randomUUID().slice(0, 8)}`;
this.db.prepare( this.db.prepare(
'INSERT INTO projects (id, name, description, owner_id, folder_id) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO projects (id, name, description, owner_id, folder_id) VALUES (?, ?, ?, ?, ?)',
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default', data.folder_id ?? null); ).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default', data.folder_id ?? null);
@@ -149,7 +149,7 @@ export class SqliteAdapter implements DatabaseInterface {
} }
createProjectFolder(data: Partial<DBProjectFolder>): DBProjectFolder { createProjectFolder(data: Partial<DBProjectFolder>): DBProjectFolder {
const id = data.id ?? `pfolder-${Date.now()}`; const id = data.id ?? `pfolder-${randomUUID().slice(0, 8)}`;
this.db.prepare( this.db.prepare(
'INSERT INTO project_folders (id, name, parent_id, owner_id) VALUES (?, ?, ?, ?)', 'INSERT INTO project_folders (id, name, parent_id, owner_id) VALUES (?, ?, ?, ?)',
).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null, data.owner_id ?? 'user-default'); ).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null, data.owner_id ?? 'user-default');
@@ -183,7 +183,7 @@ export class SqliteAdapter implements DatabaseInterface {
} }
createDrawing(data: Partial<DBDrawing>): DBDrawing { createDrawing(data: Partial<DBDrawing>): DBDrawing {
const id = data.id ?? `draw-${Date.now()}`; const id = data.id ?? `draw-${randomUUID().slice(0, 8)}`;
this.db.prepare( this.db.prepare(
'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)', 'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)',
).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}'); ).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}');
@@ -212,7 +212,7 @@ export class SqliteAdapter implements DatabaseInterface {
} }
createLayer(data: Partial<DBLayer>): DBLayer { createLayer(data: Partial<DBLayer>): DBLayer {
const id = data.id ?? `layer-${Date.now()}`; const id = data.id ?? `layer-${randomUUID().slice(0, 8)}`;
// Idempotent: if a layer with the same id already exists, return it. // Idempotent: if a layer with the same id already exists, return it.
// This prevents race conditions under React StrictMode double-render. // This prevents race conditions under React StrictMode double-render.
const existing = this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer | undefined; const existing = this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer | undefined;
@@ -249,7 +249,7 @@ export class SqliteAdapter implements DatabaseInterface {
} }
createElement(data: Partial<DBElement>): DBElement { createElement(data: Partial<DBElement>): DBElement {
const id = data.id ?? `elem-${Date.now()}`; const id = data.id ?? `elem-${randomUUID().slice(0, 8)}`;
this.db.prepare( this.db.prepare(
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0, ).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0,
@@ -281,7 +281,7 @@ export class SqliteAdapter implements DatabaseInterface {
} }
createBlock(data: Partial<DBBlock>): DBBlock { createBlock(data: Partial<DBBlock>): DBBlock {
const id = data.id ?? `block-${Date.now()}`; const id = data.id ?? `block-${randomUUID().slice(0, 8)}`;
this.db.prepare( this.db.prepare(
'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null, ).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null,
@@ -352,7 +352,7 @@ export class SqliteAdapter implements DatabaseInterface {
} }
createNotification(data: Partial<DBNotification>): DBNotification { createNotification(data: Partial<DBNotification>): DBNotification {
const id = data.id ?? `notif-${Date.now()}`; const id = data.id ?? `notif-${randomUUID().slice(0, 8)}`;
this.db.prepare( this.db.prepare(
'INSERT INTO notifications (id, user_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, ?)', 'INSERT INTO notifications (id, user_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, data.user_id!, data.type ?? 'info', data.title!, data.message!, data.read ?? 0); ).run(id, data.user_id!, data.type ?? 'info', data.title!, data.message!, data.read ?? 0);
@@ -377,7 +377,7 @@ export class SqliteAdapter implements DatabaseInterface {
} }
createProjectShare(data: Partial<DBProjectShare>): DBProjectShare { createProjectShare(data: Partial<DBProjectShare>): DBProjectShare {
const id = data.id ?? `share-${Date.now()}`; const id = data.id ?? `share-${randomUUID().slice(0, 8)}`;
const shareToken = data.share_token ?? randomUUID(); const shareToken = data.share_token ?? randomUUID();
this.db.prepare( this.db.prepare(
'INSERT INTO project_shares (id, project_id, shared_with_email, shared_by, permission, share_token) VALUES (?, ?, ?, ?, ?, ?)', 'INSERT INTO project_shares (id, project_id, shared_with_email, shared_by, permission, share_token) VALUES (?, ?, ?, ?, ?, ?)',
+69
View File
@@ -381,3 +381,72 @@ describe('SqliteAdapter', () => {
}); });
}); });
}); });
// ─── ID-Kollisionsklasse: Date.now() → randomUUID (BUG-1-Familie) ──
// Deterministischer Beweis: Date.now() wird via Fake-Timers EINGEFROREN,
// damit zwei Creates garantiert im selben Tick landen. Mit Date.now()-IDs
// kollidiert der zweite INSERT am PRIMARY KEY (UNIQUE-Exception); mit
// randomUUID sind die IDs stets eindeutig.
import { vi } from 'vitest';
const FROZEN_NOW = 1756000000000;
describe('SqliteAdapter auto-ID uniqueness within same tick', () => {
let db: SqliteAdapter;
beforeAll(async () => {
vi.useFakeTimers();
vi.setSystemTime(FROZEN_NOW);
db = new SqliteAdapter(':memory:');
await db.init();
});
afterAll(() => {
db.close();
vi.useRealTimers();
});
it('createUser liefert unterschiedliche IDs bei zwei Creates im selben Tick', () => {
const a = db.createUser({ email: `tick-a-${Date.now()}@x.de`, password_hash: 'h', name: 'A' });
const b = db.createUser({ email: `tick-b-${Date.now()}@x.de`, password_hash: 'h', name: 'B' });
expect(b.id).not.toBe(a.id);
});
it('createProject/createProjectFolder liefern unterschiedliche IDs im selben Tick', () => {
const p1 = db.createProject({ name: 'P1' });
const p2 = db.createProject({ name: 'P2' });
expect(p2.id).not.toBe(p1.id);
const f1 = db.createProjectFolder({ name: 'F1' });
const f2 = db.createProjectFolder({ name: 'F2' });
expect(f2.id).not.toBe(f1.id);
});
it('createDrawing/createLayer/createElement/createBlock liefern unterschiedliche IDs im selben Tick', () => {
const proj = db.createProject({ name: 'DrawHost' });
const d1 = db.createDrawing({ project_id: proj.id, name: 'D1' });
const d2 = db.createDrawing({ project_id: proj.id, name: 'D2' });
expect(d2.id).not.toBe(d1.id);
const l1 = db.createLayer({ drawing_id: d1.id, name: 'L1' });
const l2 = db.createLayer({ drawing_id: d1.id, name: 'L2' });
expect(l2.id).not.toBe(l1.id);
const e1 = db.createElement({ drawing_id: d1.id, layer_id: l1.id, type: 'line' });
const e2 = db.createElement({ drawing_id: d1.id, layer_id: l1.id, type: 'line' });
expect(e2.id).not.toBe(e1.id);
const b1 = db.createBlock({ drawing_id: d1.id, name: 'B1' });
const b2 = db.createBlock({ drawing_id: d1.id, name: 'B2' });
expect(b2.id).not.toBe(b1.id);
});
it('createNotification/createProjectShare liefern unterschiedliche IDs im selben Tick', () => {
const proj = db.createProject({ name: 'ShareHost' });
const n1 = db.createNotification({ user_id: 'user-default', title: 'T1', message: 'M1' });
const n2 = db.createNotification({ user_id: 'user-default', title: 'T2', message: 'M2' });
expect(n2.id).not.toBe(n1.id);
const s1 = db.createProjectShare({ project_id: proj.id, shared_with_email: 'a@x.de', shared_by: 'user-default' });
const s2 = db.createProjectShare({ project_id: proj.id, shared_with_email: 'b@x.de', shared_by: 'user-default' });
expect(s2.id).not.toBe(s1.id);
});
});