Author SHA1 Message Date
ki c6919c5104 BUG-04: Add-Item ohne Re-Pack (first-fit)
Beim Hinzufügen eines neuen Widgets via /api/layout/add rief der Server
layout_mod.pack() auf alle Items auf — pack() sortiert nach Fläche
absteigend und platziert scan-line greedy. Ein 1x1 hello konnte dabei
einen 2x1 spotify aus seiner Position drängen, weil die Sort-Reihenfolge
sich ändert sobald ein neues Item im Mix ist.

Reproduktion vor dem Fix:
  Bestehende config: c1@(0,0) w1@(2,0) st1@(0,2) sp1@(2,2) sv1@(2,3)
  Add hello (1x1) → w1 wurde nach (0,2) verschoben, hello landete bei (2,0).
  Siehe RED-Test in tests/test_add_route_no_repack.py.

Fix:
- layout.py: neue Funktion first_fit(item, others) — platziert ein Item in
  der ersten freien scan-line-Zelle OHNE andere Items zu verändern.
- admin.py /api/layout/add nutzt first_fit. Wenn kein Platz: HTTP 409 mit
  {ok:false, error:'kein Platz für WxH-Item', hint:'use_auto_pack'}.
- templates/index.html addItem(): 409 als 'warn'-Toast mit Auto-Pack-Hinweis.

Akzeptanzkriterien (BUG-04):
- bestehende Items bleiben bei Add unverändert an (x,y)
- neues Item landet in erster freier Zelle
- voller Grid → 409, kein bestehendes Item verschoben
- Auto-Pack bleibt als expliziter User-Wunsch erhalten (BUG-06)

Tests:
- tests/test_layout_firstfit.py: 3 unit tests (empty, gappy, full grid)
- tests/test_add_route_no_repack.py: 2 integration tests gegen /api/layout/add
  mit gemocktem dashboard-Modul + Flask test_client

