Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a465912b55 | ||
|
|
6059748f9b | ||
|
|
9f705ad7e4 |
@@ -13,3 +13,5 @@ minimax_*.png
|
|||||||
config.json
|
config.json
|
||||||
*.service
|
*.service
|
||||||
.backup/
|
.backup/
|
||||||
|
.venv/
|
||||||
|
tests/__pycache__/
|
||||||
|
|||||||
+70
-12
@@ -797,6 +797,21 @@
|
|||||||
.toast.info .toast-icon { color: var(--info); }
|
.toast.info .toast-icon { color: var(--info); }
|
||||||
.toast.warn .toast-icon { color: var(--warn); }
|
.toast.warn .toast-icon { color: var(--warn); }
|
||||||
.toast-text { flex: 1; min-width: 0; word-break: break-word; }
|
.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 {
|
@keyframes toastIn {
|
||||||
from { opacity: 0; transform: translateX(20px); }
|
from { opacity: 0; transform: translateX(20px); }
|
||||||
to { opacity: 1; transform: translateX(0); }
|
to { opacity: 1; transform: translateX(0); }
|
||||||
@@ -1135,12 +1150,25 @@
|
|||||||
// ============ Toast-Helper ============
|
// ============ Toast-Helper ============
|
||||||
// Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn")
|
// 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) — duration 0 = manuell wegklicken
|
||||||
function toast(text, type = "info", duration = 3500) {
|
// 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) {
|
||||||
const c = document.getElementById('toastContainer');
|
const c = document.getElementById('toastContainer');
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'toast ' + type;
|
el.className = 'toast ' + type;
|
||||||
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
|
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
|
||||||
el.innerHTML = `<div class="toast-icon">${iconChar}</div><div class="toast-text"></div>`;
|
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;
|
el.querySelector('.toast-text').textContent = text;
|
||||||
let removed = false;
|
let removed = false;
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
@@ -1297,7 +1325,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 +1332,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 +1348,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
|
||||||
cont.appendChild(div);
|
// - 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);
|
||||||
|
// Origin-Cell nur als "occupied" markieren, damit sie ihre leere Optik verliert
|
||||||
|
if (originCell) originCell.classList.add('occupied');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark occupied cells
|
// Mark occupied cells
|
||||||
@@ -1625,17 +1659,41 @@
|
|||||||
const ok = await modalConfirm({
|
const ok = await modalConfirm({
|
||||||
icon: 'warn',
|
icon: 'warn',
|
||||||
title: 'Alle Items neu anordnen?',
|
title: 'Alle Items neu anordnen?',
|
||||||
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren.',
|
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.',
|
||||||
confirmText: 'Neu anordnen',
|
confirmText: 'Neu anordnen',
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
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 r = await fetch('/api/layout/pack', { method: 'POST' });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
|
if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
|
||||||
layoutItems = j.items;
|
layoutItems = j.items;
|
||||||
renderGrid();
|
renderGrid();
|
||||||
debouncedSave();
|
debouncedSave();
|
||||||
toast('Layout automatisch angeordnet', 'success');
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Plugin-Configs ============
|
// ============ Plugin-Configs ============
|
||||||
|
|||||||
@@ -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,58 @@
|
|||||||
|
// 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);
|
||||||
@@ -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)
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user