Ein Film stand bei 1:32 und tauchte in „Fortsetzen" trotzdem nicht auf. Die Reihe fragt nicht die Regel, sondern deren festgehaltenes Ergebnis: die Spalte `InProgress`. Die Bruchrechnung „Stelle kleiner als Anteil der Laufzeit" lässt sich nicht nach SQL übersetzen, also steht sie als Wahrheitswert in der Zeile — und berechnet wird sie nur, wenn eine Fortschrittsmeldung eintrifft. Die kaputte Meldung von vorgestern hatte dort `false` hinterlassen; die Korrektur am Server verhinderte neuen Schaden, räumte den alten aber nicht weg. `Sehstandpflege` rechnet nach jedem Update einmal durch: erst die Laufzeit aus der Datei nachtragen, dann neu bewerten — in dieser Reihenfolge, denn eine Neubewertung auf Basis eines Stummels käme wieder zu `false`. `UpdatedAt` bleibt, wo es war; danach ist die Reihe sortiert. Und der Knopf heisst jetzt so wie die Reihe, über der er steht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
13 KiB
C#
329 lines
13 KiB
C#
using System.Net.Http.Json;
|
|
using Flaro.Api.Endpoints;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace Flaro.Api.Tests;
|
|
|
|
/// <summary>Das Sehprotokoll und was sich daraus ablesen lässt.</summary>
|
|
[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]);
|
|
}
|
|
|
|
/// <summary>Die Uhr des Servers — stellbar, damit Zeit vergehen kann.</summary>
|
|
private Stelluhr _uhr = new(DateTimeOffset.UtcNow);
|
|
|
|
/// <summary>Ein Server, dessen Uhr dieser Test stellt.</summary>
|
|
private FlaroServer MitUhr()
|
|
{
|
|
_uhr = new Stelluhr(DateTimeOffset.UtcNow);
|
|
return new FlaroServer(dienste => dienste.AddSingleton<TimeProvider>(_uhr));
|
|
}
|
|
|
|
private async Task<HttpClient> 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<LibraryResponse>())!;
|
|
|
|
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
|
for (var versuch = 0; versuch < 100; versuch++)
|
|
{
|
|
var stand = await client.GetFromJsonAsync<ScanStatus>($"/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<Guid> ErsterFilm(HttpClient client)
|
|
{
|
|
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/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<StatistikResponse?> Statistik(HttpClient client) =>
|
|
client.GetFromJsonAsync<StatistikResponse>("/api/v1/users/me/statistics");
|
|
|
|
/// <summary>Geschaute Zeit landet im Protokoll.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>Vorspulen ist kein Schauen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Der Grund für den Deckel.</b> 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.
|
|
/// </remarks>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>Zurückspringen zieht nichts ab.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Die Wochenkurve hat so viele Punkte wie verlangt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Auch leere Wochen stehen darin. Eine Kurve, die nur die Wochen zeigt,
|
|
/// in denen etwas lief, behauptet eine Gleichmässigkeit, die es nicht gab.
|
|
/// </remarks>
|
|
[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<StatistikResponse>(
|
|
"/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));
|
|
});
|
|
}
|
|
|
|
/// <summary>Was ohne Protokoll vollständig ist, steht auch da.</summary>
|
|
[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");
|
|
});
|
|
}
|
|
|
|
/// <summary>Jeder sieht nur seine eigenen Zahlen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Auch der Administrator.</b> 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.
|
|
/// </remarks>
|
|
[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);
|
|
});
|
|
}
|
|
|
|
/// <summary>Zwei Fortschrittsmeldungen gleichzeitig stürzen nicht ab.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Aus dem Protokoll eines laufenden Servers.</b> 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.
|
|
/// </remarks>
|
|
[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.");
|
|
}
|
|
|
|
/// <summary>Ein Stummel als Laufzeit nimmt den Titel nicht dauerhaft aus „Fortsetzen".</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Der gemeldete Fall.</b> 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 <c>InProgress</c>. Berechnet
|
|
/// wird sie nur, wenn eine Meldung eintrifft.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
[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<Page<ContinueEntry>>("/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<Page<ContinueEntry>>("/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");
|
|
}
|
|
|
|
/// <summary>„Entfernen" nimmt ihn heraus — das soll weiter gehen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Die Reparatur darf nicht so weit gehen, dass sie eine Entscheidung
|
|
/// des Zuschauers überschreibt.
|
|
/// </remarks>
|
|
[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<Page<ContinueEntry>>("/api/v1/home/continue");
|
|
|
|
Assert.That(danach!.Items, Is.Empty);
|
|
}
|
|
}
|