Presence was decided by a LINQ predicate inline in the controller, so the semantics SR-002 sets were nowhere stated in code -- the read path complied by accident rather than by requirement. PresenceLookup is now the unit that decides, tagged, with the reasoning next to it. JR-004: windows are served exactly as given. UT-021 pins that [0,10] and [10,20] are not merged despite looking mergeable -- two windows mean a genuine departure and return, and collapsing them answers a different question from the one the truth file asked. UT-022 pins a byte-identical round trip. JR-005: bounds inclusive at both ends, zero-length windows are real sightings rather than degenerate ones to discard, overlaps resolve. The wording was the larger half of JR-005. The overlay rendered a bare list: it asserted nothing, but told the viewer nothing either, and the default reading of a paused frame is "these people are on screen" -- exactly what SR-002 forbids. It now carries an "In this scene" heading. ActorAtTime became ActorInScene, and README no longer contains "on screen" anywhere; it stated the forbidden reading outright in seven places, including the opening sentence. JR-006: measured rather than assumed. UT-023 builds 50 actors x 1000 windows and asserts the response is bounded by actor count, never window count. The lookup is a full scan on purpose -- an early exit on `start > t` would exploit the sortedness the format requires, but would silently under-report the moment one producer emitted windows out of order. UT-020 pins that unsorted input still resolves; WindowsAreSorted is a diagnostic, not a correctness dependency. Third mutation check: making the end bound exclusive fails UT-016 and UT-018 and nothing else. One character turns an inclusive window into a half-open one, dropping an actor at exactly the moment a scene ends. TRACES: UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023 TRACES: JR-004, JR-005, JR-006 | SR-002 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
5.3 KiB
C#
151 lines
5.3 KiB
C#
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using Jellyfin.Plugin.JRay.Models;
|
|
using Jellyfin.Plugin.JRay.Services;
|
|
using Xunit;
|
|
|
|
namespace Jellyfin.Plugin.JRay.Tests;
|
|
|
|
/// <summary>
|
|
/// JR-004 (windows are scene-membership claims, served verbatim), JR-005 (query
|
|
/// semantics and inclusive bounds) and JR-006 (numerous windows).
|
|
///
|
|
/// These pin the semantics SR-002 sets. The failure they exist to prevent is a
|
|
/// well-meaning "tidy-up" — merging adjacent windows, trimming a zero-length
|
|
/// one, or collapsing overlaps — each of which silently answers a different
|
|
/// question from the one the truth file asked.
|
|
///
|
|
/// TRACES: UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023 | JR-004, JR-005, JR-006
|
|
/// </summary>
|
|
public class PresenceLookupTests
|
|
{
|
|
private static TruthActor Actor(params double[][] windows)
|
|
{
|
|
var actor = new TruthActor { Name = "Steve Buscemi", TmdbId = "884" };
|
|
foreach (var w in windows)
|
|
{
|
|
actor.Scenes.Add(w);
|
|
}
|
|
|
|
return actor;
|
|
}
|
|
|
|
// UT-016
|
|
[Theory]
|
|
[InlineData(12.0)] // exactly the start
|
|
[InlineData(30.0)] // inside
|
|
[InlineData(45.0)] // exactly the end
|
|
public void IsPresentAt_WithinInclusiveBounds_IsPresent(double t)
|
|
{
|
|
// Both ends inclusive: a window is [start, end], not [start, end).
|
|
Assert.True(PresenceLookup.IsPresentAt(Actor([12.0, 45.0]), t));
|
|
}
|
|
|
|
// UT-017
|
|
[Theory]
|
|
[InlineData(11.999)]
|
|
[InlineData(45.001)]
|
|
public void IsPresentAt_OutsideBounds_IsAbsent(double t)
|
|
{
|
|
Assert.False(PresenceLookup.IsPresentAt(Actor([12.0, 45.0]), t));
|
|
}
|
|
|
|
// UT-018
|
|
[Fact]
|
|
public void IsPresentAt_ZeroLengthWindow_IsPresentAtThatInstant()
|
|
{
|
|
// A single sighting is a legitimate window. Discarding it as degenerate
|
|
// would drop the actor from a scene they are demonstrably in.
|
|
Assert.True(PresenceLookup.IsPresentAt(Actor([30.0, 30.0]), 30.0));
|
|
}
|
|
|
|
// UT-019
|
|
[Fact]
|
|
public void IsPresentAt_OverlappingWindows_IsPresentInsideTheEnclosingOne()
|
|
{
|
|
// [0,100] encloses [50,60]. A lookup that assumed non-overlapping,
|
|
// sorted windows and stopped at the first start > t would miss t = 80.
|
|
Assert.True(PresenceLookup.IsPresentAt(Actor([0.0, 100.0], [50.0, 60.0]), 80.0));
|
|
}
|
|
|
|
// UT-020
|
|
[Fact]
|
|
public void IsPresentAt_UnsortedWindows_StillFindsPresence()
|
|
{
|
|
// Sortedness is a producer guarantee, not something correctness may
|
|
// depend on. A file that violates it must still be read correctly.
|
|
var actor = Actor([100.0, 110.0], [10.0, 20.0]);
|
|
|
|
Assert.True(PresenceLookup.IsPresentAt(actor, 15.0));
|
|
Assert.False(PresenceLookup.WindowsAreSorted(actor));
|
|
}
|
|
|
|
// UT-021
|
|
[Fact]
|
|
public void ActorsPresentAt_AdjacentWindowsAreNeverMerged()
|
|
{
|
|
// [0,10] and [10,20] look mergeable. They must not be merged: two
|
|
// windows mean a genuine departure and return, and the plugin does not
|
|
// reinterpret that claim. The actor is reported once, from two windows.
|
|
var truth = new TruthFile();
|
|
truth.Actors.Add(Actor([0.0, 10.0], [10.0, 20.0]));
|
|
|
|
Assert.Single(PresenceLookup.ActorsPresentAt(truth, 10.0));
|
|
Assert.Equal(2, truth.Actors[0].Scenes.Count);
|
|
}
|
|
|
|
// UT-022
|
|
[Fact]
|
|
public void TruthFile_RoundTrips_WithWindowsByteIdentical()
|
|
{
|
|
// JR-004: served exactly as given. A round trip through the serializer
|
|
// is where a silent normalisation would show up.
|
|
const string Json = """
|
|
{"schema_version":1,"movie":"/m.mkv","sample_fps":1,"anneal_sec":2,
|
|
"actors":[{"name":"A","imdb_id":"","tmdb_id":"884","jellyfin_id":"",
|
|
"scenes":[[0.0,10.0],[10.0,20.0],[30.0,30.0]]}]}
|
|
""";
|
|
|
|
var parsed = JsonSerializer.Deserialize<TruthFile>(Json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
|
var windows = parsed.Actors[0].Scenes;
|
|
|
|
Assert.Equal(3, windows.Count);
|
|
Assert.Equal([0.0, 10.0], windows[0]);
|
|
Assert.Equal([10.0, 20.0], windows[1]);
|
|
Assert.Equal([30.0, 30.0], windows[2]);
|
|
}
|
|
|
|
// UT-023
|
|
[Fact]
|
|
public void ActorsPresentAt_WithManyWindows_StaysCheapAndBoundsTheResponse()
|
|
{
|
|
// SR-002: windows may be numerous; consumers must not assume a handful
|
|
// of long ones. Track-extent presence with a short re-acquisition
|
|
// timeout produces many short windows per actor.
|
|
var truth = new TruthFile();
|
|
for (var a = 0; a < 50; a++)
|
|
{
|
|
var actor = Actor();
|
|
for (var w = 0; w < 1000; w++)
|
|
{
|
|
actor.Scenes.Add([w * 10.0, (w * 10.0) + 4.0]);
|
|
}
|
|
|
|
truth.Actors.Add(actor);
|
|
}
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
var present = PresenceLookup.ActorsPresentAt(truth, 5002.0).ToList();
|
|
sw.Stop();
|
|
|
|
// The response is bounded by actor count, never by window count — which
|
|
// is what keeps `jray?t=` small however finely presence is sliced.
|
|
Assert.Equal(50, present.Count);
|
|
|
|
// 50 000 windows scanned. Generous bound: this asserts the read path is
|
|
// not accidentally quadratic, not a precise budget on a shared runner.
|
|
Assert.True(sw.ElapsedMilliseconds < 250, $"lookup took {sw.ElapsedMilliseconds} ms");
|
|
}
|
|
}
|