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));
}
}