Files
Flaro/server/tests/Flaro.Api.Tests/MarkenTests.cs
T
ScarriffleandClaude Opus 5 f79ecf2757 „Schon durchsucht" und „hat eine Marke" sind zwei verschiedene Dinge
Genau dazwischen fehlte die Unterscheidung. Ein Film, bei dem die Suche
einmal nichts fand, trägt einen Vermerk und keine Marke — und war damit
für jeden weiteren Lauf erledigt, obwohl er der interessanteste Fall ist.
Es gab nur alles oder nichts, und der Knopf bei der Mediathek schickte
immer „alles": 453 Filme mal zwanzig Minuten Schlüsselbilder, auch für
die vierhundert, bei denen längst etwas stand.

Aus `bool erneut` wird `Markenumfang` mit drei Werten. `Neue` ist, was
der Scan tut, und ändert sich nicht. `Luecken` nimmt sich vor, wo keine
Marke steht — gleich ob dort schon einmal gesucht wurde. `Alles` ist der
alte Holzhammer.

Bei der Mediathek jetzt zwei Knöpfe, weil es zwei Fragen sind: „Fehlende
Marken suchen" ist die alltägliche, „Alles neu durchsuchen" die seltene
und teure, und deren Rückfrage nennt die Zahl der Titel. Auf der
Detailseite bleibt es bei einem Knopf mit „alles" — wer ihn bei genau
diesem Titel drückt, ist mit genau diesem Titel unzufrieden.

Bei einer Serie wird eine Staffel ganz gelesen, sobald einer ihrer
Folgen etwas fehlt. Billiger ginge es nur, indem man die Lücke gar nicht
erst angeht, und genau dafür drückt jemand den Knopf.

Vier Prüfungen am Endpunkt, die messen, ob der Lauf den Film anfasst:
`Neue` überspringt den Durchsuchten, `Luecken` nimmt ihn sich vor,
`Alles` sowieso, und ein Film mit Marke bleibt bei `Luecken` in Ruhe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 14:20:47 +02:00

405 lines
16 KiB
C#

