diff --git a/Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj b/Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj
new file mode 100644
index 0000000..9b8a9bd
--- /dev/null
+++ b/Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj
@@ -0,0 +1,35 @@
+
+
+
+ net9.0
+ enable
+ false
+
+ false
+ false
+
+ LatestMajor
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs b/Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs
new file mode 100644
index 0000000..e822fa3
--- /dev/null
+++ b/Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs
@@ -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;
+
+///
+/// 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
+///
+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 { 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
+ {
+ 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()));
+ }
+
+ // 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
+ {
+ 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 { 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
+ {
+ Rule(PolicyScope.Series, Guid.Empty.ToString("D"), PolicyAction.Ignore),
+ };
+
+ Assert.Null(PolicyResolver.Resolve(rules, ItemId, Guid.Empty, Array.Empty()));
+ }
+}
diff --git a/Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs b/Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs
new file mode 100644
index 0000000..e345057
--- /dev/null
+++ b/Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs
@@ -0,0 +1,92 @@
+using Jellyfin.Plugin.JRay.Services;
+using Xunit;
+
+namespace Jellyfin.Plugin.JRay.Tests;
+
+///
+/// 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
+///
+public class WebClientPatchServiceTests
+{
+ private const string Marker = "";
+ private const string ScriptTag = "";
+
+ // UT-001
+ [Fact]
+ public void RemoveInjection_WithMarkedTagAndNewline_RestoresOriginalBytes()
+ {
+ var original = "x
\n";
+ var patched = "x
\n" + ScriptTag + Marker + "\n";
+
+ // 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 = "" + ScriptTag + Marker + "";
+
+ Assert.Equal("", WebClientPatchService.RemoveInjection(patched));
+ }
+
+ // UT-003
+ [Fact]
+ public void RemoveInjection_WithNoMarker_LeavesDocumentUnchanged()
+ {
+ var clean = "x
\n";
+
+ Assert.Equal(clean, WebClientPatchService.RemoveInjection(clean));
+ }
+
+ // UT-004
+ [Fact]
+ public void RemoveInjection_IsIdempotent()
+ {
+ var patched = "" + ScriptTag + Marker + "\n";
+
+ 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 = "";
+ var patched = "" + foreign + ScriptTag + Marker + "\n";
+
+ 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 = "" + ScriptTag + "\n";
+
+ Assert.Equal(patched, WebClientPatchService.RemoveInjection(patched));
+ }
+}
diff --git a/Jellyfin.Plugin.JRay.sln b/Jellyfin.Plugin.JRay.sln
index f24635f..29e95b5 100644
--- a/Jellyfin.Plugin.JRay.sln
+++ b/Jellyfin.Plugin.JRay.sln
@@ -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
diff --git a/Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj b/Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj
index 337e3b2..8a8dadc 100644
--- a/Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj
+++ b/Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj
@@ -25,6 +25,15 @@
+
+
+
+ <_Parameter1>Jellyfin.Plugin.JRay.Tests
+
+
+
diff --git a/docs/requirements.md b/docs/requirements.md
index 97c0697..be920b7 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -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.
+```
+
+`LatestMajor` 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
```