using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// Was eine Fortschrittsmeldung am gemerkten Stand ändern darf.
///
///
///
/// Der Anlass. Ein Film stand bei 46 Minuten, und nach einem Neuladen
/// bot die Detailseite nur noch „Abspielen" an. Gemessen, woran es lag: eine
/// einzige Meldung „Stelle 500 s, Laufzeit 60 s" machte aus der Stelle 500
/// die Stelle 60, aus der Laufzeit 600 die Laufzeit 60, setzte das Häkchen
/// „gesehen" und nahm den Titel aus „Weiterschauen".
///
///
/// Woher so eine Meldung kommt: wer einen gerechneten Strom abspielt, misst
/// an einer Playlist, die noch wächst, und sieht nur das bisher Erzeugte.
/// Scheitert die Wiedergabe gleich darauf — auf dem iPad tat sie das —,
/// bleibt dieser Stummel als „Laufzeit" stehen. Dazu meldet jeder Spieler
/// beim Öffnen einmal die Zeit null, bevor er überhaupt gesprungen ist.
///
///
/// Die Regel. Ein Client darf einen Stand fortschreiben, aber nicht
/// zerstören. Die Laufzeit, die der Server kennt, schlägt die gemeldete, und
/// eine gemeldete Null löscht nichts.
///
///
[TestFixture]
public sealed class FortschrittTests
{
private string _bestand = "";
[SetUp]
public void Anlegen() => _bestand = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-fort-" + Guid.CreateVersion7().ToString("N"))).FullName;
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ }
}
/// Ein Server mit einem eingelesenen Film, angemeldet als Administrator.
private async Task<(HttpClient Client, Guid Film)> MitEinemFilm(FlaroServer server)
{
var voll = Path.Combine(_bestand, "Langprobe (2026)", "Langprobe (2026).mkv");
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
await File.WriteAllBytesAsync(voll, new byte[2048]);
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest("Prüfbestand", "Movies", _bestand));
angelegt.EnsureSuccessStatusCode();
var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var versuch = 0; versuch < 100; versuch++)
{
var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State is "finished") break;
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
await Task.Delay(50);
}
var seite = await client.GetFromJsonAsync>("/api/v1/movies");
return (client, seite!.Items[0].Id);
}
private static async Task Melden(
HttpClient client, Guid film, double stelle, double? laufzeit)
{
var antwort = await client.PostAsJsonAsync($"/api/v1/items/{film}/progress",
new ProgressRequest(stelle, laufzeit, null));
antwort.EnsureSuccessStatusCode();
return (await antwort.Content.ReadFromJsonAsync())!;
}
/// Eine zu kurz gemeldete Laufzeit nimmt dem Film nicht die Stelle.
///
///
/// Das ist der gemeldete Fehler, Zahl für Zahl. Vorher kam hier Stelle 60
/// heraus, Laufzeit 60 und „gesehen".
///
[Test]
public async Task Eine_zu_kurze_Laufzeit_zerstoert_den_Stand_nicht()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
await Melden(client, film, 500, 600);
var danach = await Melden(client, film, 500, 60);
Assert.Multiple(() =>
{
Assert.That(danach.PositionSeconds, Is.EqualTo(500).Within(0.001),
"die Stelle darf nicht auf den Stummel zusammengestaucht werden");
Assert.That(danach.RuntimeSeconds, Is.EqualTo(600).Within(0.001),
"und die bekannte Laufzeit nicht durch ihn ersetzt");
Assert.That(danach.Watched, Is.False,
"bei 500 von 600 Sekunden ist nichts zu Ende gesehen");
Assert.That(danach.InProgress, Is.True,
"der Film gehört weiter in die Reihe Weiterschauen");
});
}
/// Eine gemeldete Null löscht keine gemerkte Stelle.
///
///
/// Beide Clients tun das. Die App meldet beim Öffnen ausdrücklich
/// die Zeit null — dort legt erst diese Meldung die Sitzung an. Der
/// Browser tut es im Fenster zwischen „Datei geöffnet" und „Sprung
/// ausgeführt", und wenn er die Datei nicht entschlüsseln kann, kommt er
/// aus diesem Fenster nie heraus.
///
[Test]
public async Task Eine_gemeldete_Null_loescht_die_Stelle_nicht()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
await Melden(client, film, 2771, 11468);
var danach = await Melden(client, film, 0, 11468);
Assert.That(danach.PositionSeconds, Is.EqualTo(2771).Within(0.001));
}
/// Wer wirklich von vorn anfängt, setzt die Stelle trotzdem zurück.
///
///
/// Das ist der Preis der Regel darüber, und er ist klein: zehn Sekunden
/// später meldet derselbe Spieler die 10, und die zählt. „Von vorn"
/// bleibt damit „von vorn", nur eben ab der ersten gesehenen Sekunde.
///
[Test]
public async Task Von_vorn_setzt_die_Stelle_mit_der_ersten_echten_Meldung_zurueck()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
await Melden(client, film, 2771, 11468);
await Melden(client, film, 0, 11468);
var danach = await Melden(client, film, 10, 11468);
Assert.That(danach.PositionSeconds, Is.EqualTo(10).Within(0.001));
}
/// Zurückspulen bleibt möglich.
///
///
/// Die Regel gilt der Null, nicht dem Rückwärtsgehen. Wer eine Szene
/// noch einmal sehen will, soll danach auch dort weitermachen.
///
[Test]
public async Task Zurueckspulen_setzt_die_Stelle_weiter_zurueck()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
await Melden(client, film, 500, 600);
var danach = await Melden(client, film, 120, 600);
Assert.That(danach.PositionSeconds, Is.EqualTo(120).Within(0.001));
}
/// Ohne jede bekannte Laufzeit zählt weiter, was der Client sagt.
///
///
/// Sonst gäbe es für einen frisch angefangenen Titel keinen Anteil und
/// damit nie ein „gesehen". Die neue Rangfolge nimmt dem Client nur den
/// Vorrang, nicht das Wort.
///
[Test]
public async Task Ohne_bekannte_Laufzeit_gilt_die_gemeldete()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
var danach = await Melden(client, film, 100, 1000);
Assert.That(danach.RuntimeSeconds, Is.EqualTo(1000).Within(0.001));
}
// --- Wo der Stand bisher weggeworfen wurde ---
/// Auch ein Vorschlag bringt seinen Stand mit.
///
///
/// Drei Listen gaben State: null zurück — Vorschläge, die
/// saisonale Reihe und die ähnlichen Titel —, obwohl die Stände in
/// derselben Datenbank liegen. Ein halb gesehener Film sah dort aus wie
/// ein unangetasteter, während dieselbe Kachel in der Mediathek einen
/// Balken trug. Die Kachel wird an einer Stelle gebaut; sie kann nur
/// zeichnen, was ankommt.
///
/// Geprüft wird hier die Vorschlagsreihe: sie entsteht aus dem
/// Sehverhalten, also genügt ein angefangener Film, damit sie überhaupt
/// etwas enthält — und ausgerechnet dieser Film steht dann darin.
///
[Test]
public async Task Ein_Vorschlag_bringt_seinen_Stand_mit()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
await Melden(client, film, 300, 600);
var seite = await client.GetFromJsonAsync>(
"/api/v1/home/suggestions");
var eintrag = seite!.Items.FirstOrDefault(x => x.Id == film);
Assume.That(eintrag, Is.Not.Null, "ohne Genres schlägt der Server nichts vor");
Assert.Multiple(() =>
{
Assert.That(eintrag!.State, Is.Not.Null, "ohne Stand kein Balken");
Assert.That(eintrag.State!.PositionSeconds, Is.EqualTo(300).Within(0.001));
});
}
/// Eine weggelegte Serie steht nicht mehr in „Als Nächstes".
///
///
/// Weggelegt gilt für die Serie, nicht für die Folge. Eine
/// einzelne Folge dort wegzulegen nützte nichts: beim nächsten Laden
/// rückte die übernächste nach, und man legte zwanzigmal weg. Gemeint
/// ist „diese Serie nicht mehr vorschlagen" — und genau dieser Zustand
/// wurde in der Reihe nie gelesen.
///
[Test]
public async Task Eine_weggelegte_Serie_verschwindet_aus_Als_Naechstes()
{
using var server = new FlaroServer();
foreach (var name in new[] { "S01E01", "S01E02" })
{
var voll = Path.Combine(_bestand, "Probe (2026)", "Season 01", $"Probe - {name}.mkv");
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
await File.WriteAllBytesAsync(voll, new byte[2048]);
}
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest("Prüfbestand", "Shows", _bestand));
angelegt.EnsureSuccessStatusCode();
var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var versuch = 0; versuch < 100; versuch++)
{
var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State is "finished") break;
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
await Task.Delay(50);
}
var serien = await client.GetFromJsonAsync>("/api/v1/series");
Assume.That(serien!.Items, Is.Not.Empty, "der Scan hat keine Serie gefunden");
var serie = serien.Items[0].Id;
var folgen = await client.GetFromJsonAsync>(
$"/api/v1/series/{serie}/episodes");
// Erst die erste Folge abhaken — sonst hat der Benutzer die Serie nie
// angefasst, und sie stünde ohnehin nicht in der Reihe.
(await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched",
new WatchedRequest(true))).EnsureSuccessStatusCode();
var vorher = await client.GetFromJsonAsync>("/api/v1/home/next");
Assume.That(vorher!.Items.Select(x => x.SeriesId), Does.Contain(serie),
"ohne Eintrag gibt es nichts wegzulegen");
(await client.PostAsync($"/api/v1/items/{serie}/dismiss", null)).EnsureSuccessStatusCode();
var nachher = await client.GetFromJsonAsync>("/api/v1/home/next");
Assert.That(nachher!.Items.Select(x => x.SeriesId), Does.Not.Contain(serie));
}
}