using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
using Flaro.Core.Library;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Tests;
/// <summary>Vorspann und Abspann auf dem Weg zu den Spielern.</summary>
///
/// <remarks>
/// <b>Ohne Tonabgleich.</b> Ob er findet, was er finden soll, steht in
/// <c>TonabgleichTests</c> — mit erfundenen Abdrücken und ohne Mediendateien.
/// Hier geht es um das Stück danach: dass eine gefundene Marke auch bei dem
/// ankommt, der den Knopf zeichnen soll.
/// </remarks>
public class MarkenTests
{
private string _bestand = "";
[SetUp]
public void Anlegen() => _bestand = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-medien-" + Guid.CreateVersion7().ToString("N"))).FullName;
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
}
private void Datei(string relativ)
{
var voll = Path.Combine(_bestand, relativ);
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
File.WriteAllBytes(voll, new byte[2048]);
}
private async Task<HttpClient> Eingelesen(FlaroServer server, string art)
{
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest("Prüfbestand", art, _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 in fünf Sekunden nicht fertig.");
return null!;
}
private static async Task<(Guid Serie, Guid Folge)> MitEinerFolge(HttpClient client)
{
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var serie = serien!.Items[0].Id;
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serie}/episodes");
return (serie, folgen!.Items[0].Id);
}
[Test]
public async Task Eine_Marke_kommt_bei_der_Folge_an()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var (_, folge) = await MitEinerFolge(client);
await server.InDerDatenbank(async db =>
{
db.Marken.Add(new Marke
{
EpisodeId = folge, Art = Markenart.Intro,
VonSekunden = 30, BisSekunden = 65, ErkanntAm = DateTimeOffset.UtcNow,
});
db.Marken.Add(new Marke
{
EpisodeId = folge, Art = Markenart.Abspann,
VonSekunden = 2500, BisSekunden = 2600, ErkanntAm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
});
var einzeln = await client.GetFromJsonAsync<EpisodeResponse>($"/api/v1/episodes/{folge}");
var intro = einzeln!.Markers!.Single(m => m.Kind == "intro");
Assert.Multiple(() =>
{
Assert.That(einzeln.Markers, Has.Count.EqualTo(2));
Assert.That(intro.StartSeconds, Is.EqualTo(30));
Assert.That(intro.EndSeconds, Is.EqualTo(65));
Assert.That(einzeln.Markers!.Any(m => m.Kind == "credits"), Is.True,
"der Abspann heisst gegenüber den Apps credits");
});
}
/// <summary>Auch in der Folgenliste — dort holt die App sie beim Blättern.</summary>
[Test]
public async Task Die_Folgenliste_bringt_die_Marken_mit()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var (serie, folge) = await MitEinerFolge(client);
await server.InDerDatenbank(async db =>
{
db.Marken.Add(new Marke
{
EpisodeId = folge, Art = Markenart.Intro,
VonSekunden = 10, BisSekunden = 40, ErkanntAm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
});
var liste = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serie}/episodes");
Assert.Multiple(() =>
{
Assert.That(liste!.Items.Single(f => f.Id == folge).Markers, Has.Count.EqualTo(1));
Assert.That(liste.Items.Single(f => f.Id != folge).Markers, Is.Empty,
"eine Folge ohne Marke bekommt eine leere Liste, nicht die der anderen");
});
}
/// <summary>Ohne erkannte Marken bleibt die Liste leer — kein Knopf, kein Fehler.</summary>
[Test]
public async Task Ohne_Erkennung_ist_die_Liste_leer()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var (_, folge) = await MitEinerFolge(client);
var einzeln = await client.GetFromJsonAsync<EpisodeResponse>($"/api/v1/episodes/{folge}");
Assert.That(einzeln!.Markers, Is.Empty);
}
/// <summary>Den Lauf darf nur ein Verwalter anstossen.</summary>
[Test]
public async Task Die_Suche_laesst_sich_anstossen()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var antwort = await client.PostAsync($"/api/v1/series/{serien!.Items[0].Id}/marken", null);
var unbekannt = await client.PostAsync($"/api/v1/series/{Guid.NewGuid()}/marken", null);
Assert.Multiple(() =>
{
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Accepted));
Assert.That(unbekannt.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
});
}
/// <summary>Eine abgesuchte Staffel wird nicht noch einmal abgetastet.</summary>
///
/// <remarks>
/// <b>Der teuerste Fehler, den es hier gab.</b> Gemerkt wurde nur, was
/// gefunden wurde. Eine Staffel ohne Abspann — es gibt genug davon —
/// sah damit bei jedem Scan aus wie eine, in der noch niemand
/// nachgesehen hat: jede Tonspur jeder Folge noch einmal von der
/// Platte, für immer, ohne Ertrag.
///
/// Geprüft wird hier der Vermerk, nicht das Abtasten selbst — ohne
/// fpcalc und ohne echten Ton gibt es nichts zu finden, und genau
/// dieser Fall ist der, um den es geht.
/// </remarks>
[Test]
public async Task Ein_Lauf_ohne_Fund_merkt_sich_das()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var serie = serien!.Items[0].Id;
(await client.PostAsync($"/api/v1/series/{serie}/marken", null)).EnsureSuccessStatusCode();
// Der Lauf hängt im Hintergrund; ohne fpcalc ist er sofort durch.
List<Markensuche> vermerke = [];
for (var versuch = 0; versuch < 40; versuch++)
{
await server.InDerDatenbank(async db =>
vermerke = await db.Markensuchen.AsNoTracking()
.Where(x => x.SeriesId == serie).ToListAsync());
if (vermerke.Count > 0) break;
await Task.Delay(50);
}
// Ohne fpcalc gibt es keinen Vermerk — dann ist nichts gelaufen und
// auch nichts zu merken. Mit fpcalc müssen beide Arten dastehen.
Assert.That(vermerke.Count, Is.AnyOf(0, 2),
"entweder gar nicht gelaufen oder beide Arten vermerkt");
if (vermerke.Count == 2)
{
Assert.That(vermerke.Select(v => v.Folgen), Is.All.EqualTo(2),
"die Folgenzahl löst den nächsten Lauf aus");
}
}
/// <summary>Ein Film trägt seine Abspannmarke genauso.</summary>
[Test]
public async Task Ein_Film_bekommt_seine_Marke()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Movies");
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var film = filme!.Items[0].Id;
await server.InDerDatenbank(async db =>
{
db.Marken.Add(new Marke
{
MovieId = film, Art = Markenart.Abspann,
VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
});
var einzeln = await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film}");
var liste = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.Multiple(() =>
{
Assert.That(einzeln!.Markers!.Single().Kind, Is.EqualTo("credits"));
Assert.That(einzeln.Markers!.Single().StartSeconds, Is.EqualTo(6900));
Assert.That(liste!.Items[0].Markers, Has.Count.EqualTo(1), "auch in der Liste");
});
}
/// <summary>Eine falsche Marke lässt sich wieder wegnehmen.</summary>
///
/// <remarks>
/// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal recht
/// hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas dagegen
/// tun ausser die Datenbank anzufassen.
/// </remarks>
[Test]
public async Task Eine_falsche_Filmmarke_laesst_sich_verwerfen()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Movies");
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var film = filme!.Items[0].Id;
await server.InDerDatenbank(async db =>
{
db.Marken.Add(new Marke
{
MovieId = film, Art = Markenart.Abspann,
VonSekunden = 60, BisSekunden = 7200, ErkanntAm = DateTimeOffset.UtcNow,
});
db.Markensuchen.Add(new Markensuche
{
MovieId = film, Art = Markenart.Abspann, Folgen = 1,
GelaufenAm = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
});
var weg = await client.DeleteAsync($"/api/v1/movies/{film}/marken");
var danach = await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film}");
var vermerke = 0;
await server.InDerDatenbank(async db =>
vermerke = await db.Markensuchen.CountAsync(x => x.MovieId == film));
Assert.Multiple(() =>
{
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(danach!.Markers, Is.Empty);
Assert.That(vermerke, Is.EqualTo(1),
"der Vermerk bleibt, sonst legte der nächste Scan dieselbe Marke wieder an");
});
}
/// <summary>Wie weit ein Lauf greift — drei Umfänge, drei Antworten.</summary>
///
/// <remarks>
/// <para>
/// <b>„Schon durchsucht" und „hat eine Marke" sind zwei verschiedene
/// Dinge.</b> Ein Film, bei dem die Suche einmal nichts fand, trägt
/// einen Vermerk und keine Marke — und war damit für jeden weiteren
/// Lauf erledigt, obwohl er der interessanteste Fall ist.
/// </para>
/// <para>
/// Geprüft wird am Vermerk: fasst der Lauf den Film an, schreibt er
/// <c>GelaufenAm</c> neu. Das ist die einzige Spur, die auch ohne
/// ffmpeg und ohne echten Abspann entsteht.
/// </para>
/// </remarks>
[TestCase("Neue", false, TestName = "Neue überspringt, was schon durchsucht ist")]
[TestCase("Luecken", true, TestName = "Luecken nimmt sich den Film ohne Marke vor")]
[TestCase("Alles", true, TestName = "Alles nimmt sich alles vor")]
public async Task Der_Umfang_entscheidet_bei_einem_Film_ohne_Marke(
string umfang, bool erwartetAngefasst)
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Movies");
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var film = filme!.Items[0].Id;
// Gesucht wurde schon, gefunden nichts: Vermerk ja, Marke nein.
var vorher = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
await server.InDerDatenbank(async db =>
{
// **Ohne Laufzeit kommt der Lauf nicht bis zum Vermerk.** Die
// Prüfdatei ist leer; die Laufzeit steht sonst aus ffprobe.
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
datei.Duration = TimeSpan.FromMinutes(120);
db.Markensuchen.Add(new Markensuche
{
MovieId = film, Art = Markenart.Abspann, Folgen = 1, GelaufenAm = vorher,
});
await db.SaveChangesAsync();
});
(await client.PostAsync($"/api/v1/movies/{film}/marken?umfang={umfang}", null))
.EnsureSuccessStatusCode();
var gelaufen = vorher;
for (var versuch = 0; versuch < 40; versuch++)
{
await server.InDerDatenbank(async db =>
gelaufen = await db.Markensuchen.AsNoTracking()
.Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
if (gelaufen != vorher) break;
await Task.Delay(50);
}
Assert.That(gelaufen != vorher, Is.EqualTo(erwartetAngefasst),
erwartetAngefasst
? "dieser Umfang muss den Film noch einmal anfassen"
: "dieser Umfang muss ihn überspringen");
}
/// <summary>Ein Film mit Marke bleibt bei „nur Lücken" unangetastet.</summary>
[Test]
public async Task Luecken_laesst_einen_Film_mit_Marke_in_Ruhe()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Movies");
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var film = filme!.Items[0].Id;
var vorher = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
await server.InDerDatenbank(async db =>
{
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
datei.Duration = TimeSpan.FromMinutes(120);
db.Marken.Add(new Marke
{
MovieId = film, Art = Markenart.Abspann,
VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = vorher,
});
db.Markensuchen.Add(new Markensuche
{
MovieId = film, Art = Markenart.Abspann, Folgen = 1, GelaufenAm = vorher,
});
await db.SaveChangesAsync();
});
(await client.PostAsync($"/api/v1/movies/{film}/marken?umfang=Luecken", null))
.EnsureSuccessStatusCode();
await Task.Delay(400);
var gelaufen = DateTimeOffset.MinValue;
await server.InDerDatenbank(async db =>
gelaufen = await db.Markensuchen.AsNoTracking()
.Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
Assert.That(gelaufen, Is.EqualTo(vorher),
"wo schon eine Marke steht, ist nichts zu füllen");
}
}