Files
Flaro/server/tests/Flaro.Api.Tests/StatistikTests.cs
T
ScarriffleandClaude Opus 5 579a49b73b Der Server wusste, was du zuletzt gesehen hast — und sonst nichts
Für „wie viel habe ich letzte Woche geschaut" gab es keine Daten.
`UserItemState` ist eine Zeile je Titel: der letzte Stand. Wer einen Film
zweimal sieht, überschreibt den ersten Durchgang; wann etwas lief, wusste
der Server nur fürs jeweils letzte Mal. Nachträglich erfinden lässt sich
das nicht.

Jetzt gibt es ein Sehprotokoll: **eine Zeile je Nutzer, Tag und Titel.**
Keine Sitzungen — die brauchen ein Ende, und das kommt oft nie an, wenn
der Browser zugemacht oder das Telefon zugeklappt wird. Tagesbündel haben
diesen Lebenslauf nicht, es wird nur addiert.

**Gezählt wird, was wirklich lief.** Der Zuwachs zwischen zwei Meldungen,
gedeckelt auf die Zeit, die dazwischen verstrichen ist (mal zwei als
Toleranz für doppelte Geschwindigkeit und ausgefallene Meldungen). Sonst
ergäbe ein Sprung von Minute 5 auf Minute 90 anderthalb Stunden Sehzeit in
zehn Sekunden. Gemessen an fünf Meldungen im Abstand von elf Sekunden:
88 Sekunden statt 190.

Dabei fiel ein zweiter Fall auf: war **gar keine** Zeit verstrichen, griff
kein Deckel statt eines engen — eine frisch angelegte Zeile hätte ihre
Anfangsstelle voll mitgezählt. Wer einen Film öffnet und sofort auf Minute
fünf springt, hat keine fünf Minuten gesehen.

Im Profil stehen drei Zahlen, und jede sagt, woher sie kommt: die
aufgezeichnete Zeit fängt heute an, die Zahl gesehener Titel steht seit
jeher fest. Dazu eine Wochenkurve, die häufigsten Genres und was zuletzt
lief. **Jeder sieht nur sich selbst, auch ein Administrator** — es gibt
keinen Weg, ein fremdes Konto zu meinen.

Zwei Dinge auf dem Weg dorthin:

**Eine stellbare Uhr für die Tests.** Zwei Minuten Film lassen sich nicht
in fünf Millisekunden sehen, und das ist richtig so — prüfen liess es sich
nur, indem die Zeit vergeht, ohne sie zu verbringen.

**Ein Namenskonflikt im CSS.** Die leeren Wochenbalken trugen die Klasse
`leer`, und die gehört längst der Fläche „hier ist nichts" — mitsamt
24 Pixeln Polster. Jeder leere Balken war dadurch 48 Pixel hoch, egal
welche Höhe dastand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:20:01 +02:00

234 lines
8.8 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);
});
}
}