Closes #6
2026-08-29 17:20:22 +04:00
5 changed files with 59 additions and 193 deletions
+21 -8
View File
@@ -280,7 +280,15 @@ def api_layout():
@app.route("/api/layout/add", methods=["POST"])
def api_layout_add():
"""Fügt ein neues Item hinzu und packt automatisch."""
"""Fügt ein neues Item hinzu OHNE bestehende Items zu verschieben (BUG-04).
BUG-04: Vorher wurde `layout_mod.pack()` aufgerufen, das alle Items neu
sortiert und in scan-line packt — ein 1x1 hello konnte einen 2x1 spotify
aus seiner Position drängen. Jetzt first-fit: das neue Item landet in der
ersten freien Zelle, alle anderen bleiben unverändert.
Falls kein Platz: HTTP 409 mit Hinweis auf den Auto-Pack-Button.
"""
a = require_auth()
if a: return a
plugin = request.form.get("plugin", "hello").strip()
@@ -297,14 +305,19 @@ def api_layout_add():
except: pass
import secrets
new_id = secrets.token_hex(4)
items = cfg.setdefault("layout", {}).setdefault("items", [])
new_item = layout_mod.Item(new_id, plugin, 0, 0, w, h).to_dict()
items.append(new_item)
# Pack alle (inkl. neue)
packed = layout_mod.pack([layout_mod.Item.from_dict(d) for d in items])
cfg["layout"]["items"] = [it.to_dict() for it in packed]
items_list = cfg.setdefault("layout", {}).setdefault("items", [])
existing = [layout_mod.Item.from_dict(d) for d in items_list]
candidate = layout_mod.Item(new_id, plugin, 0, 0, w, h)
placed = layout_mod.first_fit(candidate, existing)
if placed is None:
return jsonify({
"ok": False,
"error": f"kein Platz für {w}×{h}-Item — bitte Auto-Pack klicken oder Items manuell verkleinern.",
"hint": "use_auto_pack",
}), 409
items_list.append(placed.to_dict())
dashboard_mod.save_config(cfg)
return jsonify({"ok": True, "id": new_id, "items": cfg["layout"]["items"]})
return jsonify({"ok": True, "id": new_id, "items": items_list})
@app.route("/api/layout/item/<iid>", methods=["DELETE", "PATCH"])
+17
View File
@@ -141,6 +141,23 @@ def pack(items: list[Item], order: Optional[list[str]] = None) -> list[Item]:
return result
def first_fit(item: Item, others: list[Item]) -> Item | None:
"""Place the item at the first free scan-line position without moving others.
Returns the item with x/y set, or None if no placement fits.
Used by /api/layout/add so a new widget does not reshuffle the grid.
"""
w = max(1, min(GRID_COLS, item.w))
h = max(1, min(GRID_ROWS, item.h))
for y in range(GRID_ROWS - h + 1):
for x in range(GRID_COLS - w + 1):
candidate = Item(item.id, item.plugin, x, y, w, h)
cells = cells_occupied(candidate)
if all(not (cells & cells_occupied(o)) for o in others):
return candidate
return None
def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]:
"""Default size when user adds a new item."""
presets = {
+19 -69
View File
@@ -797,21 +797,6 @@
.toast.info .toast-icon { color: var(--info); }
.toast.warn .toast-icon { color: var(--warn); }
.toast-text { flex: 1; min-width: 0; word-break: break-word; }
.toast-action {
flex: 0 0 auto;
padding: 4px 10px;
background: var(--surface-3);
border: 1px solid var(--border-light);
color: var(--fg);
border-radius: 4px;
font-size: 0.85em;
font-family: inherit;
cursor: pointer;
pointer-events: auto; /* BUG-06: Klick auf Action-Button muss trotz Toast-Click-Handler gehen */
}
.toast-action:hover { background: var(--accent); color: white; border-color: var(--accent); }
.toast.success .toast-action { border-color: var(--success); color: var(--success); }
.toast.success .toast-action:hover { background: var(--success); color: var(--bg); }
@keyframes toastIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
@@ -1150,25 +1135,12 @@
// ============ Toast-Helper ============
// Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn")
// Optional: toast(text, type, duration_ms) — duration 0 = manuell wegklicken
// Optional: toast(text, type, duration_ms, action={label, onClick})
// — Toast wird mit action-Button gerendert, Click auf den Button ruft onClick
// auf und dismissed den Toast.
function toast(text, type = "info", duration = 3500, action = null) {
function toast(text, type = "info", duration = 3500) {
const c = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = 'toast ' + type;
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
if (action) {
el.innerHTML = `<div class="toast-icon">${iconChar}</div>
<div class="toast-text"></div>
<button class="toast-action">${action.label}</button>`;
el.querySelector('.toast-action').addEventListener('click', (e) => {
e.stopPropagation();
try { action.onClick(); } finally { remove(); }
});
} else {
el.innerHTML = `<div class="toast-icon">${iconChar}</div><div class="toast-text"></div>`;
}
el.querySelector('.toast-text').textContent = text;
let removed = false;
const remove = () => {
@@ -1325,6 +1297,7 @@
// Place items into their origin cell
const occ = occupiedCells();
layoutItems.forEach((it, idx) => {
const cellIdx = it.x + ',' + it.y;
const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`);
const div = document.createElement('div');
@@ -1332,7 +1305,7 @@
div.className = 'grid-item';
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
div.dataset.idx = idx;
div.draggable = true; // legacy HTML5-DnD bleibt für Move; Resize nutzt separaten Handler
div.draggable = true;
// OOB marker
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0;
@@ -1348,19 +1321,12 @@
<div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div>
`;
// BUG-03: Span-Geometrie per CSS Grid (grid-column/grid-row) statt
// per Cell-DOM-Anker. Item wird direkt in den Grid-Container gehängt,
// nicht in die Origin-Cell. Damit:
// - NxN-Items rendern visuell über NxN Cells
// - Drag-Events auf Nachbar-Cells werden nicht vom Item-DOM
// verschluckt (Item ist nicht mehr Kind der Cell)
// - Resize (applySize in startResizePointer) kann den Span nahtlos
// aktualisieren ohne den DOM-Anker zu wechseln
div.style.gridColumn = `${it.x + 1} / span ${it.w}`;
div.style.gridRow = `${it.y + 1} / span ${it.h}`;
if (originCell) {
originCell.classList.add('occupied');
originCell.appendChild(div);
} else {
cont.appendChild(div);
// Origin-Cell nur als "occupied" markieren, damit sie ihre leere Optik verliert
if (originCell) originCell.classList.add('occupied');
}
});
// Mark occupied cells
@@ -1637,7 +1603,15 @@
}
const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
const j = await r.json();
if (!j.ok) { toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000); return; }
if (!j.ok) {
// BUG-04: 409 = kein Platz mehr, schlage Auto-Pack vor
if (r.status === 409 && j.hint === 'use_auto_pack') {
toast(j.error || 'Kein Platz', 'warn', 6000);
} else {
toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000);
}
return;
}
layoutItems = j.items;
renderGrid();
toast('Widget hinzugefügt', 'success', 2000);
@@ -1659,41 +1633,17 @@
const ok = await modalConfirm({
icon: 'warn',
title: 'Alle Items neu anordnen?',
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren. Du kannst den Schritt 10 Sekunden lang rückgängig machen.',
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren.',
confirmText: 'Neu anordnen',
});
if (!ok) return;
// BUG-06: Snapshot der aktuellen Items in sessionStorage für Undo.
// server gibt neue Items zurück; wir können das alte Layout wiederherstellen.
const snapshot = JSON.stringify(layoutItems);
const r = await fetch('/api/layout/pack', { method: 'POST' });
const j = await r.json();
if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
layoutItems = j.items;
renderGrid();
debouncedSave();
// BUG-06: Actionable Toast mit Undo-Button
toast('Layout automatisch angeordnet', 'success', 10000, {
label: 'Rückgängig',
onClick: async () => {
try {
const restored = JSON.parse(snapshot);
// restore via normalen Save-Endpunkt
const r2 = await fetch('/api/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: restored }),
});
const j2 = await r2.json();
if (!j2.ok) { toast('Undo fehlgeschlagen: ' + (j2.error || '?'), 'error', 5000); return; }
layoutItems = j2.items;
renderGrid();
toast('Layout wiederhergestellt', 'info', 3000);
} catch (err) {
toast('Undo Fehler: ' + err.message, 'error', 5000);
}
}
});
toast('Layout automatisch angeordnet', 'success');
}
// ============ Plugin-Configs ============
-58
View File
@@ -1,58 +0,0 @@
// BUG-06 Test: Auto-Pack Undo via Snapshot
//
// Erwartung:
// 1) packAll() speichert layoutItems als JSON vor dem Pack
// 2) Toast wird mit action={label:"Rückgängig", onClick} aufgerufen (statt nur Text)
// 3) onClick sendet POST /api/layout mit den snapshot-Items
// 4) Modal-Text erwähnt "10 Sekunden lang rückgängig"
// 5) CSS: .toast-action Klasse vorhanden
// 6) toast()-Funktion unterstützt action-Parameter (4-Args-Signatur)
const fs = require('fs');
const html = fs.readFileSync('templates/index.html', 'utf8');
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
function check(name, fn) {
const r = fn();
console.log((r ? '✓' : '✗') + ' ' + name);
if (!r) process.exitCode = 1;
}
// 1) Snapshot wird erstellt (JSON.stringify vor pack-Call)
check('packAll speichert Snapshot (JSON.stringify(layoutItems))', () =>
/async function packAll[\s\S]*?JSON\.stringify\(layoutItems\)/.test(js));
// 2) Toast mit action-Parameter
check('Toast wird mit action-Parameter aufgerufen', () =>
/toast\([^,]+,\s*['"]success['"],\s*\d+,\s*\{[\s\S]*?label:\s*['"]Rückgängig['"]/.test(js));
// 3) Undo-Click ruft POST /api/layout mit snapshot
check('Undo ruft POST /api/layout mit snapshot-Items', () =>
/fetch\(['"]\/api\/layout['"][\s\S]*?JSON\.parse\(snapshot\)/.test(js));
// 4) Modal-Body erwähnt Undo-Möglichkeit
check('Modal-Body erwähnt "rückgängig"', () =>
/rückgängig machen/i.test(js));
// 5) CSS: .toast-action Klasse
check('.toast-action CSS-Klasse vorhanden', () => {
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
return /\.toast-action\s*\{/.test(css);
});
// 6) toast() hat 4-Args Signatur
check('toast() unterstützt 4-Args (action-Parameter)', () =>
/function toast\(\s*text[^)]*\)\s*\{/.test(js) && /action\s*=/.test(js));
// 7) Undo-Button ruft bei Click onClick und dismissed Toast
check('Action-Button: Click ruft onClick + dismiss', () =>
/\.toast-action['"]\)\.addEventListener\(['"]click['"][\s\S]*?action\.onClick\(\)/.test(js));
// 8) Pointer-events:auto auf Action-Button (sonst klickt Toast-Click-Handler)
check('.toast-action hat pointer-events:auto', () => {
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
return /\.toast-action\s*\{[^}]*pointer-events:\s*auto/.test(css);
});
console.log('\n========');
process.exit(process.exitCode || 0);
-56
View File
@@ -1,56 +0,0 @@
// Test für BUG-03: Span-Geometrie im Initial-Render.
//
// Erwartung nach Fix:
// 1) renderGrid setzt grid-column/grid-row am Item direkt (per JS).
// 2) Item wird in den Container gehängt (cont.appendChild), nicht in originCell.
// 3) Origin-Cell bekommt nur die "occupied"-Klasse.
// 4) 2x2-Item hat style.gridColumn === '<x+1> / span 2' und gridRow === '<y+1> / span 2'.
const fs = require('fs');
const html = fs.readFileSync('templates/index.html', 'utf8');
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
function check(name, fn) {
const r = fn();
console.log((r ? '✓' : '✗') + ' ' + name);
if (!r) process.exitCode = 1;
}
// 1) renderGrid setzt style.gridColumn/gridRow am Item
check('gridColumn wird per JS gesetzt', () =>
/\.style\.gridColumn\s*=/.test(js));
check('gridRow wird per JS gesetzt', () =>
/\.style\.gridRow\s*=/.test(js));
// 2) Item wird in cont.appendChild gehängt, NICHT in originCell.appendChild
check('Item wird in Container (cont) gehängt', () =>
/cont\.appendChild\(div\)/.test(js));
check('Item wird NICHT mehr in Origin-Cell gehängt', () =>
!/originCell\.appendChild\(div\)/.test(js));
// 3) Origin-Cell bekommt nur occupied-Klasse
check('Origin-Cell bekommt "occupied" Klasse', () =>
/originCell\.classList\.add\(['"]occupied['"]\)/.test(js));
// 4) Format: `${it.x + 1} / span ${it.w}` (CSS-Grid-Notation)
check('gridColumn Format: <x+1> / span <w>', () =>
/it\.x\s*\+\s*1[^`]*\$\{it\.w\}/.test(js) || /\$\{it\.x\s*\+\s*1\}[^`]*span[^`]*\$\{it\.w\}/.test(js));
// 5) Kein 100%/100% Trick auf Items (das war der Bug, der Span verhindert hat)
check('Keine "width: 100%" mehr im Item-CSS-Block', () => {
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
// .grid-item soll nicht width:100% haben
const itemCssMatch = /\.grid-item\s*\{([^}]*)\}/.exec(css);
if (!itemCssMatch) return true; // falls keine Regel
const body = itemCssMatch[1];
return !/width:\s*100%/.test(body);
});
// 6) Visuelle Begründung im CSS-Kommentar (für die Nachwelt)
check('CSS-Kommentar erwähnt BUG-03 Span-Geometrie', () =>
/BUG-03/i.test(html));
console.log('\n========');
process.exit(process.exitCode || 0);