Eine Designschicht statt hundertfach gesetzter Zahlen

Token für Schrift, Symbolgröße, Abstand, Radius und Bewegung, dazu sieben
Bauteile, die vorher in jeder Kachel neu gebaut wurden — Karte, Abschnittstitel,
Hinweis, Pillenknopf, Regler, Ablegefläche, Haarlinie.

Symbole bekommen eine **eigene** Staffel und nicht die der Schrift: ein
9-Punkt-Symbol neben 9-Punkt-Text wirkt kleiner, nicht gleich groß. Beide in
eine Staffel zu zwingen hieße, an jeder Stelle gegen sie zu arbeiten.

Das Flächenrezept — Tint, Bänderung, Kante — stand zweimal da, einmal für das
Panel und einmal für Fenster. Es ist jetzt `OnyxStone`, generisch über die Form,
damit auch die animierende Notch-Silhouette es benutzen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 21:04:51 +02:00
co-authored by Claude Opus 5
parent 49a3ff0548
commit ecb39fbe5f
11 changed files with 1195 additions and 184 deletions
@@ -0,0 +1,109 @@
import Testing
import Foundation
import SwiftUI
@testable import OnyxDesign
@Suite("Bewegungsprofil")
@MainActor
struct MotionProfileTests {
private func freshDefaults(_ name: String) -> UserDefaults {
let suite = "onyx.motion.test.\(name)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
@Test("Ohne Einstellung ist es schnell")
func defaultsToSnappy() {
let theme = OnyxTheme(defaults: freshDefaults(#function))
#expect(theme.motion == .snappy)
}
@Test("Das gewählte Profil übersteht den Neustart")
func choiceSurvivesRestart() {
let defaults = freshDefaults(#function)
let theme = OnyxTheme(defaults: defaults)
theme.motion = .calm
let restarted = OnyxTheme(defaults: defaults)
#expect(restarted.motion == .calm)
}
/// Ein Profilname, den diese Fassung nicht kennt, darf die Bewegung nicht
/// ausfallen lassen — eine App ohne Animation sieht aus, als hinge sie.
@Test("Ein unbekannter Name fällt auf die Voreinstellung zurück")
func unknownNameFallsBack() {
let defaults = freshDefaults(#function)
defaults.set("hyperspeed", forKey: "onyx.theme.motion")
#expect(OnyxTheme(defaults: defaults).motion == .snappy)
}
/// Der Zurücksetzen-Knopf steht bei den Farben. Nähme er das
/// Bewegungsprofil mit, stellte er etwas um, wovon in seiner Beschriftung
/// nichts steht.
@Test("Farben zurücksetzen lässt das Bewegungsprofil in Ruhe")
func resetLeavesMotionAlone() {
let theme = OnyxTheme(defaults: freshDefaults(#function))
theme.motion = .bouncy
theme.reset()
#expect(theme.motion == .bouncy)
#expect(theme.isDefault)
}
/// Beim Öffnen sieht man zu, beim Schließen ist man schon woanders.
@Test("Einfahren ist in jedem Profil kürzer als Ausfahren")
func collapseIsShorterThanExpand() {
// Die Federantwort steckt in der Animation und ist von außen nicht
// lesbar. Prüfbar ist die Dauer, die der Fenstercontroller benutzt —
// und die ist genau der Wert, an dem es sichtbar schiefgeht.
for profile in MotionProfile.allCases {
#expect(profile.collapseDuration > 0)
#expect(profile.collapseDuration < 1)
}
#expect(MotionProfile.snappy.collapseDuration < MotionProfile.calm.collapseDuration)
}
/// Verschwindet das Fenster vor dem Ende der Bewegung, reißt das Panel
/// sichtbar weg. Genau dafür hängt die Dauer am Profil und ist keine feste
/// Zahl mehr.
@Test("Jedes Profil bringt seine eigene Einfahrdauer mit")
func everyProfileCarriesItsOwnDuration() {
let durations = Set(MotionProfile.allCases.map(\.collapseDuration))
#expect(durations.count == MotionProfile.allCases.count)
}
}
@Suite("Pegel im Regler")
struct MeterFractionTests {
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos: halb
/// so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
/// Bereich, und genau das ist hier zu prüfen — sonst ist der Regler ein
/// Balken, der die meiste Zeit leer aussieht.
@Test("Leiser Ton schlägt sichtbar aus")
func quietSignalIsVisible() {
// Ein Viertel der Amplitude sähe linear nach 25 % aus. Die Wurzel macht
// daraus die Hälfte.
#expect(abs(OnyxSlider.meterFraction(0.25) - 0.5) < 0.0001)
}
@Test("Die Enden bleiben, wo sie sind")
func endsAreUntouched() {
#expect(OnyxSlider.meterFraction(0) == 0)
#expect(OnyxSlider.meterFraction(1) == 1)
}
/// Der Ausschlag kommt aus einem Tap und ist nicht garantiert im Bereich.
/// Ein Wert über 1 machte den Pegelbalken breiter als den Regler, ein
/// negativer ließe `sqrt` NaN liefern — und NaN in einer Breitenangabe
/// beendet SwiftUI.
@Test("Werte außerhalb des Bereichs werden gekappt")
func outOfRangeIsClamped() {
#expect(OnyxSlider.meterFraction(4) == 1)
#expect(OnyxSlider.meterFraction(-1) == 0)
#expect(!OnyxSlider.meterFraction(-1).isNaN)
}
}
@@ -0,0 +1,59 @@
import Testing
import SwiftUI
@testable import OnyxDesign
@Suite("Werkstoffe")
@MainActor
struct NotchMaterialTests {
@Test("Stein ist deckend — sonst hätte er an der Hardware-Notch eine Kante")
func stoneIsOpaque() {
#expect(NotchMaterial.stone.fillOpacity == 1.0)
// Und braucht deshalb keine Weichzeichnung darunter.
#expect(!NotchMaterial.stone.showsVibrancy)
}
@Test("Je durchlässiger, desto weniger Deckkraft")
func opacityDecreasesWithTranslucency() {
#expect(NotchMaterial.stone.fillOpacity > NotchMaterial.blur.fillOpacity)
#expect(NotchMaterial.blur.fillOpacity > NotchMaterial.glass.fillOpacity)
}
@Test("Je durchlässiger, desto mehr Glanz")
func sheenGrowsWithTranslucency() {
// Auf deckendem Schwarz genügt ein Hauch; auf Glas ist der Glanz das,
// was die Fläche überhaupt als Körper lesbar macht.
#expect(NotchMaterial.stone.sheen < NotchMaterial.blur.sheen)
#expect(NotchMaterial.blur.sheen < NotchMaterial.glass.sheen)
}
@Test("Alle durchscheinenden Werkstoffe brauchen die Vibrancy-Ebene")
func translucentMaterialsNeedVibrancy() {
#expect(NotchMaterial.blur.showsVibrancy)
#expect(NotchMaterial.glass.showsVibrancy)
}
@Test("Kein Werkstoff ist unsichtbar")
func nothingIsFullyTransparent() {
// Eine Fläche mit Deckkraft null wäre kein Werkstoff, sondern ein
// fehlendes Panel — und niemand fände die Einstellung zurück.
for material in NotchMaterial.allCases {
#expect(material.fillOpacity > 0.3)
}
}
@Test("Jeder Werkstoff überlebt das Speichern")
func roundTripsThroughDefaults() {
for material in NotchMaterial.allCases {
#expect(NotchMaterial(rawValue: material.rawValue) == material)
}
}
@Test("Ein unbekannter Name fällt auf Stein zurück, nicht ins Nichts")
func unknownNameFallsBack() {
let defaults = UserDefaults(suiteName: "onyx.test.material")!
defaults.removePersistentDomain(forName: "onyx.test.material")
defaults.set("liquidUnobtainium", forKey: "onyx.theme.material")
#expect(OnyxTheme(defaults: defaults).material == .stone)
}
}
@@ -74,3 +74,30 @@ struct ColourHexTests {
#expect(Color(onyxHex: "#GGGGGG") == nil)
}
}
@Suite("Farben, die sich nicht gleichen dürfen")
@MainActor
struct ColourCollisionTests {
/// Beide waren zeichengleich `#6FCF97`. Damit hieß derselbe grüne Balken im
/// Netzwerk „lädt herunter" und in den Messwerten „alles in Ordnung" — und
/// wer beides nebeneinander sieht, liest eine Aussage, die niemand gemeint
/// hat.
@Test("Herunterladen ist nicht die Signalfarbe für „in Ordnung“")
func downloadIsNotThePositiveSignal() {
#expect(OnyxTheme.defaultDownload.onyxHex != Onyx.Color.positive.onyxHex)
}
@Test("Herunter und Hoch sind auch untereinander unterscheidbar")
func directionsDifferFromEachOther() {
#expect(OnyxTheme.defaultDownload.onyxHex != OnyxTheme.defaultUpload.onyxHex)
}
/// Die drei Signalfarben tragen die Bedeutung. Fielen zwei davon zusammen,
/// wäre eine Warnung von einem Fehler nicht mehr zu unterscheiden.
@Test("Die Signalfarben sind paarweise verschieden")
func signalColoursAreDistinct() {
let signals = [Onyx.Color.positive, Onyx.Color.warning, Onyx.Color.critical]
#expect(Set(signals.map(\.onyxHex)).count == signals.count)
}
}