Files
Flaro/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs
T
ScarriffleandClaude Opus 5 ed13d5fa43 Kratzplatz, Markenerkennung, Tonabdruck — erfundene Wörter für Fachbegriffe
„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.

    Kratzplatz*       → Transcode*          (was es ist, steht in FLARO_TRANSCODE)
    Marke, Marken     → Segment, Segmente
    Markenerkennung   → Segmenterkennung
    Markenart/-suche  → Segmentart/-suche
    Tonabdruck        → Fingerprint         (Chromaprint nennt es so)
    Tonabgleich       → Fingerprintabgleich
    Schlüsselbild     → Keyframe
    Bildabtaster      → Frameabtaster

Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.

**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.

**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.

Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:26:57 +02:00

328 lines
11 KiB
C#

using Flaro.Core.Playback;
namespace Flaro.Core.Tests;
/// <summary>Die Regel, um derentwillen dieser Server existiert.</summary>
[TestFixture]
public sealed class ProgressRuleTests
{
private static readonly TimeSpan Zweistünder = TimeSpan.FromHours(2);
/// <summary>Die Vorgabe des Servers — was gilt, wenn niemand etwas einstellt.</summary>
private static readonly Wiedergaberegeln Regeln = Wiedergaberegeln.Vorgabe;
/// <summary>Kurzschreibweise, damit die Prüfungen lesbar bleiben.</summary>
private static bool BelongsInContinueWatching(UserItemState zustand) =>
ProgressRule.BelongsInContinueWatching(zustand, Regeln);
/// <summary>Der Fall, der den Ausschlag gab.</summary>
///
/// <remarks>
/// Jellyfins <c>MinResumePct</c> steht auf 5. Bei zwei Stunden sind das
/// sechs Minuten, und wer nach fünf abbricht, findet beim nächsten Mal
/// nichts vor. Hier ist genau diese Stelle abgelegt.
/// </remarks>
[Test]
public void Die_ersten_Minuten_zaehlen_auch()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(5), Zweistünder, Regeln);
Assert.Multiple(() =>
{
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(5)));
Assert.That(ergebnis.Watched, Is.False);
});
}
[Test]
public void Eine_einzige_Sekunde_zaehlt_auch()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(1), Zweistünder, Regeln);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(1)));
}
/// <summary>Jellyfins <c>MinResumeDurationSeconds</c> steht auf 300.</summary>
///
/// <remarks>
/// Dort gilt eine vierminütige Folge nach dreissig Sekunden als
/// vollständig gesehen. Hier ist sie das, was sie ist: angefangen.
/// </remarks>
[Test]
public void Ein_kurzer_Titel_gilt_nicht_sofort_als_gesehen()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(4), Regeln);
Assert.Multiple(() =>
{
Assert.That(ergebnis.Watched, Is.False);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(30)));
});
}
[Test]
public void Kurz_vor_Schluss_gilt_er_als_gesehen()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(115), Zweistünder, Regeln);
Assert.That(ergebnis.Watched, Is.True);
}
/// <summary>Und die Stelle bleibt trotzdem stehen.</summary>
///
/// <remarks>
/// Jellyfin setzt sie an dieser Stelle auf null. Wer den Abspann sehen
/// oder die letzte Szene nochmal will, fängt dort von vorn an.
/// </remarks>
[Test]
public void Gesehen_wirft_die_Stelle_nicht_weg()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(118), Zweistünder, Regeln);
Assert.Multiple(() =>
{
Assert.That(ergebnis.Watched, Is.True);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(118)));
});
}
[Test]
public void Genau_an_der_Grenze_gilt_er_als_gesehen()
{
var ergebnis = ProgressRule.Apply(Zweistünder * Regeln.GesehenAnteil, Zweistünder, Regeln);
Assert.That(ergebnis.Watched, Is.True);
}
[Test]
public void Eine_Sekunde_davor_noch_nicht()
{
var ergebnis = ProgressRule.Apply(
Zweistünder * Regeln.GesehenAnteil - TimeSpan.FromSeconds(1), Zweistünder, Regeln);
Assert.That(ergebnis.Watched, Is.False);
}
/// <summary>Ohne Laufzeit gibt es keinen Anteil — also kein Urteil.</summary>
///
/// <remarks>
/// Jellyfin nimmt in diesem Fall an, der Titel sei vollständig gesehen
/// („we'll just have to assume it was fully played"). Das ist die
/// Annahme, die man nicht zurücknehmen kann, ohne sie zu bemerken.
/// </remarks>
[Test]
public void Ohne_Laufzeit_wird_nichts_vermutet()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(90), null, Regeln);
Assert.Multiple(() =>
{
Assert.That(ergebnis.Watched, Is.False);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(90)));
});
}
[Test]
public void Eine_Laufzeit_von_null_ist_keine()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(3), TimeSpan.Zero, Regeln);
Assert.That(ergebnis.Watched, Is.False);
}
/// <summary>Spieler melden beim Spulen und beim Beenden Unsinn.</summary>
[Test]
public void Eine_negative_Stelle_wird_zu_null()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(-5), Zweistünder, Regeln);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.Zero));
}
[Test]
public void Jenseits_des_Endes_wird_auf_das_Ende_gekuerzt()
{
var ergebnis = ProgressRule.Apply(Zweistünder + TimeSpan.FromSeconds(3), Zweistünder, Regeln);
Assert.Multiple(() =>
{
Assert.That(ergebnis.Position, Is.EqualTo(Zweistünder));
Assert.That(ergebnis.Watched, Is.True);
});
}
// --- Weiterschauen ---------------------------------------------------
[Test]
public void Angefangen_gehoert_in_Weiterschauen()
{
Assert.That(BelongsInContinueWatching(
Zustand(TimeSpan.FromMinutes(20), Zweistünder)), Is.True);
}
[Test]
public void Nie_angefangen_gehoert_nicht_hinein()
{
Assert.That(BelongsInContinueWatching(
Zustand(TimeSpan.Zero, Zweistünder)), Is.False);
}
[Test]
public void Durchgeschaut_gehoert_nicht_hinein()
{
Assert.That(BelongsInContinueWatching(
Zustand(TimeSpan.FromMinutes(118), Zweistünder)), Is.False);
}
[Test]
public void Weggelegtes_gehoert_nicht_hinein()
{
var zustand = Zustand(TimeSpan.FromMinutes(20), Zweistünder);
zustand.Dismissed = true;
Assert.That(BelongsInContinueWatching(zustand), Is.False);
}
/// <summary>Ein zweites Mal angefangen — dann steht er wieder da.</summary>
///
/// <remarks>
/// Das Häkchen bleibt: gesehen hat man ihn ja. Beides nebeneinander ist
/// genau das, was ohne weggeworfene Position möglich wird.
/// </remarks>
[Test]
public void Ein_gesehener_Titel_kommt_beim_zweiten_Mal_zurueck()
{
var zustand = Zustand(TimeSpan.FromMinutes(10), Zweistünder);
zustand.Watched = true;
Assert.That(BelongsInContinueWatching(zustand), Is.True);
}
/* ---------- Die einstellbare Untergrenze ---------- */
/// <summary>Hineingeschaut ist noch nicht angefangen.</summary>
///
/// <remarks>
/// Der Anlass: eine Sekunde im Aufmacher, und der Titel stand danach in
/// „Weiterschauen". Die Stelle wird trotzdem abgelegt — nur die Reihe
/// bleibt frei. Das ist der Unterschied zu Jellyfin, der oben steht.
/// </remarks>
[Test]
public void Unter_der_Schwelle_gehoert_er_nicht_hinein()
{
var zustand = Zustand(TimeSpan.FromSeconds(1), Zweistünder);
Assert.Multiple(() =>
{
Assert.That(ProgressRule.BelongsInContinueWatching(zustand, Regeln), Is.False);
Assert.That(zustand.Position, Is.EqualTo(TimeSpan.FromSeconds(1)),
"die Stelle bleibt — weggeworfen wird sie nie");
});
}
[Test]
public void Genau_auf_der_Schwelle_gehoert_er_hinein()
{
Assert.That(ProgressRule.BelongsInContinueWatching(
Zustand(Regeln.FortsetzenAb, Zweistünder), Regeln), Is.True);
}
/// <summary>„Sofort" ist eine gültige Einstellung — dann zählt jede Sekunde.</summary>
[Test]
public void Ohne_Schwelle_zaehlt_die_erste_Sekunde()
{
var ohne = Regeln with { FortsetzenAb = TimeSpan.Zero };
Assert.That(ProgressRule.BelongsInContinueWatching(
Zustand(TimeSpan.FromSeconds(1), Zweistünder), ohne), Is.True);
}
/// <summary>Und der Anteil wirkt auch.</summary>
[Test]
public void Ein_hoeherer_Anteil_laesst_ihn_laenger_stehen()
{
var zustand = Zustand(TimeSpan.FromMinutes(112), Zweistünder); // 93,3 %
Assert.Multiple(() =>
{
Assert.That(ProgressRule.BelongsInContinueWatching(zustand, Regeln), Is.False,
"bei 90 % ist er durch");
Assert.That(ProgressRule.BelongsInContinueWatching(
zustand, Regeln with { GesehenAnteil = 0.95 }), Is.True,
"bei 95 % noch nicht");
});
}
/// <summary>Unsinn aus der Datenbank wird eingefangen, nicht übernommen.</summary>
[Test]
public void Geprueft_bindet_den_Anteil_ein()
{
Assert.Multiple(() =>
{
Assert.That(new Wiedergaberegeln(TimeSpan.FromSeconds(-5), 0).Geprueft(),
Is.EqualTo(new Wiedergaberegeln(TimeSpan.Zero, 0.5)));
Assert.That(new Wiedergaberegeln(TimeSpan.Zero, 7).Geprueft().GesehenAnteil,
Is.EqualTo(1.0));
});
}
private static UserItemState Zustand(TimeSpan stelle, TimeSpan? laenge) =>
new() { UserId = Guid.CreateVersion7(), MovieId = Guid.CreateVersion7(), Position = stelle, Runtime = laenge };
/// <summary>Ein erkannter Abspann zieht das Häkchen nach vorn.</summary>
///
/// <remarks>
/// <b>Der Fall aus dem Gebrauch.</b> Eine Serie hängt sechs Minuten
/// Abspann an eine Folge von fünfundvierzig. Neunzig Prozent liegen dann
/// mitten im Abspann, und die Folge gilt erst als gesehen, wenn man ihn
/// sich ganz ansieht. Die Schwelle dafür herunterzudrehen hiesse, jeden
/// anderen Titel zu früh abzuhaken.
/// </remarks>
[Test]
public void Der_Abspann_zaehlt_als_Schluss()
{
var laenge = TimeSpan.FromMinutes(45);
var abspann = TimeSpan.FromMinutes(39);
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
var amAbspann = ProgressRule.Apply(abspann, laenge, regeln, abspann);
var davor = ProgressRule.Apply(abspann - TimeSpan.FromSeconds(30), laenge, regeln, abspann);
Assert.Multiple(() =>
{
Assert.That(amAbspann.Watched, Is.True, "ab dem Abspann ist die Handlung vorbei");
Assert.That(davor.Watched, Is.False, "eine halbe Minute davor noch nicht");
});
}
/// <summary>Ein Segment hinter der Schwelle verschiebt nichts nach hinten.</summary>
///
/// <remarks>
/// Sonst machte eine falsch erkannte Segment aus einem Titel, der bei
/// neunzig Prozent fertig war, einen, der es erst bei fünfundneunzig
/// ist — und niemand sähe, warum.
/// </remarks>
[Test]
public void Eine_spaete_Segment_schiebt_nichts_nach_hinten()
{
var laenge = TimeSpan.FromMinutes(45);
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
var ergebnis = ProgressRule.Apply(
TimeSpan.FromMinutes(41), laenge, regeln, abspannAb: TimeSpan.FromMinutes(44));
Assert.That(ergebnis.Watched, Is.True);
}
/// <summary>Ohne Segment bleibt alles, wie es war.</summary>
[Test]
public void Ohne_Abspann_gilt_der_Anteil()
{
var laenge = TimeSpan.FromMinutes(45);
var regeln = new Wiedergaberegeln { GesehenAnteil = 0.9 };
Assert.That(ProgressRule.Apply(TimeSpan.FromMinutes(39), laenge, regeln).Watched, Is.False);
Assert.That(ProgressRule.Apply(TimeSpan.FromMinutes(41), laenge, regeln).Watched, Is.True);
}
}