Test project: UT-001..011 for JR-016 and JR-022
🏗️ Build Plugin / build (push) Successful in 39s
Latest Release / latest-release (push) Successful in 38s
🧪 Test Plugin / test (push) Successful in 26s

The register defined 46 requirements and zero tests, so every Done in it rested
on inspection. This adds the T1 tier: an xUnit project in the solution,
covering the two units whose logic is pure enough to test without a Jellyfin
host.

JR-022 (UT-001..006) covers the cases where removal could reach too far --
another plugin's injection, and an unmarked look-alike script tag -- because
index.html is a file JRay shares. UT-001 pins the trailing newline to the tag,
without which every install cycle leaves another blank line behind.

JR-016 (UT-007..011) covers specificity resolution, including the prioritised
series inside an ignored genre that motivated the rule, and a Series rule
valued Guid.Empty, which would otherwise swallow every movie in the library.

RemoveInjection is reached through InternalsVisibleTo rather than being made
public: it is factored out for testability, not part of the surface.

The suite BUILDS but does not RUN here. Jellyfin.Controller framework-
references Microsoft.AspNetCore.App, so the test host demands it even for pure
logic, and this machine has no ASP.NET Core runtime at any version --
RollForward cannot substitute for a framework that is absent entirely. Fix is
to install aspnet-runtime, which the CI image needs for the same reason.

So every UT here is recorded as Written, not Passing, and no requirement is
promoted to Done on their strength. A test whose result nobody has seen is not
evidence.

TRACES: UT-001, UT-002, UT-003, UT-004, UT-005, UT-006 | JR-022
TRACES: UT-007, UT-008, UT-009, UT-010, UT-011 | JR-016

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 19:12:24 +02:00
co-authored by Claude Opus 5
parent d786d56368
commit 5159692364
6 changed files with 287 additions and 6 deletions
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-016 — prioritise/ignore rules resolve by specificity: Item beats Series
/// beats Genre. A scope+value holds only one action, so the only conflicts
/// possible are across scopes, and that is exactly what these cover.
///
/// TRACES: UT-007, UT-008, UT-009, UT-010, UT-011 | JR-016
/// </summary>
public class PolicyResolverTests
{
private static readonly Guid ItemId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid SeriesId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static MediaPolicyRule Rule(PolicyScope scope, string value, PolicyAction action)
=> new() { Scope = scope, Value = value, Action = action };
// UT-007
[Fact]
public void Resolve_WithNoMatchingRule_ReturnsNull()
{
var rules = new List<MediaPolicyRule> { Rule(PolicyScope.Genre, "Anime", PolicyAction.Ignore) };
Assert.Null(PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "Drama" }));
}
// UT-008
[Fact]
public void Resolve_ItemRuleBeatsSeriesRule()
{
var rules = new List<MediaPolicyRule>
{
Rule(PolicyScope.Series, SeriesId.ToString("D"), PolicyAction.Ignore),
Rule(PolicyScope.Item, ItemId.ToString("D"), PolicyAction.Prioritise),
};
Assert.Equal(PolicyAction.Prioritise, PolicyResolver.Resolve(rules, ItemId, SeriesId, Array.Empty<string>()));
}
// UT-009
[Fact]
public void Resolve_PrioritisedSeriesInsideIgnoredGenre_SeriesWins()
{
// The case that motivated specificity resolution: an admin ignores a
// whole genre but wants one series out of it anyway. If genre won, the
// more specific instruction would be silently discarded.
var rules = new List<MediaPolicyRule>
{
Rule(PolicyScope.Genre, "Anime", PolicyAction.Ignore),
Rule(PolicyScope.Series, SeriesId.ToString("D"), PolicyAction.Prioritise),
};
Assert.Equal(PolicyAction.Prioritise, PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "Anime" }));
}
// UT-010
[Fact]
public void Resolve_GenreMatchIsCaseInsensitive()
{
var rules = new List<MediaPolicyRule> { Rule(PolicyScope.Genre, "anime", PolicyAction.Ignore) };
Assert.Equal(PolicyAction.Ignore, PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "AnImE" }));
}
// UT-011
[Fact]
public void Resolve_SeriesRuleDoesNotMatchNonEpisode()
{
// A movie carries Guid.Empty as its series id. A series rule whose value
// happened to be an empty GUID must not swallow every movie in the
// library.
var rules = new List<MediaPolicyRule>
{
Rule(PolicyScope.Series, Guid.Empty.ToString("D"), PolicyAction.Ignore),
};
Assert.Null(PolicyResolver.Resolve(rules, ItemId, Guid.Empty, Array.Empty<string>()));
}
}