diff --git a/ErrorHandling/QuerySyntax/ResultQueryExpressionExtensions.cs b/ErrorHandling/QuerySyntax/ResultQueryExpressionExtensions.cs index 736c83247..8d89d2141 100644 --- a/ErrorHandling/QuerySyntax/ResultQueryExpressionExtensions.cs +++ b/ErrorHandling/QuerySyntax/ResultQueryExpressionExtensions.cs @@ -8,7 +8,7 @@ public static Result SelectMany( this Result input, Func> continuation) { - throw new NotImplementedException(); + return input.Then(continuation); } public static Result SelectMany( @@ -16,6 +16,9 @@ public static Result SelectMany( Func> continuation, Func resultSelector) { - throw new NotImplementedException(); + var tempResult = input.SelectMany(continuation); + return !tempResult.IsSuccess + ? Result.Fail(tempResult.Error) + : Result.Of(() => resultSelector(input.Value, tempResult.Value)); } } \ No newline at end of file diff --git a/ErrorHandling/Result.cs b/ErrorHandling/Result.cs index 2e8db379b..b6bea5455 100644 --- a/ErrorHandling/Result.cs +++ b/ErrorHandling/Result.cs @@ -1,14 +1,16 @@ using System; +using System.Linq.Expressions; namespace ErrorHandling; public class None { - private None() - { - } + private None() { } + + public static readonly None Value = new None(); } + public struct Result { public Result(string error, T value = default(T)) @@ -22,8 +24,7 @@ public struct Result public T GetValueOrThrow() { - if (IsSuccess) return Value; - throw new InvalidOperationException($"No value. Only Error {Error}"); + return IsSuccess ? Value : throw new InvalidOperationException($"No value. Only Error {Error}"); } public bool IsSuccess => Error == null; @@ -62,20 +63,33 @@ public static Result Then( this Result input, Func continuation) { - throw new NotImplementedException(); + return !input.IsSuccess ? Fail(input.Error) : Of(() => continuation(input.Value), input.Error); } public static Result Then( this Result input, Func> continuation) { - throw new NotImplementedException(); + return !input.IsSuccess ? Fail(input.Error) : Of(() => continuation(input.Value), input.Error).Value; } - public static Result OnFail( - this Result input, - Action handleError) + public static Result ReplaceError(this Result input, Func replacement) + { + return input.IsSuccess ? input : Fail(replacement(input.Error));; + } + + public static Result RefineError(this Result input, string refineErrorMessage) { - throw new NotImplementedException(); + return ReplaceError(input, err => $"{refineErrorMessage}. {input.Error}"); } -} \ No newline at end of file + + public static Result OnFail(this Result input, Action handleError) + { + if (!input.IsSuccess) + { + handleError(input.Error); + } + + return input; + } +} diff --git a/TagCloud.Client/ContainerBuilderHelper.cs b/TagCloud.Client/ContainerBuilderHelper.cs new file mode 100644 index 000000000..0a2da93f9 --- /dev/null +++ b/TagCloud.Client/ContainerBuilderHelper.cs @@ -0,0 +1,20 @@ +using Autofac; +using ErrorHandling; +using TagCloud.DI; + +namespace TagCloud.Client; + +public static class ContainerBuilderHelper +{ + public static Result<(IContainer container, ProgramArgs args)> BuildContainer(ProgramArgs args) + { + return Result.Of(() => + { + var builder = new ContainerBuilder(); + builder.RegisterModule(new TagCloudModule(args.WordsFile, args.StopWordsFile)); + + var container = builder.Build(); + return (container, args); + }, "Ошибка при создании контейнера"); + } +} \ No newline at end of file diff --git a/TagCloud.Client/Program.cs b/TagCloud.Client/Program.cs new file mode 100644 index 000000000..5819a0c4f --- /dev/null +++ b/TagCloud.Client/Program.cs @@ -0,0 +1,30 @@ +using ErrorHandling; +using TagCloud.Client; + +public class Program +{ + public static int Main(string[] args) + { + return Run(args) + .OnFail(err => Console.WriteLine("Ошибка: " + err)) + .IsSuccess + ? 0 + : -1; + } + + private static Result Run(string[] args) + { + return + ProgramArgsValidator.ValidateArgs(args) + .Then(ProgramArgsValidator.ParseArgs) + .Then(ProgramArgsValidator.ValidateFiles) + .Then(ProgramArgsValidator.ValidateFont) + .Then(ContainerBuilderHelper.BuildContainer) + .Then(TagCloudGeneratorHelper.GenerateTagCloud) + .Then(_ => + { + Console.WriteLine("Генерация завершена"); + return None.Value; + }); + } +} diff --git a/TagCloud.Client/ProgramArgs.cs b/TagCloud.Client/ProgramArgs.cs new file mode 100644 index 000000000..f56169d60 --- /dev/null +++ b/TagCloud.Client/ProgramArgs.cs @@ -0,0 +1,9 @@ +namespace TagCloud.Client; + +public record ProgramArgs( + string WordsFile, + string StopWordsFile, + string OutputFile, + int Width, + int Height, + string FontName); \ No newline at end of file diff --git a/TagCloud.Client/ProgramArgsValidator.cs b/TagCloud.Client/ProgramArgsValidator.cs new file mode 100644 index 000000000..a37d3b406 --- /dev/null +++ b/TagCloud.Client/ProgramArgsValidator.cs @@ -0,0 +1,85 @@ +using System.Drawing; +using System.Drawing.Text; +using Autofac; +using ErrorHandling; +using TagCloud; +using TagCloud.DI; +using TagCloud.Models; + +namespace TagCloud.Client; + +public static class ProgramArgsValidator +{ + public static Result ValidateArgs(string[] args) + { + return args.Length < 3 + ? Result.Fail("Не переданы аргументы") + : Result.Ok(args); + } + + public static Result ParseArgs(string[] args) + { + return Result.Of(() => + { + var width = args.Length > 3 ? int.Parse(args[3]) : 1200; + var height = args.Length > 4 ? int.Parse(args[4]) : 800; + var fontName = args.Length > 5 ? args[5] : "Arial"; + + return new ProgramArgs( + args[0], + args[1], + args[2], + width, + height, + fontName + ); + }, "Ошибка при разборе аргументов"); + } + + public static Result ValidateFiles(ProgramArgs args) + { + if (!File.Exists(args.WordsFile)) + return Result.Fail($"Файл слов не найден: {args.WordsFile}"); + + return !File.Exists(args.StopWordsFile) + ? Result.Fail($"Файл стоп-слов не найден: {args.StopWordsFile}") + : Result.Ok(args); + } + + public static Result ValidateFont(ProgramArgs args) + { + var fonts = new InstalledFontCollection(); + + var exists = fonts.Families + .Any(f => string.Equals(f.Name, args.FontName, StringComparison.OrdinalIgnoreCase)); + + return !exists ? Result.Fail($"Шрифт не найден в системе: {args.FontName}") : Result.Ok(args); + } + + public static Result GenerateTagCloud((IContainer container, ProgramArgs args) input) + { + return Result.Of(() => + { + var (container, args) = input; + + var config = new TagCloudVisualizationConfig + { + CanvasWidth = args.Width, + CanvasHeight = args.Height, + CanvasBackgroundColor = Color.Black, + ShapeFillColor = Color.DarkSlateGray, + ShapeBorderColor = Color.White, + ShapeBorderThickness = 1, + FontName = args.FontName + }; + + using var scope = container.BeginLifetimeScope(); + var generator = scope.Resolve(); + + generator.Generate(args.OutputFile, config); + + Console.WriteLine($"Файл сохранён: {args.OutputFile}"); + return None.Value; + }, "Ошибка при генерации облака тегов"); + } +} \ No newline at end of file diff --git a/TagCloud.Client/TagCloud.Client.csproj b/TagCloud.Client/TagCloud.Client.csproj new file mode 100644 index 000000000..64132d920 --- /dev/null +++ b/TagCloud.Client/TagCloud.Client.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/TagCloud.Client/TagCloudGeneratorHelper.cs b/TagCloud.Client/TagCloudGeneratorHelper.cs new file mode 100644 index 000000000..ba43f8e82 --- /dev/null +++ b/TagCloud.Client/TagCloudGeneratorHelper.cs @@ -0,0 +1,37 @@ +using System.Drawing; +using Autofac; +using ErrorHandling; +using TagCloud.Models; + + +namespace TagCloud.Client; + +public static class TagCloudGeneratorHelper +{ + public static Result GenerateTagCloud((IContainer container, ProgramArgs args) input) + { + return Result.Of(() => + { + var (container, args) = input; + + var config = new TagCloudVisualizationConfig + { + CanvasWidth = args.Width, + CanvasHeight = args.Height, + CanvasBackgroundColor = Color.Black, + ShapeFillColor = Color.DarkSlateGray, + ShapeBorderColor = Color.White, + ShapeBorderThickness = 1, + FontName = args.FontName + }; + + using var scope = container.BeginLifetimeScope(); + var generator = scope.Resolve(); + + generator.Generate(args.OutputFile, config); + + Console.WriteLine($"Файл сохранён: {args.OutputFile}"); + return None.Value; + }, "Ошибка при генерации облака тегов"); + } +} \ No newline at end of file diff --git a/TagCloud.Client/output2.png b/TagCloud.Client/output2.png new file mode 100644 index 000000000..af6540d92 Binary files /dev/null and b/TagCloud.Client/output2.png differ diff --git a/TagCloud.Client/stopwords.docx b/TagCloud.Client/stopwords.docx new file mode 100644 index 000000000..480ab12c6 Binary files /dev/null and b/TagCloud.Client/stopwords.docx differ diff --git a/TagCloud.Client/stopwords.txt b/TagCloud.Client/stopwords.txt new file mode 100644 index 000000000..83e08c84c --- /dev/null +++ b/TagCloud.Client/stopwords.txt @@ -0,0 +1,11 @@ +hello +the +and +a +of +in +on +at +for +rain +sun \ No newline at end of file diff --git a/TagCloud.Client/words.docx b/TagCloud.Client/words.docx new file mode 100644 index 000000000..80cedae87 Binary files /dev/null and b/TagCloud.Client/words.docx differ diff --git a/TagCloud.Client/words.txt b/TagCloud.Client/words.txt new file mode 100644 index 000000000..9f5ae8af2 --- /dev/null +++ b/TagCloud.Client/words.txt @@ -0,0 +1,131 @@ +apple +apple +banana +banana +banana +sky +sky +sky +cloud +cloud +cloud +cloud +hello +hello +hello +world +world +world +world +world +rain +rain +rain +rain +rain +rain +flower +flower +flower +flower +flower +sun +sun +sun +sun +sun +moon +moon +moon +moon +moon +moon +star +star +star +star +star +star +star +tree +tree +tree +tree +tree +tree +tree +tree +river +river +river +river +river +river +mountain +mountain +mountain +mountain +mountain +mountain +mountain +mountain +mountain +wind +wind +wind +wind +wind +wind +wind +wind +wind +wind +stone +stone +stone +stone +stone +stone +stone +stone +stone +fire +fire +fire +fire +fire +fire +fire +fire +fire +fire +cloudy +cloudy +cloudy +cloudy +cloudy +cloudy +cloudy +cloudy +cloudy +cloudy +rainbow +rainbow +rainbow +rainbow +rainbow +rainbow +rainbow +rainbow +rainbow +rainbow +leaf +leaf +leaf +leaf +leaf +leaf +leaf +leaf +leaf +leaf diff --git a/TagCloud.Core/Abstractions/DrawLogic/CircularCloudLayouterBase.cs b/TagCloud.Core/Abstractions/DrawLogic/CircularCloudLayouterBase.cs new file mode 100644 index 000000000..c78929fb3 --- /dev/null +++ b/TagCloud.Core/Abstractions/DrawLogic/CircularCloudLayouterBase.cs @@ -0,0 +1,20 @@ +using System.Drawing; +using ErrorHandling; + +namespace TagCloud.Abstractions; + +public abstract class CircularCloudLayouterBase(Point center) +{ + private readonly List rectangles = []; + + public Point Center { get; } = center; + public IReadOnlyList Rectangles => rectangles; + + protected Result AddRectangle(Rectangle rectangle) + { + rectangles.Add(rectangle); + return Result.Ok(None.Value); + } + + public abstract Result PutNextRectangle(Size rectangleSize); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/DrawLogic/ICenterShifter.cs b/TagCloud.Core/Abstractions/DrawLogic/ICenterShifter.cs new file mode 100644 index 000000000..1357b70d2 --- /dev/null +++ b/TagCloud.Core/Abstractions/DrawLogic/ICenterShifter.cs @@ -0,0 +1,8 @@ +using System.Drawing; + +namespace TagCloud.Abstractions; + +public interface ICenterShifter +{ + Rectangle ShiftToCenter(Rectangle rectangle, Point center, IEnumerable others); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/DrawLogic/IFontSizeMapper.cs b/TagCloud.Core/Abstractions/DrawLogic/IFontSizeMapper.cs new file mode 100644 index 000000000..4a1703adb --- /dev/null +++ b/TagCloud.Core/Abstractions/DrawLogic/IFontSizeMapper.cs @@ -0,0 +1,6 @@ +namespace TagCloud.Abstractions; + +public interface IFontSizeMapper +{ + int Map(int frequency, int minFont, int maxFont, int minFreq, int maxFreq); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/DrawLogic/IFrequencyCalculator.cs b/TagCloud.Core/Abstractions/DrawLogic/IFrequencyCalculator.cs new file mode 100644 index 000000000..c093282da --- /dev/null +++ b/TagCloud.Core/Abstractions/DrawLogic/IFrequencyCalculator.cs @@ -0,0 +1,6 @@ +namespace TagCloud.Abstractions; + +public interface IFrequencyCalculator +{ + IReadOnlyDictionary Calculate(IEnumerable words); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/DrawLogic/ISpiral.cs b/TagCloud.Core/Abstractions/DrawLogic/ISpiral.cs new file mode 100644 index 000000000..960c19109 --- /dev/null +++ b/TagCloud.Core/Abstractions/DrawLogic/ISpiral.cs @@ -0,0 +1,8 @@ +using System.Drawing; + +namespace TagCloud.Abstractions; + +public interface ISpiral +{ + Point GetNextPoint(); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/DrawLogic/ITagCloudRenderer.cs b/TagCloud.Core/Abstractions/DrawLogic/ITagCloudRenderer.cs new file mode 100644 index 000000000..bacc1b154 --- /dev/null +++ b/TagCloud.Core/Abstractions/DrawLogic/ITagCloudRenderer.cs @@ -0,0 +1,10 @@ +using System.Drawing; +using TagCloud.Models; + +namespace TagCloud.Abstractions; + +public interface ITagCloudRenderer +{ + void Render(IEnumerable<(Tag tag, Rectangle rect)> placedTags, string outputPath, + TagCloudVisualizationConfig config); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/DrawLogic/ITagPlacer.cs b/TagCloud.Core/Abstractions/DrawLogic/ITagPlacer.cs new file mode 100644 index 000000000..142c9845f --- /dev/null +++ b/TagCloud.Core/Abstractions/DrawLogic/ITagPlacer.cs @@ -0,0 +1,10 @@ +using System.Drawing; +using ErrorHandling; + +namespace TagCloud.Abstractions; + +public interface ITagPlacer +{ + Result PutNextRectangle(Size size); + IReadOnlyCollection Rectangles { get; } +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/Generic/DocxFileProviderBase.cs b/TagCloud.Core/Abstractions/Generic/DocxFileProviderBase.cs new file mode 100644 index 000000000..ddb706b66 --- /dev/null +++ b/TagCloud.Core/Abstractions/Generic/DocxFileProviderBase.cs @@ -0,0 +1,22 @@ +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace TagCloud.Abstractions.Generic; + +public abstract class DocxFileProviderBase(string filePath) +{ + private readonly string FilePath = filePath; + + protected IEnumerable ReadParagraphs() + { + using var doc = WordprocessingDocument.Open(FilePath, false); + var body = doc.MainDocumentPart.Document.Body; + + foreach (var paragraph in body.Elements()) + { + var text = paragraph.InnerText.Trim(); + if (!string.IsNullOrWhiteSpace(text)) + yield return text; + } + } +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/Generic/ISourceFactory.cs b/TagCloud.Core/Abstractions/Generic/ISourceFactory.cs new file mode 100644 index 000000000..9e19aa9a2 --- /dev/null +++ b/TagCloud.Core/Abstractions/Generic/ISourceFactory.cs @@ -0,0 +1,8 @@ +using ErrorHandling; + +namespace TagCloud.Abstractions.Generic; + +public interface ISourceFactory +{ + Result Create(string filePath); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/Generic/IWordsProvider.cs b/TagCloud.Core/Abstractions/Generic/IWordsProvider.cs new file mode 100644 index 000000000..796580eca --- /dev/null +++ b/TagCloud.Core/Abstractions/Generic/IWordsProvider.cs @@ -0,0 +1,7 @@ +namespace TagCloud.Abstractions.Generic; + +public interface IWordsProvider +{ + bool CanHandle(string filePath); + TSource Create(string filePath); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/Generic/TxtFileProviderBase.cs b/TagCloud.Core/Abstractions/Generic/TxtFileProviderBase.cs new file mode 100644 index 000000000..da54c00f3 --- /dev/null +++ b/TagCloud.Core/Abstractions/Generic/TxtFileProviderBase.cs @@ -0,0 +1,13 @@ +namespace TagCloud.Abstractions.Generic; + +public abstract class TxtFileProviderBase(string filePath) +{ + private readonly string FilePath = filePath; + + protected IEnumerable ReadLines() + { + return File.ReadLines(FilePath) + .Select(s => s.Trim().ToLowerInvariant()) + .Where(s => !string.IsNullOrEmpty(s)); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/Generic/TxtProviderBase.cs b/TagCloud.Core/Abstractions/Generic/TxtProviderBase.cs new file mode 100644 index 000000000..12f0f9c9a --- /dev/null +++ b/TagCloud.Core/Abstractions/Generic/TxtProviderBase.cs @@ -0,0 +1,23 @@ +namespace TagCloud.Abstractions.Generic; + +public abstract class TxtProviderBase : IWordsProvider +{ + private readonly string extension; + + protected TxtProviderBase(string extension) + { + this.extension = extension.StartsWith(".") ? extension : "." + extension; + } + + public bool CanHandle(string filePath) + { + return Path.GetExtension(filePath).Equals(extension, StringComparison.OrdinalIgnoreCase); + } + + public T Create(string filePath) + { + return CreateProvider(filePath); + } + + protected abstract T CreateProvider(string filePath); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/StopWords/IWordFilter.cs b/TagCloud.Core/Abstractions/StopWords/IWordFilter.cs new file mode 100644 index 000000000..d85de7de8 --- /dev/null +++ b/TagCloud.Core/Abstractions/StopWords/IWordFilter.cs @@ -0,0 +1,7 @@ +namespace TagCloud.Abstractions; + +public interface IWordFilter +{ + bool ShouldKeep(string preprocessedWord); + IEnumerable Filter(IEnumerable words); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/WordsSource/IWordPreprocessor.cs b/TagCloud.Core/Abstractions/WordsSource/IWordPreprocessor.cs new file mode 100644 index 000000000..d8b35b204 --- /dev/null +++ b/TagCloud.Core/Abstractions/WordsSource/IWordPreprocessor.cs @@ -0,0 +1,7 @@ +namespace TagCloud.Abstractions; + +public interface IWordPreprocessor +{ + string Preprocess(string word); + IEnumerable PreprocessMany(IEnumerable words); +} \ No newline at end of file diff --git a/TagCloud.Core/Abstractions/WordsSource/IWordsSource.cs b/TagCloud.Core/Abstractions/WordsSource/IWordsSource.cs new file mode 100644 index 000000000..e8e7fc758 --- /dev/null +++ b/TagCloud.Core/Abstractions/WordsSource/IWordsSource.cs @@ -0,0 +1,6 @@ +namespace TagCloud.Abstractions.WordsSource; + +public interface IWordsSource +{ + public IEnumerable ReadWords(); +} \ No newline at end of file diff --git a/TagCloud.Core/DI/TagCloudModule.cs b/TagCloud.Core/DI/TagCloudModule.cs new file mode 100644 index 000000000..b9b03d6e6 --- /dev/null +++ b/TagCloud.Core/DI/TagCloudModule.cs @@ -0,0 +1,105 @@ +using System.Drawing; +using Autofac; +using Autofac.Features.AttributeFilters; +using TagCloud.Abstractions; +using TagCloud.Abstractions.Generic; +using TagCloud.Abstractions.WordsSource; +using TagCloud.Implementations; +using TagCloud.Implementations.Generics; +using TagCloud.Implementations.StopWords; +using TagCloud.Implementations.WordSource; + +namespace TagCloud.DI; + +public class TagCloudModule(string wordsFile, string stopWordsFile) : Module +{ + protected override void Load(ContainerBuilder builder) + { + var center = new Point(0, 0); + + builder.Register(_ => new ArchimedeanSpiral(center)) + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.Register(ctx => + { + var spiral = ctx.Resolve(); + var shifter = ctx.Resolve(); + return new CircularCloudLayouter(center, spiral, shifter); + }) + .As() + .SingleInstance(); + + builder.Register(ctx => + { + var factory = ctx.Resolve>(); + return factory + .Create(wordsFile) + .GetValueOrThrow(); + }) + .Keyed("words") + .SingleInstance(); + + + builder.Register(ctx => + { + var factory = ctx.Resolve>(); + return factory + .Create(stopWordsFile) + .GetValueOrThrow(); + }) + .Keyed("stopWords") + .SingleInstance(); + + + builder.Register>(ctx => + { + var c = ctx.Resolve(); + return () => c.Resolve(); + }) + .As>() + .InstancePerDependency(); + + builder.RegisterAssemblyTypes(typeof(TxtWordsSourceProvider).Assembly) + .AssignableTo>() + .As>() + .SingleInstance(); + + builder.RegisterType>() + .As>() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .WithAttributeFiltering() + .InstancePerDependency(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .InstancePerDependency(); + + builder.RegisterType() + .WithAttributeFiltering() + .InstancePerDependency(); + } +} diff --git a/TagCloud.Core/Extensions/RectangleExtensions.cs b/TagCloud.Core/Extensions/RectangleExtensions.cs new file mode 100644 index 000000000..81c8e5f82 --- /dev/null +++ b/TagCloud.Core/Extensions/RectangleExtensions.cs @@ -0,0 +1,16 @@ +using System.Drawing; + +namespace TagCloud.Extensions; + +public static class RectangleExtensions +{ + public static Point Center(this Rectangle rectangle) + { + return new Point(rectangle.X + rectangle.Width / 2, rectangle.Y + rectangle.Height / 2); + } + + public static bool Intersects(this Rectangle rectangle, IEnumerable others) + { + return others.Any(r => r.IntersectsWith(rectangle)); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/DrawLogic/ArchimedeanSpiral.cs b/TagCloud.Core/Implementations/DrawLogic/ArchimedeanSpiral.cs new file mode 100644 index 000000000..d71dc805e --- /dev/null +++ b/TagCloud.Core/Implementations/DrawLogic/ArchimedeanSpiral.cs @@ -0,0 +1,21 @@ +using System.Drawing; +using TagCloud.Abstractions; + +namespace TagCloud.Implementations; + +public class ArchimedeanSpiral(Point center, double spiralStep = 0.2, double radiusStep = 0.5) + : ISpiral +{ + private double angle; + + public Point GetNextPoint() + { + angle += spiralStep; + var radius = radiusStep * angle; + + var x = (int)(center.X + radius * Math.Cos(angle)); + var y = (int)(center.Y + radius * Math.Sin(angle)); + + return new Point(x, y); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/DrawLogic/CenterShifter.cs b/TagCloud.Core/Implementations/DrawLogic/CenterShifter.cs new file mode 100644 index 000000000..dfb986294 --- /dev/null +++ b/TagCloud.Core/Implementations/DrawLogic/CenterShifter.cs @@ -0,0 +1,30 @@ +using System.Drawing; +using TagCloud.Abstractions; + +namespace TagCloud.Implementations; + +public class CenterShifter : ICenterShifter +{ + public Rectangle ShiftToCenter(Rectangle rectangle, Point center, IEnumerable others) + { + var dx = Math.Sign(center.X - (rectangle.X + rectangle.Width / 2)); + var dy = Math.Sign(center.Y - (rectangle.Y + rectangle.Height / 2)); + if (dx == 0 && dy == 0) return rectangle; + + while (true) + { + var moved = new Rectangle(rectangle.X + dx, rectangle.Y + dy, rectangle.Width, rectangle.Height); + if (others.Any(r => r.IntersectsWith(moved)) || GetDistance(moved, center) > GetDistance(rectangle, center)) + return rectangle; + + rectangle = moved; + } + } + + private double GetDistance(Rectangle rectangle, Point center) + { + var cx = rectangle.X + rectangle.Width / 2; + var cy = rectangle.Y + rectangle.Height / 2; + return Math.Sqrt((cx - center.X) * (cx - center.X) + (cy - center.Y) * (cy - center.Y)); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/DrawLogic/CircularCloudLayouter.cs b/TagCloud.Core/Implementations/DrawLogic/CircularCloudLayouter.cs new file mode 100644 index 000000000..688aa086b --- /dev/null +++ b/TagCloud.Core/Implementations/DrawLogic/CircularCloudLayouter.cs @@ -0,0 +1,53 @@ +using System.Drawing; +using ErrorHandling; +using TagCloud.Abstractions; +using TagCloud.Extensions; + +namespace TagCloud.Implementations; + +public class CircularCloudLayouter(Point center, ISpiral spiral, ICenterShifter centerShifter, int maxAttempts = 1000) + : CircularCloudLayouterBase(center) +{ + public override Result PutNextRectangle(Size size) + { + return + ValidateSize(size) + .Then(FindFreeRectangle) + .Then(rect => + Result.Of( + () => centerShifter.ShiftToCenter(rect, Center, Rectangles), + "Ошибка при смещении прямоугольника к центру")) + .Then(rect => + { + AddRectangle(rect); + return rect; + }); + } + + private Result FindFreeRectangle(Size size) + { + for (var i = 0; i < maxAttempts; i++) + { + var point = spiral.GetNextPoint(); + var candidate = CreateRectangleCenteredAt(point, size); + + if (!candidate.Intersects(Rectangles)) + return Result.Ok(candidate); + } + + return Result.Fail( + $"Не удалось разместить прямоугольник {size.Width}x{size.Height} за {maxAttempts} попыток"); + } + + private static Rectangle CreateRectangleCenteredAt(Point center, Size size) + { + return new Rectangle(center.X - size.Width / 2, center.Y - size.Height / 2, size.Width, size.Height); + } + + private static Result ValidateSize(Size size) + { + return size.Width <= 0 || size.Height <= 0 + ? Result.Fail("Размер прямоугольника должен быть больше нуля") + : Result.Ok(size); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/DrawLogic/CircularTagPlacer.cs b/TagCloud.Core/Implementations/DrawLogic/CircularTagPlacer.cs new file mode 100644 index 000000000..5041d81ac --- /dev/null +++ b/TagCloud.Core/Implementations/DrawLogic/CircularTagPlacer.cs @@ -0,0 +1,15 @@ +using System.Drawing; +using ErrorHandling; +using TagCloud.Abstractions; + +namespace TagCloud.Implementations; + +public class CircularTagPlacer(CircularCloudLayouterBase layouter) : ITagPlacer +{ + public Result PutNextRectangle(Size size) + { + return layouter.PutNextRectangle(size); + } + + public IReadOnlyCollection Rectangles => layouter.Rectangles; +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/DrawLogic/FrequencyCalculator.cs b/TagCloud.Core/Implementations/DrawLogic/FrequencyCalculator.cs new file mode 100644 index 000000000..7af236f98 --- /dev/null +++ b/TagCloud.Core/Implementations/DrawLogic/FrequencyCalculator.cs @@ -0,0 +1,14 @@ +using TagCloud.Abstractions; + +namespace TagCloud.Implementations; + +public class FrequencyCalculator : IFrequencyCalculator +{ + public IReadOnlyDictionary Calculate(IEnumerable words) + { + var dict = new Dictionary(); + foreach (var w in words) dict[w] = dict.GetValueOrDefault(w) + 1; + + return dict; + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/DrawLogic/LinearFontSizeMapper.cs b/TagCloud.Core/Implementations/DrawLogic/LinearFontSizeMapper.cs new file mode 100644 index 000000000..49f5d6094 --- /dev/null +++ b/TagCloud.Core/Implementations/DrawLogic/LinearFontSizeMapper.cs @@ -0,0 +1,24 @@ +using TagCloud.Abstractions; + +namespace TagCloud.Implementations; + +public class LinearFontSizeMapper : IFontSizeMapper +{ + public int Map(int frequency, int minFont, int maxFont, int minFreq, int maxFreq) + { + if (minFreq == maxFreq) + return (minFont + maxFont) / 2; + + var lowFreq = Math.Min(minFreq, maxFreq); + var highFreq = Math.Max(minFreq, maxFreq); + var clampedFreq = Math.Min(Math.Max(frequency, lowFreq), highFreq); + + var frac = (double)(clampedFreq - minFreq) / (maxFreq - minFreq); + + var value = minFont + (int)Math.Round(frac * (maxFont - minFont), MidpointRounding.AwayFromZero); + + var lowFont = Math.Min(minFont, maxFont); + var highFont = Math.Max(minFont, maxFont); + return Math.Min(Math.Max(value, lowFont), highFont); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/Generics/SourceFactory.cs b/TagCloud.Core/Implementations/Generics/SourceFactory.cs new file mode 100644 index 000000000..da7f5acef --- /dev/null +++ b/TagCloud.Core/Implementations/Generics/SourceFactory.cs @@ -0,0 +1,20 @@ +using ErrorHandling; +using TagCloud.Abstractions.Generic; + +namespace TagCloud.Implementations.Generics; + +public class SourceFactory( + IEnumerable> providers) + : ISourceFactory +{ + public Result Create(string filePath) + { + var provider = providers.FirstOrDefault(p => p.CanHandle(filePath)); + + return provider == null + ? Result.Fail($"Неподдерживаемый формат файла: {filePath}") + : Result.Of( + () => provider.Create(filePath), + $"Ошибка при создании источника из файла: {filePath}"); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/StopWords/DocxStopWordsTxtProvider.cs b/TagCloud.Core/Implementations/StopWords/DocxStopWordsTxtProvider.cs new file mode 100644 index 000000000..bc24b0c2c --- /dev/null +++ b/TagCloud.Core/Implementations/StopWords/DocxStopWordsTxtProvider.cs @@ -0,0 +1,12 @@ +using TagCloud.Abstractions.Generic; +using TagCloud.Abstractions.WordsSource; + +namespace TagCloud.Implementations.StopWords; + +public class DocxStopWordsTxtProvider() : TxtProviderBase(".docx") +{ + protected override IWordsSource CreateProvider(string filePath) + { + return new DocxStopWordsProvider(filePath); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/StopWords/DocxStopWordsesProvider.cs b/TagCloud.Core/Implementations/StopWords/DocxStopWordsesProvider.cs new file mode 100644 index 000000000..1becf38c4 --- /dev/null +++ b/TagCloud.Core/Implementations/StopWords/DocxStopWordsesProvider.cs @@ -0,0 +1,13 @@ +using TagCloud.Abstractions.Generic; +using TagCloud.Abstractions.WordsSource; + +namespace TagCloud.Implementations.StopWords; + +public class DocxStopWordsProvider(string filePath) + : DocxFileProviderBase(filePath), IWordsSource +{ + public IEnumerable ReadWords() + { + return ReadParagraphs(); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/StopWords/StopWordsFilter.cs b/TagCloud.Core/Implementations/StopWords/StopWordsFilter.cs new file mode 100644 index 000000000..4a9fe7310 --- /dev/null +++ b/TagCloud.Core/Implementations/StopWords/StopWordsFilter.cs @@ -0,0 +1,20 @@ +using Autofac.Features.AttributeFilters; +using TagCloud.Abstractions; +using TagCloud.Abstractions.WordsSource; + +namespace TagCloud.Implementations.StopWords; + +public class StopWordsFilter([KeyFilter("stopWords")] IWordsSource provider) : IWordFilter +{ + private readonly IEnumerable stopWords = provider.ReadWords(); + + public bool ShouldKeep(string word) + { + return !string.IsNullOrWhiteSpace(word) && !stopWords.Contains(word); + } + + public IEnumerable Filter(IEnumerable words) + { + return words.Where(ShouldKeep); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/StopWords/TxtStopWordsTxtProvider.cs b/TagCloud.Core/Implementations/StopWords/TxtStopWordsTxtProvider.cs new file mode 100644 index 000000000..0f9ffb8cf --- /dev/null +++ b/TagCloud.Core/Implementations/StopWords/TxtStopWordsTxtProvider.cs @@ -0,0 +1,12 @@ +using TagCloud.Abstractions.Generic; +using TagCloud.Abstractions.WordsSource; + +namespace TagCloud.Implementations.StopWords; + +public class TxtStopWordsTxtProvider() : TxtProviderBase(".txt") +{ + protected override IWordsSource CreateProvider(string filePath) + { + return new TxtStopWordsProvider(filePath); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/StopWords/TxtStopWordsesProvider.cs b/TagCloud.Core/Implementations/StopWords/TxtStopWordsesProvider.cs new file mode 100644 index 000000000..b6b329a19 --- /dev/null +++ b/TagCloud.Core/Implementations/StopWords/TxtStopWordsesProvider.cs @@ -0,0 +1,13 @@ +using TagCloud.Abstractions.Generic; +using TagCloud.Abstractions.WordsSource; + +namespace TagCloud.Implementations.StopWords; + +public class TxtStopWordsProvider(string filePath) + : TxtFileProviderBase(filePath), IWordsSource +{ + public IEnumerable ReadWords() + { + return ReadLines().ToHashSet(); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/TagCloudRenderer.cs b/TagCloud.Core/Implementations/TagCloudRenderer.cs new file mode 100644 index 000000000..54bb56839 --- /dev/null +++ b/TagCloud.Core/Implementations/TagCloudRenderer.cs @@ -0,0 +1,39 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.Drawing.Text; +using TagCloud.Abstractions; +using TagCloud.Models; + +namespace TagCloud.Implementations; + +public class TagCloudRenderer : ITagCloudRenderer +{ + public void Render(IEnumerable<(Tag tag, Rectangle rect)> placedTags, string outputPath, + TagCloudVisualizationConfig config) + { + using var bmp = new Bitmap(config.CanvasWidth, config.CanvasHeight); + using var g = Graphics.FromImage(bmp); + g.Clear(config.CanvasBackgroundColor); + g.TextRenderingHint = TextRenderingHint.AntiAlias; + + foreach (var (tag, rect) in placedTags) + { + var font = new Font(config.FontName, tag.FontSize, FontStyle.Bold, GraphicsUnit.Pixel); + var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + var drawRect = new Rectangle(rect.X + config.CanvasWidth / 2, rect.Y + config.CanvasHeight / 2, rect.Width, + rect.Height); + + using var fillBrush = new SolidBrush(config.ShapeFillColor); + g.FillRectangle(fillBrush, drawRect); + + using var pen = new Pen(config.ShapeBorderColor, config.ShapeBorderThickness); + g.DrawRectangle(pen, drawRect); + + var text = tag.Text; + g.DrawString(text, font, Brushes.White, drawRect, sf); + font.Dispose(); + } + + bmp.Save(outputPath, ImageFormat.Png); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/WordSource/DocxWordsSourceProvider.cs b/TagCloud.Core/Implementations/WordSource/DocxWordsSourceProvider.cs new file mode 100644 index 000000000..6b03257c1 --- /dev/null +++ b/TagCloud.Core/Implementations/WordSource/DocxWordsSourceProvider.cs @@ -0,0 +1,12 @@ +using TagCloud.Abstractions.Generic; +using TagCloud.Abstractions.WordsSource; + +namespace TagCloud.Implementations.WordSource; + +public class DocxWordsSourceProvider() : TxtProviderBase(".docx") +{ + protected override IWordsSource CreateProvider(string filePath) + { + return new DocxWordsSource(filePath); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/WordSource/DocxWordsesSource.cs b/TagCloud.Core/Implementations/WordSource/DocxWordsesSource.cs new file mode 100644 index 000000000..753e67893 --- /dev/null +++ b/TagCloud.Core/Implementations/WordSource/DocxWordsesSource.cs @@ -0,0 +1,12 @@ +using TagCloud.Abstractions.Generic; +using TagCloud.Abstractions.WordsSource; + +namespace TagCloud.Implementations.WordSource; + +public class DocxWordsSource(string filePath) : DocxFileProviderBase(filePath), IWordsSource +{ + public IEnumerable ReadWords() + { + return ReadParagraphs(); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/WordSource/LowercasePreprocessor.cs b/TagCloud.Core/Implementations/WordSource/LowercasePreprocessor.cs new file mode 100644 index 000000000..79329835e --- /dev/null +++ b/TagCloud.Core/Implementations/WordSource/LowercasePreprocessor.cs @@ -0,0 +1,18 @@ +using TagCloud.Abstractions; + +namespace TagCloud.Implementations.WordSource; + +public class LowercasePreprocessor : IWordPreprocessor +{ + public string Preprocess(string word) + { + return word.ToLowerInvariant(); + } + + public IEnumerable PreprocessMany(IEnumerable words) + { + foreach (var w in words) + if (!string.IsNullOrWhiteSpace(w)) + yield return Preprocess(w); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/WordSource/TxtWordsSourceProvider.cs b/TagCloud.Core/Implementations/WordSource/TxtWordsSourceProvider.cs new file mode 100644 index 000000000..50fa5d66c --- /dev/null +++ b/TagCloud.Core/Implementations/WordSource/TxtWordsSourceProvider.cs @@ -0,0 +1,18 @@ +using TagCloud.Abstractions.Generic; +using TagCloud.Abstractions.WordsSource; + +namespace TagCloud.Implementations.WordSource; + +public class TxtWordsSourceProvider : + IWordsProvider +{ + public bool CanHandle(string filePath) + { + return Path.GetExtension(filePath).Equals(".txt", StringComparison.OrdinalIgnoreCase); + } + + public IWordsSource Create(string filePath) + { + return new TxtWordsSource(filePath); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Implementations/WordSource/TxtWordsesSource.cs b/TagCloud.Core/Implementations/WordSource/TxtWordsesSource.cs new file mode 100644 index 000000000..9aa20507a --- /dev/null +++ b/TagCloud.Core/Implementations/WordSource/TxtWordsesSource.cs @@ -0,0 +1,12 @@ +using TagCloud.Abstractions.Generic; +using TagCloud.Abstractions.WordsSource; + +namespace TagCloud.Implementations.WordSource; + +public class TxtWordsSource(string filePath) : TxtFileProviderBase(filePath), IWordsSource +{ + public IEnumerable ReadWords() + { + return ReadLines(); + } +} \ No newline at end of file diff --git a/TagCloud.Core/Models/Tag.cs b/TagCloud.Core/Models/Tag.cs new file mode 100644 index 000000000..31028ac56 --- /dev/null +++ b/TagCloud.Core/Models/Tag.cs @@ -0,0 +1,3 @@ +namespace TagCloud.Models; + +public record Tag(string Text, int Frequency, int FontSize); \ No newline at end of file diff --git a/TagCloud.Core/Models/TagCloudVisualizationConfig.cs b/TagCloud.Core/Models/TagCloudVisualizationConfig.cs new file mode 100644 index 000000000..cab1a0a64 --- /dev/null +++ b/TagCloud.Core/Models/TagCloudVisualizationConfig.cs @@ -0,0 +1,14 @@ +using System.Drawing; + +namespace TagCloud.Models; + +public class TagCloudVisualizationConfig +{ + public int CanvasWidth { get; set; } + public int CanvasHeight { get; set; } + public Color CanvasBackgroundColor { get; set; } + public Color ShapeFillColor { get; set; } + public Color ShapeBorderColor { get; set; } + public int ShapeBorderThickness { get; set; } + public string FontName { get; set; } +} \ No newline at end of file diff --git a/TagCloud.Core/TagCloud.Core.csproj b/TagCloud.Core/TagCloud.Core.csproj new file mode 100644 index 000000000..15d383325 --- /dev/null +++ b/TagCloud.Core/TagCloud.Core.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + TagCloud + + + + + + + + + + + + + diff --git a/TagCloud.Core/TagCloudGenerator.cs b/TagCloud.Core/TagCloudGenerator.cs new file mode 100644 index 000000000..49abc38a5 --- /dev/null +++ b/TagCloud.Core/TagCloudGenerator.cs @@ -0,0 +1,76 @@ +using System.Drawing; +using ErrorHandling; +using Autofac.Features.AttributeFilters; +using TagCloud.Abstractions; +using TagCloud.Abstractions.WordsSource; +using TagCloud.Models; + +namespace TagCloud; + +public class TagCloudGenerator( + [KeyFilter("words")] IWordsSource wordsSource, + IWordPreprocessor preprocessor, + IWordFilter filter, + IFrequencyCalculator freqCalc, + IFontSizeMapper sizeMapper, + Func tagPlacerFactory, + ITagCloudRenderer renderer) +{ + public Result Generate(string outputPath, TagCloudVisualizationConfig config, int minFont = 10, int maxFont = 64, + int maxTags = 500) + { + return Result.Of(() => wordsSource.ReadWords(), "Ошибка при чтении слов") + .Then(raw => Result.Of( + () => raw.Select(preprocessor.Preprocess).Where(filter.ShouldKeep).ToList(), + "Ошибка при обработке или фильтрации слов")) + .Then(processed => + { + var freqs = Result.Of(() => freqCalc.Calculate(processed), "Ошибка при подсчёте частот"); + + return freqs.Then(f => + { + if (!f.Any()) + return Result.Fail("Нет слов для визуализации после фильтрации и обработки."); + + var top = f.OrderByDescending(kv => kv.Value).Take(maxTags).ToList(); + var minFreq = top.Min(kv => kv.Value); + var maxFreq = top.Max(kv => kv.Value); + + var tagPlacer = tagPlacerFactory(); + var placed = new List<(Tag tag, Rectangle rect)>(); + + foreach (var kv in top) + { + var tag = Result.Of(() => + { + var fontSize = sizeMapper.Map(kv.Value, minFont, maxFont, minFreq, maxFreq); + return new Tag(kv.Key, kv.Value, fontSize); + }, $"Ошибка при создании тега для слова {kv.Key}") + .GetValueOrThrow(); + + var rectResult = Result.Of(() => + { + using var tmpBmp = new Bitmap(1, 1); + using var g = Graphics.FromImage(tmpBmp); + var font = new Font(config.FontName, tag.FontSize, FontStyle.Bold, GraphicsUnit.Pixel); + var sizeF = g.MeasureString(tag.Text, font); + return new Size((int)Math.Ceiling(sizeF.Width) + 6, (int)Math.Ceiling(sizeF.Height) + 6); + }, $"Ошибка при измерении размера тега {tag.Text}") + .Then(size => tagPlacer.PutNextRectangle(size)); + + if (!rectResult.IsSuccess) + return Result.Fail(rectResult.Error); + + var rect = rectResult.GetValueOrThrow(); + placed.Add((tag, rect)); + } + + return Result.Of(() => + { + renderer.Render(placed, outputPath, config); + return None.Value; + }, "Ошибка при рендеринге облака тегов"); + }); + }); + } +} diff --git a/TagCloud.Tests/CircularCloudLayouterTests.cs b/TagCloud.Tests/CircularCloudLayouterTests.cs new file mode 100644 index 000000000..72548bef1 --- /dev/null +++ b/TagCloud.Tests/CircularCloudLayouterTests.cs @@ -0,0 +1,206 @@ +using System.Drawing; +using FakeItEasy; +using FluentAssertions; +using TagCloud.Abstractions; +using TagCloud.Extensions; +using TagCloud.Implementations; + +namespace TagCloud.Tests; + +[TestFixture] +public class CircularCloudLayouterTests +{ + [SetUp] + public void SetUp() + { + var center = new Point(0, 0); + var spiral = new ArchimedeanSpiral(center); + var centerShifter = new CenterShifter(); + layouter = new CircularCloudLayouter(center, spiral, centerShifter); + } + + private CircularCloudLayouter layouter; + + [TestCaseSource(nameof(GenerateInvalidSizes))] + public void PutNextRectangle_ShouldFail_WhenSizeIsInvalid(Size size) + { + var result = layouter.PutNextRectangle(size); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("должен быть больше нуля"); + } + + [TestCaseSource(nameof(GenerateDifferentSizes))] + public void PutNextRectangle_ShouldHaveCorrectSize(Size size) + { + var result = layouter.PutNextRectangle(size); + + result.IsSuccess.Should().BeTrue(); + var rect = result.GetValueOrThrow(); + + rect.Width.Should().Be(size.Width); + rect.Height.Should().Be(size.Height); + } + + [TestCaseSource(nameof(GenerateSpiralPoints))] + public void PutNextRectangle_ShouldPlaceRectanglesAtExpectedPoints(Point[] expectedPoints) + { + var fakeSpiral = A.Fake(); + var queue = new Queue(expectedPoints); + A.CallTo(() => fakeSpiral.GetNextPoint()).ReturnsLazily(() => queue.Dequeue()); + + var fakeLayouter = new CircularCloudLayouter( + new Point(0, 0), + fakeSpiral, + new CenterShifter() + ); + + foreach (var point in expectedPoints) + { + var result = fakeLayouter.PutNextRectangle(new Size(10, 10)); + result.IsSuccess.Should().BeTrue(); + var rect = result.GetValueOrThrow(); + rect.Center().Should().Be(point); } + + fakeLayouter.Rectangles.Should().HaveCount(expectedPoints.Length); + } + + [TestCaseSource(nameof(GenerateRectanglesCount))] + public void PutNextRectangle_ShouldPlaceManyRectanglesWithoutIntersections(int count) + { + for (var i = 0; i < count; i++) + { + var result = layouter.PutNextRectangle(new Size(10, 10)); + result.IsSuccess.Should().BeTrue(); + } + + layouter.Rectangles.Should().HaveCount(count); + + foreach (var r1 in layouter.Rectangles) + foreach (var r2 in layouter.Rectangles.Where(r2 => r1 != r2)) + r1.IntersectsWith(r2).Should().BeFalse(); + } + + [Test] + public void PutNextRectangle_FirstRectangle_ShouldBeExactlyAtCenter() + { + var result = layouter.PutNextRectangle(new Size(10, 10)); + result.IsSuccess.Should().BeTrue(); + var rect = result.GetValueOrThrow(); + rect.Center().Should().Be(layouter.Center); + } + + [Test] + public void PutNextRectangle_ShouldCallCenterShifter() + { + var fakeShifter = A.Fake(); + var fakeSpiral = A.Fake(); + A.CallTo(() => fakeSpiral.GetNextPoint()).Returns(new Point(0, 0)); + var layouterWithFake = new CircularCloudLayouter( + new Point(0, 0), + fakeSpiral, + fakeShifter + ); + + layouterWithFake.PutNextRectangle(new Size(10, 10)); + + A.CallTo(() => fakeShifter.ShiftToCenter(A._, A._, A>._)) + .MustHaveHappened(); + } + + [Test] + public void PutNextRectangle_ShouldTryMultipleSpiralPointsUntilFree() + { + var fakeSpiral = A.Fake(); + var fakeShifter = new CenterShifter(); + + var occupiedPoint = new Point(0, 0); + var freePoint = new Point(10, 10); + var callCount = 0; + + A.CallTo(() => fakeSpiral.GetNextPoint()).ReturnsLazily(() => + { + callCount++; + return callCount == 1 ? occupiedPoint : freePoint; + }); + + var layouterWithFake = new CircularCloudLayouter( + new Point(0, 0), + fakeSpiral, + fakeShifter + ); + + var firstResult = layouterWithFake.PutNextRectangle(new Size(10, 10)); + firstResult.IsSuccess.Should().BeTrue(); + var firstRect = firstResult.GetValueOrThrow(); + + var secondResult = layouterWithFake.PutNextRectangle(new Size(10, 10)); + secondResult.IsSuccess.Should().BeTrue(); + var secondRect = secondResult.GetValueOrThrow(); + + secondRect.Center().Should().Be(freePoint); + } + + public static IEnumerable GenerateRectanglesCount() + { + yield return new TestCaseData(1) + .SetName("PutNextRectangle_SingleRectangle_NoIntersection") + .SetDescription("Placing a single rectangle should succeed without intersections."); + yield return new TestCaseData(2) + .SetName("PutNextRectangle_TwoRectangles_NoIntersection") + .SetDescription("Placing two rectangles should succeed without intersections."); + yield return new TestCaseData(20) + .SetName("PutNextRectangle_TwentyRectangles_NoIntersection") + .SetDescription("Placing 20 rectangles should succeed without intersections."); + yield return new TestCaseData(200) + .SetName("PutNextRectangle_TwoHundredRectangles_NoIntersection") + .SetDescription("Placing 200 rectangles should succeed without intersections."); + for (var i = 50; i <= 200; i *= 2) + yield return new TestCaseData(i) + .SetName($"PutNextRectangle_{i}Rectangles_NoOverlap") + .SetDescription($"Placing {i} rectangles should not produce overlaps."); + } + + public static IEnumerable GenerateSpiralPoints() + { + var points = Enumerable.Range(0, 20).Select(i => new Point(i * 10, i * 10)).ToArray(); + yield return new TestCaseData(points) + .SetName("PutNextRectangle_ShouldTryMultipleSpiralPointsUntilFree") + .SetDescription("Rectangles should be placed at expected points along the spiral."); + } + + public static IEnumerable GenerateDifferentSizes() + { + yield return new TestCaseData(new Size(10, 10)) + .SetName("PutNextRectangle_Size10x10") + .SetDescription("Rectangle with width=10, height=10 should be placed correctly."); + yield return new TestCaseData(new Size(20, 5)) + .SetName("PutNextRectangle_Size20x5") + .SetDescription("Rectangle with width=20, height=5 should be placed correctly."); + yield return new TestCaseData(new Size(5, 30)) + .SetName("PutNextRectangle_Size5x30") + .SetDescription("Rectangle with width=5, height=30 should be placed correctly."); + yield return new TestCaseData(new Size(15, 25)) + .SetName("PutNextRectangle_Size15x25") + .SetDescription("Rectangle with width=15, height=25 should be placed correctly."); + } + + public static IEnumerable GenerateInvalidSizes() + { + yield return new TestCaseData(new Size(0, 10)) + .SetName("PutNextRectangle_Throws_WhenWidthIsZero") + .SetDescription("CanvasWidth is zero, should throw ArgumentException."); + yield return new TestCaseData(new Size(10, 0)) + .SetName("PutNextRectangle_Throws_WhenHeightIsZero") + .SetDescription("CanvasHeight is zero, should throw ArgumentException."); + yield return new TestCaseData(new Size(-5, 10)) + .SetName("PutNextRectangle_Throws_WhenWidthIsNegative") + .SetDescription("CanvasWidth is negative, should throw ArgumentException."); + yield return new TestCaseData(new Size(10, -5)) + .SetName("PutNextRectangle_Throws_WhenHeightIsNegative") + .SetDescription("CanvasHeight is negative, should throw ArgumentException."); + yield return new TestCaseData(new Size(-5, -5)) + .SetName("PutNextRectangle_Throws_WhenWidthAndHeightNegative") + .SetDescription("CanvasWidth and height are negative, should throw ArgumentException."); + } +} \ No newline at end of file diff --git a/TagCloud.Tests/FileStopWordsProvider.cs b/TagCloud.Tests/FileStopWordsProvider.cs new file mode 100644 index 000000000..3279af228 --- /dev/null +++ b/TagCloud.Tests/FileStopWordsProvider.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using TagCloud.Implementations; +using TagCloud.Implementations.StopWords; + +namespace TagCloud.Tests; + +[TestFixture] +public class TxtStopWordsProviderTests +{ + [SetUp] + public void SetUp() + { + tempPath = Path.GetTempFileName(); + } + + [TearDown] + public void TearDown() + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + + private string tempPath; + + [TestCaseSource(nameof(GetTestCases))] + public void GetStopWords_ShouldReturnExpectedWords(string[] fileLines, IEnumerable expected) + { + File.WriteAllLines(tempPath, fileLines); + + var provider = new TxtStopWordsProvider(tempPath); + var result = provider.ReadWords(); + + result.Should().BeEquivalentTo(expected, + "TxtStopWordsProvider should lowercase words, trim whitespace, ignore empty lines, and return unique values."); + } + + private static IEnumerable GetTestCases() + { + yield return new TestCaseData(new[] { "THE", "and", "", "And", "the ", " or" }, new[] { "the", "and", "or" }) + .SetName("GetStopWords_ShouldReturnLowercasedUniqueWords") + .SetDescription("Ensures lowercasing and uniqueness for a mixed input of cases and duplicates."); + + yield return new TestCaseData(Array.Empty(), Array.Empty()) + .SetName("GetStopWords_ShouldReturnEmpty_WhenFileIsEmpty") + .SetDescription("Empty file should produce no stop words."); + + yield return new TestCaseData(new[] { "", " ", " " }, Array.Empty()) + .SetName("GetStopWords_ShouldIgnoreOnlyWhitespaceLines") + .SetDescription("Whitespace-only lines should be ignored."); + + yield return new TestCaseData(new[] { " A " }, new[] { "a" }) + .SetName("GetStopWords_ShouldTrimAndLowercaseSingleWord") + .SetDescription("Single word should be trimmed and lowercased."); + + yield return new TestCaseData(new[] { "One", "TWO", "three" }, new[] { "one", "two", "three" }) + .SetName("GetStopWords_ShouldNormalizeCase_ForMultipleWords") + .SetDescription("Words should be lowercased but kept distinct."); + + yield return new TestCaseData(new[] { " spaced ", "value" }, new[] { "spaced", "value" }) + .SetName("GetStopWords_ShouldTrimSpaces") + .SetDescription("Leading and trailing spaces should be removed."); + + yield return new TestCaseData(new[] { "\t\thello\t" }, new[] { "hello" }) + .SetName("GetStopWords_ShouldTrimTabs") + .SetDescription("Tabs should be trimmed like spaces."); + + yield return new TestCaseData(new[] { "HELLO ", " hello", " Hello " }, new[] { "hello" }) + .SetName("GetStopWords_ShouldDeduplicateAfterLowercasing") + .SetDescription("Different casing versions of the same word should collapse into one."); + + yield return new TestCaseData(new[] { "hi!", "HI!", "hi?" }, new[] { "hi!", "hi?" }) + .SetName("GetStopWords_ShouldTreatPunctuationAsPartOfWord") + .SetDescription("Punctuation should not be removed, and lowercasing should not affect punctuation."); + + yield return new TestCaseData(new[] { "русский", " ТЕКСТ ", "данные" }, new[] { "русский", "текст", "данные" }) + .SetName("GetStopWords_ShouldSupportUnicode") + .SetDescription("Provider should handle Unicode words correctly."); + + yield return new TestCaseData(new[] { "123", " 456 ", "789" }, new[] { "123", "456", "789" }) + .SetName("GetStopWords_ShouldHandleNumericWords") + .SetDescription("Numbers should be treated as valid stop words."); + + yield return new TestCaseData(new[] { "#", "!", "?" }, new[] { "#", "!", "?" }) + .SetName("GetStopWords_ShouldAllowSymbolOnlyWords") + .SetDescription("Non-alphanumeric symbols should be accepted."); + + yield return new TestCaseData(new[] { "a", "", "b", " ", "c" }, new[] { "a", "b", "c" }) + .SetName("GetStopWords_ShouldIgnoreEmptyLinesBetweenWords") + .SetDescription("Only non-empty trimmed lines count."); + + yield return new TestCaseData(new[] { " A", "a ", "A", "a" }, new[] { "a" }) + .SetName("GetStopWords_ShouldRemoveDuplicatesAfterTrimming") + .SetDescription("Whitespace and casing variations should not produce duplicates."); + + yield return new TestCaseData(new[] { "\ufeffhello", "world" }, new[] { "hello", "world" }) + .SetName("GetStopWords_ShouldHandleUTF8BOM") + .SetDescription("BOM-prefixed lines should be trimmed correctly."); + + yield return new TestCaseData(new[] { "two words", " another sentence " }, + new[] { "two words", "another sentence" }) + .SetName("GetStopWords_ShouldNotSplitInternalSpaces") + .SetDescription("The provider should not split multi-word lines; they are treated as whole tokens."); + + yield return new TestCaseData(new[] { "A!", "a!", "A?" }, new[] { "a!", "a?" }) + .SetName("GetStopWords_ShouldNormalizeCaseButKeepPunctuation") + .SetDescription("Case-insensitive uniqueness but punctuation should remain unchanged."); + + yield return new TestCaseData(new[] { "test", "TEST ", " test" }, new[] { "test" }) + .SetName("GetStopWords_ShouldDeduplicateCompletely") + .SetDescription("All variants of a word should collapse into a single stop word."); + + yield return new TestCaseData(Enumerable.Repeat(" LargeWord ", 1000).ToArray(), new[] { "largeword" }) + .SetName("GetStopWords_ShouldHandleLargeFile") + .SetDescription("Provider should work efficiently with large files."); + + yield return new TestCaseData(new[] { "a", "A", "aA", "Aa", "AA" }, new[] { "a", "aa" }) + .SetName("GetStopWords_ShouldNormalizeMixedCasePatterns") + .SetDescription("Words with different case combinations should lower-case correctly."); + } +} \ No newline at end of file diff --git a/TagCloud.Tests/FileWordsSource.cs b/TagCloud.Tests/FileWordsSource.cs new file mode 100644 index 000000000..8712c50d7 --- /dev/null +++ b/TagCloud.Tests/FileWordsSource.cs @@ -0,0 +1,146 @@ +using FluentAssertions; +using TagCloud.Implementations; +using TagCloud.Implementations.WordSource; + +namespace TagCloud.Tests; + +[TestFixture] +public class TxtWordsSourceTests +{ + [SetUp] + public void SetUp() + { + tempPath = Path.GetTempFileName(); + } + + [TearDown] + public void TearDown() + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + + private string tempPath; + + [TestCaseSource(nameof(GetTestCases))] + public void ReadWords_ShouldReturnExpectedWords(string[] fileLines, IEnumerable expected) + { + File.WriteAllLines(tempPath, fileLines); + + var source = new TxtWordsSource(tempPath); + var result = source.ReadWords().ToArray(); + + result.Should().BeEquivalentTo(expected, + "TxtWordsSource should trim lines and ignore empty results."); + } + + private static IEnumerable GetTestCases() + { + yield return new TestCaseData(new[] { "hello", " world ", "", " ", "test" }, + new[] { "hello", "world", "test" }) + .SetName("ReadWords_ShouldReturnTrimmedNonEmptyLines") + .SetDescription("Trims whitespace on each line and skips empty/whitespace-only lines."); + + yield return new TestCaseData(Array.Empty(), Array.Empty()) + .SetName("ReadWords_ShouldReturnEmpty_WhenFileIsEmpty") + .SetDescription("Empty file → empty result."); + + yield return new TestCaseData(new[] { "", " ", " " }, Array.Empty()) + .SetName("ReadWords_ShouldIgnoreOnlyWhitespaceLines") + .SetDescription("Whitespace-only lines are ignored after trimming."); + + yield return new TestCaseData(new[] { " a " }, new[] { "a" }) + .SetName("ReadWords_ShouldTrimSingleLine") + .SetDescription("Single trimmed line should return the inner word."); + + yield return new TestCaseData(new[] { "Line1", "Line2", "Line3" }, new[] { "line1", "line2", "line3" }) + .SetName("ReadWords_ShouldReadMultipleSimpleLines") + .SetDescription("Multiple lines without extra spaces are returned unchanged."); + + yield return new TestCaseData(new[] { " spaced ", "value" }, new[] { "spaced", "value" }) + .SetName("ReadWords_ShouldTrimLeadingAndTrailingSpaces") + .SetDescription("Leading and trailing spaces are removed from all lines."); + + yield return new TestCaseData(new[] { "\t\thello\t" }, new[] { "hello" }) + .SetName("ReadWords_ShouldTrimTabs") + .SetDescription("Tabs are treated as whitespace and trimmed."); + + yield return new TestCaseData(new[] { "\nhello", "world\n" }, new[] { "hello", "world" }) + .SetName("ReadWords_ShouldTrimNewlineCharacters") + .SetDescription("Newline characters at the edges are trimmed."); + + yield return new TestCaseData(new[] { "hello ", " hello", " hello " }, new[] { "hello", "hello", "hello" }) + .SetName("ReadWords_ShouldTreatTrimmedWordsAsEqual_ButReturnAll") + .SetDescription("After trimming, identical words remain duplicates and must all be returned."); + + yield return new TestCaseData(new[] { "HELLO", "hello", "Hello" }, new[] { "hello", "hello", "hello" }) + .SetName("ReadWords_ShouldPreserveCase") + .SetDescription("Trimming does not affect letter casing."); + + yield return new TestCaseData(new[] { "word!", "!word", "wo!rd" }, new[] { "word!", "!word", "wo!rd" }) + .SetName("ReadWords_ShouldNotRemovePunctuation") + .SetDescription("Punctuation inside and around words is preserved."); + + yield return new TestCaseData(new[] { " many spaces " }, new[] { "many spaces" }) + .SetName("ReadWords_ShouldNotCollapseInternalSpaces") + .SetDescription("Internal spacing is preserved; only outer spaces are trimmed."); + + yield return new TestCaseData(new[] { "123", " 456 ", "789" }, new[] { "123", "456", "789" }) + .SetName("ReadWords_ShouldHandleNumericLines") + .SetDescription("Numbers are treated as words and trimmed normally."); + + yield return new TestCaseData(new[] { "русский", " текст ", "данные" }, new[] { "русский", "текст", "данные" }) + .SetName("ReadWords_ShouldHandleUnicodeWords") + .SetDescription("Unicode text is handled correctly and trimmed."); + + yield return new TestCaseData(new[] { "\t mixed \t spaces\t" }, new[] { "mixed \t spaces" }) + .SetName("ReadWords_ShouldPreserveInternalTabs") + .SetDescription("Tabs and spaces inside the word remain unchanged."); + + yield return new TestCaseData(new[] { "a", "", "b", " ", "c" }, new[] { "a", "b", "c" }) + .SetName("ReadWords_ShouldSkipEmptyLinesInBetween") + .SetDescription("Empty and whitespace-only lines between valid lines are ignored."); + + yield return new TestCaseData(new[] { " a ", " a ", " a", "a " }, new[] { "a", "a", "a", "a" }) + .SetName("ReadWords_ShouldReturnDuplicatesAfterTrim") + .SetDescription("Trimmed duplicates must be returned as individual items."); + + yield return new TestCaseData(new[] { "a\n", "\tb" }, new[] { "a", "b" }) + .SetName("ReadWords_ShouldHandleWeirdTrimCombinations") + .SetDescription("Combination of newlines and tabs is trimmed correctly."); + + yield return new TestCaseData(new[] { "line with spaces", " another line " }, + new[] { "line with spaces", "another line" }) + .SetName("ReadWords_ShouldHandleSentences") + .SetDescription("Multi-word sentences keep internal spacing and get trimmed externally."); + + yield return new TestCaseData(Enumerable.Repeat(" skip ", 1000).ToArray(), + Enumerable.Repeat("skip", 1000)) + .SetName("ReadWords_ShouldHandleLargeFile") + .SetDescription("Large amount of lines processed correctly with trimming."); + + yield return new TestCaseData( + new[] { ":", " , ", " ! " }, + new[] { ":", ",", "!" }) + .SetName("PunctuationIsWord") + .SetDescription("Standalone punctuation is considered a valid word after trimming."); + + yield return new TestCaseData(new[] { " ", " ", "\t" }, Array.Empty()) + .SetName("ReadWords_ShouldIgnoreWhitespaceOnlyLines") + .SetDescription("Whitespace-only lines are ignored, even if tabs."); + + yield return new TestCaseData(new[] { "a", "\0", "b" }, new[] { "a", "\0", "b" }) + .SetName("ReadWords_ShouldAllowNullCharInsideWord") + .SetDescription("Null character inside a line is preserved after trimming."); + + yield return new TestCaseData(new[] { "########" }, new[] { "########" }) + .SetName("ReadWords_ShouldAllowSpecialSymbolOnlyWords") + .SetDescription("Lines consisting only of symbols remain unchanged after trimming."); + + yield return new TestCaseData( + new[] { "ascii", "ümlaut", "中文" }, + new[] { "ascii", "ümlaut", "中文" }) + .SetName("ReadWords_ShouldHandleMixedLanguageLines") + .SetDescription("Words from different languages are handled and trimmed properly."); + } +} \ No newline at end of file diff --git a/TagCloud.Tests/FrequencyCalculator.cs b/TagCloud.Tests/FrequencyCalculator.cs new file mode 100644 index 000000000..e52ee6f0e --- /dev/null +++ b/TagCloud.Tests/FrequencyCalculator.cs @@ -0,0 +1,87 @@ +using FluentAssertions; +using TagCloud.Implementations; + +namespace TagCloud.Tests; + +[TestFixture] +public class FrequencyCalculatorTests +{ + private readonly FrequencyCalculator calc = new(); + + [TestCaseSource(nameof(GetTestCases))] + public void Calculate_ShouldReturnExpectedFrequencies(IEnumerable words, + Dictionary expected) + { + var frequencies = calc.Calculate(words); + + frequencies.Should().BeEquivalentTo(expected, + "frequency calculator should count occurrences of each word exactly"); + } + + private static IEnumerable GetTestCases() + { + yield return new TestCaseData(new[] { "a", "b", "a", "c", "b", "a" }, + new Dictionary { ["a"] = 3, ["b"] = 2, ["c"] = 1 } + ) + .SetName("Calculate_ShouldReturnCorrectFrequencies_ForBasicInput") + .SetDescription("Simple counting of repeated words."); + + yield return new TestCaseData(new string[] { }, new Dictionary()) + .SetName("Calculate_ShouldReturnEmptyDictionary_WhenInputIsEmpty") + .SetDescription("No words → empty result."); + + yield return new TestCaseData(new[] { "Hello", "hello", "HELLO" }, new Dictionary + { + ["Hello"] = 1, + ["hello"] = 1, + ["HELLO"] = 1 + } + ) + .SetName("Calculate_ShouldTreatWordsWithDifferentCasingAsDifferent") + .SetDescription("Calculator does not normalize case."); + + yield return new TestCaseData(new[] { "a", "b", "a" }, new Dictionary + { + ["a"] = 2, + ["b"] = 1 + } + ) + .SetName("Calculate_ShouldCountNullValuesSeparately") + .SetDescription("null is treated as a valid dictionary key."); + + yield return new TestCaseData(new[] { " hi", "hi ", " hi " }, new Dictionary + { + [" hi"] = 1, + ["hi "] = 1, + [" hi "] = 1 + } + ) + .SetName("Calculate_ShouldHandleWhitespace_AsDistinctWords") + .SetDescription("No trimming performed → strings stay distinct."); + + yield return new TestCaseData(new[] { "hi!", "hi!", "hi?", "hi!" }, new Dictionary + { + ["hi!"] = 3, + ["hi?"] = 1 + } + ) + .SetName("Calculate_ShouldCountPunctuationInWords") + .SetDescription("Special characters are part of the word."); + + yield return new TestCaseData(GenerateLargeInput(), GenerateLargeExpected()) + .SetName("Calculate_ShouldHandleLargeInput") + .SetDescription("Performance and correctness on 10k words."); + } + + private static IEnumerable GenerateLargeInput() + { + for (var i = 0; i < 10000; i++) yield return "word" + i % 10; + } + + private static Dictionary GenerateLargeExpected() + { + var dict = new Dictionary(); + for (var i = 0; i < 10; i++) dict["word" + i] = 1000; + return dict; + } +} \ No newline at end of file diff --git a/TagCloud.Tests/LinearFontSizeMapper.cs b/TagCloud.Tests/LinearFontSizeMapper.cs new file mode 100644 index 000000000..ed05263ab --- /dev/null +++ b/TagCloud.Tests/LinearFontSizeMapper.cs @@ -0,0 +1,84 @@ +using FluentAssertions; +using TagCloud.Implementations; + +namespace TagCloud.Tests; + +[TestFixture] +public class LinearFontSizeMapperTests +{ + private readonly LinearFontSizeMapper mapper = new(); + + [TestCaseSource(nameof(GetTestCases))] + public void Map_ShouldReturnExpectedFontSize(int frequency, int minFont, int maxFont, int minFreq, int maxFreq, + int expected) + { + var result = mapper.Map(frequency, minFont, maxFont, minFreq, maxFreq); + + result.Should().Be(expected, + $"Expected correct linear interpolation for frequency={frequency} " + + $"given freqRange=[{minFreq}, {maxFreq}] and fontRange=[{minFont}, {maxFont}]."); + } + + private static IEnumerable GetTestCases() + { + yield return new TestCaseData(5, 10, 50, 5, 5, 30) + .SetName("Map_ShouldReturnExpectedFontSize_WhenMinFreqEqualsMaxFreq") + .SetDescription("If all frequencies are equal, mapper should return mid-font."); + + yield return new TestCaseData(1, 10, 50, 1, 10, 10) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFrequencyEqualsMinFreq") + .SetDescription("Frequency at minimum maps to minFont."); + + yield return new TestCaseData(10, 10, 50, 1, 10, 50) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFrequencyEqualsMaxFreq") + .SetDescription("Frequency at maximum maps to maxFont."); + + yield return new TestCaseData(2, 10, 50, 1, 3, 30) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFrequencyIsInTheMiddleOfRange") + .SetDescription("Frequency between min and max produces linear mid value."); + + yield return new TestCaseData(2, 10, 49, 1, 4, 23) + .SetName("Map_ShouldReturnExpectedFontSize_WhenRoundingIsRequired") + .SetDescription("Mapper should round to nearest integer."); + + yield return new TestCaseData(0, 10, 50, 1, 10, 10) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFrequencyBelowMinFreq") + .SetDescription("Frequency lower than range should clamp to minFont."); + + yield return new TestCaseData(20, 10, 50, 1, 10, 50) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFrequencyAboveMaxFreq") + .SetDescription("Frequency above range should clamp to maxFont."); + + yield return new TestCaseData(-5, 10, 50, -10, 10, 20) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFrequencyIsNegativeWithinRange") + .SetDescription("Handles negative frequencies inside a negative range."); + + yield return new TestCaseData(-20, 10, 50, -10, 10, 10) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFrequencyIsBelowNegativeRange") + .SetDescription("Clamps below negative minFreq to minFont."); + + yield return new TestCaseData(20, 10, 50, -10, 10, 50) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFrequencyExceedsNegativeRange") + .SetDescription("Clamps above maxFreq to maxFont."); + + yield return new TestCaseData(5, 50, 10, 1, 10, 32) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFontRangeIsReversed") + .SetDescription("Supports inverted font ranges (maxFont < minFont)."); + + yield return new TestCaseData(5, 10, 50, 10, 1, 32) + .SetName("Map_ShouldReturnExpectedFontSize_WhenFrequencyRangeIsReversed") + .SetDescription("Works even when minFreq > maxFreq, inverse interpolation."); + + yield return new TestCaseData(500_000, 10, 100, 0, 1_000_000, 55) + .SetName("Map_ShouldReturnExpectedFontSize_WhenUsingLargeValues") + .SetDescription("Handles large integers without overflow."); + + yield return new TestCaseData(1, 1, 3, 1, 4, 1) + .SetName("Map_ShouldReturnExpectedFontSize_WhenRoundingDown") + .SetDescription("Linear interpolation should round down when fraction < .5."); + + yield return new TestCaseData(3, 1, 3, 1, 4, 2) + .SetName("Map_ShouldReturnExpectedFontSize_WhenRoundingUp") + .SetDescription("Linear interpolation should round up when fraction >= .5."); + } +} \ No newline at end of file diff --git a/TagCloud.Tests/LowercasePreprocessor.cs b/TagCloud.Tests/LowercasePreprocessor.cs new file mode 100644 index 000000000..439a7f61a --- /dev/null +++ b/TagCloud.Tests/LowercasePreprocessor.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using TagCloud.Implementations; +using TagCloud.Implementations.WordSource; + +namespace TagCloud.Tests; + +[TestFixture] +public class LowercasePreprocessorTests +{ + private readonly LowercasePreprocessor pre = new(); + + [TestCaseSource(nameof(GetTestCases))] + public void PreprocessMany_ShouldReturnExpectedWords(IEnumerable words, IEnumerable expected) + { + var result = pre.PreprocessMany(words).ToArray(); + result.Should().BeEquivalentTo(expected, + "LowercasePreprocessor should convert all words to lowercase without modifying anything else."); + } + + private static IEnumerable GetTestCases() + { + yield return new TestCaseData(new[] { "Hello", "WORLD", "TeSt" }, new[] { "hello", "world", "test" }) + .SetName("PreprocessMany_ShouldLowercaseBasicWords") + .SetDescription("Ensures typical words are converted to lowercase."); + + yield return new TestCaseData(Array.Empty(), Array.Empty()) + .SetName("PreprocessMany_ShouldReturnEmpty_WhenInputIsEmpty") + .SetDescription("Empty input should produce empty output."); + + yield return new TestCaseData(new[] { "hello", "world" }, new[] { "hello", "world" }) + .SetName("PreprocessMany_ShouldNotModifyAlreadyLowercaseWords") + .SetDescription("Words already in lowercase should remain unchanged."); + + yield return new TestCaseData(new[] { "HELLO", "WORLD" }, new[] { "hello", "world" }) + .SetName("PreprocessMany_ShouldHandleAllUppercaseWords") + .SetDescription("Words in all-uppercase are converted properly."); + + yield return new TestCaseData(new[] { "hElLo", "WoRlD" }, new[] { "hello", "world" }) + .SetName("PreprocessMany_ShouldHandleMixedCaseWords") + .SetDescription("Mixed-case words should be fully lowercased."); + + yield return new TestCaseData(new[] { "русский", "ТЕКСТ", "сЛоВо" }, new[] { "русский", "текст", "слово" }) + .SetName("PreprocessMany_ShouldHandleUnicodeLetters") + .SetDescription("Unicode characters should be lowercased using invariant rules."); + + yield return new TestCaseData(new[] { "123", "456" }, new[] { "123", "456" }) + .SetName("PreprocessMany_ShouldIgnoreNumbers") + .SetDescription("Numbers remain unchanged because lowercase does not affect digits."); + + yield return new TestCaseData( + new[] { "HELLO!", "TeSt?", "!WORLD" }, + new[] { "hello!", "test?", "!world" }) + .SetName("PreprocessMany_ShouldPreservePunctuation") + .SetDescription("Punctuation should remain unchanged while letters are lowercased."); + + yield return new TestCaseData(new[] { " TEST ", " WORD " }, new[] { " test ", " word " }) + .SetName("PreprocessMany_ShouldNotTrimWhitespace") + .SetDescription("Whitespace is not removed or modified—only letters are lowercased."); + + yield return new TestCaseData(new[] { "\tTEST\t", "WOW\n" }, new[] { "\ttest\t", "wow\n" }) + .SetName("PreprocessMany_ShouldPreserveWhitespaceCharacters") + .SetDescription("Tabs and newlines remain intact."); + + yield return new TestCaseData(new[] { "A", "a", "A", "a" }, new[] { "a", "a", "a", "a" }) + .SetName("PreprocessMany_ShouldProcessDuplicates") + .SetDescription("Duplicates should be transformed independently."); + + yield return new TestCaseData(new[] { "hello world", "TEST STRING" }, new[] { "hello world", "test string" }) + .SetName("PreprocessMany_ShouldHandleWordsWithSpacesInside") + .SetDescription("Lowercasing should apply to each character but spacing remains unchanged."); + + yield return new TestCaseData(new[] { "WORD1", "WORD2", "WORD3" }, new[] { "word1", "word2", "word3" }) + .SetName("PreprocessMany_ShouldLowercaseAlphanumericWords") + .SetDescription("Alphanumeric words should lowercase their alphabetic part only."); + + yield return new TestCaseData(Enumerable.Repeat("TESTWORD", 1000), Enumerable.Repeat("testword", 1000)) + .SetName("PreprocessMany_ShouldHandleLargeInput") + .SetDescription("Large input should be processed correctly and efficiently."); + } +} \ No newline at end of file diff --git a/TagCloud.Tests/TagCloud.Tests.csproj b/TagCloud.Tests/TagCloud.Tests.csproj new file mode 100644 index 000000000..c0a54813d --- /dev/null +++ b/TagCloud.Tests/TagCloud.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/TagCloud.Tests/TagCloudIntegrationTests.cs b/TagCloud.Tests/TagCloudIntegrationTests.cs new file mode 100644 index 000000000..92bb2607b --- /dev/null +++ b/TagCloud.Tests/TagCloudIntegrationTests.cs @@ -0,0 +1,149 @@ +using System.Drawing; +using Autofac; +using FluentAssertions; +using TagCloud; +using TagCloud.DI; +using TagCloud.Models; + +namespace TagCloud.Tests; + +[TestFixture] +public class TagCloudIntegrationTests +{ + private string baseDir; + private string outputPath; + private TagCloudVisualizationConfig cfg; + + [SetUp] + public void SetUp() + { + baseDir = Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, + "..", "..", ".." + )); + + var testName = TestContext.CurrentContext.Test.Name; + outputPath = Path.Combine(baseDir, $"cloud_{testName}.png"); + + cfg = new TagCloudVisualizationConfig + { + CanvasWidth = 800, + CanvasHeight = 800, + ShapeFillColor = Color.Blue, + ShapeBorderColor = Color.White + }; + + if (File.Exists(outputPath)) + File.Delete(outputPath); + } + + private TagCloudGenerator Resolve(string words, string stopwords) + { + var testName = TestContext.CurrentContext.Test.Name; + + var wordsFile = Path.Combine(baseDir, $"words_{testName}.txt"); + var stopwordsFile = Path.Combine(baseDir, $"stopwords_{testName}.txt"); + + File.WriteAllLines(wordsFile, (words ?? "").Split('\n')); + File.WriteAllLines(stopwordsFile, (stopwords ?? "").Split('\n')); + + var builder = new ContainerBuilder(); + builder.RegisterModule(new TagCloudModule(wordsFile, stopwordsFile)); + + var container = builder.Build(); + return container.Resolve(); + } + + [Test] + public void Generate_ShouldReadWordsAndStopwordsFromFiles() + { + var words = "apple\nbanana\nbanana\ncloud"; + var stopwords = "apple"; + + var gen = Resolve(words, stopwords); + + var result = gen.Generate(outputPath, cfg); + + result.IsSuccess.Should().BeTrue("чтение слов и генерация должны пройти успешно"); + + File.Exists(outputPath).Should().BeTrue("файл изображения должен быть создан"); + new FileInfo(outputPath).Length.Should().BeGreaterThan(50, "изображение не должно быть пустым"); + } + + [TestCaseSource(nameof(GetGenerationCases))] + public void Generate_ShouldBehaveAsExpected(string words, string stop, bool shouldSucceed) + { + var gen = Resolve(words, stop); + + var result = gen.Generate(outputPath, cfg); + + if (shouldSucceed) + { + result.IsSuccess.Should().BeTrue("ожидалась успешная генерация"); + + File.Exists(outputPath).Should().BeTrue("файл должен быть создан"); + new FileInfo(outputPath).Length.Should().BeGreaterThan(50, "картинка не должна быть пустой"); + } + else + { + result.IsSuccess.Should().BeFalse("ожидалась ошибка генерации"); + result.Error.Should().NotBeNullOrWhiteSpace(); + } + } + + [TestCaseSource(nameof(GetInvalidPathCases))] + public void Generate_ShouldFail_OnInvalidOutputPath(string badPath) + { + var gen = Resolve("word\nword\ncloud", ""); + + var result = gen.Generate(badPath, cfg); + + result.IsSuccess.Should().BeFalse("некорректный путь должен приводить к ошибке"); + result.Error.Should().NotBeNullOrWhiteSpace(); + } + + public static IEnumerable GetInvalidPathCases() + { + yield return new TestCaseData(null) + .SetName("Generate_ShouldFail_WhenOutputPathIsNull"); + + yield return new TestCaseData(" ") + .SetName("Generate_ShouldFail_WhenOutputPathIsWhitespace"); + + yield return new TestCaseData("invalid/file\\path.png") + .SetName("Generate_ShouldFail_WhenOutputPathInvalid"); + } + + public static IEnumerable GetGenerationCases() + { + yield return new TestCaseData( + "apple\napple\nbanana\nbanana\nbanana\ncloud\ncloud\ncloud", + "", + true) + .SetName("Generate_ShouldSucceed_WhenStopwordsEmpty"); + + yield return new TestCaseData( + "apple\nbanana\norange", + "banana", + true) + .SetName("Generate_ShouldFilterStopwords"); + + yield return new TestCaseData( + "one\nTWO\nthree\nTwo", + "two", + true) + .SetName("Generate_ShouldBeCaseInsensitiveForStopwords"); + + yield return new TestCaseData( + "", + "", + false) + .SetName("Generate_ShouldFail_WhenNoWordsProvided"); + + yield return new TestCaseData( + "a\nb\nc", + "a\nb\nc", + false) + .SetName("Generate_ShouldFail_WhenAllWordsFilteredOut"); + } +} diff --git a/TagCloud.Tests/stopwords_Generate_ShouldReadWordsAndStopwordsFromFiles.txt b/TagCloud.Tests/stopwords_Generate_ShouldReadWordsAndStopwordsFromFiles.txt new file mode 100644 index 000000000..4c479deff --- /dev/null +++ b/TagCloud.Tests/stopwords_Generate_ShouldReadWordsAndStopwordsFromFiles.txt @@ -0,0 +1 @@ +apple diff --git a/TagCloud.Tests/words_Generate_ShouldReadWordsAndStopwordsFromFiles.txt b/TagCloud.Tests/words_Generate_ShouldReadWordsAndStopwordsFromFiles.txt new file mode 100644 index 000000000..c15a692ef --- /dev/null +++ b/TagCloud.Tests/words_Generate_ShouldReadWordsAndStopwordsFromFiles.txt @@ -0,0 +1,4 @@ +apple +banana +banana +cloud