Phase 2 (Teil 1): Widget-Raster, Layout-Engine, Persistenz

Layout-Engine mit vier Spalten, testgetrieben. Widgets werden nicht stur
hintereinander gesetzt, sondern jeweils an die erste Stelle, an die sie passen.
Der Unterschied zeigt sich neben einem 2x2-Widget: dort bleiben rechts zwei
1x1-Plätze frei, die ein reines Anhängen dauerhaft leer ließe.

Persistenz unterscheidet zwei Fälle, die gleich aussehen und es nicht sind. Eine
leere Layout-Datei ist eine Aussage — der Nutzer hat alle Widgets entfernt. Eine
Datei, aus der nach dem Filtern unbekannter Kennungen nichts übrig bleibt, ist
dagegen ein Zeichen, dass sich die Kennungen geändert haben; dort wäre ein leeres
Panel eine stille Fehlfunktion, also greift das Standardlayout.

Beschädigte und fehlende Dateien führen beide zum Standardlayout statt zu einem
Absturz oder einem leeren Panel.

Die Panelgröße folgt dem Layout statt umgekehrt. Positioniert wird über
LayoutEngine.frame, nicht über LazyVGrid — das kann keine Kacheln über zwei
Zeilen führen, und genau das braucht der Mini-Monat.

Platzhalter-Widgets für alle 13 geplanten Karten. Sie tragen die endgültigen
Kennungen, damit gespeicherte Layouts weitergelten, wenn die echten Widgets sie
Phase für Phase ersetzen.

66 Tests grün.
This commit is contained in:
Guido Schmit
2026-08-10 17:32:48 +02:00
parent 715031f153
commit 8e0629d3a5
11 changed files with 750 additions and 36 deletions

View File

