Files
ScarriffleandClaude Opus 5 a609b2d087 Ein Haken, der stehen blieb, als die Rechnung sich änderte
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>
2026-10-08 22:41:22 +02:00

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