Der Fortschrittsbalken ist an **einer** Stelle gebaut und wird in jeder
Kachel verwendet — Web wie App. Fehlte er, dann nicht an der Kachel,
sondern weil der Server `State: null` schickte. Drei Listen taten das,
obwohl die Stände in derselben Abfrage danebenlagen:
/home/suggestions — „Weil du Ähnliches gesehen hast"
/home/seasonal — die saisonale Reihe
/movies/{id}/similar — „Ähnliche Titel"
Ausgerechnet die erste entsteht aus dem Sehverhalten: ein halb gesehener
Film sah dort aus wie ein unangetasteter, während dieselbe Kachel in der
Mediathek einen Balken trug. `ZuständeZu` gab es längst, es war nur
privat.
**Und „Als Nächstes" lässt sich jetzt räumen.** Das vorhandene Weglegen
reichte nicht dorthin: die Reihe liest `Watched` und `InProgress`, nie
`Dismissed` — und bei einer nie gestarteten Folge ist `InProgress` ohnehin
`false`. Weggelegt wird deshalb die **Serie**, nicht die Folge: beim
nächsten Laden rückte sonst die übernächste nach, und man legte
zwanzigmal weg. Den Zustand dafür gibt es schon, er wurde nur nie
gelesen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
12 KiB
C#
284 lines
12 KiB
C#
using System.Net.Http.Json;
|
|
using Flaro.Api.Endpoints;
|
|
|
|
namespace Flaro.Api.Tests;
|
|
|
|
/// <summary>Was eine Fortschrittsmeldung am gemerkten Stand ändern darf.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Der Anlass.</b> Ein Film stand bei 46 Minuten, und nach einem Neuladen
|
|
/// bot die Detailseite nur noch „Abspielen" an. Gemessen, woran es lag: eine
|
|
/// einzige Meldung „Stelle 500 s, Laufzeit 60 s" machte aus der Stelle 500
|
|
/// die Stelle 60, aus der Laufzeit 600 die Laufzeit 60, setzte das Häkchen
|
|
/// „gesehen" und nahm den Titel aus „Weiterschauen".
|
|
/// </para>
|
|
/// <para>
|
|
/// Woher so eine Meldung kommt: wer einen gerechneten Strom abspielt, misst
|
|
/// an einer Playlist, die noch wächst, und sieht nur das bisher Erzeugte.
|
|
/// Scheitert die Wiedergabe gleich darauf — auf dem iPad tat sie das —,
|
|
/// bleibt dieser Stummel als „Laufzeit" stehen. Dazu meldet jeder Spieler
|
|
/// beim Öffnen einmal die Zeit null, bevor er überhaupt gesprungen ist.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Die Regel.</b> Ein Client darf einen Stand fortschreiben, aber nicht
|
|
/// zerstören. Die Laufzeit, die der Server kennt, schlägt die gemeldete, und
|
|
/// eine gemeldete Null löscht nichts.
|
|
/// </para>
|
|
/// </remarks>
|
|
[TestFixture]
|
|
public sealed class FortschrittTests
|
|
{
|
|
private string _bestand = "";
|
|
|
|
[SetUp]
|
|
public void Anlegen() => _bestand = Directory.CreateDirectory(
|
|
Path.Combine(Path.GetTempPath(), "flaro-fort-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
|
|
|
[TearDown]
|
|
public void Aufraeumen()
|
|
{
|
|
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ }
|
|
}
|
|
|
|
/// <summary>Ein Server mit einem eingelesenen Film, angemeldet als Administrator.</summary>
|
|
private async Task<(HttpClient Client, Guid Film)> MitEinemFilm(FlaroServer server)
|
|
{
|
|
var voll = Path.Combine(_bestand, "Langprobe (2026)", "Langprobe (2026).mkv");
|
|
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
|
await File.WriteAllBytesAsync(voll, new byte[2048]);
|
|
|
|
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") break;
|
|
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
return (client, seite!.Items[0].Id);
|
|
}
|
|
|
|
private static async Task<ItemStateResponse> Melden(
|
|
HttpClient client, Guid film, double stelle, double? laufzeit)
|
|
{
|
|
var antwort = await client.PostAsJsonAsync($"/api/v1/items/{film}/progress",
|
|
new ProgressRequest(stelle, laufzeit, null));
|
|
antwort.EnsureSuccessStatusCode();
|
|
return (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
|
|
}
|
|
|
|
/// <summary>Eine zu kurz gemeldete Laufzeit nimmt dem Film nicht die Stelle.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Das ist der gemeldete Fehler, Zahl für Zahl. Vorher kam hier Stelle 60
|
|
/// heraus, Laufzeit 60 und „gesehen".
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Eine_zu_kurze_Laufzeit_zerstoert_den_Stand_nicht()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var (client, film) = await MitEinemFilm(server);
|
|
|
|
await Melden(client, film, 500, 600);
|
|
var danach = await Melden(client, film, 500, 60);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(danach.PositionSeconds, Is.EqualTo(500).Within(0.001),
|
|
"die Stelle darf nicht auf den Stummel zusammengestaucht werden");
|
|
Assert.That(danach.RuntimeSeconds, Is.EqualTo(600).Within(0.001),
|
|
"und die bekannte Laufzeit nicht durch ihn ersetzt");
|
|
Assert.That(danach.Watched, Is.False,
|
|
"bei 500 von 600 Sekunden ist nichts zu Ende gesehen");
|
|
Assert.That(danach.InProgress, Is.True,
|
|
"der Film gehört weiter in die Reihe Weiterschauen");
|
|
});
|
|
}
|
|
|
|
/// <summary>Eine gemeldete Null löscht keine gemerkte Stelle.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Beide Clients tun das.</b> Die App meldet beim Öffnen ausdrücklich
|
|
/// die Zeit null — dort legt erst diese Meldung die Sitzung an. Der
|
|
/// Browser tut es im Fenster zwischen „Datei geöffnet" und „Sprung
|
|
/// ausgeführt", und wenn er die Datei nicht entschlüsseln kann, kommt er
|
|
/// aus diesem Fenster nie heraus.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Eine_gemeldete_Null_loescht_die_Stelle_nicht()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var (client, film) = await MitEinemFilm(server);
|
|
|
|
await Melden(client, film, 2771, 11468);
|
|
var danach = await Melden(client, film, 0, 11468);
|
|
|
|
Assert.That(danach.PositionSeconds, Is.EqualTo(2771).Within(0.001));
|
|
}
|
|
|
|
/// <summary>Wer wirklich von vorn anfängt, setzt die Stelle trotzdem zurück.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Das ist der Preis der Regel darüber, und er ist klein: zehn Sekunden
|
|
/// später meldet derselbe Spieler die 10, und die zählt. „Von vorn"
|
|
/// bleibt damit „von vorn", nur eben ab der ersten gesehenen Sekunde.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Von_vorn_setzt_die_Stelle_mit_der_ersten_echten_Meldung_zurueck()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var (client, film) = await MitEinemFilm(server);
|
|
|
|
await Melden(client, film, 2771, 11468);
|
|
await Melden(client, film, 0, 11468);
|
|
var danach = await Melden(client, film, 10, 11468);
|
|
|
|
Assert.That(danach.PositionSeconds, Is.EqualTo(10).Within(0.001));
|
|
}
|
|
|
|
/// <summary>Zurückspulen bleibt möglich.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Die Regel gilt der Null, nicht dem Rückwärtsgehen. Wer eine Szene
|
|
/// noch einmal sehen will, soll danach auch dort weitermachen.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Zurueckspulen_setzt_die_Stelle_weiter_zurueck()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var (client, film) = await MitEinemFilm(server);
|
|
|
|
await Melden(client, film, 500, 600);
|
|
var danach = await Melden(client, film, 120, 600);
|
|
|
|
Assert.That(danach.PositionSeconds, Is.EqualTo(120).Within(0.001));
|
|
}
|
|
|
|
/// <summary>Ohne jede bekannte Laufzeit zählt weiter, was der Client sagt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Sonst gäbe es für einen frisch angefangenen Titel keinen Anteil und
|
|
/// damit nie ein „gesehen". Die neue Rangfolge nimmt dem Client nur den
|
|
/// Vorrang, nicht das Wort.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ohne_bekannte_Laufzeit_gilt_die_gemeldete()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var (client, film) = await MitEinemFilm(server);
|
|
|
|
var danach = await Melden(client, film, 100, 1000);
|
|
|
|
Assert.That(danach.RuntimeSeconds, Is.EqualTo(1000).Within(0.001));
|
|
}
|
|
|
|
// --- Wo der Stand bisher weggeworfen wurde ---
|
|
|
|
/// <summary>Auch ein Vorschlag bringt seinen Stand mit.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Drei Listen gaben <c>State: null</c> zurück</b> — Vorschläge, die
|
|
/// saisonale Reihe und die ähnlichen Titel —, obwohl die Stände in
|
|
/// derselben Datenbank liegen. Ein halb gesehener Film sah dort aus wie
|
|
/// ein unangetasteter, während dieselbe Kachel in der Mediathek einen
|
|
/// Balken trug. Die Kachel wird an einer Stelle gebaut; sie kann nur
|
|
/// zeichnen, was ankommt.
|
|
///
|
|
/// Geprüft wird hier die Vorschlagsreihe: sie entsteht aus dem
|
|
/// Sehverhalten, also genügt ein angefangener Film, damit sie überhaupt
|
|
/// etwas enthält — und ausgerechnet dieser Film steht dann darin.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Ein_Vorschlag_bringt_seinen_Stand_mit()
|
|
{
|
|
using var server = new FlaroServer();
|
|
var (client, film) = await MitEinemFilm(server);
|
|
|
|
await Melden(client, film, 300, 600);
|
|
|
|
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>(
|
|
"/api/v1/home/suggestions");
|
|
|
|
var eintrag = seite!.Items.FirstOrDefault(x => x.Id == film);
|
|
Assume.That(eintrag, Is.Not.Null, "ohne Genres schlägt der Server nichts vor");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(eintrag!.State, Is.Not.Null, "ohne Stand kein Balken");
|
|
Assert.That(eintrag.State!.PositionSeconds, Is.EqualTo(300).Within(0.001));
|
|
});
|
|
}
|
|
|
|
/// <summary>Eine weggelegte Serie steht nicht mehr in „Als Nächstes".</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Weggelegt gilt für die Serie, nicht für die Folge.</b> Eine
|
|
/// einzelne Folge dort wegzulegen nützte nichts: beim nächsten Laden
|
|
/// rückte die übernächste nach, und man legte zwanzigmal weg. Gemeint
|
|
/// ist „diese Serie nicht mehr vorschlagen" — und genau dieser Zustand
|
|
/// wurde in der Reihe nie gelesen.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Eine_weggelegte_Serie_verschwindet_aus_Als_Naechstes()
|
|
{
|
|
using var server = new FlaroServer();
|
|
|
|
foreach (var name in new[] { "S01E01", "S01E02" })
|
|
{
|
|
var voll = Path.Combine(_bestand, "Probe (2026)", "Season 01", $"Probe - {name}.mkv");
|
|
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
|
await File.WriteAllBytesAsync(voll, new byte[2048]);
|
|
}
|
|
|
|
var admin = await server.SetUpAndSignIn();
|
|
var client = server.AlsAngemeldeter(admin.Token);
|
|
|
|
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
|
|
new CreateLibraryRequest("Prüfbestand", "Shows", _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") break;
|
|
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
|
Assume.That(serien!.Items, Is.Not.Empty, "der Scan hat keine Serie gefunden");
|
|
var serie = serien.Items[0].Id;
|
|
|
|
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
|
$"/api/v1/series/{serie}/episodes");
|
|
|
|
// Erst die erste Folge abhaken — sonst hat der Benutzer die Serie nie
|
|
// angefasst, und sie stünde ohnehin nicht in der Reihe.
|
|
(await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched",
|
|
new WatchedRequest(true))).EnsureSuccessStatusCode();
|
|
|
|
var vorher = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/next");
|
|
Assume.That(vorher!.Items.Select(x => x.SeriesId), Does.Contain(serie),
|
|
"ohne Eintrag gibt es nichts wegzulegen");
|
|
|
|
(await client.PostAsync($"/api/v1/items/{serie}/dismiss", null)).EnsureSuccessStatusCode();
|
|
|
|
var nachher = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/next");
|
|
|
|
Assert.That(nachher!.Items.Select(x => x.SeriesId), Does.Not.Contain(serie));
|
|
}
|
|
}
|