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)); } }