diff --git a/.github/workflows/word-censor.yml b/.github/workflows/word-censor.yml index e0238e0f..88c50f42 100644 --- a/.github/workflows/word-censor.yml +++ b/.github/workflows/word-censor.yml @@ -5,11 +5,13 @@ on: branches: [main] paths: - 'se5/WordCensor/**' + - 'se5/WordCensor.Tests/**' - 'se5/Plugin-Shared/**' - '.github/workflows/word-censor.yml' pull_request: paths: - 'se5/WordCensor/**' + - 'se5/WordCensor.Tests/**' - 'se5/Plugin-Shared/**' - '.github/workflows/word-censor.yml' workflow_dispatch: @@ -90,8 +92,22 @@ jobs: echo "ref=$ref" >> "$GITHUB_OUTPUT" echo "release=$release_flag" >> "$GITHUB_OUTPUT" + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Test + run: dotnet test se5/WordCensor.Tests/WordCensor.Tests.csproj -c Release + build: - needs: prepare + needs: [prepare, test] runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/se5/WordCensor.Tests/BatchModeTests.cs b/se5/WordCensor.Tests/BatchModeTests.cs new file mode 100644 index 00000000..9a2523b9 --- /dev/null +++ b/se5/WordCensor.Tests/BatchModeTests.cs @@ -0,0 +1,87 @@ +using SubtitleEdit.Plugins.Shared; +using SubtitleEdit.Plugins.WordCensor; +using System.Text.Json; + +namespace WordCensor.Tests; + +/// Subtitle Edit Batch convert: "batch" censors every line headless with the saved settings. +public class BatchModeTests +{ + private static PluginRequest Request(string? settingsJson, params string[] texts) => new() + { + RequestType = PluginRequestType.Batch, + Subtitle = new PluginSubtitle + { + Format = "SubRip", + SubRip = SubRipParser.Serialize(texts.Select((t, i) => new SrtBlock + { + StartMs = i * 1000, + EndMs = i * 1000 + 900, + Text = t, + }).ToList()), + }, + Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(), + }; + + private static List Texts(PluginResponse response) => + SubRipParser.Parse(response.Subtitle!.Native).Select(b => b.Text).ToList(); + + [Fact] + public void Batch_DefaultSettings_GrawlixOnAllLines() + { + var response = BatchMode.BuildBatchResponse(Request(null, "Oh shit.", "Plain line", "Damn it")); + + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Settings); + var texts = Texts(response); + Assert.Equal(3, texts.Count); + Assert.DoesNotContain("shit", texts[0]); + Assert.EndsWith("it.", texts[0]); + Assert.Equal("Plain line", texts[1]); + Assert.DoesNotContain("Damn", texts[2]); + Assert.EndsWith("mn it", texts[2]); + } + + [Fact] + public void Batch_NothingToCensor_ReturnsOkWithoutSubtitle() + { + var response = BatchMode.BuildBatchResponse(Request(null, "Hello there", "Nice day")); + + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + } + + [Fact] + public void Batch_UsesSavedReplacementAndColor() + { + var response = BatchMode.BuildBatchResponse(Request( + "{\"mode\":\"Replacement\",\"replacement\":\"[beep]\",\"colorRed\":true}", "What the fuck?")); + + Assert.Equal(new[] { "What the [beep]?" }, Texts(response)); + } + + [Theory] + [InlineData("Grawlix")] + [InlineData("Alternative")] + public void Batch_RandomModes_AreReproducible(string mode) + { + var settings = $"{{\"mode\":\"{mode}\"}}"; + var first = BatchMode.BuildBatchResponse(Request(settings, "Shit, shit, shit.", "Damn fuck damn")); + var second = BatchMode.BuildBatchResponse(Request(settings, "Shit, shit, shit.", "Damn fuck damn")); + + Assert.Equal(Texts(first), Texts(second)); + Assert.NotEqual("Shit, shit, shit.", Texts(first)[0]); + } + + [Fact] + public void Settings_RoundTrip() + { + var options = new CensorOptions { Mode = CensorMode.Alternative, Replacement = "***", ColorRed = true }; + + var loaded = CensorSettings.Load(CensorSettings.Build(options)); + + Assert.Equal(CensorMode.Alternative, loaded.Mode); + Assert.Equal("***", loaded.Replacement); + Assert.True(loaded.ColorRed); + } +} diff --git a/se5/WordCensor.Tests/WordCensor.Tests.csproj b/se5/WordCensor.Tests/WordCensor.Tests.csproj new file mode 100644 index 00000000..2fd07171 --- /dev/null +++ b/se5/WordCensor.Tests/WordCensor.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/se5/WordCensor/BatchMode.cs b/se5/WordCensor/BatchMode.cs new file mode 100644 index 00000000..6400d35b --- /dev/null +++ b/se5/WordCensor/BatchMode.cs @@ -0,0 +1,62 @@ +using SubtitleEdit.Plugins.Shared; +using System; + +namespace SubtitleEdit.Plugins.WordCensor; + +/// +/// Subtitle Edit's Batch convert ("batch" request): censor every line with the saved settings, +/// without UI. All lines are changed, like the window's default where every change is checked. +/// +public static class BatchMode +{ + public static PluginResponse BuildBatchResponse(PluginRequest request) + { + var options = CensorSettings.Load(request.Settings); + var blocks = SubRipParser.Parse(request.Subtitle.SubRip); + + // Grawlix and "random alternative" are random by design. Seed from the file's text so a + // batch run is reproducible: the same file with the same settings gives the same output. + var engine = new WordCensorEngine(StableSeed(request.Subtitle.SubRip)); + + var changed = 0; + foreach (var block in blocks) + { + if (engine.TryCensor(block.Text, options, out var censored)) + { + block.Text = censored; + changed++; + } + } + + if (changed == 0) + { + return new PluginResponse { Status = PluginStatus.Ok, Message = "Word censor: no offensive words found." }; + } + + return new PluginResponse + { + Status = PluginStatus.Ok, + Message = $"Censored words in {changed} line(s).", + UndoDescription = "Word censor", + Subtitle = new PluginSubtitle + { + Format = "SubRip", + Native = SubRipParser.Serialize(blocks), + }, + }; + } + + /// FNV-1a over the text (string.GetHashCode is randomized per process). + private static int StableSeed(string? text) + { + unchecked + { + var hash = 2166136261u; + foreach (var c in text ?? string.Empty) + { + hash = (hash ^ c) * 16777619u; + } + return (int)hash; + } + } +} diff --git a/se5/WordCensor/CensorSettings.cs b/se5/WordCensor/CensorSettings.cs new file mode 100644 index 00000000..3f62de2e --- /dev/null +++ b/se5/WordCensor/CensorSettings.cs @@ -0,0 +1,56 @@ +using System; +using System.Text.Json; + +namespace SubtitleEdit.Plugins.WordCensor; + +/// The saved plugin settings (mode, replacement text, red highlight) shared by the window and Batch convert. +public static class CensorSettings +{ + public const string DefaultReplacement = "[censored]"; + public const int SettingsVersion = 1; + + public static CensorOptions Load(JsonElement? settings) + { + var options = new CensorOptions { Mode = CensorMode.Grawlix, Replacement = DefaultReplacement, ColorRed = false }; + + if (settings is null || settings.Value.ValueKind != JsonValueKind.Object) + { + return options; + } + + var root = settings.Value; + + if (root.TryGetProperty("mode", out var modeProp) && modeProp.ValueKind == JsonValueKind.String && + Enum.TryParse(modeProp.GetString(), ignoreCase: true, out var parsed)) + { + options.Mode = parsed; + } + if (root.TryGetProperty("replacement", out var rep) && rep.ValueKind == JsonValueKind.String) + { + var s = rep.GetString(); + if (!string.IsNullOrEmpty(s)) + { + options.Replacement = s; + } + } + if (root.TryGetProperty("colorRed", out var cr) && cr.ValueKind == JsonValueKind.True) + { + options.ColorRed = true; + } + + return options; + } + + public static JsonElement Build(CensorOptions options) + { + var dto = new + { + mode = options.Mode.ToString(), + replacement = options.Replacement, + colorRed = options.ColorRed, + }; + var json = JsonSerializer.Serialize(dto); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } +} diff --git a/se5/WordCensor/MainWindow.axaml b/se5/WordCensor/MainWindow.axaml index ca93fa31..aaa245d8 100644 --- a/se5/WordCensor/MainWindow.axaml +++ b/se5/WordCensor/MainWindow.axaml @@ -90,6 +90,7 @@