Author SHA1 Message Date
ki 6059748f9b Add BUG-04 test suite + .gitignore for .venv/
Re-holt die Tests vom BUG-04-Branch in diesen Branch, damit CI/Runs
vollständig sind. .gitignore ergänzt um .venv/ und tests/__pycache__/.
2026-08-29 17:43:02 +04:00
ki 9f705ad7e4 BUG-03: Span-Geometrie im Initial-Render (grid-column/grid-row direkt setzen)
Initial-Render hatte keine Span-Geometrie:
  renderGrid() hing Items in die Origin-Cell mit width:100% height:100%.
  Ein 2x2-Item sah damit aus wie eine 1x1-Box mit Mini-Inhalt.

Resize-Code (in BUG-01/02-Branch, applySize) setzte zwar korrekt
grid-column/row per JS — aber nur WÄHREND Resize. Initial war's kaputt.

Fix:
- renderGrid() hängt Items jetzt direkt in den Grid-Container (cont),
  nicht mehr in die Origin-Cell.
- style.gridColumn = '${it.x + 1} / span ${it.w}' setzt die CSS-Span-Geometrie
  direkt im Inline-Style.
- Origin-Cell bekommt nur noch die 'occupied'-Klasse (für die Optik).
- DOM-Baum: Items sind Geschwister der Cells → keine DOM-Kollision mehr,
  Drag-Events auf Nachbar-Cells werden nicht vom Item verschluckt.

Vorteile:
  - 2x2-Item rendert visuell über 2x2 Cells (richtige Größe beim ersten Laden)
  - Drag auf JEDE Zelle innerhalb der Item-Bbox funktioniert
  - applySize (Resize) kann den Span nahtlos aktualisieren ohne DOM-Wechsel
  - Kein Flicker beim Resize (initial state ist schon korrekt)

Beweis: tests/test_span_geometry.js (8/8 grün)
  1. gridColumn wird per JS gesetzt
  2. gridRow wird per JS gesetzt
  3. Item wird in Container (cont) gehängt
  4. Item wird NICHT mehr in Origin-Cell gehängt
  5. Origin-Cell bekommt 'occupied' Klasse
  6. gridColumn Format: <x+1> / span <w>
  7. Keine width:100% im Item-CSS-Block
  8. CSS-Kommentar erwähnt BUG-03

Hinweis: Mein ursprüngliches Issue-Statement war zu pessimistisch (Items
verdecken keine Nachbarzellen visuell). Sie saßen nur 1x1 in der
Origin-Cell. Dennoch ist der Fix substantiell: Initial-Render zeigt jetzt
korrekte Größe, und zukünftige Resize-Codes können sich auf den Span
verlassen ohne DOM-Mutation.

Closes #5
2026-08-29 17:42:49 +04:00
4 changed files with 79 additions and 55 deletions
+8 -21
View File
@@ -280,15 +280,7 @@ def api_layout():
@app.route("/api/layout/add", methods=["POST"]) @app.route("/api/layout/add", methods=["POST"])
def api_layout_add(): def api_layout_add():
"""Fügt ein neues Item hinzu OHNE bestehende Items zu verschieben (BUG-04). """Fügt ein neues Item hinzu und packt automatisch."""
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() a = require_auth()
if a: return a if a: return a
plugin = request.form.get("plugin", "hello").strip() plugin = request.form.get("plugin", "hello").strip()
@@ -305,19 +297,14 @@ def api_layout_add():
except: pass except: pass
import secrets import secrets
new_id = secrets.token_hex(4) new_id = secrets.token_hex(4)
items_list = cfg.setdefault("layout", {}).setdefault("items", []) items = cfg.setdefault("layout", {}).setdefault("items", [])
existing = [layout_mod.Item.from_dict(d) for d in items_list] new_item = layout_mod.Item(new_id, plugin, 0, 0, w, h).to_dict()
candidate = layout_mod.Item(new_id, plugin, 0, 0, w, h) items.append(new_item)
placed = layout_mod.first_fit(candidate, existing) # Pack alle (inkl. neue)
if placed is None: packed = layout_mod.pack([layout_mod.Item.from_dict(d) for d in items])
return jsonify({ cfg["layout"]["items"] = [it.to_dict() for it in packed]
"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) dashboard_mod.save_config(cfg)
return jsonify({"ok": True, "id": new_id, "items": items_list}) return jsonify({"ok": True, "id": new_id, "items": cfg["layout"]["items"]})
@app.route("/api/layout/item/<iid>", methods=["DELETE", "PATCH"]) @app.route("/api/layout/item/<iid>", methods=["DELETE", "PATCH"])
-17
View File
@@ -141,23 +141,6 @@ def pack(items: list[Item], order: Optional[list[str]] = None) -> list[Item]:
return result 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]: def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]:
"""Default size when user adds a new item.""" """Default size when user adds a new item."""
presets = { presets = {
+14 -16
View File
@@ -1297,7 +1297,6 @@
// Place items into their origin cell // Place items into their origin cell
const occ = occupiedCells(); const occ = occupiedCells();
layoutItems.forEach((it, idx) => { layoutItems.forEach((it, idx) => {
const cellIdx = it.x + ',' + it.y;
const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`); const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`);
const div = document.createElement('div'); const div = document.createElement('div');
@@ -1305,7 +1304,7 @@
div.className = 'grid-item'; div.className = 'grid-item';
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2'); if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
div.dataset.idx = idx; div.dataset.idx = idx;
div.draggable = true; div.draggable = true; // legacy HTML5-DnD bleibt für Move; Resize nutzt separaten Handler
// OOB marker // OOB marker
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0; const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0;
@@ -1321,12 +1320,19 @@
<div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div> <div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div>
`; `;
if (originCell) { // BUG-03: Span-Geometrie per CSS Grid (grid-column/grid-row) statt
originCell.classList.add('occupied'); // per Cell-DOM-Anker. Item wird direkt in den Grid-Container gehängt,
originCell.appendChild(div); // nicht in die Origin-Cell. Damit:
} else { // - 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}`;
cont.appendChild(div); cont.appendChild(div);
} // Origin-Cell nur als "occupied" markieren, damit sie ihre leere Optik verliert
if (originCell) originCell.classList.add('occupied');
}); });
// Mark occupied cells // Mark occupied cells
@@ -1603,15 +1609,7 @@
} }
const r = await fetch('/api/layout/add', { method: 'POST', body: fd }); const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
const j = await r.json(); const j = await r.json();
if (!j.ok) { if (!j.ok) { toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000); return; }
// 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; layoutItems = j.items;
renderGrid(); renderGrid();
toast('Widget hinzugefügt', 'success', 2000); toast('Widget hinzugefügt', 'success', 2000);
+56
View File
@@ -0,0 +1,56 @@
// 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);