using System.Text.Json; using Jellyfin.Plugin.JRay.Models; using Jellyfin.Plugin.JRay.Services; using Xunit; namespace Jellyfin.Plugin.JRay.Tests; /// /// JR-002 (the schema_version 2 shape) and JR-003 (an unknown version is /// refused, never guessed). /// /// These are the flag-day tests. v1 is gone rather than deprecated, so what has /// to be pinned is not only that v2 parses but that v1 does *not* quietly /// half-parse into something a reader would treat as real. /// /// TRACES: UT-029, UT-030, UT-031, UT-032, UT-033, UT-034, UT-035, UT-036, UT-037 | JR-002, JR-003 /// public class TruthSchemaTests { private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); private const string V2 = """ {"schema_version":2,"movie":"/data/movies/Movie.mkv", "extraction":{"sample_fps":5,"extinction_sec":12,"gallery_size":1820, "gallery_scope":"global","pipeline_version":"scene-actor-extraction 0.4.1"}, "cut":{"runtime_sec":6420.5,"audio_signature":"v1:v7fA3k"}, "actors":[{"name":"Steve Buscemi","imdb_id":"nm0000114","tmdb_id":"884", "jellyfin_id":"abc123-guid", "scenes":[{"start":191.6,"end":209.2,"belief":0.98,"route":"live"}, {"start":438.2,"end":465.6,"belief":0.81,"route":"deferred"}]}]} """; // UT-029 [Fact] public void AV2FileRoundTripsWithProvenanceAndCutIntact() { var parsed = JsonSerializer.Deserialize(V2, Options)!; Assert.Equal(2, parsed.SchemaVersion); Assert.Equal("/data/movies/Movie.mkv", parsed.Movie); // sample_fps moved into `extraction` in the bump. Reading it from the // top level would silently yield zero. Assert.Equal(5, parsed.Extraction!.SampleFps); Assert.Equal(12, parsed.Extraction.ExtinctionSec); Assert.Equal(1820, parsed.Extraction.GallerySize); Assert.Equal("global", parsed.Extraction.GalleryScope); Assert.Equal(6420.5, parsed.Cut!.RuntimeSec); Assert.Equal("v1:v7fA3k", parsed.Cut.AudioSignature); } // UT-030 [Fact] public void ScenesAreObjectsThatRetainBeliefAndRoute() { // The reason `scenes` stopped being float pairs. A window that loses its // belief and route is indistinguishable from a v1 window, which is // exactly the regression this pins. var parsed = JsonSerializer.Deserialize(V2, Options)!; var windows = parsed.Actors[0].Scenes; Assert.Equal(2, windows.Count); Assert.Equal(0.98, windows[0].Belief); Assert.Equal("live", windows[0].Route); Assert.Equal(0.81, windows[1].Belief); Assert.Equal("deferred", windows[1].Route); } // UT-031 [Theory] [InlineData("live")] [InlineData("deferred")] [InlineData("pooled")] public void AllThreeRoutesSurviveARoundTrip(string route) { // All three are named by extraction AR-017. A serializer that dropped an // unrecognised one would make `pooled` claims look live. var truth = new TruthFile { SchemaVersion = TruthSchema.SupportedVersion }; var actor = new TruthActor { Name = "A", TmdbId = "884" }; actor.Scenes.Add(new TruthScene { Start = 1, End = 2, Belief = 0.5, Route = route }); truth.Actors.Add(actor); var round = JsonSerializer.Deserialize(JsonSerializer.Serialize(truth, Options), Options)!; Assert.Equal(route, round.Actors[0].Scenes[0].Route); } // UT-032 [Fact] public void AWindowWithoutBeliefIsNullRatherThanZero() { // Absent and "believed with probability zero" are different statements. // Defaulting to 0.0 would make an unannotated window look maximally // untrustworthy, and a consumer ranking on belief would discard it. const string NoBelief = """ {"schema_version":2,"movie":"/m.mkv", "actors":[{"name":"A","tmdb_id":"884","scenes":[{"start":1,"end":2}]}]} """; var parsed = JsonSerializer.Deserialize(NoBelief, Options)!; Assert.Null(parsed.Actors[0].Scenes[0].Belief); Assert.Null(parsed.Actors[0].Scenes[0].Route); } // UT-033 — JR-003, the flag day itself. [Theory] [InlineData(1)] [InlineData(3)] [InlineData(0)] public void AnUnsupportedVersionIsRefused(int version) { // Both directions matter. v1 is the version that exists in the wild, and // v3 is a future producer this build cannot know the shape of — guessing // at either is what JR-003 forbids. var truth = new TruthFile { SchemaVersion = version }; Assert.False(TruthSchema.IsSupported(truth)); Assert.Contains(version.ToString(System.Globalization.CultureInfo.InvariantCulture), TruthSchema.DescribeRejection(version), System.StringComparison.Ordinal); } // UT-034 [Fact] public void AMissingSchemaVersionIsRefusedRatherThanAssumedCurrent() { // An absent field deserialises to 0. Treating that as "probably the // current version" is the single most tempting mistake here, and it // would accept any malformed document that happened to parse. const string NoVersion = """ {"movie":"/m.mkv","actors":[]} """; var parsed = JsonSerializer.Deserialize(NoVersion, Options); Assert.False(TruthSchema.IsSupported(parsed)); } // UT-035 [Fact] public void ANullTruthFileIsRefusedWithoutThrowing() { // `null` reaches this from a file containing the literal `null`, which // parses successfully. The check must reject it rather than dereference. Assert.False(TruthSchema.IsSupported(null)); } // UT-037 [Fact] public void TheProducersActualOutputParses() { // Byte-for-byte the shape `scene-actor-extraction` writes today // (`src/nodes/result_sink_node.hpp`, IR-002) — *not* the fully populated // example from the spec. It omits `cut` entirely and carries only three // of the five `extraction` fields. // // This is the test that would have caught the break: the plugin read v1 // while the pipeline had already moved to v2, so nothing the pipeline // produced could be read at all. A round-trip test written against the // spec's example alone would have passed throughout. const string AsProduced = """ { "schema_version": 2, "movie": "/data/movies/Film.mkv", "extraction": { "sample_fps": 5.0, "extinction_sec": 12.0, "gallery_scope": "global" }, "actors": [ { "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884", "jellyfin_id": "", "scenes": [ { "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" } ] } ] } """; var parsed = JsonSerializer.Deserialize(AsProduced, Options); Assert.True(TruthSchema.IsSupported(parsed)); Assert.Equal(5.0, parsed!.Extraction!.SampleFps); Assert.Equal(12.0, parsed.Extraction.ExtinctionSec); Assert.Equal("global", parsed.Extraction.GalleryScope); // Absent blocks and fields are null, not defaults that would read as data. Assert.Null(parsed.Cut); Assert.Null(parsed.Extraction.GallerySize); Assert.Null(parsed.Extraction.PipelineVersion); var window = Assert.Single(parsed.Actors[0].Scenes); Assert.Equal((191.6, 209.2), (window.Start, window.End)); Assert.Equal(0.98, window.Belief); Assert.Equal("live", window.Route); } // UT-036 [Fact] public void AV1FileDoesNotHalfParseIntoUsableWindows() { // The load-bearing claim of the flag day. v1 `scenes` were float pairs, // so a v1 file either fails to deserialise or produces windows that are // not usable — what must never happen is silent success with windows at // 0,0, which would report actors present at the start of every film. const string V1 = """ {"schema_version":1,"movie":"/m.mkv","sample_fps":1,"anneal_sec":2, "actors":[{"name":"A","tmdb_id":"884","scenes":[[0.0,10.0],[10.0,20.0]]}]} """; TruthFile? parsed = null; try { parsed = JsonSerializer.Deserialize(V1, Options); } catch (JsonException) { // The expected path: a float pair is not an object. return; } // If it did parse, the version gate is what stops it being used. Assert.False(TruthSchema.IsSupported(parsed)); } }