diff --git a/se5/BrazilianToEuropeanPortuguese.Tests/BigCompareObjectTests.cs b/se5/BrazilianToEuropeanPortuguese.Tests/BigCompareObjectTests.cs index 62743f36..2582b5ec 100644 --- a/se5/BrazilianToEuropeanPortuguese.Tests/BigCompareObjectTests.cs +++ b/se5/BrazilianToEuropeanPortuguese.Tests/BigCompareObjectTests.cs @@ -379,8 +379,8 @@ public void WhatWentWrongKeepsTheImpersonalSubject() [InlineData("O único jeito de resolver.", "A única forma de resolver.")] [InlineData("Ele tem jeito para isso.", "Ele tem jeito para isso.")] [InlineData("Ela nunca devia ter pego o caso.", "Ela nunca devia ter pegado no caso.")] - [InlineData("Só lembre-se de mim.", "Só lembra-te de mim.")] - [InlineData("Só lembra-te de quem enfrentas.", "Só lembra-te de quem enfrentas.")] + [InlineData("Só lembre-se de mim.", "Lembra-te só de mim.")] + [InlineData("Só lembra-te de quem enfrentas.", "Lembra-te só de quem enfrentas.")] [InlineData("Ele só lembrou-se de mim.", "Ele só se lembrou de mim.")] [InlineData("Já disse-te a verdade.", "Já te disse a verdade.")] public void BlueBloodsLeftoversUseEuropeanConstructions(string input, string expected) diff --git a/se5/BrazilianToEuropeanPortuguese.Tests/GrammarChecksTests.cs b/se5/BrazilianToEuropeanPortuguese.Tests/GrammarChecksTests.cs new file mode 100644 index 00000000..afcc1d13 --- /dev/null +++ b/se5/BrazilianToEuropeanPortuguese.Tests/GrammarChecksTests.cs @@ -0,0 +1,565 @@ +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using SubtitleEdit.Plugins.BrazilianToEuropeanPortuguese; +using SubtitleEdit.Plugins.Shared; + +namespace BrazilianToEuropeanPortuguese.Tests; + +public sealed class GrammarChecksTests +{ + private static readonly ConversionOptions Enabled = new(GrammarCheck: true, GrammarAutoFix: true); + private static readonly IProgress Progress = new Progress(_ => { }); + private static CheckLine Line(string text = "Eles tem razão.", string? english = "They are right.") => + new(0, text, text, null, english); + private static GrammarMatch Match(string text, string from = "tem", string to = "têm", string rule = "AGREEMENT") => + new(text.IndexOf(from, StringComparison.Ordinal), from.Length, rule, "Check agreement.", new[] { to }); + private static Task SameMeaning(string _, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + return Task.FromResult(new[] { 1f, 0f }); + } + + private static async Task<(Dictionary Texts, List Notes, string Summary)> Run( + IReadOnlyList lines, ConversionOptions? options = null, PortugueseLocalWordList? words = null, + Func>>? check = null, + Func>? embed = null) + { + var texts = lines.ToDictionary(l => l.Index, l => l.Converted); + var notes = new List(); + check ??= (text, _) => Task.FromResult>( + text.Contains("tem", StringComparison.Ordinal) ? new[] { Match(text) } : Array.Empty()); + var summary = await GrammarChecks.RunAsync(lines, options ?? Enabled, words, texts, + (_, note) => notes.Add(note), check, embed, Progress, CancellationToken.None); + return (texts, notes, summary); + } + + [Fact] + public async Task DefaultAndDisabledStageMakeNoRequests() + { + Task> Fail(string _, CancellationToken __) => throw new Exception("Unexpected server request."); + foreach (var options in new[] { ConversionOptions.Default, Enabled with { DisabledRules = "languagetool" } }) + { + var result = await Run(new[] { Line() }, options, check: Fail); + Assert.Equal(Line().Converted, result.Texts[0]); + Assert.Empty(result.Notes); + Assert.Empty(result.Summary); + } + } + + [Fact] + public async Task ReviewModeNeverEmbedsOrChangesText() + { + var result = await Run(new[] { Line() }, Enabled with { GrammarAutoFix = false }, + embed: (_, _) => throw new Exception("Review must not embed.")); + Assert.Equal("Eles tem razão.", result.Texts[0]); + Assert.Contains("review only", Assert.Single(result.Notes)); + Assert.Contains("0 allowlisted", result.Summary); + } + + [Theory] + [InlineData("misspelling", "TYPOS", "spelling")] + [InlineData("typographical", "PUNCTUATION", "typography")] + [InlineData("whitespace", "", "typography")] + [InlineData("style", "REDUNDANCY", "style")] + [InlineData("register", "", "style")] + [InlineData("grammar", "", "grammar")] + [InlineData("", "GRAMMAR", "grammar")] + [InlineData("", "", "other")] + [InlineData("new-server-type", "NEW_CATEGORY", "other")] + public async Task DisabledGroupsProduceNoAlertsOrCorrections(string issueType, string category, string group) + { + var match = Match("Eles tem razão.") with { IssueType = issueType, Category = category }; + Assert.Equal(group, GrammarRuleSelection.Group(match)); + var result = await Run(new[] { Line() }, Enabled with { GrammarDisabledGroups = group }, + check: (_, _) => Task.FromResult>(new[] { match }), + embed: (_, _) => throw new Exception("Disabled checks must not embed.")); + Assert.Empty(result.Notes); + Assert.Contains("0 cue(s)", result.Summary); + Assert.Equal("Eles tem razão.", result.Texts[0]); + } + + [Fact] + public async Task IndividualExclusionsDoNotDisableOtherRules() + { + var result = await Run(new[] { Line() }, Enabled with { GrammarDisabledRules = "AGREEMENT" }, + check: (text, _) => Task.FromResult>(new[] + { + Match(text), Match(text, rule: "OTHER_RULE") with { Replacements = Array.Empty() }, + }), embed: SameMeaning); + Assert.Equal("Eles tem razão.", result.Texts[0]); + Assert.Contains("[OTHER_RULE]", Assert.Single(result.Notes)); + Assert.DoesNotContain("[AGREEMENT]", result.Notes[0]); + } + + [Fact] + public async Task DisabledGroupsAlsoApplyDuringGrammarRecheck() + { + var result = await Run(new[] { Line() }, Enabled with { GrammarDisabledGroups = "style" }, + check: (text, _) => Task.FromResult>(text.Contains("tem", StringComparison.Ordinal) + ? new[] { Match(text) with { IssueType = "grammar" } } + : new[] { new GrammarMatch(0, 4, "WORDINESS", "Style only.", Array.Empty(), "style") }), + embed: SameMeaning); + Assert.Equal("Eles têm razão.", result.Texts[0]); + Assert.Contains("corrected", Assert.Single(result.Notes)); + } + + [Fact] + public void GrammarSelectionsRoundTripAndLegacySettingsKeepAllGroups() + { + var options = Enabled with { GrammarDisabledRules = "DASH_ENUMERATION_SPACE_RULE,MORFOLOGIK_RULE_PT_PT", GrammarDisabledGroups = "spelling,style,typography" }; + Assert.Equal(options, ConversionOptions.Load(new PluginRequest { SettingsVersion = 1, Settings = options.ToJson() })); + var legacy = ConversionOptions.Load(new PluginRequest { SettingsVersion = 1, Settings = JsonSerializer.SerializeToElement(new { grammarCheck = true }) }); + Assert.Empty(legacy.GrammarDisabledRules); + Assert.Empty(legacy.GrammarDisabledGroups); + Assert.Equal("A,B", GrammarRuleSelection.Validate("B,\nA,A")); + Assert.Equal(new[] { "A", "B" }, GrammarRuleSelection.ObservedIds(new[] { "LanguageTool [B]: test\nLanguageTool [A]: test", null })); + } + + [Theory] + [InlineData("{\"grammarDisabledRules\":false}")] + [InlineData("{\"grammarDisabledRules\":\"BAD ID\"}")] + [InlineData("{\"grammarDisabledRules\":\"A&text=secret\"}")] + [InlineData("{\"grammarDisabledGroups\":\"unknown\"}")] + [InlineData("{\"grammarDisabledGroups\":[]}")] + public void InvalidGrammarSelectionsFailExplicitly(string json) => + Assert.Throws(() => ConversionOptions.Load(new PluginRequest + { SettingsVersion = 1, Settings = JsonSerializer.Deserialize(json) })); + + [Fact] + public async Task AutomaticAgreementNeedsModelAndRechecksGrammar() + { + var missing = await Run(new[] { Line() }); + Assert.Equal("Eles tem razão.", missing.Texts[0]); + Assert.Contains("requires enabled, installed MiniLM", Assert.Single(missing.Notes)); + var requests = new List(); + var accepted = await Run(new[] { Line() }, embed: SameMeaning, check: (text, _) => + { + requests.Add(text); + return Task.FromResult>(text.Contains("tem", StringComparison.Ordinal) + ? new[] { Match(text) } : Array.Empty()); + }); + Assert.Equal("Eles têm razão.", accepted.Texts[0]); + Assert.Equal(new[] { "Eles tem razão.", "Eles têm razão." }, requests); + Assert.Contains("corrected", Assert.Single(accepted.Notes)); + } + + [Theory] + [InlineData("Eles tem razão.", "Eles têm razão.", "They are right.", true)] + [InlineData("Elas vem aqui.", "Elas vêm aqui.", "They come here.", true)] + [InlineData("Ele têm razão.", "Ele tem razão.", "He is right.", true)] + [InlineData("Ela vêm aqui.", "Ela vem aqui.", "She comes here.", true)] + [InlineData("ELES TEM RAZÃO.", "ELES TÊM RAZÃO.", "They are right.", true)] + [InlineData("Eles não tem razão.", "Eles não têm razão.", "They are not right.", true)] + [InlineData("Eles tem razão.", "Eles têm razão.", "He is right.", false)] + [InlineData("Eles tem razão.", "Eles têm razão.", null, false)] + [InlineData("Eles tem razão.", "Eles têm razão.", "Right.", false)] + [InlineData("Tu tem razão.", "Tu tens razão.", "You are right.", false)] + [InlineData("A equipa têm razão.", "A equipa tem razão.", "The team is right.", false)] + [InlineData("Eles tem razão.", "Eles tinham razão.", "They were right.", false)] + [InlineData("Ela falarei nisto.", "Ela falaria nisto.", "She would talk about this.", false)] + [InlineData("\"Eles tem razão.\"", "\"Eles têm razão.\"", "They are right.", false)] + [InlineData("'Eles tem razão.'", "'Eles têm razão.'", "They are right.", false)] + [InlineData("-Eles tem razão.", "-Eles têm razão.", "They are right.", false)] + [InlineData("Eles tem razão.", "Eles têm razão.", "They are right.", false)] + public void AllowlistIsBounded(string before, string after, string? english, bool expected) => + Assert.Equal(expected, GrammarChecks.Eligible(before, after, english, Enabled)); + + [Fact] + public void DisabledAgreementSettingsRemainAuthoritative() + { + Assert.False(GrammarChecks.Eligible("Eles tem razão.", "Eles têm razão.", "They are right.", Enabled with { YouToTu = false })); + Assert.False(GrammarChecks.Eligible("Eles tem razão.", "Eles têm razão.", "They are right.", Enabled with { DisabledRules = "address" })); + } + + [Fact] + public async Task UnlistedVocabularyAndTenseAreSuggestionsOnlyEvenWithPerfectSimilarity() + { + var line = Line("Ela falarei nisto.", "She would talk about this."); + var result = await Run(new[] { line }, embed: SameMeaning, check: (text, _) => + Task.FromResult>(new[] { Match(text, "falarei", "falaria") })); + Assert.Equal(line.Converted, result.Texts[0]); + Assert.Contains("not a unique allowlisted", Assert.Single(result.Notes)); + } + + [Fact] + public async Task SafeSentenceGroupPreservesCueBoundariesAndInternalNewlines() + { + var sentence = new SentenceMeaning(0, 1, "Eles tem razão, de facto.", "They are right, in fact."); + var lines = new[] + { + Line("Eles tem\nrazão,", "They are right,") with { MeaningSentence = sentence }, + new CheckLine(1, "de facto.", "de facto.", null, "in fact.", sentence), + }; + var result = await Run(lines, embed: SameMeaning); + Assert.Equal("Eles têm\nrazão,", result.Texts[0]); + Assert.Equal("de facto.", result.Texts[1]); + Assert.Equal(2, result.Notes.Count); + } + + [Fact] + public async Task IncompleteSelectedGroupDoesNotReadUnselectedContext() + { + var sentence = new SentenceMeaning(0, 1, "Eles tem razão.", "They are right."); + var seen = new List(); + var line = Line("razão.", "right.") with { Index = 1, MeaningSentence = sentence }; + await Run(new[] { line }, check: (text, _) => + { + seen.Add(text); + return Task.FromResult>(Array.Empty()); + }); + Assert.Equal(new[] { "razão." }, seen); + } + + [Fact] + public async Task SubjectInPreviousSafeCueCanConfirmAgreementWithoutMovingWords() + { + var sentence = new SentenceMeaning(0, 1, "Eles tem razão.", "They are right."); + var lines = new[] + { + Line("Eles", "They") with { MeaningSentence = sentence }, + new CheckLine(1, "tem razão.", "tem razão.", null, "are right.", sentence), + }; + var result = await Run(lines, embed: SameMeaning); + Assert.Equal("Eles", result.Texts[0]); + Assert.Equal("têm razão.", result.Texts[1]); + } + + [Theory] + [InlineData("Eles tem razão.", "Eles têm razão.")] + [InlineData("Eles\ntem razão.", "Eles\ntêm razão.")] + public async Task LanguageToolPhraseReplacementsPreserveWhitespaceAndRejectSubjectChanges(string before, string expected) + { + var result = await Run(new[] { Line(before) }, embed: SameMeaning, check: (text, _) => + Task.FromResult>(text.Contains("tem", StringComparison.Ordinal) + ? new[] { new GrammarMatch(0, text.IndexOf("tem", StringComparison.Ordinal) + 3, "GENERAL_VERB_AGREEMENT_ERRORS", + "Concordância.", new[] { "Eles têm", "Ele tem" }) } : Array.Empty())); + Assert.Equal(expected, result.Texts[0]); + } + + [Fact] + public async Task ActualPhraseReplacementMaySpanSafeCueBoundaryButOnlyVerbChanges() + { + var sentence = new SentenceMeaning(0, 1, "Eles tem razão.", "They are right."); + var lines = new[] + { + Line("Eles", "They") with { MeaningSentence = sentence }, + new CheckLine(1, "tem razão.", "tem razão.", null, "are right.", sentence), + }; + var result = await Run(lines, embed: SameMeaning, check: (text, _) => + Task.FromResult>(text.Contains("tem", StringComparison.Ordinal) + ? new[] { new GrammarMatch(0, 8, "GENERAL_VERB_AGREEMENT_ERRORS", "Concordância.", + new[] { "Eles têm", "Ele tem" }) } : Array.Empty())); + Assert.Equal("Eles", result.Texts[0]); + Assert.Equal("têm razão.", result.Texts[1]); + } + + [Fact] + public async Task LowSimilarityWithholdsCandidateBeforeGrammarRecheck() + { + var requests = 0; + var result = await Run(new[] { Line() }, check: (text, _) => + { + requests++; + return Task.FromResult>(new[] { Match(text) }); + }, embed: (text, _) => Task.FromResult(text.Contains("têm", StringComparison.Ordinal) + ? new[] { 0f, 1f } : new[] { 1f, 0f })); + Assert.Equal(1, requests); + Assert.Equal("Eles tem razão.", result.Texts[0]); + Assert.Contains("meaning safeguard rejected", Assert.Single(result.Notes)); + } + + [Fact] + public async Task IgnoredAndCustomWordsCannotBeChangedOrUsedAsAgreementEvidence() + { + var words = new PortugueseLocalWordList(); + words.Ignored.Add("Eles"); + var ignored = await Run(new[] { Line() }, words: words, embed: SameMeaning); + Assert.Equal("Eles tem razão.", ignored.Texts[0]); + words.Ignored.Clear(); + words.Words.Add(("custom", "tem")); + var custom = await Run(new[] { Line() }, words: words, embed: SameMeaning, + check: (_, _) => Task.FromResult>(new[] { new GrammarMatch(5, 3, "TEST", "Protected.", new[] { "têm" }) })); + Assert.Equal("Eles tem razão.", custom.Texts[0]); + Assert.Empty(custom.Notes); + var original = "😀 Eles tem\n{\\an8}razão."; + var masked = GrammarChecks.Mask(original, words); + Assert.Equal(original.Length, masked.Length); + Assert.DoesNotContain("tem", masked); + Assert.DoesNotContain("", masked); + } + + [Fact] + public async Task OverlappingDiagnosticsRemainReviewOnly() + { + var result = await Run(new[] { Line() }, embed: SameMeaning, check: (text, _) => + Task.FromResult>(new[] { Match(text), Match(text) with { Rule = "OTHER" } })); + Assert.Equal(Line().Converted, result.Texts[0]); + Assert.Equal(2, result.Notes.Count); + } + + [Fact] + public async Task InsertionAndNoReplacementFindingsRemainVisibleForReview() + { + var result = await Run(new[] { Line() }, check: (_, _) => + Task.FromResult>(new[] + { + new GrammarMatch(4, 0, "INSERTION", "Missing punctuation.", new[] { "," }), + new GrammarMatch(9, 5, "REVIEW", "Check this construction.", Array.Empty()), + })); + Assert.Equal(2, result.Notes.Count); + Assert.Contains(result.Notes, n => n.Contains("INSERTION")); + Assert.Contains(result.Notes, n => n.Contains("no replacement supplied")); + Assert.Equal(Line().Converted, result.Texts[0]); + } + + [Fact] + public async Task OnlyTheFixedDiagnosticIsLabelledCorrected() + { + var calls = 0; + var result = await Run(new[] { Line() }, embed: SameMeaning, check: (text, _) => + { + calls++; + var other = new GrammarMatch(9, 5, "AGREEMENT", "Review this word.", Array.Empty()); + return Task.FromResult>(calls == 1 ? new[] { Match(text), other } : new[] { other }); + }); + Assert.Equal("Eles têm razão.", result.Texts[0]); + Assert.Single(result.Notes, n => n.Contains("; corrected")); + Assert.Single(result.Notes, n => n.Contains("; review (not automatically changed)")); + } + + [Fact] + public async Task NewOrUnresolvedDiagnosticsRejectCandidate() + { + foreach (var unresolved in new[] { true, false }) + { + var calls = 0; + var result = await Run(new[] { Line() }, embed: SameMeaning, check: (text, _) => + { + calls++; + return Task.FromResult>(calls == 1 ? new[] { Match(text) } : + new[] { new GrammarMatch(0, 4, unresolved ? "AGREEMENT" : "NEW", "New finding.", Array.Empty()) }); + }); + Assert.Equal(2, calls); + Assert.Equal(Line().Converted, result.Texts[0]); + Assert.Contains("recheck", Assert.Single(result.Notes)); + } + } + + [Theory] + [InlineData(.8f, .8f, .8f, true)] + [InlineData(.7001f, .8f, .8f, true)] + [InlineData(.699f, .8f, .8f, false)] + [InlineData(.8f, .8f, .759f, false)] + [InlineData(.8f, .5f, .449f, false)] + [InlineData(.8f, .4f, .8f, false)] + [InlineData(float.NaN, .8f, .8f, false)] + public async Task MeaningGateUsesOriginalCandidateAndEnglish(float similarity, float beforeEnglish, float afterEnglish, bool expected) + { + Task Embed(string text, CancellationToken _) => Task.FromResult(text switch + { + "before" => new[] { 1f, 0f, 0f }, + "after" => new[] { similarity, 1f, 0f }, + _ => new[] { beforeEnglish, afterEnglish - similarity * beforeEnglish, 0f }, + }); + Assert.Equal(expected, await GrammarChecks.MeaningSafeAsync("before", "after", "english", Embed, CancellationToken.None)); + } + + [Fact] + public async Task EmbeddingErrorsPropagateAndNeverApplyChanges() + { + await Assert.ThrowsAsync(() => Run(new[] { Line() }, embed: (_, _) => throw new IOException("model failed"))); + } + + [Fact] + public void OptionsRoundTripAndOlderSettingsStayOff() + { + var options = Enabled with { GrammarServer = "http://languagetool:8010/" }; + var request = new PluginRequest { Settings = options.ToJson(), SettingsVersion = ConversionOptions.Version }; + Assert.Equal(options, ConversionOptions.Load(request)); + request.Settings = JsonSerializer.SerializeToElement(new { spelling = true }); + Assert.False(ConversionOptions.Load(request).GrammarCheck); + Assert.False(ConversionOptions.Load(request).GrammarAutoFix); + request.Settings = JsonSerializer.SerializeToElement(new { grammarCheck = "yes" }); + Assert.Throws(() => ConversionOptions.Load(request)); + } + + private static string Response(string text, params GrammarMatch[] matches) => JsonSerializer.Serialize(new + { + language = new { code = "pt-PT" }, + matches = matches.Select(m => new + { + offset = m.Offset, length = m.Length, message = m.Message, + rule = new { id = m.Rule, issueType = m.IssueType, category = new { id = m.Category } }, + replacements = m.Replacements.Select(value => new { value }), + }), + }); + + private sealed class Handler(Func> send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + send(request, cancellationToken); + } + + [Fact] + public async Task ClientSendsRuleExclusionsAndPreservesClassification() + { + using var client = new LanguageToolClient("http://localhost:8081/", new Handler(async (request, token) => + { + var form = Uri.UnescapeDataString(await request.Content!.ReadAsStringAsync(token)); + Assert.Contains("disabledRules=DASH_ENUMERATION_SPACE_RULE,MORFOLOGIK_RULE_PT_PT", form); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(Response("Eles tem razão.", Match("Eles tem razão.") with { IssueType = "grammar", Category = "GRAMMAR" })), + }; + }), "MORFOLOGIK_RULE_PT_PT,DASH_ENUMERATION_SPACE_RULE"); + var match = Assert.Single(await client.CheckAsync("Eles tem razão.", CancellationToken.None)); + Assert.Equal("grammar", match.IssueType); + Assert.Equal("GRAMMAR", match.Category); + } + + [Theory] + [InlineData("http://127.0.0.1:8081/", "http://127.0.0.1:8081/v2/check")] + [InlineData("http://languagetool:8010/v2", "http://languagetool:8010/v2/check")] + [InlineData("https://grammar.local/v2/check", "https://grammar.local/v2/check")] + public void ServerUrlSupportsLocalDockerAndHttps(string server, string expected) => + Assert.Equal(expected, LanguageToolClient.CheckUri(server).AbsoluteUri); + + [Theory] + [InlineData("")] + [InlineData("file:///tmp/grammar")] + [InlineData("http://user:password@localhost/")] + [InlineData("http://localhost/?text=hello")] + [InlineData("http://localhost/#fragment")] + [InlineData("https://api.languagetool.org")] + [InlineData("https://api.languagetoolplus.com/v2")] + public void InvalidAndCloudUrlsAreRejected(string server) => + Assert.Throws(() => LanguageToolClient.CheckUri(server)); + + [Fact] + public async Task ClientPostsPtPtCachesWithinRunAndUsesUtf16Offsets() + { + var calls = 0; + const string text = "😀 Eles tem razão."; + using var client = new LanguageToolClient("http://localhost:8081/", new Handler(async (request, token) => + { + calls++; + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("/v2/check", request.RequestUri!.AbsolutePath); + var form = await request.Content!.ReadAsStringAsync(token); + Assert.Contains("language=pt-PT", form); + Assert.Contains("text=", form); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Response(text, Match(text))) }; + })); + var first = await client.CheckAsync(text, CancellationToken.None); + Assert.Equal(8, Assert.Single(first).Offset); + Assert.Same(first, await client.CheckAsync(text, CancellationToken.None)); + Assert.Equal(1, calls); + } + + [Theory] + [InlineData("{}")] + [InlineData("not json")] + [InlineData("{\"language\":{\"code\":\"pt-BR\"},\"matches\":[]}")] + [InlineData("{\"language\":{\"code\":\"pt-PT\"},\"warnings\":{\"incompleteResults\":true},\"matches\":[]}")] + [InlineData("{\"language\":{\"code\":\"pt-PT\"},\"warnings\":{\"incompleteResults\":\"true\"},\"matches\":[]}")] + [InlineData("{\"language\":{\"code\":\"pt-PT\"},\"matches\":[{\"offset\":999,\"length\":3}]}")] + [InlineData("{\"language\":{\"code\":\"pt-PT\"},\"matches\":[{\"offset\":\"0\",\"length\":3}]}")] + public async Task InvalidResponsesFailExplicitly(string body) + { + using var client = new LanguageToolClient("http://localhost:8081/", new Handler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }))); + await Assert.ThrowsAsync(() => client.CheckAsync("Eles tem razão.", CancellationToken.None)); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.Redirect)] + public async Task HttpFailuresAreNotSuccessShaped(HttpStatusCode status) + { + using var client = new LanguageToolClient("http://localhost:8081/", new Handler((_, _) => + Task.FromResult(new HttpResponseMessage(status)))); + await Assert.ThrowsAsync(() => client.CheckAsync("Eles tem razão.", CancellationToken.None)); + } + + [Fact] + public async Task TimeoutAndUserCancellationAreDistinct() + { + using var client = new LanguageToolClient("http://localhost:8081/", new Handler((_, _) => throw new TaskCanceledException())); + Assert.Contains("timed out", (await Assert.ThrowsAsync(() => client.CheckAsync("text", CancellationToken.None))).Message); + using var cancel = new CancellationTokenSource(); + cancel.Cancel(); + await Assert.ThrowsAnyAsync(() => client.CheckAsync("text", cancel.Token)); + } + + [Theory] + [InlineData("", "", 1)] + [InlineData("TEST", "", 0)] + [InlineData("", "style", 0)] + public async Task BatchUsesSelfHostedProtocolAndPreservesTimingsAndTextInReviewMode(string disabledRules, string disabledGroups, int findings) + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + using var limit = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var server = Task.Run(async () => + { + using var socket = await listener.AcceptTcpClientAsync(limit.Token); + await using var stream = socket.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + Assert.StartsWith("POST /v2/check ", await reader.ReadLineAsync(limit.Token)); + var length = 0; + string? line; + while (!string.IsNullOrEmpty(line = await reader.ReadLineAsync(limit.Token))) + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) length = int.Parse(line.Split(':')[1]); + var content = new char[length]; + Assert.Equal(length, await reader.ReadBlockAsync(content.AsMemory(), limit.Token)); + Assert.Contains("language=pt-PT", new string(content)); + Assert.Contains("disabledRules=" + disabledRules, new string(content)); + var body = Encoding.UTF8.GetBytes(Response("Olá, mundo.", new GrammarMatch(0, 3, "TEST", "Test suggestion.", new[] { "Bom dia" }, "style"))); + var header = Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(header, limit.Token); + await stream.WriteAsync(body, limit.Token); + }, limit.Token); + var request = new PluginRequest + { + RequestType = PluginRequestType.Batch, + Subtitle = new PluginSubtitle { SubRip = "1\n00:00:01,234 --> 00:00:03,456\nOlá, mundo.\n\n" }, + SettingsVersion = ConversionOptions.Version, + Settings = new ConversionOptions(GrammarCheck: true, GrammarServer: $"http://127.0.0.1:{port}/", + GrammarExternalServer: true, GrammarDisabledRules: disabledRules, GrammarDisabledGroups: disabledGroups).ToJson(), + }; + var result = await Task.Run(() => BatchMode.BuildBatchResponse(request, Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".xml"), + new EmbeddingSettings(CheckMeaning: false)), limit.Token); + await server; + Assert.Equal(PluginStatus.Ok, result.Status); + Assert.Null(result.Subtitle); + Assert.Contains($"{findings} cue(s) with findings", result.Message); + Assert.Contains("0 allowlisted", result.Message); + Assert.Equal("1\n00:00:01,234 --> 00:00:03,456\nOlá, mundo.\n\n", request.Subtitle.SubRip); + } + + [Fact] + public void BatchReportsServerFailureAndRetainsRuleOutput() + { + var request = new PluginRequest + { + RequestType = PluginRequestType.Batch, + Subtitle = new PluginSubtitle { SubRip = "1\n00:00:01,234 --> 00:00:03,456\nPegue o ônibus.\n\n" }, + SettingsVersion = ConversionOptions.Version, + Settings = new ConversionOptions(GrammarCheck: true, GrammarServer: "http://127.0.0.1:0/", GrammarExternalServer: true).ToJson(), + }; + var response = BatchMode.BuildBatchResponse(request, Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".xml"), + new EmbeddingSettings(CheckMeaning: false)); + Assert.Contains("Optional checks failed; rule-only output retained", response.Message); + Assert.Contains("LanguageTool", response.Message); + Assert.Equal(PluginStatus.Ok, response.Status); + var output = Assert.Single(SubRipParser.Parse(response.Subtitle!.Native)); + Assert.Contains("autocarro", output.Text); + Assert.Equal(1234, output.StartMs); + Assert.Equal(3456, output.EndMs); + } +} diff --git a/se5/BrazilianToEuropeanPortuguese.Tests/GrammarRuntimeTests.cs b/se5/BrazilianToEuropeanPortuguese.Tests/GrammarRuntimeTests.cs new file mode 100644 index 00000000..f940af94 --- /dev/null +++ b/se5/BrazilianToEuropeanPortuguese.Tests/GrammarRuntimeTests.cs @@ -0,0 +1,162 @@ +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Text; +using SubtitleEdit.Plugins.BrazilianToEuropeanPortuguese; +using SubtitleEdit.Plugins.Shared; +using Xunit.Abstractions; + +namespace BrazilianToEuropeanPortuguese.Tests; + +[Collection("XdgDataHome")] +public sealed class GrammarRuntimeTests(ITestOutputHelper output) +{ + [Theory] + [InlineData("linux", Architecture.X64)] + [InlineData("linux", Architecture.Arm64)] + [InlineData("mac", Architecture.X64)] + [InlineData("mac", Architecture.Arm64)] + [InlineData("windows", Architecture.X64)] + [InlineData("windows", Architecture.Arm64)] + public void PortableJavaPackagesArePinnedForSupportedTargets(string os, Architecture architecture) + { + var package = GrammarAssets.JavaPackage(os, architecture); + Assert.Equal(64, package.Hash.Length); + Assert.Matches("^[a-f0-9]{64}$", package.Hash); + Assert.InRange(package.Size, 30_000_000, 60_000_000); + Assert.Contains("21.0.13", package.File); + Assert.StartsWith("https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/", package.Url); + if (os == "windows") Assert.Contains("x64_windows", package.File); + } + + [Fact] + public void UnsupportedJavaPlatformAndArchitectureFailExplicitly() + { + Assert.Throws(() => GrammarAssets.JavaPackage("linux", Architecture.X86)); + Assert.Throws(() => GrammarAssets.JavaPackage("other", Architecture.X64)); + } + + [Fact] + public void GrammarPackageIsFixedAndDefaultsAreManagedButOff() + { + Assert.Equal("https://languagetool.org/download/LanguageTool-6.6.zip", GrammarAssets.Tool.Url); + Assert.Equal("53600506b399bb5ffe1e4c8dec794fd378212f14aaf38ccef9b6f89314d11631", GrammarAssets.Tool.Hash); + Assert.Equal(251998221, GrammarAssets.Tool.Size); + Assert.False(ConversionOptions.Default.GrammarCheck); + Assert.False(ConversionOptions.Default.GrammarExternalServer); + var external = new ConversionOptions(GrammarCheck: true, GrammarExternalServer: true); + Assert.Equal(external, ConversionOptions.Load(new PluginRequest + { + SettingsVersion = ConversionOptions.Version, Settings = external.ToJson(), + })); + } + + [Fact] + public async Task MissingManagedAssetsExplainTheTwoDownloadButtons() + { + var directory = Path.Combine(Path.GetTempPath(), "ptpt-missing-grammar-" + Guid.NewGuid().ToString("N")); + var exception = await Assert.ThrowsAsync(() => + GrammarRuntime.StartAsync(new Progress(), CancellationToken.None, directory)); + Assert.Contains("Download LanguageTool pt-PT", exception.Message); + Assert.Contains("Download portable Java", exception.Message); + Assert.False(Directory.Exists(directory)); + } + + [Fact] + public void SafeJavaLicenseAliasesAreCopiedAndEscapesAreRejected() + { + var directory = Path.Combine(Path.GetTempPath(), "ptpt-grammar-extract-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var archive = Path.Combine(directory, "java.tar.gz"); + try + { + void Archive(string link) + { + using var file = File.Create(archive); + using var gzip = new GZipStream(file, CompressionMode.Compress); + using var writer = new TarWriter(gzip); + using var content = new MemoryStream(Encoding.UTF8.GetBytes("license text")); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "java/legal/java.base/LICENSE") { DataStream = content }); + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "java/legal/java.naming/LICENSE") { LinkName = link }); + } + Archive("../java.base/LICENSE"); + var safe = Path.Combine(directory, "safe"); + EmbeddingAssets.ExtractRuntime(archive, safe, CancellationToken.None, allowInternalLinks: true); + Assert.Equal("license text", File.ReadAllText(Path.Combine(safe, "java/legal/java.naming/LICENSE"))); + Assert.Null(new FileInfo(Path.Combine(safe, "java/legal/java.naming/LICENSE")).LinkTarget); + Archive("../../../../outside"); + Assert.Throws(() => + EmbeddingAssets.ExtractRuntime(archive, Path.Combine(directory, "unsafe"), CancellationToken.None, allowInternalLinks: true)); + } + finally { Directory.Delete(directory, true); } + } + + [OptInFact("PTPT_TEST_GRAMMAR", "downloads and runs the actual managed LanguageTool and portable Java")] + public async Task RealManagedPtPtCheckerStartsChecksAndStopsWithoutExternalServer() + { + var directory = Environment.GetEnvironmentVariable("PTPT_GRAMMAR_TEST_ROOT") ?? + Path.Combine(Path.GetTempPath(), "ptpt-real-grammar-" + Guid.NewGuid().ToString("N")); + var keep = Environment.GetEnvironmentVariable("PTPT_GRAMMAR_TEST_ROOT") != null; + using var limit = new CancellationTokenSource(TimeSpan.FromMinutes(8)); + var progress = new Progress(output.WriteLine); + try + { + await GrammarAssets.DownloadToolAsync(progress, limit.Token, directory); + await GrammarAssets.DownloadJavaAsync(progress, limit.Token, directory); + Assert.True(GrammarAssets.IsInstalled(directory)); + int pid; + var clock = Stopwatch.StartNew(); + await using (var runtime = await GrammarRuntime.StartAsync(progress, limit.Token, directory)) + { + pid = runtime.ProcessId; + output.WriteLine($"Ready in {clock.Elapsed.TotalSeconds:0.00}s; PID {pid}; {runtime.Server}"); + using var client = new LanguageToolClient(runtime.Server); + var examples = new[] + { + "Eles tem razão.", "Eles têm razão.", "Elas vem aqui.", "Elas vêm aqui.", + "Ela falarei nisto.", "Não para polícias.", "As operações policiais começaram.", + }; + clock.Restart(); + foreach (var example in examples) + { + var matches = await client.CheckAsync(example, limit.Token); + output.WriteLine(example + " => " + string.Join("; ", matches.Select(m => + $"{m.Rule}: {string.Join("/", m.Replacements.Take(3))}"))); + } + output.WriteLine($"Checked {examples.Length} examples in {clock.Elapsed.TotalSeconds:0.00}s."); + var wrong = await client.CheckAsync(examples[0], limit.Token); + Assert.Contains(wrong, m => m.Replacements.Contains("Eles têm", StringComparer.Ordinal)); + var correct = await client.CheckAsync(examples[1], limit.Token); + Assert.DoesNotContain(correct, m => m.Replacements.Contains("tem", StringComparer.Ordinal)); + var lines = new[] { new CheckLine(0, examples[0], examples[0], null, "They are right.") }; + var texts = new Dictionary { [0] = examples[0] }; + var notes = new List(); + var summary = await GrammarChecks.RunAsync(lines, new ConversionOptions(GrammarCheck: true, GrammarAutoFix: true), + null, texts, (_, note) => notes.Add(note), client.CheckAsync, + (_, _) => Task.FromResult(new[] { 1f, 0f }), progress, limit.Token); + Assert.Equal(examples[1], texts[0]); + Assert.Contains(notes, n => n.Contains("corrected")); + output.WriteLine(summary); + } + try + { + using var process = Process.GetProcessById(pid); + Assert.True(process.HasExited); + } + catch (ArgumentException) { /* Process has been reaped. */ } + Assert.Empty(Directory.GetFiles(directory, "server-*.properties")); + + var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + await Assert.ThrowsAnyAsync(() => + GrammarRuntime.StartAsync(progress, cancelled.Token, directory)); + cancelled.Dispose(); + using var interrupted = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + await Assert.ThrowsAnyAsync(() => + GrammarRuntime.StartAsync(progress, interrupted.Token, directory)); + Assert.Empty(Directory.GetFiles(directory, "server-*.properties")); + } + finally { if (!keep && Directory.Exists(directory)) Directory.Delete(directory, true); } + } +} diff --git a/se5/BrazilianToEuropeanPortuguese.Tests/InjuryStateTests.cs b/se5/BrazilianToEuropeanPortuguese.Tests/InjuryStateTests.cs new file mode 100644 index 00000000..71ce60b9 --- /dev/null +++ b/se5/BrazilianToEuropeanPortuguese.Tests/InjuryStateTests.cs @@ -0,0 +1,62 @@ +using SubtitleEdit.Plugins.BrazilianToEuropeanPortuguese; + +namespace BrazilianToEuropeanPortuguese.Tests; + +public sealed class InjuryStateTests +{ + [Theory] + [InlineData("a perna esquerda do\nmeu padrinho foi lesionada.", "a perna esquerda do\nmeu padrinho ficou lesionada.")] + [InlineData("A perna esquerda do meu padrinho foi lesionada.", "A perna esquerda do meu padrinho ficou lesionada.")] + [InlineData("O braço direito foi magoado.", "O braço direito ficou magoado.")] + [InlineData("As pernas foram lesionadas.", "As pernas ficaram lesionadas.")] + [InlineData("Os joelhos do meu pai foram feridos.", "Os joelhos do meu pai ficaram feridos.")] + [InlineData("A mão foi gravemente lesionada.", "A mão ficou gravemente lesionada.")] + [InlineData("O tornozelo foi ligeiramente magoado.", "O tornozelo ficou ligeiramente magoado.")] + [InlineData("As costas foram lesionadas no acidente.", "As costas ficaram lesionadas no acidente.")] + [InlineData("O pescoço foi lesionado.", "O pescoço ficou lesionado.")] + [InlineData("O tendão foi lesionado.", "O tendão ficou lesionado.")] + [InlineData("Os tendões foram lesionados.", "Os tendões ficaram lesionados.")] + public void AnatomicalInjuriesUseResultState(string source, string expected) + { + var converter = new PortugueseConverter(); + Assert.Equal(expected, converter.Convert(source)); + Assert.Equal(expected, converter.Convert(expected)); + } + + [Theory] + [InlineData("A perna foi lesionada pelo agressor.")] + [InlineData("A perna foi lesionada por uma bala.")] + [InlineData("A perna foi lesionada de propósito.")] + [InlineData("A perna foi lesionada intencionalmente.")] + [InlineData("O suspeito foi ferido.")] + [InlineData("A perna não foi lesionada.")] + [InlineData("A perna já estava lesionada.")] + [InlineData("As pernas foi lesionada.")] + [InlineData("A perna que foi lesionada.")] + [InlineData("A perna foi examinada.")] + [InlineData("A perna e o braço foram lesionados.")] + public void EventivePassivesNegationAndOtherMeaningsAreNotRewritten(string source) => + Assert.Equal(source, AutomaticGrammar.Apply(source, ConversionOptions.Default)); + + [Theory] + [InlineData("a perna do\nmeu padrinho foi lesionada.", "a perna do\nmeu padrinho ficou lesionada.")] + [InlineData("a casa da\nminha irmã ficou bonita.", "a casa da\nminha irmã ficou bonita.")] + [InlineData("o carro dos\nmeus amigos chegou.", "o carro dos\nmeus amigos chegou.")] + [InlineData("estou no\nmeu quarto.", "estou no\nmeu quarto.")] + [InlineData("ela está na\nsua casa.", "ela está na\nsua casa.")] + [InlineData("olha para o\nmeu carro.", "olha para o\nmeu carro.")] + [InlineData("Ele falou do\nmeu bebé!", "Ele falou do\nmeu bebé!")] + public void WrappedPossessivesDoNotDuplicateExistingArticles(string source, string expected) + { + var converter = new PortugueseConverter(); + Assert.Equal(expected, converter.Convert(source)); + Assert.Equal(expected, converter.Convert(expected)); + } + + [Fact] + public void ColloquialOptionControlsResultativePreference() + { + var converter = new PortugueseConverter(new ConversionOptions(Colloquial: false)); + Assert.Equal("A perna foi lesionada.", converter.Convert("A perna foi lesionada.")); + } +} diff --git a/se5/BrazilianToEuropeanPortuguese.Tests/QualityTests.cs b/se5/BrazilianToEuropeanPortuguese.Tests/QualityTests.cs index 7150643a..d641b54c 100644 --- a/se5/BrazilianToEuropeanPortuguese.Tests/QualityTests.cs +++ b/se5/BrazilianToEuropeanPortuguese.Tests/QualityTests.cs @@ -241,8 +241,14 @@ public void FindingTypesDistinguishRulesModelsReviewsAndManualEdits() var combined = new ChangeProposal(3, "A", "B", reviewNote: "MiniLM: check it.\nGervásio chose an alternative.\nGrammar: check agreement.", rules: new[] { new RuleChange("word-list", "Vocabulary") }); - Assert.Equal(FindingType.RuleBased | FindingType.MiniLm | FindingType.Judge | FindingType.OtherReview, + Assert.Equal(FindingType.RuleBased | FindingType.MiniLm | FindingType.Judge | FindingType.Grammar, combined.FindingTypes); + var grammar = new ChangeProposal(6, "A", "A", reviewNote: "LanguageTool [TEST]: check grammar."); + Assert.Equal(FindingType.Grammar, grammar.FindingTypes); + var corrected = new ChangeProposal(7, "A", "B", rules: new[] { new RuleChange("languagetool", "Grammar correction") }); + Assert.Equal(FindingType.RuleBased | FindingType.Grammar, corrected.FindingTypes); + var mixed = new ChangeProposal(8, "A", "A", reviewNote: "LanguageTool [TEST]: check grammar.\nReview this wording."); + Assert.Equal(FindingType.Grammar | FindingType.OtherReview, mixed.FindingTypes); mini.ConvertedText = "Manual correction"; Assert.Equal(FindingType.MiniLm | FindingType.ManualEdit, mini.FindingTypes); mini.ConvertedText = "A"; diff --git a/se5/BrazilianToEuropeanPortuguese.Tests/ReferenceTests.cs b/se5/BrazilianToEuropeanPortuguese.Tests/ReferenceTests.cs index 2ab126e1..8b5a03f3 100644 --- a/se5/BrazilianToEuropeanPortuguese.Tests/ReferenceTests.cs +++ b/se5/BrazilianToEuropeanPortuguese.Tests/ReferenceTests.cs @@ -1,4 +1,5 @@ using System.Text; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.VisualTree; @@ -477,6 +478,58 @@ public void MainWindowAppliesReferenceToProposals() var subtitle = Path.Combine(data, "episode.pt-br.srt"); File.WriteAllText(subtitle, ""); PluginBootstrap.BuildAvaloniaApp().SetupWithoutStarting(); + var grammarWindow = new MainWindow(new PluginRequest + { + SettingsVersion = ConversionOptions.Version, + Settings = new ConversionOptions(GrammarCheck: true, GrammarServer: "http://languagetool:8010/").ToJson(), + Subtitle = new PluginSubtitle { SubRip = "1\n00:00:01,000 --> 00:00:03,000\nOlá.\n" }, + }); + Assert.True(grammarWindow.FindControl("GrammarCheck")!.IsChecked); + Assert.False(grammarWindow.FindControl("GrammarAutoFix")!.IsChecked); + Assert.True(grammarWindow.FindControl("GrammarAutoFix")!.IsVisible); + Assert.True(grammarWindow.FindControl("GrammarAutoFix")!.IsEnabled); + var grammarControls = Assert.IsType(grammarWindow.FindControl("GrammarAutoFix")!.Parent); + Assert.True(grammarControls.Children.IndexOf(grammarWindow.FindControl("GrammarAutoFix")!) < + grammarControls.Children.IndexOf(grammarWindow.FindControl("GrammarExternalServer")!)); + grammarWindow.FindControl("GrammarAutoFix")!.IsChecked = true; + Assert.True(grammarWindow.FindControl("GrammarAutoFix")!.IsChecked); + Assert.False(grammarWindow.FindControl("GrammarExternalServer")!.IsChecked); + Assert.False(grammarWindow.FindControl("GrammarServer")!.IsVisible); + Assert.NotNull(grammarWindow.FindControl