using System.Net.Http.Json; using Flaro.Api.Endpoints; using Microsoft.Extensions.DependencyInjection; namespace Flaro.Api.Tests; /// Das Sehprotokoll und was sich daraus ablesen lässt. [TestFixture] public sealed class StatistikTests { private string _bestand = ""; [SetUp] public void Anlegen() => _bestand = Directory.CreateDirectory( Path.Combine(Path.GetTempPath(), "flaro-stat-" + Guid.CreateVersion7().ToString("N"))).FullName; [TearDown] public void Aufraeumen() { try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ } } private void Datei(string relativ) { var voll = Path.Combine(_bestand, relativ); Directory.CreateDirectory(Path.GetDirectoryName(voll)!); File.WriteAllBytes(voll, new byte[2048]); } /// Die Uhr des Servers — stellbar, damit Zeit vergehen kann. private Stelluhr _uhr = new(DateTimeOffset.UtcNow); /// Ein Server, dessen Uhr dieser Test stellt. private FlaroServer MitUhr() { _uhr = new Stelluhr(DateTimeOffset.UtcNow); return new FlaroServer(dienste => dienste.AddSingleton(_uhr)); } private async Task Eingelesen(FlaroServer server) { 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") return client; if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); await Task.Delay(50); } Assert.Fail("Der Scan wurde nicht fertig."); return client; } private static async Task ErsterFilm(HttpClient client) { var seite = await client.GetFromJsonAsync>("/api/v1/movies"); return seite!.Items[0].Id; } private static Task Melden(HttpClient client, Guid id, double stelle, double? laenge) => client.PostAsJsonAsync($"/api/v1/items/{id}/progress", new ProgressRequest(stelle, laenge, null)); private static Task Statistik(HttpClient client) => client.GetFromJsonAsync("/api/v1/users/me/statistics"); /// Geschaute Zeit landet im Protokoll. [Test] public async Task Was_laeuft_wird_mitgeschrieben() { Datei("Matrix (1999)/Matrix (1999).mkv"); using var server = MitUhr(); var client = await Eingelesen(server); var film = await ErsterFilm(client); var leer = await Statistik(client); Assert.That(leer!.LoggedSeconds, Is.Zero, "vor der ersten Wiedergabe steht nichts drin"); // Zweimal eine Minute schauen — mit einer Uhr, die mitläuft. await Melden(client, film, 0, 7200); _uhr.Weiter(TimeSpan.FromMinutes(1)); await Melden(client, film, 60, 7200); _uhr.Weiter(TimeSpan.FromMinutes(1)); await Melden(client, film, 120, 7200); var danach = await Statistik(client); Assert.That(danach!.LoggedSeconds, Is.EqualTo(120).Within(1), "zwei Minuten gesehen, zwei Minuten im Protokoll"); } /// Vorspulen ist kein Schauen. /// /// /// Der Grund für den Deckel. Zwischen zwei Meldungen liegen zehn /// Sekunden. Wer in dieser Zeit von Minute 5 auf Minute 90 springt, hat /// nicht 85 Minuten gesehen — ohne Deckel stünde ein einmal /// durchgespulter Film in der Statistik wie zweimal angeschaut. /// [Test] public async Task Vorspulen_zaehlt_nicht_als_Sehzeit() { Datei("Matrix (1999)/Matrix (1999).mkv"); using var server = MitUhr(); var client = await Eingelesen(server); var film = await ErsterFilm(client); await Melden(client, film, 300, 7200); // Ein Sprung um 85 Minuten, zehn Sekunden später. _uhr.Weiter(TimeSpan.FromSeconds(10)); await Melden(client, film, 5700, 7200); var stand = await Statistik(client); Assert.That(stand!.LoggedSeconds, Is.LessThan(60), "der Sprung darf nicht als anderthalb Stunden zählen"); } /// Zurückspringen zieht nichts ab. [Test] public async Task Zuruecksprigen_zaehlt_weder_vor_noch_zurueck() { Datei("Matrix (1999)/Matrix (1999).mkv"); using var server = MitUhr(); var client = await Eingelesen(server); var film = await ErsterFilm(client); await Melden(client, film, 0, 7200); _uhr.Weiter(TimeSpan.FromMinutes(2)); await Melden(client, film, 100, 7200); var vorher = (await Statistik(client))!.LoggedSeconds; _uhr.Weiter(TimeSpan.FromMinutes(1)); await Melden(client, film, 20, 7200); var nachher = (await Statistik(client))!.LoggedSeconds; Assert.That(nachher, Is.EqualTo(vorher).Within(0.001)); } /// Die Wochenkurve hat so viele Punkte wie verlangt. /// /// /// Auch leere Wochen stehen darin. Eine Kurve, die nur die Wochen zeigt, /// in denen etwas lief, behauptet eine Gleichmässigkeit, die es nicht gab. /// [Test] public async Task Die_Kurve_hat_auch_leere_Wochen() { Datei("Matrix (1999)/Matrix (1999).mkv"); using var server = MitUhr(); var client = await Eingelesen(server); var film = await ErsterFilm(client); await Melden(client, film, 0, 7200); _uhr.Weiter(TimeSpan.FromMinutes(2)); await Melden(client, film, 90, 7200); var stand = await client.GetFromJsonAsync( "/api/v1/users/me/statistics?wochen=6"); Assert.Multiple(() => { Assert.That(stand!.Weeks, Has.Count.EqualTo(6)); Assert.That(stand.Weeks.Select(w => w.Start.DayOfWeek), Is.All.EqualTo(DayOfWeek.Monday), "Wochen beginnen am Montag, sonst lässt sich die Kurve nicht vergleichen"); Assert.That(stand.Weeks[^1].Seconds, Is.GreaterThan(0), "die laufende Woche ist die letzte"); Assert.That(stand.Weeks.Sum(w => w.Seconds), Is.EqualTo(stand.LoggedSeconds).Within(0.001)); }); } /// Was ohne Protokoll vollständig ist, steht auch da. [Test] public async Task Gesehene_Titel_zaehlen_auch_ohne_Protokoll() { Datei("Matrix (1999)/Matrix (1999).mkv"); using var server = MitUhr(); var client = await Eingelesen(server); var film = await ErsterFilm(client); await client.PutAsJsonAsync($"/api/v1/items/{film}/watched", new WatchedRequest(true)); var stand = await Statistik(client); Assert.Multiple(() => { Assert.That(stand!.WatchedTitles, Is.EqualTo(1)); Assert.That(stand.Recent, Has.Count.EqualTo(1)); Assert.That(stand.Recent[0].Title, Is.EqualTo("Matrix")); Assert.That(stand.Recent[0].Watched, Is.True); Assert.That(stand.LoggedSeconds, Is.Zero, "abhaken ist kein Schauen — das Protokoll bleibt leer"); }); } /// Jeder sieht nur seine eigenen Zahlen. /// /// /// Auch der Administrator. Wer wann welchen Film anschaut, ist eine /// Auskunft über Menschen und nicht über den Server — es gibt deshalb gar /// keinen Weg, ein fremdes Konto zu meinen. /// [Test] public async Task Die_Zahlen_des_einen_sind_nicht_die_des_anderen() { Datei("Matrix (1999)/Matrix (1999).mkv"); using var server = MitUhr(); var alsAdmin = await Eingelesen(server); var film = await ErsterFilm(alsAdmin); var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); await Melden(alsAdmin, film, 0, 7200); _uhr.Weiter(TimeSpan.FromMinutes(3)); await Melden(alsAdmin, film, 120, 7200); var meine = await Statistik(alsAdmin); var fremde = await Statistik(gast); Assert.Multiple(() => { Assert.That(meine!.LoggedSeconds, Is.GreaterThan(0)); Assert.That(fremde!.LoggedSeconds, Is.Zero); Assert.That(fremde.Recent, Is.Empty); }); } /// Zwei Fortschrittsmeldungen gleichzeitig stürzen nicht ab. /// /// /// Aus dem Protokoll eines laufenden Servers. Der Spieler meldet /// alle zehn Sekunden, ein Sprung meldet sofort — zwei Meldungen können /// sich überholen. Vorher fanden dann beide keine Zeile im Sehprotokoll, /// beide legten eine an, und die zweite lief in „UNIQUE constraint failed: /// Sehtage.UserId, Sehtage.Tag, Sehtage.MovieId". Der Zuschauer merkte /// nichts, im Protokoll stand ein Stapelabzug. /// [Test] public async Task Zwei_Meldungen_gleichzeitig_zaehlen_beide() { Datei("Gleichzeitig (2024)/Gleichzeitig (2024).mkv"); using var server = MitUhr(); var client = await Eingelesen(server); var film = await ErsterFilm(client); // Den Nullpunkt setzen, dann Zeit vergehen lassen — sonst greift der // Deckel und es gäbe ohnehin nichts gutzuschreiben. await Melden(client, film, 0, 7200); _uhr.Weiter(TimeSpan.FromMinutes(5)); var beide = await Task.WhenAll( client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(60, 7200, null)), client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(90, 7200, null))); Assert.That(beide.Select(a => (int)a.StatusCode), Has.All.LessThan(400), "Eine Fortschrittsmeldung darf nie an einem Wettlauf scheitern."); Assert.That((await Statistik(client))!.LoggedSeconds, Is.GreaterThan(0), "und mitgezählt werden muss sie auch."); } /// Ein Stummel als Laufzeit nimmt den Titel nicht dauerhaft aus „Fortsetzen". /// /// /// /// Der gemeldete Fall. Ein Film stand bei 1:32, und in /// „Fortsetzen" tauchte er trotzdem nicht auf — bis er einmal neu /// gestartet wurde. Die Reihe fragt nicht die Regel, sondern deren /// festgehaltenes Ergebnis: die Spalte InProgress. Berechnet /// wird sie nur, wenn eine Meldung eintrifft. /// /// /// Hier wird geprüft, dass eine Meldung mit zu kurzer Laufzeit den /// Eintrag gar nicht erst herauswirft — und dass er danach weiter in /// „Fortsetzen" steht, ohne dass jemand den Film anfassen muss. /// /// [Test] public async Task Eine_zu_kurze_Laufzeit_wirft_nicht_aus_Fortsetzen() { Datei("Spinnenmann (2026)/Spinnenmann (2026).mkv"); using var server = MitUhr(); var client = await Eingelesen(server); var film = await ErsterFilm(client); await Melden(client, film, 5520, 11460); var davor = await client.GetFromJsonAsync>("/api/v1/home/continue"); Assert.That(davor!.Items.Select(e => e.Id), Has.Member(film), "bei 1:32 von 3:11 gehört er dort hin"); // Die Meldung, die früher alles zerstörte: ein Client, der an einer // wachsenden Playlist misst und nur einen Stummel sieht. await Melden(client, film, 5520, 60); var danach = await client.GetFromJsonAsync>("/api/v1/home/continue"); Assert.That(danach!.Items.Select(e => e.Id), Has.Member(film), "und er bleibt dort, auch nach einer unsinnig kurzen Laufzeit"); } /// „Entfernen" nimmt ihn heraus — das soll weiter gehen. /// /// /// Die Reparatur darf nicht so weit gehen, dass sie eine Entscheidung /// des Zuschauers überschreibt. /// [Test] public async Task Wer_entfernt_wird_bleibt_draussen() { Datei("Spinnenmann (2026)/Spinnenmann (2026).mkv"); using var server = MitUhr(); var client = await Eingelesen(server); var film = await ErsterFilm(client); await Melden(client, film, 5520, 11460); (await client.PostAsync($"/api/v1/items/{film}/dismiss", null)).EnsureSuccessStatusCode(); var danach = await client.GetFromJsonAsync>("/api/v1/home/continue"); Assert.That(danach!.Items, Is.Empty); } }