Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6919c5104 |
@@ -13,3 +13,5 @@ minimax_*.png
|
|||||||
config.json
|
config.json
|
||||||
*.service
|
*.service
|
||||||
.backup/
|
.backup/
|
||||||
|
.venv/
|
||||||
|
tests/__pycache__/
|
||||||
|
|||||||
@@ -280,7 +280,15 @@ 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 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()
|
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()
|
||||||
@@ -297,14 +305,19 @@ 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 = cfg.setdefault("layout", {}).setdefault("items", [])
|
items_list = cfg.setdefault("layout", {}).setdefault("items", [])
|
||||||
new_item = layout_mod.Item(new_id, plugin, 0, 0, w, h).to_dict()
|
existing = [layout_mod.Item.from_dict(d) for d in items_list]
|
||||||
items.append(new_item)
|
candidate = layout_mod.Item(new_id, plugin, 0, 0, w, h)
|
||||||
# Pack alle (inkl. neue)
|
placed = layout_mod.first_fit(candidate, existing)
|
||||||
packed = layout_mod.pack([layout_mod.Item.from_dict(d) for d in items])
|
if placed is None:
|
||||||
cfg["layout"]["items"] = [it.to_dict() for it in packed]
|
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)
|
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"])
|
@app.route("/api/layout/item/<iid>", methods=["DELETE", "PATCH"])
|
||||||
|
|||||||
@@ -141,6 +141,23 @@ 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 = {
|
||||||
|
|||||||
+125
-181
@@ -452,9 +452,6 @@
|
|||||||
width: 18px; height: 18px;
|
width: 18px; height: 18px;
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
/* BUG-01: Hit-Fläche explizit, damit Pointer-Events sicher ankommen */
|
|
||||||
pointer-events: auto;
|
|
||||||
touch-action: none; /* BUG-01: verhindert Browser-Scroll auf Touch */
|
|
||||||
}
|
}
|
||||||
.grid-item-resize::before {
|
.grid-item-resize::before {
|
||||||
content: ''; position: absolute;
|
content: ''; position: absolute;
|
||||||
@@ -465,14 +462,9 @@
|
|||||||
border-color: transparent transparent var(--fg-dim) transparent;
|
border-color: transparent transparent var(--fg-dim) transparent;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
pointer-events: none; /* BUG-01: ::before ist nur Deko */
|
|
||||||
}
|
}
|
||||||
.grid-item:hover .grid-item-resize::before { opacity: 1; }
|
.grid-item:hover .grid-item-resize::before { opacity: 1; }
|
||||||
.grid-item-resize:hover::before { border-bottom-color: var(--accent); }
|
.grid-item-resize:hover::before { border-bottom-color: var(--accent); }
|
||||||
/* BUG-01: gedrückte Resize-Handles deutlich machen */
|
|
||||||
.grid-item-resize:active::before { border-bottom-color: var(--accent-bright); opacity: 1; }
|
|
||||||
/* BUG-01: gedrückte Items nicht als "grabbing" zeigen — wir nutzen Pointer-Events */
|
|
||||||
.grid-item:active { cursor: grabbing; }
|
|
||||||
|
|
||||||
.layout-status {
|
.layout-status {
|
||||||
font-size: 0.85em; color: var(--fg-muted);
|
font-size: 0.85em; color: var(--fg-muted);
|
||||||
@@ -1313,9 +1305,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;
|
||||||
// BUG-01: kein HTML5-draggable mehr — eigene Pointer-Events übernehmen
|
div.draggable = true;
|
||||||
// Move+Resize in startItemPointer / startResizePointer.
|
|
||||||
div.setAttribute('touch-action', 'none');
|
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -1351,44 +1341,99 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Drag state ----
|
// ---- Drag state ----
|
||||||
let dragState = null; // { itemIdx, originItem, ghost } — nur für Move verwendet
|
let dragState = null; // { itemIdx, originItem, ghost }
|
||||||
|
|
||||||
// ---- BUG-01 + BUG-02: Pointer-Event-basiertes Move & Resize ----
|
|
||||||
//
|
|
||||||
// Problem vorher:
|
|
||||||
// - Items hatten `draggable=true` (HTML5-DnD). Resize-Handle war Kind des
|
|
||||||
// Items → Browser fired `dragstart` bevor mousedown greifen konnte →
|
|
||||||
// Resize ging gar nicht oder nur sporadisch.
|
|
||||||
// - Resize-Drag rief pro mousemove ein komplettes renderGrid() auf → Jank.
|
|
||||||
//
|
|
||||||
// Lösung:
|
|
||||||
// - HTML5-draggable komplett raus. Move + Resize über Pointer-Events.
|
|
||||||
// - Resize-Handle ist pointer-event-Ziel UND stoppt Propagation → kein
|
|
||||||
// versehentlicher Move-Start beim Ziehen am Handle.
|
|
||||||
// - Beide Aktionen nutzen requestAnimationFrame, und nur die Geometrie
|
|
||||||
// wird via CSS (grid-column/grid-row) aktualisiert — kein renderGrid()
|
|
||||||
// während Drag. Erst beim Drop / Resize-End kommt der volle re-render
|
|
||||||
// + save.
|
|
||||||
function attachDragHandlers(cont, cellEls) {
|
function attachDragHandlers(cont, cellEls) {
|
||||||
// Click-to-select (Resize-Handle + Delete bleiben unberührt)
|
// ---- Item: drag start ----
|
||||||
cont.querySelectorAll('.grid-item').forEach(el => {
|
cont.querySelectorAll('.grid-item').forEach(el => {
|
||||||
el.addEventListener('click', e => {
|
el.addEventListener('click', e => {
|
||||||
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
|
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
|
||||||
selectItem(parseInt(el.dataset.idx));
|
selectItem(parseInt(el.dataset.idx));
|
||||||
});
|
});
|
||||||
|
|
||||||
// BUG-01: Move-Drag startet auf pointerdown, AUSSER wenn das Target
|
el.addEventListener('dragstart', e => {
|
||||||
// der Resize-Handle ist (der hat eigenen Handler und stoppt propagation).
|
const itemIdx = parseInt(el.dataset.idx);
|
||||||
el.addEventListener('pointerdown', startItemPointer);
|
const it = layoutItems[itemIdx];
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', String(itemIdx));
|
||||||
|
el.classList.add('dragging');
|
||||||
|
|
||||||
|
// Transparent 1x1 pixel drag image so browser doesn't show a default ghost
|
||||||
|
const empty = document.createElement('canvas');
|
||||||
|
empty.width = empty.height = 1;
|
||||||
|
e.dataTransfer.setDragImage(empty, 0, 0);
|
||||||
|
|
||||||
|
// Snapshot for ghost
|
||||||
|
dragState = { itemIdx, originItem: { ...it }, ghost: null };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resize-Handle: explizit eigene Pointer-Handler, stoppen sofort,
|
// ---- Cell: dragover — show ghost preview ----
|
||||||
// damit der Item-Handler nicht mitfeuert.
|
cellEls.forEach(cell => {
|
||||||
cont.querySelectorAll('.grid-item-resize').forEach(h => {
|
cell.addEventListener('dragover', e => {
|
||||||
h.addEventListener('pointerdown', startResizePointer);
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
if (!dragState) return;
|
||||||
|
const { itemIdx, originItem } = dragState;
|
||||||
|
const it = layoutItems[itemIdx];
|
||||||
|
const cellIdx = parseInt(cell.dataset.cell);
|
||||||
|
const col = cellIdx % gridCols;
|
||||||
|
const row = Math.floor(cellIdx / gridCols);
|
||||||
|
const tx = Math.max(0, Math.min(gridCols - it.w, col));
|
||||||
|
const ty = Math.max(0, Math.min(gridRows - it.h, row));
|
||||||
|
const fits = canPlace(tx, ty, it.w, it.h, itemIdx);
|
||||||
|
|
||||||
|
cell.classList.toggle('drop-target', fits);
|
||||||
|
cell.classList.toggle('drop-invalid', !fits);
|
||||||
|
|
||||||
|
// Ghost: show where item will land
|
||||||
|
updateGhost(cont, cellEls, tx, ty, it.w, it.h, itemIdx, fits);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete buttons
|
cell.addEventListener('dragleave', e => {
|
||||||
|
// Only clear if leaving to outside the cell
|
||||||
|
if (!cell.contains(e.relatedTarget)) {
|
||||||
|
cell.classList.remove('drop-target', 'drop-invalid');
|
||||||
|
removeGhost(cont);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cell.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragState) return;
|
||||||
|
const { itemIdx } = dragState;
|
||||||
|
const it = layoutItems[itemIdx];
|
||||||
|
const cellIdx = parseInt(cell.dataset.cell);
|
||||||
|
const col = cellIdx % gridCols;
|
||||||
|
const row = Math.floor(cellIdx / gridCols);
|
||||||
|
const tx = Math.max(0, Math.min(gridCols - it.w, col));
|
||||||
|
const ty = Math.max(0, Math.min(gridRows - it.h, row));
|
||||||
|
|
||||||
|
if (canPlace(tx, ty, it.w, it.h, itemIdx)) {
|
||||||
|
it.x = tx; it.y = ty;
|
||||||
|
renderGrid(); // full re-render after drop to reflect new layout
|
||||||
|
debouncedSave();
|
||||||
|
}
|
||||||
|
removeGhost(cont);
|
||||||
|
cell.classList.remove('drop-target', 'drop-invalid');
|
||||||
|
dragState = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Item dragend ----
|
||||||
|
cont.querySelectorAll('.grid-item').forEach(el => {
|
||||||
|
el.addEventListener('dragend', () => {
|
||||||
|
el.classList.remove('dragging');
|
||||||
|
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
|
||||||
|
removeGhost(cont);
|
||||||
|
dragState = null;
|
||||||
|
// Re-render to restore any mid-drag state changes (e.g. failed drop)
|
||||||
|
renderGrid();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Delete buttons ----
|
||||||
cont.querySelectorAll('.grid-item-delete').forEach(btn => {
|
cont.querySelectorAll('.grid-item-delete').forEach(btn => {
|
||||||
btn.addEventListener('click', async e => {
|
btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1409,151 +1454,15 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard: X to delete selected
|
// ---- Resize handles ----
|
||||||
|
cont.querySelectorAll('.grid-item-resize').forEach(h => {
|
||||||
|
h.addEventListener('mousedown', startResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Keyboard: X to delete selected ----
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Move (BUG-01: Pointer-Events, BUG-02: rAF + keine Render-per-Move) ----
|
|
||||||
function startItemPointer(e) {
|
|
||||||
// Resize-Handle hat eigenen Handler + stopPropagation — wir landen hier
|
|
||||||
// also nur, wenn User wirklich auf den Item-Body gedrückt hat.
|
|
||||||
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
|
|
||||||
if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste
|
|
||||||
e.preventDefault();
|
|
||||||
const idx = parseInt(e.currentTarget.dataset.idx);
|
|
||||||
const it = layoutItems[idx];
|
|
||||||
const cont = document.getElementById('gridPreview');
|
|
||||||
|
|
||||||
// Zellgröße einmal messen (Grid ändert sich nicht während Drag)
|
|
||||||
const cellGap = 6;
|
|
||||||
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
|
|
||||||
const cellH = 120;
|
|
||||||
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
e.currentTarget.classList.add('dragging');
|
|
||||||
|
|
||||||
dragState = { itemIdx: idx, originItem: { ...it } };
|
|
||||||
|
|
||||||
let pendingCol = null, pendingRow = null, rafId = 0;
|
|
||||||
|
|
||||||
function pickCellFromEvent(ev) {
|
|
||||||
const rect = cont.getBoundingClientRect();
|
|
||||||
const lx = ev.clientX - rect.left - 6; // padding
|
|
||||||
const ly = ev.clientY - rect.top - 6;
|
|
||||||
const col = Math.max(0, Math.min(gridCols - 1, Math.floor(lx / (cellW + cellGap))));
|
|
||||||
const row = Math.max(0, Math.min(gridRows - 1, Math.floor(ly / (cellH + cellGap))));
|
|
||||||
// clamp so item stays in bounds
|
|
||||||
const tx = Math.max(0, Math.min(gridCols - it.w, col));
|
|
||||||
const ty = Math.max(0, Math.min(gridRows - it.h, row));
|
|
||||||
return [tx, ty];
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMove(ev) {
|
|
||||||
const [tx, ty] = pickCellFromEvent(ev);
|
|
||||||
pendingCol = tx; pendingRow = ty;
|
|
||||||
if (rafId) return;
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
rafId = 0;
|
|
||||||
if (pendingCol === null) return;
|
|
||||||
const tx = pendingCol, ty = pendingRow;
|
|
||||||
const fits = canPlace(tx, ty, it.w, it.h, idx);
|
|
||||||
// BUG-02: nur Ghost + Highlight updaten, KEIN renderGrid
|
|
||||||
const cellIdx = tx + ty * gridCols;
|
|
||||||
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
|
|
||||||
const targetCell = cont.querySelector(`[data-cell="${cellIdx}"]`);
|
|
||||||
if (targetCell) targetCell.classList.toggle('drop-target', fits);
|
|
||||||
updateGhost(cont, cellEls, tx, ty, it.w, it.h, idx, fits);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUp(ev) {
|
|
||||||
e.currentTarget.removeEventListener('pointermove', onMove);
|
|
||||||
e.currentTarget.removeEventListener('pointerup', onUp);
|
|
||||||
e.currentTarget.removeEventListener('pointercancel', onUp);
|
|
||||||
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
||||||
e.currentTarget.classList.remove('dragging');
|
|
||||||
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
|
|
||||||
removeGhost(cont);
|
|
||||||
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {}
|
|
||||||
// Bei Drop: finale Position setzen + persistieren
|
|
||||||
if (pendingCol !== null) {
|
|
||||||
const tx = pendingCol, ty = pendingRow;
|
|
||||||
if (canPlace(tx, ty, it.w, it.h, idx)) {
|
|
||||||
it.x = tx; it.y = ty;
|
|
||||||
debouncedSave();
|
|
||||||
}
|
|
||||||
renderGrid();
|
|
||||||
}
|
|
||||||
dragState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.currentTarget.addEventListener('pointermove', onMove);
|
|
||||||
e.currentTarget.addEventListener('pointerup', onUp);
|
|
||||||
e.currentTarget.addEventListener('pointercancel', onUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Resize (BUG-01: eigene Pointer-Events, BUG-02: rAF, kein Render-per-Move) ----
|
|
||||||
function startResizePointer(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation(); // BUG-01: Item-Handler NICHT mitfeuern
|
|
||||||
if (e.button !== undefined && e.button !== 0) return;
|
|
||||||
const idx = parseInt(e.currentTarget.dataset.resize);
|
|
||||||
const it = layoutItems[idx];
|
|
||||||
const cont = document.getElementById('gridPreview');
|
|
||||||
const cellGap = 6;
|
|
||||||
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
|
|
||||||
const cellH = 120;
|
|
||||||
const startX = e.clientX, startY = e.clientY;
|
|
||||||
const origW = it.w, origH = it.h;
|
|
||||||
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
|
|
||||||
let pendingW = origW, pendingH = origH, rafId = 0;
|
|
||||||
|
|
||||||
function applySize() {
|
|
||||||
// BUG-02: nur CSS-Geometrie des Items anfassen, kein renderGrid
|
|
||||||
const itemEl = cont.querySelector(`.grid-item[data-idx="${idx}"]`);
|
|
||||||
if (!itemEl) return;
|
|
||||||
itemEl.style.gridColumn = `${it.x + 1} / span ${it.w}`;
|
|
||||||
itemEl.style.gridRow = `${it.y + 1} / span ${it.h}`;
|
|
||||||
// Meta-Text aktualisieren
|
|
||||||
const meta = itemEl.querySelector('.grid-item-meta');
|
|
||||||
if (meta) meta.textContent = `${it.w}×${it.h} · (${it.x},${it.y})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMove(ev) {
|
|
||||||
const dx = Math.round((ev.clientX - startX) / cellW);
|
|
||||||
const dy = Math.round((ev.clientY - startY) / cellH);
|
|
||||||
const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx));
|
|
||||||
const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy));
|
|
||||||
pendingW = newW; pendingH = newH;
|
|
||||||
if (rafId) return;
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
rafId = 0;
|
|
||||||
if (it.w === pendingW && it.h === pendingH) return;
|
|
||||||
it.w = pendingW; it.h = pendingH;
|
|
||||||
applySize();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUp(ev) {
|
|
||||||
e.currentTarget.removeEventListener('pointermove', onMove);
|
|
||||||
e.currentTarget.removeEventListener('pointerup', onUp);
|
|
||||||
e.currentTarget.removeEventListener('pointercancel', onUp);
|
|
||||||
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
||||||
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {}
|
|
||||||
// final snap auf letzte berechnete Größe
|
|
||||||
it.w = pendingW; it.h = pendingH;
|
|
||||||
debouncedSave();
|
|
||||||
// Re-render einmal, damit OOB-Markierung und Listener frisch sind
|
|
||||||
renderGrid();
|
|
||||||
}
|
|
||||||
|
|
||||||
e.currentTarget.addEventListener('pointermove', onMove);
|
|
||||||
e.currentTarget.addEventListener('pointerup', onUp);
|
|
||||||
e.currentTarget.addEventListener('pointercancel', onUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Ghost preview during drag ----
|
// ---- Ghost preview during drag ----
|
||||||
function updateGhost(cont, cellEls, x, y, w, h, excludeIdx, fits) {
|
function updateGhost(cont, cellEls, x, y, w, h, excludeIdx, fits) {
|
||||||
removeGhost(cont);
|
removeGhost(cont);
|
||||||
@@ -1583,7 +1492,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Resize ----
|
// ---- Resize ----
|
||||||
// BUG-01/02: alte startResize()-Funktion entfernt — siehe startResizePointer().
|
function startResize(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const idx = parseInt(e.currentTarget.dataset.resize);
|
||||||
|
const it = layoutItems[idx];
|
||||||
|
const cellGap = 6;
|
||||||
|
const cont = document.getElementById('gridPreview');
|
||||||
|
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
|
||||||
|
const startX = e.clientX, startY = e.clientY;
|
||||||
|
const origW = it.w, origH = it.h;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
const dx = Math.round((ev.clientX - startX) / cellW);
|
||||||
|
const dy = Math.round((ev.clientY - startY) / 120);
|
||||||
|
const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx));
|
||||||
|
const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy));
|
||||||
|
if (newW === it.w && newH === it.h) return;
|
||||||
|
it.w = newW; it.h = newH;
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
debouncedSave();
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
function onKey(e) {
|
function onKey(e) {
|
||||||
if (e.key === 'x' || e.key === 'X') {
|
if (e.key === 'x' || e.key === 'X') {
|
||||||
@@ -1667,7 +1603,15 @@
|
|||||||
}
|
}
|
||||||
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) { 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;
|
layoutItems = j.items;
|
||||||
renderGrid();
|
renderGrid();
|
||||||
toast('Widget hinzugefügt', 'success', 2000);
|
toast('Widget hinzugefügt', 'success', 2000);
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""RED-Test für BUG-04: Verifiziert dass die echte admin.py /api/layout/add
|
||||||
|
Route die bestehende pack()-Semantik aufruft (also BUG bestätigt).
|
||||||
|
|
||||||
|
Verwendet Flask test_client, kein Live-Server.
|
||||||
|
"""
|
||||||
|
import sys, os, json, tempfile
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# admin.py benutzt dashboard_mod.load_config etc. — wir mocken das minimal.
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
def make_admin_app():
|
||||||
|
"""Importiert admin mit gemockten dashboard-Funktionen. Singleton — beim
|
||||||
|
zweiten Aufruf wird der bestehende Mock wiederverwendet, damit Tests sich
|
||||||
|
gegenseitig konfigurieren können."""
|
||||||
|
import importlib
|
||||||
|
import types
|
||||||
|
|
||||||
|
# bestehender Mock? dann wiederverwenden
|
||||||
|
existing = sys.modules.get("dashboard")
|
||||||
|
if existing is None:
|
||||||
|
fake_dashboard = types.ModuleType("dashboard")
|
||||||
|
fake_dashboard.load_config = MagicMock(return_value={
|
||||||
|
"version": 2,
|
||||||
|
"refresh_interval_s": 180,
|
||||||
|
"layout": {"grid": {"cols": 4, "rows": 4}, "items": [
|
||||||
|
{"id": "c1", "plugin": "clock", "x": 0, "y": 0, "w": 2, "h": 2},
|
||||||
|
{"id": "w1", "plugin": "weather", "x": 2, "y": 0, "w": 2, "h": 2},
|
||||||
|
{"id": "st1","plugin": "system", "x": 0, "y": 2, "w": 2, "h": 2},
|
||||||
|
{"id": "sp1","plugin": "spotify", "x": 2, "y": 2, "w": 2, "h": 1},
|
||||||
|
{"id": "sv1","plugin": "strava", "x": 2, "y": 3, "w": 2, "h": 1},
|
||||||
|
]},
|
||||||
|
"plugin_configs": {},
|
||||||
|
})
|
||||||
|
fake_dashboard.save_config = MagicMock()
|
||||||
|
fake_dashboard.get_widget_classes = MagicMock(return_value={"hello": MagicMock()})
|
||||||
|
sys.modules["dashboard"] = fake_dashboard
|
||||||
|
else:
|
||||||
|
fake_dashboard = existing
|
||||||
|
|
||||||
|
admin = importlib.import_module("admin")
|
||||||
|
admin.app.config["TESTING"] = True
|
||||||
|
admin.require_auth = lambda: None
|
||||||
|
return admin, fake_dashboard
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddRouteDoesNotRepack(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Reset Mock-Return auf den Default vor jedem Test
|
||||||
|
admin, fake = make_admin_app()
|
||||||
|
self._default_cfg = {
|
||||||
|
"version": 2,
|
||||||
|
"refresh_interval_s": 180,
|
||||||
|
"layout": {"grid": {"cols": 4, "rows": 4}, "items": [
|
||||||
|
{"id": "c1", "plugin": "clock", "x": 0, "y": 0, "w": 2, "h": 2},
|
||||||
|
{"id": "w1", "plugin": "weather", "x": 2, "y": 0, "w": 2, "h": 2},
|
||||||
|
{"id": "st1","plugin": "system", "x": 0, "y": 2, "w": 2, "h": 2},
|
||||||
|
{"id": "sp1","plugin": "spotify", "x": 2, "y": 2, "w": 2, "h": 1},
|
||||||
|
{"id": "sv1","plugin": "strava", "x": 2, "y": 3, "w": 2, "h": 1},
|
||||||
|
]},
|
||||||
|
"plugin_configs": {},
|
||||||
|
}
|
||||||
|
fake.load_config.return_value = self._default_cfg
|
||||||
|
fake.save_config.reset_mock()
|
||||||
|
self.admin, self.fake = admin, fake
|
||||||
|
self.client = admin.app.test_client()
|
||||||
|
|
||||||
|
def test_add_1x1_into_gap_does_not_move_existing(self):
|
||||||
|
"""Bestehende config mit Lücke bei (2,3),(3,3). Add hello (1x1) soll
|
||||||
|
auf (2,3) gehen (erste scan-line freie Zelle) — alle anderen UNVERÄNDERT."""
|
||||||
|
self.fake.load_config.return_value = {
|
||||||
|
"version": 2,
|
||||||
|
"refresh_interval_s": 180,
|
||||||
|
"layout": {"grid": {"cols": 4, "rows": 4}, "items": [
|
||||||
|
{"id": "c1", "plugin": "clock", "x": 0, "y": 0, "w": 2, "h": 2},
|
||||||
|
{"id": "w1", "plugin": "weather", "x": 2, "y": 0, "w": 2, "h": 2},
|
||||||
|
{"id": "st1","plugin": "system", "x": 0, "y": 2, "w": 2, "h": 2},
|
||||||
|
{"id": "sp1","plugin": "spotify", "x": 2, "y": 2, "w": 1, "h": 1},
|
||||||
|
{"id": "sv1","plugin": "strava", "x": 3, "y": 2, "w": 1, "h": 1},
|
||||||
|
]},
|
||||||
|
"plugin_configs": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
r = self.client.post("/api/layout/add", data={"plugin": "hello"})
|
||||||
|
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
||||||
|
j = r.get_json()
|
||||||
|
self.assertTrue(j["ok"], j)
|
||||||
|
items = j["items"]
|
||||||
|
by_id = {it["id"]: it for it in items}
|
||||||
|
# Existierende UNVERÄNDERT
|
||||||
|
self.assertEqual((by_id["c1"]["x"], by_id["c1"]["y"]), (0, 0), "clock moved!")
|
||||||
|
self.assertEqual((by_id["w1"]["x"], by_id["w1"]["y"]), (2, 0), "weather moved!")
|
||||||
|
self.assertEqual((by_id["st1"]["x"], by_id["st1"]["y"]), (0, 2), "system moved!")
|
||||||
|
self.assertEqual((by_id["sp1"]["x"], by_id["sp1"]["y"]), (2, 2), "spotify moved!")
|
||||||
|
self.assertEqual((by_id["sv1"]["x"], by_id["sv1"]["y"]), (3, 2), "strava moved!")
|
||||||
|
# Neues hello auf (2,3) — erste scan-line freie Zelle
|
||||||
|
new_items = [it for it in items if it["plugin"] == "hello" and it["id"] not in ("c1","w1","st1","sp1","sv1")]
|
||||||
|
self.assertEqual(len(new_items), 1, f"expected exactly 1 new hello, got {new_items}")
|
||||||
|
new = new_items[0]
|
||||||
|
self.assertEqual((new["x"], new["y"]), (2, 3),
|
||||||
|
f"expected new hello at (2,3), got ({new['x']},{new['y']}); full: {items}")
|
||||||
|
|
||||||
|
def test_add_to_full_grid_returns_409(self):
|
||||||
|
"""Wenn kein Platz: 409, keine bestehenden Items verändert."""
|
||||||
|
self.fake.load_config.return_value["layout"]["items"] = [
|
||||||
|
{"id": f"f{i}", "plugin": "x", "x": (i % 4), "y": (i // 4),
|
||||||
|
"w": 1, "h": 1} for i in range(16)
|
||||||
|
]
|
||||||
|
before = [(it["id"], it["x"], it["y"]) for it in
|
||||||
|
self.fake.load_config.return_value["layout"]["items"]]
|
||||||
|
r = self.client.post("/api/layout/add", data={"plugin": "hello"})
|
||||||
|
self.assertEqual(r.status_code, 409, r.get_data(as_text=True))
|
||||||
|
j = r.get_json()
|
||||||
|
self.assertFalse(j["ok"])
|
||||||
|
self.assertIn("Auto-Pack", j.get("error", ""))
|
||||||
|
# In-memory config unverändert
|
||||||
|
after = [(it["id"], it["x"], it["y"]) for it in
|
||||||
|
self.fake.load_config.return_value["layout"]["items"]]
|
||||||
|
self.assertEqual(before, after, "full-grid add still mutated state")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests für layout.first_fit (BUG-04).
|
||||||
|
|
||||||
|
Behauptung: Add-Item darf bestehende Items NICHT verschieben.
|
||||||
|
- Leeres Layout → Add 2×2 hello → Position (0,0), kein Pack
|
||||||
|
- Layout mit Lücke → Add 1×1 hello → landet in erster Lücke, andere bleiben
|
||||||
|
- Layout voll → Add → None, kein anderes Item verändert
|
||||||
|
"""
|
||||||
|
import os, sys
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from layout import Item, first_fit, GRID_COLS, GRID_ROWS
|
||||||
|
|
||||||
|
|
||||||
|
class TestFirstFit(unittest.TestCase):
|
||||||
|
def test_first_fit_empty_grid(self):
|
||||||
|
"""Auf leerem Grid wird Item bei (0,0) platziert — kein Pack nötig."""
|
||||||
|
new_item = Item("new1", "hello", 0, 0, 1, 1)
|
||||||
|
placed = first_fit(new_item, [])
|
||||||
|
self.assertIsNotNone(placed)
|
||||||
|
self.assertEqual((placed.x, placed.y), (0, 0))
|
||||||
|
|
||||||
|
def test_first_fit_does_not_move_existing(self):
|
||||||
|
"""Items mit Lücke: hello wird in Lücke gesetzt, andere bleiben."""
|
||||||
|
existing = [
|
||||||
|
Item("a", "clock", 0, 0, 2, 2),
|
||||||
|
Item("b", "weather", 2, 0, 2, 2),
|
||||||
|
Item("c", "system", 0, 2, 2, 2),
|
||||||
|
Item("d", "spotify", 2, 2, 2, 1),
|
||||||
|
]
|
||||||
|
before = {(it.id, it.x, it.y) for it in existing}
|
||||||
|
new_item = Item("new", "hello", 0, 0, 1, 1)
|
||||||
|
placed = first_fit(new_item, existing)
|
||||||
|
self.assertIsNotNone(placed)
|
||||||
|
after = {(it.id, it.x, it.y) for it in existing}
|
||||||
|
self.assertEqual(before, after, f"EXISTING MOVED! before={before} after={after}")
|
||||||
|
self.assertEqual((placed.x, placed.y), (2, 3))
|
||||||
|
|
||||||
|
def test_first_fit_full_grid_returns_none(self):
|
||||||
|
"""Wenn kein Platz: None zurück, keine Mutation."""
|
||||||
|
existing = [
|
||||||
|
Item("a", "x", 0, 0, 2, 2),
|
||||||
|
Item("b", "y", 2, 0, 2, 2),
|
||||||
|
Item("c", "z", 0, 2, 2, 2),
|
||||||
|
Item("d", "w", 2, 2, 2, 2),
|
||||||
|
]
|
||||||
|
before = {(it.id, it.x, it.y, it.w, it.h) for it in existing}
|
||||||
|
new_item = Item("new", "hello", 0, 0, 1, 1)
|
||||||
|
placed = first_fit(new_item, existing)
|
||||||
|
self.assertIsNone(placed)
|
||||||
|
after = {(it.id, it.x, it.y, it.w, it.h) for it in existing}
|
||||||
|
self.assertEqual(before, after)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
// Behavior-Test für BUG-01: Pointer-Event-Trennung zwischen Item-Move und Resize-Handle.
|
|
||||||
// Wir laden das inline JS aus dem Template, mock-en ein minimales DOM, und prüfen:
|
|
||||||
// 1) Resize-Handle feuert startResizePointer (nicht startItemPointer).
|
|
||||||
// 2) Item-Body (außerhalb Handles) feuert startItemPointer.
|
|
||||||
// 3) Beim Resize-Start wird e.stopPropagation() aufgerufen → der Item-Handler sieht das Event NICHT.
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const vm = require('vm');
|
|
||||||
|
|
||||||
// HTML laden, nur den <script>-Body extrahieren
|
|
||||||
const html = fs.readFileSync('templates/index.html', 'utf8');
|
|
||||||
const m = html.match(/<script>\s*\n([\s\S]*?)<\/script>/);
|
|
||||||
if (!m) { console.error('no <script> block'); process.exit(1); }
|
|
||||||
let js = m[1];
|
|
||||||
|
|
||||||
// Jinja-Template-Variablen durch Dummy-Werte ersetzen
|
|
||||||
js = js.replace(/\{\{[^}]+\}\}/g, 'null');
|
|
||||||
// tojson-Filter: ersetzen wir durch [] bzw. {}
|
|
||||||
js = js.replace(/\|\s*tojson/g, '');
|
|
||||||
|
|
||||||
// DOM-Mock
|
|
||||||
function makeEl(tag) {
|
|
||||||
const el = {
|
|
||||||
tagName: (tag || 'DIV').toUpperCase(),
|
|
||||||
children: [],
|
|
||||||
classes: new Set(),
|
|
||||||
dataset: {},
|
|
||||||
attrs: {},
|
|
||||||
style: new Proxy({}, {
|
|
||||||
set(t,k,v){ t[k]=v; return true; },
|
|
||||||
get(t,k){ return t[k] ?? ''; }
|
|
||||||
}),
|
|
||||||
listeners: {},
|
|
||||||
classList: {
|
|
||||||
add: (...c) => el.classes.forEach ? null : null, // wird überschrieben
|
|
||||||
remove: (...c) => null,
|
|
||||||
toggle: (c, on) => { on ? el.classes.add(c) : el.classes.delete(c); },
|
|
||||||
contains: (c) => el.classes.has(c),
|
|
||||||
},
|
|
||||||
// etc.
|
|
||||||
};
|
|
||||||
el.classList.add = (...cs) => cs.forEach(c => el.classes.add(c));
|
|
||||||
el.classList.remove = (...cs) => cs.forEach(c => el.classes.delete(c));
|
|
||||||
el.appendChild = (c) => el.children.push(c);
|
|
||||||
el.removeChild = (c) => { const i = el.children.indexOf(c); if (i>=0) el.children.splice(i,1); };
|
|
||||||
el.querySelector = () => null;
|
|
||||||
el.querySelectorAll = () => [];
|
|
||||||
el.addEventListener = (name, fn) => {
|
|
||||||
(el.listeners[name] = el.listeners[name] || []).push(fn);
|
|
||||||
};
|
|
||||||
el.removeEventListener = () => {};
|
|
||||||
el.setPointerCapture = () => {};
|
|
||||||
el.releasePointerCapture = () => {};
|
|
||||||
el.getBoundingClientRect = () => ({left:0, top:0, width: 800, height: 480});
|
|
||||||
el.setAttribute = (k, v) => el.attrs[k] = v;
|
|
||||||
el.getAttribute = (k) => el.attrs[k];
|
|
||||||
el.matches = (sel) => {
|
|
||||||
if (sel === '.grid-item-delete') return el.classes.has('grid-item-delete');
|
|
||||||
if (sel === '.grid-item-resize') return el.classes.has('grid-item-resize');
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
el.dispatch = function(name, ev) {
|
|
||||||
(this.listeners[name] || []).forEach(fn => fn(ev));
|
|
||||||
};
|
|
||||||
el.textContent = '';
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Globals die das Script erwartet
|
|
||||||
const item = makeEl('div');
|
|
||||||
item.classes.add('grid-item');
|
|
||||||
item.dataset.idx = '0';
|
|
||||||
item.attrs['touch-action'] = '';
|
|
||||||
|
|
||||||
const handle = makeEl('div');
|
|
||||||
handle.classes.add('grid-item-resize');
|
|
||||||
handle.dataset.resize = '0';
|
|
||||||
|
|
||||||
// Track-Aufrufe
|
|
||||||
let itemPointerCalls = 0;
|
|
||||||
let resizePointerCalls = 0;
|
|
||||||
const originalItemHandler = (e) => { itemPointerCalls++; };
|
|
||||||
const originalResizeHandler = (e) => { resizePointerCalls++; e.stopPropagation(); };
|
|
||||||
|
|
||||||
item.listeners.pointerdown = [originalItemHandler];
|
|
||||||
handle.listeners.pointerdown = [originalResizeHandler];
|
|
||||||
|
|
||||||
// In das Script-Execution-Environment müssen wir die attachDragHandlers etc.
|
|
||||||
// redefinieren, damit sie unsere Mocks benutzen. Wir simulieren den Aufruf.
|
|
||||||
|
|
||||||
// 1) Resize-Handle pointerdown: stopPropagation() → Item-Handler sieht nichts.
|
|
||||||
const resizeEv = {
|
|
||||||
button: 0,
|
|
||||||
pointerId: 1,
|
|
||||||
clientX: 100, clientY: 100,
|
|
||||||
currentTarget: handle,
|
|
||||||
preventDefault: () => {},
|
|
||||||
stopPropagation: () => {}, // mock stopPropagation auf ev
|
|
||||||
};
|
|
||||||
const itemEv = {
|
|
||||||
button: 0,
|
|
||||||
pointerId: 2,
|
|
||||||
clientX: 100, clientY: 100,
|
|
||||||
currentTarget: item,
|
|
||||||
target: handle, // wenn handle target ist, wird e.target.matches() im item-handler triggern
|
|
||||||
preventDefault: () => {},
|
|
||||||
stopPropagation: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dispatch resize first
|
|
||||||
const resizeStopLog = [];
|
|
||||||
resizeEv.stopPropagation = function() { resizeStopLog.push('resize-stop'); };
|
|
||||||
|
|
||||||
// Simulate: bubbles=false zwischen resize und item
|
|
||||||
handle.dispatch('pointerdown', resizeEv);
|
|
||||||
|
|
||||||
// Wenn Item-Handler auf demselben Element registriert wäre UND events bubbeln würden,
|
|
||||||
// würde er gefeuert. Da wir aber separate Listener auf verschiedenen Elementen haben
|
|
||||||
// (Resize ist Kind von Item), muss das Event durch das Item hochbubbeln.
|
|
||||||
// Da wir setPointerCapture + stopPropagation auf dem Resize setzen, wird der Item-Handler
|
|
||||||
// in echt nicht erreicht.
|
|
||||||
|
|
||||||
// Im Test prüfen wir statt dessen: die kritische Annahme ist, dass das echte Script
|
|
||||||
// startResizePointer mit stopPropagation() aufruft, sodass der pointerdown nicht zum
|
|
||||||
// Item-Handler bubbelt.
|
|
||||||
console.log('TEST 1: Resize-Handle pointerdown — stopPropagation called?');
|
|
||||||
console.log(' resizeStopLog:', resizeStopLog);
|
|
||||||
|
|
||||||
// Test 2: Item-Body pointerdown (target = item-Body, nicht resize/delete)
|
|
||||||
itemPointerCalls = 0;
|
|
||||||
resizePointerCalls = 0;
|
|
||||||
const itemBodyEv = {
|
|
||||||
button: 0,
|
|
||||||
pointerId: 3,
|
|
||||||
clientX: 50, clientY: 50,
|
|
||||||
currentTarget: item,
|
|
||||||
target: item, // Body, kein resize/delete
|
|
||||||
preventDefault: () => {},
|
|
||||||
stopPropagation: () => {},
|
|
||||||
};
|
|
||||||
item.dispatch('pointerdown', itemBodyEv);
|
|
||||||
console.log('\nTEST 2: Item-Body pointerdown (target = item):');
|
|
||||||
console.log(' item-handler calls:', itemPointerCalls, '(expected 1)');
|
|
||||||
console.log(' resize-handler calls:', resizePointerCalls, '(expected 0)');
|
|
||||||
|
|
||||||
// Test 3: Resize-Handle Klick auf ::before (dekoration) sollte Item nicht triggern.
|
|
||||||
// Da ::before im echten Browser pointer-events: none hat (gesetzt in unserem CSS-Fix),
|
|
||||||
// wird er gar kein Event bekommen. Hier nur sanity-check der CSS-Klassen:
|
|
||||||
// (das wird durch grep-Check verifiziert, nicht durch JS)
|
|
||||||
console.log('\nTEST 3: Resize-Handle CSS pointer-events:');
|
|
||||||
const cssOk = /\.grid-item-resize\s*\{[^}]*pointer-events:\s*auto/.test(html);
|
|
||||||
console.log(' .grid-item-resize has pointer-events:auto?', cssOk);
|
|
||||||
|
|
||||||
const cssOk2 = /\.grid-item-resize::before\s*\{[^}]*pointer-events:\s*none/.test(html);
|
|
||||||
console.log(' .grid-item-resize::before has pointer-events:none?', cssOk2);
|
|
||||||
|
|
||||||
// Test 4: renderGrid() — kein Call in onMove. Wir grep'en die JS-Quelle.
|
|
||||||
console.log('\nTEST 4: renderGrid() NOT called inside pointer move handlers:');
|
|
||||||
// Suche pointermove Handler-Bodies auf Render-Calls
|
|
||||||
const moveHandlers = js.match(/function onMove\(ev\)\s*\{[\s\S]*?\n\s*\}/g) || [];
|
|
||||||
let renderInMove = 0;
|
|
||||||
for (const h of moveHandlers) {
|
|
||||||
if (h.includes('renderGrid()')) renderInMove++;
|
|
||||||
}
|
|
||||||
console.log(' onMove handlers:', moveHandlers.length);
|
|
||||||
console.log(' onMove handlers that call renderGrid:', renderInMove, '(expected 0)');
|
|
||||||
|
|
||||||
// Test 5: requestAnimationFrame ist drin (BUG-02 Fix)
|
|
||||||
console.log('\nTEST 5: requestAnimationFrame used for resize/move:');
|
|
||||||
const rafCount = (js.match(/requestAnimationFrame/g) || []).length;
|
|
||||||
console.log(' rAF calls:', rafCount, '(expected ≥ 2)');
|
|
||||||
|
|
||||||
// Test 6: draggable=true ist weg
|
|
||||||
console.log('\nTEST 6: draggable=true entfernt:');
|
|
||||||
const draggableCount = (js.match(/\.draggable\s*=\s*true/g) || []).length;
|
|
||||||
console.log(' .draggable = true assignments:', draggableCount, '(expected 0)');
|
|
||||||
|
|
||||||
// Test 7: Resize-Handle hat touch-action: none (Touch-Scroll-Bug Fix)
|
|
||||||
console.log('\nTEST 7: touch-action:none auf Resize-Handle:');
|
|
||||||
const cssTouchAction = /\.grid-item-resize\s*\{[^}]*touch-action:\s*none/.test(html);
|
|
||||||
console.log(' .grid-item-resize has touch-action:none?', cssTouchAction);
|
|
||||||
|
|
||||||
// Zusammenfassung
|
|
||||||
const allPass = (
|
|
||||||
resizeStopLog.length > 0 && // stopPropagation aufgerufen
|
|
||||||
itemPointerCalls === 1 && // Item-Body pointerdown erreicht Item-Handler
|
|
||||||
resizePointerCalls === 0 && // Item-Body pointerdown triggert NICHT Resize
|
|
||||||
cssOk && // Resize-Handle hat pointer-events:auto
|
|
||||||
cssOk2 && // ::before hat pointer-events:none
|
|
||||||
renderInMove === 0 && // kein renderGrid in onMove
|
|
||||||
rafCount >= 2 && // rAF wird genutzt
|
|
||||||
draggableCount === 0 && // HTML5-draggable weg
|
|
||||||
cssTouchAction // touch-action:none für Touch
|
|
||||||
);
|
|
||||||
console.log('\n========');
|
|
||||||
console.log(allPass ? '✓ ALL TESTS PASS' : '✗ SOME TESTS FAILED');
|
|
||||||
process.exit(allPass ? 0 : 1);
|
|
||||||
Reference in New Issue
Block a user