@@ -0,0 +1,194 @@
import Testing
import CoreGraphics
@testable import OnyxWidgetKit
// Das Panel-Raster hat vier Spalten. Widgets werden in der gespeicherten
// Reihenfolge eingesetzt, jedes an die erste Stelle, an die es passt. Bleibt
// links eine Lücke, füllt sie das nächste Widget, das hineinpasst sonst
// stünden im Panel dauernd Löcher, die niemand füllen kann.
@Suite("Layout-Engine")
struct LayoutEngineTests {
private func placement(_ widget: String, _ size: WidgetSize) -> WidgetPlacement {
WidgetPlacement(widgetID: widget, size: size)
}
// MARK: - Grundlegendes Setzen
@Test("Ein kleines Widget landet oben links")
func singleSmall() {
let resolved = LayoutEngine.resolve([placement("cpu", .small)])
#expect(resolved.count == 1)
#expect(resolved[0].column == 0)
#expect(resolved[0].row == 0)
}
@Test("Vier kleine Widgets füllen genau die erste Zeile")
func fourSmallFillFirstRow() {
let resolved = LayoutEngine.resolve((0..<4).map { placement("w\($0)", .small) })
#expect(resolved.map(\.row) == [0, 0, 0, 0])
#expect(resolved.map(\.column) == [0, 1, 2, 3])
}
@Test("Das fünfte kleine Widget rutscht in die zweite Zeile")
func fifthWraps() {
let resolved = LayoutEngine.resolve((0..<5).map { placement("w\($0)", .small) })
#expect(resolved[4].row == 1)
#expect(resolved[4].column == 0)
}
@Test("Ein breites Widget belegt alle vier Spalten")
func wideTakesFullRow() {
let resolved = LayoutEngine.resolve([placement("media", .wide)])
#expect(resolved[0].column == 0)
#expect(resolved[0].columnSpan == 4)
}
@Test("Ein hohes Widget belegt zwei Zeilen")
func largeSpansTwoRows() {
let resolved = LayoutEngine.resolve([placement("calendar", .large)])
#expect(resolved[0].columnSpan == 2)
#expect(resolved[0].rowSpan == 2)
}
// MARK: - Packen
@Test("Ein breites Widget passt nicht neben ein kleines und beginnt eine neue Zeile")
func wideStartsNewRowWhenBlocked() {
let resolved = LayoutEngine.resolve([
placement("cpu", .small),
placement("media", .wide),
])
#expect(resolved[0].row == 0)
#expect(resolved[1].row == 1)
#expect(resolved[1].column == 0)
}
@Test("Kleine Widgets füllen die Lücke neben einem hohen Widget")
func smallFillsGapBesideLarge() {
// Ein 2×2-Widget links lässt rechts zwei freie 1×1-Plätze in Zeile 0
// und zwei in Zeile 1 die dürfen nicht verloren gehen.
let resolved = LayoutEngine.resolve([
placement("calendar", .large),
placement("cpu", .small),
placement("ram", .small),
placement("gpu", .small),
])
#expect(resolved[1].row == 0)
#expect(resolved[1].column == 2)
#expect(resolved[2].row == 0)
#expect(resolved[2].column == 3)
#expect(resolved[3].row == 1)
#expect(resolved[3].column == 2)
}
@Test("Belegte Felder werden nie doppelt vergeben")
func noOverlaps() {
let resolved = LayoutEngine.resolve([
placement("a", .large), placement("b", .medium), placement("c", .small),
placement("d", .wide), placement("e", .small), placement("f", .large),
])
var occupied = Set<GridCell>()
for item in resolved {
for row in item.row..<(item.row + item.rowSpan) {
for column in item.column..<(item.column + item.columnSpan) {
let cell = GridCell(row: row, column: column)
#expect(!occupied.contains(cell), "Feld \(cell) doppelt belegt")
occupied.insert(cell)
}
}
}
}
@Test("Nichts ragt über den rechten Rand hinaus")
func nothingOverflowsRight() {
let resolved = LayoutEngine.resolve([
placement("a", .small), placement("b", .medium),
placement("c", .large), placement("d", .wide),
])
for item in resolved {
#expect(item.column + item.columnSpan <= LayoutEngine.columns)
}
}
@Test("Die gespeicherte Reihenfolge bleibt erhalten")
func orderIsStable() {
let placements = [placement("a", .small), placement("b", .wide), placement("c", .small)]
let resolved = LayoutEngine.resolve(placements)
#expect(resolved.map(\.placement.widgetID) == ["a", "b", "c"])
}
@Test("Leeres Layout ergibt kein Ergebnis und keinen Absturz")
func emptyLayout() {
#expect(LayoutEngine.resolve([]).isEmpty)
}
// MARK: - Panelgröße
@Test("Panelgröße folgt der belegten Zeilenzahl, nicht der Widgetzahl")
func panelSizeFollowsRows() {
let oneRow = LayoutEngine.panelSize(for: LayoutEngine.resolve([
placement("a", .small), placement("b", .small),
]))
let twoRows = LayoutEngine.panelSize(for: LayoutEngine.resolve(
(0..<5).map { placement("w\($0)", .small) }))
#expect(twoRows.height > oneRow.height)
// Die Breite steht fest: vier Spalten, immer.
#expect(twoRows.width == oneRow.width)
}
@Test("Ein leeres Panel hat trotzdem eine sinnvolle Mindestgröße")
func emptyPanelHasMinimumSize() {
let size = LayoutEngine.panelSize(for: [])
#expect(size.width > 0)
#expect(size.height > 0)
}
@Test("Ein hohes Widget allein ergibt zwei Zeilen Höhe")
func largeAloneIsTwoRowsTall() {
let large = LayoutEngine.panelSize(for: LayoutEngine.resolve([placement("a", .large)]))
let small = LayoutEngine.panelSize(for: LayoutEngine.resolve([placement("a", .small)]))
#expect(large.height > small.height)
}
// MARK: - Rahmen im Panel
@Test("Ein Widget in Spalte 2 sitzt weiter rechts als eines in Spalte 0")
func framesFollowColumns() {
let resolved = LayoutEngine.resolve([
placement("a", .medium), placement("b", .medium),
])
let panel = LayoutEngine.panelSize(for: resolved)
let first = LayoutEngine.frame(for: resolved[0], in: panel)
let second = LayoutEngine.frame(for: resolved[1], in: panel)
#expect(second.minX > first.minX)
#expect(first.minY == second.minY)
}
@Test("Ein breites Widget ist im Rahmen wirklich breiter als ein kleines")
func wideFrameIsWider() {
let wide = LayoutEngine.resolve([placement("a", .wide)])
let small = LayoutEngine.resolve([placement("a", .small)])
let wideFrame = LayoutEngine.frame(for: wide[0], in: LayoutEngine.panelSize(for: wide))
let smallFrame = LayoutEngine.frame(for: small[0], in: LayoutEngine.panelSize(for: small))
#expect(wideFrame.width > smallFrame.width)
}
}

View File

