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,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!--
The plugin project treats warnings as errors and runs StyleCop. Tests do
not inherit that: their naming conventions differ deliberately (Method_
Condition_Expectation reads as documentation, and trips SA1300-family
rules), and a style failure in a test is not a defect in the thing under
test.
-->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!--
The plugin targets net9.0 to match Jellyfin's ABI, but a machine that can
build it need not have the 9.0 *runtime* installed. Roll the test host
forward to whatever major is present so the suite runs on a developer box
and on CI without pinning either to a runtime that is not the plugin's.
-->
<RollForward>LatestMajor</RollForward>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj" />
</ItemGroup>
</Project>
@@ -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>()));
}
}
@@ -0,0 +1,92 @@
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-022 — an earlier JRay injected its overlay script into index.html on
/// disk. Those users must not be left with a stale injection pointing at
/// endpoints that have since changed, so removal survives even though JR-021
/// deleted the injection that created it.
///
/// Removal keys on JRay's own marker. The cases that matter are the ones where
/// it could reach too far: another plugin's injection, or a script tag that
/// looks like JRay's but carries no marker.
///
/// TRACES: UT-001, UT-002, UT-003, UT-004, UT-005 | JR-022
/// </summary>
public class WebClientPatchServiceTests
{
private const string Marker = "<!-- jray-overlay -->";
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
// UT-001
[Fact]
public void RemoveInjection_WithMarkedTagAndNewline_RestoresOriginalBytes()
{
var original = "<html><body><div>x</div>\n</body></html>";
var patched = "<html><body><div>x</div>\n" + ScriptTag + Marker + "\n</body></html>";
// The trailing newline goes with the tag. If it did not, every
// install/uninstall cycle would leave another blank line behind.
Assert.Equal(original, WebClientPatchService.RemoveInjection(patched));
}
// UT-002
[Fact]
public void RemoveInjection_WithMarkedTagAndNoNewline_RemovesTag()
{
var patched = "<html><body>" + ScriptTag + Marker + "</body></html>";
Assert.Equal("<html><body></body></html>", WebClientPatchService.RemoveInjection(patched));
}
// UT-003
[Fact]
public void RemoveInjection_WithNoMarker_LeavesDocumentUnchanged()
{
var clean = "<html><body><div>x</div>\n</body></html>";
Assert.Equal(clean, WebClientPatchService.RemoveInjection(clean));
}
// UT-004
[Fact]
public void RemoveInjection_IsIdempotent()
{
var patched = "<html><body>" + ScriptTag + Marker + "\n</body></html>";
var once = WebClientPatchService.RemoveInjection(patched);
var twice = WebClientPatchService.RemoveInjection(once);
// Startup calls this unconditionally, so it runs on every boot forever
// after the patch is gone.
Assert.Equal(once, twice);
}
// UT-005
[Fact]
public void RemoveInjection_LeavesAnotherPluginsInjectionIntact()
{
var foreign = "<script defer src=\"/Plugins/Other/Script\"></script><!-- other-overlay -->";
var patched = "<html><body>" + foreign + ScriptTag + Marker + "\n</body></html>";
var cleaned = WebClientPatchService.RemoveInjection(patched);
// The marker is what makes removal unambiguous. Removing anything we did
// not add is the failure this guards: it is another plugin's file too.
Assert.Contains(foreign, cleaned, System.StringComparison.Ordinal);
Assert.DoesNotContain(Marker, cleaned, System.StringComparison.Ordinal);
}
// UT-006
[Fact]
public void RemoveInjection_WithUnmarkedLookalikeTag_LeavesItAlone()
{
// Same script tag, no marker: JRay did not write this, so JRay does not
// remove it.
var patched = "<html><body>" + ScriptTag + "\n</body></html>";
Assert.Equal(patched, WebClientPatchService.RemoveInjection(patched));
}
}
+19 -2
View File
@@ -1,7 +1,9 @@
Microsoft Visual Studio Solution File, Format Version 12.00
#
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay", "Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay.Tests", "Jellyfin.Plugin.JRay.Tests\Jellyfin.Plugin.JRay.Tests.csproj", "{104C1021-3155-4404-9CA0-8ED8F310A152}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -12,7 +14,7 @@ Global
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.Build.0 = Debug|Any CPU
@@ -24,5 +26,20 @@ Global
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.Build.0 = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.ActiveCfg = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.Build.0 = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|Any CPU.Build.0 = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x64.ActiveCfg = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x64.Build.0 = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x86.ActiveCfg = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x86.Build.0 = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|Any CPU.ActiveCfg = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|Any CPU.Build.0 = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x64.ActiveCfg = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x64.Build.0 = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x86.ActiveCfg = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
@@ -25,6 +25,15 @@
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<!-- Lets the test project reach internals such as
WebClientPatchService.RemoveInjection, which is factored out precisely
so the removal logic is testable without touching a filesystem. -->
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Jellyfin.Plugin.JRay.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
+47 -4
View File
@@ -16,6 +16,26 @@ Tag code with `// TRACES: JR-012 | SR-002`.
| `JR` | Everything this plugin does — truth format, API, overlay, exchange client |
| `UT` / `IT` | Unit / integration tests |
## Tests (UT)
| ID | Asserts | Covers | Status |
|---|---|---|---|
| UT-001 | Marked tag **and its trailing newline** removed — no blank line accumulates per upgrade cycle | JR-022 | Written |
| UT-002 | Marked tag with no trailing newline removed | JR-022 | Written |
| UT-003 | Document without the marker left byte-identical | JR-022 | Written |
| UT-004 | Removal is idempotent — startup runs it on every boot forever after | JR-022 | Written |
| UT-005 | **Another plugin's injection left intact** — it is their file too | JR-022 | Written |
| UT-006 | An unmarked look-alike script tag is left alone — JRay did not write it | JR-022 | Written |
| UT-007 | No matching rule resolves to `null` | JR-016 | Written |
| UT-008 | Item rule beats Series rule | JR-016 | Written |
| UT-009 | **Prioritised series inside an ignored genre — series wins** | JR-016 | Written |
| UT-010 | Genre matching is case-insensitive | JR-016 | Written |
| UT-011 | A Series rule valued `Guid.Empty` does not swallow every movie | JR-016 | Written |
`Written` rather than `Passing`: see the execution blocker below. A test whose
result nobody has seen is not evidence, and recording it as passing would be the
same failure as counting a GPU-only test as covered.
`JR` is flat rather than split by theme. The plugin is one deployable with one
audience, and the thematic grouping lives in the section headings below, where it
costs nothing and cannot go stale against a prefix.
@@ -167,9 +187,32 @@ renumbering it. Calling jRay's live tier "T3" would make the gate count
live-only requirements as covered — the exact class of error the 158% coverage
bug belongs to.
**There is no test project today.** That is the single largest gap in this
register: 46 requirements, zero `UT`/`IT` IDs, so measured coverage will open at
zero and every `Done` above rests on inspection rather than evidence.
`Jellyfin.Plugin.JRay.Tests` (xUnit, in the solution) carries the T1 tier. It
builds clean alongside the plugin.
### Execution blocker — the suite does not run on this machine
The plugin framework-references **`Microsoft.AspNetCore.App`** through
`Jellyfin.Controller`, so the test host demands that shared framework even for
tests that touch only pure logic. This box has `Microsoft.NETCore.App` 8.0.29
and 10.0.10 and **no ASP.NET Core runtime at all**, so `dotnet test` aborts
before a single test executes:
```
Framework: 'Microsoft.AspNetCore.App', version '9.0.0' — No frameworks were found.
```
`<RollForward>LatestMajor</RollForward>` on the test project solves the *other*
half — the plugin targets `net9.0` to match Jellyfin's ABI, and no .NET 9
runtime is installed either — but roll-forward cannot conjure a framework of
which no version exists.
**Fix:** install the ASP.NET Core runtime (`aspnet-runtime` on Arch). It is
needed on the CI host for the same reason, so this belongs in the CI image
rather than in a developer's setup notes.
Until then every `UT` above is **Written, not Passing**, and no requirement
should be promoted to `Done` on the strength of them.
### Per-requirement verification plan
@@ -243,7 +286,7 @@ python3 scripts/vendor/jray-project/scripts/traceability/extract_traces.py \
--system-spec scripts/vendor/jray-project/SPEC.md \
--types JR \
--suffixes .cs,.js,.sh \
--scan-roots Jellyfin.Plugin.JRay,scripts/checks \
--scan-roots Jellyfin.Plugin.JRay,Jellyfin.Plugin.JRay.Tests,scripts/checks \
--format coverage
```