Deine Angabe war der Schlüssel: du hast **mitten im Film die Tonspur umgeschaltet**. Dabei baut der Spieler den Strom an der aktuellen Stelle neu auf, und damit greift der Sprung — den ich gestern nur mit Spielzeugdateien gemessen hatte. Also eine Datei gebaut, die einem Film gleicht: 23,976 Bilder je Sekunde, Schlüsselbild alle fünf Sekunden, zwei Tonspuren, Deutsch als Stereo-AAC und Englisch als 5.1 in EAC3. Gemessen, erster Zeitstempel je Spur: | | Bild ab | Ton ab | |---|---|---| | Spur 2, ab Sekunde 0 | 0,021 | 0,000 | | Spur 2, ab 17 (mitten in der Bildgruppe) | 0,000 | **1,964** | | Spur 2, ab 15 (knapp vor dem Schlüsselbild) | 0,000 | **4,969** | Fast fünf Sekunden Bild ohne Ton. Zueinander sind beide dabei richtig getaktet — aber eine solche Lücke am Anfang darf jeder Spieler deuten, wie er will, und hls.js schiebt den Ton dann nach vorn. Genau das hast du gehört. Die Rundung auf das Schlüsselbild, die seit E8GSRE drin ist, nimmt das weg: dieselbe Datei, Sprung auf 17, gerundet auf 15,015 — **Bild ab 0,000, Ton ab 0,004**. Du läufst noch auf 5YZ63F; deshalb steht im Protokoll auch keine ffmpeg-Warnung, die gibt es erst ab E8GSRE. **Die Tonspur lässt sich jetzt vor dem Start wählen.** Auf der Detailseite steht neben „Abspielen" ein Knopf „Ton: Deutsch"; das Menü zeigt alle Spuren der Datei. Gemerkt wird dabei die **Sprache**, nicht die Spurnummer — welche Nummer Englisch trägt, ist von Film zu Film verschieden, dass du Englisch willst, ist es nicht. Beim nächsten Film steht die passende Spur damit schon da, und das Umschalten mitten im Film entfällt. Wer doch im Spieler wechselt, ändert dieselbe Merkstelle. **Und ein Fehler aus deinem Protokoll**, der nichts damit zu tun hat: „UNIQUE constraint failed: Sehtage.UserId, Sehtage.Tag, Sehtage.MovieId". Das ist ein Wettlauf im Sehprotokoll von vorgestern — der Spieler meldet alle zehn Sekunden, ein Sprung meldet sofort, und zwei Meldungen können sich überholen. Dann fanden beide keine Zeile, beide legten eine an. Jetzt wird in der Datenbank hochgezählt statt im Speicher, und wer dabei zu spät kommt, zählt beim zweiten Versuch mit. Mit Test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
10 KiB
C#
267 lines
10 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.");
|
|
}
|
|
}
|