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:
co-authored by
Claude Opus 5
parent
49a3ff0548
commit
ecb39fbe5f
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user