From df751bb94ea245f6b0cc03f65649097d48c1fe90 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:35:29 +0200 Subject: [PATCH 01/77] feat(backend): add rule options for empty section structure with availability and severity enums --- .../Options/Rules/EmptySectionStructureRuleOptions.cs | 9 +++++++++ backend/Options/Rules/RuleAvailability.cs | 7 +++++++ backend/Options/Rules/RuleSeverity.cs | 8 ++++++++ 3 files changed, 24 insertions(+) create mode 100644 backend/Options/Rules/EmptySectionStructureRuleOptions.cs create mode 100644 backend/Options/Rules/RuleAvailability.cs create mode 100644 backend/Options/Rules/RuleSeverity.cs diff --git a/backend/Options/Rules/EmptySectionStructureRuleOptions.cs b/backend/Options/Rules/EmptySectionStructureRuleOptions.cs new file mode 100644 index 0000000..b15fee5 --- /dev/null +++ b/backend/Options/Rules/EmptySectionStructureRuleOptions.cs @@ -0,0 +1,9 @@ +namespace backend.RuleOptions; + +public sealed class EmptySectionStructureRuleOptions +{ + public const string SectionName = "Validation:Rules:EmptySectionStructureRule"; + + public RuleAvailability Availability { get; init; } = RuleAvailability.Available; + public RuleSeverity Severity { get; init; } = RuleSeverity.Warning; +} diff --git a/backend/Options/Rules/RuleAvailability.cs b/backend/Options/Rules/RuleAvailability.cs new file mode 100644 index 0000000..4924431 --- /dev/null +++ b/backend/Options/Rules/RuleAvailability.cs @@ -0,0 +1,7 @@ +namespace backend.RuleOptions; + +public enum RuleAvailability +{ + Available, + Hidden +} diff --git a/backend/Options/Rules/RuleSeverity.cs b/backend/Options/Rules/RuleSeverity.cs new file mode 100644 index 0000000..bca1419 --- /dev/null +++ b/backend/Options/Rules/RuleSeverity.cs @@ -0,0 +1,8 @@ +namespace backend.RuleOptions; + +public enum RuleSeverity +{ + Info, + Warning, + Error +} From fb1257b9a4ce1f2c50c3cdd1cb32553c143ba3f8 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:35:40 +0200 Subject: [PATCH 02/77] feat(backend): enhance validation severity normalization to include Info level --- backend/Models/ValidationResult.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/Models/ValidationResult.cs b/backend/Models/ValidationResult.cs index c08b2a5..0224a54 100644 --- a/backend/Models/ValidationResult.cs +++ b/backend/Models/ValidationResult.cs @@ -4,14 +4,19 @@ namespace backend.Models; public static class ValidationSeverity { + public const string Info = "Info"; public const string Error = "Error"; public const string Warning = "Warning"; public static string Normalize(string? severity) { - return string.Equals(severity, Warning, StringComparison.OrdinalIgnoreCase) - ? Warning - : Error; + if (string.Equals(severity, Info, StringComparison.OrdinalIgnoreCase)) + return Info; + + if (string.Equals(severity, Warning, StringComparison.OrdinalIgnoreCase)) + return Warning; + + return Error; } } From 9970dacd6f2af77513fdb038ff1ee2232dacdc29 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:36:14 +0200 Subject: [PATCH 03/77] refactor(backend): update EmptySectionStructureRule to use options for rule ID and severity --- backend/Rules/EmptySectionStructureRule.cs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/Rules/EmptySectionStructureRule.cs b/backend/Rules/EmptySectionStructureRule.cs index 27e1c2f..84ac693 100644 --- a/backend/Rules/EmptySectionStructureRule.cs +++ b/backend/Rules/EmptySectionStructureRule.cs @@ -1,8 +1,10 @@ using backend.Models; using Backend.Models; +using backend.RuleOptions; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Analysis; using backend.Services.Comments; @@ -21,13 +23,25 @@ namespace backend.Rules; /// public class EmptySectionStructureRule : IValidationRule { - public string Name => "EmptySectionStructureRule"; + public const string RuleId = nameof(EmptySectionStructureRule); + + private readonly EmptySectionStructureRuleOptions _options; + + public EmptySectionStructureRule(IOptions? options = null) + { + _options = options?.Value ?? new EmptySectionStructureRuleOptions(); + } + + public string Name => RuleId; public IEnumerable Validate( WordprocessingDocument doc, UniversityConfig config, DocumentCommentService? commentService = null) { + if (_options.Availability == RuleAvailability.Hidden) + return []; + var errors = new List(); var body = doc.MainDocumentPart?.Document.Body; if (body is null) return errors; @@ -86,13 +100,15 @@ public IEnumerable Validate( "with no introductory text. Add at least one paragraph of body text " + "before the first sub-section."; - errors.Add(ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, msg, lastHeadingParaIdx, lastHeadingPreview, - ParagraphIndexKind.BodyElement)); + ParagraphIndexKind.BodyElement); + result.Severity = _options.Severity.ToString(); + errors.Add(result); if (lastHeadingParagraph is not null) commentService?.AddCommentToParagraph(doc, lastHeadingParagraph, msg); From 0af0a4bf7024b7ee0237118eb6c619aafc0237f1 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:36:26 +0200 Subject: [PATCH 04/77] refactor(backend): enhance ThesisValidatorService to support empty section options and rule execution filtering --- .../Analysis/ThesisValidatorService.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/Services/Analysis/ThesisValidatorService.cs b/backend/Services/Analysis/ThesisValidatorService.cs index 6bb9800..c7148bf 100644 --- a/backend/Services/Analysis/ThesisValidatorService.cs +++ b/backend/Services/Analysis/ThesisValidatorService.cs @@ -1,5 +1,7 @@ using backend.Models; using backend.Services.Comments; +using backend.Rules; +using backend.RuleOptions; using backend.Services.Exceptions; using backend.Services.Extraction; using backend.Services.Skipping; @@ -7,6 +9,7 @@ using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; namespace backend.Services.Analysis; @@ -15,11 +18,15 @@ public class ThesisValidatorService { private readonly IReadOnlyList _ruleList; private readonly IReadOnlySet _ruleNames; + private readonly EmptySectionStructureRuleOptions _emptySectionOptions; - public ThesisValidatorService(IEnumerable rules) + public ThesisValidatorService( + IEnumerable rules, + IOptions? emptySectionOptions = null) { _ruleList = rules.ToList(); _ruleNames = _ruleList.Select(rule => rule.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + _emptySectionOptions = emptySectionOptions?.Value ?? new EmptySectionStructureRuleOptions(); } public IReadOnlyList GetAvailableRuleNames() @@ -264,7 +271,15 @@ private IReadOnlyList FilterRules( : _ruleList.Where(rule => selectedSet.Contains(rule.Name)).ToList(); } - return candidates.ToList(); + return candidates + .Where(IsExecutableRule) + .ToList(); + } + + private bool IsExecutableRule(IValidationRule rule) + { + return !string.Equals(rule.Name, EmptySectionStructureRule.RuleId, StringComparison.OrdinalIgnoreCase) + || _emptySectionOptions.Availability != RuleAvailability.Hidden; } } From e9341237f3639ef4ab7ace0a9126cb53a52a3a58 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:36:45 +0200 Subject: [PATCH 05/77] refactor(backend): add configuration options for EmptySectionStructureRule in appsettings --- backend/Program.cs | 5 +++++ backend/appsettings.json | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/backend/Program.cs b/backend/Program.cs index a3bb55b..ca8118e 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,6 +1,7 @@ using System.Reflection; using backend.Endpoints; using backend.Models; +using backend.RuleOptions; using backend.Services.Analysis; using backend.Services.CodeBlocks; using backend.Services.Language; @@ -36,6 +37,10 @@ "CodeBlockDetection:CodeFonts must contain at least one font.") .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(EmptySectionStructureRuleOptions.SectionName)) + .ValidateOnStart(); + builder.Services.AddSingleton(); builder.Services.AddHttpClient(); diff --git a/backend/appsettings.json b/backend/appsettings.json index 7b8426a..571089c 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -7,6 +7,12 @@ }, "AllowedHosts": "*", "Validation": { + "Rules": { + "EmptySectionStructureRule": { + "Availability": "Hidden", + "Severity": "Error" + } + }, "CodeBlockDetection": { "MinimumCodeFontTextRatio": 0.7, "RequireWholeParagraphMonospace": false, From eada51b0d0e1ef488c451c76538d24c1e9f1c1a1 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:36:58 +0200 Subject: [PATCH 06/77] refactor(backend): update GetAvailableRules to filter based on EmptySectionStructureRuleOptions --- backend/Endpoints/DocumentEndpoint.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/Endpoints/DocumentEndpoint.cs b/backend/Endpoints/DocumentEndpoint.cs index 7ab8729..42f4b77 100644 --- a/backend/Endpoints/DocumentEndpoint.cs +++ b/backend/Endpoints/DocumentEndpoint.cs @@ -1,9 +1,12 @@ using backend.Models; +using backend.Rules; +using backend.RuleOptions; using Backend.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using backend.Services.Analysis; using backend.Services.Exceptions; +using ThesisValidator.Rules; namespace backend.Endpoints; @@ -122,9 +125,12 @@ private static IResult ValidateWithComments( } } - private static IResult GetAvailableRules(ThesisValidatorService thesisValidatorService) + private static IResult GetAvailableRules( + ThesisValidatorService thesisValidatorService, + IOptions emptySectionOptions) { var ruleList = thesisValidatorService.GetAvailableRules() + .Where(rule => IsAvailableRule(rule, emptySectionOptions.Value)) .Select(rule => new { Name = rule.Id, @@ -139,6 +145,14 @@ private static IResult GetAvailableRules(ThesisValidatorService thesisValidatorS return Results.Ok(new { Rules = ruleList, Count = ruleList.Count }); } + private static bool IsAvailableRule( + RuleDefinition rule, + EmptySectionStructureRuleOptions emptySectionOptions) + { + return !string.Equals(rule.Id, EmptySectionStructureRule.RuleId, StringComparison.OrdinalIgnoreCase) + || emptySectionOptions.Availability != RuleAvailability.Hidden; + } + private static IResult HealthCheck() { return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow }); From 5f447df29fd99da259364cd619b4b43c6f6f972c Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:37:06 +0200 Subject: [PATCH 07/77] refactor(tests): add unit tests for EmptySectionStructureRule configuration and validation --- ...ySectionStructureRuleConfigurationTests.cs | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs diff --git a/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs b/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs new file mode 100644 index 0000000..f5fe618 --- /dev/null +++ b/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs @@ -0,0 +1,212 @@ +using System.Collections; +using System.Reflection; +using backend.Endpoints; +using backend.Models; +using backend.Rules; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Comments; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; + +namespace backend.Tests.Rules; + +public class EmptySectionStructureRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenEmptySectionRuleIsAvailable_IncludesRule() + { + var result = InvokeGetAvailableRules(new EmptySectionStructureRuleOptions + { + Availability = RuleAvailability.Available + }); + + Assert.Contains(EmptySectionStructureRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenEmptySectionRuleIsHidden_ExcludesRule() + { + var result = InvokeGetAvailableRules(new EmptySectionStructureRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + Assert.DoesNotContain(EmptySectionStructureRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() + { + var rule = new RecordingRule(EmptySectionStructureRule.RuleId); + var service = CreateService( + [rule], + new EmptySectionStructureRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + using var stream = CreateEmptySectionDocxStream(); + var (results, _) = service.Validate( + stream, + new UniversityConfig(), + [EmptySectionStructureRule.RuleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Fact] + public void Validate_WhenSeverityIsWarning_AppliesWarning() + { + var result = ValidateEmptySectionRule(new EmptySectionStructureRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenSeverityIsError_AppliesError() + { + var result = ValidateEmptySectionRule(new EmptySectionStructureRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WhenConfiguredSeverityMatchesPreviousDefault_KeepsExistingErrorBehavior() + { + Assert.Equal( + ValidationSeverity.Error, + RuleCatalog.GetDefinition(EmptySectionStructureRule.RuleId).DefaultSeverity); + + var result = ValidateEmptySectionRule(new EmptySectionStructureRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + } + + private static ValidationResult ValidateEmptySectionRule(EmptySectionStructureRuleOptions options) + { + var rule = new EmptySectionStructureRule(Options.Create(options)); + using var stream = CreateEmptySectionDocxStream(); + using var doc = WordprocessingDocument.Open(stream, false); + + return Assert.Single(rule.Validate(doc, new UniversityConfig())); + } + + private static IResult InvokeGetAvailableRules(EmptySectionStructureRuleOptions options) + { + var method = typeof(DocumentEndpoint).GetMethod( + "GetAvailableRules", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method.Invoke( + null, + new object?[] + { + CreateService( + [new RecordingRule(EmptySectionStructureRule.RuleId), new RecordingRule("FontFamily")], + options), + Options.Create(options) + }); + + return Assert.IsAssignableFrom(result); + } + + private static ThesisValidatorService CreateService( + IEnumerable rules, + EmptySectionStructureRuleOptions options) + { + return new ThesisValidatorService(rules, Options.Create(options)); + } + + private static IReadOnlyList GetRuleNames(IResult result) + { + Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); + + var value = result.GetType().GetProperty("Value")?.GetValue(result); + Assert.NotNull(value); + + var rules = value.GetType().GetProperty("Rules")?.GetValue(value); + Assert.NotNull(rules); + + return ((IEnumerable)rules) + .Cast() + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private static MemoryStream CreateEmptySectionDocxStream() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document( + new Body( + CreateHeading("Chapter 1", "Heading1"), + CreateHeading("Section 1.1", "Heading2"))); + mainPart.Document.Save(); + } + + stream.Position = 0; + return stream; + } + + private static Paragraph CreateHeading(string text, string styleId) + { + return new Paragraph( + new ParagraphProperties(new ParagraphStyleId { Val = styleId }), + new Run(new Text(text))); + } + + private sealed class RecordingRule : IValidationRule + { + public RecordingRule(string name) + { + Name = name; + } + + public string Name { get; } + + public int RunCount { get; private set; } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + RunCount++; + return + [ + new ValidationResult + { + RuleName = Name, + Message = "Executed" + } + ]; + } + } +} From 09b093d606d08806da96ff9e7774c6223bf233f7 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:14:56 +0200 Subject: [PATCH 08/77] refactor(rules): abstract rule options into base rule option base class --- backend/Options/Rules/EmptySectionStructureRuleOptions.cs | 8 +++++--- backend/Options/Rules/RuleOptionsBase.cs | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 backend/Options/Rules/RuleOptionsBase.cs diff --git a/backend/Options/Rules/EmptySectionStructureRuleOptions.cs b/backend/Options/Rules/EmptySectionStructureRuleOptions.cs index b15fee5..e6f9a00 100644 --- a/backend/Options/Rules/EmptySectionStructureRuleOptions.cs +++ b/backend/Options/Rules/EmptySectionStructureRuleOptions.cs @@ -1,9 +1,11 @@ namespace backend.RuleOptions; -public sealed class EmptySectionStructureRuleOptions +public sealed class EmptySectionStructureRuleOptions : RuleOptionsBase { public const string SectionName = "Validation:Rules:EmptySectionStructureRule"; - public RuleAvailability Availability { get; init; } = RuleAvailability.Available; - public RuleSeverity Severity { get; init; } = RuleSeverity.Warning; + public EmptySectionStructureRuleOptions() + { + Severity = RuleSeverity.Warning; + } } diff --git a/backend/Options/Rules/RuleOptionsBase.cs b/backend/Options/Rules/RuleOptionsBase.cs new file mode 100644 index 0000000..9dc313e --- /dev/null +++ b/backend/Options/Rules/RuleOptionsBase.cs @@ -0,0 +1,7 @@ +namespace backend.RuleOptions; + +public abstract class RuleOptionsBase +{ + public RuleAvailability Availability { get; set; } = RuleAvailability.Available; + public RuleSeverity Severity { get; set; } = RuleSeverity.Error; +} From 49449d50f8ebff42ab53e616827247c000043f2f Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:14:57 +0200 Subject: [PATCH 09/77] feat(rules): introduce centralized rule configuration service --- .../Rules/IRuleConfigurationService.cs | 16 ++++ .../Rules/RuleConfigurationService.cs | 75 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 backend/Services/Rules/IRuleConfigurationService.cs create mode 100644 backend/Services/Rules/RuleConfigurationService.cs diff --git a/backend/Services/Rules/IRuleConfigurationService.cs b/backend/Services/Rules/IRuleConfigurationService.cs new file mode 100644 index 0000000..22e8239 --- /dev/null +++ b/backend/Services/Rules/IRuleConfigurationService.cs @@ -0,0 +1,16 @@ +using Backend.Models; +using ThesisValidator.Rules; + +namespace backend.Services.Rules; + +public interface IRuleConfigurationService +{ + bool IsRuleAvailable(string ruleId); + + string ResolveSeverity( + string ruleId, + UniversityConfig config, + string? explicitSeverity = null); + + RuleDefinition ApplyConfiguration(RuleDefinition definition); +} diff --git a/backend/Services/Rules/RuleConfigurationService.cs b/backend/Services/Rules/RuleConfigurationService.cs new file mode 100644 index 0000000..46c1eed --- /dev/null +++ b/backend/Services/Rules/RuleConfigurationService.cs @@ -0,0 +1,75 @@ +using backend.Models; +using backend.Rules; +using backend.RuleOptions; +using backend.Services.Results; +using Backend.Models; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; + +namespace backend.Services.Rules; + +public sealed class RuleConfigurationService : IRuleConfigurationService +{ + private readonly EmptySectionStructureRuleOptions _emptySectionOptions; + private readonly FontFamilyRuleOptions _fontFamilyOptions; + + public RuleConfigurationService( + IOptions emptySectionOptions, + IOptions? fontFamilyOptions = null) + { + _emptySectionOptions = emptySectionOptions.Value; + _fontFamilyOptions = fontFamilyOptions?.Value ?? new FontFamilyRuleOptions(); + } + + public bool IsRuleAvailable(string ruleId) + { + if (IsEmptySectionStructureRule(ruleId)) + return _emptySectionOptions.Availability != RuleAvailability.Hidden; + + if (IsFontFamilyRule(ruleId)) + return _fontFamilyOptions.Availability != RuleAvailability.Hidden; + + return true; + } + + public string ResolveSeverity( + string ruleId, + UniversityConfig config, + string? explicitSeverity = null) + { + if (IsEmptySectionStructureRule(ruleId)) + return ValidationSeverity.Normalize(_emptySectionOptions.Severity.ToString()); + + if (IsFontFamilyRule(ruleId)) + return ValidationSeverity.Normalize(_fontFamilyOptions.Severity.ToString()); + + return SeverityResolver.Resolve(ruleId, config, explicitSeverity); + } + + public RuleDefinition ApplyConfiguration(RuleDefinition definition) + { + if (IsEmptySectionStructureRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_emptySectionOptions.Severity.ToString()) }; + + if (IsFontFamilyRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_fontFamilyOptions.Severity.ToString()) }; + + return definition; + } + + private static bool IsEmptySectionStructureRule(string ruleId) + { + return string.Equals( + ruleId, + EmptySectionStructureRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsFontFamilyRule(string ruleId) + { + return string.Equals( + ruleId, + FontFamilyValidationRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } +} From d9dc5539401aee9a68a7428a65e79ad6c85c575b Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:14:57 +0200 Subject: [PATCH 10/77] refactor(backend): integrate configuration service into pipeline and endpoints --- ...ySectionStructureRuleConfigurationTests.cs | 52 +++++++++++++++---- backend/Endpoints/DocumentEndpoint.cs | 17 ++---- backend/Program.cs | 7 +++ backend/Rules/EmptySectionStructureRule.cs | 12 +++-- .../Analysis/ThesisValidatorService.cs | 14 ++--- 5 files changed, 68 insertions(+), 34 deletions(-) diff --git a/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs b/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs index f5fe618..0851076 100644 --- a/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs +++ b/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs @@ -6,6 +6,7 @@ using backend.RuleOptions; using backend.Services.Analysis; using backend.Services.Comments; +using backend.Services.Rules; using Backend.Models; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; @@ -29,6 +30,18 @@ public void GetAvailableRules_WhenEmptySectionRuleIsAvailable_IncludesRule() Assert.Contains(EmptySectionStructureRule.RuleId, GetRuleNames(result)); } + [Fact] + public void GetAvailableRules_WhenEmptySectionSeverityIsConfigured_ReportsConfiguredSeverity() + { + var result = InvokeGetAvailableRules(new EmptySectionStructureRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, GetRuleDefaultSeverity(result, EmptySectionStructureRule.RuleId)); + } + [Fact] public void GetAvailableRules_WhenEmptySectionRuleIsHidden_ExcludesRule() { @@ -105,7 +118,7 @@ public void Validate_WhenConfiguredSeverityMatchesPreviousDefault_KeepsExistingE private static ValidationResult ValidateEmptySectionRule(EmptySectionStructureRuleOptions options) { - var rule = new EmptySectionStructureRule(Options.Create(options)); + var rule = new EmptySectionStructureRule(CreateRuleConfigurationService(options)); using var stream = CreateEmptySectionDocxStream(); using var doc = WordprocessingDocument.Open(stream, false); @@ -127,7 +140,7 @@ private static IResult InvokeGetAvailableRules(EmptySectionStructureRuleOptions CreateService( [new RecordingRule(EmptySectionStructureRule.RuleId), new RecordingRule("FontFamily")], options), - Options.Create(options) + CreateRuleConfigurationService(options) }); return Assert.IsAssignableFrom(result); @@ -137,10 +150,36 @@ private static ThesisValidatorService CreateService( IEnumerable rules, EmptySectionStructureRuleOptions options) { - return new ThesisValidatorService(rules, Options.Create(options)); + return new ThesisValidatorService(rules, CreateRuleConfigurationService(options)); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + EmptySectionStructureRuleOptions options) + { + return new RuleConfigurationService(Options.Create(options)); } private static IReadOnlyList GetRuleNames(IResult result) + { + return GetRules(result) + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private static string? GetRuleDefaultSeverity(IResult result, string ruleName) + { + return GetRules(result) + .Where(rule => string.Equals( + rule.GetType().GetProperty("Name")?.GetValue(rule) as string, + ruleName, + StringComparison.OrdinalIgnoreCase)) + .Select(rule => rule.GetType().GetProperty("DefaultSeverity")?.GetValue(rule) as string) + .SingleOrDefault(); + } + + private static IEnumerable GetRules(IResult result) { Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); @@ -150,12 +189,7 @@ private static IReadOnlyList GetRuleNames(IResult result) var rules = value.GetType().GetProperty("Rules")?.GetValue(value); Assert.NotNull(rules); - return ((IEnumerable)rules) - .Cast() - .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) - .Where(name => name is not null) - .Cast() - .ToList(); + return ((IEnumerable)rules).Cast(); } private static MemoryStream CreateEmptySectionDocxStream() diff --git a/backend/Endpoints/DocumentEndpoint.cs b/backend/Endpoints/DocumentEndpoint.cs index 42f4b77..eee90ce 100644 --- a/backend/Endpoints/DocumentEndpoint.cs +++ b/backend/Endpoints/DocumentEndpoint.cs @@ -1,12 +1,10 @@ using backend.Models; -using backend.Rules; -using backend.RuleOptions; using Backend.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using backend.Services.Analysis; using backend.Services.Exceptions; -using ThesisValidator.Rules; +using backend.Services.Rules; namespace backend.Endpoints; @@ -127,10 +125,11 @@ private static IResult ValidateWithComments( private static IResult GetAvailableRules( ThesisValidatorService thesisValidatorService, - IOptions emptySectionOptions) + IRuleConfigurationService ruleConfigurationService) { var ruleList = thesisValidatorService.GetAvailableRules() - .Where(rule => IsAvailableRule(rule, emptySectionOptions.Value)) + .Where(rule => ruleConfigurationService.IsRuleAvailable(rule.Id)) + .Select(ruleConfigurationService.ApplyConfiguration) .Select(rule => new { Name = rule.Id, @@ -145,14 +144,6 @@ private static IResult GetAvailableRules( return Results.Ok(new { Rules = ruleList, Count = ruleList.Count }); } - private static bool IsAvailableRule( - RuleDefinition rule, - EmptySectionStructureRuleOptions emptySectionOptions) - { - return !string.Equals(rule.Id, EmptySectionStructureRule.RuleId, StringComparison.OrdinalIgnoreCase) - || emptySectionOptions.Availability != RuleAvailability.Hidden; - } - private static IResult HealthCheck() { return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow }); diff --git a/backend/Program.cs b/backend/Program.cs index ca8118e..94e639a 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -5,6 +5,7 @@ using backend.Services.Analysis; using backend.Services.CodeBlocks; using backend.Services.Language; +using backend.Services.Rules; using Backend.Models; using ThesisValidator.Rules; @@ -41,6 +42,12 @@ .Bind(builder.Configuration.GetSection(EmptySectionStructureRuleOptions.SectionName)) .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(FontFamilyRuleOptions.SectionName)) + .ValidateOnStart(); + +builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddHttpClient(); diff --git a/backend/Rules/EmptySectionStructureRule.cs b/backend/Rules/EmptySectionStructureRule.cs index 84ac693..85c7396 100644 --- a/backend/Rules/EmptySectionStructureRule.cs +++ b/backend/Rules/EmptySectionStructureRule.cs @@ -10,6 +10,7 @@ using backend.Services.Comments; using backend.Services.Extraction; using backend.Services.Results; +using backend.Services.Rules; using backend.Services.Skipping; using backend.Services.Structure; @@ -25,11 +26,12 @@ public class EmptySectionStructureRule : IValidationRule { public const string RuleId = nameof(EmptySectionStructureRule); - private readonly EmptySectionStructureRuleOptions _options; + private readonly IRuleConfigurationService _ruleConfigurationService; - public EmptySectionStructureRule(IOptions? options = null) + public EmptySectionStructureRule(IRuleConfigurationService? ruleConfigurationService = null) { - _options = options?.Value ?? new EmptySectionStructureRuleOptions(); + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService(Options.Create(new EmptySectionStructureRuleOptions())); } public string Name => RuleId; @@ -39,7 +41,7 @@ public IEnumerable Validate( UniversityConfig config, DocumentCommentService? commentService = null) { - if (_options.Availability == RuleAvailability.Hidden) + if (!_ruleConfigurationService.IsRuleAvailable(Name)) return []; var errors = new List(); @@ -107,7 +109,7 @@ public IEnumerable Validate( lastHeadingParaIdx, lastHeadingPreview, ParagraphIndexKind.BodyElement); - result.Severity = _options.Severity.ToString(); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); errors.Add(result); if (lastHeadingParagraph is not null) diff --git a/backend/Services/Analysis/ThesisValidatorService.cs b/backend/Services/Analysis/ThesisValidatorService.cs index c7148bf..e7e6963 100644 --- a/backend/Services/Analysis/ThesisValidatorService.cs +++ b/backend/Services/Analysis/ThesisValidatorService.cs @@ -1,11 +1,11 @@ using backend.Models; using backend.Services.Comments; -using backend.Rules; -using backend.RuleOptions; using backend.Services.Exceptions; using backend.Services.Extraction; +using backend.Services.Rules; using backend.Services.Skipping; using backend.Services.Structure; +using backend.RuleOptions; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; @@ -18,15 +18,16 @@ public class ThesisValidatorService { private readonly IReadOnlyList _ruleList; private readonly IReadOnlySet _ruleNames; - private readonly EmptySectionStructureRuleOptions _emptySectionOptions; + private readonly IRuleConfigurationService _ruleConfigurationService; public ThesisValidatorService( IEnumerable rules, - IOptions? emptySectionOptions = null) + IRuleConfigurationService? ruleConfigurationService = null) { _ruleList = rules.ToList(); _ruleNames = _ruleList.Select(rule => rule.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); - _emptySectionOptions = emptySectionOptions?.Value ?? new EmptySectionStructureRuleOptions(); + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService(Options.Create(new EmptySectionStructureRuleOptions())); } public IReadOnlyList GetAvailableRuleNames() @@ -278,8 +279,7 @@ private IReadOnlyList FilterRules( private bool IsExecutableRule(IValidationRule rule) { - return !string.Equals(rule.Name, EmptySectionStructureRule.RuleId, StringComparison.OrdinalIgnoreCase) - || _emptySectionOptions.Availability != RuleAvailability.Hidden; + return _ruleConfigurationService.IsRuleAvailable(rule.Name); } } From 0f857eefb8e9f4e65c5143f23babef3d083651ec Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:14:57 +0200 Subject: [PATCH 11/77] feat(rules): implement dynamic configuration for font family rule --- .../Rules/FontFamilyRuleConfigurationTests.cs | 249 ++++++++++++++++++ .../Options/Rules/FontFamilyRuleOptions.cs | 8 + backend/Rules/FontFamilyRule.cs | 34 ++- backend/appsettings.json | 7 +- 4 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 backend.Tests/Rules/FontFamilyRuleConfigurationTests.cs create mode 100644 backend/Options/Rules/FontFamilyRuleOptions.cs diff --git a/backend.Tests/Rules/FontFamilyRuleConfigurationTests.cs b/backend.Tests/Rules/FontFamilyRuleConfigurationTests.cs new file mode 100644 index 0000000..f5adec8 --- /dev/null +++ b/backend.Tests/Rules/FontFamilyRuleConfigurationTests.cs @@ -0,0 +1,249 @@ +using System.Collections; +using System.Reflection; +using backend.Endpoints; +using backend.Models; +using backend.Rules; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Comments; +using backend.Services.Rules; +using backend.Tests.Helpers; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; + +namespace backend.Tests.Rules; + +public class FontFamilyRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenFontFamilyRuleIsAvailable_IncludesRule() + { + var result = InvokeGetAvailableRules(new FontFamilyRuleOptions + { + Availability = RuleAvailability.Available + }); + + Assert.Contains(FontFamilyValidationRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenFontFamilyRuleIsHidden_ExcludesRule() + { + var result = InvokeGetAvailableRules(new FontFamilyRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + Assert.DoesNotContain(FontFamilyValidationRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() + { + var rule = new RecordingRule(FontFamilyValidationRule.RuleId); + var service = CreateService( + [rule], + new FontFamilyRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + using var stream = CreateDocxStream(); + var (results, _) = service.Validate( + stream, + CreateConfig(), + [FontFamilyValidationRule.RuleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Fact] + public void Validate_WhenSeverityIsWarning_AppliesWarning() + { + var result = ValidateFontFamilyRule(new FontFamilyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenSeverityIsError_AppliesError() + { + var result = ValidateFontFamilyRule(new FontFamilyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WhenRequiredFontFamilyIsConfigured_UsesConfiguredFont() + { + var rule = new FontFamilyValidationRule( + ruleConfigurationService: CreateRuleConfigurationService(new FontFamilyRuleOptions()), + options: Options.Create(new FontFamilyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error, + RequiredFontFamily = "Arial" + })); + using var docx = DocxTestHelper.CreateInMemoryDocx(("Configured font paragraph", "Arial")); + + var results = rule.Validate(docx.Document, CreateConfig()).ToList(); + + Assert.Empty(results); + } + + [Fact] + public void Validate_WithSelectedRules_RunsFontFamilyWithoutRunningUnselectedRule() + { + var selectedRule = new RecordingRule(FontFamilyValidationRule.RuleId); + var unselectedRule = new RecordingRule("Grammar"); + var service = CreateService( + [selectedRule, unselectedRule], + new FontFamilyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + using var stream = CreateDocxStream(); + service.Validate(stream, CreateConfig(), [FontFamilyValidationRule.RuleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + private static ValidationResult ValidateFontFamilyRule(FontFamilyRuleOptions options) + { + var rule = new FontFamilyValidationRule( + ruleConfigurationService: CreateRuleConfigurationService(options), + options: Options.Create(options)); + using var docx = DocxTestHelper.CreateInMemoryDocx(("Wrong font paragraph", "Arial")); + + return Assert.Single(rule.Validate(docx.Document, CreateConfig())); + } + + private static IResult InvokeGetAvailableRules(FontFamilyRuleOptions options) + { + var method = typeof(DocumentEndpoint).GetMethod( + "GetAvailableRules", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method.Invoke( + null, + new object?[] + { + CreateService( + [new RecordingRule(FontFamilyValidationRule.RuleId), new RecordingRule("Grammar")], + options), + CreateRuleConfigurationService(options) + }); + + return Assert.IsAssignableFrom(result); + } + + private static ThesisValidatorService CreateService( + IEnumerable rules, + FontFamilyRuleOptions options) + { + return new ThesisValidatorService(rules, CreateRuleConfigurationService(options)); + } + + private static IRuleConfigurationService CreateRuleConfigurationService(FontFamilyRuleOptions options) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + Options.Create(options)); + } + + private static UniversityConfig CreateConfig() + { + return new UniversityConfig + { + Formatting = new FormattingConfig + { + Font = new FontConfig { FontFamily = "Times New Roman" } + } + }; + } + + private static MemoryStream CreateDocxStream() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Body text"))))); + mainPart.Document.Save(); + } + + stream.Position = 0; + return stream; + } + + private static IReadOnlyList GetRuleNames(IResult result) + { + return GetRules(result) + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private static IEnumerable GetRules(IResult result) + { + Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); + + var value = result.GetType().GetProperty("Value")?.GetValue(result); + Assert.NotNull(value); + + var rules = value.GetType().GetProperty("Rules")?.GetValue(value); + Assert.NotNull(rules); + + return ((IEnumerable)rules).Cast(); + } + + private sealed class RecordingRule : IValidationRule + { + public RecordingRule(string name) + { + Name = name; + } + + public string Name { get; } + + public int RunCount { get; private set; } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + RunCount++; + return + [ + new ValidationResult + { + RuleName = Name, + Message = "Executed" + } + ]; + } + } +} diff --git a/backend/Options/Rules/FontFamilyRuleOptions.cs b/backend/Options/Rules/FontFamilyRuleOptions.cs new file mode 100644 index 0000000..28df970 --- /dev/null +++ b/backend/Options/Rules/FontFamilyRuleOptions.cs @@ -0,0 +1,8 @@ +namespace backend.RuleOptions; + +public sealed class FontFamilyRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:FontFamily"; + + public string? RequiredFontFamily { get; set; } +} diff --git a/backend/Rules/FontFamilyRule.cs b/backend/Rules/FontFamilyRule.cs index 98044d3..524fd48 100644 --- a/backend/Rules/FontFamilyRule.cs +++ b/backend/Rules/FontFamilyRule.cs @@ -1,7 +1,9 @@ using backend.Models; +using backend.RuleOptions; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Analysis; using backend.Services.CodeBlocks; @@ -9,18 +11,33 @@ using backend.Services.Extraction; using backend.Services.Formatting; using backend.Services.Results; +using backend.Services.Rules; namespace backend.Rules; public class FontFamilyValidationRule : IValidationRule { + public const string RuleId = nameof(FontConfig.FontFamily); + private readonly ICodeBlockDetector _codeBlockDetector; + private readonly IRuleConfigurationService _ruleConfigurationService; + private readonly FontFamilyRuleOptions _options; - public string Name => nameof(FontConfig.FontFamily); + public string Name => RuleId; - public FontFamilyValidationRule(ICodeBlockDetector? codeBlockDetector = null) + public FontFamilyValidationRule( + ICodeBlockDetector? codeBlockDetector = null, + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) { + var fontFamilyOptions = options ?? Options.Create(new FontFamilyRuleOptions()); + _codeBlockDetector = codeBlockDetector ?? CodeBlockDetector.CreateDefault(); + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + fontFamilyOptions); + _options = fontFamilyOptions.Value; } public IEnumerable Validate(WordprocessingDocument doc, UniversityConfig config) @@ -33,7 +50,12 @@ public IEnumerable Validate( UniversityConfig config, DocumentCommentService? commentService) { - var expectedFont = config.Formatting.Font.FontFamily; + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + + var expectedFont = string.IsNullOrWhiteSpace(_options.RequiredFontFamily) + ? config.Formatting.Font.FontFamily + : _options.RequiredFontFamily.Trim(); var errors = new List(); foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.BodyParagraphs(doc, config)) @@ -74,7 +96,7 @@ private void ValidateParagraph( commentService?.AddCommentToRun(doc, run, message); - errors.Add(ValidationResultFactory.ForRun( + var result = ValidationResultFactory.ForRun( Name, config, message, @@ -83,7 +105,9 @@ private void ValidateParagraph( characterOffset, text.Length, TextExtractionService.Truncate(text, 50), - ParagraphIndexKind.BodyElement)); + ParagraphIndexKind.BodyElement); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); } } diff --git a/backend/appsettings.json b/backend/appsettings.json index 571089c..4371b36 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -9,8 +9,13 @@ "Validation": { "Rules": { "EmptySectionStructureRule": { - "Availability": "Hidden", + "Availability": "Available", "Severity": "Error" + }, + "FontFamily": { + "Availability": "Available", + "Severity": "Warning", + "RequiredFontFamily": "Arial" } }, "CodeBlockDetection": { From 32ee096a59a331c2eac52b63d212141850741cc2 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:14:58 +0200 Subject: [PATCH 12/77] refactor(rules): remove deprecated figure caption rule and unused font fixtures --- backend.Tests/Fixtures/Fonts/fonts - Copy.zip | Bin 13716 -> 0 bytes backend.Tests/Fixtures/Fonts/fonts.docx | Bin 17202 -> 0 bytes .../FigureCaptionAutomaticNumberingRule.cs | 54 ------------------ 3 files changed, 54 deletions(-) delete mode 100644 backend.Tests/Fixtures/Fonts/fonts - Copy.zip delete mode 100644 backend.Tests/Fixtures/Fonts/fonts.docx delete mode 100644 backend/Rules/FigureCaptionAutomaticNumberingRule.cs diff --git a/backend.Tests/Fixtures/Fonts/fonts - Copy.zip b/backend.Tests/Fixtures/Fonts/fonts - Copy.zip deleted file mode 100644 index b53d9cac8319be48e10ae787803c0d8b2215a3f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13716 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 Ks5@DH+xtI2q_M;R diff --git a/backend.Tests/Fixtures/Fonts/fonts.docx b/backend.Tests/Fixtures/Fonts/fonts.docx deleted file mode 100644 index c1f636bf617b8f19c45fcb66c9960f460e04809d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17202 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 Uhx;QnLIj`y8Gg`ztmpy%2UfL1761SM diff --git a/backend/Rules/FigureCaptionAutomaticNumberingRule.cs b/backend/Rules/FigureCaptionAutomaticNumberingRule.cs deleted file mode 100644 index 9bf2e71..0000000 --- a/backend/Rules/FigureCaptionAutomaticNumberingRule.cs +++ /dev/null @@ -1,54 +0,0 @@ -using backend.Models; -using backend.Services.Comments; -using backend.Services.Extraction; -using backend.Services.Results; -using backend.Services.Structure; -using Backend.Models; -using DocumentFormat.OpenXml.Packaging; -using ThesisValidator.Rules; - -namespace backend.Rules; - -/// -/// Warns when a visually valid figure caption appears to be manually numbered. -/// -public class FigureCaptionAutomaticNumberingRule : IValidationRule -{ - public string Name => "FigureCaptionAutomaticNumberingRule"; - - public IEnumerable Validate( - WordprocessingDocument doc, - UniversityConfig config, - DocumentCommentService? commentService = null) - { - var warnings = new List(); - - foreach (var caption in FigureCaptionDetector.GetDetectedFigureCaptions(doc, config)) - { - var text = caption.Text.Trim(); - if (!CaptionDetectionService.HasValidFigureCaptionFormat(text)) - continue; - - if (!caption.UsesDedicatedCaptionStyle) - continue; - - if (caption.HasFigureSequenceField) - continue; - - var message = - "Podpis rysunku wygl\u0105da na wpisany r\u0119cznie. Zalecane jest u\u017cycie funkcji Worda 'Wstaw podpis', aby numeracja rysunk\u00f3w by\u0142a automatyczna."; - warnings.Add(ValidationResultFactory.ForParagraph( - Name, - config, - message, - caption.ParagraphIndex, - TextExtractionService.Truncate(text, 50), - ParagraphIndexKind.Descendant, - ValidationSeverity.Warning)); - - commentService?.AddCommentToParagraph(doc, caption.Paragraph, message); - } - - return warnings; - } -} From 3e1defaabbbf84f4799e5817f13dfdd7b0593a39 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:31:15 +0200 Subject: [PATCH 13/77] feat(rules): implement dynamic configuration for heading style usage rule --- ...HeadingStyleUsageRuleConfigurationTests.cs | 274 ++++++++++++++++++ .../Rules/HeadingStyleUsageRuleOptions.cs | 9 + backend/Program.cs | 8 + backend/Rules/HeadingStyleUsageRule.cs | 37 ++- .../Rules/RuleConfigurationService.cs | 22 +- backend/appsettings.json | 8 +- 6 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 backend.Tests/Rules/HeadingStyleUsageRuleConfigurationTests.cs create mode 100644 backend/Options/Rules/HeadingStyleUsageRuleOptions.cs diff --git a/backend.Tests/Rules/HeadingStyleUsageRuleConfigurationTests.cs b/backend.Tests/Rules/HeadingStyleUsageRuleConfigurationTests.cs new file mode 100644 index 0000000..16d4d4d --- /dev/null +++ b/backend.Tests/Rules/HeadingStyleUsageRuleConfigurationTests.cs @@ -0,0 +1,274 @@ +using System.Collections; +using System.Reflection; +using backend.Endpoints; +using backend.Models; +using backend.Rules; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Comments; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; + +namespace backend.Tests.Rules; + +public class HeadingStyleUsageRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenHeadingStyleUsageRuleIsAvailable_IncludesRule() + { + var result = InvokeGetAvailableRules(new HeadingStyleUsageRuleOptions + { + Availability = RuleAvailability.Available + }); + + Assert.Contains(HeadingStyleUsageRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenHeadingStyleUsageRuleIsHidden_ExcludesRule() + { + var result = InvokeGetAvailableRules(new HeadingStyleUsageRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + Assert.DoesNotContain(HeadingStyleUsageRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() + { + var rule = new RecordingRule(HeadingStyleUsageRule.RuleId); + var service = CreateService( + [rule], + new HeadingStyleUsageRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + using var stream = CreateDocxStream(); + var (results, _) = service.Validate( + stream, + CreateConfig(), + [HeadingStyleUsageRule.RuleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Fact] + public void Validate_WhenSeverityIsWarning_AppliesWarning() + { + var result = ValidateHeadingStyleUsageRule(new HeadingStyleUsageRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenSeverityIsError_AppliesError() + { + var result = ValidateHeadingStyleUsageRule(new HeadingStyleUsageRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WithSelectedRules_RunsHeadingStyleUsageWithoutRunningUnselectedRule() + { + var selectedRule = new RecordingRule(HeadingStyleUsageRule.RuleId); + var unselectedRule = new RecordingRule("Grammar"); + var service = CreateService( + [selectedRule, unselectedRule], + new HeadingStyleUsageRuleOptions + { + Availability = RuleAvailability.Available + }); + + using var stream = CreateDocxStream(); + service.Validate(stream, CreateConfig(), [HeadingStyleUsageRule.RuleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + [Fact] + public void Validate_WhenFontSizeThresholdIsConfigured_UsesConfiguredThreshold() + { + var options = new HeadingStyleUsageRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error, + FontSizeThresholdAboveBodyPt = 4 + }; + var rule = CreateRule(options); + using var docx = CreateManualHeadingDocx(fontSizeHalfPoints: "28"); + + var results = rule.Validate(docx, CreateConfig()).ToList(); + + Assert.Empty(results); + } + + private static ValidationResult ValidateHeadingStyleUsageRule(HeadingStyleUsageRuleOptions options) + { + var rule = CreateRule(options); + using var docx = CreateManualHeadingDocx(fontSizeHalfPoints: "28"); + + return Assert.Single(rule.Validate(docx, CreateConfig())); + } + + private static HeadingStyleUsageRule CreateRule(HeadingStyleUsageRuleOptions options) + { + return new HeadingStyleUsageRule( + CreateRuleConfigurationService(options), + Options.Create(options)); + } + + private static IResult InvokeGetAvailableRules(HeadingStyleUsageRuleOptions options) + { + var method = typeof(DocumentEndpoint).GetMethod( + "GetAvailableRules", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method.Invoke( + null, + new object?[] + { + CreateService( + [new RecordingRule(HeadingStyleUsageRule.RuleId), new RecordingRule("Grammar")], + options), + CreateRuleConfigurationService(options) + }); + + return Assert.IsAssignableFrom(result); + } + + private static ThesisValidatorService CreateService( + IEnumerable rules, + HeadingStyleUsageRuleOptions options) + { + return new ThesisValidatorService(rules, CreateRuleConfigurationService(options)); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + HeadingStyleUsageRuleOptions options) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + headingStyleUsageOptions: Options.Create(options)); + } + + private static UniversityConfig CreateConfig() + { + return new UniversityConfig + { + Formatting = new FormattingConfig + { + Font = new FontConfig + { + FontFamily = "Times New Roman", + FontSize = 12 + } + } + }; + } + + private static MemoryStream CreateDocxStream() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Body text"))))); + mainPart.Document.Save(); + } + + stream.Position = 0; + return stream; + } + + private static WordprocessingDocument CreateManualHeadingDocx(string fontSizeHalfPoints) + { + var stream = new MemoryStream(); + var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document( + new Body( + new Paragraph( + new Run( + new RunProperties( + new Bold(), + new FontSize { Val = fontSizeHalfPoints }), + new Text("Manual heading"))))); + mainPart.Document.Save(); + stream.Position = 0; + return doc; + } + + private static IReadOnlyList GetRuleNames(IResult result) + { + return GetRules(result) + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private static IEnumerable GetRules(IResult result) + { + Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); + + var value = result.GetType().GetProperty("Value")?.GetValue(result); + Assert.NotNull(value); + + var rules = value.GetType().GetProperty("Rules")?.GetValue(value); + Assert.NotNull(rules); + + return ((IEnumerable)rules).Cast(); + } + + private sealed class RecordingRule : IValidationRule + { + public RecordingRule(string name) + { + Name = name; + } + + public string Name { get; } + + public int RunCount { get; private set; } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + RunCount++; + return + [ + new ValidationResult + { + RuleName = Name, + Message = "Executed" + } + ]; + } + } +} diff --git a/backend/Options/Rules/HeadingStyleUsageRuleOptions.cs b/backend/Options/Rules/HeadingStyleUsageRuleOptions.cs new file mode 100644 index 0000000..6b39c01 --- /dev/null +++ b/backend/Options/Rules/HeadingStyleUsageRuleOptions.cs @@ -0,0 +1,9 @@ +namespace backend.RuleOptions; + +public sealed class HeadingStyleUsageRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:HeadingStyleUsageRule"; + + public int FontSizeThresholdAboveBodyPt { get; set; } = 2; + public int MaxHeadingTextLength { get; set; } = 200; +} diff --git a/backend/Program.cs b/backend/Program.cs index 94e639a..90eea5f 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -46,6 +46,14 @@ .Bind(builder.Configuration.GetSection(FontFamilyRuleOptions.SectionName)) .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(HeadingStyleUsageRuleOptions.SectionName)) + .Validate(options => options.FontSizeThresholdAboveBodyPt >= 0, + "HeadingStyleUsageRule:FontSizeThresholdAboveBodyPt must be greater than or equal to 0.") + .Validate(options => options.MaxHeadingTextLength > 0, + "HeadingStyleUsageRule:MaxHeadingTextLength must be greater than 0.") + .ValidateOnStart(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/backend/Rules/HeadingStyleUsageRule.cs b/backend/Rules/HeadingStyleUsageRule.cs index 0b17023..b6dab34 100644 --- a/backend/Rules/HeadingStyleUsageRule.cs +++ b/backend/Rules/HeadingStyleUsageRule.cs @@ -1,13 +1,16 @@ using backend.Models; +using backend.RuleOptions; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Analysis; using backend.Services.Comments; using backend.Services.Extraction; using backend.Services.Formatting; using backend.Services.Results; +using backend.Services.Rules; using backend.Services.Skipping; using backend.Services.Structure; @@ -18,19 +21,37 @@ namespace backend.Rules; /// public class HeadingStyleUsageRule : IValidationRule { - public string Name => "HeadingStyleUsageRule"; + public const string RuleId = nameof(HeadingStyleUsageRule); - private const int FontSizeThresholdAboveBodyPt = 2; - private const int MaxHeadingTextLength = 200; + private readonly IRuleConfigurationService _ruleConfigurationService; + private readonly HeadingStyleUsageRuleOptions _options; + + public HeadingStyleUsageRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var headingOptions = options ?? Options.Create(new HeadingStyleUsageRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + headingStyleUsageOptions: headingOptions); + _options = headingOptions.Value; + } + + public string Name => RuleId; public IEnumerable Validate( WordprocessingDocument doc, UniversityConfig config, DocumentCommentService? commentService = null) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); var bodyFontSizePt = config.Formatting.Font.FontSize; - var thresholdPt = bodyFontSizePt + FontSizeThresholdAboveBodyPt; + var thresholdPt = bodyFontSizePt + _options.FontSizeThresholdAboveBodyPt; foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.BodyParagraphs(doc, config)) { @@ -42,7 +63,7 @@ public IEnumerable Validate( var text = TextExtractionService.GetParagraphText(doc, paragraph, config).Trim(); - if (string.IsNullOrWhiteSpace(text) || text.Length > MaxHeadingTextLength) + if (string.IsNullOrWhiteSpace(text) || text.Length > _options.MaxHeadingTextLength) continue; if (!LooksLikeManualHeading(doc, paragraph, config, thresholdPt)) @@ -54,13 +75,15 @@ public IEnumerable Validate( "apply a proper Heading style (Heading 1, Heading 2, etc.) " + "instead of manual bold/font-size formatting."; - errors.Add(ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, message, paragraphIndex, preview, - ParagraphIndexKind.BodyElement)); + ParagraphIndexKind.BodyElement); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); commentService?.AddCommentToParagraph(doc, paragraph, message); } diff --git a/backend/Services/Rules/RuleConfigurationService.cs b/backend/Services/Rules/RuleConfigurationService.cs index 46c1eed..fefe420 100644 --- a/backend/Services/Rules/RuleConfigurationService.cs +++ b/backend/Services/Rules/RuleConfigurationService.cs @@ -12,13 +12,16 @@ public sealed class RuleConfigurationService : IRuleConfigurationService { private readonly EmptySectionStructureRuleOptions _emptySectionOptions; private readonly FontFamilyRuleOptions _fontFamilyOptions; + private readonly HeadingStyleUsageRuleOptions _headingStyleUsageOptions; public RuleConfigurationService( IOptions emptySectionOptions, - IOptions? fontFamilyOptions = null) + IOptions? fontFamilyOptions = null, + IOptions? headingStyleUsageOptions = null) { _emptySectionOptions = emptySectionOptions.Value; _fontFamilyOptions = fontFamilyOptions?.Value ?? new FontFamilyRuleOptions(); + _headingStyleUsageOptions = headingStyleUsageOptions?.Value ?? new HeadingStyleUsageRuleOptions(); } public bool IsRuleAvailable(string ruleId) @@ -29,6 +32,9 @@ public bool IsRuleAvailable(string ruleId) if (IsFontFamilyRule(ruleId)) return _fontFamilyOptions.Availability != RuleAvailability.Hidden; + if (IsHeadingStyleUsageRule(ruleId)) + return _headingStyleUsageOptions.Availability != RuleAvailability.Hidden; + return true; } @@ -43,6 +49,9 @@ public string ResolveSeverity( if (IsFontFamilyRule(ruleId)) return ValidationSeverity.Normalize(_fontFamilyOptions.Severity.ToString()); + if (IsHeadingStyleUsageRule(ruleId)) + return ValidationSeverity.Normalize(_headingStyleUsageOptions.Severity.ToString()); + return SeverityResolver.Resolve(ruleId, config, explicitSeverity); } @@ -54,6 +63,9 @@ public RuleDefinition ApplyConfiguration(RuleDefinition definition) if (IsFontFamilyRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_fontFamilyOptions.Severity.ToString()) }; + if (IsHeadingStyleUsageRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_headingStyleUsageOptions.Severity.ToString()) }; + return definition; } @@ -72,4 +84,12 @@ private static bool IsFontFamilyRule(string ruleId) FontFamilyValidationRule.RuleId, StringComparison.OrdinalIgnoreCase); } + + private static bool IsHeadingStyleUsageRule(string ruleId) + { + return string.Equals( + ruleId, + HeadingStyleUsageRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } } diff --git a/backend/appsettings.json b/backend/appsettings.json index 4371b36..69a0940 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -15,7 +15,13 @@ "FontFamily": { "Availability": "Available", "Severity": "Warning", - "RequiredFontFamily": "Arial" + "RequiredFontFamily": "New Times Roman" + }, + "HeadingStyleUsageRule": { + "Availability": "Available", + "Severity": "Warning", + "FontSizeThresholdAboveBodyPt": 2, + "MaxHeadingTextLength": 200 } }, "CodeBlockDetection": { From f89a82cea757d848b0a42de39c0ab8c3045db8d0 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:38:35 +0200 Subject: [PATCH 14/77] feat(rules): add HierarchyDepthRule with configuration options and validation tests --- .../HierarchyDepthRuleConfigurationTests.cs | 256 ++++++++++++++++++ .../Rules/HierarchyDepthRuleOptions.cs | 8 + backend/Program.cs | 6 + backend/Rules/HierarchyDepthRule.cs | 36 ++- .../Rules/RuleConfigurationService.cs | 23 +- backend/appsettings.json | 5 + 6 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 backend.Tests/Rules/HierarchyDepthRuleConfigurationTests.cs create mode 100644 backend/Options/Rules/HierarchyDepthRuleOptions.cs diff --git a/backend.Tests/Rules/HierarchyDepthRuleConfigurationTests.cs b/backend.Tests/Rules/HierarchyDepthRuleConfigurationTests.cs new file mode 100644 index 0000000..d1ee2da --- /dev/null +++ b/backend.Tests/Rules/HierarchyDepthRuleConfigurationTests.cs @@ -0,0 +1,256 @@ +using System.Collections; +using System.Reflection; +using backend.Endpoints; +using backend.Models; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Comments; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Rules; +using ThesisValidator.Rules; + +namespace backend.Tests.Rules; + +public class HierarchyDepthRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenHierarchyDepthRuleIsAvailable_IncludesRule() + { + var result = InvokeGetAvailableRules(new HierarchyDepthRuleOptions + { + Availability = RuleAvailability.Available + }); + + Assert.Contains(HierarchyDepthRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenHierarchyDepthRuleIsHidden_ExcludesRule() + { + var result = InvokeGetAvailableRules(new HierarchyDepthRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + Assert.DoesNotContain(HierarchyDepthRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() + { + var rule = new RecordingRule(HierarchyDepthRule.RuleId); + var service = CreateService( + [rule], + new HierarchyDepthRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + using var stream = CreateDocxStream(); + var (results, _) = service.Validate( + stream, + new UniversityConfig(), + [HierarchyDepthRule.RuleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Fact] + public void Validate_WhenSeverityIsWarning_AppliesWarning() + { + var result = ValidateHierarchyDepthRule(new HierarchyDepthRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenSeverityIsError_AppliesError() + { + var result = ValidateHierarchyDepthRule(new HierarchyDepthRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WithSelectedRules_RunsHierarchyDepthWithoutRunningUnselectedRule() + { + var selectedRule = new RecordingRule(HierarchyDepthRule.RuleId); + var unselectedRule = new RecordingRule("Grammar"); + var service = CreateService( + [selectedRule, unselectedRule], + new HierarchyDepthRuleOptions + { + Availability = RuleAvailability.Available + }); + + using var stream = CreateDocxStream(); + service.Validate(stream, new UniversityConfig(), [HierarchyDepthRule.RuleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + [Fact] + public void Validate_WhenMaxAllowedLevelIsConfigured_UsesConfiguredLevel() + { + var options = new HierarchyDepthRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error, + MaxAllowedLevel = 4 + }; + var rule = CreateRule(options); + using var docx = CreateHeadingDocx("Heading4"); + + var results = rule.Validate(docx, new UniversityConfig()).ToList(); + + Assert.Empty(results); + } + + private static ValidationResult ValidateHierarchyDepthRule(HierarchyDepthRuleOptions options) + { + var rule = CreateRule(options); + using var docx = CreateHeadingDocx("Heading4"); + + return Assert.Single(rule.Validate(docx, new UniversityConfig())); + } + + private static HierarchyDepthRule CreateRule(HierarchyDepthRuleOptions options) + { + return new HierarchyDepthRule( + CreateRuleConfigurationService(options), + Options.Create(options)); + } + + private static IResult InvokeGetAvailableRules(HierarchyDepthRuleOptions options) + { + var method = typeof(DocumentEndpoint).GetMethod( + "GetAvailableRules", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method.Invoke( + null, + new object?[] + { + CreateService( + [new RecordingRule(HierarchyDepthRule.RuleId), new RecordingRule("Grammar")], + options), + CreateRuleConfigurationService(options) + }); + + return Assert.IsAssignableFrom(result); + } + + private static ThesisValidatorService CreateService( + IEnumerable rules, + HierarchyDepthRuleOptions options) + { + return new ThesisValidatorService(rules, CreateRuleConfigurationService(options)); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + HierarchyDepthRuleOptions options) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + hierarchyDepthOptions: Options.Create(options)); + } + + private static MemoryStream CreateDocxStream() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Body text"))))); + mainPart.Document.Save(); + } + + stream.Position = 0; + return stream; + } + + private static WordprocessingDocument CreateHeadingDocx(string styleId) + { + var stream = new MemoryStream(); + var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document( + new Body( + new Paragraph( + new ParagraphProperties(new ParagraphStyleId { Val = styleId }), + new Run(new Text("Too deep heading"))))); + mainPart.Document.Save(); + stream.Position = 0; + return doc; + } + + private static IReadOnlyList GetRuleNames(IResult result) + { + return GetRules(result) + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private static IEnumerable GetRules(IResult result) + { + Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); + + var value = result.GetType().GetProperty("Value")?.GetValue(result); + Assert.NotNull(value); + + var rules = value.GetType().GetProperty("Rules")?.GetValue(value); + Assert.NotNull(rules); + + return ((IEnumerable)rules).Cast(); + } + + private sealed class RecordingRule : IValidationRule + { + public RecordingRule(string name) + { + Name = name; + } + + public string Name { get; } + + public int RunCount { get; private set; } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + RunCount++; + return + [ + new ValidationResult + { + RuleName = Name, + Message = "Executed" + } + ]; + } + } +} diff --git a/backend/Options/Rules/HierarchyDepthRuleOptions.cs b/backend/Options/Rules/HierarchyDepthRuleOptions.cs new file mode 100644 index 0000000..3d0b7ae --- /dev/null +++ b/backend/Options/Rules/HierarchyDepthRuleOptions.cs @@ -0,0 +1,8 @@ +namespace backend.RuleOptions; + +public sealed class HierarchyDepthRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:HierarchyDepthRule"; + + public int MaxAllowedLevel { get; set; } = 3; +} diff --git a/backend/Program.cs b/backend/Program.cs index 90eea5f..2c1fb97 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -54,6 +54,12 @@ "HeadingStyleUsageRule:MaxHeadingTextLength must be greater than 0.") .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(HierarchyDepthRuleOptions.SectionName)) + .Validate(options => options.MaxAllowedLevel > 0, + "HierarchyDepthRule:MaxAllowedLevel must be greater than 0.") + .ValidateOnStart(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/backend/Rules/HierarchyDepthRule.cs b/backend/Rules/HierarchyDepthRule.cs index 3897bb2..31ac4d5 100644 --- a/backend/Rules/HierarchyDepthRule.cs +++ b/backend/Rules/HierarchyDepthRule.cs @@ -1,7 +1,10 @@ using backend.Models; +using backend.RuleOptions; +using backend.Services.Rules; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Analysis; using backend.Services.Comments; @@ -17,30 +20,51 @@ namespace Rules; /// public class HierarchyDepthRule : IValidationRule { - private const int MaxAllowedLevel = 3; + public const string RuleId = nameof(HierarchyDepthRule); - public string Name => "HierarchyDepthRule"; + private readonly IRuleConfigurationService _ruleConfigurationService; + private readonly HierarchyDepthRuleOptions _options; + + public HierarchyDepthRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var hierarchyOptions = options ?? Options.Create(new HierarchyDepthRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + hierarchyDepthOptions: hierarchyOptions); + _options = hierarchyOptions.Value; + } + + public string Name => RuleId; public IEnumerable Validate(WordprocessingDocument doc, UniversityConfig config, DocumentCommentService? documentCommentService = null) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.DescendantParagraphs(doc, config)) { var level = HeadingDetectionService.GetHeadingLevel(doc, paragraph); - if (level is null || level <= MaxAllowedLevel) + if (level is null || level <= _options.MaxAllowedLevel) continue; var text = TextExtractionService.GetParagraphText(doc, paragraph, config); var preview = TextExtractionService.Truncate(text, 60); - var errorMessage = $"Structure too deep. Detected Level {level}, but maximum allowed is {MaxAllowedLevel}."; + var errorMessage = $"Structure too deep. Detected Level {level}, but maximum allowed is {_options.MaxAllowedLevel}."; - errors.Add(ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, errorMessage, paragraphIndex, - preview)); + preview); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); documentCommentService?.AddCommentToParagraph(doc, paragraph, errorMessage); } diff --git a/backend/Services/Rules/RuleConfigurationService.cs b/backend/Services/Rules/RuleConfigurationService.cs index fefe420..4a97b6e 100644 --- a/backend/Services/Rules/RuleConfigurationService.cs +++ b/backend/Services/Rules/RuleConfigurationService.cs @@ -4,6 +4,7 @@ using backend.Services.Results; using Backend.Models; using Microsoft.Extensions.Options; +using Rules; using ThesisValidator.Rules; namespace backend.Services.Rules; @@ -13,15 +14,18 @@ public sealed class RuleConfigurationService : IRuleConfigurationService private readonly EmptySectionStructureRuleOptions _emptySectionOptions; private readonly FontFamilyRuleOptions _fontFamilyOptions; private readonly HeadingStyleUsageRuleOptions _headingStyleUsageOptions; + private readonly HierarchyDepthRuleOptions _hierarchyDepthOptions; public RuleConfigurationService( IOptions emptySectionOptions, IOptions? fontFamilyOptions = null, - IOptions? headingStyleUsageOptions = null) + IOptions? headingStyleUsageOptions = null, + IOptions? hierarchyDepthOptions = null) { _emptySectionOptions = emptySectionOptions.Value; _fontFamilyOptions = fontFamilyOptions?.Value ?? new FontFamilyRuleOptions(); _headingStyleUsageOptions = headingStyleUsageOptions?.Value ?? new HeadingStyleUsageRuleOptions(); + _hierarchyDepthOptions = hierarchyDepthOptions?.Value ?? new HierarchyDepthRuleOptions(); } public bool IsRuleAvailable(string ruleId) @@ -35,6 +39,9 @@ public bool IsRuleAvailable(string ruleId) if (IsHeadingStyleUsageRule(ruleId)) return _headingStyleUsageOptions.Availability != RuleAvailability.Hidden; + if (IsHierarchyDepthRule(ruleId)) + return _hierarchyDepthOptions.Availability != RuleAvailability.Hidden; + return true; } @@ -52,6 +59,9 @@ public string ResolveSeverity( if (IsHeadingStyleUsageRule(ruleId)) return ValidationSeverity.Normalize(_headingStyleUsageOptions.Severity.ToString()); + if (IsHierarchyDepthRule(ruleId)) + return ValidationSeverity.Normalize(_hierarchyDepthOptions.Severity.ToString()); + return SeverityResolver.Resolve(ruleId, config, explicitSeverity); } @@ -66,6 +76,9 @@ public RuleDefinition ApplyConfiguration(RuleDefinition definition) if (IsHeadingStyleUsageRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_headingStyleUsageOptions.Severity.ToString()) }; + if (IsHierarchyDepthRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_hierarchyDepthOptions.Severity.ToString()) }; + return definition; } @@ -92,4 +105,12 @@ private static bool IsHeadingStyleUsageRule(string ruleId) HeadingStyleUsageRule.RuleId, StringComparison.OrdinalIgnoreCase); } + + private static bool IsHierarchyDepthRule(string ruleId) + { + return string.Equals( + ruleId, + HierarchyDepthRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } } diff --git a/backend/appsettings.json b/backend/appsettings.json index 69a0940..2421a36 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -22,6 +22,11 @@ "Severity": "Warning", "FontSizeThresholdAboveBodyPt": 2, "MaxHeadingTextLength": 200 + }, + "HierarchyDepthRule": { + "Availability": "Available", + "Severity": "Error", + "MaxAllowedLevel": 3 } }, "CodeBlockDetection": { From 8d3050afac8ea22c0ea51c15f5d9c0ccde25c09b Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:12:34 +0200 Subject: [PATCH 15/77] feat(rules): add LineSpacingDependencyRule with configuration options and validation tests --- ...SpacingDependencyRuleConfigurationTests.cs | 272 ++++++++++++++++++ .../Rules/LineSpacingDependencyRuleTests.cs | 63 ++-- .../FormattingResolutionServiceTests.cs | 40 ++- .../Rules/LineSpacingDependencyRuleOptions.cs | 8 + backend/Program.cs | 6 + backend/Rules/LineSpacingDependencyRule.cs | 88 ++++-- .../Rules/RuleConfigurationService.cs | 22 +- backend/appsettings.json | 5 + 8 files changed, 452 insertions(+), 52 deletions(-) create mode 100644 backend.Tests/Rules/LineSpacingDependencyRuleConfigurationTests.cs create mode 100644 backend/Options/Rules/LineSpacingDependencyRuleOptions.cs diff --git a/backend.Tests/Rules/LineSpacingDependencyRuleConfigurationTests.cs b/backend.Tests/Rules/LineSpacingDependencyRuleConfigurationTests.cs new file mode 100644 index 0000000..9427f3f --- /dev/null +++ b/backend.Tests/Rules/LineSpacingDependencyRuleConfigurationTests.cs @@ -0,0 +1,272 @@ +using System.Collections; +using System.Reflection; +using backend.Endpoints; +using backend.Models; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Comments; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Rules; +using ThesisValidator.Rules; + +namespace backend.Tests.Rules; + +public class LineSpacingDependencyRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenLineSpacingDependencyRuleIsAvailable_IncludesRule() + { + var result = InvokeGetAvailableRules(new LineSpacingDependencyRuleOptions + { + Availability = RuleAvailability.Available + }); + + Assert.Contains(LineSpacingDependencyRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenLineSpacingDependencyRuleIsHidden_ExcludesRule() + { + var result = InvokeGetAvailableRules(new LineSpacingDependencyRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + Assert.DoesNotContain(LineSpacingDependencyRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() + { + var rule = new RecordingRule(LineSpacingDependencyRule.RuleId); + var service = CreateService( + [rule], + new LineSpacingDependencyRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + using var stream = CreateDocxStream(); + var (results, _) = service.Validate( + stream, + new UniversityConfig(), + [LineSpacingDependencyRule.RuleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Fact] + public void Validate_WhenSeverityIsWarning_AppliesWarning() + { + var result = ValidateLineSpacingDependencyRule(new LineSpacingDependencyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenSeverityIsError_AppliesError() + { + var result = ValidateLineSpacingDependencyRule(new LineSpacingDependencyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WithSelectedRules_RunsLineSpacingDependencyWithoutRunningUnselectedRule() + { + var selectedRule = new RecordingRule(LineSpacingDependencyRule.RuleId); + var unselectedRule = new RecordingRule("Grammar"); + var service = CreateService( + [selectedRule, unselectedRule], + new LineSpacingDependencyRuleOptions + { + Availability = RuleAvailability.Available + }); + + using var stream = CreateDocxStream(); + service.Validate(stream, new UniversityConfig(), [LineSpacingDependencyRule.RuleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + [Fact] + public void Validate_WhenTargetLineSpacingIsConfigured_UsesConfiguredLineSpacing() + { + var options = new LineSpacingDependencyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error, + TargetLineSpacingTwips = 240 + }; + var rule = CreateRule(options); + using var docx = CreateDocxWithLineSpacing(240, LineSpacingRuleValues.Auto, null, 120); + + var results = rule.Validate(docx, new UniversityConfig(), null).ToList(); + + Assert.Empty(results); + } + + private static ValidationResult ValidateLineSpacingDependencyRule(LineSpacingDependencyRuleOptions options) + { + var rule = CreateRule(options); + using var docx = CreateDocxWithLineSpacing(240, LineSpacingRuleValues.Auto, null, 120); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static LineSpacingDependencyRule CreateRule(LineSpacingDependencyRuleOptions options) + { + return new LineSpacingDependencyRule( + ruleConfigurationService: CreateRuleConfigurationService(options), + options: Options.Create(options)); + } + + private static IResult InvokeGetAvailableRules(LineSpacingDependencyRuleOptions options) + { + var method = typeof(DocumentEndpoint).GetMethod( + "GetAvailableRules", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method.Invoke( + null, + new object?[] + { + CreateService( + [new RecordingRule(LineSpacingDependencyRule.RuleId), new RecordingRule("Grammar")], + options), + CreateRuleConfigurationService(options) + }); + + return Assert.IsAssignableFrom(result); + } + + private static ThesisValidatorService CreateService( + IEnumerable rules, + LineSpacingDependencyRuleOptions options) + { + return new ThesisValidatorService(rules, CreateRuleConfigurationService(options)); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + LineSpacingDependencyRuleOptions options) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + lineSpacingDependencyOptions: Options.Create(options)); + } + + private static MemoryStream CreateDocxStream() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Body text"))))); + mainPart.Document.Save(); + } + + stream.Position = 0; + return stream; + } + + private static WordprocessingDocument CreateDocxWithLineSpacing( + int lineValue, + LineSpacingRuleValues? lineRule, + int? beforeTwips, + int? afterTwips) + { + var stream = new MemoryStream(); + var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body()); + + var spacing = new SpacingBetweenLines { Line = lineValue.ToString() }; + + if (lineRule.HasValue) + spacing.LineRule = lineRule.Value; + + if (beforeTwips.HasValue) + spacing.Before = beforeTwips.Value.ToString(); + + if (afterTwips.HasValue) + spacing.After = afterTwips.Value.ToString(); + + mainPart.Document.Body!.Append( + new Paragraph( + new ParagraphProperties(spacing), + new Run(new Text("Test paragraph")))); + mainPart.Document.Save(); + stream.Position = 0; + return doc; + } + + private static IReadOnlyList GetRuleNames(IResult result) + { + return GetRules(result) + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private static IEnumerable GetRules(IResult result) + { + Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); + + var value = result.GetType().GetProperty("Value")?.GetValue(result); + Assert.NotNull(value); + + var rules = value.GetType().GetProperty("Rules")?.GetValue(value); + Assert.NotNull(rules); + + return ((IEnumerable)rules).Cast(); + } + + private sealed class RecordingRule : IValidationRule + { + public RecordingRule(string name) + { + Name = name; + } + + public string Name { get; } + + public int RunCount { get; private set; } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + RunCount++; + return + [ + new ValidationResult + { + RuleName = Name, + Message = "Executed" + } + ]; + } + } +} diff --git a/backend.Tests/Rules/LineSpacingDependencyRuleTests.cs b/backend.Tests/Rules/LineSpacingDependencyRuleTests.cs index 64582b2..f160190 100644 --- a/backend.Tests/Rules/LineSpacingDependencyRuleTests.cs +++ b/backend.Tests/Rules/LineSpacingDependencyRuleTests.cs @@ -14,7 +14,7 @@ public class LineSpacingDependencyRuleTests private static UniversityConfig CreateConfig() => new(); private static InMemoryDocx CreateDocxWithLineSpacing( - int lineValue, + int? lineValue, LineSpacingRuleValues? lineRule, int? beforeTwips, int? afterTwips, @@ -26,7 +26,10 @@ private static InMemoryDocx CreateDocxWithLineSpacing( var mainPart = doc.AddMainDocumentPart(); mainPart.Document = new Document(new Body()); - var spacing = new SpacingBetweenLines { Line = lineValue.ToString() }; + var spacing = new SpacingBetweenLines(); + + if (lineValue.HasValue) + spacing.Line = lineValue.Value.ToString(); if (lineRule.HasValue) spacing.LineRule = lineRule.Value; @@ -54,26 +57,23 @@ private static InMemoryDocx CreateDocxWithLineSpacing( } [Fact] - public void LineSpacing15_WithSpacingAfter_ReturnsError() + public void LineSpacing15_WithSpacingAfter_ReturnsNoErrors() { using var docx = CreateDocxWithLineSpacing(360, LineSpacingRuleValues.Auto, null, 4000); var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); - Assert.Single(errors); - Assert.Contains("1.5 line spacing", errors[0].Message); - Assert.Contains("After=200", errors[0].Message); + Assert.Empty(errors); } [Fact] - public void LineSpacing15_WithSpacingBefore_ReturnsError() + public void LineSpacing15_WithSpacingBefore_ReturnsNoErrors() { using var docx = CreateDocxWithLineSpacing(360, LineSpacingRuleValues.Auto, 120, 0); var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); - Assert.Single(errors); - Assert.Contains("Before=6", errors[0].Message); + Assert.Empty(errors); } [Fact] @@ -97,40 +97,64 @@ public void LineSpacing15_WithNullSpacing_ReturnsNoErrors() } [Fact] - public void SingleLineSpacing_WithSpacingAfter_ReturnsNoErrors() + public void SingleLineSpacing_WithSpacingAfter_ReturnsError() { using var docx = CreateDocxWithLineSpacing(240, LineSpacingRuleValues.Auto, null, 120); var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); - Assert.Empty(errors); + Assert.Single(errors); + Assert.Contains("line spacing must be 1.5", errors[0].Message); + Assert.Contains("Found: 1.0", errors[0].Message); } [Fact] - public void DoubleLineSpacing_WithSpacingAfter_ReturnsNoErrors() + public void DoubleLineSpacing_WithSpacingAfter_ReturnsError() { using var docx = CreateDocxWithLineSpacing(480, LineSpacingRuleValues.Auto, null, 120); var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); - Assert.Empty(errors); + Assert.Single(errors); + Assert.Contains("Found: 2.0", errors[0].Message); } [Fact] - public void LineSpacing15_With6ptSpacing_ReturnsError() + public void LineSpacing15_With6ptSpacing_ReturnsNoErrors() { using var docx = CreateDocxWithLineSpacing(360, LineSpacingRuleValues.Auto, null, 120); var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + Assert.Empty(errors); + } + + [Fact] + public void MissingLineSpacing_ReturnsError() + { + using var docx = CreateDocxWithLineSpacing(null, null, null, null); + + var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + Assert.Single(errors); - Assert.Contains("After=6", errors[0].Message); + Assert.Contains("Found: not set", errors[0].Message); + } + + [Fact] + public void ExactLineRule_ReturnsError() + { + using var docx = CreateDocxWithLineSpacing(360, LineSpacingRuleValues.Exact, null, null); + + var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + + Assert.Single(errors); + Assert.Contains("Exact line rule", errors[0].Message); } [Fact] public void ExcludedStylePattern_ReturnsNoErrors() { - using var docx = CreateDocxWithLineSpacing(360, LineSpacingRuleValues.Auto, null, 120, "Caption"); + using var docx = CreateDocxWithLineSpacing(240, LineSpacingRuleValues.Auto, null, 120, "Caption"); var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); @@ -138,12 +162,11 @@ public void ExcludedStylePattern_ReturnsNoErrors() } [Fact] - public void HelloDocx_ListStyleParagraphsAreExcluded() + public void ExcludedListStyleParagraphsAreSkipped() { - using var doc = WordprocessingDocument.Open( - @"C:\Users\envv\Documents\GitHub\thesis-validator\backend.Tests\Fixtures\hello.docx", false); + using var docx = CreateDocxWithLineSpacing(240, LineSpacingRuleValues.Auto, null, 120, "ListParagraph"); - var errors = _rule.Validate(doc, CreateConfig(), null).ToList(); + var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } diff --git a/backend.Tests/Services/FormattingResolutionServiceTests.cs b/backend.Tests/Services/FormattingResolutionServiceTests.cs index 44d1b73..c4e41ed 100644 --- a/backend.Tests/Services/FormattingResolutionServiceTests.cs +++ b/backend.Tests/Services/FormattingResolutionServiceTests.cs @@ -15,12 +15,20 @@ public void ResolveFormatting_UsesDirectParagraphProperties() new Paragraph( new ParagraphProperties( new Justification { Val = JustificationValues.Center }, - new SpacingBetweenLines { After = "120" }), + new SpacingBetweenLines + { + After = "120", + Line = "360", + LineRule = LineSpacingRuleValues.Auto + }), new Run(new Text("Body")))); var paragraph = GetOnlyParagraph(docx.Document); + var (lineSpacing, lineRule) = FormattingResolutionService.ResolveLineSpacing(docx.Document, paragraph); Assert.Equal(JustificationValues.Center, FormattingResolutionService.ResolveJustification(docx.Document, paragraph)); Assert.Equal(120, FormattingResolutionService.ResolveSpacingAfter(docx.Document, paragraph)); + Assert.Equal(360, lineSpacing); + Assert.Equal(LineSpacingRuleValues.Auto, lineRule); } [Fact] @@ -31,7 +39,12 @@ public void ResolveFormatting_WalksBasedOnStyleChain() new Style( new StyleParagraphProperties( new Justification { Val = JustificationValues.Both }, - new SpacingBetweenLines { After = "120" }, + new SpacingBetweenLines + { + After = "120", + Line = "360", + LineRule = LineSpacingRuleValues.Auto + }, new Indentation { FirstLine = "567" }), new StyleRunProperties(new FontSize { Val = "28" })) { @@ -49,6 +62,9 @@ public void ResolveFormatting_WalksBasedOnStyleChain() Assert.Equal(JustificationValues.Both, FormattingResolutionService.ResolveJustification(docx.Document, paragraph)); Assert.Equal(120, FormattingResolutionService.ResolveSpacingAfter(docx.Document, paragraph)); + var (lineSpacing, lineRule) = FormattingResolutionService.ResolveLineSpacing(docx.Document, paragraph); + Assert.Equal(360, lineSpacing); + Assert.Equal(LineSpacingRuleValues.Auto, lineRule); Assert.Equal(567, FormattingResolutionService.ResolveFirstLineIndent(docx.Document, paragraph)); Assert.Equal(14.0, FormattingResolutionService.ResolveFontSizePt(docx.Document, paragraph, run)); } @@ -59,7 +75,12 @@ public void ResolveFormatting_UsesDefaultParagraphStyle() using var docx = CreateDocxWithStyles( new Styles( new Style( - new StyleParagraphProperties(new SpacingBetweenLines { After = "240" })) + new StyleParagraphProperties(new SpacingBetweenLines + { + After = "240", + Line = "360", + LineRule = LineSpacingRuleValues.Auto + })) { Type = StyleValues.Paragraph, Default = true, @@ -69,6 +90,9 @@ public void ResolveFormatting_UsesDefaultParagraphStyle() var paragraph = GetOnlyParagraph(docx.Document); Assert.Equal(240, FormattingResolutionService.ResolveSpacingAfter(docx.Document, paragraph)); + var (lineSpacing, lineRule) = FormattingResolutionService.ResolveLineSpacing(docx.Document, paragraph); + Assert.Equal(360, lineSpacing); + Assert.Equal(LineSpacingRuleValues.Auto, lineRule); } [Fact] @@ -82,7 +106,12 @@ public void ResolveFormatting_UsesDocumentDefaults() new ParagraphPropertiesDefault( new ParagraphPropertiesBaseStyle( new Justification { Val = JustificationValues.Both }, - new SpacingBetweenLines { After = "80" })))), + new SpacingBetweenLines + { + After = "80", + Line = "360", + LineRule = LineSpacingRuleValues.Auto + })))), styleId: null); var paragraph = GetOnlyParagraph(docx.Document); var run = paragraph.Elements().Single(); @@ -90,6 +119,9 @@ public void ResolveFormatting_UsesDocumentDefaults() Assert.Equal(12.0, FormattingResolutionService.ResolveFontSizePt(docx.Document, paragraph, run)); Assert.Equal(JustificationValues.Both, FormattingResolutionService.ResolveJustification(docx.Document, paragraph)); Assert.Equal(80, FormattingResolutionService.ResolveSpacingAfter(docx.Document, paragraph)); + var (lineSpacing, lineRule) = FormattingResolutionService.ResolveLineSpacing(docx.Document, paragraph); + Assert.Equal(360, lineSpacing); + Assert.Equal(LineSpacingRuleValues.Auto, lineRule); } private static Paragraph GetOnlyParagraph(WordprocessingDocument doc) diff --git a/backend/Options/Rules/LineSpacingDependencyRuleOptions.cs b/backend/Options/Rules/LineSpacingDependencyRuleOptions.cs new file mode 100644 index 0000000..718ce5a --- /dev/null +++ b/backend/Options/Rules/LineSpacingDependencyRuleOptions.cs @@ -0,0 +1,8 @@ +namespace backend.RuleOptions; + +public sealed class LineSpacingDependencyRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:LineSpacingDependencyRule"; + + public int TargetLineSpacingTwips { get; set; } = 360; +} diff --git a/backend/Program.cs b/backend/Program.cs index 2c1fb97..30f1767 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -60,6 +60,12 @@ "HierarchyDepthRule:MaxAllowedLevel must be greater than 0.") .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(LineSpacingDependencyRuleOptions.SectionName)) + .Validate(options => options.TargetLineSpacingTwips > 0, + "LineSpacingDependencyRule:TargetLineSpacingTwips must be greater than 0.") + .ValidateOnStart(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/backend/Rules/LineSpacingDependencyRule.cs b/backend/Rules/LineSpacingDependencyRule.cs index ff15f73..f7c2774 100644 --- a/backend/Rules/LineSpacingDependencyRule.cs +++ b/backend/Rules/LineSpacingDependencyRule.cs @@ -1,7 +1,10 @@ using backend.Models; +using backend.RuleOptions; +using backend.Services.Rules; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Analysis; using backend.Services.CodeBlocks; @@ -15,19 +18,31 @@ namespace Rules; /// -/// Rule #11: If line spacing is 1.5, then paragraph spacing before and after must be 0. +/// Rule #11: Body text must use the configured line spacing. /// public class LineSpacingDependencyRule : IValidationRule { - private readonly ICodeBlockDetector _codeBlockDetector; + public const string RuleId = nameof(LineSpacingDependencyRule); - private const int LineSpacing15 = 360; + private readonly ICodeBlockDetector _codeBlockDetector; + private readonly IRuleConfigurationService _ruleConfigurationService; + private readonly LineSpacingDependencyRuleOptions _options; - public string Name => "LineSpacingDependencyRule"; + public string Name => RuleId; - public LineSpacingDependencyRule(ICodeBlockDetector? codeBlockDetector = null) + public LineSpacingDependencyRule( + ICodeBlockDetector? codeBlockDetector = null, + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) { + var lineSpacingOptions = options ?? Options.Create(new LineSpacingDependencyRuleOptions()); + _codeBlockDetector = codeBlockDetector ?? CodeBlockDetector.CreateDefault(); + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + lineSpacingDependencyOptions: lineSpacingOptions); + _options = lineSpacingOptions.Value; } public IEnumerable Validate( @@ -35,6 +50,9 @@ public IEnumerable Validate( UniversityConfig config, DocumentCommentService? documentCommentService) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.DescendantParagraphs(doc, config)) { @@ -48,42 +66,58 @@ public IEnumerable Validate( continue; var (lineSpacing, lineRule) = FormattingResolutionService.ResolveLineSpacing(doc, paragraph); - if (!IsLineSpacing15(lineSpacing, lineRule)) + if (IsTargetLineSpacing(lineSpacing, lineRule)) continue; - var (spacingBefore, spacingAfter) = FormattingResolutionService.ResolveParagraphSpacing(doc, paragraph); - - if (spacingBefore != 0 || spacingAfter != 0) - { - var beforePt = UnitConversion.TwipsToPoints(spacingBefore); - var afterPt = UnitConversion.TwipsToPoints(spacingAfter); - var preview = TextExtractionService.GetPreview(paragraph, config, 50); - if (string.IsNullOrWhiteSpace(preview)) - continue; + var preview = TextExtractionService.GetPreview(paragraph, config, 50); + if (string.IsNullOrWhiteSpace(preview)) + continue; - var errorMessage = $"Paragraph with 1.5 line spacing must have 0pt spacing before and after. " + - $"Found: Before={beforePt:F1}pt, After={afterPt:F1}pt."; + var errorMessage = $"Paragraph line spacing must be {FormatRequiredLineSpacing()}. " + + $"Found: {FormatLineSpacing(lineSpacing, lineRule)}."; - errors.Add(ValidationResultFactory.ForParagraph( - Name, - config, - errorMessage, - paragraphIndex, - preview)); + var result = ValidationResultFactory.ForParagraph( + Name, + config, + errorMessage, + paragraphIndex, + preview); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); - documentCommentService?.AddCommentToParagraph(doc, paragraph, errorMessage); - } + documentCommentService?.AddCommentToParagraph(doc, paragraph, errorMessage); } return errors; } - private static bool IsLineSpacing15(int? lineSpacing, LineSpacingRuleValues? lineRule) + private bool IsTargetLineSpacing(int? lineSpacing, LineSpacingRuleValues? lineRule) { if (!lineSpacing.HasValue) return false; return (lineRule == null || lineRule == LineSpacingRuleValues.Auto) - && lineSpacing.Value == LineSpacing15; + && lineSpacing.Value == _options.TargetLineSpacingTwips; + } + + private string FormatRequiredLineSpacing() + { + return FormatAutoLineSpacing(_options.TargetLineSpacingTwips); + } + + private static string FormatLineSpacing(int? lineSpacing, LineSpacingRuleValues? lineRule) + { + if (!lineSpacing.HasValue) + return "not set"; + + if (lineRule is null || lineRule == LineSpacingRuleValues.Auto) + return FormatAutoLineSpacing(lineSpacing.Value); + + return $"{lineSpacing.Value} with {lineRule.Value} line rule"; + } + + private static string FormatAutoLineSpacing(int lineSpacing) + { + return $"{lineSpacing / 240.0:F1}"; } } diff --git a/backend/Services/Rules/RuleConfigurationService.cs b/backend/Services/Rules/RuleConfigurationService.cs index 4a97b6e..c9eea01 100644 --- a/backend/Services/Rules/RuleConfigurationService.cs +++ b/backend/Services/Rules/RuleConfigurationService.cs @@ -15,17 +15,20 @@ public sealed class RuleConfigurationService : IRuleConfigurationService private readonly FontFamilyRuleOptions _fontFamilyOptions; private readonly HeadingStyleUsageRuleOptions _headingStyleUsageOptions; private readonly HierarchyDepthRuleOptions _hierarchyDepthOptions; + private readonly LineSpacingDependencyRuleOptions _lineSpacingDependencyOptions; public RuleConfigurationService( IOptions emptySectionOptions, IOptions? fontFamilyOptions = null, IOptions? headingStyleUsageOptions = null, - IOptions? hierarchyDepthOptions = null) + IOptions? hierarchyDepthOptions = null, + IOptions? lineSpacingDependencyOptions = null) { _emptySectionOptions = emptySectionOptions.Value; _fontFamilyOptions = fontFamilyOptions?.Value ?? new FontFamilyRuleOptions(); _headingStyleUsageOptions = headingStyleUsageOptions?.Value ?? new HeadingStyleUsageRuleOptions(); _hierarchyDepthOptions = hierarchyDepthOptions?.Value ?? new HierarchyDepthRuleOptions(); + _lineSpacingDependencyOptions = lineSpacingDependencyOptions?.Value ?? new LineSpacingDependencyRuleOptions(); } public bool IsRuleAvailable(string ruleId) @@ -42,6 +45,9 @@ public bool IsRuleAvailable(string ruleId) if (IsHierarchyDepthRule(ruleId)) return _hierarchyDepthOptions.Availability != RuleAvailability.Hidden; + if (IsLineSpacingDependencyRule(ruleId)) + return _lineSpacingDependencyOptions.Availability != RuleAvailability.Hidden; + return true; } @@ -62,6 +68,9 @@ public string ResolveSeverity( if (IsHierarchyDepthRule(ruleId)) return ValidationSeverity.Normalize(_hierarchyDepthOptions.Severity.ToString()); + if (IsLineSpacingDependencyRule(ruleId)) + return ValidationSeverity.Normalize(_lineSpacingDependencyOptions.Severity.ToString()); + return SeverityResolver.Resolve(ruleId, config, explicitSeverity); } @@ -79,6 +88,9 @@ public RuleDefinition ApplyConfiguration(RuleDefinition definition) if (IsHierarchyDepthRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_hierarchyDepthOptions.Severity.ToString()) }; + if (IsLineSpacingDependencyRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_lineSpacingDependencyOptions.Severity.ToString()) }; + return definition; } @@ -113,4 +125,12 @@ private static bool IsHierarchyDepthRule(string ruleId) HierarchyDepthRule.RuleId, StringComparison.OrdinalIgnoreCase); } + + private static bool IsLineSpacingDependencyRule(string ruleId) + { + return string.Equals( + ruleId, + LineSpacingDependencyRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } } diff --git a/backend/appsettings.json b/backend/appsettings.json index 2421a36..79d7c13 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -27,6 +27,11 @@ "Availability": "Available", "Severity": "Error", "MaxAllowedLevel": 3 + }, + "LineSpacingDependencyRule": { + "Availability": "Available", + "Severity": "Error", + "TargetLineSpacingTwips": 360 } }, "CodeBlockDetection": { From 80c43c692af08cf84cb57bc2ebffe91eee486811 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:13:25 +0200 Subject: [PATCH 16/77] feat: Implement List Punctuation and Indentation Consistency Rules - Added ListPunctuationConsistencyRule and ListIndentationConsistencyRule to validate list formatting. - Removed the deprecated ListConsistencyRule. - Updated SectionContextTests to include new rules. - Created options classes for new rules and registered them in Program.cs. - Refactored list extraction logic into ListRuleItemExtractor for better code organization. - Updated appsettings.json to include configuration for new rules. - Enhanced SkipDecisionService and StructuralStyleSkipRule to accommodate new rules. - Updated frontend validation models to reflect new rules and their descriptions. --- .../Rules/ListSplitRuleConfigurationTests.cs | 378 ++++++++++++++++++ ...encyRuleTests.cs => ListSplitRuleTests.cs} | 142 +++++-- backend.Tests/Services/SectionContextTests.cs | 2 +- .../ListIndentationConsistencyRuleOptions.cs | 6 + .../ListPunctuationConsistencyRuleOptions.cs | 6 + backend/Program.cs | 8 + backend/Rules/ListConsistencyRule.cs | 250 ------------ .../Rules/ListIndentationConsistencyRule.cs | 109 +++++ .../Rules/ListPunctuationConsistencyRule.cs | 157 ++++++++ backend/Rules/ListRuleItemExtractor.cs | 75 ++++ backend/Rules/RuleDefinition.cs | 11 +- .../Analysis/ThesisValidatorService.cs | 2 +- .../Rules/RuleConfigurationService.cs | 42 +- .../Services/Skipping/SkipDecisionService.cs | 26 +- .../Skipping/StructuralStyleSkipRule.cs | 30 +- backend/appsettings.json | 8 + frontend/src/app/models/validation.models.ts | 11 +- 17 files changed, 950 insertions(+), 313 deletions(-) create mode 100644 backend.Tests/Rules/ListSplitRuleConfigurationTests.cs rename backend.Tests/Rules/{ListConsistencyRuleTests.cs => ListSplitRuleTests.cs} (60%) create mode 100644 backend/Options/Rules/ListIndentationConsistencyRuleOptions.cs create mode 100644 backend/Options/Rules/ListPunctuationConsistencyRuleOptions.cs delete mode 100644 backend/Rules/ListConsistencyRule.cs create mode 100644 backend/Rules/ListIndentationConsistencyRule.cs create mode 100644 backend/Rules/ListPunctuationConsistencyRule.cs create mode 100644 backend/Rules/ListRuleItemExtractor.cs diff --git a/backend.Tests/Rules/ListSplitRuleConfigurationTests.cs b/backend.Tests/Rules/ListSplitRuleConfigurationTests.cs new file mode 100644 index 0000000..14d1b68 --- /dev/null +++ b/backend.Tests/Rules/ListSplitRuleConfigurationTests.cs @@ -0,0 +1,378 @@ +using System.Collections; +using System.Reflection; +using backend.Endpoints; +using backend.Models; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Comments; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Rules; +using ThesisValidator.Rules; + +namespace backend.Tests.Rules; + +public class ListSplitRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenListPunctuationConsistencyRuleIsAvailable_IncludesRule() + { + var result = InvokeGetAvailableRules( + ListPunctuationConsistencyRule.RuleId, + CreateRuleConfigurationService( + punctuationOptions: new ListPunctuationConsistencyRuleOptions + { + Availability = RuleAvailability.Available + })); + + Assert.Contains(ListPunctuationConsistencyRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenListPunctuationConsistencyRuleIsHidden_ExcludesRule() + { + var result = InvokeGetAvailableRules( + ListPunctuationConsistencyRule.RuleId, + CreateRuleConfigurationService( + punctuationOptions: new ListPunctuationConsistencyRuleOptions + { + Availability = RuleAvailability.Hidden + })); + + Assert.DoesNotContain(ListPunctuationConsistencyRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenListPunctuationConsistencyRuleIsManuallySelected_DoesNotExecuteRule() + { + AssertHiddenRuleIsNotExecuted( + ListPunctuationConsistencyRule.RuleId, + CreateRuleConfigurationService( + punctuationOptions: new ListPunctuationConsistencyRuleOptions + { + Availability = RuleAvailability.Hidden + })); + } + + [Fact] + public void Validate_WhenListPunctuationConsistencySeverityIsWarning_AppliesWarning() + { + var result = ValidateListPunctuationConsistencyRule(new ListPunctuationConsistencyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenListPunctuationConsistencySeverityIsError_AppliesError() + { + var result = ValidateListPunctuationConsistencyRule(new ListPunctuationConsistencyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WithSelectedRules_RunsListPunctuationConsistencyWithoutRunningUnselectedRule() + { + AssertSelectedRuleBehavior( + ListPunctuationConsistencyRule.RuleId, + CreateRuleConfigurationService( + punctuationOptions: new ListPunctuationConsistencyRuleOptions + { + Availability = RuleAvailability.Available + })); + } + + [Fact] + public void GetAvailableRules_WhenListIndentationConsistencyRuleIsAvailable_IncludesRule() + { + var result = InvokeGetAvailableRules( + ListIndentationConsistencyRule.RuleId, + CreateRuleConfigurationService( + indentationOptions: new ListIndentationConsistencyRuleOptions + { + Availability = RuleAvailability.Available + })); + + Assert.Contains(ListIndentationConsistencyRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenListIndentationConsistencyRuleIsHidden_ExcludesRule() + { + var result = InvokeGetAvailableRules( + ListIndentationConsistencyRule.RuleId, + CreateRuleConfigurationService( + indentationOptions: new ListIndentationConsistencyRuleOptions + { + Availability = RuleAvailability.Hidden + })); + + Assert.DoesNotContain(ListIndentationConsistencyRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenListIndentationConsistencyRuleIsManuallySelected_DoesNotExecuteRule() + { + AssertHiddenRuleIsNotExecuted( + ListIndentationConsistencyRule.RuleId, + CreateRuleConfigurationService( + indentationOptions: new ListIndentationConsistencyRuleOptions + { + Availability = RuleAvailability.Hidden + })); + } + + [Fact] + public void Validate_WhenListIndentationConsistencySeverityIsWarning_AppliesWarning() + { + var result = ValidateListIndentationConsistencyRule(new ListIndentationConsistencyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenListIndentationConsistencySeverityIsError_AppliesError() + { + var result = ValidateListIndentationConsistencyRule(new ListIndentationConsistencyRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WithSelectedRules_RunsListIndentationConsistencyWithoutRunningUnselectedRule() + { + AssertSelectedRuleBehavior( + ListIndentationConsistencyRule.RuleId, + CreateRuleConfigurationService( + indentationOptions: new ListIndentationConsistencyRuleOptions + { + Availability = RuleAvailability.Available + })); + } + + private static ValidationResult ValidateListPunctuationConsistencyRule( + ListPunctuationConsistencyRuleOptions options) + { + var ruleConfigurationService = CreateRuleConfigurationService(punctuationOptions: options); + var rule = new ListPunctuationConsistencyRule( + ruleConfigurationService, + Options.Create(options)); + using var docx = CreateDocxWithParagraphs( + CreateListItem("First item;", 1), + CreateListItem("Second item,", 1), + CreateListItem("Third item.", 1)); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static ValidationResult ValidateListIndentationConsistencyRule( + ListIndentationConsistencyRuleOptions options) + { + var ruleConfigurationService = CreateRuleConfigurationService(indentationOptions: options); + var rule = new ListIndentationConsistencyRule( + ruleConfigurationService, + Options.Create(options)); + using var docx = CreateDocxWithParagraphs( + CreateListItem("First item;", 1, indentTwips: 720), + CreateListItem("Second item;", 1, indentTwips: 1440), + CreateListItem("Third item.", 1, indentTwips: 720)); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static IResult InvokeGetAvailableRules( + string ruleId, + IRuleConfigurationService ruleConfigurationService) + { + var method = typeof(DocumentEndpoint).GetMethod( + "GetAvailableRules", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method.Invoke( + null, + new object?[] + { + new ThesisValidatorService( + [new RecordingRule(ruleId), new RecordingRule("Grammar")], + ruleConfigurationService), + ruleConfigurationService + }); + + return Assert.IsAssignableFrom(result); + } + + private static void AssertHiddenRuleIsNotExecuted( + string ruleId, + IRuleConfigurationService ruleConfigurationService) + { + var rule = new RecordingRule(ruleId); + var service = new ThesisValidatorService([rule], ruleConfigurationService); + + using var stream = CreateDocxStream(); + var (results, _) = service.Validate( + stream, + new UniversityConfig(), + [ruleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + private static void AssertSelectedRuleBehavior( + string ruleId, + IRuleConfigurationService ruleConfigurationService) + { + var selectedRule = new RecordingRule(ruleId); + var unselectedRule = new RecordingRule("Grammar"); + var service = new ThesisValidatorService( + [selectedRule, unselectedRule], + ruleConfigurationService); + + using var stream = CreateDocxStream(); + service.Validate(stream, new UniversityConfig(), [ruleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + ListPunctuationConsistencyRuleOptions? punctuationOptions = null, + ListIndentationConsistencyRuleOptions? indentationOptions = null) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + listPunctuationConsistencyOptions: Options.Create(punctuationOptions ?? new ListPunctuationConsistencyRuleOptions()), + listIndentationConsistencyOptions: Options.Create(indentationOptions ?? new ListIndentationConsistencyRuleOptions())); + } + + private static MemoryStream CreateDocxStream() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Body text"))))); + mainPart.Document.Save(); + } + + stream.Position = 0; + return stream; + } + + private static WordprocessingDocument CreateDocxWithParagraphs(params Paragraph[] paragraphs) + { + var stream = new MemoryStream(); + var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body()); + + foreach (var paragraph in paragraphs) + { + mainPart.Document.Body!.Append(paragraph); + } + + mainPart.Document.Save(); + stream.Position = 0; + return doc; + } + + private static Paragraph CreateListItem( + string text, + int numberingId, + int level = 0, + int? indentTwips = null) + { + var numberingProps = new NumberingProperties( + new NumberingLevelReference { Val = level }, + new NumberingId { Val = numberingId } + ); + + var paraProps = new ParagraphProperties(numberingProps); + if (indentTwips.HasValue) + { + paraProps.Indentation = new Indentation { Left = indentTwips.Value.ToString() }; + } + + return new Paragraph( + paraProps, + new Run(new Text(text)) + ); + } + + private static IReadOnlyList GetRuleNames(IResult result) + { + return GetRules(result) + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private static IEnumerable GetRules(IResult result) + { + Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); + + var value = result.GetType().GetProperty("Value")?.GetValue(result); + Assert.NotNull(value); + + var rules = value.GetType().GetProperty("Rules")?.GetValue(value); + Assert.NotNull(rules); + + return ((IEnumerable)rules).Cast(); + } + + private sealed class RecordingRule : IValidationRule + { + public RecordingRule(string name) + { + Name = name; + } + + public string Name { get; } + + public int RunCount { get; private set; } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + RunCount++; + return + [ + new ValidationResult + { + RuleName = Name, + Message = "Executed" + } + ]; + } + } +} diff --git a/backend.Tests/Rules/ListConsistencyRuleTests.cs b/backend.Tests/Rules/ListSplitRuleTests.cs similarity index 60% rename from backend.Tests/Rules/ListConsistencyRuleTests.cs rename to backend.Tests/Rules/ListSplitRuleTests.cs index b87b27c..a7a0fe6 100644 --- a/backend.Tests/Rules/ListConsistencyRuleTests.cs +++ b/backend.Tests/Rules/ListSplitRuleTests.cs @@ -7,9 +7,10 @@ namespace backend.Tests.Rules; -public class ListConsistencyRuleTests +public class ListSplitRuleTests { - private readonly ListConsistencyRule _rule = new(); + private readonly ListPunctuationConsistencyRule _punctuationRule = new(); + private readonly ListIndentationConsistencyRule _indentationRule = new(); private static UniversityConfig CreateConfig() => new(); @@ -71,7 +72,7 @@ private static InMemoryDocx CreateDocxWithParagraphs(params Paragraph[] paragrap } [Fact] - public void ConsistentPunctuationWithSemicolons_ReturnsNoErrors() + public void PunctuationRule_ConsistentPunctuationWithSemicolons_ReturnsNoErrors() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item;", 1), @@ -79,13 +80,13 @@ public void ConsistentPunctuationWithSemicolons_ReturnsNoErrors() CreateListItem("Third item.", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } [Fact] - public void ConsistentPunctuationWithCommas_ReturnsNoErrors() + public void PunctuationRule_ConsistentPunctuationWithCommas_ReturnsNoErrors() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item,", 1), @@ -93,13 +94,13 @@ public void ConsistentPunctuationWithCommas_ReturnsNoErrors() CreateListItem("Third item.", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } [Fact] - public void MixedPunctuation_ReturnsErrors() + public void PunctuationRule_MixedPunctuation_ReturnsErrors() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item;", 1), @@ -108,7 +109,7 @@ public void MixedPunctuation_ReturnsErrors() CreateListItem("Fourth item.", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Single(errors); Assert.Contains("','", errors[0].Message); @@ -116,7 +117,23 @@ public void MixedPunctuation_ReturnsErrors() } [Fact] - public void LastItemNotEndingWithPeriod_ReturnsError() + public void PunctuationRule_ListParagraphStyle_IsCheckedAsListItem() + { + using var docx = CreateDocxWithParagraphs( + CreateListItem("First item;", 1, styleId: "ListParagraph"), + CreateListItem("Second item,", 1, styleId: "ListParagraph"), + CreateListItem("Third item.", 1, styleId: "ListParagraph") + ); + + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); + + Assert.Single(errors); + Assert.Contains("','", errors[0].Message); + Assert.Contains("';'", errors[0].Message); + } + + [Fact] + public void PunctuationRule_LastItemNotEndingWithPeriod_ReturnsError() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item;", 1), @@ -124,14 +141,14 @@ public void LastItemNotEndingWithPeriod_ReturnsError() CreateListItem("Third item;", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Single(errors); Assert.Contains("Last list item should end with period", errors[0].Message); } [Fact] - public void MiddleItemMissingPunctuation_ReturnsError() + public void PunctuationRule_MiddleItemMissingPunctuation_ReturnsError() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item;", 1), @@ -139,7 +156,7 @@ public void MiddleItemMissingPunctuation_ReturnsError() CreateListItem("Third item.", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Single(errors); Assert.Contains("no punctuation", errors[0].Message); @@ -147,19 +164,19 @@ public void MiddleItemMissingPunctuation_ReturnsError() } [Fact] - public void SingleItemList_ReturnsNoErrors() + public void PunctuationRule_SingleItemList_ReturnsNoErrors() { using var docx = CreateDocxWithParagraphs( CreateListItem("Only item.", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } [Fact] - public void FirstItemNoPunctuation_MiddleItemsMustMatch() + public void PunctuationRule_FirstItemNoPunctuation_MiddleItemsMustMatch() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item", 1), @@ -167,7 +184,7 @@ public void FirstItemNoPunctuation_MiddleItemsMustMatch() CreateListItem("Third item.", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Single(errors); Assert.Contains("';'", errors[0].Message); @@ -175,7 +192,7 @@ public void FirstItemNoPunctuation_MiddleItemsMustMatch() } [Fact] - public void AllItemsNoPunctuation_LastMustEndWithPeriod() + public void PunctuationRule_AllItemsNoPunctuation_LastMustEndWithPeriod() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item", 1), @@ -183,14 +200,14 @@ public void AllItemsNoPunctuation_LastMustEndWithPeriod() CreateListItem("Third item", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Single(errors); Assert.Contains("Last list item should end with period", errors[0].Message); } [Fact] - public void ConsistentIndentation_ReturnsNoErrors() + public void IndentationRule_ConsistentIndentation_ReturnsNoErrors() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item;", 1, level: 0, indentTwips: 720), @@ -198,13 +215,13 @@ public void ConsistentIndentation_ReturnsNoErrors() CreateListItem("Third item.", 1, level: 0, indentTwips: 720) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _indentationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } [Fact] - public void InconsistentIndentation_ReturnsError() + public void IndentationRule_InconsistentIndentation_ReturnsError() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item;", 1, level: 0, indentTwips: 720), @@ -212,14 +229,29 @@ public void InconsistentIndentation_ReturnsError() CreateListItem("Third item.", 1, level: 0, indentTwips: 720) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _indentationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Single(errors); Assert.Contains("inconsistent indentation", errors[0].Message); } [Fact] - public void DifferentLevelsWithDifferentIndentation_ReturnsNoErrors() + public void IndentationRule_ListParagraphStyle_IsCheckedAsListItem() + { + using var docx = CreateDocxWithParagraphs( + CreateListItem("First item;", 1, level: 0, indentTwips: 720, styleId: "ListParagraph"), + CreateListItem("Second item;", 1, level: 0, indentTwips: 1440, styleId: "ListParagraph"), + CreateListItem("Third item.", 1, level: 0, indentTwips: 720, styleId: "ListParagraph") + ); + + var errors = _indentationRule.Validate(docx.Document, CreateConfig(), null).ToList(); + + Assert.Single(errors); + Assert.Contains("inconsistent indentation", errors[0].Message); + } + + [Fact] + public void IndentationRule_DifferentLevelsWithDifferentIndentation_ReturnsNoErrors() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item;", 1, level: 0, indentTwips: 720), @@ -227,14 +259,40 @@ public void DifferentLevelsWithDifferentIndentation_ReturnsNoErrors() CreateListItem("Second item.", 1, level: 0, indentTwips: 720) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _indentationRule.Validate(docx.Document, CreateConfig(), null).ToList(); - var indentErrors = errors.Where(e => e.Message.Contains("indentation")).ToList(); - Assert.Empty(indentErrors); + Assert.Empty(errors); + } + + [Fact] + public void PunctuationRule_IgnoresIndentationDifferences() + { + using var docx = CreateDocxWithParagraphs( + CreateListItem("First item;", 1, level: 0, indentTwips: 720), + CreateListItem("Second item.", 1, level: 0, indentTwips: 1440) + ); + + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); + + Assert.Empty(errors); + } + + [Fact] + public void IndentationRule_IgnoresPunctuationDifferences() + { + using var docx = CreateDocxWithParagraphs( + CreateListItem("First item;", 1, level: 0, indentTwips: 720), + CreateListItem("Second item,", 1, level: 0, indentTwips: 720), + CreateListItem("Third item.", 1, level: 0, indentTwips: 720) + ); + + var errors = _indentationRule.Validate(docx.Document, CreateConfig(), null).ToList(); + + Assert.Empty(errors); } [Fact] - public void TwoSeparateLists_CheckedIndependently() + public void PunctuationRule_TwoSeparateLists_CheckedIndependently() { using var docx = CreateDocxWithParagraphs( CreateListItem("List1 item1;", 1), @@ -244,39 +302,39 @@ public void TwoSeparateLists_CheckedIndependently() CreateListItem("List2 item2.", 2) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } [Fact] - public void NumberedHeadings_AreNotCheckedAsListItems() + public void PunctuationRule_NumberedHeadings_AreNotCheckedAsListItems() { using var docx = CreateDocxWithParagraphs( CreateNumberedHeading("Chapter one", 1), CreateNumberedHeading("Chapter two", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } [Fact] - public void ExcludedStylePattern_AreNotCheckedAsListItems() + public void PunctuationRule_ExcludedStylePattern_AreNotCheckedAsListItems() { using var docx = CreateDocxWithParagraphs( CreateListItem("Caption one;", 1, styleId: "Caption"), CreateListItem("Caption two;", 1, styleId: "Caption") ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } [Fact] - public void NumberedHeading_BreaksAdjacentLists() + public void PunctuationRule_NumberedHeading_BreaksAdjacentLists() { using var docx = CreateDocxWithParagraphs( CreateListItem("First list item;", 1), @@ -286,13 +344,13 @@ public void NumberedHeading_BreaksAdjacentLists() CreateListItem("Second list end.", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } [Fact] - public void TwoListsBothWithErrors_ReportsAllErrors() + public void PunctuationRule_TwoListsBothWithErrors_ReportsAllErrors() { using var docx = CreateDocxWithParagraphs( CreateListItem("List1 item1;", 1), @@ -302,13 +360,13 @@ public void TwoListsBothWithErrors_ReportsAllErrors() CreateListItem("List2 item2,", 2) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Equal(2, errors.Count); } [Fact] - public void EmptyListItem_HandledGracefully() + public void PunctuationRule_EmptyListItem_HandledGracefully() { using var docx = CreateDocxWithParagraphs( CreateListItem("First item;", 1), @@ -316,7 +374,7 @@ public void EmptyListItem_HandledGracefully() CreateListItem("Third item.", 1) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Single(errors); Assert.Contains("no punctuation", errors[0].Message); @@ -324,7 +382,7 @@ public void EmptyListItem_HandledGracefully() } [Fact] - public void NoLists_ReturnsNoErrors() + public void PunctuationRule_NoLists_ReturnsNoErrors() { var stream = new MemoryStream(); var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); @@ -336,13 +394,13 @@ public void NoLists_ReturnsNoErrors() mainPart.Document.Save(); using var docx = new InMemoryDocx(doc, stream); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.Empty(errors); } [Fact] - public void NestedListLevels_CheckedSeparately() + public void PunctuationRule_NestedListLevels_CheckedSeparately() { using var docx = CreateDocxWithParagraphs( CreateListItem("Main item 1:", 1, level: 0), @@ -351,7 +409,7 @@ public void NestedListLevels_CheckedSeparately() CreateListItem("Main item 2.", 1, level: 0) ); - var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + var errors = _punctuationRule.Validate(docx.Document, CreateConfig(), null).ToList(); Assert.NotNull(errors); } diff --git a/backend.Tests/Services/SectionContextTests.cs b/backend.Tests/Services/SectionContextTests.cs index 7feb4d6..e02076d 100644 --- a/backend.Tests/Services/SectionContextTests.cs +++ b/backend.Tests/Services/SectionContextTests.cs @@ -165,7 +165,7 @@ private static ( private static readonly HashSet ElementsBasedRules = new(StringComparer.OrdinalIgnoreCase) { - "FontFamily", "ListConsistencyRule", "Grammar", + "FontFamily", "ListPunctuationConsistencyRule", "ListIndentationConsistencyRule", "Grammar", "FigureCaptionStyleRule", "EmptySectionStructureRule", "HeadingStyleUsageRule", "CheckTableOfContents", }; diff --git a/backend/Options/Rules/ListIndentationConsistencyRuleOptions.cs b/backend/Options/Rules/ListIndentationConsistencyRuleOptions.cs new file mode 100644 index 0000000..6e0d7c5 --- /dev/null +++ b/backend/Options/Rules/ListIndentationConsistencyRuleOptions.cs @@ -0,0 +1,6 @@ +namespace backend.RuleOptions; + +public sealed class ListIndentationConsistencyRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:ListIndentationConsistencyRule"; +} diff --git a/backend/Options/Rules/ListPunctuationConsistencyRuleOptions.cs b/backend/Options/Rules/ListPunctuationConsistencyRuleOptions.cs new file mode 100644 index 0000000..06a40cf --- /dev/null +++ b/backend/Options/Rules/ListPunctuationConsistencyRuleOptions.cs @@ -0,0 +1,6 @@ +namespace backend.RuleOptions; + +public sealed class ListPunctuationConsistencyRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:ListPunctuationConsistencyRule"; +} diff --git a/backend/Program.cs b/backend/Program.cs index 30f1767..5f44ac2 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -66,6 +66,14 @@ "LineSpacingDependencyRule:TargetLineSpacingTwips must be greater than 0.") .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ListPunctuationConsistencyRuleOptions.SectionName)) + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ListIndentationConsistencyRuleOptions.SectionName)) + .ValidateOnStart(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/backend/Rules/ListConsistencyRule.cs b/backend/Rules/ListConsistencyRule.cs deleted file mode 100644 index 5658091..0000000 --- a/backend/Rules/ListConsistencyRule.cs +++ /dev/null @@ -1,250 +0,0 @@ -using backend.Models; -using Backend.Models; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using ThesisValidator.Rules; -using backend.Services.Analysis; -using backend.Services.Comments; -using backend.Services.Extraction; -using backend.Services.Formatting; -using backend.Services.Results; -using backend.Services.Skipping; -using backend.Services.Structure; - -namespace Rules; - -/// -/// Validates list consistency: -/// 1. Punctuation: Items (except last) should end with same punctuation (; or ,), last item ends with period. -/// 2. Indentation: All items at the same level should have identical indentation. -/// -public class ListConsistencyRule : IValidationRule -{ - public string Name => "ListConsistencyRule"; - - public IEnumerable Validate(WordprocessingDocument doc, UniversityConfig config, DocumentCommentService? documentCommentService) - { - var errors = new List(); - var lists = ExtractLists(doc, config); - - foreach (var list in lists) - { - errors.AddRange(ValidatePunctuationConsistency(doc, list, config, documentCommentService)); - - errors.AddRange(ValidateIndentationConsistency(doc, list, config, documentCommentService)); - } - - return errors; - } - - private static List ExtractLists(WordprocessingDocument doc, UniversityConfig config) - { - var lists = new List(); - ListGroup? currentList = null; - int? currentNumberingId = null; - - foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.BodyParagraphs(doc, config)) - { - if (HeadingDetectionService.IsHeading(doc, paragraph) - || SkipDecisionService.HasExcludedStructuralStyle(doc, paragraph)) - { - currentList = null; - currentNumberingId = null; - continue; - } - - var numberingProps = paragraph.ParagraphProperties?.NumberingProperties; - var numberingId = numberingProps?.NumberingId?.Val?.Value; - - if (numberingId.HasValue) - { - if (currentList == null || currentNumberingId != numberingId) - { - currentList = new ListGroup { NumberingId = numberingId.Value }; - lists.Add(currentList); - currentNumberingId = numberingId; - } - - var level = numberingProps?.NumberingLevelReference?.Val?.Value ?? 0; - var indent = FormattingResolutionService.ResolveLeftIndent(paragraph); - - currentList.Items.Add(new ListItem - { - Paragraph = paragraph, - ParagraphIndex = paragraphIndex, - Level = level, - IndentLeft = indent - }); - } - else - { - currentList = null; - currentNumberingId = null; - } - } - - return lists; - } - - private IEnumerable ValidatePunctuationConsistency( - WordprocessingDocument doc, - ListGroup list, - UniversityConfig config, - DocumentCommentService? documentCommentService) - { - var errors = new List(); - - if (list.Items.Count < 2) - return errors; - - var itemsByLevel = list.Items.GroupBy(i => i.Level); - - foreach (var levelGroup in itemsByLevel) - { - var items = levelGroup.ToList(); - if (items.Count < 2) - continue; - - var firstItem = items.First(); - var lastItem = items.Last(); - var middleItems = items.Count > 2 ? items.Skip(1).Take(items.Count - 2).ToList() : []; - - var expectedPunctuation = GetTrailingPunctuation(firstItem.Paragraph, config); - - foreach (var item in middleItems) - { - var ending = GetTrailingPunctuation(item.Paragraph, config); - var text = TextExtractionService.GetParagraphText(doc, item.Paragraph, config); - var preview = GetListItemPreview(text); - - if (ending != expectedPunctuation) - { - var expectedDesc = expectedPunctuation.HasValue - ? $"'{expectedPunctuation}'" - : "no punctuation"; - var actualDesc = ending.HasValue - ? $"'{ending}'" - : "no punctuation"; - - var errorMessage = $"List item ends with {actualDesc} but first item uses {expectedDesc}. Text: \"{preview}\""; - - errors.Add(ValidationResultFactory.ForParagraph( - Name, - config, - errorMessage, - item.ParagraphIndex, - preview, - ParagraphIndexKind.BodyElement)); - - documentCommentService?.AddCommentToParagraph(doc, item.Paragraph, errorMessage); - } - } - - var lastEnding = GetTrailingPunctuation(lastItem.Paragraph, config); - if (lastEnding != '.') - { - var lastText = TextExtractionService.GetParagraphText(doc, lastItem.Paragraph, config); - var lastPreview = GetListItemPreview(lastText); - - var errorMessage = lastEnding.HasValue - ? $"Last list item should end with period (.), found '{lastEnding}'. Text: \"{lastPreview}\"" - : $"Last list item should end with period (.). Text: \"{lastPreview}\""; - - errors.Add(ValidationResultFactory.ForParagraph( - Name, - config, - errorMessage, - lastItem.ParagraphIndex, - lastPreview, - ParagraphIndexKind.BodyElement)); - - documentCommentService?.AddCommentToParagraph(doc, lastItem.Paragraph, errorMessage); - } - } - - return errors; - } - - private IEnumerable ValidateIndentationConsistency( - WordprocessingDocument doc, - ListGroup list, - UniversityConfig config, - DocumentCommentService? documentCommentService) - { - var errors = new List(); - - var itemsByLevel = list.Items.GroupBy(i => i.Level); - - foreach (var levelGroup in itemsByLevel) - { - var items = levelGroup.ToList(); - if (items.Count < 2) - continue; - - var indentCounts = items - .GroupBy(i => i.IndentLeft) - .OrderByDescending(g => g.Count()) - .ToList(); - - if (indentCounts.Count <= 1) - continue; - - var expectedIndent = indentCounts.First().Key; - - foreach (var item in items.Where(i => i.IndentLeft != expectedIndent)) - { - var text = TextExtractionService.GetParagraphText(doc, item.Paragraph, config); - var preview = TextExtractionService.Truncate(text, 40); - - var expectedCm = UnitConversion.TwipsToCentimeters(expectedIndent); - var actualCm = UnitConversion.TwipsToCentimeters(item.IndentLeft); - - var errorMessage = $"List item has inconsistent indentation ({actualCm:F2} cm). " + - $"Expected {expectedCm:F2} cm at level {item.Level}. Text: \"{preview}\""; - - errors.Add(ValidationResultFactory.ForParagraph( - Name, - config, - errorMessage, - item.ParagraphIndex, - preview, - ParagraphIndexKind.BodyElement)); - - documentCommentService?.AddCommentToParagraph(doc, item.Paragraph, errorMessage); - } - } - - return errors; - } - - private static char? GetTrailingPunctuation(Paragraph paragraph, UniversityConfig config) - { - var text = TextExtractionService.GetParagraphText(paragraph, config).TrimEnd(); - if (string.IsNullOrEmpty(text)) - return null; - - var lastChar = text[^1]; - return char.IsPunctuation(lastChar) ? lastChar : null; - } - - private static string GetListItemPreview(string text) - { - return string.IsNullOrWhiteSpace(text) - ? "[empty]" - : TextExtractionService.Truncate(text, 40); - } - - private class ListGroup - { - public int NumberingId { get; set; } - public List Items { get; } = []; - } - - private class ListItem - { - public required Paragraph Paragraph { get; init; } - public int ParagraphIndex { get; init; } - public int Level { get; init; } - public int IndentLeft { get; init; } - } -} diff --git a/backend/Rules/ListIndentationConsistencyRule.cs b/backend/Rules/ListIndentationConsistencyRule.cs new file mode 100644 index 0000000..c440476 --- /dev/null +++ b/backend/Rules/ListIndentationConsistencyRule.cs @@ -0,0 +1,109 @@ +using backend.Models; +using backend.RuleOptions; +using backend.Services.Comments; +using backend.Services.Extraction; +using backend.Services.Formatting; +using backend.Services.Results; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml.Packaging; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; + +namespace Rules; + +/// +/// Validates that all list items at the same level use identical indentation. +/// +public class ListIndentationConsistencyRule : IValidationRule +{ + public const string RuleId = nameof(ListIndentationConsistencyRule); + + private readonly IRuleConfigurationService _ruleConfigurationService; + + public string Name => RuleId; + + public ListIndentationConsistencyRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var indentationOptions = options ?? Options.Create(new ListIndentationConsistencyRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + listIndentationConsistencyOptions: indentationOptions); + } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService) + { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + + var errors = new List(); + var lists = ListRuleItemExtractor.ExtractLists(doc, config); + + foreach (var list in lists) + { + errors.AddRange(ValidateIndentationConsistency(doc, list, config, documentCommentService)); + } + + return errors; + } + + private IEnumerable ValidateIndentationConsistency( + WordprocessingDocument doc, + ListGroup list, + UniversityConfig config, + DocumentCommentService? documentCommentService) + { + var errors = new List(); + var itemsByLevel = list.Items.GroupBy(i => i.Level); + + foreach (var levelGroup in itemsByLevel) + { + var items = levelGroup.ToList(); + if (items.Count < 2) + continue; + + var indentCounts = items + .GroupBy(i => i.IndentLeft) + .OrderByDescending(g => g.Count()) + .ToList(); + + if (indentCounts.Count <= 1) + continue; + + var expectedIndent = indentCounts.First().Key; + + foreach (var item in items.Where(i => i.IndentLeft != expectedIndent)) + { + var text = TextExtractionService.GetParagraphText(doc, item.Paragraph, config); + var preview = TextExtractionService.Truncate(text, 40); + + var expectedCm = UnitConversion.TwipsToCentimeters(expectedIndent); + var actualCm = UnitConversion.TwipsToCentimeters(item.IndentLeft); + + var errorMessage = $"List item has inconsistent indentation ({actualCm:F2} cm). " + + $"Expected {expectedCm:F2} cm at level {item.Level}. Text: \"{preview}\""; + + var result = ValidationResultFactory.ForParagraph( + Name, + config, + errorMessage, + item.ParagraphIndex, + preview, + ParagraphIndexKind.BodyElement); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); + + documentCommentService?.AddCommentToParagraph(doc, item.Paragraph, errorMessage); + } + } + + return errors; + } +} diff --git a/backend/Rules/ListPunctuationConsistencyRule.cs b/backend/Rules/ListPunctuationConsistencyRule.cs new file mode 100644 index 0000000..49df415 --- /dev/null +++ b/backend/Rules/ListPunctuationConsistencyRule.cs @@ -0,0 +1,157 @@ +using backend.Models; +using backend.RuleOptions; +using backend.Services.Comments; +using backend.Services.Extraction; +using backend.Services.Results; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; + +namespace Rules; + +/// +/// Validates list punctuation consistency: +/// Items except the last should end with the same punctuation, and the last item should end with a period. +/// +public class ListPunctuationConsistencyRule : IValidationRule +{ + public const string RuleId = nameof(ListPunctuationConsistencyRule); + + private readonly IRuleConfigurationService _ruleConfigurationService; + + public string Name => RuleId; + + public ListPunctuationConsistencyRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var punctuationOptions = options ?? Options.Create(new ListPunctuationConsistencyRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + listPunctuationConsistencyOptions: punctuationOptions); + } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService) + { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + + var errors = new List(); + var lists = ListRuleItemExtractor.ExtractLists(doc, config); + + foreach (var list in lists) + { + errors.AddRange(ValidatePunctuationConsistency(doc, list, config, documentCommentService)); + } + + return errors; + } + + private IEnumerable ValidatePunctuationConsistency( + WordprocessingDocument doc, + ListGroup list, + UniversityConfig config, + DocumentCommentService? documentCommentService) + { + var errors = new List(); + + if (list.Items.Count < 2) + return errors; + + var itemsByLevel = list.Items.GroupBy(i => i.Level); + + foreach (var levelGroup in itemsByLevel) + { + var items = levelGroup.ToList(); + if (items.Count < 2) + continue; + + var firstItem = items.First(); + var lastItem = items.Last(); + var middleItems = items.Count > 2 ? items.Skip(1).Take(items.Count - 2).ToList() : []; + + var expectedPunctuation = GetTrailingPunctuation(firstItem.Paragraph, config); + + foreach (var item in middleItems) + { + var ending = GetTrailingPunctuation(item.Paragraph, config); + var text = TextExtractionService.GetParagraphText(doc, item.Paragraph, config); + var preview = GetListItemPreview(text); + + if (ending != expectedPunctuation) + { + var expectedDesc = expectedPunctuation.HasValue + ? $"'{expectedPunctuation}'" + : "no punctuation"; + var actualDesc = ending.HasValue + ? $"'{ending}'" + : "no punctuation"; + + var errorMessage = $"List item ends with {actualDesc} but first item uses {expectedDesc}. Text: \"{preview}\""; + + var result = ValidationResultFactory.ForParagraph( + Name, + config, + errorMessage, + item.ParagraphIndex, + preview, + ParagraphIndexKind.BodyElement); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); + + documentCommentService?.AddCommentToParagraph(doc, item.Paragraph, errorMessage); + } + } + + var lastEnding = GetTrailingPunctuation(lastItem.Paragraph, config); + if (lastEnding != '.') + { + var lastText = TextExtractionService.GetParagraphText(doc, lastItem.Paragraph, config); + var lastPreview = GetListItemPreview(lastText); + + var errorMessage = lastEnding.HasValue + ? $"Last list item should end with period (.), found '{lastEnding}'. Text: \"{lastPreview}\"" + : $"Last list item should end with period (.). Text: \"{lastPreview}\""; + + var result = ValidationResultFactory.ForParagraph( + Name, + config, + errorMessage, + lastItem.ParagraphIndex, + lastPreview, + ParagraphIndexKind.BodyElement); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); + + documentCommentService?.AddCommentToParagraph(doc, lastItem.Paragraph, errorMessage); + } + } + + return errors; + } + + private static char? GetTrailingPunctuation(Paragraph paragraph, UniversityConfig config) + { + var text = TextExtractionService.GetParagraphText(paragraph, config).TrimEnd(); + if (string.IsNullOrEmpty(text)) + return null; + + var lastChar = text[^1]; + return char.IsPunctuation(lastChar) ? lastChar : null; + } + + private static string GetListItemPreview(string text) + { + return string.IsNullOrWhiteSpace(text) + ? "[empty]" + : TextExtractionService.Truncate(text, 40); + } +} diff --git a/backend/Rules/ListRuleItemExtractor.cs b/backend/Rules/ListRuleItemExtractor.cs new file mode 100644 index 0000000..e92c7a8 --- /dev/null +++ b/backend/Rules/ListRuleItemExtractor.cs @@ -0,0 +1,75 @@ +using backend.Services.Analysis; +using backend.Services.Formatting; +using backend.Services.Skipping; +using backend.Services.Structure; +using Backend.Models; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace Rules; + +internal static class ListRuleItemExtractor +{ + public static List ExtractLists(WordprocessingDocument doc, UniversityConfig config) + { + var lists = new List(); + ListGroup? currentList = null; + int? currentNumberingId = null; + + foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.BodyParagraphs(doc, config)) + { + if (HeadingDetectionService.IsHeading(doc, paragraph) + || SkipDecisionService.HasExcludedStructuralStyle(doc, paragraph, excludeListStyles: false)) + { + currentList = null; + currentNumberingId = null; + continue; + } + + var numberingProps = paragraph.ParagraphProperties?.NumberingProperties; + var numberingId = numberingProps?.NumberingId?.Val?.Value; + + if (numberingId.HasValue) + { + if (currentList == null || currentNumberingId != numberingId) + { + currentList = new ListGroup { NumberingId = numberingId.Value }; + lists.Add(currentList); + currentNumberingId = numberingId; + } + + var level = numberingProps?.NumberingLevelReference?.Val?.Value ?? 0; + var indent = FormattingResolutionService.ResolveLeftIndent(paragraph); + + currentList.Items.Add(new ListItem + { + Paragraph = paragraph, + ParagraphIndex = paragraphIndex, + Level = level, + IndentLeft = indent + }); + } + else + { + currentList = null; + currentNumberingId = null; + } + } + + return lists; + } +} + +internal sealed class ListGroup +{ + public int NumberingId { get; set; } + public List Items { get; } = []; +} + +internal sealed class ListItem +{ + public required Paragraph Paragraph { get; init; } + public int ParagraphIndex { get; init; } + public int Level { get; init; } + public int IndentLeft { get; init; } +} diff --git a/backend/Rules/RuleDefinition.cs b/backend/Rules/RuleDefinition.cs index e429cea..62113c9 100644 --- a/backend/Rules/RuleDefinition.cs +++ b/backend/Rules/RuleDefinition.cs @@ -43,9 +43,14 @@ public static class RuleCatalog "Title Punctuation", RuleCategories.Formatting, ValidationSeverity.Error), - ["ListConsistencyRule"] = new( - "ListConsistencyRule", - "List Consistency", + ["ListPunctuationConsistencyRule"] = new( + "ListPunctuationConsistencyRule", + "List Punctuation Consistency", + RuleCategories.Layout, + ValidationSeverity.Error), + ["ListIndentationConsistencyRule"] = new( + "ListIndentationConsistencyRule", + "List Indentation Consistency", RuleCategories.Layout, ValidationSeverity.Error), ["ParagraphSpacingRule"] = new( diff --git a/backend/Services/Analysis/ThesisValidatorService.cs b/backend/Services/Analysis/ThesisValidatorService.cs index e7e6963..7a4141f 100644 --- a/backend/Services/Analysis/ThesisValidatorService.cs +++ b/backend/Services/Analysis/ThesisValidatorService.cs @@ -189,7 +189,7 @@ private static ( var elementsMap = new List<(int, string)>(); var descendantsMap = new List<(int, string)>(); - // Elements-based map (direct body children only - matches FontFamily, List, Grammar, FigureCaption, EmptySection rules) + // Elements-based map (direct body children only - matches FontFamily, list rules, Grammar, FigureCaption, EmptySection rules) foreach (var (para, elemIdx) in DocumentAnalysisScope.BodyParagraphs(doc, config)) { var level = HeadingDetectionService.GetHeadingLevel(doc, para); diff --git a/backend/Services/Rules/RuleConfigurationService.cs b/backend/Services/Rules/RuleConfigurationService.cs index c9eea01..e162a40 100644 --- a/backend/Services/Rules/RuleConfigurationService.cs +++ b/backend/Services/Rules/RuleConfigurationService.cs @@ -16,19 +16,25 @@ public sealed class RuleConfigurationService : IRuleConfigurationService private readonly HeadingStyleUsageRuleOptions _headingStyleUsageOptions; private readonly HierarchyDepthRuleOptions _hierarchyDepthOptions; private readonly LineSpacingDependencyRuleOptions _lineSpacingDependencyOptions; + private readonly ListPunctuationConsistencyRuleOptions _listPunctuationConsistencyOptions; + private readonly ListIndentationConsistencyRuleOptions _listIndentationConsistencyOptions; public RuleConfigurationService( IOptions emptySectionOptions, IOptions? fontFamilyOptions = null, IOptions? headingStyleUsageOptions = null, IOptions? hierarchyDepthOptions = null, - IOptions? lineSpacingDependencyOptions = null) + IOptions? lineSpacingDependencyOptions = null, + IOptions? listPunctuationConsistencyOptions = null, + IOptions? listIndentationConsistencyOptions = null) { _emptySectionOptions = emptySectionOptions.Value; _fontFamilyOptions = fontFamilyOptions?.Value ?? new FontFamilyRuleOptions(); _headingStyleUsageOptions = headingStyleUsageOptions?.Value ?? new HeadingStyleUsageRuleOptions(); _hierarchyDepthOptions = hierarchyDepthOptions?.Value ?? new HierarchyDepthRuleOptions(); _lineSpacingDependencyOptions = lineSpacingDependencyOptions?.Value ?? new LineSpacingDependencyRuleOptions(); + _listPunctuationConsistencyOptions = listPunctuationConsistencyOptions?.Value ?? new ListPunctuationConsistencyRuleOptions(); + _listIndentationConsistencyOptions = listIndentationConsistencyOptions?.Value ?? new ListIndentationConsistencyRuleOptions(); } public bool IsRuleAvailable(string ruleId) @@ -48,6 +54,12 @@ public bool IsRuleAvailable(string ruleId) if (IsLineSpacingDependencyRule(ruleId)) return _lineSpacingDependencyOptions.Availability != RuleAvailability.Hidden; + if (IsListPunctuationConsistencyRule(ruleId)) + return _listPunctuationConsistencyOptions.Availability != RuleAvailability.Hidden; + + if (IsListIndentationConsistencyRule(ruleId)) + return _listIndentationConsistencyOptions.Availability != RuleAvailability.Hidden; + return true; } @@ -71,6 +83,12 @@ public string ResolveSeverity( if (IsLineSpacingDependencyRule(ruleId)) return ValidationSeverity.Normalize(_lineSpacingDependencyOptions.Severity.ToString()); + if (IsListPunctuationConsistencyRule(ruleId)) + return ValidationSeverity.Normalize(_listPunctuationConsistencyOptions.Severity.ToString()); + + if (IsListIndentationConsistencyRule(ruleId)) + return ValidationSeverity.Normalize(_listIndentationConsistencyOptions.Severity.ToString()); + return SeverityResolver.Resolve(ruleId, config, explicitSeverity); } @@ -91,6 +109,12 @@ public RuleDefinition ApplyConfiguration(RuleDefinition definition) if (IsLineSpacingDependencyRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_lineSpacingDependencyOptions.Severity.ToString()) }; + if (IsListPunctuationConsistencyRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_listPunctuationConsistencyOptions.Severity.ToString()) }; + + if (IsListIndentationConsistencyRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_listIndentationConsistencyOptions.Severity.ToString()) }; + return definition; } @@ -133,4 +157,20 @@ private static bool IsLineSpacingDependencyRule(string ruleId) LineSpacingDependencyRule.RuleId, StringComparison.OrdinalIgnoreCase); } + + private static bool IsListPunctuationConsistencyRule(string ruleId) + { + return string.Equals( + ruleId, + ListPunctuationConsistencyRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsListIndentationConsistencyRule(string ruleId) + { + return string.Equals( + ruleId, + ListIndentationConsistencyRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } } diff --git a/backend/Services/Skipping/SkipDecisionService.cs b/backend/Services/Skipping/SkipDecisionService.cs index f05296c..7bd9983 100644 --- a/backend/Services/Skipping/SkipDecisionService.cs +++ b/backend/Services/Skipping/SkipDecisionService.cs @@ -98,32 +98,42 @@ public static SkipDecision ShouldSkipElement( public static SkipDecision ShouldSkipStructuralStyle( WordprocessingDocument doc, - Paragraph paragraph) + Paragraph paragraph, + bool excludeListStyles = true) { return StructuralStyleRule.ShouldSkipParagraph( doc, paragraph, new UniversityConfig(), - new SkipContext()); + new SkipContext(), + excludeListStyles); } - public static SkipDecision ShouldSkipStructuralStyle(Paragraph paragraph) + public static SkipDecision ShouldSkipStructuralStyle( + Paragraph paragraph, + bool excludeListStyles = true) { return StructuralStyleRule.ShouldSkipParagraph( null, paragraph, new UniversityConfig(), - new SkipContext()); + new SkipContext(), + excludeListStyles); } - public static bool HasExcludedStructuralStyle(Paragraph paragraph) + public static bool HasExcludedStructuralStyle( + Paragraph paragraph, + bool excludeListStyles = true) { - return ShouldSkipStructuralStyle(paragraph).ShouldSkip; + return ShouldSkipStructuralStyle(paragraph, excludeListStyles).ShouldSkip; } - public static bool HasExcludedStructuralStyle(WordprocessingDocument doc, Paragraph paragraph) + public static bool HasExcludedStructuralStyle( + WordprocessingDocument doc, + Paragraph paragraph, + bool excludeListStyles = true) { - return ShouldSkipStructuralStyle(doc, paragraph).ShouldSkip; + return ShouldSkipStructuralStyle(doc, paragraph, excludeListStyles).ShouldSkip; } public static bool IsListItem(Paragraph paragraph) diff --git a/backend/Services/Skipping/StructuralStyleSkipRule.cs b/backend/Services/Skipping/StructuralStyleSkipRule.cs index b5a5d69..5eb614a 100644 --- a/backend/Services/Skipping/StructuralStyleSkipRule.cs +++ b/backend/Services/Skipping/StructuralStyleSkipRule.cs @@ -29,9 +29,24 @@ public SkipDecision ShouldSkipParagraph( Paragraph paragraph, UniversityConfig config, SkipContext context) + { + return ShouldSkipParagraph( + doc, + paragraph, + config, + context, + excludeListStyles: true); + } + + public SkipDecision ShouldSkipParagraph( + WordprocessingDocument? doc, + Paragraph paragraph, + UniversityConfig config, + SkipContext context, + bool excludeListStyles) { var styleId = StyleResolutionService.GetParagraphStyleId(paragraph); - if (ContainsExcludedPattern(styleId)) + if (ContainsExcludedPattern(styleId, excludeListStyles)) { return SkipDecision.Skip( SkipReason.StructuralStyle, @@ -43,7 +58,7 @@ public SkipDecision ShouldSkipParagraph( var style = StyleResolutionService.FindStyle(doc, styleId); var styleName = style?.StyleName?.Val?.Value; - if (ContainsExcludedPattern(styleName)) + if (ContainsExcludedPattern(styleName, excludeListStyles)) { return SkipDecision.Skip( SkipReason.StructuralStyle, @@ -56,12 +71,19 @@ public SkipDecision ShouldSkipParagraph( : SkipDecision.Include; } - private static bool ContainsExcludedPattern(string? value) + private static bool ContainsExcludedPattern(string? value, bool excludeListStyles) { if (string.IsNullOrEmpty(value)) return false; var valueLower = value.ToLowerInvariant(); - return ExcludedStylePatterns.Any(pattern => valueLower.Contains(pattern)); + return ExcludedStylePatterns.Any(pattern => + (excludeListStyles || !IsListStylePattern(pattern)) + && valueLower.Contains(pattern)); + } + + private static bool IsListStylePattern(string pattern) + { + return pattern is "list" or "lista"; } } diff --git a/backend/appsettings.json b/backend/appsettings.json index 79d7c13..5e2a8b4 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -32,6 +32,14 @@ "Availability": "Available", "Severity": "Error", "TargetLineSpacingTwips": 360 + }, + "ListPunctuationConsistencyRule": { + "Availability": "Available", + "Severity": "Error" + }, + "ListIndentationConsistencyRule": { + "Availability": "Available", + "Severity": "Error" } }, "CodeBlockDetection": { diff --git a/frontend/src/app/models/validation.models.ts b/frontend/src/app/models/validation.models.ts index 341719c..62bcd64 100644 --- a/frontend/src/app/models/validation.models.ts +++ b/frontend/src/app/models/validation.models.ts @@ -95,9 +95,14 @@ export const RULE_METADATA: Record Date: Mon, 27 Apr 2026 22:22:39 +0200 Subject: [PATCH 17/77] feat(rules): add Missing Figure Caption Rule with configuration options and validation tests --- ...singFigureCaptionRuleConfigurationTests.cs | 243 ++++++++++++++++++ .../Rules/MissingFigureCaptionRuleOptions.cs | 6 + backend/Program.cs | 4 + backend/Rules/MissingFigureCaptionRule.cs | 30 ++- .../Rules/RuleConfigurationService.cs | 20 ++ backend/appsettings.json | 4 + 6 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 backend.Tests/Rules/MissingFigureCaptionRuleConfigurationTests.cs create mode 100644 backend/Options/Rules/MissingFigureCaptionRuleOptions.cs diff --git a/backend.Tests/Rules/MissingFigureCaptionRuleConfigurationTests.cs b/backend.Tests/Rules/MissingFigureCaptionRuleConfigurationTests.cs new file mode 100644 index 0000000..64fd97e --- /dev/null +++ b/backend.Tests/Rules/MissingFigureCaptionRuleConfigurationTests.cs @@ -0,0 +1,243 @@ +using System.Collections; +using System.Reflection; +using backend.Endpoints; +using backend.Models; +using backend.Rules; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Comments; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; +using A = DocumentFormat.OpenXml.Drawing; +using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; +using PIC = DocumentFormat.OpenXml.Drawing.Pictures; + +namespace backend.Tests.Rules; + +public class MissingFigureCaptionRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenMissingFigureCaptionRuleIsAvailable_IncludesRule() + { + var result = InvokeGetAvailableRules(new MissingFigureCaptionRuleOptions + { + Availability = RuleAvailability.Available + }); + + Assert.Contains(MissingFigureCaptionRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenMissingFigureCaptionRuleIsHidden_ExcludesRule() + { + var result = InvokeGetAvailableRules(new MissingFigureCaptionRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + Assert.DoesNotContain(MissingFigureCaptionRule.RuleId, GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() + { + var rule = new RecordingRule(MissingFigureCaptionRule.RuleId); + var service = CreateService( + [rule], + new MissingFigureCaptionRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + using var stream = CreateDocxStream(); + var (results, _) = service.Validate( + stream, + new UniversityConfig(), + [MissingFigureCaptionRule.RuleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Fact] + public void Validate_WhenSeverityIsWarning_AppliesWarning() + { + var result = ValidateMissingFigureCaptionRule(new MissingFigureCaptionRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenSeverityIsError_AppliesError() + { + var result = ValidateMissingFigureCaptionRule(new MissingFigureCaptionRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WithSelectedRules_RunsMissingFigureCaptionWithoutRunningUnselectedRule() + { + var selectedRule = new RecordingRule(MissingFigureCaptionRule.RuleId); + var unselectedRule = new RecordingRule("Grammar"); + var service = CreateService( + [selectedRule, unselectedRule], + new MissingFigureCaptionRuleOptions + { + Availability = RuleAvailability.Available + }); + + using var stream = CreateDocxStream(); + service.Validate(stream, new UniversityConfig(), [MissingFigureCaptionRule.RuleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + private static ValidationResult ValidateMissingFigureCaptionRule(MissingFigureCaptionRuleOptions options) + { + var rule = new MissingFigureCaptionRule( + CreateRuleConfigurationService(options), + Options.Create(options)); + using var docx = CreateDocxWithPicture(); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static IResult InvokeGetAvailableRules(MissingFigureCaptionRuleOptions options) + { + var method = typeof(DocumentEndpoint).GetMethod( + "GetAvailableRules", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method.Invoke( + null, + new object?[] + { + CreateService( + [new RecordingRule(MissingFigureCaptionRule.RuleId), new RecordingRule("Grammar")], + options), + CreateRuleConfigurationService(options) + }); + + return Assert.IsAssignableFrom(result); + } + + private static ThesisValidatorService CreateService( + IEnumerable rules, + MissingFigureCaptionRuleOptions options) + { + return new ThesisValidatorService(rules, CreateRuleConfigurationService(options)); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + MissingFigureCaptionRuleOptions options) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + missingFigureCaptionOptions: Options.Create(options)); + } + + private static MemoryStream CreateDocxStream() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Body text"))))); + mainPart.Document.Save(); + } + + stream.Position = 0; + return stream; + } + + private static WordprocessingDocument CreateDocxWithPicture() + { + var stream = new MemoryStream(); + var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body( + new Paragraph( + new Run( + new Drawing( + new DW.Inline( + new A.Graphic( + new A.GraphicData( + new PIC.Picture()) + { + Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" + }))))))); + mainPart.Document.Save(); + stream.Position = 0; + return doc; + } + + private static IReadOnlyList GetRuleNames(IResult result) + { + return GetRules(result) + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private static IEnumerable GetRules(IResult result) + { + Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); + + var value = result.GetType().GetProperty("Value")?.GetValue(result); + Assert.NotNull(value); + + var rules = value.GetType().GetProperty("Rules")?.GetValue(value); + Assert.NotNull(rules); + + return ((IEnumerable)rules).Cast(); + } + + private sealed class RecordingRule : IValidationRule + { + public RecordingRule(string name) + { + Name = name; + } + + public string Name { get; } + + public int RunCount { get; private set; } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + RunCount++; + return + [ + new ValidationResult + { + RuleName = Name, + Message = "Executed" + } + ]; + } + } +} diff --git a/backend/Options/Rules/MissingFigureCaptionRuleOptions.cs b/backend/Options/Rules/MissingFigureCaptionRuleOptions.cs new file mode 100644 index 0000000..ff06a63 --- /dev/null +++ b/backend/Options/Rules/MissingFigureCaptionRuleOptions.cs @@ -0,0 +1,6 @@ +namespace backend.RuleOptions; + +public sealed class MissingFigureCaptionRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:MissingFigureCaptionRule"; +} diff --git a/backend/Program.cs b/backend/Program.cs index 5f44ac2..b849884 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -66,6 +66,10 @@ "LineSpacingDependencyRule:TargetLineSpacingTwips must be greater than 0.") .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(MissingFigureCaptionRuleOptions.SectionName)) + .ValidateOnStart(); + builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(ListPunctuationConsistencyRuleOptions.SectionName)) .ValidateOnStart(); diff --git a/backend/Rules/MissingFigureCaptionRule.cs b/backend/Rules/MissingFigureCaptionRule.cs index 78ad44a..782074d 100644 --- a/backend/Rules/MissingFigureCaptionRule.cs +++ b/backend/Rules/MissingFigureCaptionRule.cs @@ -1,9 +1,12 @@ using backend.Models; +using backend.RuleOptions; using backend.Services.Comments; using backend.Services.Results; +using backend.Services.Rules; using backend.Services.Structure; using Backend.Models; using DocumentFormat.OpenXml.Packaging; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; namespace backend.Rules; @@ -13,13 +16,32 @@ namespace backend.Rules; /// public class MissingFigureCaptionRule : IValidationRule { - public string Name => "MissingFigureCaptionRule"; + public const string RuleId = nameof(MissingFigureCaptionRule); + + private readonly IRuleConfigurationService _ruleConfigurationService; + + public string Name => RuleId; + + public MissingFigureCaptionRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var missingFigureCaptionOptions = options ?? Options.Create(new MissingFigureCaptionRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + missingFigureCaptionOptions: missingFigureCaptionOptions); + } public IEnumerable Validate( WordprocessingDocument doc, UniversityConfig config, DocumentCommentService? commentService = null) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); foreach (var association in FigureCaptionDetector.AssociateFiguresWithCaptions( @@ -31,13 +53,15 @@ public IEnumerable Validate( continue; var message = "Figure has no caption - add a figure caption below the figure."; - errors.Add(ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, message, association.Figure.ParagraphIndex, "[Figure]", - ParagraphIndexKind.Descendant)); + ParagraphIndexKind.Descendant); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); commentService?.AddCommentToParagraph(doc, association.Figure.Paragraph, message); } diff --git a/backend/Services/Rules/RuleConfigurationService.cs b/backend/Services/Rules/RuleConfigurationService.cs index e162a40..4cff623 100644 --- a/backend/Services/Rules/RuleConfigurationService.cs +++ b/backend/Services/Rules/RuleConfigurationService.cs @@ -16,6 +16,7 @@ public sealed class RuleConfigurationService : IRuleConfigurationService private readonly HeadingStyleUsageRuleOptions _headingStyleUsageOptions; private readonly HierarchyDepthRuleOptions _hierarchyDepthOptions; private readonly LineSpacingDependencyRuleOptions _lineSpacingDependencyOptions; + private readonly MissingFigureCaptionRuleOptions _missingFigureCaptionOptions; private readonly ListPunctuationConsistencyRuleOptions _listPunctuationConsistencyOptions; private readonly ListIndentationConsistencyRuleOptions _listIndentationConsistencyOptions; @@ -25,6 +26,7 @@ public RuleConfigurationService( IOptions? headingStyleUsageOptions = null, IOptions? hierarchyDepthOptions = null, IOptions? lineSpacingDependencyOptions = null, + IOptions? missingFigureCaptionOptions = null, IOptions? listPunctuationConsistencyOptions = null, IOptions? listIndentationConsistencyOptions = null) { @@ -33,6 +35,7 @@ public RuleConfigurationService( _headingStyleUsageOptions = headingStyleUsageOptions?.Value ?? new HeadingStyleUsageRuleOptions(); _hierarchyDepthOptions = hierarchyDepthOptions?.Value ?? new HierarchyDepthRuleOptions(); _lineSpacingDependencyOptions = lineSpacingDependencyOptions?.Value ?? new LineSpacingDependencyRuleOptions(); + _missingFigureCaptionOptions = missingFigureCaptionOptions?.Value ?? new MissingFigureCaptionRuleOptions(); _listPunctuationConsistencyOptions = listPunctuationConsistencyOptions?.Value ?? new ListPunctuationConsistencyRuleOptions(); _listIndentationConsistencyOptions = listIndentationConsistencyOptions?.Value ?? new ListIndentationConsistencyRuleOptions(); } @@ -54,6 +57,9 @@ public bool IsRuleAvailable(string ruleId) if (IsLineSpacingDependencyRule(ruleId)) return _lineSpacingDependencyOptions.Availability != RuleAvailability.Hidden; + if (IsMissingFigureCaptionRule(ruleId)) + return _missingFigureCaptionOptions.Availability != RuleAvailability.Hidden; + if (IsListPunctuationConsistencyRule(ruleId)) return _listPunctuationConsistencyOptions.Availability != RuleAvailability.Hidden; @@ -83,6 +89,9 @@ public string ResolveSeverity( if (IsLineSpacingDependencyRule(ruleId)) return ValidationSeverity.Normalize(_lineSpacingDependencyOptions.Severity.ToString()); + if (IsMissingFigureCaptionRule(ruleId)) + return ValidationSeverity.Normalize(_missingFigureCaptionOptions.Severity.ToString()); + if (IsListPunctuationConsistencyRule(ruleId)) return ValidationSeverity.Normalize(_listPunctuationConsistencyOptions.Severity.ToString()); @@ -109,6 +118,9 @@ public RuleDefinition ApplyConfiguration(RuleDefinition definition) if (IsLineSpacingDependencyRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_lineSpacingDependencyOptions.Severity.ToString()) }; + if (IsMissingFigureCaptionRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_missingFigureCaptionOptions.Severity.ToString()) }; + if (IsListPunctuationConsistencyRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_listPunctuationConsistencyOptions.Severity.ToString()) }; @@ -158,6 +170,14 @@ private static bool IsLineSpacingDependencyRule(string ruleId) StringComparison.OrdinalIgnoreCase); } + private static bool IsMissingFigureCaptionRule(string ruleId) + { + return string.Equals( + ruleId, + MissingFigureCaptionRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + private static bool IsListPunctuationConsistencyRule(string ruleId) { return string.Equals( diff --git a/backend/appsettings.json b/backend/appsettings.json index 5e2a8b4..f4f7063 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -33,6 +33,10 @@ "Severity": "Error", "TargetLineSpacingTwips": 360 }, + "MissingFigureCaptionRule": { + "Availability": "Available", + "Severity": "Error" + }, "ListPunctuationConsistencyRule": { "Availability": "Available", "Severity": "Error" From 9d5a30231a2212a0adc9c21baaf8d3f113268d5a Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:33:24 +0200 Subject: [PATCH 18/77] refactor(tests): remove deprecated figure caption rules test file --- .../Rules/FigureCaptionRulesTests.cs | 462 ------------------ 1 file changed, 462 deletions(-) delete mode 100644 backend.Tests/Rules/FigureCaptionRulesTests.cs diff --git a/backend.Tests/Rules/FigureCaptionRulesTests.cs b/backend.Tests/Rules/FigureCaptionRulesTests.cs deleted file mode 100644 index 46db953..0000000 --- a/backend.Tests/Rules/FigureCaptionRulesTests.cs +++ /dev/null @@ -1,462 +0,0 @@ -using backend.Models; -using backend.Rules; -using backend.Tests.Helpers; -using Backend.Models; -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using A = DocumentFormat.OpenXml.Drawing; -using C = DocumentFormat.OpenXml.Drawing.Charts; -using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; -using PIC = DocumentFormat.OpenXml.Drawing.Pictures; - -namespace backend.Tests.Rules; - -public class FigureCaptionRulesTests -{ - private const string CaptionStyleId = "CustomLegend"; - - private readonly MissingFigureCaptionRule _missingRule = new(); - private readonly FigureCaptionPositionRule _positionRule = new(); - private readonly FigureCaptionStyleRule _styleRule = new(); - private readonly FigureCaptionFormatRule _formatRule = new(); - private readonly FigureCaptionAutomaticNumberingRule _numberingRule = new(); - - private static UniversityConfig CreateConfig() => new(); - - [Fact] - public void ImageWithCorrectAutomaticCaptionBelow_ReturnsNoFigureCaptionResults() - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateAutomaticCaption("Rys. ", "1", " Schemat architektury systemu")); - - Assert.Empty(_missingRule.Validate(docx.Document, CreateConfig())); - Assert.Empty(_positionRule.Validate(docx.Document, CreateConfig())); - Assert.Empty(_styleRule.Validate(docx.Document, CreateConfig())); - Assert.Empty(_formatRule.Validate(docx.Document, CreateConfig())); - Assert.Empty(_numberingRule.Validate(docx.Document, CreateConfig())); - } - - [Fact] - public void ImageInsideTableWithoutCaption_ReturnsMissingCaptionError() - { - using var docx = CreateDocx( - CreateSingleCellTable( - CreatePictureParagraph())); - var config = CreateConfig(); - - var missing = _missingRule.Validate(docx.Document, config).ToList(); - - Assert.Single(missing); - Assert.Equal("MissingFigureCaptionRule", missing[0].RuleName); - } - - [Fact] - public void ImageInsideTableWithNormalTextCaptionBelow_ReturnsMissingAndStyleError() - { - using var docx = CreateDocx( - CreateSingleCellTable( - CreatePictureParagraph(), - CreateCaptionParagraph("Rys. 1 Schemat architektury systemu", styleId: "Normal"))); - var config = CreateConfig(); - - var missing = _missingRule.Validate(docx.Document, config).ToList(); - - Assert.Single(missing); - Assert.Equal("MissingFigureCaptionRule", missing[0].RuleName); - Assert.Empty(_positionRule.Validate(docx.Document, config)); - Assert.Empty(_formatRule.Validate(docx.Document, config)); - Assert.Empty(_numberingRule.Validate(docx.Document, config)); - - var styleErrors = _styleRule.Validate(docx.Document, config).ToList(); - - Assert.NotEmpty(styleErrors); - Assert.All(styleErrors, error => Assert.Equal("FigureCaptionStyleRule", error.RuleName)); - } - - [Fact] - public void ImageInsideTableWithAutomaticCaptionBelow_DoesNotFailFormatRule() - { - using var docx = CreateDocx( - CreateSingleCellTable( - CreatePictureParagraph(), - CreateAutomaticCaption("Rysunek ", "1", " Schemat architektury systemu"))); - var config = CreateConfig(); - - Assert.Empty(_missingRule.Validate(docx.Document, config)); - Assert.Empty(_positionRule.Validate(docx.Document, config)); - Assert.Empty(_formatRule.Validate(docx.Document, config)); - } - - [Fact] - public void ImageWithAutomaticToolbarCaptionWithoutDescription_DoesNotFailFormatRule() - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateAutomaticCaption("Rysunek ", "1", string.Empty)); - var config = CreateConfig(); - - Assert.Empty(_missingRule.Validate(docx.Document, config)); - Assert.Empty(_formatRule.Validate(docx.Document, config)); - } - - [Fact] - public void ImageWithoutCaption_ReturnsOnlyMissingCaptionError() - { - using var docx = CreateDocx(CreatePictureParagraph()); - var config = CreateConfig(); - - var missing = _missingRule.Validate(docx.Document, config).ToList(); - - Assert.Single(missing); - Assert.Equal("MissingFigureCaptionRule", missing[0].RuleName); - Assert.Equal(ValidationSeverity.Error, missing[0].Severity); - Assert.Empty(_positionRule.Validate(docx.Document, config)); - Assert.Empty(_styleRule.Validate(docx.Document, config)); - Assert.Empty(_formatRule.Validate(docx.Document, config)); - Assert.Empty(_numberingRule.Validate(docx.Document, config)); - } - - [Fact] - public void ImageWithEmptyCaptionStyledParagraph_ReturnsOnlyMissingCaptionError() - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateCaptionParagraph(" ")); - var config = CreateConfig(); - - var missing = _missingRule.Validate(docx.Document, config).ToList(); - - Assert.Single(missing); - Assert.Equal("MissingFigureCaptionRule", missing[0].RuleName); - Assert.Empty(_positionRule.Validate(docx.Document, config)); - Assert.Empty(_styleRule.Validate(docx.Document, config)); - Assert.Empty(_formatRule.Validate(docx.Document, config)); - Assert.Empty(_numberingRule.Validate(docx.Document, config)); - } - - [Fact] - public void ImageWithManuallyTypedValidCaption_WarnsAboutManualNumberingOnly() - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateCaptionParagraph("Rys. 1 Schemat architektury systemu")); - var config = CreateConfig(); - - Assert.Empty(_missingRule.Validate(docx.Document, config)); - Assert.Empty(_formatRule.Validate(docx.Document, config)); - - var warnings = _numberingRule.Validate(docx.Document, config).ToList(); - - Assert.Single(warnings); - Assert.Equal("FigureCaptionAutomaticNumberingRule", warnings[0].RuleName); - Assert.Equal(ValidationSeverity.Warning, warnings[0].Severity); - } - - [Theory] - [InlineData("Rys 1 Schemat architektury systemu")] - [InlineData("Rysunek 1 Schemat architektury systemu")] - public void ImageWithManuallyTypedValidCaptionUsingSupportedLabels_WarnsAboutManualNumberingOnly( - string captionText) - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateCaptionParagraph(captionText)); - var config = CreateConfig(); - - Assert.Empty(_missingRule.Validate(docx.Document, config)); - Assert.Empty(_formatRule.Validate(docx.Document, config)); - - var warnings = _numberingRule.Validate(docx.Document, config).ToList(); - - Assert.Single(warnings); - Assert.Equal("FigureCaptionAutomaticNumberingRule", warnings[0].RuleName); - Assert.Equal(ValidationSeverity.Warning, warnings[0].Severity); - } - - [Fact] - public void CaptionAboveImage_ReturnsPositionErrorWithoutMissingCaptionError() - { - using var docx = CreateDocx( - CreateAutomaticCaption("Rys. ", "1", " Schemat architektury systemu"), - CreatePictureParagraph()); - var config = CreateConfig(); - - Assert.Empty(_missingRule.Validate(docx.Document, config)); - - var errors = _positionRule.Validate(docx.Document, config).ToList(); - - Assert.Single(errors); - Assert.Equal("FigureCaptionPositionRule", errors[0].RuleName); - Assert.Equal(ValidationSeverity.Error, errors[0].Severity); - Assert.Contains("below", errors[0].Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void FigureBeforeAnotherFigure_DoesNotTreatLaterCaptionAsSeparatedCaption() - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreatePictureParagraph(), - CreateCaptionParagraph("Rys. 2 Schemat architektury systemu")); - var config = CreateConfig(); - - var missing = _missingRule.Validate(docx.Document, config).ToList(); - var position = _positionRule.Validate(docx.Document, config).ToList(); - - Assert.Single(missing); - Assert.Equal("MissingFigureCaptionRule", missing[0].RuleName); - Assert.Empty(position); - } - - [Fact] - public void ZeroWidthSpacingBetweenFigureAndCaption_DoesNotTriggerPositionError() - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateCaptionParagraph("\u200B"), - CreateCaptionParagraph("Rys. 1 Schemat architektury systemu")); - var config = CreateConfig(); - - Assert.Empty(_missingRule.Validate(docx.Document, config)); - Assert.Empty(_positionRule.Validate(docx.Document, config)); - } - - [Fact] - public void CaptionWithWrongLabel_ReturnsFormatErrorWithoutMissingCaptionError() - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateCaptionParagraph("Obrazek 1 Schemat architektury systemu")); - var config = CreateConfig(); - - Assert.Empty(_missingRule.Validate(docx.Document, config)); - - var errors = _formatRule.Validate(docx.Document, config).ToList(); - - Assert.Single(errors); - Assert.Equal("FigureCaptionFormatRule", errors[0].RuleName); - Assert.Equal(ValidationSeverity.Error, errors[0].Severity); - } - - [Fact] - public void CaptionWithWrongStyle_ReturnsStyleError() - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateCaptionParagraph( - "Rys. 1 Schemat architektury systemu", - styleId: "Normal", - includeDirectCaptionFormatting: true)); - - var errors = _styleRule.Validate(docx.Document, CreateConfig()).ToList(); - - Assert.Single(errors); - Assert.Equal("FigureCaptionStyleRule", errors[0].RuleName); - Assert.Equal(ValidationSeverity.Error, errors[0].Severity); - Assert.Contains("Caption style", errors[0].Message); - } - - [Fact] - public void TextBoxOnlyDrawing_DoesNotRequireFigureCaption() - { - using var docx = CreateDocx(CreateTextBoxOnlyParagraph()); - - var errors = _missingRule.Validate(docx.Document, CreateConfig()).ToList(); - - Assert.Empty(errors); - } - - [Fact] - public void ChartDrawing_IsTreatedAsFigureCandidate() - { - using var docx = CreateDocx(CreateChartParagraph()); - - var errors = _missingRule.Validate(docx.Document, CreateConfig()).ToList(); - - Assert.Single(errors); - Assert.Equal("MissingFigureCaptionRule", errors[0].RuleName); - } - - [Theory] - [InlineData("Rys. 1 Schemat architektury systemu")] - [InlineData("Rys. 1")] - [InlineData("Rys 1 Schemat architektury systemu")] - [InlineData("Rys 1")] - [InlineData("Rys. 1: Schemat architektury systemu")] - [InlineData("Rysunek 1 Schemat architektury systemu")] - [InlineData("Rysunek 1")] - [InlineData("Rys. 2.1 Diagram klas")] - [InlineData("Rys. 2.1")] - public void AcceptedVisibleCaptionFormats_PassFormatRule(string captionText) - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateCaptionParagraph(captionText)); - - var errors = _formatRule.Validate(docx.Document, CreateConfig()).ToList(); - - Assert.Empty(errors); - } - - [Theory] - [InlineData("Diagram 1 Schemat architektury systemu")] - [InlineData("Rysunek: 1 Schemat architektury systemu")] - public void InvalidVisibleCaptionFormats_FailFormatRule(string captionText) - { - using var docx = CreateDocx( - CreatePictureParagraph(), - CreateCaptionParagraph(captionText)); - - var errors = _formatRule.Validate(docx.Document, CreateConfig()).ToList(); - - Assert.Single(errors); - Assert.Equal("FigureCaptionFormatRule", errors[0].RuleName); - } - - private static InMemoryDocx CreateDocx(params OpenXmlElement[] bodyChildren) - { - var stream = new MemoryStream(); - var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); - - var mainPart = doc.AddMainDocumentPart(); - mainPart.Document = new Document(new Body()); - AddStyles(mainPart); - - foreach (var child in bodyChildren) - { - mainPart.Document.Body!.Append(child); - } - - mainPart.Document.Save(); - return new InMemoryDocx(doc, stream); - } - - private static void AddStyles(MainDocumentPart mainPart) - { - var stylesPart = mainPart.AddNewPart(); - stylesPart.Styles = new Styles( - new Style( - new StyleName { Val = "Normal" }) - { - Type = StyleValues.Paragraph, - Default = true, - StyleId = "Normal" - }, - new Style( - new StyleName { Val = "Legenda" }, - new StyleParagraphProperties( - new Justification { Val = JustificationValues.Center }, - new Indentation { Left = "0", FirstLine = "0" }), - new StyleRunProperties( - new FontSize { Val = "22" })) - { - Type = StyleValues.Paragraph, - StyleId = CaptionStyleId - }); - } - - private static Paragraph CreatePictureParagraph() - { - return new Paragraph(new Run(CreatePictureDrawing())); - } - - private static Table CreateSingleCellTable(params OpenXmlElement[] cellContent) - { - var cell = new TableCell(); - foreach (var element in cellContent) - { - cell.Append(element.CloneNode(true)); - } - - return new Table( - new TableProperties( - new TableBorders( - new TopBorder { Val = BorderValues.Nil }, - new BottomBorder { Val = BorderValues.Nil }, - new LeftBorder { Val = BorderValues.Nil }, - new RightBorder { Val = BorderValues.Nil }, - new InsideHorizontalBorder { Val = BorderValues.Nil }, - new InsideVerticalBorder { Val = BorderValues.Nil })), - new TableRow(cell)); - } - - private static Paragraph CreateChartParagraph() - { - return new Paragraph(new Run(new Drawing( - new DW.Inline( - new A.Graphic( - new A.GraphicData(new C.ChartReference { Id = "rIdChart1" }) - { - Uri = "http://schemas.openxmlformats.org/drawingml/2006/chart" - }))))); - } - - private static Paragraph CreateTextBoxOnlyParagraph() - { - return new Paragraph(new Run(new Drawing( - new DW.Inline( - new A.Graphic( - new A.GraphicData( - new A.TextBody( - new A.BodyProperties(), - new A.ListStyle(), - new A.Paragraph( - new A.Run(new A.Text("Only text"))))) - { - Uri = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape" - }))))); - } - - private static Drawing CreatePictureDrawing() - { - return new Drawing( - new DW.Inline( - new A.Graphic( - new A.GraphicData( - new PIC.Picture()) - { - Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" - }))); - } - - private static Paragraph CreateAutomaticCaption( - string label, - string number, - string description) - { - return new Paragraph( - new ParagraphProperties(new ParagraphStyleId { Val = CaptionStyleId }), - new Run(new Text(label) { Space = SpaceProcessingModeValues.Preserve }), - new SimpleField( - new Run(new Text(number))) - { - Instruction = "SEQ Rys \\* ARABIC" - }, - new Run(new Text(description) { Space = SpaceProcessingModeValues.Preserve })); - } - - private static Paragraph CreateCaptionParagraph( - string text, - string styleId = CaptionStyleId, - bool includeDirectCaptionFormatting = false) - { - var paragraphProperties = new ParagraphProperties(new ParagraphStyleId { Val = styleId }); - var runProperties = new RunProperties(); - - if (includeDirectCaptionFormatting) - { - paragraphProperties.Append( - new Justification { Val = JustificationValues.Center }, - new Indentation { Left = "0", FirstLine = "0" }); - runProperties.Append(new FontSize { Val = "22" }); - } - - return new Paragraph( - paragraphProperties, - new Run( - runProperties, - new Text(text) { Space = SpaceProcessingModeValues.Preserve })); - } -} From 0402e5729653725e82494aba988c21c2bb2a87d5 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:33:24 +0200 Subject: [PATCH 19/77] refactor(rules): update core rule definition and validation result factory --- backend.Tests/Services/ValidationResultFactoryTests.cs | 4 ++-- backend/Rules/RuleDefinition.cs | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend.Tests/Services/ValidationResultFactoryTests.cs b/backend.Tests/Services/ValidationResultFactoryTests.cs index b2afd70..37a577a 100644 --- a/backend.Tests/Services/ValidationResultFactoryTests.cs +++ b/backend.Tests/Services/ValidationResultFactoryTests.cs @@ -56,11 +56,11 @@ public void IsErrorSetter_KeepsSeverityBackwardCompatible() } [Fact] - public void RuleCatalog_KeepsManualTocAsNonSelectableWarning() + public void RuleCatalog_KeepsManualTocAsSelectableWarning() { var definition = RuleCatalog.GetDefinition("Manual table of contents"); - Assert.False(definition.Selectable); + Assert.True(definition.Selectable); Assert.Equal("Warning", definition.DefaultSeverity); Assert.Equal("Structure", definition.Category); } diff --git a/backend/Rules/RuleDefinition.cs b/backend/Rules/RuleDefinition.cs index 62113c9..5e4a4d3 100644 --- a/backend/Rules/RuleDefinition.cs +++ b/backend/Rules/RuleDefinition.cs @@ -105,15 +105,14 @@ public static class RuleCatalog ValidationSeverity.Warning), ["CheckTableOfContents"] = new( "CheckTableOfContents", - "Table of Contents", + "Automatic Table of Contents", RuleCategories.Structure, ValidationSeverity.Error), ["Manual table of contents"] = new( "Manual table of contents", - "Manual table of contents", + "Manual Table of Contents", RuleCategories.Structure, - ValidationSeverity.Warning, - Selectable: false), + ValidationSeverity.Warning), ["EmptySectionStructureRule"] = new( "EmptySectionStructureRule", "Empty Sections", From 98192cf1d438e381d274a0c4c7c3be46c8396eb0 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:33:25 +0200 Subject: [PATCH 20/77] feat(rules): implement dynamic configuration for table of contents rules --- backend.Tests/Rules/TocRuleTests.cs | 53 ++++++++++++++- .../Rules/ManualTableOfContentsRuleOptions.cs | 11 +++ backend/Options/Rules/TocRuleOptions.cs | 6 ++ backend/Rules/ManualTableOfContentsRule.cs | 67 +++++++++++++++++++ backend/Rules/TOCRule.cs | 50 +++++++------- 5 files changed, 160 insertions(+), 27 deletions(-) create mode 100644 backend/Options/Rules/ManualTableOfContentsRuleOptions.cs create mode 100644 backend/Options/Rules/TocRuleOptions.cs create mode 100644 backend/Rules/ManualTableOfContentsRule.cs diff --git a/backend.Tests/Rules/TocRuleTests.cs b/backend.Tests/Rules/TocRuleTests.cs index 12d92fc..f831b67 100644 --- a/backend.Tests/Rules/TocRuleTests.cs +++ b/backend.Tests/Rules/TocRuleTests.cs @@ -23,6 +23,18 @@ public void Validate_WhenAutomaticTocExists_ReturnsNoIssue() Assert.Empty(results); } + [Fact] + public void ManualRule_WhenAutomaticTocExists_ReturnsNoIssue() + { + using var docx = CreateDocxWithAutomaticToc(); + + var results = new ManualTableOfContentsRule() + .Validate(docx.Document, new UniversityConfig()) + .ToList(); + + Assert.Empty(results); + } + [Theory] [InlineData("Spis tre\u015bci")] [InlineData("Spis tresci")] @@ -35,15 +47,50 @@ public void Validate_WhenManualTocHeadingExists_ReturnsStructureWarning(string h { using var docx = CreateDocx(heading, "Chapter 1"); - var result = Assert.Single(new TocRule().Validate(docx.Document, new UniversityConfig())); + var result = Assert.Single(new ManualTableOfContentsRule().Validate(docx.Document, new UniversityConfig())); Assert.False(result.IsError); - Assert.Equal(TocRule.ManualTableOfContentsRuleName, result.RuleName); + Assert.Equal(ManualTableOfContentsRule.RuleId, result.RuleName); Assert.Equal("Structure", result.Category); Assert.Equal(1, result.Location.Paragraph); Assert.Contains("no automatic Word TOC field", result.Message); } + [Fact] + public void TocRule_WhenManualTocHeadingExists_ReturnsMissingAutomaticTocError() + { + using var docx = CreateDocx("Spis tresci", "Chapter 1"); + + var result = Assert.Single(new TocRule().Validate(docx.Document, new UniversityConfig())); + + Assert.True(result.IsError); + Assert.Equal(nameof(FormattingConfig.CheckTableOfContents), result.RuleName); + Assert.Equal("Document is missing an automatic Word Table of Contents.", result.Message); + } + + [Fact] + public void Validate_WhenManualTocHeadingExistsAndBothRulesRun_ReturnsTwoIssues() + { + using var stream = CreateDocxStream("Spis tresci", "Chapter 1"); + + var service = new ThesisValidatorService(new IValidationRule[] + { + new TocRule(), + new ManualTableOfContentsRule() + }); + + var results = service.Validate( + stream, + new UniversityConfig(), + [TocRule.RuleId, ManualTableOfContentsRule.RuleId]) + .Results + .ToList(); + + Assert.Equal(2, results.Count); + Assert.Contains(results, result => result.RuleName == TocRule.RuleId); + Assert.Contains(results, result => result.RuleName == ManualTableOfContentsRule.RuleId); + } + [Fact] public void Validate_WhenNoAutomaticOrManualTocExists_KeepsMissingTocError() { @@ -53,7 +100,7 @@ public void Validate_WhenNoAutomaticOrManualTocExists_KeepsMissingTocError() Assert.True(result.IsError); Assert.Equal(nameof(FormattingConfig.CheckTableOfContents), result.RuleName); - Assert.Equal("Document is missing a Table of Contents.", result.Message); + Assert.Equal("Document is missing an automatic Word Table of Contents.", result.Message); } [Fact] diff --git a/backend/Options/Rules/ManualTableOfContentsRuleOptions.cs b/backend/Options/Rules/ManualTableOfContentsRuleOptions.cs new file mode 100644 index 0000000..642fdc4 --- /dev/null +++ b/backend/Options/Rules/ManualTableOfContentsRuleOptions.cs @@ -0,0 +1,11 @@ +namespace backend.RuleOptions; + +public sealed class ManualTableOfContentsRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:ManualTableOfContents"; + + public ManualTableOfContentsRuleOptions() + { + Severity = RuleSeverity.Warning; + } +} diff --git a/backend/Options/Rules/TocRuleOptions.cs b/backend/Options/Rules/TocRuleOptions.cs new file mode 100644 index 0000000..ed61873 --- /dev/null +++ b/backend/Options/Rules/TocRuleOptions.cs @@ -0,0 +1,6 @@ +namespace backend.RuleOptions; + +public sealed class TocRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:CheckTableOfContents"; +} diff --git a/backend/Rules/ManualTableOfContentsRule.cs b/backend/Rules/ManualTableOfContentsRule.cs new file mode 100644 index 0000000..4269ffe --- /dev/null +++ b/backend/Rules/ManualTableOfContentsRule.cs @@ -0,0 +1,67 @@ +using backend.Models; +using backend.RuleOptions; +using backend.Services.Comments; +using backend.Services.Extraction; +using backend.Services.Results; +using backend.Services.Rules; +using backend.Services.Structure; +using Backend.Models; +using DocumentFormat.OpenXml.Packaging; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; + +namespace Rules; + +public class ManualTableOfContentsRule : IValidationRule +{ + public const string RuleId = "Manual table of contents"; + + private const string ManualTableOfContentsMessage = + "A table of contents section was detected, but no automatic Word TOC field was found. The table of contents was probably created manually and may become inconsistent with the document structure."; + + private readonly IRuleConfigurationService _ruleConfigurationService; + + public string Name => RuleId; + + public ManualTableOfContentsRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var manualTableOfContentsOptions = options ?? Options.Create(new ManualTableOfContentsRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + manualTableOfContentsOptions: manualTableOfContentsOptions); + } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + + var detection = TableOfContentsDetectionService.Detect(doc); + if (detection.Kind != TableOfContentsKind.Manual) + return []; + + var result = ValidationResultFactory.ForParagraph( + Name, + config, + ManualTableOfContentsMessage, + detection.ParagraphIndex, + TextExtractionService.Truncate(detection.Text ?? string.Empty, 80), + severity: ValidationSeverity.Warning); + result.Severity = _ruleConfigurationService.ResolveSeverity( + Name, + config, + ValidationSeverity.Warning); + + if (detection.Paragraph is not null && documentCommentService is not null) + documentCommentService.AddCommentToParagraph(doc, detection.Paragraph, ManualTableOfContentsMessage); + + return [result]; + } +} diff --git a/backend/Rules/TOCRule.cs b/backend/Rules/TOCRule.cs index 41b7018..685ff1b 100644 --- a/backend/Rules/TOCRule.cs +++ b/backend/Rules/TOCRule.cs @@ -1,60 +1,62 @@ using backend.Models; +using backend.RuleOptions; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Comments; -using backend.Services.Extraction; using backend.Services.Results; +using backend.Services.Rules; using backend.Services.Structure; namespace Rules; public class TocRule : IValidationRule { - public const string ManualTableOfContentsRuleName = "Manual table of contents"; + public const string RuleId = nameof(FormattingConfig.CheckTableOfContents); - private const string ManualTableOfContentsMessage = - "A table of contents section was detected, but no automatic Word TOC field was found. The table of contents was probably created manually and may become inconsistent with the document structure."; + private readonly IRuleConfigurationService _ruleConfigurationService; - public string Name => nameof(FormattingConfig.CheckTableOfContents); + public string Name => RuleId; + + public TocRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var tocOptions = options ?? Options.Create(new TocRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + tocOptions: tocOptions); + } public IEnumerable Validate( WordprocessingDocument doc, UniversityConfig config, DocumentCommentService? documentCommentService = null) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); var detection = DetectTableOfContents(doc); if (detection.Kind == TableOfContentsKind.Automatic) return errors; - if (detection.Kind == TableOfContentsKind.Manual) - { - errors.Add(ValidationResultFactory.ForParagraph( - ManualTableOfContentsRuleName, - config, - ManualTableOfContentsMessage, - detection.ParagraphIndex, - TextExtractionService.Truncate(detection.Text ?? string.Empty, 80), - severity: ValidationSeverity.Warning)); - - if (detection.Paragraph is not null && documentCommentService is not null) - documentCommentService.AddCommentToParagraph(doc, detection.Paragraph, ManualTableOfContentsMessage); - - return errors; - } - var body = doc.MainDocumentPart?.Document.Body; var firstRun = body?.Descendants().FirstOrDefault(); - errors.Add(ValidationResultFactory.Create( + var missingTocResult = ValidationResultFactory.Create( Name, config, - "Document is missing a Table of Contents.")); + "Document is missing an automatic Word Table of Contents."); + missingTocResult.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(missingTocResult); if (firstRun != null && documentCommentService != null) - documentCommentService.AddCommentToRun(doc, firstRun, "Document is missing a Table of Contents"); + documentCommentService.AddCommentToRun(doc, firstRun, "Document is missing an automatic Word Table of Contents"); return errors; } From 40f1c1779304d3e0695d3bc4c5f60490d014b67e Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:33:25 +0200 Subject: [PATCH 21/77] feat(rules): implement dynamic configuration for paragraph formatting rules --- .../Rules/ParagraphIndentRuleTests.cs | 93 ++++++++++++++++++ .../Rules/ParagraphIndentRuleOptions.cs | 10 ++ .../Options/Rules/SingleSpaceRuleOptions.cs | 6 ++ .../Rules/TextJustificationRuleOptions.cs | 6 ++ backend/Rules/ParagraphIndentRule.cs | 94 ++++++++++++++----- backend/Rules/SingleSpaceRule.cs | 28 +++++- backend/Rules/TextJustificationRule.cs | 28 +++++- 7 files changed, 232 insertions(+), 33 deletions(-) create mode 100644 backend/Options/Rules/ParagraphIndentRuleOptions.cs create mode 100644 backend/Options/Rules/SingleSpaceRuleOptions.cs create mode 100644 backend/Options/Rules/TextJustificationRuleOptions.cs diff --git a/backend.Tests/Rules/ParagraphIndentRuleTests.cs b/backend.Tests/Rules/ParagraphIndentRuleTests.cs index 49b5ad2..60c2398 100644 --- a/backend.Tests/Rules/ParagraphIndentRuleTests.cs +++ b/backend.Tests/Rules/ParagraphIndentRuleTests.cs @@ -25,6 +25,29 @@ private static InMemoryDocx CreateDocxWithParagraph(Paragraph paragraph) return new InMemoryDocx(doc, stream); } + private static InMemoryDocx CreateDocxWithDefaultFirstLineIndent(Paragraph paragraph, int firstLineTwips) + { + var stream = new MemoryStream(); + var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); + + var mainPart = doc.AddMainDocumentPart(); + var stylesPart = mainPart.AddNewPart(); + stylesPart.Styles = new Styles( + new Style( + new StyleParagraphProperties( + new Indentation { FirstLine = firstLineTwips.ToString() })) + { + Type = StyleValues.Paragraph, + Default = true, + StyleId = "Normal" + }); + + mainPart.Document = new Document(new Body(paragraph)); + mainPart.Document.Save(); + + return new InMemoryDocx(doc, stream); + } + private static Paragraph CreateParagraph(string text, string? styleId = null) { var paragraphProperties = new ParagraphProperties(); @@ -46,6 +69,33 @@ private static Paragraph CreateParagraphWithFont(string text, string fontFamily) new Text(text))); } + private static Paragraph CreateParagraphWithLeadingTab(string text) + { + return new Paragraph( + new ParagraphProperties(), + new Run(new TabChar()), + new Run(new Text(text))); + } + + private static Paragraph CreateParagraphWithFirstLineIndent(string text, int firstLineTwips) + { + return new Paragraph( + new ParagraphProperties( + new Indentation { FirstLine = firstLineTwips.ToString() }), + new Run(new Text(text))); + } + + private static Paragraph CreateListParagraphWithLeadingTab(string text) + { + return new Paragraph( + new ParagraphProperties( + new NumberingProperties( + new NumberingLevelReference { Val = 0 }, + new NumberingId { Val = 1 })), + new Run(new TabChar()), + new Run(new Text(text))); + } + [Fact] public void NormalParagraphWithoutIndent_ReturnsError() { @@ -56,6 +106,49 @@ public void NormalParagraphWithoutIndent_ReturnsError() Assert.Single(errors); } + [Fact] + public void ParagraphWithAllowedDirectIndent_ReturnsNoErrors() + { + using var docx = CreateDocxWithParagraph(CreateParagraphWithFirstLineIndent("Normal paragraph", 709)); + + var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + + Assert.Empty(errors); + } + + [Fact] + public void ParagraphWithAllowedInheritedIndent_ReturnsNoErrors() + { + using var docx = CreateDocxWithDefaultFirstLineIndent(CreateParagraph("Normal paragraph"), 709); + + var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + + Assert.Empty(errors); + } + + [Fact] + public void ParagraphWithAllowedInheritedIndentAndLeadingTab_ReturnsError() + { + using var docx = CreateDocxWithDefaultFirstLineIndent( + CreateParagraphWithLeadingTab("Normal paragraph"), + 709); + + var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + + var error = Assert.Single(errors); + Assert.Contains("TAB character", error.Message); + } + + [Fact] + public void ListParagraphWithLeadingTab_ReturnsNoErrors() + { + using var docx = CreateDocxWithParagraph(CreateListParagraphWithLeadingTab("List item")); + + var errors = _rule.Validate(docx.Document, CreateConfig(), null).ToList(); + + Assert.Empty(errors); + } + [Fact] public void ExcludedStylePattern_ReturnsNoErrors() { diff --git a/backend/Options/Rules/ParagraphIndentRuleOptions.cs b/backend/Options/Rules/ParagraphIndentRuleOptions.cs new file mode 100644 index 0000000..20a82e6 --- /dev/null +++ b/backend/Options/Rules/ParagraphIndentRuleOptions.cs @@ -0,0 +1,10 @@ +namespace backend.RuleOptions; + +public sealed class ParagraphIndentRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:RequiredIndentCm"; + + public int[] AllowedIndentTwips { get; set; } = [567, 709]; + + public int ToleranceTwips { get; set; } = 60; +} diff --git a/backend/Options/Rules/SingleSpaceRuleOptions.cs b/backend/Options/Rules/SingleSpaceRuleOptions.cs new file mode 100644 index 0000000..7832163 --- /dev/null +++ b/backend/Options/Rules/SingleSpaceRuleOptions.cs @@ -0,0 +1,6 @@ +namespace backend.RuleOptions; + +public sealed class SingleSpaceRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:SingleSpaceRule"; +} diff --git a/backend/Options/Rules/TextJustificationRuleOptions.cs b/backend/Options/Rules/TextJustificationRuleOptions.cs new file mode 100644 index 0000000..190abab --- /dev/null +++ b/backend/Options/Rules/TextJustificationRuleOptions.cs @@ -0,0 +1,6 @@ +namespace backend.RuleOptions; + +public sealed class TextJustificationRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:TextJustificationRule"; +} diff --git a/backend/Rules/ParagraphIndentRule.cs b/backend/Rules/ParagraphIndentRule.cs index 7d8e828..35c2433 100644 --- a/backend/Rules/ParagraphIndentRule.cs +++ b/backend/Rules/ParagraphIndentRule.cs @@ -1,7 +1,9 @@ using backend.Models; +using backend.RuleOptions; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Analysis; using backend.Services.CodeBlocks; @@ -9,6 +11,7 @@ using backend.Services.Extraction; using backend.Services.Formatting; using backend.Services.Results; +using backend.Services.Rules; using backend.Services.Skipping; using backend.Services.Structure; @@ -17,14 +20,26 @@ namespace backend.Rules; public class ParagraphIndentRule : IValidationRule { private readonly ICodeBlockDetector _codeBlockDetector; + private readonly IRuleConfigurationService _ruleConfigurationService; + private readonly ParagraphIndentRuleOptions _options; - public string Name => nameof(LayoutConfig.RequiredIndentCm); + public const string RuleId = nameof(LayoutConfig.RequiredIndentCm); - private const int ToleranceTwips = 60; + public string Name => RuleId; - public ParagraphIndentRule(ICodeBlockDetector? codeBlockDetector = null) + public ParagraphIndentRule( + ICodeBlockDetector? codeBlockDetector = null, + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) { + var paragraphIndentOptions = options ?? Options.Create(new ParagraphIndentRuleOptions()); + _codeBlockDetector = codeBlockDetector ?? CodeBlockDetector.CreateDefault(); + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + paragraphIndentOptions: paragraphIndentOptions); + _options = paragraphIndentOptions.Value; } public IEnumerable Validate( @@ -32,8 +47,11 @@ public IEnumerable Validate( UniversityConfig config, DocumentCommentService? documentCommentService = null) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); - var allowedIndentsTwips = new[] { 567, 709 }; + var allowedIndentsTwips = _options.AllowedIndentTwips ?? []; foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.DescendantParagraphs(doc, config)) { @@ -55,19 +73,21 @@ public IEnumerable Validate( var firstLineIndent = FormattingResolutionService.ResolveFirstLineIndent(doc, paragraph); var startsWithTab = StartsWithTabCharacter(paragraph); - if (firstLineIndent == 0 && SkipDecisionService.IsListItem(paragraph)) + if (SkipDecisionService.IsListItem(paragraph)) continue; - if (startsWithTab && firstLineIndent == 0) + if (startsWithTab) { - var message = "Paragraph uses TAB character for indent instead of proper first-line indent formatting. Please use paragraph formatting (1.00 cm or 1.25 cm first-line indent) instead of TAB."; + var message = $"Paragraph uses TAB character for indent instead of proper first-line indent formatting. Please use paragraph formatting ({FormatAllowedIndents()} first-line indent) instead of TAB."; - errors.Add(ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, message, paragraphIndex, - TextExtractionService.GetPreview(paragraph, config, 50))); + TextExtractionService.GetPreview(paragraph, config, 50)); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); documentCommentService?.AddCommentToParagraph(doc, paragraph, message); continue; @@ -76,14 +96,16 @@ public IEnumerable Validate( if (!IsValidIndent(firstLineIndent, allowedIndentsTwips)) { var actualIndentCm = firstLineIndent / UnitConversion.TwipsPerCm; - var message = $"Paragraph has incorrect first line indent: {actualIndentCm:F2} cm. Expected 1.00 cm or 1.25 cm."; + var message = $"Paragraph has incorrect first line indent: {actualIndentCm:F2} cm. Expected {FormatAllowedIndents()}."; - errors.Add(ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, message, paragraphIndex, - TextExtractionService.GetPreview(paragraph, config, 50))); + TextExtractionService.GetPreview(paragraph, config, 50)); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); documentCommentService?.AddCommentToParagraph(doc, paragraph, message); } @@ -92,32 +114,54 @@ public IEnumerable Validate( return errors; } - private static bool IsValidIndent(int actualTwips, int[] allowedTwips) + private bool IsValidIndent(int actualTwips, int[] allowedTwips) { - return allowedTwips.Any(allowed => Math.Abs(actualTwips - allowed) <= ToleranceTwips); + return allowedTwips.Any(allowed => Math.Abs(actualTwips - allowed) <= _options.ToleranceTwips); } - private static bool StartsWithTabCharacter(Paragraph paragraph) + private string FormatAllowedIndents() { - var firstRun = paragraph.Elements().FirstOrDefault(); - if (firstRun == null) - return false; - - var firstChild = firstRun.Elements().FirstOrDefault(); - if (firstChild is TabChar) - return true; + var allowedIndentsTwips = _options.AllowedIndentTwips ?? []; + return allowedIndentsTwips.Length == 0 + ? "a configured indent" + : string.Join(" or ", allowedIndentsTwips.Select(indent => $"{indent / UnitConversion.TwipsPerCm:F2} cm")); + } - foreach (var child in firstRun.Elements()) + private static bool StartsWithTabCharacter(Paragraph paragraph) + { + foreach (var child in paragraph.Descendants()) { if (child is TabChar) return true; - if (child is Text) - break; + + if (child is Text text) + { + var textDecision = StartsWithTextTab(text.Text); + if (textDecision.HasValue) + return textDecision.Value; + } } return false; } + private static bool? StartsWithTextTab(string? text) + { + if (string.IsNullOrEmpty(text)) + return null; + + foreach (var ch in text) + { + if (ch == '\t') + return true; + + if (!char.IsWhiteSpace(ch) && !char.IsControl(ch)) + return false; + } + + return null; + } + private static bool IsCenteredOrRightAligned(WordprocessingDocument doc, Paragraph paragraph) { var justification = FormattingResolutionService.ResolveJustification(doc, paragraph); diff --git a/backend/Rules/SingleSpaceRule.cs b/backend/Rules/SingleSpaceRule.cs index 6819f2b..94d0640 100644 --- a/backend/Rules/SingleSpaceRule.cs +++ b/backend/Rules/SingleSpaceRule.cs @@ -1,13 +1,16 @@ using System.Text.RegularExpressions; using backend.Models; +using backend.RuleOptions; using Backend.Models; using DocumentFormat.OpenXml.Packaging; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Analysis; using backend.Services.CodeBlocks; using backend.Services.Comments; using backend.Services.Extraction; using backend.Services.Results; +using backend.Services.Rules; namespace Rules; @@ -17,13 +20,25 @@ namespace Rules; /// public partial class SingleSpaceRule : IValidationRule { + public const string RuleId = nameof(SingleSpaceRule); + private readonly ICodeBlockDetector _codeBlockDetector; + private readonly IRuleConfigurationService _ruleConfigurationService; - public string Name => "SingleSpaceRule"; + public string Name => RuleId; - public SingleSpaceRule(ICodeBlockDetector? codeBlockDetector = null) + public SingleSpaceRule( + ICodeBlockDetector? codeBlockDetector = null, + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) { + var singleSpaceOptions = options ?? Options.Create(new SingleSpaceRuleOptions()); + _codeBlockDetector = codeBlockDetector ?? CodeBlockDetector.CreateDefault(); + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + singleSpaceOptions: singleSpaceOptions); } // Regex to find 2 or more consecutive spaces @@ -32,6 +47,9 @@ public SingleSpaceRule(ICodeBlockDetector? codeBlockDetector = null) public IEnumerable Validate(WordprocessingDocument doc, UniversityConfig config, DocumentCommentService? documentCommentService) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.DescendantParagraphs(doc, config)) { @@ -52,7 +70,7 @@ public IEnumerable Validate(WordprocessingDocument doc, Univer var errorMessage = $"Multiple spaces found ({spaceCount} spaces). Only single spaces allowed between words. Context: \"{snippet}\""; - errors.Add(ValidationResultFactory.Create( + var result = ValidationResultFactory.Create( Name, config, errorMessage, @@ -62,7 +80,9 @@ public IEnumerable Validate(WordprocessingDocument doc, Univer CharacterOffset = match.Index, Length = match.Length, Text = snippet - })); + }); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); documentCommentService?.AddCommentToParagraph(doc, paragraph, errorMessage); } diff --git a/backend/Rules/TextJustificationRule.cs b/backend/Rules/TextJustificationRule.cs index b23c1bd..b7b0581 100644 --- a/backend/Rules/TextJustificationRule.cs +++ b/backend/Rules/TextJustificationRule.cs @@ -1,7 +1,9 @@ using backend.Models; +using backend.RuleOptions; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Analysis; using backend.Services.CodeBlocks; @@ -9,6 +11,7 @@ using backend.Services.Extraction; using backend.Services.Formatting; using backend.Services.Results; +using backend.Services.Rules; using backend.Services.Skipping; namespace Rules; @@ -19,13 +22,25 @@ namespace Rules; /// public class TextJustificationRule : IValidationRule { + public const string RuleId = nameof(TextJustificationRule); + private readonly ICodeBlockDetector _codeBlockDetector; + private readonly IRuleConfigurationService _ruleConfigurationService; - public string Name => "TextJustificationRule"; + public string Name => RuleId; - public TextJustificationRule(ICodeBlockDetector? codeBlockDetector = null) + public TextJustificationRule( + ICodeBlockDetector? codeBlockDetector = null, + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) { + var textJustificationOptions = options ?? Options.Create(new TextJustificationRuleOptions()); + _codeBlockDetector = codeBlockDetector ?? CodeBlockDetector.CreateDefault(); + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + textJustificationOptions: textJustificationOptions); } public IEnumerable Validate( @@ -33,6 +48,9 @@ public IEnumerable Validate( UniversityConfig config, DocumentCommentService? documentCommentService) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.DescendantParagraphs(doc, config)) { @@ -57,12 +75,14 @@ public IEnumerable Validate( var preview = TextExtractionService.Truncate(text, 50); var errorMessage = $"Paragraph is {alignmentName} aligned. Standard text must use full justification (both margins)."; - errors.Add(ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, errorMessage, paragraphIndex, - preview)); + preview); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); documentCommentService?.AddCommentToParagraph(doc, paragraph, errorMessage); } From c44555dfc8625a9b6018b95ca350e3982ad4cc2a Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:33:26 +0200 Subject: [PATCH 22/77] feat(rules): implement dynamic configuration for titles rule --- .../Rules/NoDotsInTitlesRuleOptions.cs | 18 ++++++++ backend/Rules/NoDotsInTitlesRule.cs | 45 +++++++++++++------ 2 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 backend/Options/Rules/NoDotsInTitlesRuleOptions.cs diff --git a/backend/Options/Rules/NoDotsInTitlesRuleOptions.cs b/backend/Options/Rules/NoDotsInTitlesRuleOptions.cs new file mode 100644 index 0000000..eada0e0 --- /dev/null +++ b/backend/Options/Rules/NoDotsInTitlesRuleOptions.cs @@ -0,0 +1,18 @@ +namespace backend.RuleOptions; + +public sealed class NoDotsInTitlesRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:NoDotsInTitlesRule"; + + public string[] TargetStylePatterns { get; set; } = + [ + "heading", + "nagwek", + "title", + "tytu", + "subtitle", + "podtytu", + "caption", + "podpis" + ]; +} diff --git a/backend/Rules/NoDotsInTitlesRule.cs b/backend/Rules/NoDotsInTitlesRule.cs index 6e3121e..48cbb55 100644 --- a/backend/Rules/NoDotsInTitlesRule.cs +++ b/backend/Rules/NoDotsInTitlesRule.cs @@ -1,13 +1,16 @@ using backend.Models; +using backend.RuleOptions; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; using backend.Services.Analysis; using backend.Services.Comments; using backend.Services.Extraction; using backend.Services.Formatting; using backend.Services.Results; +using backend.Services.Rules; namespace Rules; @@ -17,19 +20,31 @@ namespace Rules; /// public class NoDotsInTitlesRule : IValidationRule { - public string Name => "NoDotsInTitlesRule"; + public const string RuleId = nameof(NoDotsInTitlesRule); - // Style patterns that this rule applies to (case-insensitive) - private static readonly string[] TargetStylePatterns = - [ - "heading", "nagwek", // Headings (EN/PL) - "title", "tytu", // Title (EN/PL) - "subtitle", "podtytu", // Subtitle (EN/PL) - "caption", "podpis" // Caption (EN/PL) - ]; + private readonly IRuleConfigurationService _ruleConfigurationService; + private readonly NoDotsInTitlesRuleOptions _options; + + public string Name => RuleId; + + public NoDotsInTitlesRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var noDotsOptions = options ?? Options.Create(new NoDotsInTitlesRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + noDotsInTitlesOptions: noDotsOptions); + _options = noDotsOptions.Value; + } public IEnumerable Validate(WordprocessingDocument doc, UniversityConfig config, DocumentCommentService? documentCommentService) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.DescendantParagraphs(doc, config)) { @@ -51,12 +66,14 @@ public IEnumerable Validate(WordprocessingDocument doc, Univer var errorMessage = $"Title/Heading should not end with a period. Style: {styleId}. Text: \"{preview}\""; - errors.Add(ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, errorMessage, paragraphIndex, - preview)); + preview); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); documentCommentService?.AddCommentToParagraph(doc, paragraph, errorMessage); } @@ -65,14 +82,16 @@ public IEnumerable Validate(WordprocessingDocument doc, Univer return errors; } - private static bool HasTargetStyle(Paragraph paragraph) + private bool HasTargetStyle(Paragraph paragraph) { var styleId = StyleResolutionService.GetParagraphStyleId(paragraph); if (string.IsNullOrEmpty(styleId)) return false; var styleLower = styleId.ToLowerInvariant(); - return TargetStylePatterns.Any(pattern => styleLower.Contains(pattern)); + return (_options.TargetStylePatterns ?? []) + .Any(pattern => !string.IsNullOrWhiteSpace(pattern) + && styleLower.Contains(pattern.Trim().ToLowerInvariant())); } private static bool EndsWithSinglePeriod(string text) From 7928f31d75bb62423fe89b0f7317bd167bb91353 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:33:26 +0200 Subject: [PATCH 23/77] refactor(backend): wire up legacy formatting rule configurations and integration tests --- .../LegacyFormattingRuleConfigurationTests.cs | 428 ++++++++++++++++++ backend/Program.cs | 33 ++ .../Rules/RuleConfigurationService.cs | 120 +++++ backend/appsettings.json | 36 ++ 4 files changed, 617 insertions(+) create mode 100644 backend.Tests/Rules/LegacyFormattingRuleConfigurationTests.cs diff --git a/backend.Tests/Rules/LegacyFormattingRuleConfigurationTests.cs b/backend.Tests/Rules/LegacyFormattingRuleConfigurationTests.cs new file mode 100644 index 0000000..038d891 --- /dev/null +++ b/backend.Tests/Rules/LegacyFormattingRuleConfigurationTests.cs @@ -0,0 +1,428 @@ +using System.Collections; +using System.Reflection; +using backend.Endpoints; +using backend.Models; +using backend.Rules; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Comments; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Rules; +using ThesisValidator.Rules; + +namespace backend.Tests.Rules; + +public class LegacyFormattingRuleConfigurationTests +{ + public static IEnumerable RuleIds => + [ + [NoDotsInTitlesRule.RuleId], + [ParagraphIndentRule.RuleId], + [SingleSpaceRule.RuleId], + [TextJustificationRule.RuleId], + [TocRule.RuleId], + [ManualTableOfContentsRule.RuleId] + ]; + + [Theory] + [MemberData(nameof(RuleIds))] + public void GetAvailableRules_WhenRuleIsAvailable_IncludesRule(string ruleId) + { + var result = InvokeGetAvailableRules( + ruleId, + CreateRuleConfigurationService(ruleId, RuleAvailability.Available)); + + Assert.Contains(ruleId, GetRuleNames(result)); + } + + [Theory] + [MemberData(nameof(RuleIds))] + public void GetAvailableRules_WhenRuleIsHidden_ExcludesRule(string ruleId) + { + var result = InvokeGetAvailableRules( + ruleId, + CreateRuleConfigurationService(ruleId, RuleAvailability.Hidden)); + + Assert.DoesNotContain(ruleId, GetRuleNames(result)); + } + + [Theory] + [MemberData(nameof(RuleIds))] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule(string ruleId) + { + var rule = new RecordingRule(ruleId); + var service = new ThesisValidatorService( + [rule], + CreateRuleConfigurationService(ruleId, RuleAvailability.Hidden)); + + using var stream = CreateDocxStream(new Paragraph(new Run(new Text("Body text")))); + var (results, _) = service.Validate( + stream, + new UniversityConfig(), + [ruleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Theory] + [MemberData(nameof(RuleIds))] + public void Validate_WhenSeverityIsWarning_AppliesWarning(string ruleId) + { + var result = ValidateConfiguredRule(ruleId, RuleSeverity.Warning); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Theory] + [MemberData(nameof(RuleIds))] + public void Validate_WhenSeverityIsError_AppliesError(string ruleId) + { + var result = ValidateConfiguredRule(ruleId, RuleSeverity.Error); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Theory] + [MemberData(nameof(RuleIds))] + public void Validate_WithSelectedRules_RunsSelectedRuleWithoutRunningUnselectedRule(string ruleId) + { + var selectedRule = new RecordingRule(ruleId); + var unselectedRule = new RecordingRule("Grammar"); + var service = new ThesisValidatorService( + [selectedRule, unselectedRule], + CreateRuleConfigurationService(ruleId, RuleAvailability.Available)); + + using var stream = CreateDocxStream(new Paragraph(new Run(new Text("Body text")))); + service.Validate(stream, new UniversityConfig(), [ruleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + [Fact] + public void Validate_WhenNoDotsTargetStylePatternIsConfigured_UsesConfiguredPattern() + { + var options = new NoDotsInTitlesRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error, + TargetStylePatterns = ["customsection"] + }; + var rule = new NoDotsInTitlesRule( + CreateRuleConfigurationService(noDotsOptions: options), + Options.Create(options)); + using var docx = CreateDocx( + new Paragraph( + new ParagraphProperties(new ParagraphStyleId { Val = "CustomSection" }), + new Run(new Text("Configured heading.")))); + + var result = Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + + Assert.Equal(NoDotsInTitlesRule.RuleId, result.RuleName); + } + + [Fact] + public void Validate_WhenParagraphIndentOptionsAreConfigured_UsesConfiguredAllowedIndentAndTolerance() + { + var options = new ParagraphIndentRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error, + AllowedIndentTwips = [100], + ToleranceTwips = 100 + }; + var rule = new ParagraphIndentRule( + ruleConfigurationService: CreateRuleConfigurationService(paragraphIndentOptions: options), + options: Options.Create(options)); + using var docx = CreateDocx(new Paragraph(new Run(new Text("Body text")))); + + var results = rule.Validate(docx, new UniversityConfig(), null).ToList(); + + Assert.Empty(results); + } + + private static ValidationResult ValidateConfiguredRule(string ruleId, RuleSeverity severity) + { + return ruleId switch + { + NoDotsInTitlesRule.RuleId => ValidateNoDotsInTitlesRule(severity), + ParagraphIndentRule.RuleId => ValidateParagraphIndentRule(severity), + SingleSpaceRule.RuleId => ValidateSingleSpaceRule(severity), + TextJustificationRule.RuleId => ValidateTextJustificationRule(severity), + TocRule.RuleId => ValidateTocRule(severity), + ManualTableOfContentsRule.RuleId => ValidateManualTableOfContentsRule(severity), + _ => throw new ArgumentOutOfRangeException(nameof(ruleId), ruleId, "Unknown rule id.") + }; + } + + private static ValidationResult ValidateNoDotsInTitlesRule(RuleSeverity severity) + { + var options = new NoDotsInTitlesRuleOptions + { + Availability = RuleAvailability.Available, + Severity = severity + }; + var rule = new NoDotsInTitlesRule( + CreateRuleConfigurationService(noDotsOptions: options), + Options.Create(options)); + using var docx = CreateDocx( + new Paragraph( + new ParagraphProperties(new ParagraphStyleId { Val = "Heading1" }), + new Run(new Text("Heading.")))); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static ValidationResult ValidateParagraphIndentRule(RuleSeverity severity) + { + var options = new ParagraphIndentRuleOptions + { + Availability = RuleAvailability.Available, + Severity = severity + }; + var rule = new ParagraphIndentRule( + ruleConfigurationService: CreateRuleConfigurationService(paragraphIndentOptions: options), + options: Options.Create(options)); + using var docx = CreateDocx(new Paragraph(new Run(new Text("Body text")))); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static ValidationResult ValidateSingleSpaceRule(RuleSeverity severity) + { + var options = new SingleSpaceRuleOptions + { + Availability = RuleAvailability.Available, + Severity = severity + }; + var rule = new SingleSpaceRule( + ruleConfigurationService: CreateRuleConfigurationService(singleSpaceOptions: options), + options: Options.Create(options)); + using var docx = CreateDocx( + new Paragraph( + new Run(new Text("Two spaces") { Space = SpaceProcessingModeValues.Preserve }))); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static ValidationResult ValidateTextJustificationRule(RuleSeverity severity) + { + var options = new TextJustificationRuleOptions + { + Availability = RuleAvailability.Available, + Severity = severity + }; + var rule = new TextJustificationRule( + ruleConfigurationService: CreateRuleConfigurationService(textJustificationOptions: options), + options: Options.Create(options)); + using var docx = CreateDocx( + new Paragraph( + new ParagraphProperties(new Justification { Val = JustificationValues.Left }), + new Run(new Text("Body text")))); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static ValidationResult ValidateTocRule(RuleSeverity severity) + { + var options = new TocRuleOptions + { + Availability = RuleAvailability.Available, + Severity = severity + }; + var rule = new TocRule( + CreateRuleConfigurationService(tocOptions: options), + Options.Create(options)); + using var docx = CreateDocx(new Paragraph(new Run(new Text("Chapter 1")))); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static ValidationResult ValidateManualTableOfContentsRule(RuleSeverity severity) + { + var options = new ManualTableOfContentsRuleOptions + { + Availability = RuleAvailability.Available, + Severity = severity + }; + var rule = new ManualTableOfContentsRule( + CreateRuleConfigurationService(manualTableOfContentsOptions: options), + Options.Create(options)); + using var docx = CreateDocx(new Paragraph(new Run(new Text("Spis tresci")))); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static IResult InvokeGetAvailableRules( + string ruleId, + IRuleConfigurationService ruleConfigurationService) + { + var method = typeof(DocumentEndpoint).GetMethod( + "GetAvailableRules", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method.Invoke( + null, + new object?[] + { + new ThesisValidatorService( + [new RecordingRule(ruleId), new RecordingRule("Grammar")], + ruleConfigurationService), + ruleConfigurationService + }); + + return Assert.IsAssignableFrom(result); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + string ruleId, + RuleAvailability availability, + RuleSeverity severity = RuleSeverity.Error) + { + return ruleId switch + { + NoDotsInTitlesRule.RuleId => CreateRuleConfigurationService( + noDotsOptions: new NoDotsInTitlesRuleOptions + { + Availability = availability, + Severity = severity + }), + ParagraphIndentRule.RuleId => CreateRuleConfigurationService( + paragraphIndentOptions: new ParagraphIndentRuleOptions + { + Availability = availability, + Severity = severity + }), + SingleSpaceRule.RuleId => CreateRuleConfigurationService( + singleSpaceOptions: new SingleSpaceRuleOptions + { + Availability = availability, + Severity = severity + }), + TextJustificationRule.RuleId => CreateRuleConfigurationService( + textJustificationOptions: new TextJustificationRuleOptions + { + Availability = availability, + Severity = severity + }), + TocRule.RuleId => CreateRuleConfigurationService( + tocOptions: new TocRuleOptions + { + Availability = availability, + Severity = severity + }), + ManualTableOfContentsRule.RuleId => CreateRuleConfigurationService( + manualTableOfContentsOptions: new ManualTableOfContentsRuleOptions + { + Availability = availability, + Severity = severity + }), + _ => throw new ArgumentOutOfRangeException(nameof(ruleId), ruleId, "Unknown rule id.") + }; + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + NoDotsInTitlesRuleOptions? noDotsOptions = null, + ParagraphIndentRuleOptions? paragraphIndentOptions = null, + SingleSpaceRuleOptions? singleSpaceOptions = null, + TextJustificationRuleOptions? textJustificationOptions = null, + TocRuleOptions? tocOptions = null, + ManualTableOfContentsRuleOptions? manualTableOfContentsOptions = null) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + noDotsInTitlesOptions: Options.Create(noDotsOptions ?? new NoDotsInTitlesRuleOptions()), + paragraphIndentOptions: Options.Create(paragraphIndentOptions ?? new ParagraphIndentRuleOptions()), + singleSpaceOptions: Options.Create(singleSpaceOptions ?? new SingleSpaceRuleOptions()), + textJustificationOptions: Options.Create(textJustificationOptions ?? new TextJustificationRuleOptions()), + tocOptions: Options.Create(tocOptions ?? new TocRuleOptions()), + manualTableOfContentsOptions: Options.Create(manualTableOfContentsOptions ?? new ManualTableOfContentsRuleOptions())); + } + + private static MemoryStream CreateDocxStream(params OpenXmlElement[] bodyChildren) + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body()); + + foreach (var child in bodyChildren) + { + mainPart.Document.Body!.Append(child.CloneNode(true)); + } + + mainPart.Document.Save(); + } + + stream.Position = 0; + return stream; + } + + private static WordprocessingDocument CreateDocx(params OpenXmlElement[] bodyChildren) + { + var stream = CreateDocxStream(bodyChildren); + return WordprocessingDocument.Open(stream, true); + } + + private static IReadOnlyList GetRuleNames(IResult result) + { + return GetRules(result) + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private static IEnumerable GetRules(IResult result) + { + Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); + + var value = result.GetType().GetProperty("Value")?.GetValue(result); + Assert.NotNull(value); + + var rules = value.GetType().GetProperty("Rules")?.GetValue(value); + Assert.NotNull(rules); + + return ((IEnumerable)rules).Cast(); + } + + private sealed class RecordingRule : IValidationRule + { + public RecordingRule(string name) + { + Name = name; + } + + public string Name { get; } + + public int RunCount { get; private set; } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + RunCount++; + return + [ + new ValidationResult + { + RuleName = Name, + Message = "Executed" + } + ]; + } + } +} diff --git a/backend/Program.cs b/backend/Program.cs index b849884..3f6e864 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -38,6 +38,13 @@ "CodeBlockDetection:CodeFonts must contain at least one font.") .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(NoDotsInTitlesRuleOptions.SectionName)) + .Validate(options => options.TargetStylePatterns is not null + && options.TargetStylePatterns.Any(pattern => !string.IsNullOrWhiteSpace(pattern)), + "NoDotsInTitlesRule:TargetStylePatterns must contain at least one non-empty style pattern.") + .ValidateOnStart(); + builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(EmptySectionStructureRuleOptions.SectionName)) .ValidateOnStart(); @@ -66,6 +73,32 @@ "LineSpacingDependencyRule:TargetLineSpacingTwips must be greater than 0.") .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ParagraphIndentRuleOptions.SectionName)) + .Validate(options => options.AllowedIndentTwips is not null + && options.AllowedIndentTwips.Length > 0 + && options.AllowedIndentTwips.All(indent => indent >= 0), + "RequiredIndentCm:AllowedIndentTwips must contain only non-negative values and at least one value.") + .Validate(options => options.ToleranceTwips >= 0, + "RequiredIndentCm:ToleranceTwips must be greater than or equal to 0.") + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(SingleSpaceRuleOptions.SectionName)) + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(TextJustificationRuleOptions.SectionName)) + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(TocRuleOptions.SectionName)) + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ManualTableOfContentsRuleOptions.SectionName)) + .ValidateOnStart(); + builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(MissingFigureCaptionRuleOptions.SectionName)) .ValidateOnStart(); diff --git a/backend/Services/Rules/RuleConfigurationService.cs b/backend/Services/Rules/RuleConfigurationService.cs index 4cff623..2602381 100644 --- a/backend/Services/Rules/RuleConfigurationService.cs +++ b/backend/Services/Rules/RuleConfigurationService.cs @@ -13,9 +13,15 @@ public sealed class RuleConfigurationService : IRuleConfigurationService { private readonly EmptySectionStructureRuleOptions _emptySectionOptions; private readonly FontFamilyRuleOptions _fontFamilyOptions; + private readonly NoDotsInTitlesRuleOptions _noDotsInTitlesOptions; private readonly HeadingStyleUsageRuleOptions _headingStyleUsageOptions; private readonly HierarchyDepthRuleOptions _hierarchyDepthOptions; private readonly LineSpacingDependencyRuleOptions _lineSpacingDependencyOptions; + private readonly ParagraphIndentRuleOptions _paragraphIndentOptions; + private readonly SingleSpaceRuleOptions _singleSpaceOptions; + private readonly TextJustificationRuleOptions _textJustificationOptions; + private readonly TocRuleOptions _tocOptions; + private readonly ManualTableOfContentsRuleOptions _manualTableOfContentsOptions; private readonly MissingFigureCaptionRuleOptions _missingFigureCaptionOptions; private readonly ListPunctuationConsistencyRuleOptions _listPunctuationConsistencyOptions; private readonly ListIndentationConsistencyRuleOptions _listIndentationConsistencyOptions; @@ -23,18 +29,30 @@ public sealed class RuleConfigurationService : IRuleConfigurationService public RuleConfigurationService( IOptions emptySectionOptions, IOptions? fontFamilyOptions = null, + IOptions? noDotsInTitlesOptions = null, IOptions? headingStyleUsageOptions = null, IOptions? hierarchyDepthOptions = null, IOptions? lineSpacingDependencyOptions = null, + IOptions? paragraphIndentOptions = null, + IOptions? singleSpaceOptions = null, + IOptions? textJustificationOptions = null, + IOptions? tocOptions = null, + IOptions? manualTableOfContentsOptions = null, IOptions? missingFigureCaptionOptions = null, IOptions? listPunctuationConsistencyOptions = null, IOptions? listIndentationConsistencyOptions = null) { _emptySectionOptions = emptySectionOptions.Value; _fontFamilyOptions = fontFamilyOptions?.Value ?? new FontFamilyRuleOptions(); + _noDotsInTitlesOptions = noDotsInTitlesOptions?.Value ?? new NoDotsInTitlesRuleOptions(); _headingStyleUsageOptions = headingStyleUsageOptions?.Value ?? new HeadingStyleUsageRuleOptions(); _hierarchyDepthOptions = hierarchyDepthOptions?.Value ?? new HierarchyDepthRuleOptions(); _lineSpacingDependencyOptions = lineSpacingDependencyOptions?.Value ?? new LineSpacingDependencyRuleOptions(); + _paragraphIndentOptions = paragraphIndentOptions?.Value ?? new ParagraphIndentRuleOptions(); + _singleSpaceOptions = singleSpaceOptions?.Value ?? new SingleSpaceRuleOptions(); + _textJustificationOptions = textJustificationOptions?.Value ?? new TextJustificationRuleOptions(); + _tocOptions = tocOptions?.Value ?? new TocRuleOptions(); + _manualTableOfContentsOptions = manualTableOfContentsOptions?.Value ?? new ManualTableOfContentsRuleOptions(); _missingFigureCaptionOptions = missingFigureCaptionOptions?.Value ?? new MissingFigureCaptionRuleOptions(); _listPunctuationConsistencyOptions = listPunctuationConsistencyOptions?.Value ?? new ListPunctuationConsistencyRuleOptions(); _listIndentationConsistencyOptions = listIndentationConsistencyOptions?.Value ?? new ListIndentationConsistencyRuleOptions(); @@ -48,6 +66,9 @@ public bool IsRuleAvailable(string ruleId) if (IsFontFamilyRule(ruleId)) return _fontFamilyOptions.Availability != RuleAvailability.Hidden; + if (IsNoDotsInTitlesRule(ruleId)) + return _noDotsInTitlesOptions.Availability != RuleAvailability.Hidden; + if (IsHeadingStyleUsageRule(ruleId)) return _headingStyleUsageOptions.Availability != RuleAvailability.Hidden; @@ -57,6 +78,21 @@ public bool IsRuleAvailable(string ruleId) if (IsLineSpacingDependencyRule(ruleId)) return _lineSpacingDependencyOptions.Availability != RuleAvailability.Hidden; + if (IsParagraphIndentRule(ruleId)) + return _paragraphIndentOptions.Availability != RuleAvailability.Hidden; + + if (IsSingleSpaceRule(ruleId)) + return _singleSpaceOptions.Availability != RuleAvailability.Hidden; + + if (IsTextJustificationRule(ruleId)) + return _textJustificationOptions.Availability != RuleAvailability.Hidden; + + if (IsTocRule(ruleId)) + return _tocOptions.Availability != RuleAvailability.Hidden; + + if (IsManualTableOfContentsRule(ruleId)) + return _manualTableOfContentsOptions.Availability != RuleAvailability.Hidden; + if (IsMissingFigureCaptionRule(ruleId)) return _missingFigureCaptionOptions.Availability != RuleAvailability.Hidden; @@ -80,6 +116,9 @@ public string ResolveSeverity( if (IsFontFamilyRule(ruleId)) return ValidationSeverity.Normalize(_fontFamilyOptions.Severity.ToString()); + if (IsNoDotsInTitlesRule(ruleId)) + return ValidationSeverity.Normalize(_noDotsInTitlesOptions.Severity.ToString()); + if (IsHeadingStyleUsageRule(ruleId)) return ValidationSeverity.Normalize(_headingStyleUsageOptions.Severity.ToString()); @@ -89,6 +128,21 @@ public string ResolveSeverity( if (IsLineSpacingDependencyRule(ruleId)) return ValidationSeverity.Normalize(_lineSpacingDependencyOptions.Severity.ToString()); + if (IsParagraphIndentRule(ruleId)) + return ValidationSeverity.Normalize(_paragraphIndentOptions.Severity.ToString()); + + if (IsSingleSpaceRule(ruleId)) + return ValidationSeverity.Normalize(_singleSpaceOptions.Severity.ToString()); + + if (IsTextJustificationRule(ruleId)) + return ValidationSeverity.Normalize(_textJustificationOptions.Severity.ToString()); + + if (IsTocRule(ruleId)) + return ValidationSeverity.Normalize(_tocOptions.Severity.ToString()); + + if (IsManualTableOfContentsRule(ruleId)) + return ValidationSeverity.Normalize(_manualTableOfContentsOptions.Severity.ToString()); + if (IsMissingFigureCaptionRule(ruleId)) return ValidationSeverity.Normalize(_missingFigureCaptionOptions.Severity.ToString()); @@ -109,6 +163,9 @@ public RuleDefinition ApplyConfiguration(RuleDefinition definition) if (IsFontFamilyRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_fontFamilyOptions.Severity.ToString()) }; + if (IsNoDotsInTitlesRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_noDotsInTitlesOptions.Severity.ToString()) }; + if (IsHeadingStyleUsageRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_headingStyleUsageOptions.Severity.ToString()) }; @@ -118,6 +175,21 @@ public RuleDefinition ApplyConfiguration(RuleDefinition definition) if (IsLineSpacingDependencyRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_lineSpacingDependencyOptions.Severity.ToString()) }; + if (IsParagraphIndentRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_paragraphIndentOptions.Severity.ToString()) }; + + if (IsSingleSpaceRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_singleSpaceOptions.Severity.ToString()) }; + + if (IsTextJustificationRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_textJustificationOptions.Severity.ToString()) }; + + if (IsTocRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_tocOptions.Severity.ToString()) }; + + if (IsManualTableOfContentsRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_manualTableOfContentsOptions.Severity.ToString()) }; + if (IsMissingFigureCaptionRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_missingFigureCaptionOptions.Severity.ToString()) }; @@ -146,6 +218,14 @@ private static bool IsFontFamilyRule(string ruleId) StringComparison.OrdinalIgnoreCase); } + private static bool IsNoDotsInTitlesRule(string ruleId) + { + return string.Equals( + ruleId, + NoDotsInTitlesRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + private static bool IsHeadingStyleUsageRule(string ruleId) { return string.Equals( @@ -170,6 +250,46 @@ private static bool IsLineSpacingDependencyRule(string ruleId) StringComparison.OrdinalIgnoreCase); } + private static bool IsParagraphIndentRule(string ruleId) + { + return string.Equals( + ruleId, + ParagraphIndentRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSingleSpaceRule(string ruleId) + { + return string.Equals( + ruleId, + SingleSpaceRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTextJustificationRule(string ruleId) + { + return string.Equals( + ruleId, + TextJustificationRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTocRule(string ruleId) + { + return string.Equals( + ruleId, + TocRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsManualTableOfContentsRule(string ruleId) + { + return string.Equals( + ruleId, + ManualTableOfContentsRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + private static bool IsMissingFigureCaptionRule(string ruleId) { return string.Equals( diff --git a/backend/appsettings.json b/backend/appsettings.json index f4f7063..27691f6 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -17,6 +17,28 @@ "Severity": "Warning", "RequiredFontFamily": "New Times Roman" }, + "SingleSpaceRule": { + "Availability": "Available", + "Severity": "Error" + }, + "TextJustificationRule": { + "Availability": "Available", + "Severity": "Error" + }, + "NoDotsInTitlesRule": { + "Availability": "Available", + "Severity": "Error", + "TargetStylePatterns": [ + "heading", + "nagwek", + "title", + "tytu", + "subtitle", + "podtytu", + "caption", + "podpis" + ] + }, "HeadingStyleUsageRule": { "Availability": "Available", "Severity": "Warning", @@ -33,6 +55,20 @@ "Severity": "Error", "TargetLineSpacingTwips": 360 }, + "RequiredIndentCm": { + "Availability": "Available", + "Severity": "Error", + "AllowedIndentTwips": [567, 709], + "ToleranceTwips": 60 + }, + "CheckTableOfContents": { + "Availability": "Available", + "Severity": "Error" + }, + "ManualTableOfContents": { + "Availability": "Available", + "Severity": "Warning" + }, "MissingFigureCaptionRule": { "Availability": "Available", "Severity": "Error" From 946ba09eb78b16190025f13432ce3f6c8895dc20 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:33:26 +0200 Subject: [PATCH 24/77] feat(frontend): update validation models to reflect rule updates --- frontend/src/app/models/validation.models.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/models/validation.models.ts b/frontend/src/app/models/validation.models.ts index 62bcd64..ff3519d 100644 --- a/frontend/src/app/models/validation.models.ts +++ b/frontend/src/app/models/validation.models.ts @@ -156,12 +156,12 @@ export const RULE_METADATA: Record Date: Tue, 28 Apr 2026 11:22:29 +0200 Subject: [PATCH 25/77] feat(rules): add FigureCaptionFormatRule and associated options with tests --- ...gureCaptionFormatRuleConfigurationTests.cs | 160 ++++++++++++++++++ .../Rules/FigureCaptionFormatRuleOptions.cs | 6 + backend/Rules/FigureCaptionFormatRule.cs | 28 ++- 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 backend.Tests/Rules/FigureCaptionFormatRuleConfigurationTests.cs create mode 100644 backend/Options/Rules/FigureCaptionFormatRuleOptions.cs diff --git a/backend.Tests/Rules/FigureCaptionFormatRuleConfigurationTests.cs b/backend.Tests/Rules/FigureCaptionFormatRuleConfigurationTests.cs new file mode 100644 index 0000000..d56e5f5 --- /dev/null +++ b/backend.Tests/Rules/FigureCaptionFormatRuleConfigurationTests.cs @@ -0,0 +1,160 @@ +using backend.Models; +using backend.Rules; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; +using A = DocumentFormat.OpenXml.Drawing; +using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; +using PIC = DocumentFormat.OpenXml.Drawing.Pictures; + +namespace backend.Tests.Rules; + +public class FigureCaptionFormatRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenFigureCaptionFormatRuleIsAvailable_IncludesRule() + { + var service = CreateRuleConfigurationService(new FigureCaptionFormatRuleOptions + { + Availability = RuleAvailability.Available + }); + + var result = RuleConfigurationTestSupport.InvokeGetAvailableRules( + [new RecordingRule(FigureCaptionFormatRule.RuleId), new RecordingRule(GrammarRule.RuleId)], + service); + + Assert.Contains(FigureCaptionFormatRule.RuleId, RuleConfigurationTestSupport.GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenFigureCaptionFormatRuleIsHidden_ExcludesRule() + { + var service = CreateRuleConfigurationService(new FigureCaptionFormatRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + var result = RuleConfigurationTestSupport.InvokeGetAvailableRules( + [new RecordingRule(FigureCaptionFormatRule.RuleId), new RecordingRule(GrammarRule.RuleId)], + service); + + Assert.DoesNotContain(FigureCaptionFormatRule.RuleId, RuleConfigurationTestSupport.GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() + { + var rule = new RecordingRule(FigureCaptionFormatRule.RuleId); + var service = new ThesisValidatorService( + [rule], + CreateRuleConfigurationService(new FigureCaptionFormatRuleOptions + { + Availability = RuleAvailability.Hidden + })); + + using var stream = RuleConfigurationTestSupport.CreateDocxStream(); + var (results, _) = service.Validate( + stream, + new UniversityConfig(), + [FigureCaptionFormatRule.RuleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Fact] + public void Validate_WhenSeverityIsWarning_AppliesWarning() + { + var result = ValidateFigureCaptionFormatRule(new FigureCaptionFormatRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenSeverityIsError_AppliesError() + { + var result = ValidateFigureCaptionFormatRule(new FigureCaptionFormatRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WithSelectedRules_RunsFigureCaptionFormatWithoutRunningUnselectedRule() + { + var selectedRule = new RecordingRule(FigureCaptionFormatRule.RuleId); + var unselectedRule = new RecordingRule(GrammarRule.RuleId); + var service = new ThesisValidatorService( + [selectedRule, unselectedRule], + CreateRuleConfigurationService(new FigureCaptionFormatRuleOptions + { + Availability = RuleAvailability.Available + })); + + using var stream = RuleConfigurationTestSupport.CreateDocxStream(); + service.Validate(stream, new UniversityConfig(), [FigureCaptionFormatRule.RuleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + private static ValidationResult ValidateFigureCaptionFormatRule(FigureCaptionFormatRuleOptions options) + { + var rule = new FigureCaptionFormatRule( + CreateRuleConfigurationService(options), + Options.Create(options)); + using var docx = CreateDocxWithFigureAndCaption("Obrazek 1: Invalid label"); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + FigureCaptionFormatRuleOptions options) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + figureCaptionFormatOptions: Options.Create(options)); + } + + private static WordprocessingDocument CreateDocxWithFigureAndCaption(string captionText) + { + var stream = new MemoryStream(); + var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body( + CreateFigureParagraph(), + new Paragraph(new Run(new Text(captionText))))); + mainPart.Document.Save(); + stream.Position = 0; + return doc; + } + + private static Paragraph CreateFigureParagraph() + { + return new Paragraph( + new Run( + new Drawing( + new DW.Inline( + new A.Graphic( + new A.GraphicData( + new PIC.Picture()) + { + Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" + }))))); + } +} diff --git a/backend/Options/Rules/FigureCaptionFormatRuleOptions.cs b/backend/Options/Rules/FigureCaptionFormatRuleOptions.cs new file mode 100644 index 0000000..03cb18a --- /dev/null +++ b/backend/Options/Rules/FigureCaptionFormatRuleOptions.cs @@ -0,0 +1,6 @@ +namespace backend.RuleOptions; + +public sealed class FigureCaptionFormatRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:FigureCaptionFormatRule"; +} diff --git a/backend/Rules/FigureCaptionFormatRule.cs b/backend/Rules/FigureCaptionFormatRule.cs index bc23139..7c51120 100644 --- a/backend/Rules/FigureCaptionFormatRule.cs +++ b/backend/Rules/FigureCaptionFormatRule.cs @@ -1,10 +1,13 @@ using backend.Models; +using backend.RuleOptions; using backend.Services.Comments; using backend.Services.Extraction; using backend.Services.Results; +using backend.Services.Rules; using backend.Services.Structure; using Backend.Models; using DocumentFormat.OpenXml.Packaging; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; namespace backend.Rules; @@ -14,13 +17,32 @@ namespace backend.Rules; /// public class FigureCaptionFormatRule : IValidationRule { - public string Name => "FigureCaptionFormatRule"; + public const string RuleId = nameof(FigureCaptionFormatRule); + + private readonly IRuleConfigurationService _ruleConfigurationService; + + public FigureCaptionFormatRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var figureCaptionFormatOptions = options ?? Options.Create(new FigureCaptionFormatRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + figureCaptionFormatOptions: figureCaptionFormatOptions); + } + + public string Name => RuleId; public IEnumerable Validate( WordprocessingDocument doc, UniversityConfig config, DocumentCommentService? commentService = null) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + var errors = new List(); foreach (var caption in FigureCaptionDetector.GetDetectedFigureCaptions(doc, config)) @@ -53,12 +75,14 @@ private ValidationResult MakeResult( int paragraph, string text) { - return ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, message, paragraph, text, ParagraphIndexKind.Descendant); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + return result; } } From 4198cf28819ae5c807ffeb32668e6c5b036adfa4 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:22:36 +0200 Subject: [PATCH 26/77] feat(tests): add RuleConfigurationTestSupport and RecordingRule for validation testing --- .../Rules/RuleConfigurationTestSupport.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 backend.Tests/Rules/RuleConfigurationTestSupport.cs diff --git a/backend.Tests/Rules/RuleConfigurationTestSupport.cs b/backend.Tests/Rules/RuleConfigurationTestSupport.cs new file mode 100644 index 0000000..9b2d067 --- /dev/null +++ b/backend.Tests/Rules/RuleConfigurationTestSupport.cs @@ -0,0 +1,103 @@ +using System.Collections; +using System.Reflection; +using backend.Endpoints; +using backend.Models; +using backend.Services.Analysis; +using backend.Services.Comments; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.AspNetCore.Http; +using ThesisValidator.Rules; + +namespace backend.Tests.Rules; + +internal static class RuleConfigurationTestSupport +{ + public static IResult InvokeGetAvailableRules( + IEnumerable rules, + IRuleConfigurationService ruleConfigurationService) + { + var method = typeof(DocumentEndpoint).GetMethod( + "GetAvailableRules", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method.Invoke( + null, + new object?[] + { + new ThesisValidatorService(rules, ruleConfigurationService), + ruleConfigurationService + }); + + return Assert.IsAssignableFrom(result); + } + + public static IReadOnlyList GetRuleNames(IResult result) + { + return GetRules(result) + .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + public static MemoryStream CreateDocxStream() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Body text"))))); + mainPart.Document.Save(); + } + + stream.Position = 0; + return stream; + } + + private static IEnumerable GetRules(IResult result) + { + Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); + + var value = result.GetType().GetProperty("Value")?.GetValue(result); + Assert.NotNull(value); + + var rules = value.GetType().GetProperty("Rules")?.GetValue(value); + Assert.NotNull(rules); + + return ((IEnumerable)rules).Cast(); + } +} + +internal sealed class RecordingRule : IValidationRule +{ + public RecordingRule(string name) + { + Name = name; + } + + public string Name { get; } + + public int RunCount { get; private set; } + + public IEnumerable Validate( + WordprocessingDocument doc, + UniversityConfig config, + DocumentCommentService? documentCommentService = null) + { + RunCount++; + return + [ + new ValidationResult + { + RuleName = Name, + Message = "Executed" + } + ]; + } +} From 47e6d04a5d595b6dc9dbd245face3e371e6c1a64 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:22:54 +0200 Subject: [PATCH 27/77] feat(rules): add FigureCaptionPositionRule with configuration options and tests --- ...reCaptionPositionRuleConfigurationTests.cs | 162 ++++++++++++++++++ .../Rules/FigureCaptionPositionRuleOptions.cs | 6 + backend/Rules/FigureCaptionPositionRule.cs | 30 +++- 3 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 backend.Tests/Rules/FigureCaptionPositionRuleConfigurationTests.cs create mode 100644 backend/Options/Rules/FigureCaptionPositionRuleOptions.cs diff --git a/backend.Tests/Rules/FigureCaptionPositionRuleConfigurationTests.cs b/backend.Tests/Rules/FigureCaptionPositionRuleConfigurationTests.cs new file mode 100644 index 0000000..782dccb --- /dev/null +++ b/backend.Tests/Rules/FigureCaptionPositionRuleConfigurationTests.cs @@ -0,0 +1,162 @@ +using backend.Models; +using backend.Rules; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.Rules; +using Backend.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; +using A = DocumentFormat.OpenXml.Drawing; +using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; +using PIC = DocumentFormat.OpenXml.Drawing.Pictures; + +namespace backend.Tests.Rules; + +public class FigureCaptionPositionRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenFigureCaptionPositionRuleIsAvailable_IncludesRule() + { + var service = CreateRuleConfigurationService(new FigureCaptionPositionRuleOptions + { + Availability = RuleAvailability.Available + }); + + var result = RuleConfigurationTestSupport.InvokeGetAvailableRules( + [new RecordingRule(FigureCaptionPositionRule.RuleId), new RecordingRule(GrammarRule.RuleId)], + service); + + Assert.Contains(FigureCaptionPositionRule.RuleId, RuleConfigurationTestSupport.GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenFigureCaptionPositionRuleIsHidden_ExcludesRule() + { + var service = CreateRuleConfigurationService(new FigureCaptionPositionRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + var result = RuleConfigurationTestSupport.InvokeGetAvailableRules( + [new RecordingRule(FigureCaptionPositionRule.RuleId), new RecordingRule(GrammarRule.RuleId)], + service); + + Assert.DoesNotContain(FigureCaptionPositionRule.RuleId, RuleConfigurationTestSupport.GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() + { + var rule = new RecordingRule(FigureCaptionPositionRule.RuleId); + var service = new ThesisValidatorService( + [rule], + CreateRuleConfigurationService(new FigureCaptionPositionRuleOptions + { + Availability = RuleAvailability.Hidden + })); + + using var stream = RuleConfigurationTestSupport.CreateDocxStream(); + var (results, _) = service.Validate( + stream, + new UniversityConfig(), + [FigureCaptionPositionRule.RuleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Fact] + public void Validate_WhenSeverityIsWarning_AppliesWarning() + { + var result = ValidateFigureCaptionPositionRule(new FigureCaptionPositionRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenSeverityIsError_AppliesError() + { + var result = ValidateFigureCaptionPositionRule(new FigureCaptionPositionRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WithSelectedRules_RunsFigureCaptionPositionWithoutRunningUnselectedRule() + { + var selectedRule = new RecordingRule(FigureCaptionPositionRule.RuleId); + var unselectedRule = new RecordingRule(GrammarRule.RuleId); + var service = new ThesisValidatorService( + [selectedRule, unselectedRule], + CreateRuleConfigurationService(new FigureCaptionPositionRuleOptions + { + Availability = RuleAvailability.Available + })); + + using var stream = RuleConfigurationTestSupport.CreateDocxStream(); + service.Validate(stream, new UniversityConfig(), [FigureCaptionPositionRule.RuleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + private static ValidationResult ValidateFigureCaptionPositionRule(FigureCaptionPositionRuleOptions options) + { + var rule = new FigureCaptionPositionRule( + CreateRuleConfigurationService(options), + Options.Create(options)); + using var docx = CreateDocxWithCaptionAboveFigure(); + + return Assert.Single(rule.Validate(docx, new UniversityConfig(), null)); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + FigureCaptionPositionRuleOptions options) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + figureCaptionPositionOptions: Options.Create(options)); + } + + private static WordprocessingDocument CreateDocxWithCaptionAboveFigure() + { + var stream = new MemoryStream(); + var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document); + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(new Body( + new Paragraph( + new ParagraphProperties(new ParagraphStyleId { Val = "Caption" }), + new Run(new Text("Rys. 1 Example caption"))), + CreateFigureParagraph())); + mainPart.Document.Save(); + stream.Position = 0; + return doc; + } + + private static Paragraph CreateFigureParagraph() + { + return new Paragraph( + new Run( + new Drawing( + new DW.Inline( + new A.Graphic( + new A.GraphicData( + new PIC.Picture()) + { + Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" + }))))); + } +} diff --git a/backend/Options/Rules/FigureCaptionPositionRuleOptions.cs b/backend/Options/Rules/FigureCaptionPositionRuleOptions.cs new file mode 100644 index 0000000..10ff262 --- /dev/null +++ b/backend/Options/Rules/FigureCaptionPositionRuleOptions.cs @@ -0,0 +1,6 @@ +namespace backend.RuleOptions; + +public sealed class FigureCaptionPositionRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:FigureCaptionPositionRule"; +} diff --git a/backend/Rules/FigureCaptionPositionRule.cs b/backend/Rules/FigureCaptionPositionRule.cs index 2c4dd14..330c718 100644 --- a/backend/Rules/FigureCaptionPositionRule.cs +++ b/backend/Rules/FigureCaptionPositionRule.cs @@ -1,10 +1,13 @@ using backend.Models; +using backend.RuleOptions; using backend.Services.Comments; using backend.Services.Extraction; using backend.Services.Results; +using backend.Services.Rules; using backend.Services.Structure; using Backend.Models; using DocumentFormat.OpenXml.Packaging; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; namespace backend.Rules; @@ -14,9 +17,25 @@ namespace backend.Rules; /// public class FigureCaptionPositionRule : IValidationRule { + public const string RuleId = nameof(FigureCaptionPositionRule); + + private readonly IRuleConfigurationService _ruleConfigurationService; + + public FigureCaptionPositionRule( + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) + { + var figureCaptionPositionOptions = options ?? Options.Create(new FigureCaptionPositionRuleOptions()); + + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + figureCaptionPositionOptions: figureCaptionPositionOptions); + } + // The validator infrastructure uses this identifier to label results // and to look up rule metadata/configuration. - public string Name => "FigureCaptionPositionRule"; + public string Name => RuleId; public IEnumerable Validate( // OpenXML SDK representation of the .docx package. The rule reads @@ -29,6 +48,9 @@ public IEnumerable Validate( // paragraph, so the issue is visible inside Microsoft Word as well. DocumentCommentService? commentService = null) { + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return []; + // We collect every detected violation here and return them at the end. var errors = new List(); @@ -79,13 +101,15 @@ public IEnumerable Validate( // // ParagraphIndexKind.Descendant tells downstream consumers that this index // refers to the full document-order descendant paragraph traversal. - errors.Add(ValidationResultFactory.ForParagraph( + var result = ValidationResultFactory.ForParagraph( Name, config, message, association.Caption.ParagraphIndex, preview, - ParagraphIndexKind.Descendant)); + ParagraphIndexKind.Descendant); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + errors.Add(result); // If commentService is not null, add a Word comment directly to the caption // paragraph. Under the hood, AddCommentToParagraph(...) creates/reuses the From 92550580120143fa6878946602139de779859bbd Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:23:20 +0200 Subject: [PATCH 28/77] feat(rules): add GrammarRule with configuration options and tests --- .../Rules/GrammarRuleConfigurationTests.cs | 203 ++++++++++++++++++ backend/Options/Rules/GrammarRuleOptions.cs | 6 + backend/Rules/GrammarRule.cs | 32 ++- 3 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 backend.Tests/Rules/GrammarRuleConfigurationTests.cs create mode 100644 backend/Options/Rules/GrammarRuleOptions.cs diff --git a/backend.Tests/Rules/GrammarRuleConfigurationTests.cs b/backend.Tests/Rules/GrammarRuleConfigurationTests.cs new file mode 100644 index 0000000..c335f5e --- /dev/null +++ b/backend.Tests/Rules/GrammarRuleConfigurationTests.cs @@ -0,0 +1,203 @@ +using System.Net; +using System.Text.Json; +using backend.Models; +using backend.Rules; +using backend.RuleOptions; +using backend.Services.Analysis; +using backend.Services.CodeBlocks; +using backend.Services.Language; +using backend.Services.Rules; +using backend.Tests.Helpers; +using Backend.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using ThesisValidator.Rules; + +namespace backend.Tests.Rules; + +public class GrammarRuleConfigurationTests +{ + [Fact] + public void GetAvailableRules_WhenGrammarRuleIsAvailable_IncludesRule() + { + var service = CreateRuleConfigurationService(new GrammarRuleOptions + { + Availability = RuleAvailability.Available + }); + + var result = RuleConfigurationTestSupport.InvokeGetAvailableRules( + [new RecordingRule(GrammarRule.RuleId), new RecordingRule(FigureCaptionFormatRule.RuleId)], + service); + + Assert.Contains(GrammarRule.RuleId, RuleConfigurationTestSupport.GetRuleNames(result)); + } + + [Fact] + public void GetAvailableRules_WhenGrammarRuleIsHidden_ExcludesRule() + { + var service = CreateRuleConfigurationService(new GrammarRuleOptions + { + Availability = RuleAvailability.Hidden + }); + + var result = RuleConfigurationTestSupport.InvokeGetAvailableRules( + [new RecordingRule(GrammarRule.RuleId), new RecordingRule(FigureCaptionFormatRule.RuleId)], + service); + + Assert.DoesNotContain(GrammarRule.RuleId, RuleConfigurationTestSupport.GetRuleNames(result)); + } + + [Fact] + public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() + { + var rule = new RecordingRule(GrammarRule.RuleId); + var service = new ThesisValidatorService( + [rule], + CreateRuleConfigurationService(new GrammarRuleOptions + { + Availability = RuleAvailability.Hidden + })); + + using var stream = RuleConfigurationTestSupport.CreateDocxStream(); + var (results, _) = service.Validate( + stream, + new UniversityConfig(), + [GrammarRule.RuleId]); + + Assert.Empty(results); + Assert.Equal(0, rule.RunCount); + } + + [Fact] + public void Validate_WhenSeverityIsWarning_AppliesWarning() + { + var result = ValidateGrammarRule(new GrammarRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Warning + }); + + Assert.Equal(ValidationSeverity.Warning, result.Severity); + Assert.False(result.IsError); + } + + [Fact] + public void Validate_WhenSeverityIsError_AppliesError() + { + var result = ValidateGrammarRule(new GrammarRuleOptions + { + Availability = RuleAvailability.Available, + Severity = RuleSeverity.Error + }); + + Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.True(result.IsError); + } + + [Fact] + public void Validate_WithSelectedRules_RunsGrammarWithoutRunningUnselectedRule() + { + var selectedRule = new RecordingRule(GrammarRule.RuleId); + var unselectedRule = new RecordingRule(FigureCaptionFormatRule.RuleId); + var service = new ThesisValidatorService( + [selectedRule, unselectedRule], + CreateRuleConfigurationService(new GrammarRuleOptions + { + Availability = RuleAvailability.Available + })); + + using var stream = RuleConfigurationTestSupport.CreateDocxStream(); + service.Validate(stream, new UniversityConfig(), [GrammarRule.RuleId]); + + Assert.Equal(1, selectedRule.RunCount); + Assert.Equal(0, unselectedRule.RunCount); + } + + private static ValidationResult ValidateGrammarRule(GrammarRuleOptions options) + { + var rule = new GrammarRule( + CreateMockLanguageToolService(CreateGrammarResponse()), + CodeBlockDetector.CreateDefault(), + CreateRuleConfigurationService(options), + Options.Create(options)); + using var docx = DocxTestHelper.CreateInMemoryDocx( + ("This speling is wrong.", "Times New Roman")); + + return Assert.Single(rule.Validate(docx.Document, new UniversityConfig { Language = "en-US" })); + } + + private static IRuleConfigurationService CreateRuleConfigurationService( + GrammarRuleOptions options) + { + return new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + grammarOptions: Options.Create(options)); + } + + private static LanguageToolResponse CreateGrammarResponse() + { + return new LanguageToolResponse + { + Matches = + [ + new LanguageToolMatch + { + Message = "Possible spelling mistake found", + Offset = 5, + Length = 7, + Sentence = "This speling is wrong.", + Replacements = + [ + new LanguageToolReplacement { Value = "spelling" } + ], + Rule = new LanguageToolRule + { + Id = "MORFOLOGIK_RULE_EN_US", + IssueType = "misspelling", + Category = new LanguageToolCategory { Id = "TYPOS", Name = "Possible Typo" } + } + } + ] + }; + } + + private static LanguageToolService CreateMockLanguageToolService(LanguageToolResponse response) + { + var handlerMock = new Mock(); + + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(r => r.RequestUri!.PathAndQuery.Contains("/v2/languages")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("[]") + }); + + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(r => r.RequestUri!.PathAndQuery.Contains("/v2/check")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(JsonSerializer.Serialize(response)) + }); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["LanguageTool:BaseUrl"] = "http://localhost:8010" + }) + .Build(); + + return new LanguageToolService(new HttpClient(handlerMock.Object), configuration); + } +} diff --git a/backend/Options/Rules/GrammarRuleOptions.cs b/backend/Options/Rules/GrammarRuleOptions.cs new file mode 100644 index 0000000..5d44d0b --- /dev/null +++ b/backend/Options/Rules/GrammarRuleOptions.cs @@ -0,0 +1,6 @@ +namespace backend.RuleOptions; + +public sealed class GrammarRuleOptions : RuleOptionsBase +{ + public const string SectionName = "Validation:Rules:Grammar"; +} diff --git a/backend/Rules/GrammarRule.cs b/backend/Rules/GrammarRule.cs index 92d9d6c..da4625e 100644 --- a/backend/Rules/GrammarRule.cs +++ b/backend/Rules/GrammarRule.cs @@ -9,6 +9,9 @@ using backend.Services.Extraction; using backend.Services.Language; using backend.Services.Results; +using backend.RuleOptions; +using backend.Services.Rules; +using Microsoft.Extensions.Options; namespace backend.Rules; @@ -17,18 +20,29 @@ namespace backend.Rules; /// public class GrammarRule : IValidationRule { + public const string RuleId = "Grammar"; + private readonly LanguageToolService _languageToolService; private readonly ICodeBlockDetector _codeBlockDetector; + private readonly IRuleConfigurationService _ruleConfigurationService; public GrammarRule( LanguageToolService languageToolService, - ICodeBlockDetector? codeBlockDetector = null) + ICodeBlockDetector? codeBlockDetector = null, + IRuleConfigurationService? ruleConfigurationService = null, + IOptions? options = null) { + var grammarOptions = options ?? Options.Create(new GrammarRuleOptions()); + _languageToolService = languageToolService; _codeBlockDetector = codeBlockDetector ?? CodeBlockDetector.CreateDefault(); + _ruleConfigurationService = ruleConfigurationService + ?? new RuleConfigurationService( + Options.Create(new EmptySectionStructureRuleOptions()), + grammarOptions: grammarOptions); } - public string Name => "Grammar"; + public string Name => RuleId; public IEnumerable Validate(WordprocessingDocument doc, UniversityConfig config) { @@ -46,6 +60,9 @@ private async Task> ValidateAsync( DocumentCommentService? commentService) { var errors = new List(); + if (!_ruleConfigurationService.IsRuleAvailable(Name)) + return errors; + if (!await _languageToolService.IsAvailableAsync()) { errors.Add(ValidationResultFactory.Create( @@ -149,11 +166,7 @@ private ValidationResult CreateValidationResult( var issueType = GetIssueType(match); - var severity = issueType == GrammarIssueType.Spelling || issueType == GrammarIssueType.Grammar - ? ValidationSeverity.Error - : ValidationSeverity.Warning; - - return ValidationResultFactory.ForRun( + var result = ValidationResultFactory.ForRun( Name, config, $"{issueType}: {match.Message}{suggestionText}", @@ -162,8 +175,9 @@ private ValidationResult CreateValidationResult( match.Offset, match.Length, TextExtractionService.Truncate(errorText, 50), - ParagraphIndexKind.BodyElement, - severity); + ParagraphIndexKind.BodyElement); + result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); + return result; } private static GrammarIssueType GetIssueType(LanguageToolMatch match) From 234d9686af9d1eaa4f21e613bcf1caddb9237021 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:23:32 +0200 Subject: [PATCH 29/77] feat(rules): add configuration options for FigureCaptionPositionRule, FigureCaptionFormatRule, and GrammarRule --- backend/Program.cs | 12 ++++ .../Rules/RuleConfigurationService.cs | 60 +++++++++++++++++++ backend/appsettings.json | 12 ++++ 3 files changed, 84 insertions(+) diff --git a/backend/Program.cs b/backend/Program.cs index 3f6e864..c99c536 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -103,6 +103,18 @@ .Bind(builder.Configuration.GetSection(MissingFigureCaptionRuleOptions.SectionName)) .ValidateOnStart(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(FigureCaptionPositionRuleOptions.SectionName)) + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(FigureCaptionFormatRuleOptions.SectionName)) + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(GrammarRuleOptions.SectionName)) + .ValidateOnStart(); + builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(ListPunctuationConsistencyRuleOptions.SectionName)) .ValidateOnStart(); diff --git a/backend/Services/Rules/RuleConfigurationService.cs b/backend/Services/Rules/RuleConfigurationService.cs index 2602381..331e3b5 100644 --- a/backend/Services/Rules/RuleConfigurationService.cs +++ b/backend/Services/Rules/RuleConfigurationService.cs @@ -23,6 +23,9 @@ public sealed class RuleConfigurationService : IRuleConfigurationService private readonly TocRuleOptions _tocOptions; private readonly ManualTableOfContentsRuleOptions _manualTableOfContentsOptions; private readonly MissingFigureCaptionRuleOptions _missingFigureCaptionOptions; + private readonly FigureCaptionPositionRuleOptions _figureCaptionPositionOptions; + private readonly FigureCaptionFormatRuleOptions _figureCaptionFormatOptions; + private readonly GrammarRuleOptions _grammarOptions; private readonly ListPunctuationConsistencyRuleOptions _listPunctuationConsistencyOptions; private readonly ListIndentationConsistencyRuleOptions _listIndentationConsistencyOptions; @@ -39,6 +42,9 @@ public RuleConfigurationService( IOptions? tocOptions = null, IOptions? manualTableOfContentsOptions = null, IOptions? missingFigureCaptionOptions = null, + IOptions? figureCaptionPositionOptions = null, + IOptions? figureCaptionFormatOptions = null, + IOptions? grammarOptions = null, IOptions? listPunctuationConsistencyOptions = null, IOptions? listIndentationConsistencyOptions = null) { @@ -54,6 +60,9 @@ public RuleConfigurationService( _tocOptions = tocOptions?.Value ?? new TocRuleOptions(); _manualTableOfContentsOptions = manualTableOfContentsOptions?.Value ?? new ManualTableOfContentsRuleOptions(); _missingFigureCaptionOptions = missingFigureCaptionOptions?.Value ?? new MissingFigureCaptionRuleOptions(); + _figureCaptionPositionOptions = figureCaptionPositionOptions?.Value ?? new FigureCaptionPositionRuleOptions(); + _figureCaptionFormatOptions = figureCaptionFormatOptions?.Value ?? new FigureCaptionFormatRuleOptions(); + _grammarOptions = grammarOptions?.Value ?? new GrammarRuleOptions(); _listPunctuationConsistencyOptions = listPunctuationConsistencyOptions?.Value ?? new ListPunctuationConsistencyRuleOptions(); _listIndentationConsistencyOptions = listIndentationConsistencyOptions?.Value ?? new ListIndentationConsistencyRuleOptions(); } @@ -96,6 +105,15 @@ public bool IsRuleAvailable(string ruleId) if (IsMissingFigureCaptionRule(ruleId)) return _missingFigureCaptionOptions.Availability != RuleAvailability.Hidden; + if (IsFigureCaptionPositionRule(ruleId)) + return _figureCaptionPositionOptions.Availability != RuleAvailability.Hidden; + + if (IsFigureCaptionFormatRule(ruleId)) + return _figureCaptionFormatOptions.Availability != RuleAvailability.Hidden; + + if (IsGrammarRule(ruleId)) + return _grammarOptions.Availability != RuleAvailability.Hidden; + if (IsListPunctuationConsistencyRule(ruleId)) return _listPunctuationConsistencyOptions.Availability != RuleAvailability.Hidden; @@ -146,6 +164,15 @@ public string ResolveSeverity( if (IsMissingFigureCaptionRule(ruleId)) return ValidationSeverity.Normalize(_missingFigureCaptionOptions.Severity.ToString()); + if (IsFigureCaptionPositionRule(ruleId)) + return ValidationSeverity.Normalize(_figureCaptionPositionOptions.Severity.ToString()); + + if (IsFigureCaptionFormatRule(ruleId)) + return ValidationSeverity.Normalize(_figureCaptionFormatOptions.Severity.ToString()); + + if (IsGrammarRule(ruleId)) + return ValidationSeverity.Normalize(_grammarOptions.Severity.ToString()); + if (IsListPunctuationConsistencyRule(ruleId)) return ValidationSeverity.Normalize(_listPunctuationConsistencyOptions.Severity.ToString()); @@ -193,6 +220,15 @@ public RuleDefinition ApplyConfiguration(RuleDefinition definition) if (IsMissingFigureCaptionRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_missingFigureCaptionOptions.Severity.ToString()) }; + if (IsFigureCaptionPositionRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_figureCaptionPositionOptions.Severity.ToString()) }; + + if (IsFigureCaptionFormatRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_figureCaptionFormatOptions.Severity.ToString()) }; + + if (IsGrammarRule(definition.Id)) + return definition with { DefaultSeverity = ValidationSeverity.Normalize(_grammarOptions.Severity.ToString()) }; + if (IsListPunctuationConsistencyRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_listPunctuationConsistencyOptions.Severity.ToString()) }; @@ -298,6 +334,30 @@ private static bool IsMissingFigureCaptionRule(string ruleId) StringComparison.OrdinalIgnoreCase); } + private static bool IsFigureCaptionPositionRule(string ruleId) + { + return string.Equals( + ruleId, + FigureCaptionPositionRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsFigureCaptionFormatRule(string ruleId) + { + return string.Equals( + ruleId, + FigureCaptionFormatRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsGrammarRule(string ruleId) + { + return string.Equals( + ruleId, + GrammarRule.RuleId, + StringComparison.OrdinalIgnoreCase); + } + private static bool IsListPunctuationConsistencyRule(string ruleId) { return string.Equals( diff --git a/backend/appsettings.json b/backend/appsettings.json index 27691f6..4944ed0 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -73,6 +73,18 @@ "Availability": "Available", "Severity": "Error" }, + "FigureCaptionPositionRule": { + "Availability": "Available", + "Severity": "Error" + }, + "FigureCaptionFormatRule": { + "Availability": "Available", + "Severity": "Error" + }, + "Grammar": { + "Availability": "Available", + "Severity": "Error" + }, "ListPunctuationConsistencyRule": { "Availability": "Available", "Severity": "Error" From c1fe2cf65956d3a51ea11811b912e553505cff74 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:34 +0200 Subject: [PATCH 30/77] refactor(models): clean up validation models and update app configuration --- backend/Models/DocumentValidationResponse.cs | 1 - backend/Models/ValidationResult.cs | 49 -------------------- backend/appsettings.json | 29 +++--------- 3 files changed, 7 insertions(+), 72 deletions(-) diff --git a/backend/Models/DocumentValidationResponse.cs b/backend/Models/DocumentValidationResponse.cs index fba20ea..63a221e 100644 --- a/backend/Models/DocumentValidationResponse.cs +++ b/backend/Models/DocumentValidationResponse.cs @@ -10,5 +10,4 @@ public class DocumentValidationResponse public int TotalWarnings { get; set; } public string ConfigUsed { get; set; } = string.Empty; public List Results { get; set; } = new(); - public List Headings { get; set; } = new(); } diff --git a/backend/Models/ValidationResult.cs b/backend/Models/ValidationResult.cs index 0224a54..69e114f 100644 --- a/backend/Models/ValidationResult.cs +++ b/backend/Models/ValidationResult.cs @@ -59,27 +59,6 @@ public string Severity /// public class DocumentLocation { - private const int ApproximateLinesPerPage = 30; - private int _pageNumber; - private int _lineNumber; - - /// - /// Approximate page number (1-based). Calculated based on page breaks and paragraph count. - /// - public int PageNumber - { - get => _pageNumber > 0 ? _pageNumber : EstimatePageNumber(); - set => _pageNumber = value; - } - - /// - /// Approximate line number within the page (1-based). - /// - public int LineNumber - { - get => _lineNumber > 0 ? _lineNumber : EstimateLineNumber(); - set => _lineNumber = value; - } /// /// 1-based paragraph index in the document body. @@ -112,35 +91,7 @@ public int LineNumber /// public string Section { get; set; } = string.Empty; - /// - /// Human-readable description of the location. - /// - public string Description => string.IsNullOrEmpty(Section) - ? DescribeParagraphLocation() - : Section; - - public override string ToString() => Description; - - private int EstimatePageNumber() - { - return Paragraph <= 0 - ? 0 - : ((Paragraph - 1) / ApproximateLinesPerPage) + 1; - } - private int EstimateLineNumber() - { - return Paragraph <= 0 - ? 0 - : ((Paragraph - 1) % ApproximateLinesPerPage) + 1; - } - - private string DescribeParagraphLocation() - { - return Paragraph <= 0 - ? "Document" - : $"Page {PageNumber}, Line {LineNumber}, Paragraph {Paragraph}"; - } } public class HeadingInfo diff --git a/backend/appsettings.json b/backend/appsettings.json index 4944ed0..6805d2a 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -7,6 +7,11 @@ }, "AllowedHosts": "*", "Validation": { + "Skipping": { + "SkipBeforeTableOfContents": true, + "SkipTextBoxes": true, + "SkipTableOfContentsContent": true + }, "Rules": { "EmptySectionStructureRule": { "Availability": "Available", @@ -15,7 +20,7 @@ "FontFamily": { "Availability": "Available", "Severity": "Warning", - "RequiredFontFamily": "New Times Roman" + "RequiredFontFamily": "Times New Roman" }, "SingleSpaceRule": { "Availability": "Available", @@ -120,26 +125,6 @@ "UniversityConfig": { "Name": "Default University", "Language": "pl-PL", - "Analysis": { - "SkipTextBoxes": true, - "SkipTableOfContentsContent": true - }, - "Rules": { - "Overrides": {} - }, - "Formatting": { - "Font": { - "FontFamily": "Times New Roman", - "FontSize": 12 - }, - "SkipTextBoxes": true, - "SkipTableOfContentsContent": true, - "Layout": { - "MarginLeft": 2.5, - "MarginRight": 2.5, - "RequiredIndentCm": 1.25, - "ParagraphSpacingRule": [0, 6] - } - } + "Description": "..." } } From bc16a414c73e42706148d19015865806af4725bd Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:34 +0200 Subject: [PATCH 31/77] feat(framework): introduce modern validation rule primitives --- backend/Rules/Framework/AnnotationTarget.cs | 9 +++++ backend/Rules/Framework/DocumentContent.cs | 36 +++++++++++++++++++ .../Rules/Framework/IModernValidationRule.cs | 12 +++++++ backend/Rules/Framework/NoRuleOptions.cs | 6 ++++ backend/Rules/Framework/RuleContext.cs | 9 +++++ backend/Rules/Framework/RuleDescriptor.cs | 11 ++++++ backend/Rules/Framework/RuleOptionsBinder.cs | 27 ++++++++++++++ backend/Rules/Framework/RulePolicy.cs | 7 ++++ backend/Rules/Framework/RulePolicyOptions.cs | 10 ++++++ backend/Rules/Framework/RulePolicyResolver.cs | 27 ++++++++++++++ backend/Rules/Framework/RuleProblem.cs | 9 +++++ .../Framework/ValidationResultComposer.cs | 22 ++++++++++++ backend/Rules/Framework/ValidationRule.cs | 30 ++++++++++++++++ 13 files changed, 215 insertions(+) create mode 100644 backend/Rules/Framework/AnnotationTarget.cs create mode 100644 backend/Rules/Framework/DocumentContent.cs create mode 100644 backend/Rules/Framework/IModernValidationRule.cs create mode 100644 backend/Rules/Framework/NoRuleOptions.cs create mode 100644 backend/Rules/Framework/RuleContext.cs create mode 100644 backend/Rules/Framework/RuleDescriptor.cs create mode 100644 backend/Rules/Framework/RuleOptionsBinder.cs create mode 100644 backend/Rules/Framework/RulePolicy.cs create mode 100644 backend/Rules/Framework/RulePolicyOptions.cs create mode 100644 backend/Rules/Framework/RulePolicyResolver.cs create mode 100644 backend/Rules/Framework/RuleProblem.cs create mode 100644 backend/Rules/Framework/ValidationResultComposer.cs create mode 100644 backend/Rules/Framework/ValidationRule.cs diff --git a/backend/Rules/Framework/AnnotationTarget.cs b/backend/Rules/Framework/AnnotationTarget.cs new file mode 100644 index 0000000..05d2189 --- /dev/null +++ b/backend/Rules/Framework/AnnotationTarget.cs @@ -0,0 +1,9 @@ +using DocumentFormat.OpenXml.Wordprocessing; + +namespace ThesisValidator.Rules; + +public abstract record AnnotationTarget; + +public sealed record ParagraphAnnotationTarget(Paragraph Paragraph) : AnnotationTarget; + +public sealed record RunAnnotationTarget(Run Run) : AnnotationTarget; diff --git a/backend/Rules/Framework/DocumentContent.cs b/backend/Rules/Framework/DocumentContent.cs new file mode 100644 index 0000000..977fb60 --- /dev/null +++ b/backend/Rules/Framework/DocumentContent.cs @@ -0,0 +1,36 @@ +using DocumentFormat.OpenXml.Wordprocessing; + +namespace ThesisValidator.Rules; + +public sealed class DocumentContent +{ + public IReadOnlyList BodyChildParagraphs { get; init; } = []; + + public IReadOnlyList Sections { get; init; } = []; +} + +public sealed class ParagraphNode +{ + public required Paragraph Paragraph { get; init; } + + public required int BodyIndex { get; init; } + + public required string Text { get; init; } + + public int? HeadingLevel { get; init; } + + public bool IsHeading => HeadingLevel is not null; +} + +public sealed class SectionNode +{ + public required ParagraphNode Heading { get; init; } + + public bool HasIntroductoryContent { get; set; } + + public List Children { get; } = []; + + public int Level => Heading.HeadingLevel ?? 0; + + public string Title => Heading.Text; +} diff --git a/backend/Rules/Framework/IModernValidationRule.cs b/backend/Rules/Framework/IModernValidationRule.cs new file mode 100644 index 0000000..0639c19 --- /dev/null +++ b/backend/Rules/Framework/IModernValidationRule.cs @@ -0,0 +1,12 @@ +namespace ThesisValidator.Rules; + +public interface IModernValidationRule +{ + RuleDescriptor Descriptor { get; } + + Type OptionsType { get; } + + IEnumerable Validate( + RuleContext context, + object options); +} diff --git a/backend/Rules/Framework/NoRuleOptions.cs b/backend/Rules/Framework/NoRuleOptions.cs new file mode 100644 index 0000000..ce8dc90 --- /dev/null +++ b/backend/Rules/Framework/NoRuleOptions.cs @@ -0,0 +1,6 @@ +namespace ThesisValidator.Rules; + +public sealed class NoRuleOptions +{ + +} \ No newline at end of file diff --git a/backend/Rules/Framework/RuleContext.cs b/backend/Rules/Framework/RuleContext.cs new file mode 100644 index 0000000..e0a5c32 --- /dev/null +++ b/backend/Rules/Framework/RuleContext.cs @@ -0,0 +1,9 @@ +using DocumentFormat.OpenXml.Packaging; + +namespace ThesisValidator.Rules; + +public sealed class RuleContext +{ + public required WordprocessingDocument RawDocument { get; init; } + public required DocumentContent Content { get; init; } +} diff --git a/backend/Rules/Framework/RuleDescriptor.cs b/backend/Rules/Framework/RuleDescriptor.cs new file mode 100644 index 0000000..a587715 --- /dev/null +++ b/backend/Rules/Framework/RuleDescriptor.cs @@ -0,0 +1,11 @@ +using backend.RuleOptions; + +namespace ThesisValidator.Rules; + +public sealed record RuleDescriptor( + string Name, + string DisplayName, + string Description, + string Category, + RuleAvailability DefaultAvailability, + RuleSeverity DefaultSeverity); \ No newline at end of file diff --git a/backend/Rules/Framework/RuleOptionsBinder.cs b/backend/Rules/Framework/RuleOptionsBinder.cs new file mode 100644 index 0000000..dfd6acf --- /dev/null +++ b/backend/Rules/Framework/RuleOptionsBinder.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Configuration; + +namespace ThesisValidator.Rules; + +public sealed class RuleOptionsBinder +{ + private readonly IConfiguration _configuration; + + public RuleOptionsBinder(IConfiguration configuration) + { + _configuration = configuration; + } + + public object Bind(IModernValidationRule rule) + { + var options = Activator.CreateInstance(rule.OptionsType) + ?? throw new InvalidOperationException( + $"Could not create options for rule '{rule.Descriptor.Name}'."); + + var section = _configuration.GetSection( + $"Validation:Rules:{rule.Descriptor.Name}"); + + section.Bind(options); + + return options; + } +} diff --git a/backend/Rules/Framework/RulePolicy.cs b/backend/Rules/Framework/RulePolicy.cs new file mode 100644 index 0000000..bb70137 --- /dev/null +++ b/backend/Rules/Framework/RulePolicy.cs @@ -0,0 +1,7 @@ +using backend.RuleOptions; + +namespace ThesisValidator.Rules; + +public sealed record RulePolicy( + RuleAvailability Availability, + RuleSeverity Severity); diff --git a/backend/Rules/Framework/RulePolicyOptions.cs b/backend/Rules/Framework/RulePolicyOptions.cs new file mode 100644 index 0000000..139be9a --- /dev/null +++ b/backend/Rules/Framework/RulePolicyOptions.cs @@ -0,0 +1,10 @@ +using backend.RuleOptions; + +namespace ThesisValidator.Rules; + +public sealed class RulePolicyOptions +{ + public RuleAvailability? Availability { get; set; } + + public RuleSeverity? Severity { get; set; } +} diff --git a/backend/Rules/Framework/RulePolicyResolver.cs b/backend/Rules/Framework/RulePolicyResolver.cs new file mode 100644 index 0000000..2cfa154 --- /dev/null +++ b/backend/Rules/Framework/RulePolicyResolver.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Configuration; + +namespace ThesisValidator.Rules; + +public sealed class RulePolicyResolver +{ + private readonly IConfiguration _configuration; + + public RulePolicyResolver(IConfiguration configuration) + { + _configuration = configuration; + } + + public RulePolicy Resolve(RuleDescriptor descriptor) + { + var section = _configuration.GetSection( + $"Validation:Rules:{descriptor.Name}"); + + var configuredPolicy = section.Get(); + + return new RulePolicy( + Availability: configuredPolicy?.Availability + ?? descriptor.DefaultAvailability, + Severity: configuredPolicy?.Severity + ?? descriptor.DefaultSeverity); + } +} diff --git a/backend/Rules/Framework/RuleProblem.cs b/backend/Rules/Framework/RuleProblem.cs new file mode 100644 index 0000000..ad5c026 --- /dev/null +++ b/backend/Rules/Framework/RuleProblem.cs @@ -0,0 +1,9 @@ +using backend.Models; + +namespace ThesisValidator.Rules; + +public sealed record RuleProblem( + string Message, + DocumentLocation Location, + ParagraphIndexKind ParagraphIndexKind = ParagraphIndexKind.Descendant, + AnnotationTarget? AnnotationTarget = null); diff --git a/backend/Rules/Framework/ValidationResultComposer.cs b/backend/Rules/Framework/ValidationResultComposer.cs new file mode 100644 index 0000000..c9d41a3 --- /dev/null +++ b/backend/Rules/Framework/ValidationResultComposer.cs @@ -0,0 +1,22 @@ +using backend.Models; + +namespace ThesisValidator.Rules; + +public sealed class ValidationResultComposer +{ + public ValidationResult Compose( + RuleDescriptor descriptor, + RulePolicy policy, + RuleProblem problem) + { + return new ValidationResult + { + RuleName = descriptor.Name, + Message = problem.Message, + Category = descriptor.Category, + Severity = policy.Severity.ToString(), + ParagraphIndexKind = problem.ParagraphIndexKind, + Location = problem.Location + }; + } +} diff --git a/backend/Rules/Framework/ValidationRule.cs b/backend/Rules/Framework/ValidationRule.cs new file mode 100644 index 0000000..2274ab6 --- /dev/null +++ b/backend/Rules/Framework/ValidationRule.cs @@ -0,0 +1,30 @@ + +namespace ThesisValidator.Rules; + +public abstract class ValidationRule : IModernValidationRule + where TOptions : class, new() +{ + public abstract RuleDescriptor Descriptor { get; } + + public Type OptionsType => typeof(TOptions); + + public abstract IEnumerable Validate( + RuleContext context, + TOptions options); + + IEnumerable IModernValidationRule.Validate( + RuleContext context, + object options) + { + if (options is not TOptions typedOptions) + { + throw new InvalidOperationException( + $"Invalid options type for rule '{Descriptor.Name}'. " + + $"Expected {typeof(TOptions).Name}, got {options.GetType().Name}."); + } + + return Validate(context, typedOptions); + } + + +} From 22a2aa138e1abb676e11e3428bff81ff285c44af Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:34 +0200 Subject: [PATCH 32/77] feat(engine): add structured document content analysis --- .../ModernServices/DocumentContentAnalyzer.cs | 116 ++++++++++++++ .../ModernServices/ModernDocumentSession.cs | 97 ++++++++++++ .../ModernDocumentSkipService.cs | 144 ++++++++++++++++++ .../ModernServices/ModernValidationOptions.cs | 12 ++ 4 files changed, 369 insertions(+) create mode 100644 backend/ModernServices/DocumentContentAnalyzer.cs create mode 100644 backend/ModernServices/ModernDocumentSession.cs create mode 100644 backend/ModernServices/ModernDocumentSkipService.cs create mode 100644 backend/ModernServices/ModernValidationOptions.cs diff --git a/backend/ModernServices/DocumentContentAnalyzer.cs b/backend/ModernServices/DocumentContentAnalyzer.cs new file mode 100644 index 0000000..366962b --- /dev/null +++ b/backend/ModernServices/DocumentContentAnalyzer.cs @@ -0,0 +1,116 @@ +using backend.Services.Extraction; +using backend.Services.Structure; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; + +namespace backend.ModernServices; + +public sealed class DocumentContentAnalyzer +{ + private readonly ModernDocumentSkipService _skipService; + + public DocumentContentAnalyzer(ModernDocumentSkipService? skipService = null) + { + _skipService = skipService + ?? new ModernDocumentSkipService(Options.Create(new ModernValidationOptions())); + } + + public DocumentContent Analyze(WordprocessingDocument doc) + { + var body = doc.MainDocumentPart?.Document.Body; + if (body is null) + return new DocumentContent(); + + var paragraphs = new List(); + var rootSections = new List(); + var sectionStack = new List(); + + var firstIncludedChildIndex = _skipService.GetFirstIncludedBodyChildIndex(doc); + var tocParagraphs = _skipService.GetSkippedTableOfContentsParagraphs(doc); + + var paragraphIndex = 0; + var childIndex = 0; + + foreach (var element in body.ChildElements) + { + if (element is Paragraph) + paragraphIndex++; + + if (childIndex++ < firstIncludedChildIndex) + continue; + + if (element is not Paragraph paragraph) + { + MarkIntroductoryContent(sectionStack); + continue; + } + + if (_skipService.ShouldSkipParagraph(paragraph, tocParagraphs)) + continue; + + var text = TextExtractionService.GetParagraphText( + paragraph, + _skipService.SkipTextBoxes).Trim(); + var headingLevel = HeadingDetectionService.GetHeadingLevel(doc, paragraph); + var paragraphNode = new ParagraphNode + { + Paragraph = paragraph, + BodyIndex = paragraphIndex, + Text = text, + HeadingLevel = headingLevel + }; + paragraphs.Add(paragraphNode); + + if (headingLevel is not null) + { + AddSection(paragraphNode, rootSections, sectionStack); + continue; + } + + if (!string.IsNullOrWhiteSpace(text)) + MarkIntroductoryContent(sectionStack); + } + + return new DocumentContent + { + BodyChildParagraphs = paragraphs, + Sections = rootSections + }; + } + + private static void AddSection( + ParagraphNode heading, + List rootSections, + List sectionStack) + { + var level = heading.HeadingLevel!.Value; + while (sectionStack.Count > 0 && sectionStack[^1].Level >= level) + { + sectionStack.RemoveAt(sectionStack.Count - 1); + } + + var section = new SectionNode { Heading = heading }; + if (sectionStack.Count == 0) + { + rootSections.Add(section); + } + else + { + sectionStack[^1].Children.Add(section); + } + + sectionStack.Add(section); + } + + private static void MarkIntroductoryContent(List sectionStack) + { + if (sectionStack.Count == 0) + return; + + var currentSection = sectionStack[^1]; + if (currentSection.Children.Count == 0) + currentSection.HasIntroductoryContent = true; + } +} diff --git a/backend/ModernServices/ModernDocumentSession.cs b/backend/ModernServices/ModernDocumentSession.cs new file mode 100644 index 0000000..b9dde42 --- /dev/null +++ b/backend/ModernServices/ModernDocumentSession.cs @@ -0,0 +1,97 @@ +using backend.Services.Comments; +using backend.Services.Exceptions; +using DocumentFormat.OpenXml.Packaging; + +namespace backend.ModernServices; + +public sealed class ModernDocumentSession +{ + public WordprocessingDocument OpenRead(Stream stream) + { + return OpenDocument(stream, isEditable: false); + } + + public ModernEditableDocument OpenEditableCopy(Stream stream) + { + var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + memoryStream.Position = 0; + + var document = OpenDocument(memoryStream, isEditable: true); + return new ModernEditableDocument(document, memoryStream); + } + + public MemoryStream SaveAnnotated(WordprocessingDocument document) + { + try + { + document.MainDocumentPart?.Document.Save(); + return DocumentCommentService.SaveDocumentWithComments(document); + } + catch (Exception ex) when (IsDocumentProcessingException(ex)) + { + throw new InvalidThesisDocumentException( + "The uploaded file could not be saved as an annotated DOCX document.", + ex); + } + } + + private static WordprocessingDocument OpenDocument(Stream stream, bool isEditable) + { + try + { + var document = WordprocessingDocument.Open(stream, isEditable); + if (document.MainDocumentPart?.Document is not null) + { + return document; + } + + document.Dispose(); + throw new InvalidThesisDocumentException( + "The uploaded file is not a valid Wordprocessing document."); + } + catch (InvalidThesisDocumentException) + { + throw; + } + catch (Exception ex) when (IsDocumentProcessingException(ex)) + { + throw new InvalidThesisDocumentException( + "The uploaded file could not be opened as a DOCX document.", + ex); + } + } + + private static bool IsDocumentProcessingException(Exception exception) + { + return exception is OpenXmlPackageException + or FileFormatException + or InvalidDataException + or IOException + or NotSupportedException + or InvalidOperationException + or ArgumentException + or ObjectDisposedException; + } +} + +public sealed class ModernEditableDocument : IDisposable +{ + private readonly MemoryStream _stream; + + public ModernEditableDocument( + WordprocessingDocument document, + MemoryStream stream) + { + Document = document; + _stream = stream; + } + + public WordprocessingDocument Document { get; } + + public void Dispose() + { + Document.Dispose(); + _stream.Dispose(); + } +} diff --git a/backend/ModernServices/ModernDocumentSkipService.cs b/backend/ModernServices/ModernDocumentSkipService.cs new file mode 100644 index 0000000..0102924 --- /dev/null +++ b/backend/ModernServices/ModernDocumentSkipService.cs @@ -0,0 +1,144 @@ +using backend.Services.Extraction; +using backend.Services.Skipping; +using backend.Services.Structure; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Microsoft.Extensions.Options; + +namespace backend.ModernServices; + +public sealed class ModernDocumentSkipService +{ + private readonly ModernValidationOptions _options; + + public ModernDocumentSkipService(IOptions options) + { + _options = options.Value; + } + + public bool SkipTextBoxes => _options.SkipTextBoxes; + + public int GetFirstIncludedBodyChildIndex( + WordprocessingDocument doc) + { + if (!_options.SkipBeforeTableOfContents) + return 0; + + var body = doc.MainDocumentPart?.Document.Body; + if (body is null) + return 0; + + var tocParagraph = TableOfContentsDetectionService.Detect(doc).Paragraph; + if (tocParagraph is null) + return 0; + + var tocBodyChild = GetBodyChildAncestor(tocParagraph, body); + if (tocBodyChild is null) + return 0; + + var childIndex = 0; + foreach (var child in body.ChildElements) + { + if (ReferenceEquals(child, tocBodyChild)) + return childIndex + 1; + + childIndex++; + } + + return 0; + } + + public HashSet GetSkippedTableOfContentsParagraphs( + WordprocessingDocument doc) + { + var paragraphs = new HashSet(); + if (!_options.SkipTableOfContentsContent) + return paragraphs; + + var body = doc.MainDocumentPart?.Document.Body; + if (body is null) + return paragraphs; + + var inTocField = false; + foreach (var paragraph in body.Descendants()) + { + if (TableOfContentsDetectionService.ContainsTocFieldCode(paragraph)) + { + inTocField = true; + paragraphs.Add(paragraph); + } + + if (inTocField) + { + paragraphs.Add(paragraph); + } + else if (IsTocStyleParagraph(paragraph)) + { + paragraphs.Add(paragraph); + } + + if (inTocField && ContainsFieldEnd(paragraph)) + { + inTocField = false; + } + } + + return paragraphs; + } + + public bool ShouldSkipParagraph( + Paragraph paragraph, + IReadOnlySet tableOfContentsParagraphs) + { + if (tableOfContentsParagraphs.Contains(paragraph)) + return true; + + return _options.SkipTextBoxes + && (TextBoxSkipRule.IsInsideTextBoxOrDrawingText(paragraph) + || IsTextBoxOnlyParagraph(paragraph)); + } + + private static bool IsTextBoxOnlyParagraph(Paragraph paragraph) + { + return paragraph.Descendants().Any(TextBoxSkipRule.IsInsideTextBoxOrDrawingText) + && !TextExtractionService.HasMeaningfulContent(GetParagraphTextWithoutTextBoxes(paragraph)); + } + + private static string GetParagraphTextWithoutTextBoxes(Paragraph paragraph) + { + return string.Concat(paragraph + .Descendants() + .Where(text => !TextBoxSkipRule.IsInsideTextBoxOrDrawingText(text)) + .Select(text => text.Text)); + } + + private static bool IsTocStyleParagraph(Paragraph paragraph) + { + var styleId = paragraph.ParagraphProperties?.ParagraphStyleId?.Val?.Value; + if (string.IsNullOrWhiteSpace(styleId)) + return false; + + var normalized = styleId.Replace(" ", "", StringComparison.OrdinalIgnoreCase); + return normalized.StartsWith("TOC", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("Spis", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("TableOfContents", StringComparison.OrdinalIgnoreCase); + } + + private static bool ContainsFieldEnd(Paragraph paragraph) + { + return paragraph.Descendants() + .Any(fieldChar => fieldChar.FieldCharType?.Value == FieldCharValues.End); + } + + private static OpenXmlElement? GetBodyChildAncestor(OpenXmlElement element, Body body) + { + OpenXmlElement? current = element; + while (current?.Parent is not null && current.Parent != body) + { + current = current.Parent; + } + + return current?.Parent == body ? current : null; + } +} diff --git a/backend/ModernServices/ModernValidationOptions.cs b/backend/ModernServices/ModernValidationOptions.cs new file mode 100644 index 0000000..543719c --- /dev/null +++ b/backend/ModernServices/ModernValidationOptions.cs @@ -0,0 +1,12 @@ +namespace backend.ModernServices; + +public sealed class ModernValidationOptions +{ + public const string SectionName = "Validation:Skipping"; + + public bool SkipBeforeTableOfContents { get; init; } + + public bool SkipTextBoxes { get; init; } = true; + + public bool SkipTableOfContentsContent { get; init; } = true; +} From afabd7d7c53e3dfb35c58b1a541b20d36be5a05d Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:34 +0200 Subject: [PATCH 33/77] feat(engine): implement modern rule orchestration --- .../ModernServices/ModernAnnotationApplier.cs | 46 +++++++++ backend/ModernServices/ModernRuleRunner.cs | 99 +++++++++++++++++++ .../ModernSectionContextService.cs | 44 +++++++++ .../ModernThesisValidatorService.cs | 81 +++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 backend/ModernServices/ModernAnnotationApplier.cs create mode 100644 backend/ModernServices/ModernRuleRunner.cs create mode 100644 backend/ModernServices/ModernSectionContextService.cs create mode 100644 backend/ModernServices/ModernThesisValidatorService.cs diff --git a/backend/ModernServices/ModernAnnotationApplier.cs b/backend/ModernServices/ModernAnnotationApplier.cs new file mode 100644 index 0000000..bdd5c80 --- /dev/null +++ b/backend/ModernServices/ModernAnnotationApplier.cs @@ -0,0 +1,46 @@ +using backend.Services.Comments; +using DocumentFormat.OpenXml.Packaging; +using ThesisValidator.Rules; + +namespace backend.ModernServices; + +public sealed class ModernAnnotationApplier +{ + public void Apply( + WordprocessingDocument document, + IEnumerable executions) + { + var commentService = new DocumentCommentService(); + + foreach (var execution in executions) + { + AddCommentForProblem( + commentService, + document, + execution.Problem); + } + } + + private static void AddCommentForProblem( + DocumentCommentService commentService, + WordprocessingDocument document, + RuleProblem problem) + { + switch (problem.AnnotationTarget) + { + case ParagraphAnnotationTarget paragraphTarget: + commentService.AddCommentToParagraph( + document, + paragraphTarget.Paragraph, + problem.Message); + break; + + case RunAnnotationTarget runTarget: + commentService.AddCommentToRun( + document, + runTarget.Run, + problem.Message); + break; + } + } +} diff --git a/backend/ModernServices/ModernRuleRunner.cs b/backend/ModernServices/ModernRuleRunner.cs new file mode 100644 index 0000000..39b54b0 --- /dev/null +++ b/backend/ModernServices/ModernRuleRunner.cs @@ -0,0 +1,99 @@ +using backend.Models; +using backend.RuleOptions; +using ThesisValidator.Rules; + +namespace backend.ModernServices; + +public sealed class ModernRuleRunner +{ + private readonly IReadOnlyList _rules; + private readonly RulePolicyResolver _policyResolver; + private readonly RuleOptionsBinder _optionsBinder; + private readonly ValidationResultComposer _resultComposer; + + public ModernRuleRunner( + IEnumerable rules, + RulePolicyResolver policyResolver, + RuleOptionsBinder optionsBinder, + ValidationResultComposer resultComposer) + { + _rules = rules.ToList(); + _policyResolver = policyResolver; + _optionsBinder = optionsBinder; + _resultComposer = resultComposer; + } + + public IReadOnlyList GetAvailableRules() + { + return _rules + .Select(rule => (Rule: rule, Policy: _policyResolver.Resolve(rule.Descriptor))) + .Where(pair => pair.Policy.Availability != RuleAvailability.Hidden) + .Select(pair => new RuleDefinition( + pair.Rule.Descriptor.Name, + pair.Rule.Descriptor.DisplayName, + pair.Rule.Descriptor.Category, + pair.Policy.Severity.ToString())) + .ToList(); + } + + public IReadOnlyList GetUnknownRuleNames(IEnumerable selectedRules) + { + var knownRules = _rules + .Where(rule => _policyResolver.Resolve(rule.Descriptor).Availability != RuleAvailability.Hidden) + .Select(rule => rule.Descriptor.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return selectedRules + .Where(ruleName => !knownRules.Contains(ruleName)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public IReadOnlyList Run( + RuleContext context, + IEnumerable? selectedRules) + { + var executions = new List(); + + foreach (var rule in GetRulesToRun(selectedRules)) + { + var policy = _policyResolver.Resolve(rule.Descriptor); + var boundOptions = _optionsBinder.Bind(rule); + + foreach (var problem in rule.Validate(context, boundOptions)) + { + var result = _resultComposer.Compose( + rule.Descriptor, + policy, + problem); + + executions.Add(new ModernRuleExecution(result, problem)); + } + } + + return executions; + } + + private IReadOnlyList GetRulesToRun(IEnumerable? selectedRules) + { + var selectedSet = selectedRules? + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return _rules + .Where(rule => + { + var policy = _policyResolver.Resolve(rule.Descriptor); + if (policy.Availability == RuleAvailability.Hidden) + return false; + + return selectedSet is null + || selectedSet.Count == 0 + || selectedSet.Contains(rule.Descriptor.Name); + }) + .ToList(); + } +} + +public sealed record ModernRuleExecution( + ValidationResult Result, + RuleProblem Problem); diff --git a/backend/ModernServices/ModernSectionContextService.cs b/backend/ModernServices/ModernSectionContextService.cs new file mode 100644 index 0000000..1eba4ed --- /dev/null +++ b/backend/ModernServices/ModernSectionContextService.cs @@ -0,0 +1,44 @@ +using backend.Models; +using ThesisValidator.Rules; + +namespace backend.ModernServices; + +public sealed class ModernSectionContextService +{ + public void PopulateSectionContext( + IReadOnlyList results, + DocumentContent content) + { + var headings = content.BodyChildParagraphs + .Where(paragraph => paragraph.HeadingLevel is not null) + .Select(paragraph => (paragraph.BodyIndex, paragraph.Text)) + .ToList(); + + foreach (var result in results) + { + var paragraphIndex = result.Location?.Paragraph ?? 0; + if (paragraphIndex <= 0) + continue; + + var section = FindNearestSection(headings, paragraphIndex); + if (section is not null) + result.Location!.Section = section; + } + } + + private static string? FindNearestSection( + List<(int Index, string Text)> headings, + int paragraphIndex) + { + string? nearest = null; + foreach (var (index, text) in headings) + { + if (index <= paragraphIndex) + nearest = text; + else + break; + } + + return nearest; + } +} diff --git a/backend/ModernServices/ModernThesisValidatorService.cs b/backend/ModernServices/ModernThesisValidatorService.cs new file mode 100644 index 0000000..9b539a0 --- /dev/null +++ b/backend/ModernServices/ModernThesisValidatorService.cs @@ -0,0 +1,81 @@ +using backend.Models; +using ThesisValidator.Rules; + +namespace backend.ModernServices; + +public sealed class ModernThesisValidatorService +{ + private readonly ModernDocumentSession _documentSession; + private readonly DocumentContentAnalyzer _contentAnalyzer; + private readonly ModernRuleRunner _ruleRunner; + private readonly ModernSectionContextService _sectionContextService; + private readonly ModernAnnotationApplier _annotationApplier; + + public ModernThesisValidatorService( + ModernDocumentSession documentSession, + DocumentContentAnalyzer contentAnalyzer, + ModernRuleRunner ruleRunner, + ModernSectionContextService sectionContextService, + ModernAnnotationApplier annotationApplier) + { + _documentSession = documentSession; + _contentAnalyzer = contentAnalyzer; + _ruleRunner = ruleRunner; + _sectionContextService = sectionContextService; + _annotationApplier = annotationApplier; + } + + public IReadOnlyList GetAvailableRules() + { + return _ruleRunner.GetAvailableRules(); + } + + public IReadOnlyList GetUnknownRuleNames(IEnumerable selectedRules) + { + return _ruleRunner.GetUnknownRuleNames(selectedRules); + } + + public IReadOnlyList Validate( + Stream fileStream, + IEnumerable? selectedRules = null) + { + using var document = _documentSession.OpenRead(fileStream); + return ValidateOpenDocument(document, selectedRules) + .Select(execution => execution.Result) + .ToList(); + } + + public (IReadOnlyList Results, MemoryStream AnnotatedDocument) ValidateWithComments( + Stream fileStream, + IEnumerable? selectedRules = null) + { + using var editableDocument = _documentSession.OpenEditableCopy(fileStream); + var executions = ValidateOpenDocument(editableDocument.Document, selectedRules); + + _annotationApplier.Apply(editableDocument.Document, executions); + + return ( + executions.Select(execution => execution.Result).ToList(), + _documentSession.SaveAnnotated(editableDocument.Document)); + } + + private IReadOnlyList ValidateOpenDocument( + DocumentFormat.OpenXml.Packaging.WordprocessingDocument document, + IEnumerable? selectedRules) + { + var content = _contentAnalyzer.Analyze(document); + var context = new RuleContext + { + RawDocument = document, + Content = content + }; + + var executions = _ruleRunner.Run(context, selectedRules); + var results = executions + .Select(execution => execution.Result) + .ToList(); + + _sectionContextService.PopulateSectionContext(results, content); + return executions; + } +} From 8d6dd2af76fa3b467eba906995f9340bcf99e23d Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:35 +0200 Subject: [PATCH 34/77] refactor(rules): migrate FontFamilyRule to modern framework --- .../Exploratory/FontExplorationTests.cs | 4 +- .../Rules/FontFamilyRuleConfigurationTests.cs | 238 +++++++----------- backend.Tests/Rules/FontFamilyRuleTests.cs | 210 ++++++---------- backend.Tests/backend.Tests.csproj | 4 +- backend/Rules/FontFamilyRule.cs | 117 --------- .../Rules/FontFamilyRule/FontFamilyRule.cs | 89 +++++++ 6 files changed, 257 insertions(+), 405 deletions(-) delete mode 100644 backend/Rules/FontFamilyRule.cs create mode 100644 backend/Rules/FontFamilyRule/FontFamilyRule.cs diff --git a/backend.Tests/Exploratory/FontExplorationTests.cs b/backend.Tests/Exploratory/FontExplorationTests.cs index 3943302..07aded6 100644 --- a/backend.Tests/Exploratory/FontExplorationTests.cs +++ b/backend.Tests/Exploratory/FontExplorationTests.cs @@ -17,7 +17,7 @@ public FontExplorationTests(ITestOutputHelper output) [Fact] public void Explore_Fonts() { - using var doc = DocxTestHelper.OpenDocxAsRead("Fonts/fonts.docx"); + using var doc = DocxTestHelper.OpenDocxAsRead("Fonts/Praca_Inzynierska_Tester_1_error.docx"); var body = doc.MainDocumentPart.Document.Body; foreach (var paragraph in body.Elements()) @@ -143,4 +143,4 @@ public IEnumerable ValidateTimesNewRoman( -} \ No newline at end of file +} diff --git a/backend.Tests/Rules/FontFamilyRuleConfigurationTests.cs b/backend.Tests/Rules/FontFamilyRuleConfigurationTests.cs index f5adec8..5c08faa 100644 --- a/backend.Tests/Rules/FontFamilyRuleConfigurationTests.cs +++ b/backend.Tests/Rules/FontFamilyRuleConfigurationTests.cs @@ -1,18 +1,10 @@ -using System.Collections; -using System.Reflection; -using backend.Endpoints; using backend.Models; +using backend.ModernServices; using backend.Rules; -using backend.RuleOptions; -using backend.Services.Analysis; -using backend.Services.Comments; -using backend.Services.Rules; -using backend.Tests.Helpers; -using Backend.Models; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; -using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using ThesisValidator.Rules; @@ -23,53 +15,46 @@ public class FontFamilyRuleConfigurationTests [Fact] public void GetAvailableRules_WhenFontFamilyRuleIsAvailable_IncludesRule() { - var result = InvokeGetAvailableRules(new FontFamilyRuleOptions - { - Availability = RuleAvailability.Available - }); + var service = CreateService(); + + var rules = service.GetAvailableRules(); - Assert.Contains(FontFamilyValidationRule.RuleId, GetRuleNames(result)); + Assert.Contains(rules, rule => rule.Id == FontFamilyRule.RuleId); } [Fact] public void GetAvailableRules_WhenFontFamilyRuleIsHidden_ExcludesRule() { - var result = InvokeGetAvailableRules(new FontFamilyRuleOptions + var service = CreateService(new Dictionary { - Availability = RuleAvailability.Hidden + ["Validation:Rules:FontFamily:Availability"] = "Hidden" }); - Assert.DoesNotContain(FontFamilyValidationRule.RuleId, GetRuleNames(result)); + var rules = service.GetAvailableRules(); + + Assert.DoesNotContain(rules, rule => rule.Id == FontFamilyRule.RuleId); } [Fact] public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() { - var rule = new RecordingRule(FontFamilyValidationRule.RuleId); - var service = CreateService( - [rule], - new FontFamilyRuleOptions - { - Availability = RuleAvailability.Hidden - }); + var service = CreateService(new Dictionary + { + ["Validation:Rules:FontFamily:Availability"] = "Hidden" + }); + using var stream = CreateDocxStream(("Wrong font paragraph", "Arial")); - using var stream = CreateDocxStream(); - var (results, _) = service.Validate( - stream, - CreateConfig(), - [FontFamilyValidationRule.RuleId]); + var results = service.Validate(stream, [FontFamilyRule.RuleId]); Assert.Empty(results); - Assert.Equal(0, rule.RunCount); } [Fact] public void Validate_WhenSeverityIsWarning_AppliesWarning() { - var result = ValidateFontFamilyRule(new FontFamilyRuleOptions + var result = ValidateFontFamilyRule(new Dictionary { - Availability = RuleAvailability.Available, - Severity = RuleSeverity.Warning + ["Validation:Rules:FontFamily:Severity"] = "Warning" }); Assert.Equal(ValidationSeverity.Warning, result.Severity); @@ -79,10 +64,9 @@ public void Validate_WhenSeverityIsWarning_AppliesWarning() [Fact] public void Validate_WhenSeverityIsError_AppliesError() { - var result = ValidateFontFamilyRule(new FontFamilyRuleOptions + var result = ValidateFontFamilyRule(new Dictionary { - Availability = RuleAvailability.Available, - Severity = RuleSeverity.Error + ["Validation:Rules:FontFamily:Severity"] = "Error" }); Assert.Equal(ValidationSeverity.Error, result.Severity); @@ -92,17 +76,13 @@ public void Validate_WhenSeverityIsError_AppliesError() [Fact] public void Validate_WhenRequiredFontFamilyIsConfigured_UsesConfiguredFont() { - var rule = new FontFamilyValidationRule( - ruleConfigurationService: CreateRuleConfigurationService(new FontFamilyRuleOptions()), - options: Options.Create(new FontFamilyRuleOptions - { - Availability = RuleAvailability.Available, - Severity = RuleSeverity.Error, - RequiredFontFamily = "Arial" - })); - using var docx = DocxTestHelper.CreateInMemoryDocx(("Configured font paragraph", "Arial")); + var service = CreateService(new Dictionary + { + ["Validation:Rules:FontFamily:RequiredFontFamily"] = "Arial" + }); + using var stream = CreateDocxStream(("Configured font paragraph", "Arial")); - var results = rule.Validate(docx.Document, CreateConfig()).ToList(); + var results = service.Validate(stream, [FontFamilyRule.RuleId]); Assert.Empty(results); } @@ -110,86 +90,72 @@ public void Validate_WhenRequiredFontFamilyIsConfigured_UsesConfiguredFont() [Fact] public void Validate_WithSelectedRules_RunsFontFamilyWithoutRunningUnselectedRule() { - var selectedRule = new RecordingRule(FontFamilyValidationRule.RuleId); - var unselectedRule = new RecordingRule("Grammar"); + var selectedRule = new RecordingModernRule(FontFamilyRule.RuleId); + var unselectedRule = new RecordingModernRule("Grammar"); var service = CreateService( - [selectedRule, unselectedRule], - new FontFamilyRuleOptions - { - Availability = RuleAvailability.Available, - Severity = RuleSeverity.Error - }); + configurationValues: null, + rules: [selectedRule, unselectedRule]); + using var stream = CreateDocxStream(("Body text", "Times New Roman")); - using var stream = CreateDocxStream(); - service.Validate(stream, CreateConfig(), [FontFamilyValidationRule.RuleId]); + service.Validate(stream, [FontFamilyRule.RuleId]); Assert.Equal(1, selectedRule.RunCount); Assert.Equal(0, unselectedRule.RunCount); } - private static ValidationResult ValidateFontFamilyRule(FontFamilyRuleOptions options) + private static ValidationResult ValidateFontFamilyRule( + Dictionary? configurationValues = null) { - var rule = new FontFamilyValidationRule( - ruleConfigurationService: CreateRuleConfigurationService(options), - options: Options.Create(options)); - using var docx = DocxTestHelper.CreateInMemoryDocx(("Wrong font paragraph", "Arial")); + var service = CreateService(configurationValues); + using var stream = CreateDocxStream(("Wrong font paragraph", "Arial")); - return Assert.Single(rule.Validate(docx.Document, CreateConfig())); + return Assert.Single(service.Validate(stream, [FontFamilyRule.RuleId])); } - private static IResult InvokeGetAvailableRules(FontFamilyRuleOptions options) + private static ModernThesisValidatorService CreateService( + Dictionary? configurationValues = null, + IEnumerable? rules = null) { - var method = typeof(DocumentEndpoint).GetMethod( - "GetAvailableRules", - BindingFlags.NonPublic | BindingFlags.Static); - - Assert.NotNull(method); - - var result = method.Invoke( - null, - new object?[] - { - CreateService( - [new RecordingRule(FontFamilyValidationRule.RuleId), new RecordingRule("Grammar")], - options), - CreateRuleConfigurationService(options) - }); - - return Assert.IsAssignableFrom(result); - } - - private static ThesisValidatorService CreateService( - IEnumerable rules, - FontFamilyRuleOptions options) - { - return new ThesisValidatorService(rules, CreateRuleConfigurationService(options)); - } - - private static IRuleConfigurationService CreateRuleConfigurationService(FontFamilyRuleOptions options) - { - return new RuleConfigurationService( - Options.Create(new EmptySectionStructureRuleOptions()), - Options.Create(options)); - } + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configurationValues ?? new Dictionary()) + .Build(); + var policyResolver = new RulePolicyResolver(configuration); + var optionsBinder = new RuleOptionsBinder(configuration); + var resultComposer = new ValidationResultComposer(); - private static UniversityConfig CreateConfig() - { - return new UniversityConfig - { - Formatting = new FormattingConfig - { - Font = new FontConfig { FontFamily = "Times New Roman" } - } - }; + return new ModernThesisValidatorService( + new ModernDocumentSession(), + new DocumentContentAnalyzer(new ModernDocumentSkipService( + Options.Create(new ModernValidationOptions()))), + new ModernRuleRunner( + rules ?? [new FontFamilyRule()], + policyResolver, + optionsBinder, + resultComposer), + new ModernSectionContextService(), + new ModernAnnotationApplier()); } - private static MemoryStream CreateDocxStream() + private static MemoryStream CreateDocxStream(params (string Text, string? FontFamily)[] paragraphs) { var stream = new MemoryStream(); using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) { var mainPart = doc.AddMainDocumentPart(); - mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Body text"))))); + mainPart.Document = new Document(new Body()); + + foreach (var (text, fontFamily) in paragraphs) + { + var run = new Run(new Text(text)); + if (!string.IsNullOrWhiteSpace(fontFamily)) + { + run.RunProperties = new RunProperties( + new RunFonts { Ascii = fontFamily, HighAnsi = fontFamily }); + } + + mainPart.Document.Body!.Append(new Paragraph(run)); + } + mainPart.Document.Save(); } @@ -197,53 +163,35 @@ private static MemoryStream CreateDocxStream() return stream; } - private static IReadOnlyList GetRuleNames(IResult result) + private sealed class RecordingModernRule : ValidationRule { - return GetRules(result) - .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) - .Where(name => name is not null) - .Cast() - .ToList(); - } + private readonly string _name; - private static IEnumerable GetRules(IResult result) - { - Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); - - var value = result.GetType().GetProperty("Value")?.GetValue(result); - Assert.NotNull(value); - - var rules = value.GetType().GetProperty("Rules")?.GetValue(value); - Assert.NotNull(rules); - - return ((IEnumerable)rules).Cast(); - } - - private sealed class RecordingRule : IValidationRule - { - public RecordingRule(string name) + public RecordingModernRule(string name) { - Name = name; + _name = name; } - public string Name { get; } - public int RunCount { get; private set; } - public IEnumerable Validate( - WordprocessingDocument doc, - UniversityConfig config, - DocumentCommentService? documentCommentService = null) + public override RuleDescriptor Descriptor => new( + Name: _name, + DisplayName: _name, + Description: _name, + Category: RuleCategories.Formatting, + DefaultAvailability: backend.RuleOptions.RuleAvailability.Available, + DefaultSeverity: backend.RuleOptions.RuleSeverity.Error); + + public override IEnumerable Validate( + RuleContext context, + NoRuleOptions options) { RunCount++; - return - [ - new ValidationResult - { - RuleName = Name, - Message = "Executed" - } - ]; + + yield return new RuleProblem( + "Executed", + new DocumentLocation(), + ParagraphIndexKind.BodyElement); } } } diff --git a/backend.Tests/Rules/FontFamilyRuleTests.cs b/backend.Tests/Rules/FontFamilyRuleTests.cs index d956da0..fd70737 100644 --- a/backend.Tests/Rules/FontFamilyRuleTests.cs +++ b/backend.Tests/Rules/FontFamilyRuleTests.cs @@ -1,217 +1,149 @@ using backend.Models; +using backend.ModernServices; +using backend.RuleOptions; using backend.Rules; using backend.Tests.Helpers; -using Backend.Models; +using Microsoft.Extensions.Options; +using ThesisValidator.Rules; namespace backend.Tests.Rules; public class FontFamilyRuleTests { - private readonly FontFamilyValidationRule _rule = new(); - - private static UniversityConfig CreateConfig(string fontFamily = "Times New Roman") - { - return new UniversityConfig - { - Formatting = new FormattingConfig - { - Font = new FontConfig { FontFamily = fontFamily } - } - }; - } + private readonly FontFamilyRule _rule = new(); [Fact] - public void Validate_AllParagraphsWithCorrectFont_ReturnsNoErrors() + public void Validate_AllParagraphsWithCorrectFont_ReturnsNoProblems() { - // Arrange using var docx = DocxTestHelper.CreateInMemoryDocx( ("First paragraph", "Times New Roman"), - ("Second paragraph", "Times New Roman") - ); - var config = CreateConfig("Times New Roman"); + ("Second paragraph", "Times New Roman")); - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); + var problems = Validate(docx, new FontFamilyRuleOptions()).ToList(); - // Assert - Assert.Empty(errors); + Assert.Empty(problems); } [Fact] - public void Validate_ParagraphWithWrongFont_ReturnsError() + public void Validate_ParagraphWithWrongFont_ReturnsProblem() { - // Arrange using var docx = DocxTestHelper.CreateInMemoryDocx( ("Correct font paragraph", "Times New Roman"), - ("Wrong font paragraph", "Arial") - ); - var config = CreateConfig("Times New Roman"); - - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); - - // Assert - Assert.Single(errors); - Assert.Contains("Arial", errors[0].Message); - Assert.Contains("Times New Roman", errors[0].Message); - Assert.True(errors[0].IsError); + ("Wrong font paragraph", "Arial")); + + var problems = Validate(docx, new FontFamilyRuleOptions()).ToList(); + + var problem = Assert.Single(problems); + Assert.Contains("Arial", problem.Message); + Assert.Contains("Times New Roman", problem.Message); } [Fact] - public void Validate_MultipleParagraphsWithWrongFonts_ReturnsMultipleErrors() + public void Validate_MultipleParagraphsWithWrongFonts_ReturnsMultipleProblems() { - // Arrange using var docx = DocxTestHelper.CreateInMemoryDocx( ("Arial paragraph", "Arial"), ("Calibri paragraph", "Calibri"), - ("Correct paragraph", "Times New Roman") - ); - var config = CreateConfig("Times New Roman"); + ("Correct paragraph", "Times New Roman")); - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); + var problems = Validate(docx, new FontFamilyRuleOptions()).ToList(); - // Assert - Assert.Equal(2, errors.Count); - Assert.Contains(errors, e => e.Message.Contains("Arial")); - Assert.Contains(errors, e => e.Message.Contains("Calibri")); + Assert.Equal(2, problems.Count); + Assert.Contains(problems, problem => problem.Message.Contains("Arial")); + Assert.Contains(problems, problem => problem.Message.Contains("Calibri")); } [Fact] public void Validate_WithDefaultFontStyle_UsesDefaultFont() { - // Arrange using var docx = DocxTestHelper.CreateInMemoryDocxWithDefaultFont( "Times New Roman", - "Paragraph without explicit font" - ); - var config = CreateConfig("Times New Roman"); - - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); - - // Assert - Assert.Empty(errors); - } - - [Fact] - public void Validate_ConfigWithDifferentExpectedFont_ValidatesAgainstConfigFont() - { - // Arrange - using var docx = DocxTestHelper.CreateInMemoryDocx( - ("Arial paragraph", "Arial") - ); - var config = CreateConfig("Arial"); // Expecting Arial + "Paragraph without explicit font"); - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); + var problems = Validate(docx, new FontFamilyRuleOptions()).ToList(); - // Assert - Assert.Empty(errors); // Arial is now the expected font + Assert.Empty(problems); } [Fact] - public void Validate_CodeBlockParagraph_IsSkipped() + public void Validate_WithConfiguredRequiredFont_UsesConfiguredFont() { - // Arrange using var docx = DocxTestHelper.CreateInMemoryDocx( - ("public void ValidateDocument() { return; }", "Consolas") - ); - var config = CreateConfig("Times New Roman"); + ("Arial paragraph", "Arial")); - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); + var problems = Validate(docx, new FontFamilyRuleOptions + { + RequiredFontFamily = "Arial" + }).ToList(); - // Assert - Assert.Empty(errors); + Assert.Empty(problems); } [Fact] public void Validate_EmptyParagraph_IsSkipped() { - // Arrange using var docx = DocxTestHelper.CreateInMemoryDocx( ("", "Arial"), (" ", "Calibri"), - ("Valid text", "Times New Roman") - ); - var config = CreateConfig("Times New Roman"); + ("Valid text", "Times New Roman")); - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); + var problems = Validate(docx, new FontFamilyRuleOptions()).ToList(); - // Assert - Assert.Empty(errors); // Empty/whitespace paragraphs should be skipped + Assert.Empty(problems); } [Fact] - public void Validate_ErrorContainsCorrectLocation() + public void Validate_ProblemContainsCorrectLocation() { - // Arrange using var docx = DocxTestHelper.CreateInMemoryDocx( ("First paragraph", "Times New Roman"), - ("Second paragraph with wrong font", "Arial") - ); - var config = CreateConfig("Times New Roman"); - - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); - - // Assert - Assert.Single(errors); - Assert.Equal(2, errors[0].Location.Paragraph); - Assert.Equal(1, errors[0].Location.Run); - Assert.Equal(0, errors[0].Location.CharacterOffset); - Assert.Equal("Second paragraph with wrong font".Length, errors[0].Location.Length); - Assert.Equal("Second paragraph with wrong font", errors[0].Location.Text); - Assert.True(errors[0].Location.PageNumber >= 1); - Assert.True(errors[0].Location.LineNumber >= 1); + ("Second paragraph with wrong font", "Arial")); + + var problems = Validate(docx, new FontFamilyRuleOptions()).ToList(); + + var problem = Assert.Single(problems); + Assert.Equal(2, problem.Location.Paragraph); + Assert.Equal(1, problem.Location.Run); + Assert.Equal(0, problem.Location.CharacterOffset); + Assert.Equal("Second paragraph with wrong font".Length, problem.Location.Length); + Assert.Equal("Second paragraph with wrong font", problem.Location.Text); + Assert.Equal(ParagraphIndexKind.BodyElement, problem.ParagraphIndexKind); + Assert.IsType(problem.AnnotationTarget); } [Fact] public void Validate_LocationTracksCharacterOffset() { - // Arrange - Create doc with multiple runs in same paragraph using var docx = DocxTestHelper.CreateInMemoryDocxWithMultipleRuns( ("First run ", "Times New Roman"), - ("Second run with wrong font", "Arial") - ); - var config = CreateConfig("Times New Roman"); - - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); - - // Assert - Assert.Single(errors); - Assert.Equal(1, errors[0].Location.Paragraph); - Assert.Equal(2, errors[0].Location.Run); - Assert.Equal("First run ".Length, errors[0].Location.CharacterOffset); + ("Second run with wrong font", "Arial")); + + var problems = Validate(docx, new FontFamilyRuleOptions()).ToList(); + + var problem = Assert.Single(problems); + Assert.Equal(1, problem.Location.Paragraph); + Assert.Equal(2, problem.Location.Run); + Assert.Equal("First run ".Length, problem.Location.CharacterOffset); } [Fact] - public void Validate_LocationIncludesPageAndLine() + public void Validate_RuleDescriptorUsesFontFamilyRuleId() { - // Arrange - using var docx = DocxTestHelper.CreateInMemoryDocx( - ("Wrong font paragraph", "Arial") - ); - var config = CreateConfig("Times New Roman"); - - // Act - var errors = _rule.Validate(docx.Document, config).ToList(); - - // Assert - Assert.Single(errors); - Assert.Equal(1, errors[0].Location.PageNumber); - Assert.Equal(1, errors[0].Location.LineNumber); - Assert.Contains("Page 1", errors[0].Location.Description); - Assert.Contains("Line 1", errors[0].Location.Description); + Assert.Equal("FontFamily", _rule.Descriptor.Name); } - [Fact] - public void Validate_RuleNameIsCorrect() + private IEnumerable Validate( + InMemoryDocx docx, + FontFamilyRuleOptions options) { - // Assert - Assert.Equal("FontFamily", _rule.Name); + var analyzer = new DocumentContentAnalyzer(new ModernDocumentSkipService( + Options.Create(new ModernValidationOptions()))); + var context = new RuleContext + { + RawDocument = docx.Document, + Content = analyzer.Analyze(docx.Document) + }; + + return _rule.Validate(context, options); } } diff --git a/backend.Tests/backend.Tests.csproj b/backend.Tests/backend.Tests.csproj index 9c6402d..97dc763 100644 --- a/backend.Tests/backend.Tests.csproj +++ b/backend.Tests/backend.Tests.csproj @@ -29,8 +29,8 @@ - - + + PreserveNewest diff --git a/backend/Rules/FontFamilyRule.cs b/backend/Rules/FontFamilyRule.cs deleted file mode 100644 index 524fd48..0000000 --- a/backend/Rules/FontFamilyRule.cs +++ /dev/null @@ -1,117 +0,0 @@ -using backend.Models; -using backend.RuleOptions; -using Backend.Models; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using Microsoft.Extensions.Options; -using ThesisValidator.Rules; -using backend.Services.Analysis; -using backend.Services.CodeBlocks; -using backend.Services.Comments; -using backend.Services.Extraction; -using backend.Services.Formatting; -using backend.Services.Results; -using backend.Services.Rules; - -namespace backend.Rules; - -public class FontFamilyValidationRule : IValidationRule -{ - public const string RuleId = nameof(FontConfig.FontFamily); - - private readonly ICodeBlockDetector _codeBlockDetector; - private readonly IRuleConfigurationService _ruleConfigurationService; - private readonly FontFamilyRuleOptions _options; - - public string Name => RuleId; - - public FontFamilyValidationRule( - ICodeBlockDetector? codeBlockDetector = null, - IRuleConfigurationService? ruleConfigurationService = null, - IOptions? options = null) - { - var fontFamilyOptions = options ?? Options.Create(new FontFamilyRuleOptions()); - - _codeBlockDetector = codeBlockDetector ?? CodeBlockDetector.CreateDefault(); - _ruleConfigurationService = ruleConfigurationService - ?? new RuleConfigurationService( - Options.Create(new EmptySectionStructureRuleOptions()), - fontFamilyOptions); - _options = fontFamilyOptions.Value; - } - - public IEnumerable Validate(WordprocessingDocument doc, UniversityConfig config) - { - return Validate(doc, config, null); - } - - public IEnumerable Validate( - WordprocessingDocument doc, - UniversityConfig config, - DocumentCommentService? commentService) - { - if (!_ruleConfigurationService.IsRuleAvailable(Name)) - return []; - - var expectedFont = string.IsNullOrWhiteSpace(_options.RequiredFontFamily) - ? config.Formatting.Font.FontFamily - : _options.RequiredFontFamily.Trim(); - var errors = new List(); - - foreach (var (paragraph, paragraphIndex) in DocumentAnalysisScope.BodyParagraphs(doc, config)) - { - if (CodeBlockRuleSkipper.ShouldSkip(doc, paragraph, _codeBlockDetector)) - continue; - - ValidateParagraph(doc, paragraph, paragraphIndex, expectedFont, config, errors, commentService); - } - - return errors; - } - - private void ValidateParagraph( - WordprocessingDocument doc, - Paragraph paragraph, - int paragraphIndex, - string expectedFont, - UniversityConfig config, - List errors, - DocumentCommentService? commentService) - { - int runIndex = 0; - int characterOffset = 0; - - foreach (var run in paragraph.Elements()) - { - runIndex++; - var text = TextExtractionService.GetRunText(run, config); - - if (!string.IsNullOrWhiteSpace(text)) - { - var actualFont = FormattingResolutionService.ResolveFontFamily(doc, paragraph, run); - - if (!string.Equals(actualFont, expectedFont, StringComparison.OrdinalIgnoreCase)) - { - var message = $"Invalid font '{actualFont ?? "unknown"}' found, expected '{expectedFont}'"; - - commentService?.AddCommentToRun(doc, run, message); - - var result = ValidationResultFactory.ForRun( - Name, - config, - message, - paragraphIndex, - runIndex, - characterOffset, - text.Length, - TextExtractionService.Truncate(text, 50), - ParagraphIndexKind.BodyElement); - result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); - errors.Add(result); - } - } - - characterOffset += text.Length; - } - } -} diff --git a/backend/Rules/FontFamilyRule/FontFamilyRule.cs b/backend/Rules/FontFamilyRule/FontFamilyRule.cs new file mode 100644 index 0000000..3516587 --- /dev/null +++ b/backend/Rules/FontFamilyRule/FontFamilyRule.cs @@ -0,0 +1,89 @@ +using backend.Models; +using backend.ModernServices; +using backend.RuleOptions; +using backend.Services.Extraction; +using DocumentFormat.OpenXml.Wordprocessing; +using ThesisValidator.Rules; + +namespace backend.Rules; + +public sealed class FontFamilyRule : ValidationRule +{ + public const string RuleId = "FontFamily"; + + private const string DefaultRequiredFontFamily = "Times New Roman"; + private readonly ModernFormattingResolver _formattingResolver; + + public FontFamilyRule(ModernFormattingResolver? formattingResolver = null) + { + _formattingResolver = formattingResolver ?? new ModernFormattingResolver(); + } + + public override RuleDescriptor Descriptor => new( + Name: RuleId, + DisplayName: "Font Family", + Description: "Finds text runs using a font family different from the required thesis font.", + Category: RuleCategories.Formatting, + DefaultAvailability: RuleAvailability.Available, + DefaultSeverity: RuleSeverity.Error); + + public override IEnumerable Validate( + RuleContext context, + FontFamilyRuleOptions options) + { + var expectedFont = string.IsNullOrWhiteSpace(options.RequiredFontFamily) + ? DefaultRequiredFontFamily + : options.RequiredFontFamily.Trim(); + + foreach (var paragraphNode in context.Content.BodyChildParagraphs) + { + var runIndex = 0; + var characterOffset = 0; + + foreach (var run in paragraphNode.Paragraph.Elements()) + { + runIndex++; + + var text = GetRunText(run); + if (string.IsNullOrWhiteSpace(text)) + { + characterOffset += text.Length; + continue; + } + + var actualFont = _formattingResolver.ResolveFontFamily( + context.RawDocument, + paragraphNode.Paragraph, + run); + + if (string.Equals(actualFont, expectedFont, StringComparison.OrdinalIgnoreCase)) + { + characterOffset += text.Length; + continue; + } + + var message = $"Invalid font '{actualFont ?? "unknown"}' found, expected '{expectedFont}'"; + + yield return new RuleProblem( + message, + new DocumentLocation + { + Paragraph = paragraphNode.BodyIndex, + Run = runIndex, + CharacterOffset = characterOffset, + Length = text.Length, + Text = TextExtractionService.Truncate(text, 50) + }, + ParagraphIndexKind.BodyElement, + new RunAnnotationTarget(run)); + + characterOffset += text.Length; + } + } + } + + private static string GetRunText(Run run) + { + return string.Concat(run.Elements().Select(text => text.Text)); + } +} From 2ece5a876c6aaa01d6b7dc8bbd4b9383ba402f71 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:35 +0200 Subject: [PATCH 35/77] refactor(rules): migrate EmptySectionStructureRule to modern framework --- ...ySectionStructureRuleConfigurationTests.cs | 200 ++++-------------- backend/Rules/EmptySectionStructureRule.cs | 140 ------------ .../EmptySectionStructureRule.cs | 69 ++++++ 3 files changed, 116 insertions(+), 293 deletions(-) delete mode 100644 backend/Rules/EmptySectionStructureRule.cs create mode 100644 backend/Rules/EmptySectionStructureRule/EmptySectionStructureRule.cs diff --git a/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs b/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs index 0851076..26c4f12 100644 --- a/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs +++ b/backend.Tests/Rules/EmptySectionStructureRuleConfigurationTests.cs @@ -1,17 +1,11 @@ -using System.Collections; -using System.Reflection; -using backend.Endpoints; using backend.Models; -using backend.Rules; +using backend.ModernServices; using backend.RuleOptions; -using backend.Services.Analysis; -using backend.Services.Comments; -using backend.Services.Rules; -using Backend.Models; +using backend.Rules; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; -using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using ThesisValidator.Rules; @@ -22,65 +16,46 @@ public class EmptySectionStructureRuleConfigurationTests [Fact] public void GetAvailableRules_WhenEmptySectionRuleIsAvailable_IncludesRule() { - var result = InvokeGetAvailableRules(new EmptySectionStructureRuleOptions - { - Availability = RuleAvailability.Available - }); - - Assert.Contains(EmptySectionStructureRule.RuleId, GetRuleNames(result)); - } + var service = CreateService(); - [Fact] - public void GetAvailableRules_WhenEmptySectionSeverityIsConfigured_ReportsConfiguredSeverity() - { - var result = InvokeGetAvailableRules(new EmptySectionStructureRuleOptions - { - Availability = RuleAvailability.Available, - Severity = RuleSeverity.Error - }); + var rules = service.GetAvailableRules(); - Assert.Equal(ValidationSeverity.Error, GetRuleDefaultSeverity(result, EmptySectionStructureRule.RuleId)); + Assert.Contains(rules, rule => rule.Id == EmptySectionStructureRule.RuleId); } [Fact] public void GetAvailableRules_WhenEmptySectionRuleIsHidden_ExcludesRule() { - var result = InvokeGetAvailableRules(new EmptySectionStructureRuleOptions + var service = CreateService(new Dictionary { - Availability = RuleAvailability.Hidden + ["Validation:Rules:EmptySectionStructureRule:Availability"] = "Hidden" }); - Assert.DoesNotContain(EmptySectionStructureRule.RuleId, GetRuleNames(result)); + var rules = service.GetAvailableRules(); + + Assert.DoesNotContain(rules, rule => rule.Id == EmptySectionStructureRule.RuleId); } [Fact] public void Validate_WhenHiddenRuleIsManuallySelected_DoesNotExecuteRule() { - var rule = new RecordingRule(EmptySectionStructureRule.RuleId); - var service = CreateService( - [rule], - new EmptySectionStructureRuleOptions - { - Availability = RuleAvailability.Hidden - }); - + var service = CreateService(new Dictionary + { + ["Validation:Rules:EmptySectionStructureRule:Availability"] = "Hidden" + }); using var stream = CreateEmptySectionDocxStream(); - var (results, _) = service.Validate( - stream, - new UniversityConfig(), - [EmptySectionStructureRule.RuleId]); + + var results = service.Validate(stream, [EmptySectionStructureRule.RuleId]); Assert.Empty(results); - Assert.Equal(0, rule.RunCount); } [Fact] public void Validate_WhenSeverityIsWarning_AppliesWarning() { - var result = ValidateEmptySectionRule(new EmptySectionStructureRuleOptions + var result = ValidateEmptySectionRule(new Dictionary { - Availability = RuleAvailability.Available, - Severity = RuleSeverity.Warning + ["Validation:Rules:EmptySectionStructureRule:Severity"] = "Warning" }); Assert.Equal(ValidationSeverity.Warning, result.Severity); @@ -90,10 +65,9 @@ public void Validate_WhenSeverityIsWarning_AppliesWarning() [Fact] public void Validate_WhenSeverityIsError_AppliesError() { - var result = ValidateEmptySectionRule(new EmptySectionStructureRuleOptions + var result = ValidateEmptySectionRule(new Dictionary { - Availability = RuleAvailability.Available, - Severity = RuleSeverity.Error + ["Validation:Rules:EmptySectionStructureRule:Severity"] = "Error" }); Assert.Equal(ValidationSeverity.Error, result.Severity); @@ -101,95 +75,43 @@ public void Validate_WhenSeverityIsError_AppliesError() } [Fact] - public void Validate_WhenConfiguredSeverityMatchesPreviousDefault_KeepsExistingErrorBehavior() + public void Validate_StillPopulatesSectionContext() { - Assert.Equal( - ValidationSeverity.Error, - RuleCatalog.GetDefinition(EmptySectionStructureRule.RuleId).DefaultSeverity); - - var result = ValidateEmptySectionRule(new EmptySectionStructureRuleOptions - { - Availability = RuleAvailability.Available, - Severity = RuleSeverity.Error - }); + var result = ValidateEmptySectionRule(); - Assert.Equal(ValidationSeverity.Error, result.Severity); + Assert.Equal("Chapter 1", result.Location.Section); } - private static ValidationResult ValidateEmptySectionRule(EmptySectionStructureRuleOptions options) + private static ValidationResult ValidateEmptySectionRule( + Dictionary? configurationValues = null) { - var rule = new EmptySectionStructureRule(CreateRuleConfigurationService(options)); + var service = CreateService(configurationValues); using var stream = CreateEmptySectionDocxStream(); - using var doc = WordprocessingDocument.Open(stream, false); - - return Assert.Single(rule.Validate(doc, new UniversityConfig())); - } - - private static IResult InvokeGetAvailableRules(EmptySectionStructureRuleOptions options) - { - var method = typeof(DocumentEndpoint).GetMethod( - "GetAvailableRules", - BindingFlags.NonPublic | BindingFlags.Static); - - Assert.NotNull(method); - - var result = method.Invoke( - null, - new object?[] - { - CreateService( - [new RecordingRule(EmptySectionStructureRule.RuleId), new RecordingRule("FontFamily")], - options), - CreateRuleConfigurationService(options) - }); - - return Assert.IsAssignableFrom(result); - } - - private static ThesisValidatorService CreateService( - IEnumerable rules, - EmptySectionStructureRuleOptions options) - { - return new ThesisValidatorService(rules, CreateRuleConfigurationService(options)); - } - private static IRuleConfigurationService CreateRuleConfigurationService( - EmptySectionStructureRuleOptions options) - { - return new RuleConfigurationService(Options.Create(options)); + return Assert.Single(service.Validate(stream, [EmptySectionStructureRule.RuleId])); } - private static IReadOnlyList GetRuleNames(IResult result) + private static ModernThesisValidatorService CreateService( + Dictionary? configurationValues = null) { - return GetRules(result) - .Select(rule => rule.GetType().GetProperty("Name")?.GetValue(rule) as string) - .Where(name => name is not null) - .Cast() - .ToList(); - } + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configurationValues ?? new Dictionary()) + .Build(); + var policyResolver = new RulePolicyResolver(configuration); + var optionsBinder = new RuleOptionsBinder(configuration); + var resultComposer = new ValidationResultComposer(); - private static string? GetRuleDefaultSeverity(IResult result, string ruleName) - { - return GetRules(result) - .Where(rule => string.Equals( - rule.GetType().GetProperty("Name")?.GetValue(rule) as string, - ruleName, - StringComparison.OrdinalIgnoreCase)) - .Select(rule => rule.GetType().GetProperty("DefaultSeverity")?.GetValue(rule) as string) - .SingleOrDefault(); - } - - private static IEnumerable GetRules(IResult result) - { - Assert.Equal(StatusCodes.Status200OK, result.GetType().GetProperty("StatusCode")?.GetValue(result)); - - var value = result.GetType().GetProperty("Value")?.GetValue(result); - Assert.NotNull(value); - - var rules = value.GetType().GetProperty("Rules")?.GetValue(value); - Assert.NotNull(rules); - - return ((IEnumerable)rules).Cast(); + return new ModernThesisValidatorService( + new ModernDocumentSession(), + new DocumentContentAnalyzer(new ModernDocumentSkipService( + Options.Create(new ModernValidationOptions()))), + new ModernRuleRunner( + [new EmptySectionStructureRule()], + policyResolver, + optionsBinder, + resultComposer), + new ModernSectionContextService(), + new ModernAnnotationApplier()); } private static MemoryStream CreateEmptySectionDocxStream() @@ -215,32 +137,4 @@ private static Paragraph CreateHeading(string text, string styleId) new ParagraphProperties(new ParagraphStyleId { Val = styleId }), new Run(new Text(text))); } - - private sealed class RecordingRule : IValidationRule - { - public RecordingRule(string name) - { - Name = name; - } - - public string Name { get; } - - public int RunCount { get; private set; } - - public IEnumerable Validate( - WordprocessingDocument doc, - UniversityConfig config, - DocumentCommentService? documentCommentService = null) - { - RunCount++; - return - [ - new ValidationResult - { - RuleName = Name, - Message = "Executed" - } - ]; - } - } } diff --git a/backend/Rules/EmptySectionStructureRule.cs b/backend/Rules/EmptySectionStructureRule.cs deleted file mode 100644 index 85c7396..0000000 --- a/backend/Rules/EmptySectionStructureRule.cs +++ /dev/null @@ -1,140 +0,0 @@ -using backend.Models; -using Backend.Models; -using backend.RuleOptions; -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using Microsoft.Extensions.Options; -using ThesisValidator.Rules; -using backend.Services.Analysis; -using backend.Services.Comments; -using backend.Services.Extraction; -using backend.Services.Results; -using backend.Services.Rules; -using backend.Services.Skipping; -using backend.Services.Structure; - -namespace backend.Rules; - -/// -/// A subchapter heading (e.g. Heading 2) cannot immediately follow its parent -/// chapter heading (e.g. Heading 1) without any intervening body text. -/// Every section must contain at least a brief introductory paragraph -/// before the first sub-section begins. -/// -public class EmptySectionStructureRule : IValidationRule -{ - public const string RuleId = nameof(EmptySectionStructureRule); - - private readonly IRuleConfigurationService _ruleConfigurationService; - - public EmptySectionStructureRule(IRuleConfigurationService? ruleConfigurationService = null) - { - _ruleConfigurationService = ruleConfigurationService - ?? new RuleConfigurationService(Options.Create(new EmptySectionStructureRuleOptions())); - } - - public string Name => RuleId; - - public IEnumerable Validate( - WordprocessingDocument doc, - UniversityConfig config, - DocumentCommentService? commentService = null) - { - if (!_ruleConfigurationService.IsRuleAvailable(Name)) - return []; - - var errors = new List(); - var body = doc.MainDocumentPart?.Document.Body; - if (body is null) return errors; - - int? lastHeadingLevel = null; - int lastHeadingParaIdx = 0; - string lastHeadingPreview = ""; - Paragraph? lastHeadingParagraph = null; - bool hasBodyContentSinceHeading = false; - - int paragraphIndex = 0; - int childIndex = 0; - var firstIncludedChildIndex = DocumentAnalysisScope.GetFirstIncludedBodyChildIndex(doc, config); - var tocParagraphs = TableOfContentsSkipRule.GetSkippedParagraphs(doc, config); - - foreach (var element in body.ChildElements) - { - if (element is Paragraph) - paragraphIndex++; - - if (childIndex++ < firstIncludedChildIndex) - continue; - - // Tables, SdtBlocks, etc. count as body content. - if (element is not Paragraph paragraph) - { - if (lastHeadingLevel is not null) - hasBodyContentSinceHeading = true; - continue; - } - - var skipDecision = SkipDecisionService.ShouldSkipParagraph( - doc, - paragraph, - config, - new SkipContext(paragraphIndex, null, tocParagraphs)); - if (skipDecision.ShouldSkip) - continue; - - var level = HeadingDetectionService.GetHeadingLevel(doc, paragraph); - - if (level is not null) - { - // Current element is a heading. - if (lastHeadingLevel is not null - && level > lastHeadingLevel - && !hasBodyContentSinceHeading) - { - var currentText = TextExtractionService.Truncate( - TextExtractionService.GetParagraphText(doc, paragraph, config).Trim(), - 50); - - var msg = - $"Heading {lastHeadingLevel} \"{lastHeadingPreview}\" " + - $"is immediately followed by Heading {level} \"{currentText}\" " + - "with no introductory text. Add at least one paragraph of body text " + - "before the first sub-section."; - - var result = ValidationResultFactory.ForParagraph( - Name, - config, - msg, - lastHeadingParaIdx, - lastHeadingPreview, - ParagraphIndexKind.BodyElement); - result.Severity = _ruleConfigurationService.ResolveSeverity(Name, config); - errors.Add(result); - - if (lastHeadingParagraph is not null) - commentService?.AddCommentToParagraph(doc, lastHeadingParagraph, msg); - } - - lastHeadingLevel = level; - lastHeadingParaIdx = paragraphIndex; - lastHeadingPreview = TextExtractionService.Truncate( - TextExtractionService.GetParagraphText(doc, paragraph, config).Trim(), - 60); - lastHeadingParagraph = paragraph; - hasBodyContentSinceHeading = false; - } - else - { - // Non-heading paragraph — any visible text counts as body content. - if (!hasBodyContentSinceHeading - && !string.IsNullOrWhiteSpace(TextExtractionService.GetParagraphText(doc, paragraph, config))) - { - hasBodyContentSinceHeading = true; - } - } - } - - return errors; - } -} diff --git a/backend/Rules/EmptySectionStructureRule/EmptySectionStructureRule.cs b/backend/Rules/EmptySectionStructureRule/EmptySectionStructureRule.cs new file mode 100644 index 0000000..2848888 --- /dev/null +++ b/backend/Rules/EmptySectionStructureRule/EmptySectionStructureRule.cs @@ -0,0 +1,69 @@ +using backend.Models; +using backend.RuleOptions; +using backend.Services.Extraction; +using ThesisValidator.Rules; + +namespace backend.Rules; + +/// +/// A subchapter heading (e.g. Heading 2) cannot immediately follow its parent +/// chapter heading (e.g. Heading 1) without any intervening body text. +/// Every section must contain at least a brief introductory paragraph +/// before the first sub-section begins. +/// +public sealed class EmptySectionStructureRule : ValidationRule +{ + public const string RuleId = "EmptySectionStructureRule"; + + public override RuleDescriptor Descriptor => new( + Name: RuleId, + DisplayName: "Empty Sections", + Description: "Finds headings/sections with no content.", + Category: RuleCategories.Language, + DefaultAvailability: RuleAvailability.Available, + DefaultSeverity: RuleSeverity.Error); + + public override IEnumerable Validate( + RuleContext context, + NoRuleOptions options) + { + foreach (var section in FlattenSections(context.Content.Sections)) + { + var firstChild = section.Children.FirstOrDefault(); + if (firstChild is null || section.HasIntroductoryContent) + continue; + + var parentTitle = TextExtractionService.Truncate(section.Title, 60); + var childTitle = TextExtractionService.Truncate(firstChild.Title, 50); + + var message = + $"Heading {section.Level} \"{parentTitle}\" " + + $"is immediately followed by Heading {firstChild.Level} \"{childTitle}\" " + + "with no introductory text. Add at least one paragraph of body text " + + "before the first sub-section."; + + yield return new RuleProblem( + message, + new DocumentLocation + { + Paragraph = section.Heading.BodyIndex, + Text = parentTitle + }, + ParagraphIndexKind.BodyElement, + new ParagraphAnnotationTarget(section.Heading.Paragraph)); + } + } + + private static IEnumerable FlattenSections(IEnumerable sections) + { + foreach (var section in sections) + { + yield return section; + + foreach (var child in FlattenSections(section.Children)) + { + yield return child; + } + } + } +} From f96332f9750ce1f7e93fe61e7f24f655e06d81ba Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:35 +0200 Subject: [PATCH 36/77] refactor(core): bridge legacy validation services with modern framework --- .../Rules/RuleConfigurationTestSupport.cs | 48 +++- backend/Rules/TOCRule.cs | 4 +- .../Analysis/DocumentContentAnalyzer.cs | 112 +++++++++ .../Analysis/ThesisValidatorService.cs | 233 ++++++++++++++---- .../Rules/RuleConfigurationService.cs | 12 +- 5 files changed, 350 insertions(+), 59 deletions(-) create mode 100644 backend/Services/Analysis/DocumentContentAnalyzer.cs diff --git a/backend.Tests/Rules/RuleConfigurationTestSupport.cs b/backend.Tests/Rules/RuleConfigurationTestSupport.cs index 9b2d067..6fda101 100644 --- a/backend.Tests/Rules/RuleConfigurationTestSupport.cs +++ b/backend.Tests/Rules/RuleConfigurationTestSupport.cs @@ -2,6 +2,7 @@ using System.Reflection; using backend.Endpoints; using backend.Models; +using backend.ModernServices; using backend.Services.Analysis; using backend.Services.Comments; using backend.Services.Rules; @@ -10,6 +11,8 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using ThesisValidator.Rules; namespace backend.Tests.Rules; @@ -30,8 +33,7 @@ public static IResult InvokeGetAvailableRules( null, new object?[] { - new ThesisValidatorService(rules, ruleConfigurationService), - ruleConfigurationService + CreateModernValidator(rules) }); return Assert.IsAssignableFrom(result); @@ -72,6 +74,48 @@ private static IEnumerable GetRules(IResult result) return ((IEnumerable)rules).Cast(); } + + private static ModernThesisValidatorService CreateModernValidator(IEnumerable rules) + { + var configuration = new ConfigurationBuilder().Build(); + var policyResolver = new RulePolicyResolver(configuration); + var optionsBinder = new RuleOptionsBinder(configuration); + var resultComposer = new ValidationResultComposer(); + var modernRules = rules.Select(rule => new LegacyRuleDescriptorAdapter(rule.Name)).ToList(); + + return new ModernThesisValidatorService( + new ModernDocumentSession(), + new backend.ModernServices.DocumentContentAnalyzer(new ModernDocumentSkipService( + Options.Create(new ModernValidationOptions()))), + new ModernRuleRunner(modernRules, policyResolver, optionsBinder, resultComposer), + new ModernSectionContextService(), + new ModernAnnotationApplier()); + } + + private sealed class LegacyRuleDescriptorAdapter : ValidationRule + { + private readonly string _name; + + public LegacyRuleDescriptorAdapter(string name) + { + _name = name; + } + + public override RuleDescriptor Descriptor => new( + Name: _name, + DisplayName: _name, + Description: _name, + Category: RuleCategories.Formatting, + DefaultAvailability: backend.RuleOptions.RuleAvailability.Available, + DefaultSeverity: backend.RuleOptions.RuleSeverity.Error); + + public override IEnumerable Validate( + RuleContext context, + NoRuleOptions options) + { + return []; + } + } } internal sealed class RecordingRule : IValidationRule diff --git a/backend/Rules/TOCRule.cs b/backend/Rules/TOCRule.cs index 685ff1b..ace7761 100644 --- a/backend/Rules/TOCRule.cs +++ b/backend/Rules/TOCRule.cs @@ -27,9 +27,7 @@ public TocRule( var tocOptions = options ?? Options.Create(new TocRuleOptions()); _ruleConfigurationService = ruleConfigurationService - ?? new RuleConfigurationService( - Options.Create(new EmptySectionStructureRuleOptions()), - tocOptions: tocOptions); + ?? new RuleConfigurationService(tocOptions: tocOptions); } public IEnumerable Validate( diff --git a/backend/Services/Analysis/DocumentContentAnalyzer.cs b/backend/Services/Analysis/DocumentContentAnalyzer.cs new file mode 100644 index 0000000..30e2966 --- /dev/null +++ b/backend/Services/Analysis/DocumentContentAnalyzer.cs @@ -0,0 +1,112 @@ +using Backend.Models; +using backend.Services.Extraction; +using backend.Services.Skipping; +using backend.Services.Structure; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using ThesisValidator.Rules; + +namespace backend.Services.Analysis; + +public sealed class DocumentContentAnalyzer +{ + public DocumentContent Analyze(WordprocessingDocument doc, UniversityConfig config) + { + var body = doc.MainDocumentPart?.Document.Body; + if (body is null) + return new DocumentContent(); + + var paragraphs = new List(); + var rootSections = new List(); + var sectionStack = new List(); + + var firstIncludedChildIndex = DocumentAnalysisScope.GetFirstIncludedBodyChildIndex(doc, config); + var tocParagraphs = TableOfContentsSkipRule.GetSkippedParagraphs(doc, config); + + int paragraphIndex = 0; + int childIndex = 0; + + foreach (var element in body.ChildElements) + { + if (element is Paragraph) + paragraphIndex++; + + if (childIndex++ < firstIncludedChildIndex) + continue; + + if (element is not Paragraph paragraph) + { + MarkIntroductoryContent(sectionStack); + continue; + } + + var skipDecision = SkipDecisionService.ShouldSkipParagraph( + doc, + paragraph, + config, + new SkipContext(paragraphIndex, null, tocParagraphs)); + if (skipDecision.ShouldSkip) + continue; + + var text = TextExtractionService.GetParagraphText(doc, paragraph, config).Trim(); + var headingLevel = HeadingDetectionService.GetHeadingLevel(doc, paragraph); + var paragraphNode = new ParagraphNode + { + Paragraph = paragraph, + BodyIndex = paragraphIndex, + Text = text, + HeadingLevel = headingLevel + }; + paragraphs.Add(paragraphNode); + + if (headingLevel is not null) + { + AddSection(paragraphNode, rootSections, sectionStack); + continue; + } + + if (!string.IsNullOrWhiteSpace(text)) + MarkIntroductoryContent(sectionStack); + } + + return new DocumentContent + { + BodyChildParagraphs = paragraphs, + Sections = rootSections + }; + } + + private static void AddSection( + ParagraphNode heading, + List rootSections, + List sectionStack) + { + var level = heading.HeadingLevel!.Value; + while (sectionStack.Count > 0 && sectionStack[^1].Level >= level) + { + sectionStack.RemoveAt(sectionStack.Count - 1); + } + + var section = new SectionNode { Heading = heading }; + if (sectionStack.Count == 0) + { + rootSections.Add(section); + } + else + { + sectionStack[^1].Children.Add(section); + } + + sectionStack.Add(section); + } + + private static void MarkIntroductoryContent(List sectionStack) + { + if (sectionStack.Count == 0) + return; + + var currentSection = sectionStack[^1]; + if (currentSection.Children.Count == 0) + currentSection.HasIntroductoryContent = true; + } +} diff --git a/backend/Services/Analysis/ThesisValidatorService.cs b/backend/Services/Analysis/ThesisValidatorService.cs index 7a4141f..e45219c 100644 --- a/backend/Services/Analysis/ThesisValidatorService.cs +++ b/backend/Services/Analysis/ThesisValidatorService.cs @@ -2,63 +2,123 @@ using backend.Services.Comments; using backend.Services.Exceptions; using backend.Services.Extraction; -using backend.Services.Rules; using backend.Services.Skipping; using backend.Services.Structure; using backend.RuleOptions; using Backend.Models; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Configuration; using ThesisValidator.Rules; namespace backend.Services.Analysis; public class ThesisValidatorService { - private readonly IReadOnlyList _ruleList; - private readonly IReadOnlySet _ruleNames; - private readonly IRuleConfigurationService _ruleConfigurationService; + private readonly IReadOnlyList _modernRules; + private readonly RulePolicyResolver _policyResolver; + private readonly RuleOptionsBinder _optionsBinder; + private readonly ValidationResultComposer _resultComposer; + private readonly DocumentContentAnalyzer _contentAnalyzer; + + public ThesisValidatorService(IEnumerable legacyRules) + : this(legacyRules, ruleConfigurationService: null) + { + } public ThesisValidatorService( - IEnumerable rules, - IRuleConfigurationService? ruleConfigurationService = null) + IEnumerable legacyRules, + backend.Services.Rules.IRuleConfigurationService? ruleConfigurationService) + : this( + legacyRules.Select(rule => new LegacyModernRuleAdapter(rule)), + CreateDefaultPolicyResolver(), + new RuleOptionsBinder(CreateEmptyConfiguration()), + new ValidationResultComposer()) { - _ruleList = rules.ToList(); - _ruleNames = _ruleList.Select(rule => rule.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); - _ruleConfigurationService = ruleConfigurationService - ?? new RuleConfigurationService(Options.Create(new EmptySectionStructureRuleOptions())); } - public IReadOnlyList GetAvailableRuleNames() + public ThesisValidatorService( + IEnumerable modernRules, + RulePolicyResolver policyResolver, + RuleOptionsBinder optionsBinder, + ValidationResultComposer resultComposer, + DocumentContentAnalyzer? contentAnalyzer = null) { - return _ruleList.Select(rule => rule.Name).ToList(); + _modernRules = modernRules.ToList(); + _policyResolver = policyResolver; + _optionsBinder = optionsBinder; + _resultComposer = resultComposer; + _contentAnalyzer = contentAnalyzer ?? new DocumentContentAnalyzer(); + } public IReadOnlyList GetAvailableRules() { - return _ruleList - .Select(rule => RuleCatalog.GetDefinition(rule.Name)) - .ToList(); + var definitions = new List(); + + foreach (var rule in _modernRules) + { + var descriptor = rule.Descriptor; + var policy = _policyResolver.Resolve(descriptor); + if (policy.Availability == RuleAvailability.Hidden) + continue; + + definitions.Add(new RuleDefinition( + descriptor.Name, + descriptor.DisplayName, + descriptor.Category, + policy.Severity.ToString())); + } + + return definitions; } public IReadOnlyList GetUnknownRuleNames(IEnumerable selectedRules) { + var knownRules = _modernRules + .Select(rule => rule.Descriptor.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return selectedRules - .Where(ruleName => !_ruleNames.Contains(ruleName)) + .Where(ruleName => !knownRules.Contains(ruleName)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } - public (IEnumerable Results, List Headings) Validate(Stream fileStream, UniversityConfig config, IEnumerable? selectedRules = null) + + public (IEnumerable Results, List Headings) + Validate(Stream fileStream, + UniversityConfig config, + IEnumerable? selectedRules = null) { using var doc = OpenDocument(fileStream, isEditable: false); - var rulesToRun = FilterRules(config, selectedRules); - var errors = new List(); - foreach (var rule in rulesToRun) + var content = _contentAnalyzer.Analyze(doc, config); + + + var context = new RuleContext { - errors.AddRange(rule.Validate(doc, config)); + RawDocument = doc, + Content = content + }; + + foreach (var rule in GetAvailableRules(selectedRules)) + { + var policy = _policyResolver.Resolve(rule.Descriptor); + + if (policy.Availability == RuleAvailability.Hidden) + continue; + + var options = _optionsBinder.Bind(rule); + var problems = rule.Validate(context, options); + + foreach (var problem in problems) + { + errors.Add(_resultComposer.Compose( + rule.Descriptor, + policy, + problem)); + } } var headings = ExtractHeadings(doc, config); @@ -67,6 +127,22 @@ public IReadOnlyList GetUnknownRuleNames(IEnumerable selectedRul return (errors, headings); } + public IReadOnlyList GetAvailableRules( + IEnumerable? selectedRules) + { + if (selectedRules is null) + return _modernRules; + + var selectedSet = selectedRules.ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (selectedSet.Count == 0) + return _modernRules; + + return _modernRules + .Where(rule => selectedSet.Contains(rule.Descriptor.Name)) + .ToList(); + } + public static List ExtractHeadings(WordprocessingDocument doc, UniversityConfig? config = null) { @@ -111,28 +187,52 @@ public static List ExtractHeadings(WordprocessingDocument doc, Univ /// Returns both the validation results and a stream containing the annotated document. /// public (IEnumerable Results, MemoryStream AnnotatedDocument) - ValidateWithComments(Stream fileStream, UniversityConfig config, IEnumerable? selectedRules = null) + ValidateWithComments(Stream fileStream, UniversityConfig config, IEnumerable? selectedRules = null) { using var memoryStream = new MemoryStream(); fileStream.CopyTo(memoryStream); memoryStream.Position = 0; using var doc = OpenDocument(memoryStream, isEditable: true); + var rulesToRun = GetAvailableRules(selectedRules); + + var validationResults = new List(); var commentService = new DocumentCommentService(); - var rulesToRun = FilterRules(config, selectedRules); + var content = _contentAnalyzer.Analyze(doc, config); + var context = new RuleContext + { + RawDocument = doc, + Content = content + }; - var errors = new List(); foreach (var rule in rulesToRun) { - errors.AddRange(rule.Validate(doc, config, commentService)); + var policy = _policyResolver.Resolve(rule.Descriptor); + if (policy.Availability == RuleAvailability.Hidden) + continue; + + var options = _optionsBinder.Bind(rule); + var problems = rule.Validate(context, options); + + foreach (var problem in problems) + { + validationResults.Add(_resultComposer.Compose( + rule.Descriptor, + policy, + problem)); + AddCommentForProblem(commentService, doc, problem); + } } + var (elementsMap, descendantsMap) = BuildSectionMaps(doc, config); + PopulateSectionContext(validationResults, elementsMap, descendantsMap); + try { doc.MainDocumentPart?.Document.Save(); var annotatedStream = DocumentCommentService.SaveDocumentWithComments(doc); - return (errors, annotatedStream); + return (validationResults, annotatedStream); } catch (Exception ex) when (IsDocumentProcessingException(ex)) { @@ -163,6 +263,22 @@ private static WordprocessingDocument OpenDocument(Stream stream, bool isEditabl } } + private static void AddCommentForProblem( + DocumentCommentService commentService, + WordprocessingDocument doc, + RuleProblem problem) + { + switch (problem.AnnotationTarget) + { + case ParagraphAnnotationTarget paragraphTarget: + commentService.AddCommentToParagraph(doc, paragraphTarget.Paragraph, problem.Message); + break; + case RunAnnotationTarget runTarget: + commentService.AddCommentToRun(doc, runTarget.Run, problem.Message); + break; + } + } + private static bool IsDocumentProcessingException(Exception exception) { return exception is OpenXmlPackageException @@ -255,31 +371,56 @@ private static void PopulateSectionContext( return nearest; } - private IReadOnlyList FilterRules( - UniversityConfig config, - IEnumerable? selectedRules) + private static RulePolicyResolver CreateDefaultPolicyResolver() { - IReadOnlyList candidates; - if (selectedRules is null) + return new RulePolicyResolver(CreateEmptyConfiguration()); + } + + private static IConfiguration CreateEmptyConfiguration() + { + return new ConfigurationBuilder().Build(); + } + + private sealed class LegacyModernRuleAdapter : ValidationRule + { + private readonly IValidationRule _rule; + + public LegacyModernRuleAdapter(IValidationRule rule) { - candidates = _ruleList; + _rule = rule; } - else + + public override RuleDescriptor Descriptor { - var selectedSet = selectedRules.ToHashSet(StringComparer.OrdinalIgnoreCase); - candidates = selectedSet.Count == 0 - ? _ruleList - : _ruleList.Where(rule => selectedSet.Contains(rule.Name)).ToList(); + get + { + var definition = RuleCatalog.GetDefinition(_rule.Name); + return new RuleDescriptor( + Name: definition.Id, + DisplayName: definition.DisplayName, + Description: definition.DisplayName, + Category: definition.Category, + DefaultAvailability: RuleAvailability.Available, + DefaultSeverity: Enum.TryParse( + definition.DefaultSeverity, + ignoreCase: true, + out var severity) + ? severity + : RuleSeverity.Error); + } } - return candidates - .Where(IsExecutableRule) - .ToList(); + public override IEnumerable Validate( + RuleContext context, + NoRuleOptions options) + { + return _rule + .Validate(context.RawDocument, new UniversityConfig()) + .Select(result => new RuleProblem( + result.Message, + result.Location, + result.ParagraphIndexKind)); + } } - private bool IsExecutableRule(IValidationRule rule) - { - return _ruleConfigurationService.IsRuleAvailable(rule.Name); - } } - diff --git a/backend/Services/Rules/RuleConfigurationService.cs b/backend/Services/Rules/RuleConfigurationService.cs index 331e3b5..2c37199 100644 --- a/backend/Services/Rules/RuleConfigurationService.cs +++ b/backend/Services/Rules/RuleConfigurationService.cs @@ -29,8 +29,9 @@ public sealed class RuleConfigurationService : IRuleConfigurationService private readonly ListPunctuationConsistencyRuleOptions _listPunctuationConsistencyOptions; private readonly ListIndentationConsistencyRuleOptions _listIndentationConsistencyOptions; + public RuleConfigurationService( - IOptions emptySectionOptions, + IOptions? emptySectionOptions = null, IOptions? fontFamilyOptions = null, IOptions? noDotsInTitlesOptions = null, IOptions? headingStyleUsageOptions = null, @@ -48,7 +49,7 @@ public RuleConfigurationService( IOptions? listPunctuationConsistencyOptions = null, IOptions? listIndentationConsistencyOptions = null) { - _emptySectionOptions = emptySectionOptions.Value; + _emptySectionOptions = emptySectionOptions?.Value ?? new EmptySectionStructureRuleOptions(); _fontFamilyOptions = fontFamilyOptions?.Value ?? new FontFamilyRuleOptions(); _noDotsInTitlesOptions = noDotsInTitlesOptions?.Value ?? new NoDotsInTitlesRuleOptions(); _headingStyleUsageOptions = headingStyleUsageOptions?.Value ?? new HeadingStyleUsageRuleOptions(); @@ -128,8 +129,6 @@ public string ResolveSeverity( UniversityConfig config, string? explicitSeverity = null) { - if (IsEmptySectionStructureRule(ruleId)) - return ValidationSeverity.Normalize(_emptySectionOptions.Severity.ToString()); if (IsFontFamilyRule(ruleId)) return ValidationSeverity.Normalize(_fontFamilyOptions.Severity.ToString()); @@ -184,8 +183,6 @@ public string ResolveSeverity( public RuleDefinition ApplyConfiguration(RuleDefinition definition) { - if (IsEmptySectionStructureRule(definition.Id)) - return definition with { DefaultSeverity = ValidationSeverity.Normalize(_emptySectionOptions.Severity.ToString()) }; if (IsFontFamilyRule(definition.Id)) return definition with { DefaultSeverity = ValidationSeverity.Normalize(_fontFamilyOptions.Severity.ToString()) }; @@ -237,7 +234,6 @@ public RuleDefinition ApplyConfiguration(RuleDefinition definition) return definition; } - private static bool IsEmptySectionStructureRule(string ruleId) { return string.Equals( @@ -250,7 +246,7 @@ private static bool IsFontFamilyRule(string ruleId) { return string.Equals( ruleId, - FontFamilyValidationRule.RuleId, + FontFamilyRule.RuleId, StringComparison.OrdinalIgnoreCase); } From 64c3d1595a6b865272acd5c7689444a14c2d8a2b Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:36 +0200 Subject: [PATCH 37/77] refactor(api): upgrade endpoints to modern execution pipeline --- .../Endpoints/DocumentEndpointTests.cs | 54 ++++++--- backend/Endpoints/DocumentEndpoint.cs | 110 +++++------------- backend/Endpoints/DocumentEndpointResults.cs | 9 +- backend/Endpoints/DocumentUploadRequest.cs | 4 +- .../DocumentUploadRequestValidator.cs | 10 +- 5 files changed, 69 insertions(+), 118 deletions(-) diff --git a/backend.Tests/Endpoints/DocumentEndpointTests.cs b/backend.Tests/Endpoints/DocumentEndpointTests.cs index 3b5729b..7534ce1 100644 --- a/backend.Tests/Endpoints/DocumentEndpointTests.cs +++ b/backend.Tests/Endpoints/DocumentEndpointTests.cs @@ -1,15 +1,13 @@ using System.Reflection; +using backend.ModernServices; using backend.Models; using backend.Endpoints; -using Backend.Models; -using DocumentFormat.OpenXml.Packaging; +using Microsoft.Extensions.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ThesisValidator.Rules; -using backend.Services.Analysis; -using backend.Services.Comments; namespace backend.Tests.Endpoints; @@ -88,7 +86,7 @@ public void ValidateEndpoints_WithUnreadableDocx_ReturnGenericBadRequest(string problem.Detail); } - private static IResult InvokeEndpoint(string methodName, string? rules, params IValidationRule[] availableRules) + private static IResult InvokeEndpoint(string methodName, string? rules, params IModernValidationRule[] availableRules) { var method = typeof(DocumentEndpoint).GetMethod( methodName, @@ -102,10 +100,7 @@ private static IResult InvokeEndpoint(string methodName, string? rules, params I { CreateDocxFile(), rules, - null, - null, - new ThesisValidatorService(availableRules), - Options.Create(new UniversityConfig()), + CreateValidator(availableRules), NullLoggerFactory.Instance }); @@ -128,21 +123,44 @@ private static ProblemDetails AssertBadRequest(IResult result) return Assert.IsType(value); } - private sealed class TestValidationRule : IValidationRule + private static ModernThesisValidatorService CreateValidator(params IModernValidationRule[] rules) { + var configuration = new ConfigurationBuilder().Build(); + var policyResolver = new RulePolicyResolver(configuration); + var optionsBinder = new RuleOptionsBinder(configuration); + var resultComposer = new ValidationResultComposer(); + + return new ModernThesisValidatorService( + new ModernDocumentSession(), + new DocumentContentAnalyzer(new ModernDocumentSkipService( + Options.Create(new ModernValidationOptions()))), + new ModernRuleRunner(rules, policyResolver, optionsBinder, resultComposer), + new ModernSectionContextService(), + new ModernAnnotationApplier()); + } + + private sealed class TestValidationRule : ValidationRule + { + private readonly string _name; + public TestValidationRule(string name) { - Name = name; + _name = name; } - public string Name { get; } - - public IEnumerable Validate( - WordprocessingDocument doc, - UniversityConfig config, - DocumentCommentService? documentCommentService = null) + public override RuleDescriptor Descriptor => new( + Name: _name, + DisplayName: _name, + Description: _name, + Category: RuleCategories.Formatting, + DefaultAvailability: backend.RuleOptions.RuleAvailability.Available, + DefaultSeverity: backend.RuleOptions.RuleSeverity.Error); + + public override IEnumerable Validate( + RuleContext context, + NoRuleOptions options) { - return Array.Empty(); + return []; } } } diff --git a/backend/Endpoints/DocumentEndpoint.cs b/backend/Endpoints/DocumentEndpoint.cs index eee90ce..47e4e6c 100644 --- a/backend/Endpoints/DocumentEndpoint.cs +++ b/backend/Endpoints/DocumentEndpoint.cs @@ -1,10 +1,7 @@ using backend.Models; -using Backend.Models; +using backend.ModernServices; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using backend.Services.Analysis; using backend.Services.Exceptions; -using backend.Services.Rules; namespace backend.Endpoints; @@ -47,19 +44,14 @@ public static void MapDocumentEndpoint(this WebApplication app) private static IResult ValidateDocument( IFormFile? file, [FromForm] string? rules, - [FromForm] bool? skipBeforeTableOfContents, - [FromForm] bool? skipTextBoxes, - ThesisValidatorService thesisValidatorService, - IOptions universityConfigOptions, + ModernThesisValidatorService modernValidator, ILoggerFactory loggerFactory) { if (!DocumentUploadRequestValidator .TryValidate( - file, - rules, - skipBeforeTableOfContents, - skipTextBoxes, - thesisValidatorService, out var request, out var error)) + file, + rules, + modernValidator, out var request, out var error)) { return error!; } @@ -67,12 +59,13 @@ private static IResult ValidateDocument( try { using var stream = request!.File.OpenReadStream(); - var config = CreateRequestConfig(universityConfigOptions.Value, request); - var (validationResults, headings) = thesisValidatorService - .Validate(stream, config, request.SelectedRules); + var validationResults = modernValidator.Validate(stream, request.SelectedRules); var results = validationResults.ToList(); - var response = DocumentEndpointResults.CreateValidationResponse(request, config, results, headings); + var response = DocumentEndpointResults + .CreateValidationResponse( + request, results); + return Results.Ok(response); } catch (InvalidThesisDocumentException ex) @@ -88,13 +81,10 @@ private static IResult ValidateDocument( private static IResult ValidateWithComments( IFormFile? file, [FromForm] string? rules, - [FromForm] bool? skipBeforeTableOfContents, - [FromForm] bool? skipTextBoxes, - ThesisValidatorService thesisValidatorService, - IOptions universityConfigOptions, + ModernThesisValidatorService modernValidator, ILoggerFactory loggerFactory) { - if (!DocumentUploadRequestValidator.TryValidate(file, rules, skipBeforeTableOfContents, skipTextBoxes, thesisValidatorService, out var request, out var error)) + if (!DocumentUploadRequestValidator.TryValidate(file, rules, modernValidator, out var request, out var error)) { return error!; } @@ -102,8 +92,7 @@ private static IResult ValidateWithComments( try { using var stream = request!.File.OpenReadStream(); - var config = CreateRequestConfig(universityConfigOptions.Value, request); - var (_, annotatedDocument) = thesisValidatorService.ValidateWithComments(stream, config, request.SelectedRules); + var (_, annotatedDocument) = modernValidator.ValidateWithComments(stream, request.SelectedRules); var outputFileName = DocumentEndpointResults.GetAnnotatedFileName(request.FileName); @@ -124,24 +113,22 @@ private static IResult ValidateWithComments( } private static IResult GetAvailableRules( - ThesisValidatorService thesisValidatorService, - IRuleConfigurationService ruleConfigurationService) + ModernThesisValidatorService modernValidator) { - var ruleList = thesisValidatorService.GetAvailableRules() - .Where(rule => ruleConfigurationService.IsRuleAvailable(rule.Id)) - .Select(ruleConfigurationService.ApplyConfiguration) - .Select(rule => new - { - Name = rule.Id, - rule.DisplayName, - rule.Category, - rule.DefaultSeverity, - rule.Enabled, - rule.Selectable - }) - .ToList(); + var ruleList = modernValidator.GetAvailableRules() + .Select(rule => new + { + Name = rule.Id, + rule.DisplayName, + rule.Category, + rule.DefaultSeverity, + rule.Enabled, + rule.Selectable + }) + .ToList(); return Results.Ok(new { Rules = ruleList, Count = ruleList.Count }); + } private static IResult HealthCheck() @@ -149,49 +136,4 @@ private static IResult HealthCheck() return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow }); } - private static UniversityConfig CreateRequestConfig( - UniversityConfig baseConfig, - DocumentUploadRequest request) - { - return new UniversityConfig - { - Name = baseConfig.Name, - Language = baseConfig.Language, - Analysis = new AnalysisConfig - { - SkipBeforeTableOfContents = request.SkipBeforeTableOfContents, - SkipTextBoxes = request.SkipTextBoxes ?? baseConfig.Analysis.SkipTextBoxes, - SkipTableOfContentsContent = baseConfig.Analysis.SkipTableOfContentsContent - }, - Rules = new RuleSettingsConfig - { - Overrides = baseConfig.Rules.Overrides.ToDictionary( - pair => pair.Key, - pair => new RuleOverrideConfig - { - Severity = pair.Value.Severity - }, - StringComparer.OrdinalIgnoreCase) - }, - Formatting = new FormattingConfig - { - CheckTableOfContents = baseConfig.Formatting.CheckTableOfContents, - SkipBeforeTableOfContents = request.SkipBeforeTableOfContents, - SkipTextBoxes = request.SkipTextBoxes ?? baseConfig.Formatting.SkipTextBoxes, - SkipTableOfContentsContent = baseConfig.Formatting.SkipTableOfContentsContent, - Font = new FontConfig - { - FontFamily = baseConfig.Formatting.Font.FontFamily, - FontSize = baseConfig.Formatting.Font.FontSize - }, - Layout = new LayoutConfig - { - MarginLeft = baseConfig.Formatting.Layout.MarginLeft, - MarginRight = baseConfig.Formatting.Layout.MarginRight, - RequiredIndentCm = baseConfig.Formatting.Layout.RequiredIndentCm, - ParagraphSpacingRule = baseConfig.Formatting.Layout.ParagraphSpacingRule.ToList() - } - } - }; - } } diff --git a/backend/Endpoints/DocumentEndpointResults.cs b/backend/Endpoints/DocumentEndpointResults.cs index fa10a71..45b035a 100644 --- a/backend/Endpoints/DocumentEndpointResults.cs +++ b/backend/Endpoints/DocumentEndpointResults.cs @@ -1,5 +1,4 @@ using backend.Models; -using Backend.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -13,9 +12,7 @@ internal static class DocumentEndpointResults public static DocumentValidationResponse CreateValidationResponse( DocumentUploadRequest request, - UniversityConfig config, - IReadOnlyList results, - List headings) + IReadOnlyList results) { return new DocumentValidationResponse { @@ -25,9 +22,7 @@ public static DocumentValidationResponse CreateValidationResponse( IsValid = !results.Any(r => r.IsError), TotalErrors = results.Count(r => r.IsError), TotalWarnings = results.Count(r => !r.IsError), - Results = results.ToList(), - ConfigUsed = config.Name, - Headings = headings + Results = results.ToList() }; } diff --git a/backend/Endpoints/DocumentUploadRequest.cs b/backend/Endpoints/DocumentUploadRequest.cs index 2d8d56f..07d33a4 100644 --- a/backend/Endpoints/DocumentUploadRequest.cs +++ b/backend/Endpoints/DocumentUploadRequest.cs @@ -5,6 +5,4 @@ namespace backend.Endpoints; internal sealed record DocumentUploadRequest( IFormFile File, string FileName, - IReadOnlyList SelectedRules, - bool SkipBeforeTableOfContents, - bool? SkipTextBoxes); + IReadOnlyList SelectedRules); diff --git a/backend/Endpoints/DocumentUploadRequestValidator.cs b/backend/Endpoints/DocumentUploadRequestValidator.cs index 56c58e5..caace2c 100644 --- a/backend/Endpoints/DocumentUploadRequestValidator.cs +++ b/backend/Endpoints/DocumentUploadRequestValidator.cs @@ -1,6 +1,6 @@ using System.Text.Json; +using backend.ModernServices; using Microsoft.AspNetCore.Http; -using backend.Services.Analysis; namespace backend.Endpoints; @@ -9,9 +9,7 @@ internal static class DocumentUploadRequestValidator public static bool TryValidate( IFormFile? file, string? rules, - bool? skipBeforeTableOfContents, - bool? skipTextBoxes, - ThesisValidatorService thesisValidatorService, + ModernThesisValidatorService modernValidator, out DocumentUploadRequest? request, out IResult? error) { @@ -29,14 +27,14 @@ public static bool TryValidate( return false; } - var unknownRules = thesisValidatorService.GetUnknownRuleNames(selectedRules); + var unknownRules = modernValidator.GetUnknownRuleNames(selectedRules); if (unknownRules.Count > 0) { error = DocumentEndpointResults.UnknownRules(unknownRules); return false; } - request = new DocumentUploadRequest(file!, fileName, selectedRules, skipBeforeTableOfContents == true, skipTextBoxes); + request = new DocumentUploadRequest(file!, fileName, selectedRules); return true; } From 05ea152277e937c5c256f47cfee8b1f303da87d2 Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:36 +0200 Subject: [PATCH 38/77] refactor(bootstrap): register modern validation components in DI --- backend/Program.cs | 129 +++++++++------------------------------------ 1 file changed, 24 insertions(+), 105 deletions(-) diff --git a/backend/Program.cs b/backend/Program.cs index c99c536..f512ca4 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,11 +1,10 @@ using System.Reflection; using backend.Endpoints; using backend.Models; +using backend.ModernServices; using backend.RuleOptions; -using backend.Services.Analysis; using backend.Services.CodeBlocks; using backend.Services.Language; -using backend.Services.Rules; using Backend.Models; using ThesisValidator.Rules; @@ -27,121 +26,41 @@ builder.Services.Configure( builder.Configuration.GetSection("UniversityConfig")); -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(CodeBlockDetectionOptions.SectionName)) - .Validate( - options => options.MinimumCodeFontTextRatio > 0 && options.MinimumCodeFontTextRatio <= 1, - "CodeBlockDetection:MinimumCodeFontTextRatio must be greater than 0 and less than or equal to 1.") - .Validate( - options => options.CodeFonts is not null - && options.CodeFonts.Any(font => !string.IsNullOrWhiteSpace(font)), - "CodeBlockDetection:CodeFonts must contain at least one font.") - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(NoDotsInTitlesRuleOptions.SectionName)) - .Validate(options => options.TargetStylePatterns is not null - && options.TargetStylePatterns.Any(pattern => !string.IsNullOrWhiteSpace(pattern)), - "NoDotsInTitlesRule:TargetStylePatterns must contain at least one non-empty style pattern.") - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(EmptySectionStructureRuleOptions.SectionName)) - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(FontFamilyRuleOptions.SectionName)) - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(HeadingStyleUsageRuleOptions.SectionName)) - .Validate(options => options.FontSizeThresholdAboveBodyPt >= 0, - "HeadingStyleUsageRule:FontSizeThresholdAboveBodyPt must be greater than or equal to 0.") - .Validate(options => options.MaxHeadingTextLength > 0, - "HeadingStyleUsageRule:MaxHeadingTextLength must be greater than 0.") - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(HierarchyDepthRuleOptions.SectionName)) - .Validate(options => options.MaxAllowedLevel > 0, - "HierarchyDepthRule:MaxAllowedLevel must be greater than 0.") - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(LineSpacingDependencyRuleOptions.SectionName)) - .Validate(options => options.TargetLineSpacingTwips > 0, - "LineSpacingDependencyRule:TargetLineSpacingTwips must be greater than 0.") - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(ParagraphIndentRuleOptions.SectionName)) - .Validate(options => options.AllowedIndentTwips is not null - && options.AllowedIndentTwips.Length > 0 - && options.AllowedIndentTwips.All(indent => indent >= 0), - "RequiredIndentCm:AllowedIndentTwips must contain only non-negative values and at least one value.") - .Validate(options => options.ToleranceTwips >= 0, - "RequiredIndentCm:ToleranceTwips must be greater than or equal to 0.") - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(SingleSpaceRuleOptions.SectionName)) - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(TextJustificationRuleOptions.SectionName)) - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(TocRuleOptions.SectionName)) - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(ManualTableOfContentsRuleOptions.SectionName)) - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(MissingFigureCaptionRuleOptions.SectionName)) - .ValidateOnStart(); - -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(FigureCaptionPositionRuleOptions.SectionName)) - .ValidateOnStart(); -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(FigureCaptionFormatRuleOptions.SectionName)) - .ValidateOnStart(); -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(GrammarRuleOptions.SectionName)) - .ValidateOnStart(); +builder.Services.AddSingleton(); -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(ListPunctuationConsistencyRuleOptions.SectionName)) - .ValidateOnStart(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); -builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection(ListIndentationConsistencyRuleOptions.SectionName)) +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ModernValidationOptions.SectionName)) .ValidateOnStart(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddHttpClient(); -builder.Services.AddScoped(); +var modernRuleTypes = typeof(Program).Assembly.GetTypes() + .Where(t => typeof(IModernValidationRule).IsAssignableFrom(t) + && !t.IsInterface + && !t.IsAbstract + && !t.IsNested); -var assembly = typeof(Program).Assembly; -var ruleTypes = assembly. - GetTypes(). - Where(t => typeof(IValidationRule).IsAssignableFrom(t) - && !t.IsInterface - && !t.IsAbstract); -foreach (var ruleType in ruleTypes) +foreach (var ruleType in modernRuleTypes) { - builder.Services.AddScoped(typeof(IValidationRule), ruleType); + builder.Services.AddScoped(typeof(IModernValidationRule), ruleType); } -builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); if (app.Environment.IsDevelopment()) From da4066b321944df93fa7dfe25ec8c04cf373dd0b Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:34:00 +0200 Subject: [PATCH 39/77] refactor(formatting): implement ModernFormattingResolver for font family resolution --- .../ModernFormattingResolver.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 backend/ModernServices/ModernFormattingResolver.cs diff --git a/backend/ModernServices/ModernFormattingResolver.cs b/backend/ModernServices/ModernFormattingResolver.cs new file mode 100644 index 0000000..7e345eb --- /dev/null +++ b/backend/ModernServices/ModernFormattingResolver.cs @@ -0,0 +1,42 @@ +using backend.Services.Formatting; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace backend.ModernServices; + +public sealed class ModernFormattingResolver +{ + public string? ResolveFontFamily( + WordprocessingDocument document, + Paragraph paragraph, + Run run) + { + var runFont = run.RunProperties?.RunFonts?.Ascii?.Value; + if (!string.IsNullOrEmpty(runFont)) + return runFont; + + foreach (var style in StyleResolutionService.GetStyleChain( + document, + StyleResolutionService.GetParagraphStyleId(paragraph))) + { + var styleFont = style.StyleRunProperties?.RunFonts?.Ascii?.Value; + if (!string.IsNullOrEmpty(styleFont)) + return styleFont; + } + + var defaultStyleFont = StyleResolutionService + .GetDefaultParagraphStyle(document)? + .StyleRunProperties? + .RunFonts? + .Ascii? + .Value; + if (!string.IsNullOrEmpty(defaultStyleFont)) + return defaultStyleFont; + + return StyleResolutionService + .GetDocumentDefaultRunProperties(document)? + .RunFonts? + .Ascii? + .Value; + } +} From 5ccef26ca9e6634647597ece8e659d9496c3335c Mon Sep 17 00:00:00 2001 From: void0xf <150953141+void0xf@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:49:50 +0200 Subject: [PATCH 40/77] refactor(engine): remove legacy analysis, structure, and skipping services --- .../Analysis/DocumentAnalysisScope.cs | 126 ------ .../Analysis/DocumentContentAnalyzer.cs | 112 ----- .../Analysis/ThesisValidatorService.cs | 426 ------------------ .../CodeBlocks/CodeBlockDetectionOptions.cs | 27 -- .../CodeBlocks/CodeBlockDetectionResult.cs | 16 - .../Services/CodeBlocks/CodeBlockDetector.cs | 217 --------- .../CodeBlocks/CodeBlockRuleSkipper.cs | 13 - .../Services/CodeBlocks/ICodeBlockDetector.cs | 11 - .../Comments/DocumentCommentService.cs | 168 ------- .../InvalidThesisDocumentException.cs | 9 - .../Extraction/TextExtractionService.cs | 114 ----- .../Formatting/FormattingResolutionService.cs | 352 --------------- .../Formatting/StyleResolutionService.cs | 71 --- backend/Services/Formatting/UnitConversion.cs | 40 -- .../Services/Language/LanguageToolService.cs | 175 ------- backend/Services/Results/SeverityResolver.cs | 23 - .../Results/ValidationResultFactory.cs | 83 ---- .../Rules/IRuleConfigurationService.cs | 16 - .../Rules/RuleConfigurationService.cs | 372 --------------- backend/Services/Skipping/ISkipRule.cs | 36 -- backend/Services/Skipping/SkipDecision.cs | 30 -- .../Services/Skipping/SkipDecisionService.cs | 148 ------ .../Skipping/StructuralStyleSkipRule.cs | 89 ---- .../Skipping/TableOfContentsSkipRule.cs | 171 ------- backend/Services/Skipping/TextBoxSkipRule.cs | 150 ------ .../Structure/CaptionDetectionService.cs | 144 ------ .../Structure/FigureCaptionDetector.cs | 179 -------- .../Structure/FigureDetectionService.cs | 133 ------ .../Structure/HeadingDetectionService.cs | 98 ---- .../TableOfContentsDetectionService.cs | 146 ------ 30 files changed, 3695 deletions(-) delete mode 100644 backend/Services/Analysis/DocumentAnalysisScope.cs delete mode 100644 backend/Services/Analysis/DocumentContentAnalyzer.cs delete mode 100644 backend/Services/Analysis/ThesisValidatorService.cs delete mode 100644 backend/Services/CodeBlocks/CodeBlockDetectionOptions.cs delete mode 100644 backend/Services/CodeBlocks/CodeBlockDetectionResult.cs delete mode 100644 backend/Services/CodeBlocks/CodeBlockDetector.cs delete mode 100644 backend/Services/CodeBlocks/CodeBlockRuleSkipper.cs delete mode 100644 backend/Services/CodeBlocks/ICodeBlockDetector.cs delete mode 100644 backend/Services/Comments/DocumentCommentService.cs delete mode 100644 backend/Services/Exceptions/InvalidThesisDocumentException.cs delete mode 100644 backend/Services/Extraction/TextExtractionService.cs delete mode 100644 backend/Services/Formatting/FormattingResolutionService.cs delete mode 100644 backend/Services/Formatting/StyleResolutionService.cs delete mode 100644 backend/Services/Formatting/UnitConversion.cs delete mode 100644 backend/Services/Language/LanguageToolService.cs delete mode 100644 backend/Services/Results/SeverityResolver.cs delete mode 100644 backend/Services/Results/ValidationResultFactory.cs delete mode 100644 backend/Services/Rules/IRuleConfigurationService.cs delete mode 100644 backend/Services/Rules/RuleConfigurationService.cs delete mode 100644 backend/Services/Skipping/ISkipRule.cs delete mode 100644 backend/Services/Skipping/SkipDecision.cs delete mode 100644 backend/Services/Skipping/SkipDecisionService.cs delete mode 100644 backend/Services/Skipping/StructuralStyleSkipRule.cs delete mode 100644 backend/Services/Skipping/TableOfContentsSkipRule.cs delete mode 100644 backend/Services/Skipping/TextBoxSkipRule.cs delete mode 100644 backend/Services/Structure/CaptionDetectionService.cs delete mode 100644 backend/Services/Structure/FigureCaptionDetector.cs delete mode 100644 backend/Services/Structure/FigureDetectionService.cs delete mode 100644 backend/Services/Structure/HeadingDetectionService.cs delete mode 100644 backend/Services/Structure/TableOfContentsDetectionService.cs diff --git a/backend/Services/Analysis/DocumentAnalysisScope.cs b/backend/Services/Analysis/DocumentAnalysisScope.cs deleted file mode 100644 index 92f3071..0000000 --- a/backend/Services/Analysis/DocumentAnalysisScope.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Backend.Models; -using backend.Services.Extraction; -using DocumentFormat.OpenXml; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using backend.Services.Skipping; - -namespace backend.Services.Analysis; - -public enum DocumentBlockType -{ - BodyParagraph, - TextBox, - TableOfContents -} - -public static class DocumentAnalysisScope -{ - public static IEnumerable<(Paragraph Paragraph, int Index)> BodyParagraphs( - WordprocessingDocument doc, - UniversityConfig config) - { - var body = doc.MainDocumentPart?.Document.Body; - if (body is null) - yield break; - - var firstIncludedIndex = TableOfContentsSkipRule.GetFirstIncludedBodyParagraphIndex(doc, config); - var tocParagraphs = TableOfContentsSkipRule.GetSkippedParagraphs(doc, config); - int paragraphIndex = 0; - - foreach (var paragraph in body.Elements()) - { - paragraphIndex++; - var skipDecision = SkipDecisionService.ShouldSkipParagraph( - doc, - paragraph, - config, - new SkipContext(paragraphIndex, firstIncludedIndex, tocParagraphs)); - if (skipDecision.ShouldSkip) - continue; - - yield return (paragraph, paragraphIndex); - } - } - - public static IEnumerable<(Paragraph Paragraph, int Index)> DescendantParagraphs( - WordprocessingDocument doc, - UniversityConfig config, - bool includeTableOfContentsContent = false) - { - var body = doc.MainDocumentPart?.Document.Body; - if (body is null) - yield break; - - var firstIncludedIndex = TableOfContentsSkipRule.GetFirstIncludedDescendantParagraphIndex(doc, config); - var tocParagraphs = includeTableOfContentsContent - ? new HashSet() - : TableOfContentsSkipRule.GetSkippedParagraphs(doc, config); - int paragraphIndex = 0; - - foreach (var paragraph in body.Descendants()) - { - paragraphIndex++; - var skipDecision = SkipDecisionService.ShouldSkipParagraph( - doc, - paragraph, - config, - new SkipContext(paragraphIndex, firstIncludedIndex, tocParagraphs)); - if (skipDecision.ShouldSkip) - continue; - - yield return (paragraph, paragraphIndex); - } - } - - public static DocumentBlockType GetBlockType(Paragraph paragraph) - { - if (TableOfContentsSkipRule.IsTableOfContentsParagraph(paragraph)) - return DocumentBlockType.TableOfContents; - - if (TextBoxSkipRule.IsInsideTextBoxOrDrawingText(paragraph) - || TextBoxSkipRule.ContainsTextBoxContent(paragraph)) - { - return DocumentBlockType.TextBox; - } - - return DocumentBlockType.BodyParagraph; - } - - public static string GetParagraphText(Paragraph paragraph, UniversityConfig config) - { - return TextExtractionService.GetParagraphText(paragraph, config); - } - - public static bool HasMeaningfulParagraphContent(Paragraph paragraph, UniversityConfig config) - { - return TextExtractionService.HasMeaningfulParagraphContent(paragraph, config); - } - - public static bool HasMeaningfulContent(string? text) - { - return TextExtractionService.HasMeaningfulContent(text); - } - - public static string GetRunText(Run run, UniversityConfig config) - { - return TextExtractionService.GetRunText(run, config); - } - - public static bool ContainsTextBoxContent(OpenXmlElement element) - { - return TextBoxSkipRule.ContainsTextBoxContent(element); - } - - public static bool IsTableOfContentsParagraph(Paragraph paragraph) - { - return TableOfContentsSkipRule.IsTableOfContentsParagraph(paragraph); - } - - public static int GetFirstIncludedBodyChildIndex( - WordprocessingDocument doc, - UniversityConfig config) - { - return TableOfContentsSkipRule.GetFirstIncludedBodyChildIndex(doc, config); - } -} diff --git a/backend/Services/Analysis/DocumentContentAnalyzer.cs b/backend/Services/Analysis/DocumentContentAnalyzer.cs deleted file mode 100644 index 30e2966..0000000 --- a/backend/Services/Analysis/DocumentContentAnalyzer.cs +++ /dev/null @@ -1,112 +0,0 @@ -using Backend.Models; -using backend.Services.Extraction; -using backend.Services.Skipping; -using backend.Services.Structure; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using ThesisValidator.Rules; - -namespace backend.Services.Analysis; - -public sealed class DocumentContentAnalyzer -{ - public DocumentContent Analyze(WordprocessingDocument doc, UniversityConfig config) - { - var body = doc.MainDocumentPart?.Document.Body; - if (body is null) - return new DocumentContent(); - - var paragraphs = new List(); - var rootSections = new List(); - var sectionStack = new List(); - - var firstIncludedChildIndex = DocumentAnalysisScope.GetFirstIncludedBodyChildIndex(doc, config); - var tocParagraphs = TableOfContentsSkipRule.GetSkippedParagraphs(doc, config); - - int paragraphIndex = 0; - int childIndex = 0; - - foreach (var element in body.ChildElements) - { - if (element is Paragraph) - paragraphIndex++; - - if (childIndex++ < firstIncludedChildIndex) - continue; - - if (element is not Paragraph paragraph) - { - MarkIntroductoryContent(sectionStack); - continue; - } - - var skipDecision = SkipDecisionService.ShouldSkipParagraph( - doc, - paragraph, - config, - new SkipContext(paragraphIndex, null, tocParagraphs)); - if (skipDecision.ShouldSkip) - continue; - - var text = TextExtractionService.GetParagraphText(doc, paragraph, config).Trim(); - var headingLevel = HeadingDetectionService.GetHeadingLevel(doc, paragraph); - var paragraphNode = new ParagraphNode - { - Paragraph = paragraph, - BodyIndex = paragraphIndex, - Text = text, - HeadingLevel = headingLevel - }; - paragraphs.Add(paragraphNode); - - if (headingLevel is not null) - { - AddSection(paragraphNode, rootSections, sectionStack); - continue; - } - - if (!string.IsNullOrWhiteSpace(text)) - MarkIntroductoryContent(sectionStack); - } - - return new DocumentContent - { - BodyChildParagraphs = paragraphs, - Sections = rootSections - }; - } - - private static void AddSection( - ParagraphNode heading, - List rootSections, - List sectionStack) - { - var level = heading.HeadingLevel!.Value; - while (sectionStack.Count > 0 && sectionStack[^1].Level >= level) - { - sectionStack.RemoveAt(sectionStack.Count - 1); - } - - var section = new SectionNode { Heading = heading }; - if (sectionStack.Count == 0) - { - rootSections.Add(section); - } - else - { - sectionStack[^1].Children.Add(section); - } - - sectionStack.Add(section); - } - - private static void MarkIntroductoryContent(List sectionStack) - { - if (sectionStack.Count == 0) - return; - - var currentSection = sectionStack[^1]; - if (currentSection.Children.Count == 0) - currentSection.HasIntroductoryContent = true; - } -} diff --git a/backend/Services/Analysis/ThesisValidatorService.cs b/backend/Services/Analysis/ThesisValidatorService.cs deleted file mode 100644 index e45219c..0000000 --- a/backend/Services/Analysis/ThesisValidatorService.cs +++ /dev/null @@ -1,426 +0,0 @@ -using backend.Models; -using backend.Services.Comments; -using backend.Services.Exceptions; -using backend.Services.Extraction; -using backend.Services.Skipping; -using backend.Services.Structure; -using backend.RuleOptions; -using Backend.Models; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using Microsoft.Extensions.Configuration; -using ThesisValidator.Rules; - -namespace backend.Services.Analysis; - -public class ThesisValidatorService -{ - private readonly IReadOnlyList _modernRules; - private readonly RulePolicyResolver _policyResolver; - private readonly RuleOptionsBinder _optionsBinder; - private readonly ValidationResultComposer _resultComposer; - private readonly DocumentContentAnalyzer _contentAnalyzer; - - public ThesisValidatorService(IEnumerable legacyRules) - : this(legacyRules, ruleConfigurationService: null) - { - } - - public ThesisValidatorService( - IEnumerable legacyRules, - backend.Services.Rules.IRuleConfigurationService? ruleConfigurationService) - : this( - legacyRules.Select(rule => new LegacyModernRuleAdapter(rule)), - CreateDefaultPolicyResolver(), - new RuleOptionsBinder(CreateEmptyConfiguration()), - new ValidationResultComposer()) - { - } - - public ThesisValidatorService( - IEnumerable modernRules, - RulePolicyResolver policyResolver, - RuleOptionsBinder optionsBinder, - ValidationResultComposer resultComposer, - DocumentContentAnalyzer? contentAnalyzer = null) - { - _modernRules = modernRules.ToList(); - _policyResolver = policyResolver; - _optionsBinder = optionsBinder; - _resultComposer = resultComposer; - _contentAnalyzer = contentAnalyzer ?? new DocumentContentAnalyzer(); - - } - - public IReadOnlyList GetAvailableRules() - { - var definitions = new List(); - - foreach (var rule in _modernRules) - { - var descriptor = rule.Descriptor; - var policy = _policyResolver.Resolve(descriptor); - if (policy.Availability == RuleAvailability.Hidden) - continue; - - definitions.Add(new RuleDefinition( - descriptor.Name, - descriptor.DisplayName, - descriptor.Category, - policy.Severity.ToString())); - } - - return definitions; - } - - public IReadOnlyList GetUnknownRuleNames(IEnumerable selectedRules) - { - var knownRules = _modernRules - .Select(rule => rule.Descriptor.Name) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - return selectedRules - .Where(ruleName => !knownRules.Contains(ruleName)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - - public (IEnumerable Results, List Headings) - Validate(Stream fileStream, - UniversityConfig config, - IEnumerable? selectedRules = null) - { - using var doc = OpenDocument(fileStream, isEditable: false); - var errors = new List(); - var content = _contentAnalyzer.Analyze(doc, config); - - - var context = new RuleContext - { - RawDocument = doc, - Content = content - }; - - foreach (var rule in GetAvailableRules(selectedRules)) - { - var policy = _policyResolver.Resolve(rule.Descriptor); - - if (policy.Availability == RuleAvailability.Hidden) - continue; - - var options = _optionsBinder.Bind(rule); - var problems = rule.Validate(context, options); - - foreach (var problem in problems) - { - errors.Add(_resultComposer.Compose( - rule.Descriptor, - policy, - problem)); - } - } - - var headings = ExtractHeadings(doc, config); - var (elementsMap, descendantsMap) = BuildSectionMaps(doc, config); - PopulateSectionContext(errors, elementsMap, descendantsMap); - - return (errors, headings); - } - public IReadOnlyList GetAvailableRules( - IEnumerable? selectedRules) - { - if (selectedRules is null) - return _modernRules; - - var selectedSet = selectedRules.ToHashSet(StringComparer.OrdinalIgnoreCase); - - if (selectedSet.Count == 0) - return _modernRules; - - return _modernRules - .Where(rule => selectedSet.Contains(rule.Descriptor.Name)) - .ToList(); - } - - - public static List ExtractHeadings(WordprocessingDocument doc, UniversityConfig? config = null) - { - var headings = new List(); - var effectiveConfig = config ?? new UniversityConfig(); - var body = doc.MainDocumentPart?.Document.Body; - if (body is null) - return headings; - - var tocParagraphIndex = SkipDecisionService.ShouldSkipBeforeTableOfContents(effectiveConfig) - ? TableOfContentsDetectionService.Detect(doc).ParagraphIndex - : 0; - - int paragraphIndex = 0; - foreach (var paragraph in body.Descendants()) - { - paragraphIndex++; - var text = TextExtractionService.GetParagraphText(paragraph, effectiveConfig).Trim(); - if (string.IsNullOrWhiteSpace(text)) - continue; - - if (TableOfContentsDetectionService.IsTableOfContentsHeadingText(text)) - { - headings.Add(new HeadingInfo { Level = 1, Text = text }); - continue; - } - - if (tocParagraphIndex > 0 && paragraphIndex <= tocParagraphIndex) - continue; - - var level = HeadingDetectionService.GetHeadingLevel(doc, paragraph); - if (level is null) continue; - - headings.Add(new HeadingInfo { Level = level.Value, Text = text }); - } - - return headings; - } - - /// - /// Validates the document and adds comments for each error found. - /// Returns both the validation results and a stream containing the annotated document. - /// - public (IEnumerable Results, MemoryStream AnnotatedDocument) - ValidateWithComments(Stream fileStream, UniversityConfig config, IEnumerable? selectedRules = null) - { - using var memoryStream = new MemoryStream(); - fileStream.CopyTo(memoryStream); - memoryStream.Position = 0; - - using var doc = OpenDocument(memoryStream, isEditable: true); - var rulesToRun = GetAvailableRules(selectedRules); - - var validationResults = new List(); - var commentService = new DocumentCommentService(); - var content = _contentAnalyzer.Analyze(doc, config); - var context = new RuleContext - { - RawDocument = doc, - Content = content - }; - - foreach (var rule in rulesToRun) - { - var policy = _policyResolver.Resolve(rule.Descriptor); - if (policy.Availability == RuleAvailability.Hidden) - continue; - - var options = _optionsBinder.Bind(rule); - var problems = rule.Validate(context, options); - - foreach (var problem in problems) - { - validationResults.Add(_resultComposer.Compose( - rule.Descriptor, - policy, - problem)); - AddCommentForProblem(commentService, doc, problem); - } - } - - var (elementsMap, descendantsMap) = BuildSectionMaps(doc, config); - PopulateSectionContext(validationResults, elementsMap, descendantsMap); - - try - { - doc.MainDocumentPart?.Document.Save(); - var annotatedStream = DocumentCommentService.SaveDocumentWithComments(doc); - - return (validationResults, annotatedStream); - } - catch (Exception ex) when (IsDocumentProcessingException(ex)) - { - throw new InvalidThesisDocumentException("The uploaded file could not be saved as an annotated DOCX document.", ex); - } - } - - private static WordprocessingDocument OpenDocument(Stream stream, bool isEditable) - { - try - { - var doc = WordprocessingDocument.Open(stream, isEditable); - if (doc.MainDocumentPart?.Document is not null) - { - return doc; - } - - doc.Dispose(); - throw new InvalidThesisDocumentException("The uploaded file is not a valid Wordprocessing document."); - } - catch (InvalidThesisDocumentException) - { - throw; - } - catch (Exception ex) when (IsDocumentProcessingException(ex)) - { - throw new InvalidThesisDocumentException("The uploaded file could not be opened as a DOCX document.", ex); - } - } - - private static void AddCommentForProblem( - DocumentCommentService commentService, - WordprocessingDocument doc, - RuleProblem problem) - { - switch (problem.AnnotationTarget) - { - case ParagraphAnnotationTarget paragraphTarget: - commentService.AddCommentToParagraph(doc, paragraphTarget.Paragraph, problem.Message); - break; - case RunAnnotationTarget runTarget: - commentService.AddCommentToRun(doc, runTarget.Run, problem.Message); - break; - } - } - - private static bool IsDocumentProcessingException(Exception exception) - { - return exception is OpenXmlPackageException - or FileFormatException - or InvalidDataException - or IOException - or NotSupportedException - or InvalidOperationException - or ArgumentException - or ObjectDisposedException; - } - - /// - /// Builds two sorted heading maps - one keyed by direct-child paragraph index - /// (Elements<Paragraph>) and one by all-descendants index - /// (Descendants<Paragraph>). Different rules use different traversals, - /// so we need both to reliably match a result's paragraph index to a heading. - /// - private static ( - List<(int Index, string Text)> ElementsMap, - List<(int Index, string Text)> DescendantsMap - ) BuildSectionMaps(WordprocessingDocument doc, UniversityConfig config) - { - var elementsMap = new List<(int, string)>(); - var descendantsMap = new List<(int, string)>(); - - // Elements-based map (direct body children only - matches FontFamily, list rules, Grammar, FigureCaption, EmptySection rules) - foreach (var (para, elemIdx) in DocumentAnalysisScope.BodyParagraphs(doc, config)) - { - var level = HeadingDetectionService.GetHeadingLevel(doc, para); - if (level is null) continue; - - var text = TextExtractionService.GetParagraphText(para, config).Trim(); - if (string.IsNullOrWhiteSpace(text)) continue; - - elementsMap.Add((elemIdx, text)); - } - - // Descendants-based map (all paragraphs incl. table cells - matches SingleSpace, Justification, NoDots, Indent, Spacing, LineSpacing, Hierarchy rules) - foreach (var (para, descIdx) in DocumentAnalysisScope.DescendantParagraphs(doc, config)) - { - var level = HeadingDetectionService.GetHeadingLevel(doc, para); - if (level is null) continue; - - var text = TextExtractionService.GetParagraphText(para, config).Trim(); - if (string.IsNullOrWhiteSpace(text)) continue; - - descendantsMap.Add((descIdx, text)); - } - - return (elementsMap, descendantsMap); - } - - /// - /// For each validation result, finds the nearest preceding heading and - /// writes it into . - /// Uses the correct section map based on which paragraph traversal the rule uses. - /// - private static void PopulateSectionContext( - List results, - List<(int Index, string Text)> elementsMap, - List<(int Index, string Text)> descendantsMap) - { - foreach (var result in results) - { - var paraIdx = result.Location?.Paragraph ?? 0; - if (paraIdx <= 0) continue; - - var map = result.ParagraphIndexKind == ParagraphIndexKind.BodyElement - ? elementsMap - : descendantsMap; - - var section = FindNearestSection(map, paraIdx); - - if (section is not null) - result.Location!.Section = section; - } - } - - private static string? FindNearestSection(List<(int Index, string Text)> map, int paraIdx) - { - string? nearest = null; - foreach (var (idx, text) in map) - { - if (idx <= paraIdx) - nearest = text; - else - break; - } - return nearest; - } - - private static RulePolicyResolver CreateDefaultPolicyResolver() - { - return new RulePolicyResolver(CreateEmptyConfiguration()); - } - - private static IConfiguration CreateEmptyConfiguration() - { - return new ConfigurationBuilder().Build(); - } - - private sealed class LegacyModernRuleAdapter : ValidationRule - { - private readonly IValidationRule _rule; - - public LegacyModernRuleAdapter(IValidationRule rule) - { - _rule = rule; - } - - public override RuleDescriptor Descriptor - { - get - { - var definition = RuleCatalog.GetDefinition(_rule.Name); - return new RuleDescriptor( - Name: definition.Id, - DisplayName: definition.DisplayName, - Description: definition.DisplayName, - Category: definition.Category, - DefaultAvailability: RuleAvailability.Available, - DefaultSeverity: Enum.TryParse( - definition.DefaultSeverity, - ignoreCase: true, - out var severity) - ? severity - : RuleSeverity.Error); - } - } - - public override IEnumerable Validate( - RuleContext context, - NoRuleOptions options) - { - return _rule - .Validate(context.RawDocument, new UniversityConfig()) - .Select(result => new RuleProblem( - result.Message, - result.Location, - result.ParagraphIndexKind)); - } - } - -} diff --git a/backend/Services/CodeBlocks/CodeBlockDetectionOptions.cs b/backend/Services/CodeBlocks/CodeBlockDetectionOptions.cs deleted file mode 100644 index eaec670..0000000 --- a/backend/Services/CodeBlocks/CodeBlockDetectionOptions.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace backend.Services.CodeBlocks; - -public sealed class CodeBlockDetectionOptions -{ - public const string SectionName = "Validation:CodeBlockDetection"; - - public double MinimumCodeFontTextRatio { get; set; } = 0.7; - - public bool RequireWholeParagraphMonospace { get; set; } - - public List CodeFonts { get; set; } = - [ - "Consolas", - "Courier New", - "Courier", - "Lucida Console", - "Cascadia Code", - "Cascadia Mono", - "JetBrains Mono", - "Fira Code", - "Source Code Pro", - "Menlo", - "Monaco", - "DejaVu Sans Mono", - "Liberation Mono" - ]; -} diff --git a/backend/Services/CodeBlocks/CodeBlockDetectionResult.cs b/backend/Services/CodeBlocks/CodeBlockDetectionResult.cs deleted file mode 100644 index 050aeae..0000000 --- a/backend/Services/CodeBlocks/CodeBlockDetectionResult.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace backend.Services.CodeBlocks; - -public sealed class CodeBlockDetectionResult -{ - public bool IsCodeBlock { get; init; } - - public double CodeFontTextRatio { get; init; } - - public IReadOnlyList DetectedFonts { get; init; } = Array.Empty(); - - public string? MatchedFont { get; init; } - - public int TotalTextLength { get; init; } - - public int CodeFontTextLength { get; init; } -} diff --git a/backend/Services/CodeBlocks/CodeBlockDetector.cs b/backend/Services/CodeBlocks/CodeBlockDetector.cs deleted file mode 100644 index 7bc71a3..0000000 --- a/backend/Services/CodeBlocks/CodeBlockDetector.cs +++ /dev/null @@ -1,217 +0,0 @@ -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Wordprocessing; -using Microsoft.Extensions.Options; - -namespace backend.Services.CodeBlocks; - -public sealed class CodeBlockDetector : ICodeBlockDetector -{ - private readonly CodeBlockDetectionOptions _options; - private readonly HashSet _codeFonts; - - public CodeBlockDetector(IOptions options) - { - _options = options.Value; - _codeFonts = (_options.CodeFonts ?? []) - .Where(font => !string.IsNullOrWhiteSpace(font)) - .Select(font => font.Trim()) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - } - - public static ICodeBlockDetector CreateDefault() - { - return new CodeBlockDetector(Options.Create(new CodeBlockDetectionOptions())); - } - - public bool IsCodeBlock(Paragraph paragraph, MainDocumentPart mainPart) - { - return Analyze(paragraph, mainPart).IsCodeBlock; - } - - public CodeBlockDetectionResult Analyze(Paragraph paragraph, MainDocumentPart mainPart) - { - var detectedFonts = new HashSet(StringComparer.OrdinalIgnoreCase); - string? matchedFont = null; - var totalTextLength = 0; - var codeFontTextLength = 0; - - foreach (var run in paragraph.Descendants()) - { - var text = GetRunText(run); - if (string.IsNullOrWhiteSpace(text)) - continue; - - totalTextLength += text.Length; - - var fonts = ResolveFontCandidates(run, paragraph, mainPart) - .Where(font => !string.IsNullOrWhiteSpace(font)) - .Select(font => font.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var font in fonts) - { - detectedFonts.Add(font); - } - - var runMatchedFont = fonts.FirstOrDefault(font => _codeFonts.Contains(font)); - if (runMatchedFont is not null) - { - matchedFont ??= runMatchedFont; - codeFontTextLength += text.Length; - } - } - - if (totalTextLength == 0) - { - return new CodeBlockDetectionResult - { - DetectedFonts = detectedFonts.Order(StringComparer.OrdinalIgnoreCase).ToList() - }; - } - - var ratio = codeFontTextLength / (double)totalTextLength; - var isCodeBlock = _options.RequireWholeParagraphMonospace - ? codeFontTextLength == totalTextLength - : ratio >= _options.MinimumCodeFontTextRatio; - - return new CodeBlockDetectionResult - { - IsCodeBlock = isCodeBlock, - CodeFontTextRatio = ratio, - DetectedFonts = detectedFonts.Order(StringComparer.OrdinalIgnoreCase).ToList(), - MatchedFont = matchedFont, - TotalTextLength = totalTextLength, - CodeFontTextLength = codeFontTextLength - }; - } - - private static string GetRunText(Run run) - { - return string.Concat(run.Elements().Select(text => text.Text)); - } - - private static IEnumerable ResolveFontCandidates( - Run run, - Paragraph paragraph, - MainDocumentPart mainPart) - { - var directFonts = GetRunFontValues(run.RunProperties?.RunFonts).ToList(); - if (directFonts.Count > 0) - return directFonts; - - var runStyleFonts = GetStyleRunFonts( - mainPart, - run.RunProperties?.RunStyle?.Val?.Value, - StyleValues.Character) - .ToList(); - if (runStyleFonts.Count > 0) - return runStyleFonts; - - var paragraphStyleFonts = GetStyleRunFonts( - mainPart, - paragraph.ParagraphProperties?.ParagraphStyleId?.Val?.Value, - StyleValues.Paragraph) - .ToList(); - if (paragraphStyleFonts.Count > 0) - return paragraphStyleFonts; - - var defaultParagraphStyleFonts = GetDefaultParagraphStyleRunFonts(mainPart).ToList(); - if (defaultParagraphStyleFonts.Count > 0) - return defaultParagraphStyleFonts; - - return GetRunFontValues( - mainPart.StyleDefinitionsPart? - .Styles? - .DocDefaults? - .RunPropertiesDefault? - .RunPropertiesBaseStyle? - .RunFonts); - } - - private static IEnumerable GetStyleRunFonts( - MainDocumentPart mainPart, - string? styleId, - StyleValues expectedStyleType) - { - foreach (var style in GetStyleChain(mainPart, styleId, expectedStyleType)) - { - var fonts = GetRunFontValues(style.StyleRunProperties?.RunFonts).ToList(); - if (fonts.Count > 0) - return fonts; - } - - return Array.Empty(); - } - - private static IEnumerable GetDefaultParagraphStyleRunFonts(MainDocumentPart mainPart) - { - var style = mainPart.StyleDefinitionsPart? - .Styles? - .Elements