Files
Flaro/Packages/HaloKit/Tests/HaloPlaybackTests/StallLedgerTests.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +02:00

116 lines
4.5 KiB
Swift

import Testing
import Foundation
@testable import HaloPlayback
@Suite("Rückler-Buchführung")
struct StallLedgerTests {
/// Der Fall, wegen dem es den Typ gibt.
///
/// Zehn Rückler à dreißig Sekunden lösten beim alten Watchdog nichts aus,
/// weil er sich bei jedem Erholen zurücksetzte. Hier muss sichtbar
/// werden, dass die Wiedergabe unbrauchbar ist.
@Test("Viele kurze Rückler bleiben sichtbar")
func manyShortStallsAreVisible() {
var ledger = StallLedger(window: .seconds(180))
// Alle 20 s ein Rückler von 5 s, drei Minuten lang.
for step in 1...9 {
ledger.record(.init(endedAt: .seconds(step * 20), length: .seconds(5)))
}
let now = Duration.seconds(180)
#expect(ledger.count(at: now) == 9)
// 45 s Warten in 180 s Fenster.
#expect(ledger.share(at: now) > 0.2)
#expect(ledger.share(at: now) < 0.3)
}
@Test("Was aus dem Fenster fällt, zählt nicht mehr")
func oldEpisodesFallOut() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(10), length: .seconds(30)))
#expect(ledger.count(at: .seconds(60)) == 1)
// Weit hinter dem Fenster: vergessen.
#expect(ledger.count(at: .seconds(300)) == 0)
#expect(ledger.share(at: .seconds(300)) == 0)
}
/// Am Anfang ist das Fenster noch nicht voll — dann gegen die tatsächlich
/// vergangene Zeit rechnen, sonst sieht ein früher Rückler harmlos aus.
@Test("Früh im Film zählt die vergangene Zeit, nicht das ganze Fenster")
func earlyStallsAreNotDiluted() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(20), length: .seconds(5)))
let share = ledger.share(at: .seconds(20))
// 5 s von 20 s, nicht 5 s von 180 s.
#expect(share > 0.2)
#expect(share < 0.3)
}
@Test("Der laufende Rückler zählt mit")
func ongoingStallCounts() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(60), length: .seconds(10)))
let without = ledger.share(at: .seconds(180))
let with = ledger.share(at: .seconds(180), ongoing: .seconds(20))
#expect(with > without)
}
/// Ein einzelner langer Rückler ist eine andere Aussage als viele kurze.
@Test("Der längste Einzelfall lässt sich getrennt ablesen")
func longestIsSeparate() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(30), length: .seconds(2)))
ledger.record(.init(endedAt: .seconds(60), length: .seconds(18)))
ledger.record(.init(endedAt: .seconds(90), length: .seconds(3)))
#expect(ledger.longest(at: .seconds(120)) == .seconds(18))
#expect(ledger.count(at: .seconds(120)) == 3)
}
@Test("Eine ruhige Wiedergabe meldet nichts")
func quietPlaybackIsQuiet() {
let ledger = StallLedger()
#expect(ledger.count(at: .seconds(600)) == 0)
#expect(ledger.share(at: .seconds(600)) == 0)
#expect(ledger.longest(at: .seconds(600)) == .zero)
}
@Test("Der Anteil übersteigt nie eins")
func shareIsBounded() {
var ledger = StallLedger(window: .seconds(60))
// Absurd: mehr Wartezeit als Fenster.
ledger.record(.init(endedAt: .seconds(30), length: .seconds(200)))
#expect(ledger.share(at: .seconds(60)) == 1)
}
/// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine
/// Reserve für einen Neuaufbau.
@Test("Der letzte Rückler lässt sich datieren")
func findsTheLastStall() {
var buch = StallLedger(window: .seconds(180))
buch.record(.init(endedAt: .seconds(30), length: .seconds(2)))
buch.record(.init(endedAt: .seconds(90), length: .seconds(1)))
#expect(buch.lastStall(at: .seconds(100)) == .seconds(90))
}
/// „Noch nie gestockt" ist etwas anderes als „vor langer Zeit" — wer
/// keinen hatte, wartet auf keine Frist.
@Test("Ohne Rückler gibt es kein Datum")
func noStallNoDate() {
let leer = StallLedger(window: .seconds(180))
#expect(leer.lastStall(at: .seconds(100)) == nil)
}
@Test("Was aus dem Fenster gefallen ist, zählt nicht mehr")
func forgetsOldStalls() {
var buch = StallLedger(window: .seconds(60))
buch.record(.init(endedAt: .seconds(10), length: .seconds(2)))
#expect(buch.lastStall(at: .seconds(200)) == nil)
}
}