@@ -0,0 +1,129 @@
import Testing
import Foundation
@testable import OnyxWidgetKit
// Die Layout-Datei überlebt App-Updates. Sie kann also Widgets nennen, die es
// nicht mehr gibt, oder aus einer neueren Version stammen und sie kann halb
// geschrieben auf der Platte liegen, wenn der Rechner mitten im Speichern
// abstürzt. Keiner dieser Fälle darf zu einem leeren Panel führen, ohne dass
// jemand merkt warum.
@Suite("Layout-Persistenz")
struct LayoutStoreTests {
private func temporaryURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("onyx-test-\(UUID().uuidString)")
.appendingPathComponent("layout.json")
}
private let known: Set<String> = ["calendar", "weather", "media", "cpu"]
@Test("Gespeichertes Layout kommt unverändert zurück")
func roundTrip() throws {
let url = temporaryURL()
let store = LayoutStore(url: url)
let layout = [
WidgetPlacement(widgetID: "calendar", size: .large),
WidgetPlacement(widgetID: "media", size: .wide),
]
try store.save(layout)
let loaded = store.load(knownWidgetIDs: known)
#expect(loaded.map(\.widgetID) == ["calendar", "media"])
#expect(loaded.map(\.size) == [.large, .wide])
}
@Test("Die Kennung eines Widgets bleibt über das Speichern hinweg dieselbe")
func identitySurvives() throws {
let url = temporaryURL()
let store = LayoutStore(url: url)
let placement = WidgetPlacement(widgetID: "cpu", size: .small)
try store.save([placement])
#expect(store.load(knownWidgetIDs: known).first?.id == placement.id)
}
@Test("Fehlende Datei ergibt das Standardlayout, keinen Fehler")
func missingFileFallsBack() {
let loaded = LayoutStore(url: temporaryURL()).load(knownWidgetIDs: known)
#expect(!loaded.isEmpty)
#expect(loaded.allSatisfy { known.contains($0.widgetID) })
}
@Test("Beschädigte Datei ergibt das Standardlayout statt eines Absturzes")
func corruptFileFallsBack() throws {
let url = temporaryURL()
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try Data("{ das ist kein JSON".utf8).write(to: url)
let loaded = LayoutStore(url: url).load(knownWidgetIDs: known)
#expect(!loaded.isEmpty)
}
@Test("Ein entferntes Widget wird aus dem Layout gefiltert, der Rest bleibt")
func unknownWidgetIsDropped() throws {
let url = temporaryURL()
let store = LayoutStore(url: url)
try store.save([
WidgetPlacement(widgetID: "calendar", size: .large),
WidgetPlacement(widgetID: "widget-aus-einer-alten-version", size: .small),
WidgetPlacement(widgetID: "media", size: .wide),
])
let loaded = store.load(knownWidgetIDs: known)
#expect(loaded.map(\.widgetID) == ["calendar", "media"])
}
@Test("Eine Datei, in der ALLE Widgets unbekannt sind, ergibt das Standardlayout")
func allUnknownFallsBack() throws {
// Sonst startet die App nach einem Umbau der Widget-Kennungen mit einem
// leeren Panel und keiner Erklärung.
let url = temporaryURL()
let store = LayoutStore(url: url)
try store.save([WidgetPlacement(widgetID: "voellig-unbekannt", size: .small)])
let loaded = store.load(knownWidgetIDs: known)
#expect(!loaded.isEmpty)
#expect(loaded.allSatisfy { known.contains($0.widgetID) })
}
@Test("Ein absichtlich leeres Layout bleibt leer")
func deliberatelyEmptyStaysEmpty() throws {
// Wer alle Widgets entfernt, meint das auch so das ist etwas anderes
// als eine kaputte Datei.
let url = temporaryURL()
let store = LayoutStore(url: url)
try store.save([])
#expect(store.load(knownWidgetIDs: known).isEmpty)
}
@Test("Speichern legt fehlende Verzeichnisse an")
func savingCreatesDirectories() throws {
let url = temporaryURL()
try LayoutStore(url: url).save([WidgetPlacement(widgetID: "cpu", size: .small)])
#expect(FileManager.default.fileExists(atPath: url.path))
}
@Test("Das Standardlayout nennt nur Widgets, die es wirklich gibt")
func defaultLayoutIsValid() {
let defaults = LayoutStore.defaultLayout(knownWidgetIDs: known)
#expect(!defaults.isEmpty)
#expect(defaults.allSatisfy { known.contains($0.widgetID) })
}
@Test("Ohne bekannte Widgets bleibt auch das Standardlayout leer")
func defaultLayoutWithNoWidgets() {
#expect(LayoutStore.defaultLayout(knownWidgetIDs: []).isEmpty)
}
}

View File

@@ -1 +0,0 @@
// OnyxWidgetKitTests